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.
- doc_control/__init__.py +4 -0
- doc_control/__main__.py +3 -0
- doc_control/cli.py +80 -0
- doc_control/envelope.py +40 -0
- doc_control/errors.py +72 -0
- doc_control/model/__init__.py +1 -0
- doc_control/model/address.py +113 -0
- doc_control/model/align.py +529 -0
- doc_control/model/anchor.py +609 -0
- doc_control/model/annotations.py +280 -0
- doc_control/model/document.py +109 -0
- doc_control/model/format.py +109 -0
- doc_control/model/index.py +353 -0
- doc_control/model/layout.py +143 -0
- doc_control/model/normalized.py +160 -0
- doc_control/model/pages.py +403 -0
- doc_control/model/parts.py +50 -0
- doc_control/model/search.py +403 -0
- doc_control/model/segment.py +445 -0
- doc_control/model/structure.py +418 -0
- doc_control/model/styles.py +134 -0
- doc_control/ooxml/__init__.py +1 -0
- doc_control/ooxml/commentparts.py +679 -0
- doc_control/ooxml/formatting.py +181 -0
- doc_control/ooxml/resolve.py +269 -0
- doc_control/ooxml/revisions.py +1024 -0
- doc_control/ooxml/splice.py +957 -0
- doc_control/ooxml/styledef.py +504 -0
- doc_control/ooxml/zipio.py +454 -0
- doc_control/ops/__init__.py +1 -0
- doc_control/ops/anchor.py +135 -0
- doc_control/ops/batch.py +177 -0
- doc_control/ops/comments.py +433 -0
- doc_control/ops/compare.py +620 -0
- doc_control/ops/diff.py +177 -0
- doc_control/ops/dotx.py +206 -0
- doc_control/ops/find.py +190 -0
- doc_control/ops/history.py +479 -0
- doc_control/ops/layout.py +40 -0
- doc_control/ops/locate.py +364 -0
- doc_control/ops/meta.py +49 -0
- doc_control/ops/page.py +104 -0
- doc_control/ops/paging.py +124 -0
- doc_control/ops/read.py +517 -0
- doc_control/ops/recipe.py +108 -0
- doc_control/ops/rename.py +370 -0
- doc_control/ops/revision.py +616 -0
- doc_control/ops/segment.py +289 -0
- doc_control/ops/styleset.py +262 -0
- doc_control/ops/stylewrite.py +573 -0
- doc_control/ops/text.py +207 -0
- doc_control/ops/tracked.py +1441 -0
- doc_control/py.typed +0 -0
- doc_control/recipes/engine.py +209 -0
- doc_control/recipes/schema.py +140 -0
- doc_control/recipes/store.py +96 -0
- doc_control/registry.py +76 -0
- doc_control/safety/__init__.py +38 -0
- doc_control/safety/history.py +508 -0
- doc_control/safety/lock.py +69 -0
- doc_control/safety/store.py +415 -0
- doc_control/safety/verify.py +357 -0
- doc_control/schema.py +143 -0
- doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
- doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
- doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
- doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- 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
|