doc-control 0.0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. doc_control/__init__.py +4 -0
  2. doc_control/__main__.py +3 -0
  3. doc_control/cli.py +80 -0
  4. doc_control/envelope.py +40 -0
  5. doc_control/errors.py +72 -0
  6. doc_control/model/__init__.py +1 -0
  7. doc_control/model/address.py +113 -0
  8. doc_control/model/align.py +529 -0
  9. doc_control/model/anchor.py +609 -0
  10. doc_control/model/annotations.py +280 -0
  11. doc_control/model/document.py +109 -0
  12. doc_control/model/format.py +109 -0
  13. doc_control/model/index.py +353 -0
  14. doc_control/model/layout.py +143 -0
  15. doc_control/model/normalized.py +160 -0
  16. doc_control/model/pages.py +403 -0
  17. doc_control/model/parts.py +50 -0
  18. doc_control/model/search.py +403 -0
  19. doc_control/model/segment.py +445 -0
  20. doc_control/model/structure.py +418 -0
  21. doc_control/model/styles.py +134 -0
  22. doc_control/ooxml/__init__.py +1 -0
  23. doc_control/ooxml/commentparts.py +679 -0
  24. doc_control/ooxml/formatting.py +181 -0
  25. doc_control/ooxml/resolve.py +269 -0
  26. doc_control/ooxml/revisions.py +1024 -0
  27. doc_control/ooxml/splice.py +957 -0
  28. doc_control/ooxml/styledef.py +504 -0
  29. doc_control/ooxml/zipio.py +454 -0
  30. doc_control/ops/__init__.py +1 -0
  31. doc_control/ops/anchor.py +135 -0
  32. doc_control/ops/batch.py +177 -0
  33. doc_control/ops/comments.py +433 -0
  34. doc_control/ops/compare.py +620 -0
  35. doc_control/ops/diff.py +177 -0
  36. doc_control/ops/dotx.py +206 -0
  37. doc_control/ops/find.py +190 -0
  38. doc_control/ops/history.py +479 -0
  39. doc_control/ops/layout.py +40 -0
  40. doc_control/ops/locate.py +364 -0
  41. doc_control/ops/meta.py +49 -0
  42. doc_control/ops/page.py +104 -0
  43. doc_control/ops/paging.py +124 -0
  44. doc_control/ops/read.py +517 -0
  45. doc_control/ops/recipe.py +108 -0
  46. doc_control/ops/rename.py +370 -0
  47. doc_control/ops/revision.py +616 -0
  48. doc_control/ops/segment.py +289 -0
  49. doc_control/ops/styleset.py +262 -0
  50. doc_control/ops/stylewrite.py +573 -0
  51. doc_control/ops/text.py +207 -0
  52. doc_control/ops/tracked.py +1441 -0
  53. doc_control/py.typed +0 -0
  54. doc_control/recipes/engine.py +209 -0
  55. doc_control/recipes/schema.py +140 -0
  56. doc_control/recipes/store.py +96 -0
  57. doc_control/registry.py +76 -0
  58. doc_control/safety/__init__.py +38 -0
  59. doc_control/safety/history.py +508 -0
  60. doc_control/safety/lock.py +69 -0
  61. doc_control/safety/store.py +415 -0
  62. doc_control/safety/verify.py +357 -0
  63. doc_control/schema.py +143 -0
  64. doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
  65. doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
  66. doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
  67. doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  68. doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,616 @@
