scripts/docx_revisions.py
scripts/docx_revisions.pyBrowse 12 files
1,172 tokens
4,571 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32# MIT License. Part of the Hermes docx skill.3"""Inspect and resolve tracked changes (w:ins / w:del) in a .docx.4 5Subcommands:6 list JSON list of revisions: id, author, date, type, text7 accept-all accept every insertion and deletion8 reject-all reject every insertion and deletion9 accept accept one revision by --id10 reject reject one revision by --id11 12Examples:13 docx_revisions.py list report.docx14 docx_revisions.py accept-all report.docx -o accepted.docx15 docx_revisions.py reject report.docx --id 3 -o out.docx16 17Semantics (direct XML manipulation, python-docx oxml layer):18 accept w:ins -> unwrap (keep inserted runs) reject w:ins -> remove19 accept w:del -> remove reject w:del -> restore20 (restore = w:delText tags renamed to w:t, wrapper unwrapped)21 22Covers run-level insertions/deletions anywhere in body, tables (nested23included), headers and footers. Row/paragraph-mark revisions and format24changes (w:rPrChange etc.) are reported by docx_read.py --revisions but25not resolved here.26"""27from __future__ import annotations28 29import argparse30import json31import sys32 33from docx import Document34 35from docx_common import iter_part_roots36 37W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"38 39 40def q(tag: str) -> str:41 return f"{{{W}}}{tag}"42 43 44INS, DEL = q("ins"), q("del")45 46 47def _iter_revision_elements(doc):48 """Yield every w:ins / w:del element across body, headers, footers."""49 for root in iter_part_roots(doc):50 for el in root.iter(INS, DEL):51 yield el52 53 54def _rev_text(el) -> str:55 tag = q("delText") if el.tag == DEL else q("t")56 return "".join(t.text or "" for t in el.iter(tag))57 58 59def _rev_record(el) -> dict:60 return {61 "id": el.get(q("id")),62 "author": el.get(q("author")),63 "date": el.get(q("date")),64 "type": "insertion" if el.tag == INS else "deletion",65 "text": _rev_text(el),66 }67 68 69def _unwrap(el) -> None:70 """Replace `el` with its children, keeping document order."""71 parent = el.getparent()72 idx = list(parent).index(el)73 for child in list(el):74 parent.insert(idx, child)75 idx += 176 parent.remove(el)77 78 79def _apply(el, accept: bool) -> None:80 if el.getparent() is None: # already detached via an outer wrapper81 return82 if el.tag == INS:83 if accept:84 _unwrap(el)85 else:86 el.getparent().remove(el)87 else: # w:del88 if accept:89 el.getparent().remove(el)90 else:91 for dt in list(el.iter(q("delText"))):92 dt.tag = q("t")93 _unwrap(el)94 95 96def resolve(doc, accept: bool, rev_id: str | None = None) -> int:97 targets = [el for el in _iter_revision_elements(doc)98 if rev_id is None or el.get(q("id")) == rev_id]99 for el in targets:100 _apply(el, accept)101 return len(targets)102 103 104def main() -> int:105 ap = argparse.ArgumentParser(106 description="List, accept, or reject tracked changes in a .docx.")107 sub = ap.add_subparsers(dest="cmd", required=True)108 109 def common(p, out=True):110 p.add_argument("path", help="input .docx")111 if out:112 p.add_argument("-o", "--output",113 help="output path (default: overwrite input)")114 115 common(sub.add_parser("list", help="list revisions as JSON"), out=False)116 common(sub.add_parser("accept-all", help="accept every revision"))117 common(sub.add_parser("reject-all", help="reject every revision"))118 for name in ("accept", "reject"):119 p = sub.add_parser(name, help=f"{name} one revision by id")120 common(p)121 p.add_argument("--id", required=True, help="revision id (w:id)")122 123 args = ap.parse_args()124 doc = Document(args.path)125 126 if args.cmd == "list":127 revs = [_rev_record(el) for el in _iter_revision_elements(doc)]128 print(json.dumps({"ok": True, "revisions": revs}, ensure_ascii=False))129 return 0130 131 accept = args.cmd in ("accept-all", "accept")132 rev_id = getattr(args, "id", None)133 n = resolve(doc, accept, rev_id)134 if rev_id is not None and n == 0:135 print(json.dumps({"ok": False,136 "error": f"no revision with id {rev_id}"}))137 return 1138 out = args.output or args.path139 doc.save(out)140 print(json.dumps({"ok": True, "output": out, "resolved": n,141 "action": "accept" if accept else "reject"},142 ensure_ascii=False))143 return 0144 145 146if __name__ == "__main__":147 sys.exit(main())148