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
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""The `segment` read operation: chapters, scenes, sentences and dialogue lines.
|
|
2
|
+
|
|
3
|
+
``unit`` picks the list. Chapters and scenes come from :mod:`doc_control.model.structure`
|
|
4
|
+
(stable ids, the signals behind each detection, ``overrides`` and ``profile``);
|
|
5
|
+
sentences and dialogue lines from :mod:`doc_control.model.segment`. Lists are capped at
|
|
6
|
+
about 8,000 bytes and continue with `next`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from doc_control.envelope import Reply
|
|
14
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
15
|
+
from doc_control.model.address import FINGERPRINT_LENGTH, Address
|
|
16
|
+
from doc_control.model.document import Document
|
|
17
|
+
from doc_control.model.segment import (
|
|
18
|
+
DEFAULT_LANGUAGE,
|
|
19
|
+
LANGUAGES,
|
|
20
|
+
dialogue_lines,
|
|
21
|
+
split_sentences,
|
|
22
|
+
)
|
|
23
|
+
from doc_control.model.structure import (
|
|
24
|
+
ACTIONS,
|
|
25
|
+
KINDS,
|
|
26
|
+
Override,
|
|
27
|
+
Profile,
|
|
28
|
+
Structure,
|
|
29
|
+
detect,
|
|
30
|
+
paragraphs_of,
|
|
31
|
+
)
|
|
32
|
+
from doc_control.ops.paging import PAGING_PROPERTIES, paged
|
|
33
|
+
from doc_control.registry import operation
|
|
34
|
+
|
|
35
|
+
_TEXT = 160
|
|
36
|
+
_SUPPRESSED_LIMIT = 20
|
|
37
|
+
_UNITS = ["chapters", "scenes", "sentences", "dialogue"]
|
|
38
|
+
_HEX = frozenset("0123456789abcdef")
|
|
39
|
+
|
|
40
|
+
PROFILE_PROPERTIES: dict[str, Any] = {
|
|
41
|
+
"min_chapter_words": {
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"minimum": 0,
|
|
44
|
+
"description": (
|
|
45
|
+
"Words a chapter needs after its heading; a shorter one is not a chapter "
|
|
46
|
+
"(default 0). Part headings and forced chapters are exempt."
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
"chapter_level": {
|
|
50
|
+
"type": "integer",
|
|
51
|
+
"minimum": 1,
|
|
52
|
+
"maximum": 9,
|
|
53
|
+
"description": "Deepest heading or outline level that starts a chapter (default 1).",
|
|
54
|
+
},
|
|
55
|
+
"scene_blank_lines": {
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"minimum": 0,
|
|
58
|
+
"maximum": 50,
|
|
59
|
+
"description": "Empty paragraphs before text that start a scene; 0 = never (default).",
|
|
60
|
+
},
|
|
61
|
+
"heading_styles": {
|
|
62
|
+
"type": "boolean",
|
|
63
|
+
"description": "Heading styles and outline levels start chapters (default true).",
|
|
64
|
+
},
|
|
65
|
+
"chapter_patterns": {
|
|
66
|
+
"type": "boolean",
|
|
67
|
+
"description": "`Capítulo 3`, `Prólogo`, numerals start chapters (default true).",
|
|
68
|
+
},
|
|
69
|
+
"language": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"enum": list(LANGUAGES),
|
|
72
|
+
"description": "es (default) accepts a lone `I` as a numeral; others need more signals.",
|
|
73
|
+
},
|
|
74
|
+
"scene_markers": {
|
|
75
|
+
"type": "boolean",
|
|
76
|
+
"description": "Ornament paragraphs (`* * *`) start scenes (default true).",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
OVERRIDES_SCHEMA: dict[str, Any] = {
|
|
82
|
+
"type": "array",
|
|
83
|
+
"description": "Force or deny a chapter or scene at paragraphs by fingerprint.",
|
|
84
|
+
"items": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"properties": {
|
|
87
|
+
"fp": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"minLength": FINGERPRINT_LENGTH,
|
|
90
|
+
"description": "Paragraph fingerprint, 8 lower-case hex digits.",
|
|
91
|
+
},
|
|
92
|
+
"kind": {"type": "string", "enum": list(KINDS)},
|
|
93
|
+
"action": {"type": "string", "enum": list(ACTIONS)},
|
|
94
|
+
"nth": {
|
|
95
|
+
"type": "integer",
|
|
96
|
+
"minimum": 1,
|
|
97
|
+
"description": "Only the nth paragraph with this fingerprint.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
"required": ["fp", "kind", "action"],
|
|
101
|
+
"additionalProperties": False,
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
PROFILE_SCHEMA: dict[str, Any] = {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"description": "Detection settings for this document.",
|
|
108
|
+
"properties": PROFILE_PROPERTIES,
|
|
109
|
+
"additionalProperties": False,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@operation(
|
|
114
|
+
"segment",
|
|
115
|
+
"read",
|
|
116
|
+
"Chapters and scenes with stable ids and detection signals, or sentences and dialogue lines.",
|
|
117
|
+
{
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {
|
|
120
|
+
"doc": {"type": "string", "minLength": 1, "description": "Path to the .docx."},
|
|
121
|
+
"unit": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"enum": _UNITS,
|
|
124
|
+
"description": "What to list (default chapters).",
|
|
125
|
+
},
|
|
126
|
+
"chapter": {
|
|
127
|
+
"type": "string",
|
|
128
|
+
"minLength": 1,
|
|
129
|
+
"description": "scenes only: the chapter id whose scenes to list.",
|
|
130
|
+
},
|
|
131
|
+
"range": {
|
|
132
|
+
"type": "string",
|
|
133
|
+
"minLength": 1,
|
|
134
|
+
"description": "sentences and dialogue only: paragraph address or range to cover.",
|
|
135
|
+
},
|
|
136
|
+
"language": {
|
|
137
|
+
"type": "string",
|
|
138
|
+
"enum": list(LANGUAGES),
|
|
139
|
+
"description": "Sentence rules (default es).",
|
|
140
|
+
},
|
|
141
|
+
"overrides": OVERRIDES_SCHEMA,
|
|
142
|
+
"profile": PROFILE_SCHEMA,
|
|
143
|
+
**PAGING_PROPERTIES,
|
|
144
|
+
},
|
|
145
|
+
"required": ["doc"],
|
|
146
|
+
"additionalProperties": False,
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
def segment(request: dict[str, Any]) -> Reply:
|
|
150
|
+
doc = Document(request["doc"])
|
|
151
|
+
unit = request.get("unit", "chapters")
|
|
152
|
+
language = request.get("language", DEFAULT_LANGUAGE)
|
|
153
|
+
if unit == "sentences":
|
|
154
|
+
return paged("segment", doc, request, "sentences", _sentences(doc, request, language))
|
|
155
|
+
if unit == "dialogue":
|
|
156
|
+
return paged("segment", doc, request, "dialogue", _dialogue(doc, request))
|
|
157
|
+
found = detect(paragraphs_of(doc), profile_of(request), overrides_of(request))
|
|
158
|
+
base: dict[str, Any] = {"unit": unit}
|
|
159
|
+
if found.unmatched_overrides:
|
|
160
|
+
base["unmatched_overrides"] = [
|
|
161
|
+
{
|
|
162
|
+
"fp": o.fp,
|
|
163
|
+
"kind": o.kind,
|
|
164
|
+
"action": o.action,
|
|
165
|
+
**({"nth": o.nth} if o.nth is not None else {}),
|
|
166
|
+
}
|
|
167
|
+
for o in found.unmatched_overrides
|
|
168
|
+
]
|
|
169
|
+
kind = "chapter" if unit == "chapters" else "scene"
|
|
170
|
+
shown = [s for s in found.suppressed if s[1] == kind]
|
|
171
|
+
if shown:
|
|
172
|
+
base["suppressed_total"] = len(shown)
|
|
173
|
+
base["suppressed"] = [
|
|
174
|
+
{"at": str(_address(doc, n)), "kind": k, "reason": reason}
|
|
175
|
+
for n, k, reason in shown[:_SUPPRESSED_LIMIT]
|
|
176
|
+
]
|
|
177
|
+
if unit == "chapters":
|
|
178
|
+
return paged("segment", doc, request, "chapters", _chapters(doc, found), base)
|
|
179
|
+
return paged("segment", doc, request, "scenes", _scenes(doc, request, found), base)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _address(doc: Document, number: int) -> Address:
|
|
183
|
+
return doc.index.paragraph(number).address
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def profile_of(request: dict[str, Any]) -> Profile:
|
|
187
|
+
language = request.get("language", DEFAULT_LANGUAGE)
|
|
188
|
+
return Profile(**{"language": language, **request.get("profile", {})})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def overrides_of(request: dict[str, Any]) -> list[Override]:
|
|
192
|
+
out: list[Override] = []
|
|
193
|
+
for i, raw in enumerate(request.get("overrides", [])):
|
|
194
|
+
fp = raw["fp"]
|
|
195
|
+
if len(fp) != FINGERPRINT_LENGTH or not set(fp) <= _HEX:
|
|
196
|
+
raise DocControlError(
|
|
197
|
+
ErrorCode.SCHEMA_INVALID,
|
|
198
|
+
f"override fingerprint must be {FINGERPRINT_LENGTH} lower-case hex digits",
|
|
199
|
+
path=f"$.overrides[{i}].fp",
|
|
200
|
+
)
|
|
201
|
+
out.append(Override(fp, raw["kind"], raw["action"], raw.get("nth")))
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _chapters(doc: Document, found: Structure) -> list[dict[str, Any]]:
|
|
206
|
+
scenes: dict[str | None, int] = {}
|
|
207
|
+
for scene in found.scenes:
|
|
208
|
+
scenes[scene.chapter] = scenes.get(scene.chapter, 0) + 1
|
|
209
|
+
out: list[dict[str, Any]] = []
|
|
210
|
+
for chapter in found.chapters:
|
|
211
|
+
para = doc.index.paragraph(chapter.first)
|
|
212
|
+
out.append(
|
|
213
|
+
{
|
|
214
|
+
"id": chapter.id,
|
|
215
|
+
"at": str(para.address),
|
|
216
|
+
"kind": chapter.kind,
|
|
217
|
+
"number": chapter.number,
|
|
218
|
+
"text": " ".join(para.text.split())[:_TEXT],
|
|
219
|
+
"first": chapter.first,
|
|
220
|
+
"last": chapter.last,
|
|
221
|
+
"words": chapter.words,
|
|
222
|
+
"scenes": scenes.get(chapter.id, 0),
|
|
223
|
+
"signals": list(chapter.signals),
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _scenes(doc: Document, request: dict[str, Any], found: Structure) -> list[dict[str, Any]]:
|
|
230
|
+
wanted = request.get("chapter")
|
|
231
|
+
if wanted is not None and wanted not in {c.id for c in found.chapters}:
|
|
232
|
+
raise DocControlError(
|
|
233
|
+
ErrorCode.ADDRESS_INVALID, f"no chapter {wanted!r} in the document", chapter=wanted
|
|
234
|
+
)
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
"id": scene.id,
|
|
238
|
+
"chapter": scene.chapter,
|
|
239
|
+
"at": str(_address(doc, scene.first)),
|
|
240
|
+
"first": scene.first,
|
|
241
|
+
"last": scene.last,
|
|
242
|
+
"words": scene.words,
|
|
243
|
+
"signals": list(scene.signals),
|
|
244
|
+
}
|
|
245
|
+
for scene in found.scenes
|
|
246
|
+
if wanted is None or scene.chapter == wanted
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _paragraphs_in(doc: Document, request: dict[str, Any]) -> range:
|
|
251
|
+
if "range" not in request:
|
|
252
|
+
return range(1, len(doc.index) + 1)
|
|
253
|
+
resolved = doc.index.resolve_range(request["range"], request.get("language", DEFAULT_LANGUAGE))
|
|
254
|
+
return range(resolved.first.number, resolved.last.number + 1)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _sentences(doc: Document, request: dict[str, Any], language: str) -> list[dict[str, Any]]:
|
|
258
|
+
out: list[dict[str, Any]] = []
|
|
259
|
+
for number in _paragraphs_in(doc, request):
|
|
260
|
+
para = doc.index.paragraph(number)
|
|
261
|
+
for k, span in enumerate(split_sentences(para.text, language), 1):
|
|
262
|
+
out.append(
|
|
263
|
+
{
|
|
264
|
+
"at": f"{para.address}.s{k}",
|
|
265
|
+
"start": span.start,
|
|
266
|
+
"end": span.end,
|
|
267
|
+
"text": span.text(para.text)[:_TEXT],
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _dialogue(doc: Document, request: dict[str, Any]) -> list[dict[str, Any]]:
|
|
274
|
+
out: list[dict[str, Any]] = []
|
|
275
|
+
for number in _paragraphs_in(doc, request):
|
|
276
|
+
para = doc.index.paragraph(number)
|
|
277
|
+
for line in dialogue_lines(para.text):
|
|
278
|
+
out.append(
|
|
279
|
+
{
|
|
280
|
+
"at": str(para.address),
|
|
281
|
+
"start": line.start,
|
|
282
|
+
"end": line.end,
|
|
283
|
+
"parts": [
|
|
284
|
+
{"kind": part.kind, "start": part.start, "end": part.end}
|
|
285
|
+
for part in line.parts
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
return out
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""JSON style sets: ``styleset.export`` (read) and ``styleset.install`` (write).
|
|
2
|
+
|
|
3
|
+
A style set is ``{"styleset": name, "version": 1, "styles": [...]}``; each style carries
|
|
4
|
+
``id``, ``name``, ``type``, ``based_on``, ``next``, ``link``, ``default``, ``custom``,
|
|
5
|
+
``quick`` and the ``run`` / ``paragraph`` formatting in the vocabulary of ``style.get``
|
|
6
|
+
(lengths in twips). Installing an exported set into a document leaves the definitions
|
|
7
|
+
of those styles equal to the exported ones. What a set does not carry (table style
|
|
8
|
+
conditional formatting, numbering links, theme fonts, latent styles) is left as it is
|
|
9
|
+
in the document on a replace and absent on a create.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from doc_control.envelope import Reply
|
|
19
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
20
|
+
from doc_control.model.document import Document
|
|
21
|
+
from doc_control.model.styles import Style
|
|
22
|
+
from doc_control.ooxml.styledef import apply_properties, invalid, set_child
|
|
23
|
+
from doc_control.ops.stylewrite import (
|
|
24
|
+
TYPES,
|
|
25
|
+
StyleSession,
|
|
26
|
+
apply_refs,
|
|
27
|
+
commit_styles,
|
|
28
|
+
create_style,
|
|
29
|
+
)
|
|
30
|
+
from doc_control.registry import operation
|
|
31
|
+
|
|
32
|
+
VERSION = 1
|
|
33
|
+
_DOC = {"type": "string", "minLength": 1, "description": "Path to the .docx."}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def style_entry(style: Style) -> dict[str, Any]:
|
|
37
|
+
entry: dict[str, Any] = {"id": style.id, "name": style.name, "type": style.type}
|
|
38
|
+
for key in ("based_on", "next", "link"):
|
|
39
|
+
if getattr(style, key):
|
|
40
|
+
entry[key] = getattr(style, key)
|
|
41
|
+
for key in ("default", "custom", "quick"):
|
|
42
|
+
if getattr(style, key):
|
|
43
|
+
entry[key] = True
|
|
44
|
+
if style.rpr:
|
|
45
|
+
entry["run"] = style.rpr
|
|
46
|
+
if style.ppr:
|
|
47
|
+
entry["paragraph"] = style.ppr
|
|
48
|
+
return entry
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@operation(
|
|
52
|
+
"styleset.export",
|
|
53
|
+
"read",
|
|
54
|
+
"The document's styles as a JSON style set; optionally also saved to a file.",
|
|
55
|
+
{
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"doc": _DOC,
|
|
59
|
+
"name": {"type": "string", "minLength": 1, "description": "Set name."},
|
|
60
|
+
"styles": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"items": {"type": "string", "minLength": 1},
|
|
63
|
+
"description": "Only these styles (ids or names).",
|
|
64
|
+
},
|
|
65
|
+
"custom_only": {"type": "boolean", "description": "Only user-defined styles."},
|
|
66
|
+
"out": {"type": "string", "minLength": 1, "description": "Also write the JSON here."},
|
|
67
|
+
},
|
|
68
|
+
"required": ["doc"],
|
|
69
|
+
"additionalProperties": False,
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
def styleset_export(request: dict[str, Any]) -> Reply:
|
|
73
|
+
doc = Document(request["doc"])
|
|
74
|
+
styleset = select_styleset(doc, request)
|
|
75
|
+
entries = styleset["styles"]
|
|
76
|
+
result: dict[str, Any] = {"styleset": styleset, "count": len(entries)}
|
|
77
|
+
if "out" in request:
|
|
78
|
+
write_new_file(Path(request["out"]), Path(request["doc"]), _json_bytes(styleset))
|
|
79
|
+
result["path"] = request["out"]
|
|
80
|
+
return Reply(result, doc=doc.envelope_doc())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _json_bytes(styleset: dict[str, Any]) -> bytes:
|
|
84
|
+
return (json.dumps(styleset, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def select_styleset(doc: Document, request: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
"""The document's styles (``styles`` / ``custom_only`` applied) as a style set."""
|
|
89
|
+
chosen = request.get("styles")
|
|
90
|
+
wanted: set[str] | None = None
|
|
91
|
+
if chosen is not None:
|
|
92
|
+
wanted = set()
|
|
93
|
+
for key in chosen:
|
|
94
|
+
found = doc.styles.find(key)
|
|
95
|
+
if found is None:
|
|
96
|
+
raise DocControlError(ErrorCode.STYLE_NOT_FOUND, f"no style {key!r}", style=key)
|
|
97
|
+
wanted.add(found.id)
|
|
98
|
+
entries = [
|
|
99
|
+
style_entry(s)
|
|
100
|
+
for s in doc.styles.styles
|
|
101
|
+
if (wanted is None or s.id in wanted) and not (request.get("custom_only") and not s.custom)
|
|
102
|
+
]
|
|
103
|
+
return {
|
|
104
|
+
"styleset": request.get("name") or Path(request["doc"]).stem,
|
|
105
|
+
"version": VERSION,
|
|
106
|
+
"styles": entries,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def write_new_file(out: Path, doc: Path, payload: bytes) -> None:
|
|
111
|
+
"""Write the set to a file that does not exist yet; never the document itself."""
|
|
112
|
+
same = out.exists() and doc.exists() and out.samefile(doc)
|
|
113
|
+
if same or out.resolve() == doc.resolve():
|
|
114
|
+
raise DocControlError(
|
|
115
|
+
ErrorCode.SCHEMA_INVALID, f"out is the document: {out}", path=str(out)
|
|
116
|
+
)
|
|
117
|
+
if out.exists():
|
|
118
|
+
raise DocControlError(
|
|
119
|
+
ErrorCode.SCHEMA_INVALID,
|
|
120
|
+
f"out already exists, nothing was written: {out}",
|
|
121
|
+
path=str(out),
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
with out.open("xb") as fh:
|
|
125
|
+
fh.write(payload)
|
|
126
|
+
except FileExistsError as exc:
|
|
127
|
+
raise DocControlError(
|
|
128
|
+
ErrorCode.SCHEMA_INVALID, f"out already exists: {out}", path=str(out)
|
|
129
|
+
) from exc
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
raise DocControlError(
|
|
132
|
+
ErrorCode.SCHEMA_INVALID,
|
|
133
|
+
f"cannot write out: {type(exc).__name__}",
|
|
134
|
+
path=str(out),
|
|
135
|
+
) from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _validated(styleset: Any) -> list[dict[str, Any]]:
|
|
139
|
+
if not isinstance(styleset, dict) or not isinstance(styleset.get("styles"), list):
|
|
140
|
+
raise invalid("a style set is an object with a 'styles' list")
|
|
141
|
+
if styleset.get("version") != VERSION:
|
|
142
|
+
raise invalid(
|
|
143
|
+
f"unsupported style set version {styleset.get('version')!r}", supported=VERSION
|
|
144
|
+
)
|
|
145
|
+
seen: set[str] = set()
|
|
146
|
+
for i, entry in enumerate(styleset["styles"]):
|
|
147
|
+
if not isinstance(entry, dict):
|
|
148
|
+
raise invalid(f"styles[{i}] must be an object")
|
|
149
|
+
for key in ("id", "name"):
|
|
150
|
+
if not isinstance(entry.get(key), str) or not entry[key]:
|
|
151
|
+
raise invalid(f"styles[{i}].{key} must be a non-empty string")
|
|
152
|
+
if entry.get("type", "paragraph") not in TYPES:
|
|
153
|
+
raise invalid(f"styles[{i}].type must be one of {', '.join(TYPES)}")
|
|
154
|
+
if entry["id"] in seen:
|
|
155
|
+
raise invalid(f"styles[{i}]: id {entry['id']!r} appears twice")
|
|
156
|
+
seen.add(entry["id"])
|
|
157
|
+
entries: list[dict[str, Any]] = styleset["styles"]
|
|
158
|
+
return entries
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _install(
|
|
162
|
+
session: StyleSession, entries: list[dict[str, Any]], mode: str
|
|
163
|
+
) -> list[dict[str, Any]]:
|
|
164
|
+
out: list[dict[str, Any]] = []
|
|
165
|
+
for entry in entries:
|
|
166
|
+
sid = entry["id"]
|
|
167
|
+
existing = session.records().get(sid)
|
|
168
|
+
if existing is None:
|
|
169
|
+
spec = dict(entry)
|
|
170
|
+
spec["quick"] = bool(entry.get("quick", False))
|
|
171
|
+
spec["custom"] = bool(entry.get("custom", False))
|
|
172
|
+
kind = spec.get("type", "paragraph")
|
|
173
|
+
if spec.get("default") and any(
|
|
174
|
+
r.default and r.type == kind for r in session.records().values()
|
|
175
|
+
):
|
|
176
|
+
spec["default"] = False # the document keeps its own default
|
|
177
|
+
create_style(session, spec)
|
|
178
|
+
out.append({"style": sid, "action": "created"})
|
|
179
|
+
continue
|
|
180
|
+
if mode == "skip":
|
|
181
|
+
out.append({"style": sid, "action": "skipped"})
|
|
182
|
+
continue
|
|
183
|
+
if mode == "error":
|
|
184
|
+
raise DocControlError(
|
|
185
|
+
ErrorCode.STYLE_CONFLICT, f"style {sid!r} already exists", style=sid
|
|
186
|
+
)
|
|
187
|
+
if existing.type != entry.get("type", "paragraph"):
|
|
188
|
+
raise DocControlError(
|
|
189
|
+
ErrorCode.STYLE_CONFLICT,
|
|
190
|
+
f"style {sid!r} is a {existing.type} style in the document, "
|
|
191
|
+
f"the set has a {entry.get('type', 'paragraph')} style",
|
|
192
|
+
style=sid,
|
|
193
|
+
)
|
|
194
|
+
others = [r for r in session.records().values() if r.id != sid]
|
|
195
|
+
if any(r.name.lower() == entry["name"].lower() for r in others):
|
|
196
|
+
raise DocControlError(
|
|
197
|
+
ErrorCode.STYLE_CONFLICT, f"a style named {entry['name']!r} already exists"
|
|
198
|
+
)
|
|
199
|
+
el = session.styles.element(sid)
|
|
200
|
+
set_child(el, "name", entry["name"])
|
|
201
|
+
apply_refs(
|
|
202
|
+
el,
|
|
203
|
+
{
|
|
204
|
+
"based_on": entry.get("based_on", ""),
|
|
205
|
+
"next": entry.get("next", ""),
|
|
206
|
+
"link": entry.get("link", ""),
|
|
207
|
+
"quick": bool(entry.get("quick", False)),
|
|
208
|
+
},
|
|
209
|
+
session,
|
|
210
|
+
)
|
|
211
|
+
apply_properties(el, entry.get("run", {}), entry.get("paragraph", {}), replace=True)
|
|
212
|
+
session.styles.put(el)
|
|
213
|
+
session.touched.add(sid)
|
|
214
|
+
out.append({"style": sid, "action": "replaced"})
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@operation(
|
|
219
|
+
"styleset.install",
|
|
220
|
+
"write",
|
|
221
|
+
"Install a JSON style set into the document: new styles are added, existing ones "
|
|
222
|
+
"replaced (or skipped); dry_run previews.",
|
|
223
|
+
{
|
|
224
|
+
"type": "object",
|
|
225
|
+
"properties": {
|
|
226
|
+
"doc": _DOC,
|
|
227
|
+
"styleset": {"type": "object", "description": "The style set (inline)."},
|
|
228
|
+
"path": {"type": "string", "minLength": 1, "description": "Or a style set file."},
|
|
229
|
+
"on_conflict": {
|
|
230
|
+
"enum": ["replace", "skip", "error"],
|
|
231
|
+
"description": "A style id already in the document (default replace).",
|
|
232
|
+
},
|
|
233
|
+
"expect": {
|
|
234
|
+
"type": "object",
|
|
235
|
+
"properties": {"sha256": {"type": "string", "minLength": 1}},
|
|
236
|
+
"additionalProperties": False,
|
|
237
|
+
},
|
|
238
|
+
"dry_run": {"type": "boolean", "description": "Preview only; write nothing."},
|
|
239
|
+
},
|
|
240
|
+
"required": ["doc"],
|
|
241
|
+
"additionalProperties": False,
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
def styleset_install(request: dict[str, Any]) -> Reply:
|
|
245
|
+
if ("styleset" in request) == ("path" in request):
|
|
246
|
+
raise invalid("give exactly one of 'styleset' and 'path'")
|
|
247
|
+
if "path" in request:
|
|
248
|
+
try:
|
|
249
|
+
data = json.loads(Path(request["path"]).read_text(encoding="utf-8"))
|
|
250
|
+
except (OSError, ValueError) as exc:
|
|
251
|
+
raise invalid(f"cannot read the style set: {type(exc).__name__}") from exc
|
|
252
|
+
else:
|
|
253
|
+
data = request["styleset"]
|
|
254
|
+
entries = _validated(data)
|
|
255
|
+
mode = request.get("on_conflict", "replace")
|
|
256
|
+
result = commit_styles(
|
|
257
|
+
Path(request["doc"]),
|
|
258
|
+
lambda session: _install(session, entries, mode),
|
|
259
|
+
expect_sha256=request.get("expect", {}).get("sha256"),
|
|
260
|
+
dry_run=request.get("dry_run", False),
|
|
261
|
+
)
|
|
262
|
+
return Reply(result, doc=Document(request["doc"]).envelope_doc())
|