1
+ """``revision.accept`` and ``revision.reject``: resolve tracked changes in one verified write.
2
+
3
+ Both are batch operations (``batch`` with ``{"op": "revision.accept", ...}``); they work on
4
+ revisions the tool wrote and on revisions the document already had, whoever the author.
5
+
6
+ Selection (at least one; they narrow each other)
7
+ ``ids`` revision ids as ``revisions.list`` reports them;
8
+ ``author`` the exact ``w:author`` value (``Claude (tag)`` for a tagged call);
9
+ ``at`` a paragraph address ``p3#fp`` or a run ``p3#fp..p9#fp`` (inclusive);
10
+ ``all`` true: every revision.
11
+
12
+ What happens is described in :mod:`doc_control.ooxml.resolve`. In short: an insertion or
13
+ move-in is kept (accept) or removed (reject), a deletion or move-away the other way
14
+ round; a formatting change keeps the new (accept) or restores the recorded (reject)
15
+ properties; a paragraph mark that goes away joins the paragraph with the next one in
16
+ its container (the next paragraph's properties win, as in Word). A move is two
17
+ revisions, ``move_from`` and ``move_to``: select both. The last paragraph of a cell or
18
+ of the body, and a paragraph that ends a section, cannot lose its mark: it stays,
19
+ and the response says so in ``warnings``.
20
+
21
+ Several operations in one batch are all resolved against the document as read; the
22
+ same revision accepted by one and rejected by another is a ``BATCH_CONFLICT``. They
23
+ cannot be combined with text edits (``BATCH_CONFLICT``): addresses would shift.
24
+
25
+ Table row and cell revisions (``w:trPr``/``w:tcPr``) and revisions inside text boxes
26
+ are not resolved; selecting them by id is ``UNSUPPORTED_CONTENT``, and with other
27
+ selectors they are left as they are and a warning names the fact.
28
+
29
+ Before the write the result is checked: no revision appears that was not there, the
30
+ selected ones are gone, the paragraph count is right, accepting insertions and
31
+ deletions leaves every paragraph's current text unchanged (merged as the marks say) and
32
+ no field is left open. A failed check raises ``INTERNAL`` and nothing is written.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ import xml.etree.ElementTree as ET
39
+ from collections import Counter
40
+ from collections.abc import Callable, Mapping, Sequence
41
+ from dataclasses import dataclass, field, replace
42
+ from datetime import datetime
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ from doc_control.errors import DocControlError, ErrorCode
47
+ from doc_control.model.index import DocumentIndex, Paragraph, build_index
48
+ from doc_control.ooxml import resolve as xml
49
+ from doc_control.ooxml.revisions import PPR
50
+ from doc_control.ooxml.zipio import Package
51
+ from doc_control.ops.tracked import next_in_container
52
+ from doc_control.safety.history import has_live_text, record, to_text
53
+ from doc_control.safety.lock import check_changed, check_unlocked
54
+ from doc_control.safety.verify import DOCUMENT_PART, Footprint, write_verified
55
+ from doc_control.schema import validate
56
+
57
+ __all__ = [
58
+ "OPERATION_SCHEMAS",
59
+ "PreparedResolution",
60
+ "ResolutionResult",
61
+ "ResolveRevisions",
62
+ "apply_resolutions",
63
+ "prepare_resolutions",
64
+ "resolution_from_dict",
65
+ ]
66
+
67
+ LISTED = 100 # revisions named one by one in a response
68
+ _REVISION_TAG = re.compile(rb"<(?:[\w.-]+:)?(?:ins|del|moveFrom|moveTo|rPrChange|pPrChange)[\s/>]")
69
+ _TABLE_REVISION = re.compile(
70
+ rb"<(?:[\w.-]+:)?(?:cellIns|cellDel|cellMerge)[\s/>]"
71
+ rb"|<(?:[\w.-]+:)?trPr[\s>](?:(?!</(?:[\w.-]+:)?trPr>).)*?<(?:[\w.-]+:)?(?:ins|del)[\s/>]",
72
+ re.DOTALL,
73
+ )
74
+ _MOVE_RANGE = re.compile(rb"<(?:[\w.-]+:)?move(?:From|To)Range(?:Start|End)[\s/>]")
75
+ _FIELD_CHAR = re.compile(rb"fldCharType\s*=\s*[\"'](begin|end)[\"']")
76
+ _MARK_KINDS = frozenset({"mark_insert", "mark_delete", "mark_move_from", "mark_move_to"})
77
+ _TEXT_NEUTRAL = frozenset(
78
+ {"insert", "delete", "format", "mark_insert", "mark_delete", "mark_format", "paragraph_format"}
79
+ )
80
+
81
+
82
+ def _schema(action: str, what: str) -> dict[str, Any]:
83
+ return {
84
+ "type": "object",
85
+ "properties": {
86
+ "op": {"enum": [f"revision.{action}"]},
87
+ "ids": {
88
+ "type": "array",
89
+ "items": {"type": ["string", "integer"]},
90
+ "description": f"{what} the revisions with these ids (from revisions.list).",
91
+ },
92
+ "author": {"type": "string", "minLength": 1, "description": "Exact revision author."},
93
+ "at": {
94
+ "type": "string",
95
+ "minLength": 1,
96
+ "description": "Paragraph p3#fp or run p3#fp..p9#fp.",
97
+ },
98
+ "all": {"type": "boolean", "description": "true: every revision in the document."},
99
+ },
100
+ "required": ["op"],
101
+ "additionalProperties": False,
102
+ }
103
+
104
+
105
+ OPERATION_SCHEMAS: dict[str, dict[str, Any]] = {
106
+ "revision.accept": _schema("accept", "Accept"),
107
+ "revision.reject": _schema("reject", "Reject"),
108
+ }
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class ResolveRevisions:
113
+ action: str # "accept" | "reject"
114
+ ids: tuple[str, ...] | None = None
115
+ author: str | None = None
116
+ at: str | None = None
117
+ all: bool = False
118
+
119
+
120
+ def resolution_from_dict(raw: Mapping[str, Any]) -> ResolveRevisions:
121
+ """Validate a ``revision.accept``/``revision.reject`` object and build the operation."""
122
+ op = raw.get("op")
123
+ if not isinstance(op, str) or op not in OPERATION_SCHEMAS:
124
+ raise DocControlError(
125
+ ErrorCode.SCHEMA_INVALID,
126
+ f"unknown revision operation: {op!r}",
127
+ expected=sorted(OPERATION_SCHEMAS),
128
+ )
129
+ validate(dict(raw), OPERATION_SCHEMAS[op])
130
+ ids = raw.get("ids")
131
+ if ids is not None and not ids:
132
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, f"{op}: 'ids' must not be empty")
133
+ everything = raw.get("all") is True
134
+ if ids is None and raw.get("author") is None and raw.get("at") is None and not everything:
135
+ raise DocControlError(
136
+ ErrorCode.SCHEMA_INVALID,
137
+ f"{op} needs a selection: 'ids', 'author', 'at' or 'all': true",
138
+ )
139
+ return ResolveRevisions(
140
+ op.removeprefix("revision."),
141
+ tuple(str(i).strip() for i in ids) if ids is not None else None,
142
+ raw.get("author"),
143
+ raw.get("at"),
144
+ everything,
145
+ )
146
+
147
+
148
+ # -- results ------------------------------------------------------------------------
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class ResolutionResult:
153
+ applied: int
154
+ dry_run: bool
155
+ preview: list[dict[str, Any]]
156
+ revisions: list[dict[str, str]]
157
+ accepted: int
158
+ rejected: int
159
+ merged_paragraphs: int
160
+ address_map: dict[str, str]
161
+ footprint: list[str]
162
+ before_sha256: str
163
+ after_sha256: str | None = None
164
+ backup: Path | None = None
165
+ warnings: list[str] = field(default_factory=list)
166
+ op_id: str | None = None
167
+
168
+ def to_dict(self) -> dict[str, Any]:
169
+ out: dict[str, Any] = {
170
+ "applied": self.applied,
171
+ "dry_run": self.dry_run,
172
+ "preview": self.preview,
173
+ "accepted": self.accepted,
174
+ "rejected": self.rejected,
175
+ "revisions": self.revisions,
176
+ "merged_paragraphs": self.merged_paragraphs,
177
+ "address_map": self.address_map,
178
+ "footprint": {DOCUMENT_PART: self.footprint},
179
+ }
180
+ if self.accepted + self.rejected > len(self.revisions):
181
+ out["revisions_omitted"] = self.accepted + self.rejected - len(self.revisions)
182
+ if self.warnings:
183
+ out["warnings"] = self.warnings
184
+ if not self.dry_run:
185
+ out["sha256"] = self.after_sha256
186
+ out["backup"] = str(self.backup) if self.backup else None
187
+ if self.op_id:
188
+ out["op_id"] = self.op_id
189
+ return out
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class PreparedResolution:
194
+ """The resolved ``word/document.xml`` of a package, checked but not written."""
195
+
196
+ pkg: Package
197
+ new_data: bytes | None # None: nothing to change
198
+ footprint: Footprint
199
+ result: ResolutionResult
200
+ changes: list[dict[str, Any]] = field(default_factory=list)
201
+ """The undo-log record: each rewritten paragraph's bytes before and after."""
202
+ joined: bool = False # paragraphs were merged; the log cannot restore them
203
+
204
+
205
+ # -- selection --------------------------------------------------------------------------
206
+
207
+
208
+ @dataclass(eq=False)
209
+ class _Parsed:
210
+ para: Paragraph
211
+ elem: ET.Element
212
+ revs: list[xml.Revision]
213
+
214
+
215
+ def _parse_revisions(index: DocumentIndex) -> list[_Parsed]:
216
+ spans = index.spans
217
+ out: list[_Parsed] = []
218
+ for para in index.paragraphs:
219
+ if not _REVISION_TAG.search(spans.paragraph_bytes(para.span_index)):
220
+ continue
221
+ elem = spans.parse_paragraph(para.span_index)
222
+ revs = xml.scan(elem)
223
+ if revs:
224
+ out.append(_Parsed(para, elem, revs))
225
+ return out
226
+
227
+
228
+ def _range(index: DocumentIndex, at: str) -> tuple[int, int]:
229
+ first, sep, last = at.partition("..")
230
+ a = index.resolve_address(first)
231
+ if not sep:
232
+ return a.number, a.number
233
+ b = index.resolve_address(last)
234
+ if b.number < a.number:
235
+ raise DocControlError(
236
+ ErrorCode.ADDRESS_INVALID, f"{at!r} ends before it starts", address=at
237
+ )
238
+ return a.number, b.number
239
+
240
+
241
+ def _table_revisions(data: bytes) -> bool:
242
+ return _TABLE_REVISION.search(data) is not None
243
+
244
+
245
+ def _select(
246
+ op: ResolveRevisions, index: DocumentIndex, parsed: Sequence[_Parsed], table: bool
247
+ ) -> list[tuple[_Parsed, xml.Revision]]:
248
+ low, high = _range(index, op.at) if op.at is not None else (0, len(index))
249
+ if op.ids is not None:
250
+ known = {r.id for p in parsed for r in p.revs}
251
+ for missing in [i for i in op.ids if i not in known]:
252
+ if table:
253
+ raise DocControlError(
254
+ ErrorCode.UNSUPPORTED_CONTENT,
255
+ f"no paragraph revision has id {missing}; table row and cell revisions "
256
+ "are not resolved by this operation",
257
+ revision=missing,
258
+ )
259
+ raise DocControlError(
260
+ ErrorCode.STALE_ADDRESS,
261
+ f"the document has no revision with id {missing}",
262
+ revision=missing,
263
+ )
264
+ wanted = set(op.ids) if op.ids is not None else None
265
+ return [
266
+ (p, r)
267
+ for p in parsed
268
+ if low <= p.para.number <= high
269
+ for r in p.revs
270
+ if (wanted is None or r.id in wanted) and (op.author is None or r.author == op.author)
271
+ ]
272
+
273
+
274
+ # -- preparing ------------------------------------------------------------------------------
275
+
276
+
277
+ def _fields_balanced(data: bytes) -> bool:
278
+ kinds = [m.group(1) for m in _FIELD_CHAR.finditer(data)]
279
+ return kinds.count(b"begin") == kinds.count(b"end")
280
+
281
+
282
+ def _with_content(elem: ET.Element, content: list[ET.Element]) -> None:
283
+ """Put ``content`` first among the paragraph's content (after its properties)."""
284
+ at = 1 if len(elem) and elem[0].tag == PPR else 0
285
+ elem[at:at] = content
286
+
287
+
288
+ def prepare_resolutions(
289
+ pkg: Package,
290
+ ops: Sequence[ResolveRevisions],
291
+ *,
292
+ dry_run: bool = False,
293
+ index: DocumentIndex | None = None,
294
+ ) -> PreparedResolution:
295
+ """Plan and build ``ops`` on ``pkg`` without writing: the part of
296
+ :func:`apply_resolutions` that works on bytes, self-checked (see the module docstring)."""
297
+ if not ops:
298
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, "no operations given")
299
+ if index is None:
300
+ index = DocumentIndex.from_package(pkg)
301
+ spans = index.spans
302
+ parsed = _parse_revisions(index)
303
+ table = _table_revisions(spans.data)
304
+
305
+ chosen: dict[tuple[int, int], tuple[str, int]] = {} # (paragraph, position) -> (action, op)
306
+ preview: list[dict[str, Any]] = []
307
+ where = {id(r): (p, k) for p in parsed for k, r in enumerate(p.revs)}
308
+ for i, op in enumerate(ops):
309
+ picked = _select(op, index, parsed, table)
310
+ for p, r in picked:
311
+ key = (p.para.number, where[id(r)][1])
312
+ old = chosen.get(key)
313
+ if old is not None and old[0] != op.action:
314
+ raise DocControlError(
315
+ ErrorCode.BATCH_CONFLICT,
316
+ f"edits {old[1]} and {i} accept and reject the same revision "
317
+ f"({r.kind} {r.id}) in {p.para.address}",
318
+ paragraph=str(p.para.address),
319
+ revision=r.id,
320
+ edits=sorted((old[1], i)),
321
+ )
322
+ chosen.setdefault(key, (op.action, i))
323
+ preview.append({"op": i, "action": op.action, "revisions": len(picked)})
324
+
325
+ warnings: list[str] = []
326
+ if table:
327
+ warnings.append(
328
+ "table row and cell revisions are not resolved by this operation; they stay as they are"
329
+ )
330
+
331
+ # Decide, paragraph by paragraph.
332
+ removed_marks: set[int] = set() # paragraph numbers whose mark a decision removes
333
+ touched: dict[int, _Parsed] = {}
334
+ for p in parsed:
335
+ actions = {
336
+ id(r.elem): chosen[(p.para.number, k)][0]
337
+ for k, r in enumerate(p.revs)
338
+ if (p.para.number, k) in chosen
339
+ }
340
+ if not actions:
341
+ continue
342
+ if xml.resolve_paragraph(p.elem, actions):
343
+ removed_marks.add(p.para.number)
344
+ touched[p.para.number] = p
345
+
346
+ elems: dict[int, ET.Element] = {n: p.elem for n, p in touched.items()}
347
+
348
+ # Move range markers whose side has no revision left mean nothing: drop them.
349
+ left = {
350
+ "from" if r.kind.endswith("move_from") else "to"
351
+ for p in parsed
352
+ for k, r in enumerate(p.revs)
353
+ if r.kind.endswith(("move_from", "move_to")) and (p.para.number, k) not in chosen
354
+ }
355
+ cleared = {"from", "to"} - left
356
+ if cleared:
357
+ for para in index.paragraphs:
358
+ raw = spans.paragraph_bytes(para.span_index)
359
+ if _MOVE_RANGE.search(raw):
360
+ elem = elems.get(para.number)
361
+ if elem is None:
362
+ elem = spans.parse_paragraph(para.span_index)
363
+ if xml.drop_move_markers(elem, cleared):
364
+ elems[para.number] = elem
365
+
366
+ # Join paragraphs whose mark went away with the one after them.
367
+ carry: dict[int, list[ET.Element]] = {}
368
+ gone: set[int] = set() # paragraph numbers merged into the next
369
+ for number in sorted(removed_marks):
370
+ para = index.paragraph(number)
371
+ elem = elems[number]
372
+ nxt = next_in_container(index, para)
373
+ inherited = carry.pop(number, [])
374
+ content = inherited + [c for c in elem if c.tag != PPR]
375
+ if nxt is None or elem.find(f"{PPR}/{xml.SECT_PR}") is not None:
376
+ _with_content(elem, inherited) # nothing to join with: keep the mark
377
+ warnings.append(
378
+ f"{para.address} keeps its paragraph mark: it ends a container or a section"
379
+ )
380
+ continue
381
+ if nxt.number not in elems:
382
+ elems[nxt.number] = spans.parse_paragraph(nxt.span_index)
383
+ carry.setdefault(nxt.number, []).extend(content)
384
+ gone.add(number)
385
+ for number, content in carry.items():
386
+ _with_content(elems[number], content)
387
+
388
+ replacements: dict[int, bytes] = {}
389
+ deletes: set[int] = set()
390
+ for number, elem in elems.items():
391
+ span = index.paragraph(number).span_index
392
+ if number in gone:
393
+ deletes.add(span)
394
+ else:
395
+ replacements[span] = spans.serialize(elem, span)
396
+
397
+ footprint = Footprint(replaced=frozenset(replacements) | frozenset(deletes))
398
+ shown: list[dict[str, str]] = []
399
+ counts = Counter(action for action, _ in chosen.values())
400
+ for p in parsed:
401
+ for k, r in enumerate(p.revs):
402
+ if (p.para.number, k) in chosen and len(shown) < LISTED:
403
+ shown.append(
404
+ {
405
+ "id": r.id,
406
+ "type": r.kind,
407
+ "author": r.author,
408
+ "at": str(p.para.address),
409
+ "action": chosen[(p.para.number, k)][0],
410
+ }
411
+ )
412
+ new_data: bytes | None = None
413
+ address_map: dict[str, str] = {}
414
+ foot = [f"p{n}" for n in sorted(elems)]
415
+ if elems:
416
+ new_data = spans.splice(replacements, delete=deletes)
417
+ new_index = _self_check(pkg, index, new_data, parsed, chosen, gone, touched)
418
+ address_map = _address_map(index, new_index, gone)
419
+ foot = [f"p{index.paragraph(n).number}" for n in sorted(elems)]
420
+ result = ResolutionResult(
421
+ applied=len(ops),
422
+ dry_run=dry_run,
423
+ preview=preview,
424
+ revisions=shown,
425
+ accepted=counts["accept"],
426
+ rejected=counts["reject"],
427
+ merged_paragraphs=len(gone),
428
+ address_map=address_map,
429
+ footprint=foot,
430
+ before_sha256=pkg.sha256,
431
+ warnings=warnings,
432
+ )
433
+ changes = [
434
+ {
435
+ "part": DOCUMENT_PART,
436
+ "kind": "replace",
437
+ "index": span,
438
+ "before": to_text(spans.paragraph_bytes(span)),
439
+ "after": to_text(data),
440
+ "text_before": next(p.text for p in index.paragraphs if p.span_index == span),
441
+ "deleted": not has_live_text(data),
442
+ }
443
+ for span, data in sorted(replacements.items())
444
+ ]
445
+ return PreparedResolution(pkg, new_data, footprint, result, changes, bool(gone))
446
+
447
+
448
+ def _address_map(old: DocumentIndex, new: DocumentIndex, gone: set[int]) -> dict[str, str]:
449
+ """Old to new address of every paragraph whose number changed (a joined one: where it went)."""
450
+ out: dict[str, str] = {}
451
+ number = 0
452
+ pending: list[Paragraph] = []
453
+ for para in old.paragraphs:
454
+ if para.number in gone:
455
+ pending.append(para)
456
+ continue
457
+ number += 1
458
+ target = str(new.paragraph(number).address)
459
+ for p in [*pending, para]:
460
+ if str(p.address) != target and (p.number != number or p in pending):
461
+ out[str(p.address)] = target
462
+ pending = []
463
+ return out
464
+
465
+
466
+ def _self_check(
467
+ pkg: Package,
468
+ old: DocumentIndex,
469
+ new_data: bytes,
470
+ parsed: Sequence[_Parsed],
471
+ chosen: Mapping[tuple[int, int], tuple[str, int]],
472
+ gone: set[int],
473
+ touched: Mapping[int, _Parsed],
474
+ ) -> DocumentIndex:
475
+ new = build_index(new_data)
476
+ if len(new) != len(old) - len(gone):
477
+ raise DocControlError(
478
+ ErrorCode.INTERNAL, "self-check: the resolved document has the wrong paragraph count"
479
+ )
480
+ before = Counter((r.id, r.kind, r.author) for p in parsed for r in p.revs)
481
+ picked = Counter(
482
+ (r.id, r.kind, r.author)
483
+ for p in parsed
484
+ for k, r in enumerate(p.revs)
485
+ if (p.para.number, k) in chosen
486
+ )
487
+ after: Counter[tuple[str, str, str]] = Counter()
488
+ for _, elem in _revision_paragraphs(new):
489
+ after.update((r.id, r.kind, r.author) for r in xml.scan(elem))
490
+ for key, n in after.items():
491
+ if n > max(0, before[key] - picked[key]):
492
+ raise DocControlError(
493
+ ErrorCode.INTERNAL, "self-check: a selected revision is still in the document"
494
+ )
495
+ old_data = old.spans.data
496
+ if _fields_balanced(old_data) and not _fields_balanced(new_data):
497
+ raise DocControlError(
498
+ ErrorCode.UNSUPPORTED_CONTENT,
499
+ "resolving these revisions would leave a field open: a deletion cuts through one",
500
+ )
501
+ kinds = {r.kind for p in parsed for k, r in enumerate(p.revs) if (p.para.number, k) in chosen}
502
+ actions = {a for a, _ in chosen.values()}
503
+ if actions == {"accept"} and kinds <= _TEXT_NEUTRAL:
504
+ expected = _joined(
505
+ [p.text for p in old.paragraphs], [p.number in gone for p in old.paragraphs]
506
+ )
507
+ if expected != [p.text for p in new.paragraphs]:
508
+ raise DocControlError(
509
+ ErrorCode.INTERNAL, "self-check: accepting changed the current text"
510
+ )
511
+ return new
512
+
513
+
514
+ def _revision_paragraphs(index: DocumentIndex) -> list[tuple[Paragraph, ET.Element]]:
515
+ out: list[tuple[Paragraph, ET.Element]] = []
516
+ for para in index.paragraphs:
517
+ if _REVISION_TAG.search(index.spans.paragraph_bytes(para.span_index)):
518
+ out.append((para, index.spans.parse_paragraph(para.span_index)))
519
+ return out
520
+
521
+
522
+ def _joined(texts: Sequence[str], joins: Sequence[bool]) -> list[str]:
523
+ out: list[str] = []
524
+ carry = ""
525
+ for text, join in zip(texts, joins, strict=True):
526
+ text = carry + text
527
+ if join:
528
+ carry = text
529
+ else:
530
+ out.append(text)
531
+ carry = ""
532
+ if carry:
533
+ out.append(carry)
534
+ return out
535
+
536
+
537
+ # -- applying -----------------------------------------------------------------------------------
538
+
539
+
540
+ def apply_resolutions(
541
+ doc_path: Path,
542
+ ops: Sequence[ResolveRevisions],
543
+ *,
544
+ expect_sha256: str | None = None,
545
+ now: datetime | None = None,
546
+ dry_run: bool = False,
547
+ env: Mapping[str, str] | None = None,
548
+ stage: Callable[[str], None] | None = None,
549
+ ) -> ResolutionResult:
550
+ """Resolve revisions in ``doc_path`` in one verified write.
551
+
552
+ Raises the errors of :func:`doc_control.ops.tracked.apply_edits` before touching the
553
+ file. When the selection matches nothing the file is not written.
554
+ """
555
+ if not ops:
556
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, "no operations given")
557
+ if not doc_path.is_file():
558
+ raise DocControlError(
559
+ ErrorCode.DOC_NOT_FOUND, f"no such file: {doc_path}", path=str(doc_path)
560
+ )
561
+ if not dry_run:
562
+ check_unlocked(doc_path)
563
+ check_changed(doc_path, expect_sha256)
564
+ pkg = Package(doc_path)
565
+ prepared = prepare_resolutions(pkg, ops, dry_run=dry_run)
566
+ result = prepared.result
567
+ if dry_run or prepared.new_data is None:
568
+ return result
569
+ written = write_verified(
570
+ doc_path,
571
+ {DOCUMENT_PART: prepared.new_data},
572
+ prepared.footprint,
573
+ pkg.sha256,
574
+ now=now,
575
+ env=env,
576
+ stage=stage,
577
+ )
578
+ warnings = list(result.warnings)
579
+ op_id: str | None = None
580
+ try:
581
+ op_id = record(
582
+ doc_path,
583
+ "batch",
584
+ before_sha256=written.before_sha256,
585
+ after_sha256=written.after_sha256,
586
+ backup=written.backup,
587
+ changes=prepared.changes,
588
+ request=[_as_request(op) for op in ops],
589
+ footprint={DOCUMENT_PART: result.footprint},
590
+ # Joined paragraphs are not logged, so `undo` refuses this entry (safety.history).
591
+ extra={"parts": ["joined paragraphs"]} if prepared.joined else None,
592
+ now=now,
593
+ env=env,
594
+ )
595
+ except (DocControlError, OSError) as exc:
596
+ warnings.append(f"HISTORY_NOT_RECORDED: the write succeeded but could not be logged: {exc}")
597
+ return replace(
598
+ result,
599
+ before_sha256=written.before_sha256,
600
+ after_sha256=written.after_sha256,
601
+ backup=written.backup,
602
+ warnings=warnings,
603
+ op_id=op_id,
604
+ )
605
+
606
+
607
+ def _as_request(op: ResolveRevisions) -> dict[str, Any]:
608
+ out: dict[str, Any] = {"op": f"revision.{op.action}"}
609
+ if op.ids is not None:
610
+ out["ids"] = list(op.ids)
611
+ for key in ("author", "at"):
612
+ if getattr(op, key) is not None:
613
+ out[key] = getattr(op, key)
614
+ if op.all:
615
+ out["all"] = True
616
+ return out