Files
aiturk-hermes-ide/skills/productivity/docx/scripts/docx_revisions.py
T

148 lines
4.5 KiB
Python

#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Inspect and resolve tracked changes (w:ins / w:del) in a .docx.
Subcommands:
list JSON list of revisions: id, author, date, type, text
accept-all accept every insertion and deletion
reject-all reject every insertion and deletion
accept accept one revision by --id
reject reject one revision by --id
Examples:
docx_revisions.py list report.docx
docx_revisions.py accept-all report.docx -o accepted.docx
docx_revisions.py reject report.docx --id 3 -o out.docx
Semantics (direct XML manipulation, python-docx oxml layer):
accept w:ins -> unwrap (keep inserted runs) reject w:ins -> remove
accept w:del -> remove reject w:del -> restore
(restore = w:delText tags renamed to w:t, wrapper unwrapped)
Covers run-level insertions/deletions anywhere in body, tables (nested
included), headers and footers. Row/paragraph-mark revisions and format
changes (w:rPrChange etc.) are reported by docx_read.py --revisions but
not resolved here.
"""
from __future__ import annotations
import argparse
import json
import sys
from docx import Document
from docx_common import iter_part_roots
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def q(tag: str) -> str:
return f"{{{W}}}{tag}"
INS, DEL = q("ins"), q("del")
def _iter_revision_elements(doc):
"""Yield every w:ins / w:del element across body, headers, footers."""
for root in iter_part_roots(doc):
for el in root.iter(INS, DEL):
yield el
def _rev_text(el) -> str:
tag = q("delText") if el.tag == DEL else q("t")
return "".join(t.text or "" for t in el.iter(tag))
def _rev_record(el) -> dict:
return {
"id": el.get(q("id")),
"author": el.get(q("author")),
"date": el.get(q("date")),
"type": "insertion" if el.tag == INS else "deletion",
"text": _rev_text(el),
}
def _unwrap(el) -> None:
"""Replace `el` with its children, keeping document order."""
parent = el.getparent()
idx = list(parent).index(el)
for child in list(el):
parent.insert(idx, child)
idx += 1
parent.remove(el)
def _apply(el, accept: bool) -> None:
if el.getparent() is None: # already detached via an outer wrapper
return
if el.tag == INS:
if accept:
_unwrap(el)
else:
el.getparent().remove(el)
else: # w:del
if accept:
el.getparent().remove(el)
else:
for dt in list(el.iter(q("delText"))):
dt.tag = q("t")
_unwrap(el)
def resolve(doc, accept: bool, rev_id: str | None = None) -> int:
targets = [el for el in _iter_revision_elements(doc)
if rev_id is None or el.get(q("id")) == rev_id]
for el in targets:
_apply(el, accept)
return len(targets)
def main() -> int:
ap = argparse.ArgumentParser(
description="List, accept, or reject tracked changes in a .docx.")
sub = ap.add_subparsers(dest="cmd", required=True)
def common(p, out=True):
p.add_argument("path", help="input .docx")
if out:
p.add_argument("-o", "--output",
help="output path (default: overwrite input)")
common(sub.add_parser("list", help="list revisions as JSON"), out=False)
common(sub.add_parser("accept-all", help="accept every revision"))
common(sub.add_parser("reject-all", help="reject every revision"))
for name in ("accept", "reject"):
p = sub.add_parser(name, help=f"{name} one revision by id")
common(p)
p.add_argument("--id", required=True, help="revision id (w:id)")
args = ap.parse_args()
doc = Document(args.path)
if args.cmd == "list":
revs = [_rev_record(el) for el in _iter_revision_elements(doc)]
print(json.dumps({"ok": True, "revisions": revs}, ensure_ascii=False))
return 0
accept = args.cmd in ("accept-all", "accept")
rev_id = getattr(args, "id", None)
n = resolve(doc, accept, rev_id)
if rev_id is not None and n == 0:
print(json.dumps({"ok": False,
"error": f"no revision with id {rev_id}"}))
return 1
out = args.output or args.path
doc.save(out)
print(json.dumps({"ok": True, "output": out, "resolved": n,
"action": "accept" if accept else "reject"},
ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())