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/read.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Read operations: `info`, `outline`, `stats`, `para.get`, `styles.list`, `style.get`,
|
|
2
|
+
`comments.list`, `revisions.list`.
|
|
3
|
+
|
|
4
|
+
None of them writes: the document is opened, read and closed. Lists are capped at
|
|
5
|
+
about 8,000 bytes and continue with `next` (see :mod:`doc_control.ops.paging`).
|
|
6
|
+
|
|
7
|
+
Extension points: every fact function in :data:`PARA_FACTS` is merged into the
|
|
8
|
+
`para.get` result and every one in :data:`OUTLINE_FACTS` into each `outline`
|
|
9
|
+
entry, so layout facts can be added without touching the handlers.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from doc_control.envelope import Reply, success
|
|
22
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
23
|
+
from doc_control.model.annotations import (
|
|
24
|
+
REVISION_TYPES,
|
|
25
|
+
read_comments,
|
|
26
|
+
read_revisions,
|
|
27
|
+
)
|
|
28
|
+
from doc_control.model.document import Document
|
|
29
|
+
from doc_control.model.format import para_format, run_format
|
|
30
|
+
from doc_control.model.index import Paragraph
|
|
31
|
+
from doc_control.model.parts import OFFICE_REL, W, parse_part, related_part
|
|
32
|
+
from doc_control.model.styles import Style
|
|
33
|
+
from doc_control.ops.paging import (
|
|
34
|
+
PAGING_PROPERTIES,
|
|
35
|
+
RESPONSE_BUDGET,
|
|
36
|
+
make_cursor,
|
|
37
|
+
paged,
|
|
38
|
+
read_cursor,
|
|
39
|
+
size,
|
|
40
|
+
)
|
|
41
|
+
from doc_control.registry import operation
|
|
42
|
+
from doc_control.safety.lock import find_lock_file
|
|
43
|
+
|
|
44
|
+
_DOC: dict[str, Any] = {"type": "string", "minLength": 1, "description": "Path to the .docx."}
|
|
45
|
+
_OUTLINE_TEXT = 160
|
|
46
|
+
_COMMENT_TEXT = 1000
|
|
47
|
+
_REVISION_TEXT = 200
|
|
48
|
+
_FONT_LIMIT = 20
|
|
49
|
+
_STYLE_TYPES = ["paragraph", "character", "table", "numbering"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ParagraphContext:
|
|
54
|
+
"""What a fact function gets to look at."""
|
|
55
|
+
|
|
56
|
+
doc: Document
|
|
57
|
+
paragraph: Paragraph
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
Fact = Callable[[ParagraphContext], dict[str, Any]]
|
|
61
|
+
PARA_FACTS: list[Fact] = []
|
|
62
|
+
OUTLINE_FACTS: list[Fact] = []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _list_schema(**extra: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"properties": {"doc": _DOC, **extra, **PAGING_PROPERTIES},
|
|
69
|
+
"required": ["doc"],
|
|
70
|
+
"additionalProperties": False,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _clip(text: str, limit: int) -> tuple[str, bool]:
|
|
75
|
+
return (text[:limit], True) if len(text) > limit else (text, False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# -- info ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
_CORE_FIELDS = {
|
|
81
|
+
"title": "title",
|
|
82
|
+
"subject": "subject",
|
|
83
|
+
"creator": "creator",
|
|
84
|
+
"lastModifiedBy": "last_modified_by",
|
|
85
|
+
"created": "created",
|
|
86
|
+
"modified": "modified",
|
|
87
|
+
"revision": "revision",
|
|
88
|
+
}
|
|
89
|
+
_APP_FIELDS = {"Application": "application", "Pages": "pages", "Words": "words"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _doc_props(doc: Document, part: str, fields: dict[str, str]) -> dict[str, Any]:
|
|
93
|
+
if part not in doc.pkg:
|
|
94
|
+
return {}
|
|
95
|
+
out: dict[str, Any] = {}
|
|
96
|
+
for child in parse_part(doc.pkg, part):
|
|
97
|
+
name = child.tag.rsplit("}", 1)[-1] if isinstance(child.tag, str) else ""
|
|
98
|
+
text = (child.text or "").strip()
|
|
99
|
+
if name in fields and text:
|
|
100
|
+
out[fields[name]] = int(text) if text.isdigit() else text
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@operation(
|
|
105
|
+
"info",
|
|
106
|
+
"read",
|
|
107
|
+
"File facts: size, hash, paragraph count, properties and which parts exist.",
|
|
108
|
+
{
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {"doc": _DOC},
|
|
111
|
+
"required": ["doc"],
|
|
112
|
+
"additionalProperties": False,
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
def info(request: dict[str, Any]) -> Reply:
|
|
116
|
+
doc = Document(request["doc"])
|
|
117
|
+
names = doc.pkg.names
|
|
118
|
+
settings = parse_part(doc.pkg, "word/settings.xml") if "word/settings.xml" in doc.pkg else None
|
|
119
|
+
tracking = settings is not None and settings.find(W + "trackRevisions") is not None
|
|
120
|
+
result: dict[str, Any] = {
|
|
121
|
+
"size_bytes": Path(doc.path).stat().st_size,
|
|
122
|
+
"parts": len(names),
|
|
123
|
+
"properties": _doc_props(doc, "docProps/core.xml", _CORE_FIELDS),
|
|
124
|
+
"application": _doc_props(doc, "docProps/app.xml", _APP_FIELDS),
|
|
125
|
+
"tracked_changes_on": tracking,
|
|
126
|
+
"word_lock": find_lock_file(Path(doc.path)) is not None,
|
|
127
|
+
"has": {
|
|
128
|
+
"styles": any(n == "word/styles.xml" for n in names),
|
|
129
|
+
"comments": any(n == "word/comments.xml" for n in names),
|
|
130
|
+
"footnotes": any(n == "word/footnotes.xml" for n in names),
|
|
131
|
+
"endnotes": any(n == "word/endnotes.xml" for n in names),
|
|
132
|
+
"headers": sum(1 for n in names if re.fullmatch(r"word/header\d*\.xml", n)),
|
|
133
|
+
"footers": sum(1 for n in names if re.fullmatch(r"word/footer\d*\.xml", n)),
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
return Reply(result, doc=doc.envelope_doc())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# -- outline ---------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@operation(
|
|
143
|
+
"outline",
|
|
144
|
+
"read",
|
|
145
|
+
"The headings of the document with level, style and address, in order.",
|
|
146
|
+
_list_schema(
|
|
147
|
+
max_level={
|
|
148
|
+
"type": "integer",
|
|
149
|
+
"minimum": 1,
|
|
150
|
+
"maximum": 9,
|
|
151
|
+
"description": "Only headings up to this level.",
|
|
152
|
+
}
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
def outline(request: dict[str, Any]) -> Reply:
|
|
156
|
+
doc = Document(request["doc"])
|
|
157
|
+
max_level = request.get("max_level", 9)
|
|
158
|
+
entries: list[dict[str, Any]] = []
|
|
159
|
+
for para in doc.index.paragraphs:
|
|
160
|
+
level = doc.heading_level(para)
|
|
161
|
+
if level is None or level > max_level or not para.text.strip():
|
|
162
|
+
continue
|
|
163
|
+
text, clipped = _clip(" ".join(para.text.split()), _OUTLINE_TEXT)
|
|
164
|
+
entry: dict[str, Any] = {
|
|
165
|
+
"at": str(para.address),
|
|
166
|
+
"level": level,
|
|
167
|
+
"style": doc.style_id(para),
|
|
168
|
+
"text": text,
|
|
169
|
+
}
|
|
170
|
+
if clipped:
|
|
171
|
+
entry["truncated"] = True
|
|
172
|
+
for fact in OUTLINE_FACTS:
|
|
173
|
+
entry.update(fact(ParagraphContext(doc, para)))
|
|
174
|
+
entries.append(entry)
|
|
175
|
+
return paged("outline", doc, request, "headings", entries)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# -- stats -----------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
_FONT_REF = re.compile(rb'<(?:[\w.-]+:)?rFonts\s[^>]*?\b(?:ascii|hAnsi)="([^"]*)"')
|
|
181
|
+
_RSTYLE_TAG = re.compile(rb"<(?:[\w.-]+:)?rStyle[\s/>]")
|
|
182
|
+
_TBLSTYLE_REF = re.compile(rb'<(?:[\w.-]+:)?tblStyle\s[^>]*?\bval="([^"]*)"')
|
|
183
|
+
_TBL_CHANGE = re.compile(
|
|
184
|
+
rb"<(?:[\w.-]+:)?tblPrChange[\s>].*?</(?:[\w.-]+:)?tblPrChange>", re.DOTALL
|
|
185
|
+
)
|
|
186
|
+
# Where a character style is not in use: deleted text and the "before" properties of a change.
|
|
187
|
+
_NOT_IN_USE = frozenset(W + name for name in ("del", "moveFrom", "rPrChange", "pPrChange"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _run_styles(elem: Any, out: Counter[str]) -> None:
|
|
191
|
+
for child in elem:
|
|
192
|
+
tag = child.tag
|
|
193
|
+
if not isinstance(tag, str) or tag in _NOT_IN_USE:
|
|
194
|
+
continue
|
|
195
|
+
if tag == W + "rStyle":
|
|
196
|
+
if (value := child.get(W + "val")) is not None:
|
|
197
|
+
out[value] += 1
|
|
198
|
+
else:
|
|
199
|
+
_run_styles(child, out)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _style_usage(doc: Document) -> Counter[tuple[str, str]]:
|
|
203
|
+
"""Uses of each style as ``(type, id)``.
|
|
204
|
+
|
|
205
|
+
Paragraphs are counted by style; character and table styles by reference, ignoring
|
|
206
|
+
references inside deleted runs and the old properties kept by a tracked format change.
|
|
207
|
+
"""
|
|
208
|
+
usage: Counter[tuple[str, str]] = Counter()
|
|
209
|
+
for para in doc.index.paragraphs:
|
|
210
|
+
style = doc.style_id(para)
|
|
211
|
+
if style is not None:
|
|
212
|
+
usage["paragraph", style] += 1
|
|
213
|
+
if _RSTYLE_TAG.search(doc.paragraph_bytes(para)):
|
|
214
|
+
runs: Counter[str] = Counter()
|
|
215
|
+
_run_styles(doc.index.spans.parse_paragraph(para.span_index), runs)
|
|
216
|
+
for key, n in runs.items():
|
|
217
|
+
usage["character", key] += n
|
|
218
|
+
data = doc.index.spans.data
|
|
219
|
+
tables = Counter(m.decode("utf-8", "replace") for m in _TBLSTYLE_REF.findall(data))
|
|
220
|
+
for change in _TBL_CHANGE.finditer(data):
|
|
221
|
+
tables.subtract(
|
|
222
|
+
m.decode("utf-8", "replace") for m in _TBLSTYLE_REF.findall(change.group(0))
|
|
223
|
+
)
|
|
224
|
+
for key, n in tables.items():
|
|
225
|
+
if n > 0:
|
|
226
|
+
usage["table", key] += n
|
|
227
|
+
return usage
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _fonts(doc: Document) -> list[dict[str, Any]]:
|
|
231
|
+
counts: Counter[str] = Counter()
|
|
232
|
+
parts = [doc.index.spans.data]
|
|
233
|
+
styles = related_part(doc.pkg, OFFICE_REL + "styles", "word/styles.xml")
|
|
234
|
+
if styles is not None:
|
|
235
|
+
parts.append(doc.pkg.read(styles))
|
|
236
|
+
for data in parts:
|
|
237
|
+
counts.update(raw.decode("utf-8", "replace") for raw in _FONT_REF.findall(data))
|
|
238
|
+
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
239
|
+
return [{"font": name, "uses": n} for name, n in ranked[:_FONT_LIMIT]]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _footnote_count(doc: Document) -> int:
|
|
243
|
+
name = related_part(doc.pkg, OFFICE_REL + "footnotes", "word/footnotes.xml")
|
|
244
|
+
if name is None:
|
|
245
|
+
return 0
|
|
246
|
+
notes = parse_part(doc.pkg, name).findall(W + "footnote")
|
|
247
|
+
return sum(1 for n in notes if n.get(W + "type", "normal") == "normal")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@operation(
|
|
251
|
+
"stats",
|
|
252
|
+
"read",
|
|
253
|
+
"Counts: paragraphs, words, headings, tables, comments, revisions, fonts and styles in use.",
|
|
254
|
+
{
|
|
255
|
+
"type": "object",
|
|
256
|
+
"properties": {"doc": _DOC},
|
|
257
|
+
"required": ["doc"],
|
|
258
|
+
"additionalProperties": False,
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
def stats(request: dict[str, Any]) -> Reply:
|
|
262
|
+
doc = Document(request["doc"])
|
|
263
|
+
paragraphs = doc.index.paragraphs
|
|
264
|
+
revisions = Counter(r.type for r in read_revisions(doc))
|
|
265
|
+
usage = _style_usage(doc)
|
|
266
|
+
result: dict[str, Any] = {
|
|
267
|
+
"paragraphs": len(paragraphs),
|
|
268
|
+
"empty_paragraphs": sum(1 for p in paragraphs if not p.text.strip()),
|
|
269
|
+
"words": sum(len(p.text.split()) for p in paragraphs),
|
|
270
|
+
"characters": sum(len(p.text) for p in paragraphs),
|
|
271
|
+
"headings": sum(1 for p in paragraphs if doc.heading_level(p) is not None),
|
|
272
|
+
"tables": doc.count_elements("tbl"),
|
|
273
|
+
"comments": len(read_comments(doc)),
|
|
274
|
+
"revisions": {"total": sum(revisions.values()), "by_type": dict(sorted(revisions.items()))},
|
|
275
|
+
"footnotes": _footnote_count(doc),
|
|
276
|
+
"styles_in_use": len(usage),
|
|
277
|
+
"fonts": _fonts(doc),
|
|
278
|
+
}
|
|
279
|
+
return Reply(result, doc=doc.envelope_doc())
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# -- para.get --------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _run_spans(doc: Document, para: Paragraph) -> list[dict[str, Any]]:
|
|
286
|
+
"""Spans of the visible text that carry direct character formatting."""
|
|
287
|
+
elem = doc.index.spans.parse_paragraph(para.span_index)
|
|
288
|
+
spans: list[dict[str, Any]] = []
|
|
289
|
+
for piece in para.pieces:
|
|
290
|
+
node = elem
|
|
291
|
+
for i in piece.path[:-1]:
|
|
292
|
+
node = node[i]
|
|
293
|
+
fmt = run_format(node.find(W + "rPr")) if node.tag == W + "r" else {}
|
|
294
|
+
if not fmt:
|
|
295
|
+
continue
|
|
296
|
+
last = spans[-1] if spans else None
|
|
297
|
+
if last is not None and last["end"] == piece.start and _same(last, fmt):
|
|
298
|
+
last["end"] = piece.end
|
|
299
|
+
else:
|
|
300
|
+
spans.append({"start": piece.start, "end": piece.end, **fmt})
|
|
301
|
+
return spans
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _same(span: dict[str, Any], fmt: dict[str, Any]) -> bool:
|
|
305
|
+
return {k: v for k, v in span.items() if k not in ("start", "end")} == fmt
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _clip_spans(spans: list[dict[str, Any]], start: int, end: int) -> list[dict[str, Any]]:
|
|
309
|
+
return [
|
|
310
|
+
{**s, "start": max(s["start"], start), "end": min(s["end"], end)}
|
|
311
|
+
for s in spans
|
|
312
|
+
if s["start"] < end and s["end"] > start
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@operation(
|
|
317
|
+
"para.get",
|
|
318
|
+
"read",
|
|
319
|
+
"One paragraph by address: text, style, direct formatting and formatted spans.",
|
|
320
|
+
{
|
|
321
|
+
"type": "object",
|
|
322
|
+
"properties": {
|
|
323
|
+
"doc": _DOC,
|
|
324
|
+
"at": {
|
|
325
|
+
"type": "string",
|
|
326
|
+
"minLength": 1,
|
|
327
|
+
"description": "Paragraph address from a read result, e.g. p142#a7b2a7b2.",
|
|
328
|
+
},
|
|
329
|
+
"limit": {
|
|
330
|
+
"type": "integer",
|
|
331
|
+
"minimum": 1,
|
|
332
|
+
"maximum": 1_000_000,
|
|
333
|
+
"description": "Most characters of text to return.",
|
|
334
|
+
},
|
|
335
|
+
"cursor": PAGING_PROPERTIES["cursor"],
|
|
336
|
+
},
|
|
337
|
+
"required": ["doc", "at"],
|
|
338
|
+
"additionalProperties": False,
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
def para_get(request: dict[str, Any]) -> Reply:
|
|
342
|
+
doc = Document(request["doc"])
|
|
343
|
+
para = doc.index.resolve_address(request["at"])
|
|
344
|
+
start = read_cursor("para.get", doc, request)
|
|
345
|
+
total = len(para.text)
|
|
346
|
+
if start > total:
|
|
347
|
+
raise DocControlError(
|
|
348
|
+
ErrorCode.SCHEMA_INVALID, "cursor is beyond the end of the paragraph", path="$.cursor"
|
|
349
|
+
)
|
|
350
|
+
style = doc.style_id(para)
|
|
351
|
+
style_def = doc.styles.get(style) if style is not None else None
|
|
352
|
+
elem = doc.index.spans.parse_paragraph(para.span_index)
|
|
353
|
+
head: dict[str, Any] = {
|
|
354
|
+
"at": str(para.address),
|
|
355
|
+
"number": para.number,
|
|
356
|
+
"length": total,
|
|
357
|
+
"style": style,
|
|
358
|
+
"style_name": style_def.name if style_def is not None else None,
|
|
359
|
+
"format": para_format(elem.find(W + "pPr")),
|
|
360
|
+
}
|
|
361
|
+
for fact in PARA_FACTS:
|
|
362
|
+
head.update(fact(ParagraphContext(doc, para)))
|
|
363
|
+
spans = _run_spans(doc, para)
|
|
364
|
+
ceiling = min(total - start, request.get("limit", total))
|
|
365
|
+
count = ceiling
|
|
366
|
+
while True:
|
|
367
|
+
end = start + count
|
|
368
|
+
result = {**head, "offset": start, "text": para.text[start:end]}
|
|
369
|
+
result["runs"] = _clip_spans(spans, start, end)
|
|
370
|
+
cursor = make_cursor("para.get", doc, request, end) if end < total else None
|
|
371
|
+
reply = Reply(result, doc=doc.envelope_doc(), next_cursor=cursor)
|
|
372
|
+
envelope = success("para.get", result, doc=reply.doc, next_cursor=cursor)
|
|
373
|
+
if count <= 1 or size(envelope) <= RESPONSE_BUDGET:
|
|
374
|
+
return reply
|
|
375
|
+
count = max(1, int(count * 0.8))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# -- styles ----------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _style_row(style: Style, used: int) -> dict[str, Any]:
|
|
382
|
+
row: dict[str, Any] = {"id": style.id, "name": style.name, "type": style.type}
|
|
383
|
+
if style.based_on:
|
|
384
|
+
row["based_on"] = style.based_on
|
|
385
|
+
for key in ("default", "custom"):
|
|
386
|
+
if getattr(style, key):
|
|
387
|
+
row[key] = True
|
|
388
|
+
row["used"] = used
|
|
389
|
+
return row
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@operation(
|
|
393
|
+
"styles.list",
|
|
394
|
+
"read",
|
|
395
|
+
"Styles defined in the document with how many times each is used.",
|
|
396
|
+
_list_schema(
|
|
397
|
+
type={"type": "string", "enum": _STYLE_TYPES, "description": "Only this kind of style."},
|
|
398
|
+
used_only={"type": "boolean", "description": "Only styles used at least once."},
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
def styles_list(request: dict[str, Any]) -> Reply:
|
|
402
|
+
doc = Document(request["doc"])
|
|
403
|
+
usage = _style_usage(doc)
|
|
404
|
+
rows = []
|
|
405
|
+
for style in doc.styles.styles:
|
|
406
|
+
if "type" in request and style.type != request["type"]:
|
|
407
|
+
continue
|
|
408
|
+
used = usage[style.type, style.id]
|
|
409
|
+
if request.get("used_only") and not used:
|
|
410
|
+
continue
|
|
411
|
+
rows.append(_style_row(style, used))
|
|
412
|
+
return paged("styles.list", doc, request, "styles", rows)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@operation(
|
|
416
|
+
"style.get",
|
|
417
|
+
"read",
|
|
418
|
+
"One style by id or name: its own formatting, inherited formatting and heading level.",
|
|
419
|
+
{
|
|
420
|
+
"type": "object",
|
|
421
|
+
"properties": {
|
|
422
|
+
"doc": _DOC,
|
|
423
|
+
"style": {"type": "string", "minLength": 1, "description": "Style id or name."},
|
|
424
|
+
},
|
|
425
|
+
"required": ["doc", "style"],
|
|
426
|
+
"additionalProperties": False,
|
|
427
|
+
},
|
|
428
|
+
)
|
|
429
|
+
def style_get(request: dict[str, Any]) -> Reply:
|
|
430
|
+
doc = Document(request["doc"])
|
|
431
|
+
style = doc.styles.find(request["style"])
|
|
432
|
+
if style is None:
|
|
433
|
+
raise DocControlError(
|
|
434
|
+
ErrorCode.STYLE_NOT_FOUND, f"no style {request['style']!r}", style=request["style"]
|
|
435
|
+
)
|
|
436
|
+
ppr, rpr = doc.styles.effective(style.id)
|
|
437
|
+
result = _style_row(style, _style_usage(doc)[style.type, style.id])
|
|
438
|
+
for key in ("next", "link"):
|
|
439
|
+
if getattr(style, key):
|
|
440
|
+
result[key] = getattr(style, key)
|
|
441
|
+
result["quick"] = style.quick
|
|
442
|
+
result["heading_level"] = doc.styles.heading_level(style.id)
|
|
443
|
+
result["chain"] = [s.id for s in doc.styles.chain(style.id)]
|
|
444
|
+
result["own"] = {"paragraph": style.ppr, "run": style.rpr}
|
|
445
|
+
result["effective"] = {"paragraph": ppr, "run": rpr}
|
|
446
|
+
return Reply(result, doc=doc.envelope_doc())
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# -- comments and revisions ------------------------------------------------
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@operation(
|
|
453
|
+
"comments.list",
|
|
454
|
+
"read",
|
|
455
|
+
"Comments in thread order (each root then its replies) with resolved state and location.",
|
|
456
|
+
_list_schema(
|
|
457
|
+
resolved={"type": "boolean", "description": "Only resolved (true) or open (false)."},
|
|
458
|
+
author={"type": "string", "minLength": 1, "description": "Only this author."},
|
|
459
|
+
),
|
|
460
|
+
)
|
|
461
|
+
def comments_list(request: dict[str, Any]) -> Reply:
|
|
462
|
+
doc = Document(request["doc"])
|
|
463
|
+
rows: list[dict[str, Any]] = []
|
|
464
|
+
for c in read_comments(doc):
|
|
465
|
+
if "resolved" in request and c.resolved != request["resolved"]:
|
|
466
|
+
continue
|
|
467
|
+
if "author" in request and c.author != request["author"]:
|
|
468
|
+
continue
|
|
469
|
+
text, clipped = _clip(c.text, _COMMENT_TEXT)
|
|
470
|
+
row: dict[str, Any] = {
|
|
471
|
+
"id": c.id,
|
|
472
|
+
"author": c.author,
|
|
473
|
+
"date": c.date,
|
|
474
|
+
"text": text,
|
|
475
|
+
"resolved": c.resolved,
|
|
476
|
+
}
|
|
477
|
+
if clipped:
|
|
478
|
+
row["truncated"] = True
|
|
479
|
+
if c.reply_to is not None:
|
|
480
|
+
row["reply_to"] = c.reply_to
|
|
481
|
+
if c.at is not None:
|
|
482
|
+
row["at"] = c.at
|
|
483
|
+
rows.append(row)
|
|
484
|
+
return paged("comments.list", doc, request, "comments", rows)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@operation(
|
|
488
|
+
"revisions.list",
|
|
489
|
+
"read",
|
|
490
|
+
"Tracked changes in document order with author, date, type, text and paragraph address.",
|
|
491
|
+
_list_schema(
|
|
492
|
+
author={"type": "string", "minLength": 1, "description": "Only this author."},
|
|
493
|
+
type={"type": "string", "enum": list(REVISION_TYPES), "description": "Only this type."},
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
def revisions_list(request: dict[str, Any]) -> Reply:
|
|
497
|
+
doc = Document(request["doc"])
|
|
498
|
+
rows: list[dict[str, Any]] = []
|
|
499
|
+
for r in read_revisions(doc):
|
|
500
|
+
if "author" in request and r.author != request["author"]:
|
|
501
|
+
continue
|
|
502
|
+
if "type" in request and r.type != request["type"]:
|
|
503
|
+
continue
|
|
504
|
+
text, clipped = _clip(r.text, _REVISION_TEXT)
|
|
505
|
+
row: dict[str, Any] = {
|
|
506
|
+
"id": r.id,
|
|
507
|
+
"type": r.type,
|
|
508
|
+
"author": r.author,
|
|
509
|
+
"date": r.date,
|
|
510
|
+
"at": r.at,
|
|
511
|
+
}
|
|
512
|
+
if text:
|
|
513
|
+
row["text"] = text
|
|
514
|
+
if clipped:
|
|
515
|
+
row["truncated"] = True
|
|
516
|
+
rows.append(row)
|
|
517
|
+
return paged("revisions.list", doc, request, "revisions", rows)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""`recipe.list` and `recipe.run`.
|
|
2
|
+
|
|
3
|
+
``recipe.run`` never writes: it returns ``result.batch``, a ready-to-send ``batch``
|
|
4
|
+
request (``doc``, ``ops`` and ``expect.sha256``), so applying a recipe is always a
|
|
5
|
+
separate, reviewable ``batch`` call. ``dry_run`` is accepted for symmetry with ``batch``
|
|
6
|
+
and changes nothing. A recipe is a stored one (``recipe``: name) or an inline
|
|
7
|
+
``definition``; both go through the same strict validation (``RECIPE_INVALID``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from doc_control.envelope import Reply
|
|
17
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
18
|
+
from doc_control.model.document import Document
|
|
19
|
+
from doc_control.recipes.engine import propose
|
|
20
|
+
from doc_control.recipes.schema import validate_recipe
|
|
21
|
+
from doc_control.recipes.store import MAX_RECIPE_BYTES, catalogue
|
|
22
|
+
from doc_control.registry import operation
|
|
23
|
+
|
|
24
|
+
_LIST: dict[str, Any] = {"type": "object", "properties": {}, "additionalProperties": False}
|
|
25
|
+
|
|
26
|
+
_RUN: dict[str, Any] = {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"doc": {"type": "string", "minLength": 1, "description": "Path to the .docx file."},
|
|
30
|
+
"recipe": {"type": "string", "minLength": 1, "description": "Name from recipe.list."},
|
|
31
|
+
"definition": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"description": "An inline declarative recipe (same schema as a stored one).",
|
|
34
|
+
},
|
|
35
|
+
"dry_run": {
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"description": "Accepted for symmetry; recipe.run never writes.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
"required": ["doc"],
|
|
41
|
+
"additionalProperties": False,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@operation(
|
|
46
|
+
"recipe.list",
|
|
47
|
+
"read",
|
|
48
|
+
"List the bundled and user recipes, and any recipe files that are invalid.",
|
|
49
|
+
_LIST,
|
|
50
|
+
)
|
|
51
|
+
def recipe_list(_: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
cat = catalogue()
|
|
53
|
+
return {
|
|
54
|
+
"recipes": [
|
|
55
|
+
{
|
|
56
|
+
"name": name,
|
|
57
|
+
"version": recipe["version"],
|
|
58
|
+
"description": recipe.get("description", ""),
|
|
59
|
+
"source": source,
|
|
60
|
+
}
|
|
61
|
+
for name, (source, recipe) in sorted(cat.recipes.items())
|
|
62
|
+
],
|
|
63
|
+
"invalid": cat.invalid,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@operation(
|
|
68
|
+
"recipe.run",
|
|
69
|
+
"write",
|
|
70
|
+
"Run a recipe and return a batch proposal (repeated until stable); apply it with batch.",
|
|
71
|
+
_RUN,
|
|
72
|
+
)
|
|
73
|
+
def recipe_run(request: dict[str, Any]) -> Reply:
|
|
74
|
+
if ("recipe" in request) == ("definition" in request):
|
|
75
|
+
raise DocControlError(
|
|
76
|
+
ErrorCode.SCHEMA_INVALID, "give exactly one of `recipe` and `definition`"
|
|
77
|
+
)
|
|
78
|
+
if "definition" in request:
|
|
79
|
+
size = len(json.dumps(request["definition"], ensure_ascii=False).encode("utf-8"))
|
|
80
|
+
if size > MAX_RECIPE_BYTES:
|
|
81
|
+
raise DocControlError(
|
|
82
|
+
ErrorCode.RECIPE_INVALID, f"definition is larger than {MAX_RECIPE_BYTES} bytes"
|
|
83
|
+
)
|
|
84
|
+
recipe = validate_recipe(request["definition"])
|
|
85
|
+
else:
|
|
86
|
+
found = catalogue().recipes.get(request["recipe"])
|
|
87
|
+
if found is None:
|
|
88
|
+
raise DocControlError(
|
|
89
|
+
ErrorCode.RECIPE_INVALID,
|
|
90
|
+
f"no recipe named {request['recipe']!r}; list them with recipe.list",
|
|
91
|
+
)
|
|
92
|
+
recipe = found[1]
|
|
93
|
+
doc = Document(Path(request["doc"]).as_posix())
|
|
94
|
+
paragraphs = [(p.address, p.text) for p in doc.index.paragraphs if p.text]
|
|
95
|
+
proposal = propose(recipe, paragraphs)
|
|
96
|
+
result: dict[str, Any] = {
|
|
97
|
+
"recipe": recipe["recipe"],
|
|
98
|
+
"applied": False,
|
|
99
|
+
"matches": proposal.matches,
|
|
100
|
+
"passes": proposal.passes,
|
|
101
|
+
"stable": proposal.stable,
|
|
102
|
+
"batch": {
|
|
103
|
+
"doc": request["doc"],
|
|
104
|
+
"ops": proposal.ops,
|
|
105
|
+
"expect": {"sha256": doc.sha256},
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
return Reply(result, doc=doc.envelope_doc(), warnings=proposal.warnings)
|