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,177 @@
1
+ """The ``batch`` write operation: all-or-nothing tracked edits in one verified write.
2
+
3
+ A request lists ``ops`` (``text.replace``, ``para.insert``, ``para.delete``,
4
+ ``para.move``, ``style.apply``, ``format.apply``, ``revision.accept``, ``revision.reject``;
5
+ ``comment.add``, ``comment.reply``, ``comment.resolve``; their schemas are in
6
+ :data:`ALL_SCHEMAS`). Every address refers to
7
+ the document as it was read. Before anything is written each operation is validated:
8
+ addresses must resolve and still match their fingerprint (``STALE_ADDRESS``), ranges
9
+ must not overlap (``BATCH_CONFLICT``, naming both operation indexes in ``edits``) and
10
+ ``expect.sha256`` must match the file (``DOC_CHANGED_ON_DISK``). Any failure leaves the
11
+ document untouched. ``dry_run`` does everything but the write and returns the same
12
+ response with a per-operation preview. The result carries ``address_map`` (old to new
13
+ address of every paragraph whose index changed) and, with the envelope's ``doc``, the
14
+ new sha256.
15
+
16
+ The text operation kinds are batch operations only; they are not registered as
17
+ standalone writes. The style kinds (``style.create``, ``style.update``, ``style.rename``,
18
+ ``style.merge``, see :mod:`doc_control.ops.stylewrite`) are batch kinds too, but a batch
19
+ holds either text kinds or style kinds, never both: style definition changes are not
20
+ tracked changes, so a style batch is one verified write of ``word/styles.xml`` (and the
21
+ references to renamed or merged styles) with the same dry run, ``expect`` and backup.
22
+ ``revision.accept`` and ``revision.reject`` (see :mod:`doc_control.ops.revision`) are batch
23
+ kinds that resolve revisions; they cannot share a batch with other kinds.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from doc_control.envelope import Reply
32
+ from doc_control.errors import DocControlError, ErrorCode
33
+ from doc_control.model.document import Document
34
+ from doc_control.ooxml.revisions import DEFAULT_AUTHOR
35
+ from doc_control.ops.comments import (
36
+ COMMENT_SCHEMAS,
37
+ apply_batch,
38
+ comment_from_dict,
39
+ is_comment_edit,
40
+ )
41
+ from doc_control.ops.revision import (
42
+ OPERATION_SCHEMAS as REVISION_SCHEMAS,
43
+ )
44
+ from doc_control.ops.revision import apply_resolutions, resolution_from_dict
45
+ from doc_control.ops.stylewrite import STYLE_KINDS, apply_style_ops, op_schema
46
+ from doc_control.ops.tracked import OPERATION_SCHEMAS, apply_edits, edit_from_dict
47
+ from doc_control.registry import operation
48
+
49
+ ALL_SCHEMAS = {
50
+ **OPERATION_SCHEMAS,
51
+ **COMMENT_SCHEMAS,
52
+ **{k: op_schema(k) for k in STYLE_KINDS},
53
+ **REVISION_SCHEMAS,
54
+ }
55
+ BATCH_KINDS = tuple(ALL_SCHEMAS)
56
+
57
+ _SCHEMA: dict[str, Any] = {
58
+ "type": "object",
59
+ "properties": {
60
+ "doc": {"type": "string", "minLength": 1, "description": "Path to the .docx."},
61
+ "ops": {
62
+ "type": "array",
63
+ "description": (
64
+ "Operations, applied together. Every address refers to the document as read; an "
65
+ "operation placed relative to a paragraph another operation deletes or moves "
66
+ "is a BATCH_CONFLICT. Kinds: "
67
+ + "; ".join(
68
+ f"{name} {sorted(set(s['properties']) - {'op'})}"
69
+ for name, s in ALL_SCHEMAS.items()
70
+ )
71
+ + "."
72
+ ),
73
+ "items": {
74
+ "type": "object",
75
+ "properties": {"op": {"enum": list(BATCH_KINDS)}},
76
+ "required": ["op"],
77
+ },
78
+ },
79
+ "expect": {
80
+ "type": "object",
81
+ "properties": {
82
+ "sha256": {"type": "string", "minLength": 1, "description": "Hash read earlier."}
83
+ },
84
+ "additionalProperties": False,
85
+ },
86
+ "dry_run": {"type": "boolean", "description": "Preview only; write nothing."},
87
+ "tracked": {"type": "boolean", "description": "Tracked changes (the only mode, true)."},
88
+ "author": {"type": "string", "minLength": 1, "description": "Revision author."},
89
+ "tag": {"type": "string", "minLength": 1, "description": "Appended to the author."},
90
+ },
91
+ "required": ["doc", "ops"],
92
+ "additionalProperties": False,
93
+ }
94
+
95
+
96
+ def edits_from_request(ops: list[dict[str, Any]]) -> list[Any]:
97
+ """Build every edit, naming the operation (``ops[i]``) that fails validation."""
98
+ edits: list[Any] = []
99
+ for i, raw in enumerate(ops):
100
+ try:
101
+ if raw.get("op") in COMMENT_SCHEMAS:
102
+ edits.append(comment_from_dict(raw))
103
+ else:
104
+ edits.append(edit_from_dict(raw))
105
+ except DocControlError as err:
106
+ if err.code is not ErrorCode.SCHEMA_INVALID:
107
+ raise
108
+ raise DocControlError(
109
+ ErrorCode.SCHEMA_INVALID, f"ops[{i}]: {err.message}", op_index=i, **err.details
110
+ ) from err
111
+ return edits
112
+
113
+
114
+ @operation(
115
+ "batch",
116
+ "write",
117
+ "Apply text.replace, para.insert, para.delete, para.move, style.apply, format.apply, "
118
+ "comment.add, comment.reply and comment.resolve together, all or nothing, or "
119
+ "style.create/update/rename/merge together, or revision.accept and revision.reject to "
120
+ "resolve revisions (by ids, author, address range or all); dry_run previews.",
121
+ _SCHEMA,
122
+ )
123
+ def batch(request: dict[str, Any]) -> Reply:
124
+ if request.get("tracked", True) is not True:
125
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, "untracked edits are not supported in v1")
126
+ expect = request.get("expect", {})
127
+ path = request["doc"]
128
+ kinds = {op.get("op") in REVISION_SCHEMAS for op in request["ops"]}
129
+ if True in kinds:
130
+ if False in kinds:
131
+ raise DocControlError(
132
+ ErrorCode.BATCH_CONFLICT,
133
+ "revision.accept/revision.reject cannot be combined with text or paragraph "
134
+ "edits in one batch: send them as separate batches",
135
+ )
136
+ resolutions = []
137
+ for i, raw in enumerate(request["ops"]):
138
+ try:
139
+ resolutions.append(resolution_from_dict(raw))
140
+ except DocControlError as err:
141
+ if err.code is not ErrorCode.SCHEMA_INVALID:
142
+ raise
143
+ raise DocControlError(
144
+ ErrorCode.SCHEMA_INVALID, f"ops[{i}]: {err.message}", op_index=i
145
+ ) from err
146
+ res = apply_resolutions(
147
+ Path(path),
148
+ resolutions,
149
+ expect_sha256=expect.get("sha256"),
150
+ dry_run=request.get("dry_run", False),
151
+ )
152
+ return Reply(res.to_dict(), doc=Document(path).envelope_doc())
153
+ style_ops = [isinstance(o, dict) and o.get("op") in STYLE_KINDS for o in request["ops"]]
154
+ if any(style_ops):
155
+ if not all(style_ops):
156
+ raise DocControlError(
157
+ ErrorCode.SCHEMA_INVALID,
158
+ "style operations and text operations cannot share a batch",
159
+ )
160
+ styled = apply_style_ops(
161
+ Path(path),
162
+ request["ops"],
163
+ expect_sha256=expect.get("sha256"),
164
+ dry_run=request.get("dry_run", False),
165
+ )
166
+ return Reply(styled, doc=Document(path).envelope_doc())
167
+ edits = edits_from_request(request["ops"])
168
+ apply = apply_batch if any(is_comment_edit(e) for e in edits) else apply_edits
169
+ result = apply(
170
+ Path(path),
171
+ edits,
172
+ expect_sha256=expect.get("sha256"),
173
+ author=request.get("author", DEFAULT_AUTHOR),
174
+ tag=request.get("tag"),
175
+ dry_run=request.get("dry_run", False),
176
+ )
177
+ return Reply(result.to_dict(), doc=Document(path).envelope_doc())
@@ -0,0 +1,433 @@
1
+ """Comment operations of a batch: ``comment.add``, ``comment.reply``, ``comment.resolve``.
2
+
3
+ They are batch operation kinds, not standalone writes (see :mod:`doc_control.ops.batch`).
4
+
5
+ ``comment.add`` anchors a new comment on a range (``at``: a paragraph, a range inside
6
+ one or across paragraphs, as for ``text.replace``) with ``commentRangeStart``,
7
+ ``commentRangeEnd`` and a ``commentReference`` run. ``comment.reply`` takes the id
8
+ ``comments.list`` reports; the reply is anchored on the same range as the thread's root
9
+ (a reply to a reply joins the thread). ``comment.resolve`` marks the whole thread
10
+ resolved (``resolved: false`` reopens it). All of them keep ``comments.xml``,
11
+ ``commentsExtended.xml``, ``commentsIds.xml``, ``commentsExtensible.xml`` and
12
+ ``people.xml`` consistent, creating the parts (with their content types and
13
+ relationships) when the document has none; see :mod:`doc_control.ooxml.commentparts`.
14
+
15
+ A comment is placed on a paragraph that no other operation of the same batch edits,
16
+ deletes or moves (``BATCH_CONFLICT``); apply such pairs as two batches. Comment ids
17
+ are above every ``w:id`` of the document, and above the ids the batch's revisions get.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import bisect
23
+ import re
24
+ import xml.etree.ElementTree as ET
25
+ from collections import defaultdict
26
+ from collections.abc import Mapping, Sequence
27
+ from dataclasses import dataclass, replace
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from doc_control.errors import DocControlError, ErrorCode
33
+ from doc_control.model.index import DocumentIndex, Paragraph
34
+ from doc_control.model.styles import load_styles
35
+ from doc_control.ooxml.commentparts import CommentParts
36
+ from doc_control.ooxml.revisions import (
37
+ DEFAULT_AUTHOR,
38
+ ParagraphEditor,
39
+ RevisionContext,
40
+ format_author,
41
+ format_date,
42
+ used_ids,
43
+ )
44
+ from doc_control.ooxml.zipio import Package
45
+ from doc_control.ops.tracked import (
46
+ REQUEST_NAMES,
47
+ Annotator,
48
+ TrackedResult,
49
+ boundary_ok,
50
+ check_name,
51
+ check_text,
52
+ edits_as_request,
53
+ prepare_edits,
54
+ )
55
+ from doc_control.safety.history import record
56
+ from doc_control.safety.lock import check_changed, check_unlocked
57
+ from doc_control.safety.verify import DOCUMENT_PART, write_verified
58
+ from doc_control.schema import validate
59
+
60
+ W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
61
+ W = f"{{{W_NS}}}"
62
+ _PREVIEW = 120
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class AddComment:
67
+ at: str
68
+ text: str
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ReplyComment:
73
+ comment: str
74
+ text: str
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ResolveComment:
79
+ comment: str
80
+ resolved: bool = True
81
+
82
+
83
+ CommentEdit = AddComment | ReplyComment | ResolveComment
84
+ REQUEST_NAMES.update(
85
+ {AddComment: "comment.add", ReplyComment: "comment.reply", ResolveComment: "comment.resolve"}
86
+ )
87
+
88
+ _STR: dict[str, Any] = {"type": "string", "minLength": 1}
89
+ COMMENT_SCHEMAS: dict[str, dict[str, Any]] = {
90
+ "comment.add": {
91
+ "type": "object",
92
+ "properties": {
93
+ "op": {"enum": ["comment.add"]},
94
+ "at": {**_STR, "description": "Range to comment on: p3#fp, p3#fp:10-15 or across."},
95
+ "text": {**_STR, "description": "Comment text; a newline starts a new paragraph."},
96
+ },
97
+ "required": ["op", "at", "text"],
98
+ "additionalProperties": False,
99
+ },
100
+ "comment.reply": {
101
+ "type": "object",
102
+ "properties": {
103
+ "op": {"enum": ["comment.reply"]},
104
+ "comment": {**_STR, "description": "Id of the comment to reply to (comments.list)."},
105
+ "text": {**_STR, "description": "Reply text."},
106
+ },
107
+ "required": ["op", "comment", "text"],
108
+ "additionalProperties": False,
109
+ },
110
+ "comment.resolve": {
111
+ "type": "object",
112
+ "properties": {
113
+ "op": {"enum": ["comment.resolve"]},
114
+ "comment": {**_STR, "description": "Id of any comment of the thread."},
115
+ "resolved": {"type": "boolean", "description": "false reopens the thread."},
116
+ },
117
+ "required": ["op", "comment"],
118
+ "additionalProperties": False,
119
+ },
120
+ }
121
+
122
+
123
+ def is_comment_edit(edit: object) -> bool:
124
+ return isinstance(edit, AddComment | ReplyComment | ResolveComment)
125
+
126
+
127
+ def comment_from_dict(raw: Mapping[str, Any]) -> CommentEdit:
128
+ op = raw.get("op")
129
+ if not isinstance(op, str) or op not in COMMENT_SCHEMAS:
130
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, f"unknown comment operation: {op!r}")
131
+ validate(dict(raw), COMMENT_SCHEMAS[op])
132
+ if op == "comment.add":
133
+ return AddComment(raw["at"], raw["text"])
134
+ if op == "comment.reply":
135
+ return ReplyComment(raw["comment"], raw["text"])
136
+ return ResolveComment(raw["comment"], raw.get("resolved", True))
137
+
138
+
139
+ def _short(text: str) -> str:
140
+ return text if len(text) <= _PREVIEW else text[: _PREVIEW - 1] + "…"
141
+
142
+
143
+ def _check_comment_text(text: str) -> None:
144
+ check_text(text, "text")
145
+ if not text.strip():
146
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, "comment text must not be empty")
147
+
148
+
149
+ def _marker(tag: str, cid: str) -> ET.Element:
150
+ return ET.Element(W + tag, {W + "id": cid})
151
+
152
+
153
+ def _reference(cid: str, styled: bool) -> ET.Element:
154
+ run = ET.Element(W + "r")
155
+ if styled:
156
+ rpr = ET.SubElement(run, W + "rPr")
157
+ ET.SubElement(rpr, W + "rStyle", {W + "val": "CommentReference"})
158
+ ET.SubElement(run, W + "commentReference", {W + "id": cid})
159
+ return run
160
+
161
+
162
+ _MARKER = re.compile(
163
+ rb"<(?:[\w.-]+:)?(commentRangeStart|commentReference)\b((?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>"
164
+ )
165
+ _ID_ATTR = re.compile(rb"""[\s:]id\s*=\s*["']([^"']*)["']""")
166
+
167
+
168
+ def _locate_markers(index: DocumentIndex, cid: str) -> dict[str, Paragraph]:
169
+ """Paragraph holding each of ``commentRangeStart`` / ``commentReference`` of ``cid``."""
170
+ data = index.spans.data
171
+ spans = index.spans.paragraphs
172
+ starts = [spans[p.span_index].start for p in index.paragraphs]
173
+ found: dict[str, Paragraph] = {}
174
+ for m in _MARKER.finditer(data):
175
+ ident = _ID_ATTR.search(m.group(2))
176
+ if ident is None or ident.group(1).decode() != cid:
177
+ continue
178
+ i = bisect.bisect_right(starts, m.start()) - 1
179
+ if i < 0 or m.start() >= spans[index.paragraphs[i].span_index].end:
180
+ raise DocControlError(
181
+ ErrorCode.UNSUPPORTED_CONTENT,
182
+ f"comment {cid} is anchored outside a paragraph of the main story",
183
+ comment=cid,
184
+ )
185
+ found.setdefault(m.group(1).decode(), index.paragraphs[i])
186
+ return found
187
+
188
+
189
+ def _insert_after_marker(
190
+ elem: ET.Element, tag: str, cid: str, nodes: Sequence[ET.Element], para: Paragraph
191
+ ) -> None:
192
+ parents = {child: parent for parent in elem.iter() for child in parent}
193
+ for node in elem.iter(W + tag):
194
+ if node.get(W + "id") == cid:
195
+ parent = parents.get(node)
196
+ if parent is None:
197
+ break
198
+ at = list(parent).index(node) + 1
199
+ for j, item in enumerate(nodes):
200
+ parent.insert(at + j, item)
201
+ return
202
+ raise DocControlError(
203
+ ErrorCode.UNSUPPORTED_CONTENT,
204
+ f"comment {cid} is not anchored in {para.address}",
205
+ comment=cid,
206
+ )
207
+
208
+
209
+ @dataclass
210
+ class _Plan:
211
+ """Marker placements per paragraph number."""
212
+
213
+ at_offset: defaultdict[int, list[tuple[int, list[ET.Element]]]]
214
+ after_marker: defaultdict[int, list[tuple[str, str, list[ET.Element]]]]
215
+
216
+ def annotators(self) -> dict[int, Annotator]:
217
+ return {n: self._make(n) for n in sorted(set(self.at_offset) | set(self.after_marker))}
218
+
219
+ def _make(self, number: int) -> Annotator:
220
+ offsets = self.at_offset.get(number, [])
221
+ relative = self.after_marker.get(number, [])
222
+
223
+ def annotate(elem: ET.Element, para: Paragraph, ctx: RevisionContext) -> None:
224
+ editor = ParagraphEditor(elem, para, ctx)
225
+ for offset, nodes in sorted(offsets, key=lambda item: -item[0]):
226
+ editor.place(offset, nodes)
227
+ for tag, cid, nodes in relative:
228
+ _insert_after_marker(elem, tag, cid, nodes, para)
229
+
230
+ return annotate
231
+
232
+
233
+ def _require_comment(parts: CommentParts, cid: str) -> None:
234
+ if cid not in parts.existing:
235
+ raise DocControlError(
236
+ ErrorCode.COMMENT_NOT_FOUND, f"no comment with id {cid!r}", comment=cid
237
+ )
238
+
239
+
240
+ def apply_batch(
241
+ doc_path: Path,
242
+ edits: Sequence[Any],
243
+ *,
244
+ expect_sha256: str | None = None,
245
+ author: str = DEFAULT_AUTHOR,
246
+ tag: str | None = None,
247
+ now: datetime | None = None,
248
+ dry_run: bool = False,
249
+ env: Mapping[str, str] | None = None,
250
+ ) -> TrackedResult:
251
+ """Apply tracked text edits and comment operations together in one verified write."""
252
+ if not edits:
253
+ raise DocControlError(ErrorCode.SCHEMA_INVALID, "no edits given")
254
+ check_name(author, "author")
255
+ if tag is not None:
256
+ check_name(tag, "tag")
257
+ if not doc_path.is_file():
258
+ raise DocControlError(
259
+ ErrorCode.DOC_NOT_FOUND, f"no such file: {doc_path}", path=str(doc_path)
260
+ )
261
+ if not dry_run:
262
+ check_unlocked(doc_path)
263
+ check_changed(doc_path, expect_sha256)
264
+ pkg = Package(doc_path)
265
+ now = now or datetime.now(UTC)
266
+ index = DocumentIndex.from_package(pkg)
267
+ full_author, date = format_author(author, tag), format_date(now)
268
+
269
+ styles = frozenset(s.id for s in load_styles(pkg).styles)
270
+ parts = CommentParts(pkg, styles=styles)
271
+ taken = used_ids(pkg) | {int(c) for c in parts.comment_ids() if c.strip().isdigit()}
272
+ next_id = max(taken, default=-1) + 1
273
+ reserved: list[int] = []
274
+ plan = _Plan(defaultdict(list), defaultdict(list))
275
+ previews: list[dict[str, Any]] = []
276
+ touched: list[str] = []
277
+ ref_styled = "CommentReference" in styles
278
+
279
+ for i, edit in enumerate(edits):
280
+ if isinstance(edit, AddComment):
281
+ _check_comment_text(edit.text)
282
+ rng = index.resolve_range(edit.at)
283
+ assert rng.start_offset is not None and rng.end_offset is not None
284
+ first, last, s, e = rng.first, rng.last, rng.start_offset, rng.end_offset
285
+ if not boundary_ok(first.text, s) or not boundary_ok(last.text, e):
286
+ raise DocControlError(
287
+ ErrorCode.ADDRESS_INVALID,
288
+ f"{edit.at!r} splits a character from its combining marks",
289
+ address=edit.at,
290
+ )
291
+ if first is last and s == e and first.text:
292
+ raise DocControlError(
293
+ ErrorCode.ADDRESS_INVALID,
294
+ f"{edit.at!r} is an empty range: a comment needs text to anchor on",
295
+ address=edit.at,
296
+ )
297
+ cid = str(next_id)
298
+ reserved.append(next_id)
299
+ next_id += 1
300
+ parts.add(cid, edit.text, full_author, date)
301
+ start, end = _marker("commentRangeStart", cid), _marker("commentRangeEnd", cid)
302
+ ref = _reference(cid, ref_styled)
303
+ if first is last and s == e: # an empty paragraph: a collapsed anchor
304
+ plan.at_offset[first.number].append((0, [start, end, ref]))
305
+ else:
306
+ plan.at_offset[last.number].append((e, [end, ref]))
307
+ plan.at_offset[first.number].append((s, [start]))
308
+ quoted = first.text[s:e] if first is last else first.text[s:] + "…"
309
+ previews.append(
310
+ {
311
+ "op": i,
312
+ "at": edit.at,
313
+ "before": _short(quoted),
314
+ "after": f"(comment c{cid} by {full_author}: {_short(edit.text)})",
315
+ }
316
+ )
317
+ touched.append(cid)
318
+ elif isinstance(edit, ReplyComment):
319
+ _check_comment_text(edit.text)
320
+ _require_comment(parts, edit.comment)
321
+ root = parts.root_of(edit.comment)
322
+ places = _locate_markers(index, root)
323
+ ref_para = places.get("commentReference")
324
+ if ref_para is None:
325
+ raise DocControlError(
326
+ ErrorCode.UNSUPPORTED_CONTENT,
327
+ f"comment {root} has no anchor in the main story to reply on",
328
+ comment=root,
329
+ )
330
+ cid = str(next_id)
331
+ reserved.append(next_id)
332
+ next_id += 1
333
+ parts.add(cid, edit.text, full_author, date, parent=root)
334
+ plan.after_marker[ref_para.number].append(
335
+ (
336
+ "commentReference",
337
+ root,
338
+ [_marker("commentRangeEnd", cid), _reference(cid, ref_styled)],
339
+ )
340
+ )
341
+ start_para = places.get("commentRangeStart")
342
+ if start_para is None:
343
+ start_para = ref_para
344
+ plan.after_marker[ref_para.number].append(
345
+ ("commentReference", root, [_marker("commentRangeStart", cid)])
346
+ )
347
+ else:
348
+ plan.after_marker[start_para.number].append(
349
+ ("commentRangeStart", root, [_marker("commentRangeStart", cid)])
350
+ )
351
+ previews.append(
352
+ {
353
+ "op": i,
354
+ "at": str(start_para.address),
355
+ "before": f"(thread of comment {root})",
356
+ "after": f"(reply c{cid} by {full_author}: {_short(edit.text)})",
357
+ }
358
+ )
359
+ touched.append(cid)
360
+ elif isinstance(edit, ResolveComment):
361
+ _require_comment(parts, edit.comment)
362
+ root = parts.root_of(edit.comment)
363
+ members = parts.thread(root)
364
+ changed = [m for m in members if parts.set_done(m, edit.resolved)]
365
+ previews.append(
366
+ {
367
+ "op": i,
368
+ "at": f"comment {root}",
369
+ "before": "(open)" if edit.resolved else "(resolved)",
370
+ "after": ("(resolved)" if edit.resolved else "(open)")
371
+ + ("" if changed else ", already so"),
372
+ }
373
+ )
374
+ touched += members
375
+
376
+ prepared = prepare_edits(
377
+ pkg,
378
+ edits,
379
+ author=author,
380
+ tag=tag,
381
+ now=now,
382
+ dry_run=dry_run,
383
+ index=index,
384
+ annotations=plan.annotators(),
385
+ reserved_ids=reserved,
386
+ )
387
+ part_bytes = parts.build()
388
+ replacements = dict(part_bytes)
389
+ if prepared.new_data != pkg.read(DOCUMENT_PART):
390
+ replacements[DOCUMENT_PART] = prepared.new_data
391
+ footprint = replace(prepared.footprint, parts=frozenset(part_bytes))
392
+ ids = sorted(set(touched), key=int)
393
+ result = replace(
394
+ prepared.result,
395
+ applied=len(edits),
396
+ preview=sorted(prepared.result.preview + previews, key=lambda p: p["op"]),
397
+ parts={name: [f"c{c}" for c in ids] for name in part_bytes},
398
+ comment_ids=ids,
399
+ )
400
+ if dry_run:
401
+ return result
402
+ if not replacements:
403
+ return replace(result, after_sha256=pkg.sha256, backup=None)
404
+ written = write_verified(doc_path, replacements, footprint, pkg.sha256, now=now, env=env)
405
+ warnings = list(result.warnings)
406
+ op_id: str | None = None
407
+ try:
408
+ op_id = record(
409
+ doc_path,
410
+ "batch",
411
+ before_sha256=written.before_sha256,
412
+ after_sha256=written.after_sha256,
413
+ backup=written.backup,
414
+ changes=prepared.changes,
415
+ request=edits_as_request(edits),
416
+ author=result.author,
417
+ tag=tag,
418
+ footprint={DOCUMENT_PART: result.footprint, **result.parts},
419
+ # Comment parts are not logged, so `undo` refuses this entry (see safety.history).
420
+ extra={"parts": sorted(part_bytes)},
421
+ now=now,
422
+ env=env,
423
+ )
424
+ except (DocControlError, OSError) as exc:
425
+ warnings.append(f"HISTORY_NOT_RECORDED: the write succeeded but could not be logged: {exc}")
426
+ return replace(
427
+ result,
428
+ before_sha256=written.before_sha256,
429
+ after_sha256=written.after_sha256,
430
+ backup=written.backup,
431
+ warnings=warnings,
432
+ op_id=op_id,
433
+ )