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/diff.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""``diff``: paragraph alignment between two versions of a document, read-only.
|
|
2
|
+
|
|
3
|
+
Request: ``old`` and ``new`` (paths to ``.docx`` files), optional ``limit`` and
|
|
4
|
+
``cursor`` (the envelope's ``next`` of the previous page; the cursor is tied to the old
|
|
5
|
+
file's hash and to the new file's, so a changed file is not paged on silently).
|
|
6
|
+
|
|
7
|
+
Result: ``old`` / ``new`` (path, sha256, paragraphs), ``summary`` (paragraphs by kind),
|
|
8
|
+
``total`` (records), ``changes`` (one page, capped like every list) and ``address_map``
|
|
9
|
+
(old address -> new address, or ``null`` for a paragraph that is gone, for the records
|
|
10
|
+
of that page). The envelope's ``doc`` is the old file.
|
|
11
|
+
|
|
12
|
+
Records, in the order of the old version (an insertion right after its predecessor):
|
|
13
|
+
|
|
14
|
+
* ``{"kind": "modified", "old": ["p3#..."], "new": ["p3#..."], "similarity": 0.93}``
|
|
15
|
+
(``"moved": true`` when it also changed place); ``moved``, ``split``
|
|
16
|
+
(one old, several new), ``merged`` (several old, one new), ``deleted`` (``new`` empty)
|
|
17
|
+
and ``inserted`` (``old`` empty) look alike.
|
|
18
|
+
* ``{"kind": "unchanged", "old": [a], "new": [b]}``: an unchanged paragraph whose address
|
|
19
|
+
changed (its number shifted). Unchanged paragraphs that kept their address are not
|
|
20
|
+
listed. With ``collapse_runs: true`` consecutive unchanged paragraphs shifted by the
|
|
21
|
+
same amount become one record ``{"kind": "unchanged", "old": [first], "new": [first],
|
|
22
|
+
"count": n}`` and are left out of ``address_map`` (``p<k>#<fp>`` is ``p<k + shift>#<fp>``).
|
|
23
|
+
By default every paragraph whose address changed is in ``address_map``.
|
|
24
|
+
|
|
25
|
+
Address map for a split: the old address maps to the first new part (the record lists
|
|
26
|
+
all parts); for a merge every old part maps to the merged paragraph.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from doc_control.envelope import Reply, success
|
|
36
|
+
from doc_control.model.align import UNCHANGED, Alignment, Change, align
|
|
37
|
+
from doc_control.model.document import Document
|
|
38
|
+
from doc_control.model.index import DocumentIndex
|
|
39
|
+
from doc_control.ops.paging import (
|
|
40
|
+
PAGING_PROPERTIES,
|
|
41
|
+
RESPONSE_BUDGET,
|
|
42
|
+
make_cursor,
|
|
43
|
+
read_cursor,
|
|
44
|
+
size,
|
|
45
|
+
)
|
|
46
|
+
from doc_control.registry import operation
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def diff_documents(old: Path, new: Path) -> tuple[DocumentIndex, DocumentIndex, Alignment]:
|
|
50
|
+
"""Index both files (read-only) and align their paragraphs."""
|
|
51
|
+
index_old, index_new = Document(str(old)).index, Document(str(new)).index
|
|
52
|
+
return index_old, index_new, align(index_old.paragraphs, index_new.paragraphs)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def address_map(
|
|
56
|
+
alignment: Alignment, old: DocumentIndex, new: DocumentIndex
|
|
57
|
+
) -> dict[str, str | None]:
|
|
58
|
+
"""The full cross-version map: every old address to its new address, or ``None``."""
|
|
59
|
+
out: dict[str, str | None] = {}
|
|
60
|
+
for change in alignment.changes:
|
|
61
|
+
target = str(new.paragraphs[change.new[0]].address) if change.new else None
|
|
62
|
+
for i in change.old:
|
|
63
|
+
out[str(old.paragraphs[i].address)] = target
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _records(
|
|
68
|
+
alignment: Alignment, old: DocumentIndex, new: DocumentIndex, *, collapse: bool = False
|
|
69
|
+
) -> list[dict[str, Any]]:
|
|
70
|
+
"""Output records: unchanged paragraphs that kept their address are left out."""
|
|
71
|
+
out: list[dict[str, Any]] = []
|
|
72
|
+
run: dict[str, Any] | None = None
|
|
73
|
+
next_old = shift = 0
|
|
74
|
+
for change in alignment.changes:
|
|
75
|
+
if change.kind != UNCHANGED:
|
|
76
|
+
run = None
|
|
77
|
+
out.append(_record(change, old, new))
|
|
78
|
+
continue
|
|
79
|
+
i, j = change.old[0], change.new[0]
|
|
80
|
+
if i == j:
|
|
81
|
+
run = None
|
|
82
|
+
elif not collapse:
|
|
83
|
+
out.append(_record(change, old, new))
|
|
84
|
+
elif run is not None and j - i == shift and next_old == i:
|
|
85
|
+
run["count"] += 1
|
|
86
|
+
next_old = i + 1
|
|
87
|
+
else:
|
|
88
|
+
shift, next_old = j - i, i + 1
|
|
89
|
+
run = {
|
|
90
|
+
"kind": UNCHANGED,
|
|
91
|
+
"old": [str(old.paragraphs[i].address)],
|
|
92
|
+
"new": [str(new.paragraphs[j].address)],
|
|
93
|
+
"count": 1,
|
|
94
|
+
}
|
|
95
|
+
out.append(run)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _record(change: Change, old: DocumentIndex, new: DocumentIndex) -> dict[str, Any]:
|
|
100
|
+
rec: dict[str, Any] = {
|
|
101
|
+
"kind": change.kind,
|
|
102
|
+
"old": [str(old.paragraphs[i].address) for i in change.old],
|
|
103
|
+
"new": [str(new.paragraphs[j].address) for j in change.new],
|
|
104
|
+
}
|
|
105
|
+
if change.similarity is not None:
|
|
106
|
+
rec["similarity"] = change.similarity
|
|
107
|
+
if change.moved:
|
|
108
|
+
rec["moved"] = True
|
|
109
|
+
return rec
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _page_map(records: list[dict[str, Any]]) -> dict[str, str | None]:
|
|
113
|
+
out: dict[str, str | None] = {}
|
|
114
|
+
for rec in records:
|
|
115
|
+
if "count" in rec: # a collapsed run
|
|
116
|
+
continue
|
|
117
|
+
target = rec["new"][0] if rec["new"] else None
|
|
118
|
+
for address in rec["old"]:
|
|
119
|
+
out[address] = target
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@operation(
|
|
124
|
+
"diff",
|
|
125
|
+
"read",
|
|
126
|
+
"Paragraph alignment between two versions of a document, with a cross-version address map.",
|
|
127
|
+
{
|
|
128
|
+
"type": "object",
|
|
129
|
+
"properties": {
|
|
130
|
+
"old": {"type": "string", "description": "Path of the earlier version (.docx)."},
|
|
131
|
+
"new": {"type": "string", "description": "Path of the later version (.docx)."},
|
|
132
|
+
"collapse_runs": {
|
|
133
|
+
"type": "boolean",
|
|
134
|
+
"description": "List runs of shifted unchanged paragraphs as one record each.",
|
|
135
|
+
},
|
|
136
|
+
**PAGING_PROPERTIES,
|
|
137
|
+
},
|
|
138
|
+
"required": ["old", "new"],
|
|
139
|
+
"additionalProperties": False,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
def diff(request: dict[str, Any]) -> Reply:
|
|
143
|
+
old, new = Document(request["old"]), Document(request["new"])
|
|
144
|
+
alignment = align(old.index.paragraphs, new.index.paragraphs)
|
|
145
|
+
records = _records(
|
|
146
|
+
alignment, old.index, new.index, collapse=request.get("collapse_runs", False)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# The cursor must also notice the new file changing: fold its hash into the scope.
|
|
150
|
+
scoped = {**request, "new_sha256": new.sha256}
|
|
151
|
+
start = read_cursor("diff", old, scoped)
|
|
152
|
+
limit = request.get("limit", len(records))
|
|
153
|
+
result: dict[str, Any] = {
|
|
154
|
+
"old": old.envelope_doc(),
|
|
155
|
+
"new": new.envelope_doc(),
|
|
156
|
+
"summary": alignment.counts(),
|
|
157
|
+
"total": len(records),
|
|
158
|
+
"changes": [],
|
|
159
|
+
"address_map": {},
|
|
160
|
+
}
|
|
161
|
+
placeholder = make_cursor("diff", old, scoped, 10**12)
|
|
162
|
+
room = RESPONSE_BUDGET - size(
|
|
163
|
+
success("diff", result, doc=old.envelope_doc(), next_cursor=placeholder)
|
|
164
|
+
)
|
|
165
|
+
taken: list[dict[str, Any]] = []
|
|
166
|
+
used = 0
|
|
167
|
+
for rec in records[start : start + limit]:
|
|
168
|
+
cost = 2 * len(json.dumps(rec, ensure_ascii=False).encode("utf-8")) + 4 # record + map
|
|
169
|
+
if taken and used + cost > room:
|
|
170
|
+
break
|
|
171
|
+
taken.append(rec)
|
|
172
|
+
used += cost
|
|
173
|
+
end = start + len(taken)
|
|
174
|
+
result["changes"] = taken
|
|
175
|
+
result["address_map"] = _page_map(taken)
|
|
176
|
+
cursor = make_cursor("diff", old, scoped, end) if end < len(records) else None
|
|
177
|
+
return Reply(result, doc=old.envelope_doc(), next_cursor=cursor)
|
doc_control/ops/dotx.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""``.dotx`` templates: ``styleset.export-dotx`` and ``styleset.import-dotx``.
|
|
2
|
+
|
|
3
|
+
Export writes a minimal, valid Word template package (template main content type, an
|
|
4
|
+
empty body, ``styles.xml`` holding the exported definitions, the package and document
|
|
5
|
+
relationships and ``docProps``) to a new file; the source document is never touched.
|
|
6
|
+
Import reads the styles of a ``.dotx`` back into a style set and installs it with the
|
|
7
|
+
``styleset.install`` semantics (``on_conflict``), as one verified write.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import tempfile
|
|
14
|
+
import zipfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from xml.sax.saxutils import escape
|
|
18
|
+
|
|
19
|
+
from doc_control.envelope import Reply
|
|
20
|
+
from doc_control.model.document import Document
|
|
21
|
+
from doc_control.model.styles import load_styles
|
|
22
|
+
from doc_control.ooxml.styledef import STYLES_CONTENT_TYPE, invalid
|
|
23
|
+
from doc_control.ooxml.zipio import Package
|
|
24
|
+
from doc_control.ops.styleset import (
|
|
25
|
+
_DOC,
|
|
26
|
+
_install,
|
|
27
|
+
_validated,
|
|
28
|
+
select_styleset,
|
|
29
|
+
style_entry,
|
|
30
|
+
write_new_file,
|
|
31
|
+
)
|
|
32
|
+
from doc_control.ops.stylewrite import StyleSession, commit_styles
|
|
33
|
+
from doc_control.registry import operation
|
|
34
|
+
|
|
35
|
+
TEMPLATE_CONTENT_TYPE = (
|
|
36
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"
|
|
37
|
+
)
|
|
38
|
+
_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
|
39
|
+
_W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
40
|
+
_OD = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"
|
|
41
|
+
_PKG_REL = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
42
|
+
_CORE_REL = _PKG_REL + "/metadata/core-properties"
|
|
43
|
+
_EPOCH = (1980, 1, 1, 0, 0, 0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rels(rows: list[tuple[str, str, str]]) -> str:
|
|
47
|
+
body = "".join(f'<Relationship Id="{i}" Type="{t}" Target="{g}"/>' for i, t, g in rows)
|
|
48
|
+
return f'{_XML}<Relationships xmlns="{_PKG_REL}">{body}</Relationships>'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _skeleton(title: str) -> dict[str, bytes]:
|
|
52
|
+
"""The template package without its styles definitions."""
|
|
53
|
+
types = (
|
|
54
|
+
f'{_XML}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
55
|
+
'<Default Extension="rels" '
|
|
56
|
+
'ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
57
|
+
'<Default Extension="xml" ContentType="application/xml"/>'
|
|
58
|
+
f'<Override PartName="/word/document.xml" ContentType="{TEMPLATE_CONTENT_TYPE}"/>'
|
|
59
|
+
f'<Override PartName="/word/styles.xml" ContentType="{STYLES_CONTENT_TYPE}"/>'
|
|
60
|
+
'<Override PartName="/docProps/core.xml" '
|
|
61
|
+
'ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
|
|
62
|
+
'<Override PartName="/docProps/app.xml" '
|
|
63
|
+
'ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
|
|
64
|
+
"</Types>"
|
|
65
|
+
)
|
|
66
|
+
document = (
|
|
67
|
+
f'{_XML}<w:document xmlns:w="{_W}"><w:body><w:p/>'
|
|
68
|
+
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>'
|
|
69
|
+
'<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" '
|
|
70
|
+
'w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>'
|
|
71
|
+
)
|
|
72
|
+
core = (
|
|
73
|
+
f'{_XML}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/'
|
|
74
|
+
'metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/">'
|
|
75
|
+
f"<dc:title>{escape(title)}</dc:title><dc:creator>doc-control</dc:creator>"
|
|
76
|
+
"</cp:coreProperties>"
|
|
77
|
+
)
|
|
78
|
+
app = (
|
|
79
|
+
f'{_XML}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/'
|
|
80
|
+
'extended-properties"><Application>doc-control</Application></Properties>'
|
|
81
|
+
)
|
|
82
|
+
parts = {
|
|
83
|
+
"[Content_Types].xml": types,
|
|
84
|
+
"_rels/.rels": _rels(
|
|
85
|
+
[
|
|
86
|
+
("rId1", _OD + "officeDocument", "word/document.xml"),
|
|
87
|
+
("rId2", _CORE_REL, "docProps/core.xml"),
|
|
88
|
+
("rId3", _OD + "extended-properties", "docProps/app.xml"),
|
|
89
|
+
]
|
|
90
|
+
),
|
|
91
|
+
"word/document.xml": document,
|
|
92
|
+
"word/_rels/document.xml.rels": _rels([("rId1", _OD + "styles", "styles.xml")]),
|
|
93
|
+
"docProps/core.xml": core,
|
|
94
|
+
"docProps/app.xml": app,
|
|
95
|
+
}
|
|
96
|
+
return {name: text.encode("utf-8") for name, text in parts.items()}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _zip(parts: dict[str, bytes]) -> bytes:
|
|
100
|
+
buf = io.BytesIO()
|
|
101
|
+
with zipfile.ZipFile(buf, "w") as zf:
|
|
102
|
+
for name, data in parts.items():
|
|
103
|
+
info = zipfile.ZipInfo(name, _EPOCH)
|
|
104
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
105
|
+
info.external_attr = 0o600 << 16
|
|
106
|
+
zf.writestr(info, data)
|
|
107
|
+
return buf.getvalue()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_dotx(styleset: dict[str, Any]) -> bytes:
|
|
111
|
+
"""A minimal Word template package holding the style set's definitions."""
|
|
112
|
+
entries = _validated(styleset)
|
|
113
|
+
ids = {e["id"] for e in entries}
|
|
114
|
+
# A subset can leave a reference to a style that is not in the template: drop it.
|
|
115
|
+
entries = [
|
|
116
|
+
{k: v for k, v in e.items() if k not in ("based_on", "next", "link") or v in ids}
|
|
117
|
+
for e in entries
|
|
118
|
+
]
|
|
119
|
+
parts = _skeleton(str(styleset.get("styleset") or "styles"))
|
|
120
|
+
parts["word/styles.xml"] = f'{_XML}<w:styles xmlns:w="{_W}"></w:styles>'.encode()
|
|
121
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
122
|
+
scratch = Path(tmp) / "styles.dotx"
|
|
123
|
+
scratch.write_bytes(_zip(parts))
|
|
124
|
+
session = StyleSession(Package(scratch))
|
|
125
|
+
_install(session, entries, "replace")
|
|
126
|
+
session.check()
|
|
127
|
+
parts["word/styles.xml"] = session.styles.data()
|
|
128
|
+
return _zip(parts)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@operation(
|
|
132
|
+
"styleset.export-dotx",
|
|
133
|
+
"write",
|
|
134
|
+
"Write the document's styles as a new .dotx template; never touches the document.",
|
|
135
|
+
{
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"doc": _DOC,
|
|
139
|
+
"out": {"type": "string", "minLength": 1, "description": "New .dotx to create."},
|
|
140
|
+
"name": {"type": "string", "minLength": 1, "description": "Set name."},
|
|
141
|
+
"styles": {
|
|
142
|
+
"type": "array",
|
|
143
|
+
"items": {"type": "string", "minLength": 1},
|
|
144
|
+
"description": "Only these styles (ids or names).",
|
|
145
|
+
},
|
|
146
|
+
"custom_only": {"type": "boolean", "description": "Only user-defined styles."},
|
|
147
|
+
},
|
|
148
|
+
"required": ["doc", "out"],
|
|
149
|
+
"additionalProperties": False,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
def styleset_export_dotx(request: dict[str, Any]) -> Reply:
|
|
153
|
+
doc = Document(request["doc"])
|
|
154
|
+
styleset = select_styleset(doc, request)
|
|
155
|
+
data = build_dotx(styleset)
|
|
156
|
+
write_new_file(Path(request["out"]), Path(request["doc"]), data)
|
|
157
|
+
result = {"path": request["out"], "count": len(styleset["styles"]), "bytes": len(data)}
|
|
158
|
+
return Reply(result, doc=doc.envelope_doc())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def read_dotx(path: Path) -> list[dict[str, Any]]:
|
|
162
|
+
"""The style entries of a .dotx (or any Word package) as style set entries."""
|
|
163
|
+
return [style_entry(s) for s in load_styles(Package(path)).styles]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@operation(
|
|
167
|
+
"styleset.import-dotx",
|
|
168
|
+
"write",
|
|
169
|
+
"Install the styles of a .dotx template into the document: new styles are added, "
|
|
170
|
+
"existing ones replaced (or skipped); dry_run previews.",
|
|
171
|
+
{
|
|
172
|
+
"type": "object",
|
|
173
|
+
"properties": {
|
|
174
|
+
"doc": _DOC,
|
|
175
|
+
"path": {"type": "string", "minLength": 1, "description": "The .dotx to read."},
|
|
176
|
+
"on_conflict": {
|
|
177
|
+
"enum": ["replace", "skip", "error"],
|
|
178
|
+
"description": "A style id already in the document (default replace).",
|
|
179
|
+
},
|
|
180
|
+
"expect": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"properties": {"sha256": {"type": "string", "minLength": 1}},
|
|
183
|
+
"additionalProperties": False,
|
|
184
|
+
},
|
|
185
|
+
"dry_run": {"type": "boolean", "description": "Preview only; write nothing."},
|
|
186
|
+
},
|
|
187
|
+
"required": ["doc", "path"],
|
|
188
|
+
"additionalProperties": False,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
def styleset_import_dotx(request: dict[str, Any]) -> Reply:
|
|
192
|
+
source, target = Path(request["path"]), Path(request["doc"])
|
|
193
|
+
if source.exists() and target.exists() and source.samefile(target):
|
|
194
|
+
raise invalid("path is the document itself")
|
|
195
|
+
styles = read_dotx(source)
|
|
196
|
+
if not styles:
|
|
197
|
+
raise invalid(f"no styles to import in {source.name}")
|
|
198
|
+
entries = _validated({"version": 1, "styles": styles})
|
|
199
|
+
mode = request.get("on_conflict", "replace")
|
|
200
|
+
result = commit_styles(
|
|
201
|
+
target,
|
|
202
|
+
lambda session: _install(session, entries, mode),
|
|
203
|
+
expect_sha256=request.get("expect", {}).get("sha256"),
|
|
204
|
+
dry_run=request.get("dry_run", False),
|
|
205
|
+
)
|
|
206
|
+
return Reply(result, doc=Document(request["doc"]).envelope_doc())
|
doc_control/ops/find.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""`find`: literal or regex search, returned as addressable ranges.
|
|
2
|
+
|
|
3
|
+
Each match is ``{"at": "p142#a7b2a7b2:10-15", "text": ..., "context": ...}``: the range
|
|
4
|
+
is a paragraph address plus code-point offsets into the paragraph's visible text.
|
|
5
|
+
Output is capped by ``limit``; when more matches exist the envelope's ``next`` is a
|
|
6
|
+
cursor that continues the same search (it only works on the same document bytes and
|
|
7
|
+
the same query options). Regex search runs in a child process killed at the time
|
|
8
|
+
budget (Python's regex engine cannot be interrupted); a literal search checks the budget
|
|
9
|
+
between paragraphs. A search that outlives the budget returns what it found with warning
|
|
10
|
+
``TIME_BUDGET`` and a cursor at the unfinished paragraph; if not even that paragraph
|
|
11
|
+
could be searched, ``SCHEMA_INVALID`` asks for a simpler pattern.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from doc_control.envelope import Reply
|
|
25
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
26
|
+
from doc_control.model.address import Address
|
|
27
|
+
from doc_control.model.index import DocumentIndex
|
|
28
|
+
from doc_control.model.search import IsolatedScan, LocalScan, Matcher, SearchOptions
|
|
29
|
+
from doc_control.ooxml.zipio import Package
|
|
30
|
+
from doc_control.registry import operation
|
|
31
|
+
|
|
32
|
+
DEFAULT_LIMIT = 20
|
|
33
|
+
MAX_LIMIT = 200
|
|
34
|
+
DEFAULT_CONTEXT = 30
|
|
35
|
+
MAX_CONTEXT = 200
|
|
36
|
+
MAX_TEXT = 200 # characters of a match echoed back; longer matches are cut
|
|
37
|
+
TIME_BUDGET_SECONDS = 10.0
|
|
38
|
+
|
|
39
|
+
_CURSOR = re.compile(r"c:find:([0-9a-f]{8}):([1-9][0-9]{0,11}):(0|[1-9][0-9]{0,11})")
|
|
40
|
+
|
|
41
|
+
SCHEMA: dict[str, Any] = {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"doc": {"type": "string", "minLength": 1, "description": "Path to the .docx file."},
|
|
45
|
+
"query": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"minLength": 1,
|
|
48
|
+
"description": "Text to find (at most 1000 characters), or a Python regex if `regex`.",
|
|
49
|
+
},
|
|
50
|
+
"regex": {"type": "boolean", "default": False, "description": "Treat `query` as a regex."},
|
|
51
|
+
"ignore_case": {"type": "boolean", "default": False, "description": "Ignore case."},
|
|
52
|
+
"ignore_accents": {
|
|
53
|
+
"type": "boolean",
|
|
54
|
+
"default": False,
|
|
55
|
+
"description": "Ignore accents: `maria` finds `María`. A regex must be written"
|
|
56
|
+
" without accents then.",
|
|
57
|
+
},
|
|
58
|
+
"whole_word": {
|
|
59
|
+
"type": "boolean",
|
|
60
|
+
"default": False,
|
|
61
|
+
"description": "Match only whole words (no letter, digit or mark on either side).",
|
|
62
|
+
},
|
|
63
|
+
"limit": {
|
|
64
|
+
"type": "integer",
|
|
65
|
+
"minimum": 1,
|
|
66
|
+
"maximum": MAX_LIMIT,
|
|
67
|
+
"default": DEFAULT_LIMIT,
|
|
68
|
+
"description": "Most matches to return; more are reached with `cursor`.",
|
|
69
|
+
},
|
|
70
|
+
"context": {
|
|
71
|
+
"type": "integer",
|
|
72
|
+
"minimum": 0,
|
|
73
|
+
"maximum": MAX_CONTEXT,
|
|
74
|
+
"default": DEFAULT_CONTEXT,
|
|
75
|
+
"description": "Characters of surrounding text on each side of a match.",
|
|
76
|
+
},
|
|
77
|
+
"cursor": {"type": "string", "description": "The `next` value of the previous page."},
|
|
78
|
+
},
|
|
79
|
+
"required": ["doc", "query"],
|
|
80
|
+
"additionalProperties": False,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@operation(
|
|
85
|
+
"find",
|
|
86
|
+
"read",
|
|
87
|
+
"Find text or a regex; matches come back as paragraph ranges, capped, with a cursor.",
|
|
88
|
+
SCHEMA,
|
|
89
|
+
)
|
|
90
|
+
def find(request: dict[str, Any]) -> Reply:
|
|
91
|
+
options = SearchOptions(
|
|
92
|
+
query=request["query"],
|
|
93
|
+
regex=request.get("regex", False),
|
|
94
|
+
ignore_case=request.get("ignore_case", False),
|
|
95
|
+
ignore_accents=request.get("ignore_accents", False),
|
|
96
|
+
whole_word=request.get("whole_word", False),
|
|
97
|
+
)
|
|
98
|
+
matcher = Matcher(options)
|
|
99
|
+
limit: int = request.get("limit", DEFAULT_LIMIT)
|
|
100
|
+
context: int = request.get("context", DEFAULT_CONTEXT)
|
|
101
|
+
pkg = Package(Path(request["doc"]))
|
|
102
|
+
index = DocumentIndex.from_package(pkg)
|
|
103
|
+
token = _token(pkg.sha256, options)
|
|
104
|
+
first, skip = _parse_cursor(request.get("cursor"), token)
|
|
105
|
+
|
|
106
|
+
if first > len(index):
|
|
107
|
+
raise _bad_cursor()
|
|
108
|
+
items = [(p.number, p.text) for p in index.paragraphs[first - 1 :] if p.text]
|
|
109
|
+
deadline = time.monotonic() + TIME_BUDGET_SECONDS
|
|
110
|
+
scan: LocalScan | IsolatedScan
|
|
111
|
+
# Regexes run in a child process that can be killed; literals cannot backtrack.
|
|
112
|
+
scan = (
|
|
113
|
+
IsolatedScan(options, items, deadline)
|
|
114
|
+
if options.regex
|
|
115
|
+
else LocalScan(matcher, items, deadline)
|
|
116
|
+
)
|
|
117
|
+
matches: list[dict[str, Any]] = []
|
|
118
|
+
warnings: list[str] = []
|
|
119
|
+
next_cursor: str | None = None
|
|
120
|
+
with contextlib.closing(scan.__iter__()) as found:
|
|
121
|
+
for number, spans in found:
|
|
122
|
+
para = index.paragraph(number)
|
|
123
|
+
skipping = skip if number == first else 0
|
|
124
|
+
for k, (start, end) in enumerate(spans):
|
|
125
|
+
if k < skipping:
|
|
126
|
+
continue
|
|
127
|
+
if len(matches) == limit:
|
|
128
|
+
next_cursor = f"c:find:{token}:{number}:{k}"
|
|
129
|
+
break
|
|
130
|
+
matches.append(_match(para.address, para.text, start, end, context))
|
|
131
|
+
if next_cursor is not None:
|
|
132
|
+
break
|
|
133
|
+
stopped = scan.stopped_at
|
|
134
|
+
if stopped is not None and next_cursor is None:
|
|
135
|
+
if stopped == first and not matches:
|
|
136
|
+
raise DocControlError(
|
|
137
|
+
ErrorCode.SCHEMA_INVALID,
|
|
138
|
+
f"search of p{stopped} did not finish within {TIME_BUDGET_SECONDS:g}s;"
|
|
139
|
+
" simplify the pattern (avoid nested or overlapping repeats)",
|
|
140
|
+
path="$.query",
|
|
141
|
+
paragraph=stopped,
|
|
142
|
+
)
|
|
143
|
+
warnings.append(
|
|
144
|
+
f"TIME_BUDGET: stopped at p{stopped} after {TIME_BUDGET_SECONDS:g}s;"
|
|
145
|
+
" continue with `next`"
|
|
146
|
+
)
|
|
147
|
+
next_cursor = f"c:find:{token}:{stopped}:0"
|
|
148
|
+
return Reply(
|
|
149
|
+
{"matches": matches},
|
|
150
|
+
doc={"path": request["doc"], "sha256": pkg.sha256, "paragraphs": len(index)},
|
|
151
|
+
warnings=warnings,
|
|
152
|
+
next_cursor=next_cursor,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _match(address: Address, text: str, start: int, end: int, context: int) -> dict[str, Any]:
|
|
157
|
+
lo, hi = max(0, start - context), min(len(text), end + context)
|
|
158
|
+
shown = text[start : min(end, start + MAX_TEXT)]
|
|
159
|
+
out: dict[str, Any] = {
|
|
160
|
+
"at": f"{address}:{start}-{end}",
|
|
161
|
+
"text": shown,
|
|
162
|
+
"context": ("…" if lo > 0 else "") + text[lo:hi] + ("…" if hi < len(text) else ""),
|
|
163
|
+
}
|
|
164
|
+
if end - start > MAX_TEXT:
|
|
165
|
+
out["text_truncated"] = True
|
|
166
|
+
out["context"] = ("…" if lo > 0 else "") + text[lo:start] + shown + "…"
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _token(sha256: str, options: SearchOptions) -> str:
|
|
171
|
+
"""Ties a cursor to one document and one query, so it cannot resume a different search."""
|
|
172
|
+
key = json.dumps([sha256, *vars(options).values()], ensure_ascii=True)
|
|
173
|
+
return hashlib.blake2s(key.encode("ascii")).hexdigest()[:8]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_cursor(cursor: str | None, token: str) -> tuple[int, int]:
|
|
177
|
+
if cursor is None:
|
|
178
|
+
return 1, 0
|
|
179
|
+
m = _CURSOR.fullmatch(cursor)
|
|
180
|
+
if m is None or m[1] != token:
|
|
181
|
+
raise _bad_cursor()
|
|
182
|
+
return int(m[2]), int(m[3])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _bad_cursor() -> DocControlError:
|
|
186
|
+
return DocControlError(
|
|
187
|
+
ErrorCode.SCHEMA_INVALID,
|
|
188
|
+
"cursor does not belong to this document and query; repeat the search without it",
|
|
189
|
+
path="$.cursor",
|
|
190
|
+
)
|