@panda-agent/panda-cli 0.1.29 → 0.1.30
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.
- package/dist/panda-cli-ink.bundle.mjs +258 -247
- package/package.json +6 -4
- package/skills/.gitkeep +0 -0
- package/skills/README.md +13 -0
- package/skills/docx/.skill-metadata.yaml +173 -0
- package/skills/docx/LICENSE.txt +30 -0
- package/skills/docx/SKILL.md +589 -0
- package/skills/docx/scripts/__init__.py +1 -0
- package/skills/docx/scripts/accept_changes.py +206 -0
- package/skills/docx/scripts/comment.py +442 -0
- package/skills/docx/scripts/office/helpers/__init__.py +1 -0
- package/skills/docx/scripts/office/helpers/merge_runs.py +190 -0
- package/skills/docx/scripts/office/helpers/simplify_redlines.py +185 -0
- package/skills/docx/scripts/office/pack.py +167 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- package/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
- package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
- package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
- package/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
- package/skills/docx/scripts/office/schemas/mce/mc.xsd +75 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- package/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- package/skills/docx/scripts/office/soffice.py +194 -0
- package/skills/docx/scripts/office/unpack.py +145 -0
- package/skills/docx/scripts/office/validate.py +114 -0
- package/skills/docx/scripts/office/validators/__init__.py +16 -0
- package/skills/docx/scripts/office/validators/base.py +733 -0
- package/skills/docx/scripts/office/validators/docx.py +354 -0
- package/skills/docx/scripts/office/validators/pptx.py +230 -0
- package/skills/docx/scripts/office/validators/redlining.py +212 -0
- package/skills/docx/scripts/templates/comments.xml +3 -0
- package/skills/docx/scripts/templates/commentsExtended.xml +3 -0
- package/skills/docx/scripts/templates/commentsExtensible.xml +3 -0
- package/skills/docx/scripts/templates/commentsIds.xml +3 -0
- package/skills/docx/scripts/templates/people.xml +3 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/pdf/.skill-metadata.yaml +273 -0
- package/skills/pdf/LICENSE.txt +30 -0
- package/skills/pdf/SKILL.md +324 -0
- package/skills/pdf/advanced-reference.md +609 -0
- package/skills/pdf/form-filling-guide.md +318 -0
- package/skills/pdf/forms.md +294 -0
- package/skills/pdf/reference.md +612 -0
- package/skills/pdf/scripts/check_bounding_boxes.py +198 -0
- package/skills/pdf/scripts/check_fillable_fields.py +64 -0
- package/skills/pdf/scripts/convert_pdf_to_images.py +102 -0
- package/skills/pdf/scripts/create_validation_image.py +125 -0
- package/skills/pdf/scripts/extract_form_field_info.py +220 -0
- package/skills/pdf/scripts/extract_form_structure.py +202 -0
- package/skills/pdf/scripts/fill_fillable_fields.py +205 -0
- package/skills/pdf/scripts/fill_pdf_form_with_annotations.py +193 -0
- package/skills/pptx-generator/SKILL.md +204 -0
- package/skills/pptx-generator/assets/styles/business.json +8 -0
- package/skills/pptx-generator/assets/styles/minimal.json +8 -0
- package/skills/pptx-generator/assets/styles/modern.json +8 -0
- package/skills/pptx-generator/assets/templates/ppt_data_template.json +40 -0
- package/skills/pptx-generator/references/collaboration_guide.md +381 -0
- package/skills/pptx-generator/references/json_format_spec.md +215 -0
- package/skills/pptx-generator/references/layout_guide.md +290 -0
- package/skills/pptx-generator/scripts/json_validator.py +194 -0
- package/skills/pptx-generator/scripts/pptx_builder.py +340 -0
- package/skills/pptx-generator/scripts/pptx_validator.py +162 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/SKILL.md +479 -0
- package/skills/skill-creator/agents/analyzer.md +274 -0
- package/skills/skill-creator/agents/comparator.md +202 -0
- package/skills/skill-creator/agents/grader.md +223 -0
- package/skills/skill-creator/assets/eval_review.html +146 -0
- package/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/skills/skill-creator/references/schemas.md +430 -0
- package/skills/skill-creator/scripts/__init__.py +0 -0
- package/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/skills/skill-creator/scripts/generate_report.py +326 -0
- package/skills/skill-creator/scripts/improve_description.py +248 -0
- package/skills/skill-creator/scripts/package_skill.py +136 -0
- package/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/skills/skill-creator/scripts/run_eval.py +310 -0
- package/skills/skill-creator/scripts/run_loop.py +332 -0
- package/skills/skill-creator/scripts/utils.py +47 -0
- package/skills/xlsx/.skill-metadata.yaml +185 -0
- package/skills/xlsx/LICENSE.txt +30 -0
- package/skills/xlsx/SKILL.md +233 -0
- package/skills/xlsx/scripts/office/helpers/__init__.py +1 -0
- package/skills/xlsx/scripts/office/helpers/merge_runs.py +226 -0
- package/skills/xlsx/scripts/office/helpers/simplify_redlines.py +198 -0
- package/skills/xlsx/scripts/office/pack.py +162 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- package/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
- package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
- package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
- package/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
- package/skills/xlsx/scripts/office/schemas/mce/mc.xsd +75 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- package/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- package/skills/xlsx/scripts/office/soffice.py +185 -0
- package/skills/xlsx/scripts/office/unpack.py +146 -0
- package/skills/xlsx/scripts/office/validate.py +108 -0
- package/skills/xlsx/scripts/office/validators/__init__.py +13 -0
- package/skills/xlsx/scripts/office/validators/base.py +800 -0
- package/skills/xlsx/scripts/office/validators/docx.py +383 -0
- package/skills/xlsx/scripts/office/validators/pptx.py +250 -0
- package/skills/xlsx/scripts/office/validators/redlining.py +229 -0
- package/skills/xlsx/scripts/recalc.py +296 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Resolve every tracked revision inside a Word document via LibreOffice automation.
|
|
2
|
+
|
|
3
|
+
Deploys a Basic macro into a dedicated LibreOffice user profile, opens the
|
|
4
|
+
target document headlessly, executes the macro to accept all changes, then
|
|
5
|
+
saves and closes.
|
|
6
|
+
|
|
7
|
+
LibreOffice (``soffice``) must be available on the system PATH.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import logging
|
|
14
|
+
import pathlib
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Final
|
|
19
|
+
|
|
20
|
+
import office.soffice as _lo
|
|
21
|
+
|
|
22
|
+
# ── Logging ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
_LOG: Final = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# ── Constants ────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
_PROFILE_ROOT: Final[str] = "/tmp/libreoffice_docx_profile"
|
|
29
|
+
_MACRO_FOLDER: Final[pathlib.Path] = pathlib.Path(
|
|
30
|
+
f"{_PROFILE_ROOT}/user/basic/Standard"
|
|
31
|
+
)
|
|
32
|
+
_MACRO_FILE: Final[pathlib.Path] = _MACRO_FOLDER / "Module1.xba"
|
|
33
|
+
_MACRO_ENTRY_POINT: Final[str] = "AcceptAllTrackedChanges"
|
|
34
|
+
|
|
35
|
+
_BASIC_MACRO_BODY: Final[str] = (
|
|
36
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
37
|
+
'<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">\n'
|
|
38
|
+
'<script:module xmlns:script="http://openoffice.org/2000/script" '
|
|
39
|
+
'script:name="Module1" script:language="StarBasic">\n'
|
|
40
|
+
" Sub AcceptAllTrackedChanges()\n"
|
|
41
|
+
" Dim document As Object\n"
|
|
42
|
+
" Dim dispatcher As Object\n"
|
|
43
|
+
"\n"
|
|
44
|
+
" document = ThisComponent.CurrentController.Frame\n"
|
|
45
|
+
' dispatcher = createUnoService("com.sun.star.frame.DispatchHelper")\n'
|
|
46
|
+
"\n"
|
|
47
|
+
' dispatcher.executeDispatch(document, ".uno:AcceptAllTrackedChanges", "", 0, Array())\n'
|
|
48
|
+
" ThisComponent.store()\n"
|
|
49
|
+
" ThisComponent.close(True)\n"
|
|
50
|
+
" End Sub\n"
|
|
51
|
+
"</script:module>"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_SOFFICE_TIMEOUT_SECONDS: Final[int] = 30
|
|
55
|
+
_PROFILE_INIT_TIMEOUT_SECONDS: Final[int] = 10
|
|
56
|
+
_USER_INSTALL_ARG: Final[str] = f"-env:UserInstallation=file://{_PROFILE_ROOT}"
|
|
57
|
+
_MACRO_URI: Final[str] = (
|
|
58
|
+
f"vnd.sun.star.script:Standard.Module1.{_MACRO_ENTRY_POINT}"
|
|
59
|
+
"?language=Basic&location=application"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Data structures ──────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class Result:
|
|
68
|
+
"""Outcome of an accept-changes operation."""
|
|
69
|
+
|
|
70
|
+
success: bool
|
|
71
|
+
message: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Internal helpers ─────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _initialize_profile_directory() -> None:
|
|
78
|
+
"""Launch soffice once to bootstrap the user profile directory tree."""
|
|
79
|
+
subprocess.run(
|
|
80
|
+
["soffice", "--headless", _USER_INSTALL_ARG, "--terminate_after_init"],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
timeout=_PROFILE_INIT_TIMEOUT_SECONDS,
|
|
83
|
+
check=False,
|
|
84
|
+
env=_lo.get_soffice_env(),
|
|
85
|
+
)
|
|
86
|
+
_MACRO_FOLDER.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _provision_macro() -> bool:
|
|
90
|
+
"""Ensure the LO Basic macro file exists and contains the expected code."""
|
|
91
|
+
if _MACRO_FILE.exists() and _MACRO_ENTRY_POINT in _MACRO_FILE.read_text():
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
if not _MACRO_FOLDER.exists():
|
|
95
|
+
_initialize_profile_directory()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
_MACRO_FILE.write_text(_BASIC_MACRO_BODY)
|
|
99
|
+
return True
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
_LOG.warning("Macro provisioning failed: %s", exc)
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_input(src_path: pathlib.Path) -> str | None:
|
|
106
|
+
"""Return an error message if the input is invalid, else None."""
|
|
107
|
+
if not src_path.exists():
|
|
108
|
+
return f"Error: Input file not found: {src_path}"
|
|
109
|
+
if src_path.suffix.lower() != ".docx":
|
|
110
|
+
return f"Error: Input file is not a DOCX file: {src_path}"
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _copy_source_to_destination(
|
|
115
|
+
src_path: pathlib.Path, dst_path: pathlib.Path
|
|
116
|
+
) -> str | None:
|
|
117
|
+
"""Copy the source file to the destination. Return error message on failure."""
|
|
118
|
+
try:
|
|
119
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
shutil.copy2(src_path, dst_path)
|
|
121
|
+
return None
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
return f"Error: Failed to copy input file to output location: {exc}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_macro(dst_path: pathlib.Path) -> Result:
|
|
127
|
+
"""Invoke LibreOffice with the accept-changes macro on the given file."""
|
|
128
|
+
argv = [
|
|
129
|
+
"soffice",
|
|
130
|
+
"--headless",
|
|
131
|
+
_USER_INSTALL_ARG,
|
|
132
|
+
"--norestore",
|
|
133
|
+
_MACRO_URI,
|
|
134
|
+
str(dst_path.absolute()),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
proc = subprocess.run(
|
|
139
|
+
argv,
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
timeout=_SOFFICE_TIMEOUT_SECONDS,
|
|
143
|
+
check=False,
|
|
144
|
+
env=_lo.get_soffice_env(),
|
|
145
|
+
)
|
|
146
|
+
except subprocess.TimeoutExpired:
|
|
147
|
+
# Timeout is treated as success — LO sometimes hangs after completing.
|
|
148
|
+
return Result(success=True, message="")
|
|
149
|
+
|
|
150
|
+
if proc.returncode != 0:
|
|
151
|
+
return Result(success=False, message=f"Error: LibreOffice failed: {proc.stderr}")
|
|
152
|
+
|
|
153
|
+
return Result(success=True, message="")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── Public API ───────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def accept_changes(src: str, dst: str) -> tuple[None, str]:
|
|
160
|
+
"""Accept all revisions in *src* and write the clean result to *dst*.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
A tuple of ``(None, message)`` where *message* describes the outcome.
|
|
164
|
+
"""
|
|
165
|
+
src_path = pathlib.Path(src)
|
|
166
|
+
dst_path = pathlib.Path(dst)
|
|
167
|
+
|
|
168
|
+
# Validate input file.
|
|
169
|
+
error = _validate_input(src_path)
|
|
170
|
+
if error:
|
|
171
|
+
return None, error
|
|
172
|
+
|
|
173
|
+
# Copy source to destination for in-place macro editing.
|
|
174
|
+
error = _copy_source_to_destination(src_path, dst_path)
|
|
175
|
+
if error:
|
|
176
|
+
return None, error
|
|
177
|
+
|
|
178
|
+
# Deploy the macro if not already present.
|
|
179
|
+
if not _provision_macro():
|
|
180
|
+
return None, "Error: Failed to setup LibreOffice macro"
|
|
181
|
+
|
|
182
|
+
# Execute the macro.
|
|
183
|
+
result = _run_macro(dst_path)
|
|
184
|
+
if not result.success:
|
|
185
|
+
return None, result.message
|
|
186
|
+
|
|
187
|
+
return None, f"Successfully accepted all tracked changes: {src} -> {dst}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── CLI entry point ──────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
ap = argparse.ArgumentParser(
|
|
194
|
+
description="Accept all tracked changes in a DOCX file"
|
|
195
|
+
)
|
|
196
|
+
ap.add_argument("input_file", help="Input DOCX file with tracked changes")
|
|
197
|
+
ap.add_argument(
|
|
198
|
+
"output_file", help="Output DOCX file (clean, no tracked changes)"
|
|
199
|
+
)
|
|
200
|
+
cli = ap.parse_args()
|
|
201
|
+
|
|
202
|
+
_, msg = accept_changes(cli.input_file, cli.output_file)
|
|
203
|
+
print(msg)
|
|
204
|
+
|
|
205
|
+
if "Error" in msg:
|
|
206
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Insert review annotations into an unpacked DOCX package.
|
|
2
|
+
|
|
3
|
+
Typical invocation::
|
|
4
|
+
|
|
5
|
+
python comment.py unpacked/ 0 "Comment text"
|
|
6
|
+
python comment.py unpacked/ 1 "Reply text" --parent 0
|
|
7
|
+
|
|
8
|
+
All text values must already contain proper XML escaping
|
|
9
|
+
(e.g. ``&`` for ``&``, ``’`` for a smart apostrophe).
|
|
10
|
+
|
|
11
|
+
After execution, manually wire the markers into ``document.xml``::
|
|
12
|
+
|
|
13
|
+
<w:commentRangeStart w:id="0"/>
|
|
14
|
+
... annotated content ...
|
|
15
|
+
<w:commentRangeEnd w:id="0"/>
|
|
16
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import pathlib
|
|
23
|
+
import random
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from typing import Final
|
|
29
|
+
|
|
30
|
+
import defusedxml.minidom
|
|
31
|
+
|
|
32
|
+
# ── Constants ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
_TPL_DIR: Final[pathlib.Path] = pathlib.Path(__file__).parent / "templates"
|
|
35
|
+
|
|
36
|
+
_XML_NS_MAP: Final[dict[str, str]] = {
|
|
37
|
+
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
|
38
|
+
"w14": "http://schemas.microsoft.com/office/word/2010/wordml",
|
|
39
|
+
"w15": "http://schemas.microsoft.com/office/word/2012/wordml",
|
|
40
|
+
"w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
|
|
41
|
+
"w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_NS_DECLARATIONS: Final[str] = " ".join(
|
|
45
|
+
f'xmlns:{prefix}="{uri}"' for prefix, uri in _XML_NS_MAP.items()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_CURLY_QUOTE_MAP: Final[dict[str, str]] = {
|
|
49
|
+
"\u201c": "“",
|
|
50
|
+
"\u201d": "”",
|
|
51
|
+
"\u2018": "‘",
|
|
52
|
+
"\u2019": "’",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# XML template for the comment body element.
|
|
56
|
+
_COMMENT_BODY_XML: Final[str] = (
|
|
57
|
+
'<w:comment w:id="{cid}" w:author="{who}" w:date="{when}" w:initials="{ini}">'
|
|
58
|
+
'<w:p w14:paraId="{pid}" w14:textId="77777777">'
|
|
59
|
+
"<w:r>"
|
|
60
|
+
'<w:rPr><w:rStyle w:val="CommentReference"/></w:rPr>'
|
|
61
|
+
"<w:annotationRef/>"
|
|
62
|
+
"</w:r>"
|
|
63
|
+
"<w:r>"
|
|
64
|
+
"<w:rPr>"
|
|
65
|
+
'<w:color w:val="000000"/>'
|
|
66
|
+
'<w:sz w:val="20"/>'
|
|
67
|
+
'<w:szCs w:val="20"/>'
|
|
68
|
+
"</w:rPr>"
|
|
69
|
+
"<w:t>{body}</w:t>"
|
|
70
|
+
"</w:r>"
|
|
71
|
+
"</w:p>"
|
|
72
|
+
"</w:comment>"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# User-facing hints for wiring markers into document.xml.
|
|
76
|
+
_STANDALONE_HINT: Final[str] = """
|
|
77
|
+
Add to document.xml (markers must be direct children of w:p, never inside w:r):
|
|
78
|
+
<w:commentRangeStart w:id="{cid}"/>
|
|
79
|
+
<w:r>...</w:r>
|
|
80
|
+
<w:commentRangeEnd w:id="{cid}"/>
|
|
81
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
|
|
82
|
+
|
|
83
|
+
_REPLY_HINT: Final[str] = """
|
|
84
|
+
Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r):
|
|
85
|
+
<w:commentRangeStart w:id="{pid}"/><w:commentRangeStart w:id="{cid}"/>
|
|
86
|
+
<w:r>...</w:r>
|
|
87
|
+
<w:commentRangeEnd w:id="{cid}"/><w:commentRangeEnd w:id="{pid}"/>
|
|
88
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{pid}"/></w:r>
|
|
89
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="{cid}"/></w:r>"""
|
|
90
|
+
|
|
91
|
+
# Relationship type URIs and their corresponding target file names.
|
|
92
|
+
_COMMENT_RELATIONSHIP_PAIRS: Final[list[tuple[str, str]]] = [
|
|
93
|
+
(
|
|
94
|
+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
|
|
95
|
+
"comments.xml",
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
"http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
|
|
99
|
+
"commentsExtended.xml",
|
|
100
|
+
),
|
|
101
|
+
(
|
|
102
|
+
"http://schemas.microsoft.com/office/2016/09/relationships/commentsIds",
|
|
103
|
+
"commentsIds.xml",
|
|
104
|
+
),
|
|
105
|
+
(
|
|
106
|
+
"http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible",
|
|
107
|
+
"commentsExtensible.xml",
|
|
108
|
+
),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
# Content type declarations for comment-related parts.
|
|
112
|
+
_COMMENT_CONTENT_TYPE_PARTS: Final[list[tuple[str, str]]] = [
|
|
113
|
+
(
|
|
114
|
+
"/word/comments.xml",
|
|
115
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
|
|
116
|
+
),
|
|
117
|
+
(
|
|
118
|
+
"/word/commentsExtended.xml",
|
|
119
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
|
|
120
|
+
),
|
|
121
|
+
(
|
|
122
|
+
"/word/commentsIds.xml",
|
|
123
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml",
|
|
124
|
+
),
|
|
125
|
+
(
|
|
126
|
+
"/word/commentsExtensible.xml",
|
|
127
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml",
|
|
128
|
+
),
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Data structures ──────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class CommentIdentifiers:
|
|
137
|
+
"""Generated identifiers for a single comment."""
|
|
138
|
+
|
|
139
|
+
para_id: str
|
|
140
|
+
durable_id: str
|
|
141
|
+
timestamp: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Low-level XML helpers ────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _rand_hex() -> str:
|
|
148
|
+
"""Generate a random 8-character hex ID within the valid OOXML range."""
|
|
149
|
+
return f"{random.randint(0, 0x7FFFFFFE):08X}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _sanitize_curly_quotes(raw: str) -> str:
|
|
153
|
+
"""Replace Unicode typographic quotes with XML entity references."""
|
|
154
|
+
result = raw
|
|
155
|
+
for char, entity in _CURLY_QUOTE_MAP.items():
|
|
156
|
+
result = result.replace(char, entity)
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _inject_xml_fragment(
|
|
161
|
+
target_file: pathlib.Path, wrapper_tag: str, fragment: str
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Parse *target_file*, append *fragment* as children of *wrapper_tag*, and save."""
|
|
164
|
+
doc = defusedxml.minidom.parseString(target_file.read_text(encoding="utf-8"))
|
|
165
|
+
container = doc.getElementsByTagName(wrapper_tag)[0]
|
|
166
|
+
|
|
167
|
+
tmp_doc = defusedxml.minidom.parseString(f"<root {_NS_DECLARATIONS}>{fragment}</root>")
|
|
168
|
+
for node in tmp_doc.documentElement.childNodes:
|
|
169
|
+
if node.nodeType == node.ELEMENT_NODE:
|
|
170
|
+
container.appendChild(doc.importNode(node, True))
|
|
171
|
+
|
|
172
|
+
serialised = _sanitize_curly_quotes(doc.toxml(encoding="UTF-8").decode("utf-8"))
|
|
173
|
+
target_file.write_text(serialised, encoding="utf-8")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── Relationship and content-type helpers ────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _locate_para_id(comments_file: pathlib.Path, cid: int) -> str | None:
|
|
180
|
+
"""Return the ``w14:paraId`` for the comment whose ``w:id`` equals *cid*."""
|
|
181
|
+
doc = defusedxml.minidom.parseString(comments_file.read_text(encoding="utf-8"))
|
|
182
|
+
for comment_node in doc.getElementsByTagName("w:comment"):
|
|
183
|
+
if comment_node.getAttribute("w:id") != str(cid):
|
|
184
|
+
continue
|
|
185
|
+
for p_node in comment_node.getElementsByTagName("w:p"):
|
|
186
|
+
val = p_node.getAttribute("w14:paraId")
|
|
187
|
+
if val:
|
|
188
|
+
return val
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _highest_rid(rels_file: pathlib.Path) -> int:
|
|
193
|
+
"""Find the highest numeric relationship ID in a .rels file."""
|
|
194
|
+
doc = defusedxml.minidom.parseString(rels_file.read_text(encoding="utf-8"))
|
|
195
|
+
top = 0
|
|
196
|
+
for rel_node in doc.getElementsByTagName("Relationship"):
|
|
197
|
+
raw = rel_node.getAttribute("Id")
|
|
198
|
+
if raw and raw.startswith("rId"):
|
|
199
|
+
try:
|
|
200
|
+
top = max(top, int(raw[3:]))
|
|
201
|
+
except ValueError:
|
|
202
|
+
continue
|
|
203
|
+
return top
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _target_exists(rels_file: pathlib.Path, tgt: str) -> bool:
|
|
207
|
+
"""Check whether a relationship with the given Target already exists."""
|
|
208
|
+
doc = defusedxml.minidom.parseString(rels_file.read_text(encoding="utf-8"))
|
|
209
|
+
return any(
|
|
210
|
+
r.getAttribute("Target") == tgt
|
|
211
|
+
for r in doc.getElementsByTagName("Relationship")
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _part_declared(ct_file: pathlib.Path, part: str) -> bool:
|
|
216
|
+
"""Check whether a content-type Override with the given PartName exists."""
|
|
217
|
+
doc = defusedxml.minidom.parseString(ct_file.read_text(encoding="utf-8"))
|
|
218
|
+
return any(
|
|
219
|
+
o.getAttribute("PartName") == part
|
|
220
|
+
for o in doc.getElementsByTagName("Override")
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _wire_comment_rels(root_dir: pathlib.Path) -> None:
|
|
225
|
+
"""Register comment-related relationships in document.xml.rels."""
|
|
226
|
+
rels = root_dir / "word" / "_rels" / "document.xml.rels"
|
|
227
|
+
if not rels.exists():
|
|
228
|
+
return
|
|
229
|
+
if _target_exists(rels, "comments.xml"):
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
doc = defusedxml.minidom.parseString(rels.read_text(encoding="utf-8"))
|
|
233
|
+
parent_elem = doc.documentElement
|
|
234
|
+
rid_counter = _highest_rid(rels) + 1
|
|
235
|
+
|
|
236
|
+
for rtype, tgt in _COMMENT_RELATIONSHIP_PAIRS:
|
|
237
|
+
elem = doc.createElement("Relationship")
|
|
238
|
+
elem.setAttribute("Id", f"rId{rid_counter}")
|
|
239
|
+
elem.setAttribute("Type", rtype)
|
|
240
|
+
elem.setAttribute("Target", tgt)
|
|
241
|
+
parent_elem.appendChild(elem)
|
|
242
|
+
rid_counter += 1
|
|
243
|
+
|
|
244
|
+
rels.write_bytes(doc.toxml(encoding="UTF-8"))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _wire_content_types(root_dir: pathlib.Path) -> None:
|
|
248
|
+
"""Register comment-related content types in [Content_Types].xml."""
|
|
249
|
+
ct = root_dir / "[Content_Types].xml"
|
|
250
|
+
if not ct.exists():
|
|
251
|
+
return
|
|
252
|
+
if _part_declared(ct, "/word/comments.xml"):
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
doc = defusedxml.minidom.parseString(ct.read_text(encoding="utf-8"))
|
|
256
|
+
parent_elem = doc.documentElement
|
|
257
|
+
|
|
258
|
+
for pname, ctype in _COMMENT_CONTENT_TYPE_PARTS:
|
|
259
|
+
elem = doc.createElement("Override")
|
|
260
|
+
elem.setAttribute("PartName", pname)
|
|
261
|
+
elem.setAttribute("ContentType", ctype)
|
|
262
|
+
parent_elem.appendChild(elem)
|
|
263
|
+
|
|
264
|
+
ct.write_bytes(doc.toxml(encoding="UTF-8"))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ── Comment XML part management ──────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _ensure_comments_xml(word_dir: pathlib.Path, root_dir: pathlib.Path) -> pathlib.Path:
|
|
271
|
+
"""Ensure comments.xml exists, bootstrapping from template if needed."""
|
|
272
|
+
cxml = word_dir / "comments.xml"
|
|
273
|
+
if not cxml.exists():
|
|
274
|
+
shutil.copy(_TPL_DIR / "comments.xml", cxml)
|
|
275
|
+
_wire_comment_rels(root_dir)
|
|
276
|
+
_wire_content_types(root_dir)
|
|
277
|
+
return cxml
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _ensure_extended_xml(word_dir: pathlib.Path) -> pathlib.Path:
|
|
281
|
+
"""Ensure commentsExtended.xml exists."""
|
|
282
|
+
ext = word_dir / "commentsExtended.xml"
|
|
283
|
+
if not ext.exists():
|
|
284
|
+
shutil.copy(_TPL_DIR / "commentsExtended.xml", ext)
|
|
285
|
+
return ext
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _ensure_ids_xml(word_dir: pathlib.Path) -> pathlib.Path:
|
|
289
|
+
"""Ensure commentsIds.xml exists."""
|
|
290
|
+
ids_file = word_dir / "commentsIds.xml"
|
|
291
|
+
if not ids_file.exists():
|
|
292
|
+
shutil.copy(_TPL_DIR / "commentsIds.xml", ids_file)
|
|
293
|
+
return ids_file
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _ensure_extensible_xml(word_dir: pathlib.Path) -> pathlib.Path:
|
|
297
|
+
"""Ensure commentsExtensible.xml exists."""
|
|
298
|
+
efile = word_dir / "commentsExtensible.xml"
|
|
299
|
+
if not efile.exists():
|
|
300
|
+
shutil.copy(_TPL_DIR / "commentsExtensible.xml", efile)
|
|
301
|
+
return efile
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _write_comment_body(
|
|
305
|
+
cxml: pathlib.Path,
|
|
306
|
+
comment_id: int,
|
|
307
|
+
text: str,
|
|
308
|
+
author: str,
|
|
309
|
+
initials: str,
|
|
310
|
+
ids: CommentIdentifiers,
|
|
311
|
+
) -> None:
|
|
312
|
+
"""Append the comment element to comments.xml."""
|
|
313
|
+
_inject_xml_fragment(
|
|
314
|
+
cxml,
|
|
315
|
+
"w:comments",
|
|
316
|
+
_COMMENT_BODY_XML.format(
|
|
317
|
+
cid=comment_id,
|
|
318
|
+
who=author,
|
|
319
|
+
when=ids.timestamp,
|
|
320
|
+
ini=initials,
|
|
321
|
+
pid=ids.para_id,
|
|
322
|
+
body=text,
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _write_extended_entry(
|
|
328
|
+
ext: pathlib.Path,
|
|
329
|
+
ids: CommentIdentifiers,
|
|
330
|
+
parent_para_id: str | None,
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Append the extended comment entry (with optional parent link)."""
|
|
333
|
+
if parent_para_id is not None:
|
|
334
|
+
fragment = (
|
|
335
|
+
f'<w15:commentEx w15:paraId="{ids.para_id}" '
|
|
336
|
+
f'w15:paraIdParent="{parent_para_id}" w15:done="0"/>'
|
|
337
|
+
)
|
|
338
|
+
else:
|
|
339
|
+
fragment = f'<w15:commentEx w15:paraId="{ids.para_id}" w15:done="0"/>'
|
|
340
|
+
_inject_xml_fragment(ext, "w15:commentsEx", fragment)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _write_ids_entry(ids_file: pathlib.Path, ids: CommentIdentifiers) -> None:
|
|
344
|
+
"""Append the comment ID mapping entry."""
|
|
345
|
+
fragment = (
|
|
346
|
+
f'<w16cid:commentId w16cid:paraId="{ids.para_id}" '
|
|
347
|
+
f'w16cid:durableId="{ids.durable_id}"/>'
|
|
348
|
+
)
|
|
349
|
+
_inject_xml_fragment(ids_file, "w16cid:commentsIds", fragment)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _write_extensible_entry(efile: pathlib.Path, ids: CommentIdentifiers) -> None:
|
|
353
|
+
"""Append the extensible comment entry with UTC timestamp."""
|
|
354
|
+
fragment = (
|
|
355
|
+
f'<w16cex:commentExtensible w16cex:durableId="{ids.durable_id}" '
|
|
356
|
+
f'w16cex:dateUtc="{ids.timestamp}"/>'
|
|
357
|
+
)
|
|
358
|
+
_inject_xml_fragment(efile, "w16cex:commentsExtensible", fragment)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ── Public API ───────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def add_comment(
|
|
365
|
+
unpacked_dir: str,
|
|
366
|
+
comment_id: int,
|
|
367
|
+
text: str,
|
|
368
|
+
author: str = "Claude",
|
|
369
|
+
initials: str = "C",
|
|
370
|
+
parent_id: int | None = None,
|
|
371
|
+
) -> tuple[str, str]:
|
|
372
|
+
"""Register a new comment (or reply) across all required XML parts.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
A tuple of ``(para_id, message)`` describing the outcome.
|
|
376
|
+
"""
|
|
377
|
+
root_dir = pathlib.Path(unpacked_dir)
|
|
378
|
+
word_dir = root_dir / "word"
|
|
379
|
+
|
|
380
|
+
if not word_dir.exists():
|
|
381
|
+
return "", f"Error: {word_dir} not found"
|
|
382
|
+
|
|
383
|
+
# Generate unique identifiers for this comment.
|
|
384
|
+
ids = CommentIdentifiers(
|
|
385
|
+
para_id=_rand_hex(),
|
|
386
|
+
durable_id=_rand_hex(),
|
|
387
|
+
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# 1. comments.xml — main comment body.
|
|
391
|
+
cxml = _ensure_comments_xml(word_dir, root_dir)
|
|
392
|
+
_write_comment_body(cxml, comment_id, text, author, initials, ids)
|
|
393
|
+
|
|
394
|
+
# 2. commentsExtended.xml — parent linkage and done state.
|
|
395
|
+
ext = _ensure_extended_xml(word_dir)
|
|
396
|
+
parent_para_id: str | None = None
|
|
397
|
+
if parent_id is not None:
|
|
398
|
+
parent_para_id = _locate_para_id(cxml, parent_id)
|
|
399
|
+
if parent_para_id is None:
|
|
400
|
+
return "", f"Error: Parent comment {parent_id} not found"
|
|
401
|
+
_write_extended_entry(ext, ids, parent_para_id)
|
|
402
|
+
|
|
403
|
+
# 3. commentsIds.xml — paraId ↔ durableId mapping.
|
|
404
|
+
ids_file = _ensure_ids_xml(word_dir)
|
|
405
|
+
_write_ids_entry(ids_file, ids)
|
|
406
|
+
|
|
407
|
+
# 4. commentsExtensible.xml — UTC timestamp metadata.
|
|
408
|
+
efile = _ensure_extensible_xml(word_dir)
|
|
409
|
+
_write_extensible_entry(efile, ids)
|
|
410
|
+
|
|
411
|
+
kind = "reply" if parent_id is not None else "comment"
|
|
412
|
+
return ids.para_id, f"Added {kind} {comment_id} (para_id={ids.para_id})"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ── CLI entry point ──────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
if __name__ == "__main__":
|
|
418
|
+
p = argparse.ArgumentParser(description="Add comments to DOCX documents")
|
|
419
|
+
p.add_argument("unpacked_dir", help="Unpacked DOCX directory")
|
|
420
|
+
p.add_argument("comment_id", type=int, help="Comment ID (must be unique)")
|
|
421
|
+
p.add_argument("text", help="Comment text")
|
|
422
|
+
p.add_argument("--author", default="Claude", help="Author name")
|
|
423
|
+
p.add_argument("--initials", default="C", help="Author initials")
|
|
424
|
+
p.add_argument("--parent", type=int, help="Parent comment ID (for replies)")
|
|
425
|
+
args = p.parse_args()
|
|
426
|
+
|
|
427
|
+
para_id, msg = add_comment(
|
|
428
|
+
args.unpacked_dir,
|
|
429
|
+
args.comment_id,
|
|
430
|
+
args.text,
|
|
431
|
+
args.author,
|
|
432
|
+
args.initials,
|
|
433
|
+
args.parent,
|
|
434
|
+
)
|
|
435
|
+
print(msg)
|
|
436
|
+
if "Error" in msg:
|
|
437
|
+
sys.exit(1)
|
|
438
|
+
cid = args.comment_id
|
|
439
|
+
if args.parent is not None:
|
|
440
|
+
print(_REPLY_HINT.format(pid=args.parent, cid=cid))
|
|
441
|
+
else:
|
|
442
|
+
print(_STANDALONE_HINT.format(cid=cid))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Helpers sub-package – intentionally empty.
|