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,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)