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
doc_control/ops/batch.py
ADDED
|
@@ -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
|
+
)
|