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
+ """``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)
@@ -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())
@@ -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
+ )