python-hwpx-automation 6.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Transactional edit helpers for stateless HWPX tools."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import difflib
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterable
|
|
13
|
+
|
|
14
|
+
from .. import quality as quality_contract
|
|
15
|
+
from ..storage import build_hwpx_verification_report, require_hwpx_editor_open_safe
|
|
16
|
+
from ..upstream import HwpxDocument, open_document
|
|
17
|
+
|
|
18
|
+
_MAX_SUMMARY_ITEMS = 20
|
|
19
|
+
_MAX_SNIPPET = 80
|
|
20
|
+
HwpxSnapshotSource = str | Path | bytes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class BackupReport:
|
|
25
|
+
backup_path: Path | None
|
|
26
|
+
rotated_paths: tuple[Path, ...] = ()
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
return {
|
|
30
|
+
"created": self.backup_path is not None,
|
|
31
|
+
"path": str(self.backup_path) if self.backup_path else None,
|
|
32
|
+
"rotatedPaths": [str(path) for path in self.rotated_paths],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def backup_path_for(path: Path) -> Path:
|
|
37
|
+
return path.with_suffix(path.suffix + ".bak")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rotated_backup_path(path: Path, index: int) -> Path:
|
|
41
|
+
if index < 1:
|
|
42
|
+
raise ValueError("backup rotation index must be >= 1")
|
|
43
|
+
return path.with_suffix(path.suffix + f".bak.{index}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def rotate_and_backup(path: Path, *, max_backups: int = 5) -> BackupReport:
|
|
47
|
+
"""Rotate existing ``.bak`` files and copy *path* to the current backup."""
|
|
48
|
+
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return BackupReport(None)
|
|
51
|
+
|
|
52
|
+
if path.suffix.lower() == ".hwpx":
|
|
53
|
+
require_hwpx_editor_open_safe(path, role="transaction backup source")
|
|
54
|
+
|
|
55
|
+
backup = backup_path_for(path)
|
|
56
|
+
rotated: list[Path] = []
|
|
57
|
+
if max_backups > 0:
|
|
58
|
+
oldest = rotated_backup_path(path, max_backups)
|
|
59
|
+
if oldest.exists():
|
|
60
|
+
oldest.unlink()
|
|
61
|
+
for index in range(max_backups - 1, 0, -1):
|
|
62
|
+
src = rotated_backup_path(path, index)
|
|
63
|
+
if src.exists():
|
|
64
|
+
dst = rotated_backup_path(path, index + 1)
|
|
65
|
+
os.replace(src, dst)
|
|
66
|
+
rotated.append(dst)
|
|
67
|
+
if backup.exists():
|
|
68
|
+
dst = rotated_backup_path(path, 1)
|
|
69
|
+
os.replace(backup, dst)
|
|
70
|
+
rotated.append(dst)
|
|
71
|
+
|
|
72
|
+
shutil.copy2(path, backup)
|
|
73
|
+
return BackupReport(backup, tuple(rotated))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _snippet(text: str) -> str:
|
|
77
|
+
compact = " ".join((text or "").split())
|
|
78
|
+
if len(compact) <= _MAX_SNIPPET:
|
|
79
|
+
return compact
|
|
80
|
+
return compact[: _MAX_SNIPPET - 1] + "..."
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _iter_tables(document: HwpxDocument) -> Iterable[Any]:
|
|
84
|
+
for paragraph in document.paragraphs:
|
|
85
|
+
for table in getattr(paragraph, "tables", []) or []:
|
|
86
|
+
yield table
|
|
87
|
+
yield from _iter_nested_tables(table)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _iter_nested_tables(table: Any) -> Iterable[Any]:
|
|
91
|
+
for row in getattr(table, "rows", []) or []:
|
|
92
|
+
for cell in getattr(row, "cells", []) or []:
|
|
93
|
+
for paragraph in getattr(cell, "paragraphs", []) or []:
|
|
94
|
+
for nested in getattr(paragraph, "tables", []) or []:
|
|
95
|
+
yield nested
|
|
96
|
+
yield from _iter_nested_tables(nested)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _paragraph_signature(paragraph: Any) -> dict[str, Any]:
|
|
100
|
+
element = getattr(paragraph, "element", None)
|
|
101
|
+
attrs = dict(getattr(element, "attrib", {}) or {}) if element is not None else {}
|
|
102
|
+
runs: list[dict[str, Any]] = []
|
|
103
|
+
for run in getattr(paragraph, "runs", []) or []:
|
|
104
|
+
run_element = getattr(run, "element", None)
|
|
105
|
+
runs.append(
|
|
106
|
+
{
|
|
107
|
+
"text": getattr(run, "text", "") or "",
|
|
108
|
+
"attrs": dict(getattr(run_element, "attrib", {}) or {})
|
|
109
|
+
if run_element is not None
|
|
110
|
+
else {},
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
return {"attrs": attrs, "runs": runs}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _source_bytes(source: HwpxSnapshotSource) -> bytes:
|
|
117
|
+
return source if isinstance(source, bytes) else Path(source).read_bytes()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def snapshot_document(source: HwpxSnapshotSource) -> dict[str, Any]:
|
|
121
|
+
source_bytes = _source_bytes(source)
|
|
122
|
+
display_path = "<memory>" if isinstance(source, bytes) else str(Path(source))
|
|
123
|
+
document = open_document(source_bytes)
|
|
124
|
+
try:
|
|
125
|
+
paragraphs = [
|
|
126
|
+
{
|
|
127
|
+
"index": index,
|
|
128
|
+
"text": paragraph.text or "",
|
|
129
|
+
"signature": _paragraph_signature(paragraph),
|
|
130
|
+
}
|
|
131
|
+
for index, paragraph in enumerate(document.paragraphs)
|
|
132
|
+
]
|
|
133
|
+
tables: list[dict[str, Any]] = []
|
|
134
|
+
for table_index, table in enumerate(_iter_tables(document)):
|
|
135
|
+
data = [
|
|
136
|
+
[cell.text or "" for cell in getattr(row, "cells", []) or []]
|
|
137
|
+
for row in getattr(table, "rows", []) or []
|
|
138
|
+
]
|
|
139
|
+
rows = len(data)
|
|
140
|
+
cols = max((len(row) for row in data), default=0)
|
|
141
|
+
tables.append(
|
|
142
|
+
{
|
|
143
|
+
"index": table_index,
|
|
144
|
+
"rows": rows,
|
|
145
|
+
"cols": cols,
|
|
146
|
+
"data": data,
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
return {
|
|
150
|
+
"path": display_path,
|
|
151
|
+
"paragraphs": paragraphs,
|
|
152
|
+
"tables": tables,
|
|
153
|
+
"paragraphCount": len(paragraphs),
|
|
154
|
+
"tableCount": len(tables),
|
|
155
|
+
}
|
|
156
|
+
finally:
|
|
157
|
+
close = getattr(document, "close", None)
|
|
158
|
+
if callable(close):
|
|
159
|
+
close()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _paragraph_diff_items(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
163
|
+
items: list[dict[str, Any]] = []
|
|
164
|
+
before_text = [entry["text"] for entry in before]
|
|
165
|
+
after_text = [entry["text"] for entry in after]
|
|
166
|
+
matcher = difflib.SequenceMatcher(a=before_text, b=after_text, autojunk=False)
|
|
167
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
168
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
169
|
+
break
|
|
170
|
+
if tag == "equal":
|
|
171
|
+
for offset, before_index in enumerate(range(i1, i2)):
|
|
172
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
173
|
+
break
|
|
174
|
+
after_index = j1 + offset
|
|
175
|
+
if before[before_index]["signature"] != after[after_index]["signature"]:
|
|
176
|
+
index = after[after_index]["index"]
|
|
177
|
+
items.append(
|
|
178
|
+
{
|
|
179
|
+
"type": "paragraph_format_changed",
|
|
180
|
+
"paragraphIndex": index,
|
|
181
|
+
"summary": f"Paragraph {index} formatting changed",
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
continue
|
|
185
|
+
if tag == "replace" and (i2 - i1) == (j2 - j1):
|
|
186
|
+
for before_index, after_index in zip(range(i1, i2), range(j1, j2)):
|
|
187
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
188
|
+
break
|
|
189
|
+
index = after[after_index]["index"]
|
|
190
|
+
items.append(
|
|
191
|
+
{
|
|
192
|
+
"type": "paragraph_changed",
|
|
193
|
+
"paragraphIndex": index,
|
|
194
|
+
"before": before[before_index]["text"],
|
|
195
|
+
"after": after[after_index]["text"],
|
|
196
|
+
"summary": (
|
|
197
|
+
f"Paragraph {index} text changed: "
|
|
198
|
+
f"'{_snippet(before[before_index]['text'])}' -> "
|
|
199
|
+
f"'{_snippet(after[after_index]['text'])}'"
|
|
200
|
+
),
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
continue
|
|
204
|
+
for before_index in range(i1, i2):
|
|
205
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
206
|
+
break
|
|
207
|
+
index = before[before_index]["index"]
|
|
208
|
+
items.append(
|
|
209
|
+
{
|
|
210
|
+
"type": "paragraph_removed",
|
|
211
|
+
"paragraphIndex": index,
|
|
212
|
+
"before": before[before_index]["text"],
|
|
213
|
+
"summary": f"Paragraph {index} removed: '{_snippet(before[before_index]['text'])}'",
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
for after_index in range(j1, j2):
|
|
217
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
218
|
+
break
|
|
219
|
+
index = after[after_index]["index"]
|
|
220
|
+
items.append(
|
|
221
|
+
{
|
|
222
|
+
"type": "paragraph_added",
|
|
223
|
+
"paragraphIndex": index,
|
|
224
|
+
"after": after[after_index]["text"],
|
|
225
|
+
"summary": f"Paragraph {index} added: '{_snippet(after[after_index]['text'])}'",
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
return items
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _table_diff_items(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
232
|
+
items: list[dict[str, Any]] = []
|
|
233
|
+
for table_index in range(min(len(before), len(after))):
|
|
234
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
235
|
+
break
|
|
236
|
+
before_table = before[table_index]
|
|
237
|
+
after_table = after[table_index]
|
|
238
|
+
if before_table["rows"] != after_table["rows"] or before_table["cols"] != after_table["cols"]:
|
|
239
|
+
items.append(
|
|
240
|
+
{
|
|
241
|
+
"type": "table_shape_changed",
|
|
242
|
+
"tableIndex": table_index,
|
|
243
|
+
"summary": (
|
|
244
|
+
f"Table {table_index} shape changed: "
|
|
245
|
+
f"{before_table['rows']}x{before_table['cols']} -> "
|
|
246
|
+
f"{after_table['rows']}x{after_table['cols']}"
|
|
247
|
+
),
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
max_rows = max(before_table["rows"], after_table["rows"])
|
|
251
|
+
for row in range(max_rows):
|
|
252
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
253
|
+
break
|
|
254
|
+
before_row = before_table["data"][row] if row < before_table["rows"] else []
|
|
255
|
+
after_row = after_table["data"][row] if row < after_table["rows"] else []
|
|
256
|
+
max_cols = max(len(before_row), len(after_row))
|
|
257
|
+
for col in range(max_cols):
|
|
258
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
259
|
+
break
|
|
260
|
+
before_text = before_row[col] if col < len(before_row) else ""
|
|
261
|
+
after_text = after_row[col] if col < len(after_row) else ""
|
|
262
|
+
if before_text != after_text:
|
|
263
|
+
items.append(
|
|
264
|
+
{
|
|
265
|
+
"type": "table_cell_changed",
|
|
266
|
+
"tableIndex": table_index,
|
|
267
|
+
"row": row,
|
|
268
|
+
"col": col,
|
|
269
|
+
"before": before_text,
|
|
270
|
+
"after": after_text,
|
|
271
|
+
"summary": (
|
|
272
|
+
f"Table {table_index} cell ({row}, {col}) changed: "
|
|
273
|
+
f"'{_snippet(before_text)}' -> '{_snippet(after_text)}'"
|
|
274
|
+
),
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
for table_index in range(len(before), len(after)):
|
|
278
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
279
|
+
break
|
|
280
|
+
table = after[table_index]
|
|
281
|
+
items.append(
|
|
282
|
+
{
|
|
283
|
+
"type": "table_added",
|
|
284
|
+
"tableIndex": table_index,
|
|
285
|
+
"summary": f"Table {table_index} added: {table['rows']}x{table['cols']}",
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
for table_index in range(len(after), len(before)):
|
|
289
|
+
if len(items) >= _MAX_SUMMARY_ITEMS:
|
|
290
|
+
break
|
|
291
|
+
table = before[table_index]
|
|
292
|
+
items.append(
|
|
293
|
+
{
|
|
294
|
+
"type": "table_removed",
|
|
295
|
+
"tableIndex": table_index,
|
|
296
|
+
"summary": f"Table {table_index} removed: {table['rows']}x{table['cols']}",
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
return items
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def semantic_diff(
|
|
303
|
+
before_source: HwpxSnapshotSource,
|
|
304
|
+
after_source: HwpxSnapshotSource,
|
|
305
|
+
) -> dict[str, Any]:
|
|
306
|
+
before_bytes = _source_bytes(before_source)
|
|
307
|
+
after_bytes = _source_bytes(after_source)
|
|
308
|
+
before = snapshot_document(before_bytes)
|
|
309
|
+
after = snapshot_document(after_bytes)
|
|
310
|
+
items = _paragraph_diff_items(before["paragraphs"], after["paragraphs"])
|
|
311
|
+
remaining = max(0, _MAX_SUMMARY_ITEMS - len(items))
|
|
312
|
+
if remaining:
|
|
313
|
+
items.extend(_table_diff_items(before["tables"], after["tables"])[:remaining])
|
|
314
|
+
|
|
315
|
+
changed = before_bytes != after_bytes
|
|
316
|
+
if changed and not items:
|
|
317
|
+
items.append(
|
|
318
|
+
{
|
|
319
|
+
"type": "package_changed",
|
|
320
|
+
"summary": "Package XML changed without paragraph/table text changes",
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
summaries = [item["summary"] for item in items]
|
|
325
|
+
return {
|
|
326
|
+
"schemaVersion": "hwpx.semantic-diff.v1",
|
|
327
|
+
"changed": changed,
|
|
328
|
+
"summary": "; ".join(summaries) if summaries else "No semantic changes",
|
|
329
|
+
"items": items,
|
|
330
|
+
"truncated": len(items) >= _MAX_SUMMARY_ITEMS,
|
|
331
|
+
"counts": {
|
|
332
|
+
"paragraphsBefore": before["paragraphCount"],
|
|
333
|
+
"paragraphsAfter": after["paragraphCount"],
|
|
334
|
+
"tablesBefore": before["tableCount"],
|
|
335
|
+
"tablesAfter": after["tableCount"],
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def save_dry_run(
|
|
341
|
+
document: HwpxDocument, target: str | Path, *, quality: Any = None
|
|
342
|
+
) -> dict[str, Any]:
|
|
343
|
+
target_path = Path(target)
|
|
344
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
345
|
+
prefix=f".{target_path.stem}.dry-run.",
|
|
346
|
+
suffix=target_path.suffix or ".hwpx",
|
|
347
|
+
dir=str(target_path.parent),
|
|
348
|
+
)
|
|
349
|
+
tmp_path = Path(tmp_name)
|
|
350
|
+
try:
|
|
351
|
+
os.close(fd)
|
|
352
|
+
try:
|
|
353
|
+
report = quality_contract.save_through_pipeline(document, tmp_path, quality=quality)
|
|
354
|
+
except quality_contract.QualityGateError as gate:
|
|
355
|
+
# A dry run reports the gate verdict instead of committing.
|
|
356
|
+
return {
|
|
357
|
+
"dryRun": True,
|
|
358
|
+
"wouldSave": False,
|
|
359
|
+
"visualComplete": gate.block,
|
|
360
|
+
"openSafety": None,
|
|
361
|
+
"semanticDiff": None,
|
|
362
|
+
}
|
|
363
|
+
verification = build_hwpx_verification_report(tmp_path)
|
|
364
|
+
if not verification["openSafety"]["ok"]:
|
|
365
|
+
raise RuntimeError(
|
|
366
|
+
"dry-run HWPX failed open-safety verification: "
|
|
367
|
+
+ verification["openSafety"]["summary"]
|
|
368
|
+
)
|
|
369
|
+
diff = semantic_diff(target_path, tmp_path) if target_path.exists() else None
|
|
370
|
+
verification["filePath"] = str(target_path)
|
|
371
|
+
verification["dryRunTempDeleted"] = True
|
|
372
|
+
verification["visualComplete"] = quality_contract.visual_complete_block(report)
|
|
373
|
+
return {
|
|
374
|
+
"dryRun": True,
|
|
375
|
+
"wouldSave": True,
|
|
376
|
+
"verificationReport": verification,
|
|
377
|
+
"openSafety": verification.get("openSafety"),
|
|
378
|
+
"semanticDiff": diff,
|
|
379
|
+
"visualComplete": verification["visualComplete"],
|
|
380
|
+
}
|
|
381
|
+
finally:
|
|
382
|
+
tmp_path.unlink(missing_ok=True)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def undo_last_backup(target: str | Path) -> dict[str, Any]:
|
|
386
|
+
target_path = Path(target)
|
|
387
|
+
backup_path = backup_path_for(target_path)
|
|
388
|
+
if not target_path.exists():
|
|
389
|
+
raise FileNotFoundError(f"target document does not exist: {target_path}")
|
|
390
|
+
if not backup_path.exists():
|
|
391
|
+
raise FileNotFoundError(f"backup document does not exist: {backup_path}")
|
|
392
|
+
|
|
393
|
+
require_hwpx_editor_open_safe(backup_path, role="undo backup source")
|
|
394
|
+
require_hwpx_editor_open_safe(target_path, role="undo current source")
|
|
395
|
+
|
|
396
|
+
current_fd, current_name = tempfile.mkstemp(
|
|
397
|
+
prefix=f".{target_path.stem}.current.",
|
|
398
|
+
suffix=target_path.suffix or ".hwpx",
|
|
399
|
+
dir=str(target_path.parent),
|
|
400
|
+
)
|
|
401
|
+
backup_fd, backup_name = tempfile.mkstemp(
|
|
402
|
+
prefix=f".{target_path.stem}.backup.",
|
|
403
|
+
suffix=target_path.suffix or ".hwpx",
|
|
404
|
+
dir=str(target_path.parent),
|
|
405
|
+
)
|
|
406
|
+
current_tmp = Path(current_name)
|
|
407
|
+
backup_tmp = Path(backup_name)
|
|
408
|
+
try:
|
|
409
|
+
os.close(current_fd)
|
|
410
|
+
os.close(backup_fd)
|
|
411
|
+
shutil.copy2(target_path, current_tmp)
|
|
412
|
+
shutil.copy2(backup_path, backup_tmp)
|
|
413
|
+
os.replace(backup_tmp, target_path)
|
|
414
|
+
os.replace(current_tmp, backup_path)
|
|
415
|
+
verification = build_hwpx_verification_report(target_path)
|
|
416
|
+
if not verification["openSafety"]["ok"]:
|
|
417
|
+
raise RuntimeError(
|
|
418
|
+
"undo HWPX failed open-safety verification: "
|
|
419
|
+
+ verification["openSafety"]["summary"]
|
|
420
|
+
)
|
|
421
|
+
verification["filePath"] = str(target_path)
|
|
422
|
+
diff = semantic_diff(backup_path, target_path)
|
|
423
|
+
return {
|
|
424
|
+
"restored": True,
|
|
425
|
+
"filename": str(target_path),
|
|
426
|
+
"backupPath": str(backup_path),
|
|
427
|
+
"verificationReport": verification,
|
|
428
|
+
"openSafety": verification.get("openSafety"),
|
|
429
|
+
"semanticDiff": diff,
|
|
430
|
+
}
|
|
431
|
+
except BaseException:
|
|
432
|
+
current_tmp.unlink(missing_ok=True)
|
|
433
|
+
backup_tmp.unlink(missing_ok=True)
|
|
434
|
+
raise
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Transaction helpers for hardened editing pipeline."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
from typing import Any, Callable, Dict, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TransactionError(RuntimeError):
|
|
11
|
+
"""Base error for transaction handling."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IdempotentReplayError(TransactionError):
|
|
15
|
+
"""Raised when the same idempotency key is replayed."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, key: str) -> None:
|
|
18
|
+
super().__init__(f"idempotency key '{key}' has already been processed")
|
|
19
|
+
self.key = key
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransactionManager(Generic[T]):
|
|
26
|
+
"""Provide atomic execution and idempotency tracking."""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._idempotency: Dict[str, Dict[str, Any]] = {}
|
|
30
|
+
|
|
31
|
+
def ensure_idempotency(self, scope: str, key: str) -> None:
|
|
32
|
+
token = self._token(scope, key)
|
|
33
|
+
if token in self._idempotency:
|
|
34
|
+
raise IdempotentReplayError(key)
|
|
35
|
+
|
|
36
|
+
def record_idempotency(self, scope: str, key: str, payload: Dict[str, Any]) -> None:
|
|
37
|
+
token = self._token(scope, key)
|
|
38
|
+
self._idempotency[token] = dict(payload)
|
|
39
|
+
|
|
40
|
+
def atomic(self, snapshot: T, mutator: Callable[[T], T], applier: Callable[[T], None]) -> T:
|
|
41
|
+
working_copy = copy.deepcopy(snapshot)
|
|
42
|
+
result = mutator(working_copy)
|
|
43
|
+
applier(result)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _token(scope: str, key: str) -> str:
|
|
48
|
+
return f"{scope}::{key}"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Document revision and best-effort lock detection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def document_revision(path: str | Path) -> str:
|
|
11
|
+
"""Return a stable content revision for optimistic concurrency checks."""
|
|
12
|
+
|
|
13
|
+
digest = hashlib.sha256()
|
|
14
|
+
with Path(path).open("rb") as stream:
|
|
15
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
16
|
+
digest.update(chunk)
|
|
17
|
+
return f"sha256:{digest.hexdigest()}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _candidate_lock_paths(path: Path) -> list[Path]:
|
|
21
|
+
name = path.name
|
|
22
|
+
stem = path.stem
|
|
23
|
+
parent = path.parent
|
|
24
|
+
candidates = [
|
|
25
|
+
parent / f"~${name}",
|
|
26
|
+
parent / f".~{name}",
|
|
27
|
+
parent / f".{name}.lock",
|
|
28
|
+
parent / f".{name}.lck",
|
|
29
|
+
parent / f"{name}.lock",
|
|
30
|
+
parent / f"{name}.lck",
|
|
31
|
+
parent / f"{stem}.lock",
|
|
32
|
+
parent / f"{stem}.lck",
|
|
33
|
+
]
|
|
34
|
+
seen: set[Path] = set()
|
|
35
|
+
unique: list[Path] = []
|
|
36
|
+
for candidate in candidates:
|
|
37
|
+
if candidate in seen:
|
|
38
|
+
continue
|
|
39
|
+
seen.add(candidate)
|
|
40
|
+
unique.append(candidate)
|
|
41
|
+
return unique
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def document_lock_warnings(path: str | Path) -> list[dict[str, Any]]:
|
|
45
|
+
"""Return warnings for lock/temp files near *path*.
|
|
46
|
+
|
|
47
|
+
Hancom Office lock behavior varies by host and version. This deliberately
|
|
48
|
+
uses conservative adjacent-file markers so normal backups are not reported
|
|
49
|
+
as active locks.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
target = Path(path)
|
|
53
|
+
warnings: list[dict[str, Any]] = []
|
|
54
|
+
for candidate in _candidate_lock_paths(target):
|
|
55
|
+
if not candidate.exists():
|
|
56
|
+
continue
|
|
57
|
+
warnings.append(
|
|
58
|
+
{
|
|
59
|
+
"code": "possible_document_lock",
|
|
60
|
+
"severity": "warning",
|
|
61
|
+
"message": "A lock or temporary file next to the HWPX suggests the document may be open elsewhere.",
|
|
62
|
+
"path": str(candidate),
|
|
63
|
+
"documentPath": str(target),
|
|
64
|
+
"source": "adjacent-temp-file",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
return warnings
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def document_state_payload(path: str | Path) -> dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"document_revision": document_revision(path),
|
|
73
|
+
"documentWarnings": document_lock_warnings(path),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def revision_mismatch_response(
|
|
78
|
+
path: str | Path,
|
|
79
|
+
expected_revision: str | None,
|
|
80
|
+
) -> dict[str, Any] | None:
|
|
81
|
+
if expected_revision is None:
|
|
82
|
+
return None
|
|
83
|
+
actual_revision = document_revision(path)
|
|
84
|
+
if expected_revision == actual_revision:
|
|
85
|
+
return None
|
|
86
|
+
return {
|
|
87
|
+
"ok": False,
|
|
88
|
+
"handoff_status": "blocked",
|
|
89
|
+
"reason": "document revision mismatch",
|
|
90
|
+
"expected_revision": expected_revision,
|
|
91
|
+
"document_revision": actual_revision,
|
|
92
|
+
"documentWarnings": document_lock_warnings(path),
|
|
93
|
+
"suggestion": "Re-read the document, review the external changes, then retry with the new document_revision as expected_revision.",
|
|
94
|
+
"next_tool": "get_document_info",
|
|
95
|
+
}
|