northstar-ctx 3.4.0__tar.gz
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.
- northstar_ctx-3.4.0/LICENSE +9 -0
- northstar_ctx-3.4.0/PKG-INFO +22 -0
- northstar_ctx-3.4.0/README.md +10 -0
- northstar_ctx-3.4.0/northstar_ctx/__init__.py +1 -0
- northstar_ctx-3.4.0/northstar_ctx/redline_builder.py +861 -0
- northstar_ctx-3.4.0/northstar_ctx/verify_redline.py +179 -0
- northstar_ctx-3.4.0/northstar_ctx.egg-info/PKG-INFO +22 -0
- northstar_ctx-3.4.0/northstar_ctx.egg-info/SOURCES.txt +12 -0
- northstar_ctx-3.4.0/northstar_ctx.egg-info/dependency_links.txt +1 -0
- northstar_ctx-3.4.0/northstar_ctx.egg-info/entry_points.txt +3 -0
- northstar_ctx-3.4.0/northstar_ctx.egg-info/requires.txt +2 -0
- northstar_ctx-3.4.0/northstar_ctx.egg-info/top_level.txt +1 -0
- northstar_ctx-3.4.0/pyproject.toml +19 -0
- northstar_ctx-3.4.0/setup.cfg +4 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Zavodnick
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: northstar-ctx
|
|
3
|
+
Version: 3.4.0
|
|
4
|
+
Summary: Deterministic Word (.docx) tracked-change builder and verifier driven by a JSON edit plan.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: defusedxml>=0.7
|
|
10
|
+
Requires-Dist: python-docx>=1.1
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# northstar-ctx
|
|
14
|
+
|
|
15
|
+
Deterministic tracked-change builder and verifier for Word .docx files, driven by a JSON edit plan.
|
|
16
|
+
|
|
17
|
+
export CTX_AUTHOR="Your Name"
|
|
18
|
+
ctx-redline extract in.docx
|
|
19
|
+
ctx-redline apply in.docx plan.json --out out.docx
|
|
20
|
+
ctx-verify out.docx in.docx base.docx
|
|
21
|
+
|
|
22
|
+
Requires the docx helper scripts at $DOCX_SKILL (default /mnt/skills/public/docx/scripts).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# northstar-ctx
|
|
2
|
+
|
|
3
|
+
Deterministic tracked-change builder and verifier for Word .docx files, driven by a JSON edit plan.
|
|
4
|
+
|
|
5
|
+
export CTX_AUTHOR="Your Name"
|
|
6
|
+
ctx-redline extract in.docx
|
|
7
|
+
ctx-redline apply in.docx plan.json --out out.docx
|
|
8
|
+
ctx-verify out.docx in.docx base.docx
|
|
9
|
+
|
|
10
|
+
Requires the docx helper scripts at $DOCX_SKILL (default /mnt/skills/public/docx/scripts).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.4.0"
|
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""NorthStar redline builder (ctx 3.4.0). Claude reads extract output, never the XML.
|
|
3
|
+
extract IN.docx | accept IN.docx OUT.docx [--keep "A,B"] [--ids 3,7] | apply IN.docx PLAN.json --out OUT.docx
|
|
4
|
+
[--report R.md] | check OUT.docx PLAN.json (also runs at the end of apply)
|
|
5
|
+
Plan: {"edits": [...], "questions": true}. Ops: replace (find, with), insert_after (find, text), delete (find),
|
|
6
|
+
insert_paragraph_after (text), delete_paragraph, comment (anchor, text, "question": true), reply (parent, text),
|
|
7
|
+
resolve (parent). find and anchor match one paragraph's rendered text; interior whitespace is normalized, leading
|
|
8
|
+
and trailing spaces are kept. Author and date are constants."""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import argparse, json, os, re, shutil, subprocess, sys, tempfile, zipfile
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import defusedxml.minidom
|
|
14
|
+
from xml.dom import minidom
|
|
15
|
+
|
|
16
|
+
DOCX_SKILL = Path(os.environ.get("DOCX_SKILL", "/mnt/skills/public/docx/scripts"))
|
|
17
|
+
if not (DOCX_SKILL / "comment.py").exists():
|
|
18
|
+
sys.exit(f"HALT: docx skill scripts not found at {DOCX_SKILL} (set DOCX_SKILL).")
|
|
19
|
+
sys.path.insert(0, str(DOCX_SKILL))
|
|
20
|
+
from office.helpers import rezip, safe_extract # noqa: E402
|
|
21
|
+
import comment as docx_comment # noqa: E402
|
|
22
|
+
|
|
23
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
24
|
+
XML_NS = "http://www.w3.org/XML/1998/namespace"
|
|
25
|
+
AUTHOR = os.environ.get("CTX_AUTHOR", "").strip()
|
|
26
|
+
INITIALS = os.environ.get("CTX_INITIALS", "").strip() or "".join(w[0] for w in AUTHOR.split())[:3].upper()
|
|
27
|
+
if not AUTHOR:
|
|
28
|
+
sys.exit("HALT: set CTX_AUTHOR (the tracked-change author name) before running.")
|
|
29
|
+
WS = " \t\r\n "
|
|
30
|
+
TEXT_OPS = {"replace", "insert_after", "delete", "insert_paragraph_after", "delete_paragraph"}
|
|
31
|
+
OPS = TEXT_OPS | {"comment", "reply", "resolve"}
|
|
32
|
+
KEYS = {"id", "op", "find", "with", "text", "anchor", "parent", "question"}
|
|
33
|
+
MARKERS = {"w:commentRangeStart", "w:commentRangeEnd", "w:bookmarkStart", "w:bookmarkEnd"}
|
|
34
|
+
PR_CHANGES = ("w:rPrChange", "w:pPrChange", "w:sectPrChange", "w:tblPrChange", "w:tcPrChange",
|
|
35
|
+
"w:trPrChange", "w:tblGridChange")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def now_iso():
|
|
39
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def halt(msg, code=2):
|
|
43
|
+
print(f"\nHALT: {msg}", file=sys.stderr)
|
|
44
|
+
sys.exit(code)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def norm(s):
|
|
48
|
+
"""Collapse interior whitespace runs for matching only; edges are kept (never written back)."""
|
|
49
|
+
return re.sub(r"\s+", " ", s.replace(" ", " "))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def kids(node, *tags):
|
|
53
|
+
return [c for c in node.childNodes if c.nodeType == 1 and (not tags or c.tagName in tags)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ancestor(node, tag, stop="w:p"):
|
|
57
|
+
p = node.parentNode
|
|
58
|
+
while p is not None and p.nodeType == 1 and p.tagName != stop:
|
|
59
|
+
if p.tagName == tag:
|
|
60
|
+
return p
|
|
61
|
+
p = p.parentNode
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def unwrap(e):
|
|
66
|
+
par = e.parentNode
|
|
67
|
+
for c in list(e.childNodes):
|
|
68
|
+
par.insertBefore(c, e)
|
|
69
|
+
par.removeChild(e)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def t_text(t):
|
|
73
|
+
s = "".join(c.data for c in t.childNodes if c.nodeType == c.TEXT_NODE)
|
|
74
|
+
return s if t.getAttributeNS(XML_NS, "space") == "preserve" else s.strip(" \t\r\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def el_text(e, deleted=False):
|
|
78
|
+
return "".join(t_text(t) for t in e.getElementsByTagName("w:delText" if deleted else "w:t"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------- document model
|
|
82
|
+
class Para:
|
|
83
|
+
def __init__(self, pid, node, in_table):
|
|
84
|
+
self.id, self.node, self.in_table = pid, node, in_table
|
|
85
|
+
|
|
86
|
+
def runs(self):
|
|
87
|
+
out = []
|
|
88
|
+
for c in kids(self.node):
|
|
89
|
+
if c.tagName == "w:r":
|
|
90
|
+
out.append(c)
|
|
91
|
+
elif c.tagName in ("w:ins", "w:del", "w:hyperlink", "w:smartTag"):
|
|
92
|
+
out.extend(c.getElementsByTagName("w:r"))
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
def text(self):
|
|
96
|
+
parts = []
|
|
97
|
+
for r in self.runs():
|
|
98
|
+
if ancestor(r, "w:del") is not None:
|
|
99
|
+
continue
|
|
100
|
+
for t in kids(r):
|
|
101
|
+
if t.tagName == "w:t":
|
|
102
|
+
parts.append(t_text(t))
|
|
103
|
+
elif t.tagName == "w:tab":
|
|
104
|
+
parts.append("\t")
|
|
105
|
+
elif t.tagName in ("w:br", "w:cr"):
|
|
106
|
+
parts.append("\n")
|
|
107
|
+
return "".join(parts)
|
|
108
|
+
|
|
109
|
+
def changes(self):
|
|
110
|
+
out = []
|
|
111
|
+
for tag in ("w:ins", "w:del"):
|
|
112
|
+
for el in self.node.getElementsByTagName(tag):
|
|
113
|
+
mark = el.parentNode.tagName == "w:rPr"
|
|
114
|
+
txt = "(paragraph mark)" if mark else (el_text(el) or el_text(el, True))
|
|
115
|
+
out.append((tag[2:].upper(), el.getAttribute("w:id"), el.getAttribute("w:author") or "?", txt))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_doc(unpacked):
|
|
120
|
+
dom = defusedxml.minidom.parse(str(unpacked / "word" / "document.xml"))
|
|
121
|
+
body = dom.getElementsByTagName("w:body")[0]
|
|
122
|
+
paras, counts, tbk = [], {"p": 0, "t": 0, "tb": 0}, {}
|
|
123
|
+
|
|
124
|
+
def walk(node, in_table, tpos):
|
|
125
|
+
for child in kids(node):
|
|
126
|
+
tag = child.tagName
|
|
127
|
+
if tag == "w:p":
|
|
128
|
+
if tpos and tpos[0] == "TB":
|
|
129
|
+
tbk[tpos[1]] = tbk.get(tpos[1], 0) + 1
|
|
130
|
+
pid = f"TB{tpos[1]}" + (f"P{tbk[tpos[1]]}" if tbk[tpos[1]] > 1 else "")
|
|
131
|
+
elif in_table and tpos:
|
|
132
|
+
t, r, c, k = tpos
|
|
133
|
+
pid = f"T{t}R{r}C{c}" + (f"P{k}" if k > 0 else "")
|
|
134
|
+
tpos = (t, r, c, k + 1)
|
|
135
|
+
else:
|
|
136
|
+
counts["p"] += 1
|
|
137
|
+
pid = f"P{counts['p']:03d}"
|
|
138
|
+
paras.append(Para(pid, child, in_table))
|
|
139
|
+
walk(child, in_table, tpos)
|
|
140
|
+
elif tag == "w:tbl":
|
|
141
|
+
counts["t"] += 1
|
|
142
|
+
for ri, row in enumerate(kids(child, "w:tr"), 1):
|
|
143
|
+
for ci, cell in enumerate(kids(row, "w:tc"), 1):
|
|
144
|
+
walk(cell, True, (counts["t"], ri, ci, 0))
|
|
145
|
+
elif tag == "w:txbxContent":
|
|
146
|
+
counts["tb"] += 1
|
|
147
|
+
walk(child, False, ("TB", counts["tb"]))
|
|
148
|
+
elif tag in ("mc:Fallback", "w:sectPr"):
|
|
149
|
+
continue
|
|
150
|
+
elif tag in ("w:sdt", "w:sdtContent", "w:customXml", "w:smartTag", "mc:AlternateContent", "mc:Choice",
|
|
151
|
+
"w:drawing", "w:pict", "wp:anchor", "wp:inline", "a:graphic", "a:graphicData", "wps:wsp",
|
|
152
|
+
"wps:txbx", "v:shape", "v:textbox", "w:r"):
|
|
153
|
+
walk(child, in_table, tpos)
|
|
154
|
+
|
|
155
|
+
walk(body, False, None)
|
|
156
|
+
return dom, paras
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def doc_para_count(unpacked):
|
|
160
|
+
raw = (unpacked / "word" / "document.xml").read_text(encoding="utf-8", errors="replace")
|
|
161
|
+
body = re.search(r"<w:body>(.*)</w:body>", raw, flags=re.S)
|
|
162
|
+
if not body:
|
|
163
|
+
return -1
|
|
164
|
+
return len(re.findall(r"<w:p[\s>]", re.sub(r"<mc:Fallback>.*?</mc:Fallback>", "", body.group(1), flags=re.S)))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def unpack(docx, dest, merge=True):
|
|
168
|
+
dest.mkdir()
|
|
169
|
+
with zipfile.ZipFile(docx) as zf:
|
|
170
|
+
safe_extract(zf, dest)
|
|
171
|
+
if merge:
|
|
172
|
+
subprocess.run([sys.executable, str(DOCX_SKILL / "merge_runs.py"), str(dest)], check=True, capture_output=True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------- comment threads (satellite parts, minidom)
|
|
176
|
+
def comment_maps(unpacked):
|
|
177
|
+
"""id -> (author, text, last paraId); paraId -> (parent paraId, done)."""
|
|
178
|
+
cpath, epath = unpacked / "word" / "comments.xml", unpacked / "word" / "commentsExtended.xml"
|
|
179
|
+
cm, ext = {}, {}
|
|
180
|
+
if cpath.exists():
|
|
181
|
+
for c in defusedxml.minidom.parse(str(cpath)).getElementsByTagName("w:comment"):
|
|
182
|
+
ps = c.getElementsByTagName("w:p")
|
|
183
|
+
cm[int(c.getAttribute("w:id"))] = (c.getAttribute("w:author"), " / ".join(el_text(p) for p in ps),
|
|
184
|
+
ps[-1].getAttribute("w14:paraId") if ps else "")
|
|
185
|
+
if epath.exists():
|
|
186
|
+
for x in defusedxml.minidom.parse(str(epath)).getElementsByTagName("w15:commentEx"):
|
|
187
|
+
ext[x.getAttribute("w15:paraId")] = (x.getAttribute("w15:paraIdParent"), x.getAttribute("w15:done"))
|
|
188
|
+
return cm, ext
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def thread(unpacked, cid):
|
|
192
|
+
cm, ext = comment_maps(unpacked)
|
|
193
|
+
if cid not in cm:
|
|
194
|
+
halt(f"comment {cid} does not exist (extract lists the comments).")
|
|
195
|
+
root_pid = ext.get(cm[cid][2], ("", ""))[0] or cm[cid][2]
|
|
196
|
+
root = next((i for i, v in cm.items() if v[2] == root_pid), cid)
|
|
197
|
+
members = [root] + sorted(i for i, v in cm.items() if i != root and ext.get(v[2], ("", ""))[0] == root_pid)
|
|
198
|
+
return root, root_pid, members
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def ext_edit(unpacked, fn):
|
|
202
|
+
epath = unpacked / "word" / "commentsExtended.xml"
|
|
203
|
+
if not epath.exists():
|
|
204
|
+
halt("the document has no commentsExtended.xml; threads cannot be edited.")
|
|
205
|
+
edom = defusedxml.minidom.parse(str(epath))
|
|
206
|
+
n = sum(1 for x in edom.getElementsByTagName("w15:commentEx") if fn(x))
|
|
207
|
+
epath.write_bytes(edom.toxml(encoding="UTF-8"))
|
|
208
|
+
return n
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def ensure_people(unpacked, author):
|
|
212
|
+
"""Ensure people.xml exists and contains an entry for the given author. Word 365 requires this."""
|
|
213
|
+
ppath = unpacked / "word" / "people.xml"
|
|
214
|
+
tpl = Path(os.environ.get("DOCX_SKILL", "/mnt/skills/public/docx/scripts")) / "templates" / "people.xml"
|
|
215
|
+
if not ppath.exists():
|
|
216
|
+
if tpl.exists():
|
|
217
|
+
import shutil
|
|
218
|
+
shutil.copy(tpl, ppath)
|
|
219
|
+
else:
|
|
220
|
+
ppath.write_text('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
221
|
+
'<w15:people xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml">'
|
|
222
|
+
'</w15:people>', encoding="utf-8")
|
|
223
|
+
# add relationship and content type
|
|
224
|
+
rels_path = unpacked / "word" / "_rels" / "document.xml.rels"
|
|
225
|
+
if rels_path.exists():
|
|
226
|
+
rdom = defusedxml.minidom.parse(str(rels_path))
|
|
227
|
+
has_people = any(r.getAttribute("Target") == "people.xml" for r in rdom.getElementsByTagName("Relationship"))
|
|
228
|
+
if not has_people:
|
|
229
|
+
used = {int(r.getAttribute("Id")[3:]) for r in rdom.getElementsByTagName("Relationship")
|
|
230
|
+
if r.getAttribute("Id").startswith("rId") and r.getAttribute("Id")[3:].isdigit()}
|
|
231
|
+
nid = (max(used) + 1) if used else 100
|
|
232
|
+
rel = rdom.createElement("Relationship")
|
|
233
|
+
rel.setAttribute("Id", f"rId{nid}")
|
|
234
|
+
rel.setAttribute("Type", "http://schemas.microsoft.com/office/2011/relationships/people")
|
|
235
|
+
rel.setAttribute("Target", "people.xml")
|
|
236
|
+
rdom.documentElement.appendChild(rel)
|
|
237
|
+
rels_path.write_bytes(rdom.toxml(encoding="UTF-8"))
|
|
238
|
+
ct_path = unpacked / "[Content_Types].xml"
|
|
239
|
+
if ct_path.exists():
|
|
240
|
+
cdom = defusedxml.minidom.parse(str(ct_path))
|
|
241
|
+
has_ct = any(o.getAttribute("PartName") == "/word/people.xml" for o in cdom.getElementsByTagName("Override"))
|
|
242
|
+
if not has_ct:
|
|
243
|
+
ov = cdom.createElement("Override")
|
|
244
|
+
ov.setAttribute("PartName", "/word/people.xml")
|
|
245
|
+
ov.setAttribute("ContentType", "application/vnd.openxmlformats-officedocument.wordprocessingml.people+xml")
|
|
246
|
+
cdom.documentElement.appendChild(ov)
|
|
247
|
+
ct_path.write_bytes(cdom.toxml(encoding="UTF-8"))
|
|
248
|
+
# ensure the author has a person entry
|
|
249
|
+
pdom = defusedxml.minidom.parse(str(ppath))
|
|
250
|
+
for p in pdom.getElementsByTagName("w15:person"):
|
|
251
|
+
if p.getAttribute("w15:author") == author:
|
|
252
|
+
return # already present
|
|
253
|
+
ns_w15 = "http://schemas.microsoft.com/office/word/2012/wordml"
|
|
254
|
+
person = pdom.createElementNS(ns_w15, "w15:person")
|
|
255
|
+
person.setAttribute("w15:author", author)
|
|
256
|
+
pi = pdom.createElementNS(ns_w15, "w15:presenceInfo")
|
|
257
|
+
pi.setAttribute("w15:providerId", "None")
|
|
258
|
+
pi.setAttribute("w15:userId", author)
|
|
259
|
+
person.appendChild(pi)
|
|
260
|
+
pdom.documentElement.appendChild(person)
|
|
261
|
+
ppath.write_bytes(pdom.toxml(encoding="UTF-8"))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def ref_run(dom, cid):
|
|
265
|
+
r, rpr, st = dom.createElement("w:r"), dom.createElement("w:rPr"), dom.createElement("w:rStyle")
|
|
266
|
+
st.setAttribute("w:val", "CommentReference")
|
|
267
|
+
rpr.appendChild(st)
|
|
268
|
+
r.appendChild(rpr)
|
|
269
|
+
ref = dom.createElement("w:commentReference")
|
|
270
|
+
ref.setAttribute("w:id", str(cid))
|
|
271
|
+
r.appendChild(ref)
|
|
272
|
+
return r
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def marker(dom, tag, cid):
|
|
276
|
+
m = dom.createElement(tag)
|
|
277
|
+
m.setAttribute("w:id", str(cid))
|
|
278
|
+
return m
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def op_reply(dom, unpacked, parent, text):
|
|
282
|
+
"""Reply inside an existing thread, anchored after the thread's last existing reply."""
|
|
283
|
+
root, root_pid, members = thread(unpacked, parent)
|
|
284
|
+
cid, pid, _ = docx_comment.add_comment(unpacked, text, author=AUTHOR, initials=INITIALS)
|
|
285
|
+
ensure_people(unpacked, AUTHOR)
|
|
286
|
+
ext_edit(unpacked, lambda x: x.getAttribute("w15:paraId") == pid and (x.setAttribute("w15:paraIdParent", root_pid) or True))
|
|
287
|
+
refs = [e for e in dom.getElementsByTagName("w:commentReference") if int(e.getAttribute("w:id")) in members]
|
|
288
|
+
if not refs:
|
|
289
|
+
halt(f"thread {root}: no commentReference run in the body.")
|
|
290
|
+
last_run = refs[-1].parentNode
|
|
291
|
+
new_run = ref_run(dom, cid)
|
|
292
|
+
last_run.parentNode.insertBefore(new_run, last_run.nextSibling)
|
|
293
|
+
starts = [e for e in dom.getElementsByTagName("w:commentRangeStart") if int(e.getAttribute("w:id")) in members]
|
|
294
|
+
ends = [e for e in dom.getElementsByTagName("w:commentRangeEnd") if int(e.getAttribute("w:id")) in members]
|
|
295
|
+
rs, re_ = marker(dom, "w:commentRangeStart", cid), marker(dom, "w:commentRangeEnd", cid)
|
|
296
|
+
(starts[-1].parentNode.insertBefore(rs, starts[-1].nextSibling) if starts
|
|
297
|
+
else last_run.parentNode.insertBefore(rs, last_run))
|
|
298
|
+
(ends[-1].parentNode.insertBefore(re_, ends[-1].nextSibling) if ends
|
|
299
|
+
else new_run.parentNode.insertBefore(re_, new_run))
|
|
300
|
+
return cid, root, int(refs[-1].getAttribute("w:id"))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def op_resolve(unpacked, parent):
|
|
304
|
+
root, root_pid, members = thread(unpacked, parent)
|
|
305
|
+
n = ext_edit(unpacked, lambda x: (x.getAttribute("w15:paraId") == root_pid or x.getAttribute("w15:paraIdParent") == root_pid)
|
|
306
|
+
and (x.setAttribute("w15:done", "1") or True))
|
|
307
|
+
return root, n
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------- accept (round-2 step and rule 6)
|
|
311
|
+
def rev_ok(e, keep, ids):
|
|
312
|
+
i = e.getAttribute("w:id")
|
|
313
|
+
return e.getAttribute("w:author") not in keep and (ids is None or (i.isdigit() and int(i) in ids))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def accept_dom(dom, keep, ids=None):
|
|
317
|
+
"""Accept tracked changes by authors not in keep (and, when ids is given, only those ids). Comment and
|
|
318
|
+
bookmark markers inside deleted runs survive; a deleted paragraph mark merges into the next paragraph."""
|
|
319
|
+
n, merge = 0, []
|
|
320
|
+
for tag in PR_CHANGES:
|
|
321
|
+
for e in list(dom.getElementsByTagName(tag)):
|
|
322
|
+
if rev_ok(e, keep, ids):
|
|
323
|
+
e.parentNode.removeChild(e)
|
|
324
|
+
n += 1
|
|
325
|
+
for e in list(dom.getElementsByTagName("w:ins")):
|
|
326
|
+
if e.parentNode.tagName == "w:rPr" and rev_ok(e, keep, ids):
|
|
327
|
+
e.parentNode.removeChild(e)
|
|
328
|
+
n += 1
|
|
329
|
+
for e in list(dom.getElementsByTagName("w:del")):
|
|
330
|
+
if e.parentNode.tagName == "w:rPr" and rev_ok(e, keep, ids):
|
|
331
|
+
p = e.parentNode.parentNode.parentNode
|
|
332
|
+
e.parentNode.removeChild(e)
|
|
333
|
+
n += 1
|
|
334
|
+
if p.tagName == "w:p":
|
|
335
|
+
merge.append(p)
|
|
336
|
+
for e in list(dom.getElementsByTagName("w:del")):
|
|
337
|
+
if e.parentNode is None or e.parentNode.tagName == "w:rPr" or not rev_ok(e, keep, ids):
|
|
338
|
+
continue
|
|
339
|
+
par = e.parentNode
|
|
340
|
+
for c in kids(e):
|
|
341
|
+
if c.tagName in MARKERS or c.getElementsByTagName("w:commentReference"):
|
|
342
|
+
if c.tagName == "w:r":
|
|
343
|
+
for t in kids(c):
|
|
344
|
+
if t.tagName not in ("w:rPr", "w:commentReference"):
|
|
345
|
+
c.removeChild(t)
|
|
346
|
+
par.insertBefore(c, e)
|
|
347
|
+
par.removeChild(e)
|
|
348
|
+
n += 1
|
|
349
|
+
for e in list(dom.getElementsByTagName("w:ins")):
|
|
350
|
+
if e.parentNode is None or e.parentNode.tagName == "w:rPr" or not rev_ok(e, keep, ids):
|
|
351
|
+
continue
|
|
352
|
+
unwrap(e)
|
|
353
|
+
n += 1
|
|
354
|
+
for p in merge:
|
|
355
|
+
q = p.nextSibling
|
|
356
|
+
while q is not None and q.nodeType != 1:
|
|
357
|
+
q = q.nextSibling
|
|
358
|
+
if q is None or q.tagName != "w:p":
|
|
359
|
+
continue
|
|
360
|
+
qp = kids(q, "w:pPr")
|
|
361
|
+
anchor = qp[0].nextSibling if qp else q.firstChild
|
|
362
|
+
for c in kids(p):
|
|
363
|
+
if c.tagName != "w:pPr":
|
|
364
|
+
q.insertBefore(c, anchor)
|
|
365
|
+
p.parentNode.removeChild(p)
|
|
366
|
+
return n
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def revisions(dom):
|
|
370
|
+
out = {}
|
|
371
|
+
for tag in ("w:ins", "w:del"):
|
|
372
|
+
for e in dom.getElementsByTagName(tag):
|
|
373
|
+
k = (tag[2:], e.getAttribute("w:author"))
|
|
374
|
+
out[k] = out.get(k, 0) + 1
|
|
375
|
+
return out
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def cmd_accept(src, dst, keep, ids, quiet=False):
|
|
379
|
+
with zipfile.ZipFile(src) as zin:
|
|
380
|
+
infos = zin.infolist()
|
|
381
|
+
data = {i.filename: zin.read(i.filename) for i in infos}
|
|
382
|
+
dom = defusedxml.minidom.parseString(data["word/document.xml"])
|
|
383
|
+
before = revisions(dom)
|
|
384
|
+
n = accept_dom(dom, keep, ids)
|
|
385
|
+
data["word/document.xml"] = dom.toxml(encoding="UTF-8")
|
|
386
|
+
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
|
|
387
|
+
for i in infos:
|
|
388
|
+
zout.writestr(i, data[i.filename])
|
|
389
|
+
if quiet:
|
|
390
|
+
return
|
|
391
|
+
print(f"accepted {n} tracked changes (kept authors: {', '.join(sorted(keep))}"
|
|
392
|
+
f"{'; ids ' + ','.join(map(str, sorted(ids))) if ids else ''})")
|
|
393
|
+
print(f"revisions before: {before}\nrevisions after: {revisions(dom)}\nwrote {dst}")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ---------------------------------------------------------------- extract
|
|
397
|
+
def cmd_extract(docx):
|
|
398
|
+
with tempfile.TemporaryDirectory() as td:
|
|
399
|
+
unpacked = Path(td) / "u"
|
|
400
|
+
unpack(docx, unpacked)
|
|
401
|
+
dom, paras = load_doc(unpacked)
|
|
402
|
+
indep = doc_para_count(unpacked)
|
|
403
|
+
cm, ext = comment_maps(unpacked)
|
|
404
|
+
existing = []
|
|
405
|
+
print(f"# {docx.name}\n# {len(paras)} addressable blocks\n")
|
|
406
|
+
for p in paras:
|
|
407
|
+
existing += [(p.id,) + c for c in p.changes()]
|
|
408
|
+
print(f"[{p.id}] {p.text().replace(chr(10), ' / ').replace(chr(9), ' | ')}")
|
|
409
|
+
print()
|
|
410
|
+
if existing:
|
|
411
|
+
print("# EXISTING TRACKED CHANGES (read before editing; accept takes the ids):")
|
|
412
|
+
for pid, kind, rid, author, txt in existing:
|
|
413
|
+
print(f"# {pid} {kind} id={rid} by {author}: {txt[:120]!r}")
|
|
414
|
+
print()
|
|
415
|
+
if cm:
|
|
416
|
+
print("# COMMENTS (reply and resolve take the id):")
|
|
417
|
+
for i, (author, txt, pid) in sorted(cm.items()):
|
|
418
|
+
par, done = ext.get(pid, ("", ""))
|
|
419
|
+
root = next((j for j, v in cm.items() if v[2] == par), "?") if par else None
|
|
420
|
+
kind = f"reply to C{root}" if par else "thread root" + (", resolved" if done == "1" else ", open")
|
|
421
|
+
print(f"# C{i} by {author} ({kind}): {txt[:120]!r}")
|
|
422
|
+
print()
|
|
423
|
+
status = "OK" if indep == len(paras) else "MISMATCH"
|
|
424
|
+
print(f"# STRUCTURE: <w:p> in body={indep} walked={len(paras)} [{status}]")
|
|
425
|
+
if status == "MISMATCH":
|
|
426
|
+
halt("the walker did not visit every paragraph in the body. A container type was skipped. "
|
|
427
|
+
"Do not proceed until this is understood.")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------------------------------------------------------------- apply
|
|
431
|
+
class RevIds:
|
|
432
|
+
def __init__(self, dom):
|
|
433
|
+
used = {int(e.getAttribute("w:id")) for tag in ("w:ins", "w:del", "w:commentRangeStart", "w:bookmarkStart")
|
|
434
|
+
for e in dom.getElementsByTagName(tag) if e.getAttribute("w:id").isdigit()}
|
|
435
|
+
self.next = (max(used) + 1) if used else 1000
|
|
436
|
+
|
|
437
|
+
def take(self):
|
|
438
|
+
self.next += 1
|
|
439
|
+
return str(self.next - 1)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def make_run(dom, text, rpr, deleted):
|
|
443
|
+
r = dom.createElement("w:r")
|
|
444
|
+
if rpr is not None:
|
|
445
|
+
r.appendChild(rpr.cloneNode(True))
|
|
446
|
+
t = dom.createElement("w:delText" if deleted else "w:t")
|
|
447
|
+
t.setAttributeNS(XML_NS, "xml:space", "preserve")
|
|
448
|
+
t.appendChild(dom.createTextNode(text))
|
|
449
|
+
r.appendChild(t)
|
|
450
|
+
return r
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def rpr_of(run):
|
|
454
|
+
k = kids(run, "w:rPr")
|
|
455
|
+
return k[0] if k else None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def wrap(dom, tag, ids, *runs):
|
|
459
|
+
w = dom.createElement(tag)
|
|
460
|
+
w.setAttribute("w:id", ids.take())
|
|
461
|
+
w.setAttribute("w:author", AUTHOR)
|
|
462
|
+
w.setAttribute("w:date", now_iso())
|
|
463
|
+
for r in runs:
|
|
464
|
+
w.appendChild(r)
|
|
465
|
+
return w
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def norm_to_raw(raw, nidx):
|
|
469
|
+
i = n = 0
|
|
470
|
+
while i < len(raw) and n < nidx:
|
|
471
|
+
if raw[i] in WS:
|
|
472
|
+
while i < len(raw) and raw[i] in WS:
|
|
473
|
+
i += 1
|
|
474
|
+
else:
|
|
475
|
+
i += 1
|
|
476
|
+
n += 1
|
|
477
|
+
return i
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def locate(para, needle, label):
|
|
481
|
+
hay = para.text()
|
|
482
|
+
hn, nn = norm(hay), norm(needle)
|
|
483
|
+
if not nn.strip():
|
|
484
|
+
halt(f"{para.id}: empty '{label}' string.")
|
|
485
|
+
count = hn.count(nn)
|
|
486
|
+
if count != 1:
|
|
487
|
+
halt(f"{para.id}: '{label}' {'not found' if count == 0 else f'matches {count} times; widen it so it is unique'}."
|
|
488
|
+
f"\n looking for: {needle!r}\n paragraph reads: {hay!r}")
|
|
489
|
+
nidx = hn.find(nn)
|
|
490
|
+
return norm_to_raw(hay, nidx), norm_to_raw(hay, nidx + len(nn))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def run_text(r):
|
|
494
|
+
return "".join(t_text(c) if c.tagName == "w:t" else ("\t" if c.tagName == "w:tab" else "\n")
|
|
495
|
+
for c in kids(r, "w:t", "w:tab", "w:br", "w:cr"))
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def decompose_runs(dom, para):
|
|
499
|
+
"""Split any run with mixed w:t/w:tab/w:br/w:cr children into one run per child."""
|
|
500
|
+
for r in list(para.runs()):
|
|
501
|
+
if ancestor(r, "w:del") is not None:
|
|
502
|
+
continue
|
|
503
|
+
content = kids(r, "w:t", "w:tab", "w:br", "w:cr")
|
|
504
|
+
if len(content) <= 1:
|
|
505
|
+
continue
|
|
506
|
+
rpr = rpr_of(r)
|
|
507
|
+
parent = r.parentNode
|
|
508
|
+
for c in content:
|
|
509
|
+
new_r = dom.createElement("w:r")
|
|
510
|
+
if rpr is not None:
|
|
511
|
+
new_r.appendChild(rpr.cloneNode(True))
|
|
512
|
+
new_r.appendChild(c)
|
|
513
|
+
parent.insertBefore(new_r, r)
|
|
514
|
+
parent.removeChild(r)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def split_at(dom, para, offsets):
|
|
518
|
+
decompose_runs(dom, para)
|
|
519
|
+
for target in sorted(set(offsets)):
|
|
520
|
+
pos = 0
|
|
521
|
+
for r in para.runs():
|
|
522
|
+
if ancestor(r, "w:del") is not None:
|
|
523
|
+
continue
|
|
524
|
+
rtext = run_text(r)
|
|
525
|
+
if pos < target < pos + len(rtext):
|
|
526
|
+
ts = kids(r, "w:t", "w:tab", "w:br", "w:cr")
|
|
527
|
+
if len(ts) != 1 or ts[0].tagName != "w:t":
|
|
528
|
+
halt(f"{para.id}: cannot split a run containing non-text elements at offset {target}.")
|
|
529
|
+
full, cut = t_text(ts[0]), target - pos
|
|
530
|
+
r.parentNode.insertBefore(make_run(dom, full[:cut], rpr_of(r), False), r)
|
|
531
|
+
r.parentNode.insertBefore(make_run(dom, full[cut:], rpr_of(r), False), r)
|
|
532
|
+
r.parentNode.removeChild(r)
|
|
533
|
+
break
|
|
534
|
+
pos += len(rtext)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def runs_in_span(para, start, end):
|
|
538
|
+
out, pos = [], 0
|
|
539
|
+
for r in para.runs():
|
|
540
|
+
if ancestor(r, "w:del") is not None:
|
|
541
|
+
continue
|
|
542
|
+
rtext = run_text(r)
|
|
543
|
+
if pos >= start and pos + len(rtext) <= end and rtext:
|
|
544
|
+
out.append(r)
|
|
545
|
+
pos += len(rtext)
|
|
546
|
+
return out
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def to_deleted(dom, run):
|
|
550
|
+
r = run.cloneNode(True)
|
|
551
|
+
for t in list(r.getElementsByTagName("w:t")):
|
|
552
|
+
d = dom.createElement("w:delText")
|
|
553
|
+
d.setAttributeNS(XML_NS, "xml:space", "preserve")
|
|
554
|
+
for c in list(t.childNodes):
|
|
555
|
+
d.appendChild(c)
|
|
556
|
+
t.parentNode.replaceChild(d, t)
|
|
557
|
+
return r
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def guard(para, runs, accepted):
|
|
561
|
+
"""Rule 6 and the cross-author refusal: a find range inside another author's still-tracked insertion
|
|
562
|
+
accepts that insertion first; a range crossing from one author's insertion into another's stops."""
|
|
563
|
+
owners, plain = {}, False
|
|
564
|
+
for r in runs:
|
|
565
|
+
e = ancestor(r, "w:ins")
|
|
566
|
+
if e is None:
|
|
567
|
+
plain = True
|
|
568
|
+
else:
|
|
569
|
+
owners.setdefault(e.getAttribute("w:author"), []).append(e)
|
|
570
|
+
names = list(owners)
|
|
571
|
+
if len(names) > 1:
|
|
572
|
+
halt(f"{para.id}: find range crosses from {names[0]}'s insertion into {names[1]}'s. "
|
|
573
|
+
"Split the edit at the boundary.")
|
|
574
|
+
if names and names[0] != AUTHOR:
|
|
575
|
+
for e in dict.fromkeys(owners[names[0]]):
|
|
576
|
+
rid = e.getAttribute("w:id")
|
|
577
|
+
unwrap(e)
|
|
578
|
+
accepted.append((rid, names[0]))
|
|
579
|
+
print(f"# {para.id}: accepted insertion id={rid} by {names[0]} before editing inside it (rule 6)")
|
|
580
|
+
elif names and plain:
|
|
581
|
+
halt(f"{para.id}: find range crosses from your own insertion into base text. Split the edit.")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def span(dom, para, find, label, accepted):
|
|
585
|
+
start, end = locate(para, find, label)
|
|
586
|
+
split_at(dom, para, [start, end])
|
|
587
|
+
runs = runs_in_span(para, start, end)
|
|
588
|
+
if not runs:
|
|
589
|
+
halt(f"{para.id}: internal: no runs in span after split.")
|
|
590
|
+
if accepted is not None:
|
|
591
|
+
guard(para, runs, accepted)
|
|
592
|
+
return runs
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def op_replace_or_delete(dom, para, ids, find, with_, accepted):
|
|
596
|
+
runs = span(dom, para, find, "find", accepted)
|
|
597
|
+
rpr, anchor = rpr_of(runs[0]), runs[-1]
|
|
598
|
+
parent, nxt = anchor.parentNode, anchor.nextSibling
|
|
599
|
+
deleted = [to_deleted(dom, r) for r in runs]
|
|
600
|
+
for r in runs:
|
|
601
|
+
r.parentNode.removeChild(r)
|
|
602
|
+
del_wrap = wrap(dom, "w:del", ids, *deleted)
|
|
603
|
+
parent.insertBefore(del_wrap, nxt)
|
|
604
|
+
if with_ is not None:
|
|
605
|
+
parent.insertBefore(wrap(dom, "w:ins", ids, make_run(dom, with_, rpr, False)), del_wrap.nextSibling)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def op_insert_after(dom, para, ids, find, text, accepted):
|
|
609
|
+
anchor = span(dom, para, find, "find", accepted)[-1]
|
|
610
|
+
anchor.parentNode.insertBefore(wrap(dom, "w:ins", ids, make_run(dom, text, rpr_of(anchor), False)), anchor.nextSibling)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def para_mark(dom, para_node, ids, tag):
|
|
614
|
+
ppr = kids(para_node, "w:pPr")
|
|
615
|
+
if not ppr:
|
|
616
|
+
ppr = [dom.createElement("w:pPr")]
|
|
617
|
+
para_node.insertBefore(ppr[0], para_node.firstChild)
|
|
618
|
+
rpr = kids(ppr[0], "w:rPr")
|
|
619
|
+
if not rpr:
|
|
620
|
+
rpr = [dom.createElement("w:rPr")]
|
|
621
|
+
ppr[0].appendChild(rpr[0])
|
|
622
|
+
rpr[0].insertBefore(wrap(dom, tag, ids), rpr[0].firstChild)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def op_insert_paragraph_after(dom, para, ids, text):
|
|
626
|
+
newp = dom.createElement("w:p")
|
|
627
|
+
src = kids(para.node, "w:pPr")
|
|
628
|
+
newp.appendChild(src[0].cloneNode(True) if src else dom.createElement("w:pPr"))
|
|
629
|
+
para_mark(dom, newp, ids, "w:ins")
|
|
630
|
+
rs = para.runs()
|
|
631
|
+
newp.appendChild(wrap(dom, "w:ins", ids, make_run(dom, text, rpr_of(rs[0]) if rs else None, False)))
|
|
632
|
+
para.node.parentNode.insertBefore(newp, para.node.nextSibling)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def op_delete_paragraph(dom, para, ids, accepted):
|
|
636
|
+
for r in para.runs():
|
|
637
|
+
e = ancestor(r, "w:ins")
|
|
638
|
+
if e is not None and e.getAttribute("w:author") != AUTHOR and e.parentNode is not None:
|
|
639
|
+
accepted.append((e.getAttribute("w:id"), e.getAttribute("w:author")))
|
|
640
|
+
print(f"# {para.id}: accepted insertion id={e.getAttribute('w:id')} by {e.getAttribute('w:author')} (rule 6)")
|
|
641
|
+
unwrap(e)
|
|
642
|
+
for r in para.runs():
|
|
643
|
+
if ancestor(r, "w:del") is None:
|
|
644
|
+
r.parentNode.replaceChild(wrap(dom, "w:del", ids, to_deleted(dom, r)), r)
|
|
645
|
+
para_mark(dom, para.node, ids, "w:del")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def op_comment(dom, para, anchor, text, unpacked):
|
|
649
|
+
runs = span(dom, para, anchor, "anchor", None)
|
|
650
|
+
cid, _, _ = docx_comment.add_comment(unpacked, text, author=AUTHOR, initials=INITIALS)
|
|
651
|
+
ensure_people(unpacked, AUTHOR)
|
|
652
|
+
rs, re_ = marker(dom, "w:commentRangeStart", cid), marker(dom, "w:commentRangeEnd", cid)
|
|
653
|
+
runs[0].parentNode.insertBefore(rs, runs[0])
|
|
654
|
+
runs[-1].parentNode.insertBefore(re_, runs[-1].nextSibling)
|
|
655
|
+
re_.parentNode.insertBefore(ref_run(dom, cid), re_.nextSibling)
|
|
656
|
+
return cid
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def render_marked(before, after):
|
|
660
|
+
import difflib
|
|
661
|
+
a, b, out = before.split(), after.split(), []
|
|
662
|
+
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
|
|
663
|
+
if tag == "equal":
|
|
664
|
+
out.extend(a[i1:i2])
|
|
665
|
+
else:
|
|
666
|
+
if tag != "insert":
|
|
667
|
+
out.append("~~" + " ".join(a[i1:i2]) + "~~")
|
|
668
|
+
if tag != "delete":
|
|
669
|
+
out.append("**" + " ".join(b[j1:j2]) + "**")
|
|
670
|
+
return " ".join(out)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def check_plan(plan):
|
|
674
|
+
edits = plan.get("edits")
|
|
675
|
+
if not isinstance(edits, list) or not edits:
|
|
676
|
+
halt("plan.json has no 'edits' list.")
|
|
677
|
+
need = {"replace": ("id", "find", "with"), "insert_after": ("id", "find", "text"), "delete": ("id", "find"),
|
|
678
|
+
"insert_paragraph_after": ("id", "text"), "delete_paragraph": ("id",), "comment": ("id", "anchor", "text"),
|
|
679
|
+
"reply": ("parent", "text"), "resolve": ("parent",)}
|
|
680
|
+
for i, e in enumerate(edits):
|
|
681
|
+
if e.get("op") not in OPS:
|
|
682
|
+
halt(f"edit #{i}: unknown op {e.get('op')!r}. Allowed: {sorted(OPS)}")
|
|
683
|
+
for k in need[e["op"]]:
|
|
684
|
+
if k not in e:
|
|
685
|
+
halt(f"edit #{i}: {e['op']} needs {need[e['op']]}.")
|
|
686
|
+
for k in e:
|
|
687
|
+
if k not in KEYS:
|
|
688
|
+
halt(f"edit #{i}: unexpected key {k!r}. Full-paragraph rewrites are not an op; use find/replace.")
|
|
689
|
+
return edits
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def cmd_apply(docx, plan_path, out, report):
|
|
693
|
+
plan = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
694
|
+
edits = check_plan(plan)
|
|
695
|
+
n_text = sum(1 for e in edits if e["op"] in TEXT_OPS)
|
|
696
|
+
n_comment = sum(1 for e in edits if e["op"] == "comment" and not (e.get("question") or plan.get("questions")))
|
|
697
|
+
if n_comment > n_text:
|
|
698
|
+
halt(f"BUILD GATE: {n_comment} comments against {n_text} tracked changes. Comments are for business "
|
|
699
|
+
'decisions; redline the rest, or mark the comments as questions ("question": true on the edit, '
|
|
700
|
+
'or "questions": true on the plan).')
|
|
701
|
+
with tempfile.TemporaryDirectory() as td:
|
|
702
|
+
unpacked = Path(td) / "u"
|
|
703
|
+
unpack(docx, unpacked)
|
|
704
|
+
dom, paras = load_doc(unpacked)
|
|
705
|
+
by_id = {p.id: p for p in paras}
|
|
706
|
+
before = {p.id: p.text() for p in paras}
|
|
707
|
+
ids, touched, accepted, notes = RevIds(dom), [], [], []
|
|
708
|
+
for i, e in enumerate(edits):
|
|
709
|
+
op = e["op"]
|
|
710
|
+
if op in ("reply", "resolve"):
|
|
711
|
+
if op == "reply":
|
|
712
|
+
cid, root, after = op_reply(dom, unpacked, int(e["parent"]), e["text"])
|
|
713
|
+
notes.append(f"## reply C{cid} in thread C{root}, placed after C{after}\nREPLY: {e['text']}")
|
|
714
|
+
else:
|
|
715
|
+
root, n = op_resolve(unpacked, int(e["parent"]))
|
|
716
|
+
notes.append(f"## resolved thread C{root} ({n} entries marked done)")
|
|
717
|
+
continue
|
|
718
|
+
p = by_id.get(e["id"])
|
|
719
|
+
if p is None:
|
|
720
|
+
halt(f"edit #{i}: paragraph {e['id']} does not exist. Run extract again.")
|
|
721
|
+
if op == "replace":
|
|
722
|
+
op_replace_or_delete(dom, p, ids, e["find"], e["with"], accepted)
|
|
723
|
+
elif op == "delete":
|
|
724
|
+
op_replace_or_delete(dom, p, ids, e["find"], None, accepted)
|
|
725
|
+
elif op == "insert_after":
|
|
726
|
+
op_insert_after(dom, p, ids, e["find"], e["text"], accepted)
|
|
727
|
+
elif op == "insert_paragraph_after":
|
|
728
|
+
op_insert_paragraph_after(dom, p, ids, e["text"])
|
|
729
|
+
notes.append(f"## new paragraph after {p.id}\nINSERTED: {e['text']}")
|
|
730
|
+
elif op == "delete_paragraph":
|
|
731
|
+
op_delete_paragraph(dom, p, ids, accepted)
|
|
732
|
+
else:
|
|
733
|
+
cid = op_comment(dom, p, e["anchor"], e["text"], unpacked)
|
|
734
|
+
notes.append(f"## comment C{cid} on {p.id}{' (question)' if e.get('question') else ''}\nCOMMENT: {e['text']}")
|
|
735
|
+
if op != "insert_paragraph_after" and op != "comment":
|
|
736
|
+
touched.append(p.id)
|
|
737
|
+
(unpacked / "word" / "document.xml").write_bytes(dom.toxml(encoding="UTF-8"))
|
|
738
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
739
|
+
rezip(unpacked, out)
|
|
740
|
+
original = docx
|
|
741
|
+
if accepted: # validate against the input with the same insertions accepted (rule 6 leaves them untracked)
|
|
742
|
+
original = Path(td) / "validation_original.docx"
|
|
743
|
+
cmd_accept(docx, original, set(), {int(r) for r, _ in accepted if r.isdigit()}, quiet=True)
|
|
744
|
+
after = {p.id: p.text() for p in paras} # same nodes; text() is the accepted view
|
|
745
|
+
lines = [f"# VERIFICATION REPORT: {out.name}", ""]
|
|
746
|
+
for pid in dict.fromkeys(touched):
|
|
747
|
+
lines += [f"## {pid}", f"BEFORE: {before[pid]}", f"AFTER: {after.get(pid, '')}",
|
|
748
|
+
f"MARKED: {render_marked(before[pid], after.get(pid, ''))}", ""]
|
|
749
|
+
for n in notes:
|
|
750
|
+
lines += [n, ""]
|
|
751
|
+
if accepted:
|
|
752
|
+
lines += ["## accepted before editing (rule 6): " + ", ".join(f"id={r} by {a}" for r, a in accepted), ""]
|
|
753
|
+
report.write_text("\n".join(lines), encoding="utf-8")
|
|
754
|
+
print("\n".join(lines))
|
|
755
|
+
res = subprocess.run([sys.executable, str(DOCX_SKILL / "office" / "validate.py"), str(out),
|
|
756
|
+
"--original", str(original), "--author", AUTHOR], capture_output=True, text=True)
|
|
757
|
+
print("\n# VALIDATE:\n" + (res.stdout.strip() or "(no output)"))
|
|
758
|
+
if res.returncode != 0:
|
|
759
|
+
print(res.stderr, file=sys.stderr)
|
|
760
|
+
halt("validate.py failed. The redline is not safe to deliver.")
|
|
761
|
+
print(f"\n# OK: {out}\n# report: {report}\n")
|
|
762
|
+
cmd_check(out, plan_path)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ---------------------------------------------------------------- post-build check
|
|
766
|
+
def cmd_check(out, plan_path):
|
|
767
|
+
plan = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
768
|
+
fails = []
|
|
769
|
+
try:
|
|
770
|
+
import docx
|
|
771
|
+
docx.Document(str(out))
|
|
772
|
+
print("python-docx: loads")
|
|
773
|
+
except Exception as x:
|
|
774
|
+
fails.append(f"python-docx cannot open the output: {x}")
|
|
775
|
+
if shutil.which("soffice"):
|
|
776
|
+
from office.soffice import run_soffice
|
|
777
|
+
with tempfile.TemporaryDirectory() as td:
|
|
778
|
+
r = run_soffice(["--headless", "--convert-to", "pdf", "--outdir", td, str(out)],
|
|
779
|
+
capture_output=True, text=True, timeout=180)
|
|
780
|
+
if any(Path(td).glob("*.pdf")):
|
|
781
|
+
print("LibreOffice: loads")
|
|
782
|
+
else:
|
|
783
|
+
fails.append("LibreOffice headless could not open the output: " + (r.stderr or r.stdout)[-300:])
|
|
784
|
+
else:
|
|
785
|
+
print("LibreOffice: NOT RUN (soffice not found on this machine)")
|
|
786
|
+
with tempfile.TemporaryDirectory() as td:
|
|
787
|
+
u = Path(td) / "c"
|
|
788
|
+
unpack(out, u, merge=False)
|
|
789
|
+
dom, paras = load_doc(u)
|
|
790
|
+
cm, ext = comment_maps(u)
|
|
791
|
+
counts = {t: len(dom.getElementsByTagName(t)) for t in ("w:commentRangeStart", "w:commentRangeEnd", "w:commentReference")}
|
|
792
|
+
print(f"comments: {len(cm)} in comments.xml, {counts['w:commentRangeStart']} commentRangeStart, "
|
|
793
|
+
f"{counts['w:commentRangeEnd']} commentRangeEnd, {counts['w:commentReference']} commentReference")
|
|
794
|
+
if len(set(counts.values()) | {len(cm)}) != 1:
|
|
795
|
+
fails.append("comment marker counts do not match comments.xml")
|
|
796
|
+
rs_ids = {int(e.getAttribute("w:id")) for e in dom.getElementsByTagName("w:commentRangeStart")}
|
|
797
|
+
mine = {"ins": [], "del": []}
|
|
798
|
+
for tag in ("w:ins", "w:del"):
|
|
799
|
+
for e in dom.getElementsByTagName(tag):
|
|
800
|
+
if e.getAttribute("w:author") == AUTHOR:
|
|
801
|
+
mine[tag[2:]].append((el_text(e) if tag == "w:ins" else el_text(e, True), e.parentNode.tagName == "w:rPr"))
|
|
802
|
+
|
|
803
|
+
def has(kind, text, mark=False):
|
|
804
|
+
return any(norm(text) in norm(t) and (m or not mark) for t, m in mine[kind]) if text else any(m for _, m in mine[kind])
|
|
805
|
+
|
|
806
|
+
for i, e in enumerate(plan.get("edits", [])):
|
|
807
|
+
op, ok = e["op"], True
|
|
808
|
+
if op in ("replace", "delete"):
|
|
809
|
+
ok = has("del", e["find"]) and (op == "delete" or has("ins", e["with"]))
|
|
810
|
+
elif op == "insert_after":
|
|
811
|
+
ok = has("ins", e["text"])
|
|
812
|
+
elif op == "insert_paragraph_after":
|
|
813
|
+
ok = has("ins", e["text"]) and has("ins", "", True)
|
|
814
|
+
elif op == "delete_paragraph":
|
|
815
|
+
ok = has("del", "", True)
|
|
816
|
+
elif op == "comment":
|
|
817
|
+
ok = any(norm(e["text"]) in norm(v[1]) and k in rs_ids for k, v in cm.items())
|
|
818
|
+
elif op == "reply":
|
|
819
|
+
ok = any(norm(e["text"]) in norm(v[1]) and k in rs_ids and ext.get(v[2], ("", ""))[0] for k, v in cm.items())
|
|
820
|
+
elif op == "resolve":
|
|
821
|
+
root, root_pid, members = thread(u, int(e["parent"]))
|
|
822
|
+
ok = all(ext.get(cm[m][2], ("", "0"))[1] == "1" for m in members)
|
|
823
|
+
if not ok:
|
|
824
|
+
fails.append(f"edit #{i} ({op}{', ' + e['id'] if 'id' in e else ''}) has no matching tracked change or comment")
|
|
825
|
+
print("CHECK: PASS" if not fails else "CHECK: FAIL\n " + "\n ".join(fails))
|
|
826
|
+
if fails:
|
|
827
|
+
sys.exit(1)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def main():
|
|
831
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
832
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
833
|
+
sub.add_parser("extract").add_argument("docx", type=Path)
|
|
834
|
+
ac = sub.add_parser("accept")
|
|
835
|
+
ac.add_argument("docx", type=Path)
|
|
836
|
+
ac.add_argument("out", type=Path)
|
|
837
|
+
ac.add_argument("--keep", default="", help='authors whose changes stay live, e.g. "Author A,Author B"')
|
|
838
|
+
ac.add_argument("--ids", default="", help="accept only these revision ids, e.g. 12,15")
|
|
839
|
+
apl = sub.add_parser("apply")
|
|
840
|
+
apl.add_argument("docx", type=Path)
|
|
841
|
+
apl.add_argument("plan", type=Path)
|
|
842
|
+
apl.add_argument("--out", type=Path, required=True)
|
|
843
|
+
apl.add_argument("--report", type=Path, default=None)
|
|
844
|
+
ck = sub.add_parser("check")
|
|
845
|
+
ck.add_argument("docx", type=Path)
|
|
846
|
+
ck.add_argument("plan", type=Path)
|
|
847
|
+
a = ap.parse_args()
|
|
848
|
+
if a.cmd == "extract":
|
|
849
|
+
cmd_extract(a.docx)
|
|
850
|
+
elif a.cmd == "accept":
|
|
851
|
+
keep = {AUTHOR} | {s.strip() for s in a.keep.split(",") if s.strip()}
|
|
852
|
+
ids = {int(s) for s in a.ids.split(",") if s.strip().isdigit()} or None
|
|
853
|
+
cmd_accept(a.docx, a.out, keep, ids)
|
|
854
|
+
elif a.cmd == "apply":
|
|
855
|
+
cmd_apply(a.docx, a.plan, a.out, a.report or a.out.with_suffix(".report.md"))
|
|
856
|
+
else:
|
|
857
|
+
cmd_check(a.docx, a.plan)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
if __name__ == "__main__":
|
|
861
|
+
main()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""NorthStar redline verification pair (ctx 3.4.0, rule 11).
|
|
3
|
+
Usage: python verify_redline.py OUT.docx ORIG.docx BASE.docx
|
|
4
|
+
ORIG is the counterparty document as received; BASE is ORIG after the accept step (the apply input).
|
|
5
|
+
REJECT: OUT with this turn's the author's changes rejected must read, paragraph for paragraph, as BASE.
|
|
6
|
+
REPORT: parts, revisions by author, original comments preserved, part counts, new comments, resolved and
|
|
7
|
+
open threads, reply order. Ends VERIFY: PASS or VERIFY: FAIL with the failing items.
|
|
8
|
+
"""
|
|
9
|
+
import os, re, sys, zipfile
|
|
10
|
+
from xml.dom import minidom
|
|
11
|
+
|
|
12
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
13
|
+
W14 = "http://schemas.microsoft.com/office/word/2010/wordml"
|
|
14
|
+
W15 = "http://schemas.microsoft.com/office/word/2012/wordml"
|
|
15
|
+
AUTHOR = os.environ.get("CTX_AUTHOR", "").strip()
|
|
16
|
+
if not AUTHOR:
|
|
17
|
+
sys.exit("HALT: set CTX_AUTHOR (the tracked-change author name) before running.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def text(n):
|
|
21
|
+
return "".join(c.data for c in n.childNodes if c.nodeType == c.TEXT_NODE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def rev_ids(d, author=None):
|
|
25
|
+
return {e.getAttributeNS(W, "id") for t in ("ins", "del") for e in d.getElementsByTagNameNS(W, t)
|
|
26
|
+
if author is None or e.getAttributeNS(W, "author") == author}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unwrap(e):
|
|
30
|
+
p = e.parentNode
|
|
31
|
+
for c in list(e.childNodes):
|
|
32
|
+
p.insertBefore(c, e)
|
|
33
|
+
p.removeChild(e)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sig(xml, reject=frozenset(), accept=frozenset()):
|
|
37
|
+
"""Per-paragraph (owner, text) segments: '=' kept, '+author' inserted, '-author' deleted. Revisions in
|
|
38
|
+
reject are undone (a paragraph inserted under a rejected mark is dropped); revisions in accept are accepted."""
|
|
39
|
+
d = minidom.parseString(xml)
|
|
40
|
+
for e in list(d.getElementsByTagNameNS(W, "ins")):
|
|
41
|
+
i, mark = e.getAttributeNS(W, "id"), e.parentNode.localName == "rPr"
|
|
42
|
+
if i in reject:
|
|
43
|
+
(e.parentNode.parentNode.parentNode.parentNode.removeChild(e.parentNode.parentNode.parentNode)
|
|
44
|
+
if mark else e.parentNode.removeChild(e))
|
|
45
|
+
elif i in accept:
|
|
46
|
+
e.parentNode.removeChild(e) if mark else unwrap(e)
|
|
47
|
+
for e in list(d.getElementsByTagNameNS(W, "del")):
|
|
48
|
+
i, mark = e.getAttributeNS(W, "id"), e.parentNode.localName == "rPr"
|
|
49
|
+
if mark and (i in reject or i in accept):
|
|
50
|
+
e.parentNode.removeChild(e)
|
|
51
|
+
elif i in reject:
|
|
52
|
+
for t in list(e.getElementsByTagNameNS(W, "delText")):
|
|
53
|
+
n = d.createElementNS(W, "w:t")
|
|
54
|
+
for c in list(t.childNodes):
|
|
55
|
+
n.appendChild(c)
|
|
56
|
+
t.parentNode.replaceChild(n, t)
|
|
57
|
+
unwrap(e)
|
|
58
|
+
elif i in accept:
|
|
59
|
+
e.parentNode.removeChild(e)
|
|
60
|
+
out = []
|
|
61
|
+
for p in d.getElementsByTagNameNS(W, "p"):
|
|
62
|
+
m = []
|
|
63
|
+
for n in p.getElementsByTagNameNS("*", "*"):
|
|
64
|
+
if n.localName not in ("t", "delText"):
|
|
65
|
+
continue
|
|
66
|
+
a, st = n.parentNode, "="
|
|
67
|
+
while a is not None and a.nodeType == 1 and a.localName != "p":
|
|
68
|
+
if a.localName in ("ins", "del"):
|
|
69
|
+
st = ("+" if a.localName == "ins" else "-") + a.getAttributeNS(W, "author")
|
|
70
|
+
break
|
|
71
|
+
a = a.parentNode
|
|
72
|
+
if m and m[-1][0] == st:
|
|
73
|
+
m[-1] = (st, m[-1][1] + text(n))
|
|
74
|
+
else:
|
|
75
|
+
m.append((st, text(n)))
|
|
76
|
+
out.append([s for s in m if s[1]])
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def comments(z):
|
|
81
|
+
r = {}
|
|
82
|
+
if "word/comments.xml" not in z.namelist():
|
|
83
|
+
return r
|
|
84
|
+
for x in minidom.parseString(z.read("word/comments.xml")).getElementsByTagNameNS(W, "comment"):
|
|
85
|
+
ps = x.getElementsByTagNameNS(W, "p")
|
|
86
|
+
txt = " / ".join("".join(text(t) for t in p.getElementsByTagNameNS(W, "t")) for p in ps)
|
|
87
|
+
r[int(x.getAttributeNS(W, "id"))] = (x.getAttributeNS(W, "author"), txt, ps[-1].getAttributeNS(W14, "paraId"))
|
|
88
|
+
return r
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ext(z):
|
|
92
|
+
if "word/commentsExtended.xml" not in z.namelist():
|
|
93
|
+
return {}
|
|
94
|
+
return {x.getAttributeNS(W15, "paraId"): (x.getAttributeNS(W15, "paraIdParent"), x.getAttributeNS(W15, "done"))
|
|
95
|
+
for x in minidom.parseString(z.read("word/commentsExtended.xml")).getElementsByTagNameNS(W15, "commentEx")}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main(out, orig, base):
|
|
99
|
+
zo, zg, zb = zipfile.ZipFile(out), zipfile.ZipFile(orig), zipfile.ZipFile(base)
|
|
100
|
+
fails = []
|
|
101
|
+
print("=====", out)
|
|
102
|
+
ob, bb = zo.read("word/document.xml"), zb.read("word/document.xml")
|
|
103
|
+
do, db = minidom.parseString(ob), minidom.parseString(bb)
|
|
104
|
+
new_ids = rev_ids(do, AUTHOR) - rev_ids(db, AUTHOR)
|
|
105
|
+
gone = rev_ids(db) - rev_ids(do) # base revisions the build accepted before editing inside them (rule 6)
|
|
106
|
+
a, b = sig(ob, reject=new_ids), sig(bb, accept=gone)
|
|
107
|
+
bad = [i for i, (x, y) in enumerate(zip(a, b)) if x != y]
|
|
108
|
+
if len(a) != len(b):
|
|
109
|
+
bad.append(f"paragraph count {len(a)} vs {len(b)}")
|
|
110
|
+
print(f"REJECT: this turn's {len(new_ids)} {AUTHOR} revisions rejected ({len(gone)} base revisions accepted "
|
|
111
|
+
f"under rule 6); paragraphs {len(a)} vs base {len(b)}; mismatched: {bad[:20]}")
|
|
112
|
+
for i in [i for i in bad if isinstance(i, int)][:5]:
|
|
113
|
+
print(" OUT ", a[i], "\n BASE", b[i])
|
|
114
|
+
if bad:
|
|
115
|
+
fails.append("REJECT: output with the author's changes rejected does not read as the base")
|
|
116
|
+
exp = {"word/document.xml", "word/comments.xml", "word/commentsExtended.xml", "word/commentsIds.xml",
|
|
117
|
+
"word/commentsExtensible.xml", "word/people.xml", "[Content_Types].xml", "word/_rels/document.xml.rels"}
|
|
118
|
+
changed = [n for n in zg.namelist() if n not in exp and (n not in zo.namelist() or zg.read(n) != zo.read(n))]
|
|
119
|
+
missing = [n for n in zg.namelist() if n not in zo.namelist()]
|
|
120
|
+
print("1 other parts changed vs counterparty original:", changed or "none", "| missing:", missing or "none")
|
|
121
|
+
if changed or missing:
|
|
122
|
+
fails.append("1 package parts changed or missing")
|
|
123
|
+
d = minidom.parseString(ob)
|
|
124
|
+
revs = {}
|
|
125
|
+
for t in ("ins", "del", "rPrChange", "pPrChange", "moveFrom", "moveTo"):
|
|
126
|
+
for e in d.getElementsByTagNameNS(W, t):
|
|
127
|
+
k = (t, e.getAttributeNS(W, "author"))
|
|
128
|
+
revs[k] = revs.get(k, 0) + 1
|
|
129
|
+
print("2 revisions in output by type/author:", revs)
|
|
130
|
+
co, cg = comments(zo), comments(zg)
|
|
131
|
+
lost = [i for i in cg if i not in co or co[i][:2] != cg[i][:2]]
|
|
132
|
+
print("3 original comments:", len(cg), "| lost or altered:", lost or "none")
|
|
133
|
+
if lost:
|
|
134
|
+
fails.append("3 original comments lost or altered")
|
|
135
|
+
eo, eg = ext(zo), ext(zg)
|
|
136
|
+
pid2id = {v[2]: k for k, v in co.items()}
|
|
137
|
+
n_ids = len(re.findall(rb"<w16cid:commentId ", zo.read("word/commentsIds.xml"))) if "word/commentsIds.xml" in zo.namelist() else 0
|
|
138
|
+
n_ex = len(re.findall(rb"<w16cex:commentExtensible ", zo.read("word/commentsExtensible.xml"))) if "word/commentsExtensible.xml" in zo.namelist() else 0
|
|
139
|
+
print("4 part counts comments/ext/ids/extensible:", len(co), len(eo), n_ids, n_ex)
|
|
140
|
+
if co and len({len(co), len(eo), n_ids, n_ex}) != 1:
|
|
141
|
+
fails.append("4 comment part counts differ")
|
|
142
|
+
doc = ob.decode("utf-8")
|
|
143
|
+
for i, (au, t, p) in sorted(co.items()):
|
|
144
|
+
if i in cg:
|
|
145
|
+
continue
|
|
146
|
+
par = eo.get(p, ("", ""))[0]
|
|
147
|
+
dashes = bool(re.search("[\u2013\u2014]|--", t))
|
|
148
|
+
print(f"5 NEW C{i} by {au} | reply to C{pid2id.get(par, '-')} | ref {f'<w:commentReference w:id={chr(34)}{i}{chr(34)}/>' in doc} "
|
|
149
|
+
f"| range {f'<w:commentRangeStart w:id={chr(34)}{i}{chr(34)}/>' in doc} | dashes {dashes} | {t[:80]}")
|
|
150
|
+
if dashes:
|
|
151
|
+
fails.append(f"5 comment C{i} contains a dash")
|
|
152
|
+
if au != AUTHOR:
|
|
153
|
+
fails.append(f"5 comment C{i} is not by {AUTHOR}")
|
|
154
|
+
flipped = sorted({pid2id.get(eo[p][0] or p, "?") for p in eo if p in eg and eg[p][1] != eo[p][1]})
|
|
155
|
+
print("6 threads whose resolved state changed (root ids):", flipped)
|
|
156
|
+
print("7 threads still open (root ids):", sorted(pid2id[p] for p, v in eo.items() if not v[0] and v[1] != "1" and p in pid2id))
|
|
157
|
+
for i, (au, t, p) in co.items():
|
|
158
|
+
par = eo.get(p, ("", ""))[0]
|
|
159
|
+
if i in cg or not par:
|
|
160
|
+
continue
|
|
161
|
+
mem = [pid2id[q] for q, v in eo.items() if (v[0] == par or q == par) and q in pid2id]
|
|
162
|
+
pos = {m: doc.find(f'<w:commentReference w:id="{m}"/>') for m in mem}
|
|
163
|
+
last = max(pos, key=pos.get) == i
|
|
164
|
+
print(f"8 reply C{i} displays last in its thread: {last}")
|
|
165
|
+
if not last:
|
|
166
|
+
fails.append(f"8 reply C{i} is not last in its thread")
|
|
167
|
+
print("VERIFY: PASS" if not fails else "VERIFY: FAIL\n " + "\n ".join(fails))
|
|
168
|
+
sys.exit(1 if fails else 0)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cli():
|
|
172
|
+
if len(sys.argv) != 4:
|
|
173
|
+
print(__doc__)
|
|
174
|
+
sys.exit(2)
|
|
175
|
+
main(*sys.argv[1:4])
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
cli()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: northstar-ctx
|
|
3
|
+
Version: 3.4.0
|
|
4
|
+
Summary: Deterministic Word (.docx) tracked-change builder and verifier driven by a JSON edit plan.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: defusedxml>=0.7
|
|
10
|
+
Requires-Dist: python-docx>=1.1
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# northstar-ctx
|
|
14
|
+
|
|
15
|
+
Deterministic tracked-change builder and verifier for Word .docx files, driven by a JSON edit plan.
|
|
16
|
+
|
|
17
|
+
export CTX_AUTHOR="Your Name"
|
|
18
|
+
ctx-redline extract in.docx
|
|
19
|
+
ctx-redline apply in.docx plan.json --out out.docx
|
|
20
|
+
ctx-verify out.docx in.docx base.docx
|
|
21
|
+
|
|
22
|
+
Requires the docx helper scripts at $DOCX_SKILL (default /mnt/skills/public/docx/scripts).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
northstar_ctx/__init__.py
|
|
5
|
+
northstar_ctx/redline_builder.py
|
|
6
|
+
northstar_ctx/verify_redline.py
|
|
7
|
+
northstar_ctx.egg-info/PKG-INFO
|
|
8
|
+
northstar_ctx.egg-info/SOURCES.txt
|
|
9
|
+
northstar_ctx.egg-info/dependency_links.txt
|
|
10
|
+
northstar_ctx.egg-info/entry_points.txt
|
|
11
|
+
northstar_ctx.egg-info/requires.txt
|
|
12
|
+
northstar_ctx.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
northstar_ctx
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "northstar-ctx"
|
|
7
|
+
version = "3.4.0"
|
|
8
|
+
description = "Deterministic Word (.docx) tracked-change builder and verifier driven by a JSON edit plan."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
dependencies = ["defusedxml>=0.7", "python-docx>=1.1"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
ctx-redline = "northstar_ctx.redline_builder:main"
|
|
16
|
+
ctx-verify = "northstar_ctx.verify_redline:cli"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["northstar_ctx"]
|