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,479 @@
|
|
|
1
|
+
"""History operations: ``open``, ``undo``, ``restore`` and ``history.*``.
|
|
2
|
+
|
|
3
|
+
Everything here reads or writes the document's store (see :mod:`doc_control.safety.history`
|
|
4
|
+
for the log format). ``undo`` takes back a logged ``batch`` or ``restore`` exactly (the
|
|
5
|
+
paragraphs it changed return byte for byte, so text and paragraph properties are as they
|
|
6
|
+
were) and is itself logged; it is untracked, since it removes the tool's own tracked
|
|
7
|
+
changes. ``restore`` brings a logged paragraph back as a *tracked insertion* after the
|
|
8
|
+
paragraph that holds its place now, so the writer sees it and can reject it. ``history.purge``
|
|
9
|
+
removes history only after a dry run, and leaves a tombstone. Reading history never creates a
|
|
10
|
+
store, and works for a document that no longer exists (or, with ``store``, straight from a
|
|
11
|
+
store folder listed by ``history.orphans``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from doc_control.envelope import Reply
|
|
22
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
23
|
+
from doc_control.model.document import Document
|
|
24
|
+
from doc_control.model.index import DocumentIndex
|
|
25
|
+
from doc_control.ooxml.revisions import DEFAULT_AUTHOR
|
|
26
|
+
from doc_control.ooxml.splice import scan
|
|
27
|
+
from doc_control.ooxml.zipio import Package
|
|
28
|
+
from doc_control.ops.tracked import InsertParagraph, apply_edits
|
|
29
|
+
from doc_control.registry import operation
|
|
30
|
+
from doc_control.safety import history
|
|
31
|
+
from doc_control.safety.history import (
|
|
32
|
+
UNDOABLE_KINDS,
|
|
33
|
+
Log,
|
|
34
|
+
log_path_for,
|
|
35
|
+
read_log,
|
|
36
|
+
to_bytes,
|
|
37
|
+
undo_entry,
|
|
38
|
+
)
|
|
39
|
+
from doc_control.safety.store import backup, store_dir
|
|
40
|
+
from doc_control.safety.verify import DOCUMENT_PART
|
|
41
|
+
|
|
42
|
+
_DOC: dict[str, Any] = {"type": "string", "minLength": 1, "description": "Path to the .docx."}
|
|
43
|
+
_STORE: dict[str, Any] = {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"minLength": 1,
|
|
46
|
+
"description": "A store folder (see history.orphans) to read instead of the document's own.",
|
|
47
|
+
}
|
|
48
|
+
_OP_ID: dict[str, Any] = {"type": "string", "minLength": 3, "description": "Op id, e.g. h-000123."}
|
|
49
|
+
_EXPECT: dict[str, Any] = {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {"sha256": {"type": "string", "minLength": 1}},
|
|
52
|
+
"additionalProperties": False,
|
|
53
|
+
}
|
|
54
|
+
DEFAULT_LIMIT = 20
|
|
55
|
+
MAX_LIMIT = 200
|
|
56
|
+
PREVIEW = 120
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _log(request: dict[str, Any]) -> Log:
|
|
60
|
+
"""The log named by ``store`` or ``doc``; reading creates nothing."""
|
|
61
|
+
if "store" in request:
|
|
62
|
+
return read_log(Path(request["store"]) / "log.jsonl")
|
|
63
|
+
if "doc" in request:
|
|
64
|
+
return read_log(log_path_for(Path(request["doc"])))
|
|
65
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "give `doc` or `store`")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _doc_reply(path: str, result: dict[str, Any]) -> Reply:
|
|
69
|
+
return Reply(result, doc=Document(path).envelope_doc())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _short(text: str) -> str:
|
|
73
|
+
return text if len(text) <= PREVIEW else text[: PREVIEW - 1] + "…"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# -- open -----------------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@operation(
|
|
80
|
+
"open",
|
|
81
|
+
"write",
|
|
82
|
+
"Back up the document into its store (retention applies) and note it in the history; "
|
|
83
|
+
"call it when a session starts. The document is not changed.",
|
|
84
|
+
{
|
|
85
|
+
"type": "object",
|
|
86
|
+
"properties": {"doc": _DOC},
|
|
87
|
+
"required": ["doc"],
|
|
88
|
+
"additionalProperties": False,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
def open_(request: dict[str, Any]) -> Reply:
|
|
92
|
+
path = Path(request["doc"])
|
|
93
|
+
if not path.is_file():
|
|
94
|
+
raise DocControlError(ErrorCode.DOC_NOT_FOUND, f"no such file: {path}", path=str(path))
|
|
95
|
+
sha = Package(path).sha256 # NOT_A_DOCX for anything else, before a store is made
|
|
96
|
+
copy = backup(path)
|
|
97
|
+
seen = [e for e in read_log(log_path_for(path)).entries if e.get("kind") != "purge"]
|
|
98
|
+
op_id: str | None = None
|
|
99
|
+
if not seen or seen[-1].get("sha256_after") != sha: # new to the history: note it
|
|
100
|
+
op_id = history.record(
|
|
101
|
+
path, "open", before_sha256=sha, after_sha256=sha, backup=copy, changes=[]
|
|
102
|
+
)
|
|
103
|
+
return _doc_reply(
|
|
104
|
+
str(path), {"backup": str(copy), "op_id": op_id, "store": str(store_dir(path))}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# -- undo -----------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _undoable(entry: dict[str, Any]) -> bool:
|
|
112
|
+
return entry.get("kind") in UNDOABLE_KINDS and bool(entry.get("changes"))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@operation(
|
|
116
|
+
"undo",
|
|
117
|
+
"write",
|
|
118
|
+
"Take back a logged batch or restore (the latest one that is still in effect by default), "
|
|
119
|
+
"returning its paragraphs to their prior state. Refused if they were edited since.",
|
|
120
|
+
{
|
|
121
|
+
"type": "object",
|
|
122
|
+
"properties": {
|
|
123
|
+
"doc": _DOC,
|
|
124
|
+
"op_id": {**_OP_ID, "description": "The op to undo (default: the latest in effect)."},
|
|
125
|
+
"expect": _EXPECT,
|
|
126
|
+
"dry_run": {"type": "boolean", "description": "Check and preview; write nothing."},
|
|
127
|
+
},
|
|
128
|
+
"required": ["doc"],
|
|
129
|
+
"additionalProperties": False,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
def undo(request: dict[str, Any]) -> Reply:
|
|
133
|
+
path = Path(request["doc"])
|
|
134
|
+
log = read_log(log_path_for(path))
|
|
135
|
+
undone = log.undone()
|
|
136
|
+
op_id = request.get("op_id")
|
|
137
|
+
if op_id is None:
|
|
138
|
+
pending = [e for e in log.entries if _undoable(e) and e["op_id"] not in undone]
|
|
139
|
+
if not pending:
|
|
140
|
+
raise DocControlError(ErrorCode.HISTORY_NOT_FOUND, "nothing to undo")
|
|
141
|
+
entry = pending[-1]
|
|
142
|
+
else:
|
|
143
|
+
entry = log.get(op_id)
|
|
144
|
+
if not _undoable(entry):
|
|
145
|
+
raise DocControlError(
|
|
146
|
+
ErrorCode.HISTORY_NOT_FOUND,
|
|
147
|
+
f"{op_id} is a {entry.get('kind')} entry; only batch and restore can be undone",
|
|
148
|
+
op_id=op_id,
|
|
149
|
+
)
|
|
150
|
+
if op_id in undone:
|
|
151
|
+
raise DocControlError(
|
|
152
|
+
ErrorCode.HISTORY_NOT_FOUND, f"{op_id} was already undone", op_id=op_id
|
|
153
|
+
)
|
|
154
|
+
dry_run = request.get("dry_run", False)
|
|
155
|
+
outcome = undo_entry(
|
|
156
|
+
path,
|
|
157
|
+
entry,
|
|
158
|
+
expect_sha256=request.get("expect", {}).get("sha256"),
|
|
159
|
+
dry_run=dry_run,
|
|
160
|
+
env=None,
|
|
161
|
+
)
|
|
162
|
+
warnings: list[str] = []
|
|
163
|
+
result: dict[str, Any] = {
|
|
164
|
+
"undid": entry["op_id"],
|
|
165
|
+
"dry_run": dry_run,
|
|
166
|
+
"paragraphs": len(outcome.reverted),
|
|
167
|
+
}
|
|
168
|
+
if not dry_run:
|
|
169
|
+
assert outcome.after_sha256 is not None
|
|
170
|
+
try:
|
|
171
|
+
result["op_id"] = history.record(
|
|
172
|
+
path,
|
|
173
|
+
"undo",
|
|
174
|
+
before_sha256=outcome.before_sha256,
|
|
175
|
+
after_sha256=outcome.after_sha256,
|
|
176
|
+
backup=outcome.backup,
|
|
177
|
+
changes=outcome.reverted,
|
|
178
|
+
request={"op": "undo", "op_id": entry["op_id"]},
|
|
179
|
+
extra={"undoes": entry["op_id"]},
|
|
180
|
+
)
|
|
181
|
+
except (DocControlError, OSError) as exc:
|
|
182
|
+
result["op_id"] = None
|
|
183
|
+
warnings.append(
|
|
184
|
+
f"HISTORY_NOT_RECORDED: the undo succeeded but could not be logged: {exc}"
|
|
185
|
+
)
|
|
186
|
+
result["sha256"] = outcome.after_sha256
|
|
187
|
+
result["backup"] = str(outcome.backup)
|
|
188
|
+
return Reply(result, doc=Document(str(path)).envelope_doc(), warnings=warnings)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# -- restore --------------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _restorable(change: dict[str, Any]) -> bool:
|
|
195
|
+
return change.get("kind") == "replace" and bool(change.get("text_before"))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@operation(
|
|
199
|
+
"restore",
|
|
200
|
+
"write",
|
|
201
|
+
"Bring a logged paragraph back as a tracked insertion after the paragraph that holds its "
|
|
202
|
+
"place now. Without `change`, every paragraph the op deleted.",
|
|
203
|
+
{
|
|
204
|
+
"type": "object",
|
|
205
|
+
"properties": {
|
|
206
|
+
"doc": _DOC,
|
|
207
|
+
"store": _STORE,
|
|
208
|
+
"op_id": {**_OP_ID, "description": "The logged op the paragraph was changed by."},
|
|
209
|
+
"change": {
|
|
210
|
+
"type": "integer",
|
|
211
|
+
"minimum": 0,
|
|
212
|
+
"description": "Index of one change of the op (history.list shows them).",
|
|
213
|
+
},
|
|
214
|
+
"after": {
|
|
215
|
+
"type": "string",
|
|
216
|
+
"minLength": 1,
|
|
217
|
+
"description": "Insert after this paragraph address instead of the old place "
|
|
218
|
+
"(needed when that place was edited, or the history comes from `store`).",
|
|
219
|
+
},
|
|
220
|
+
"expect": _EXPECT,
|
|
221
|
+
"dry_run": {"type": "boolean", "description": "Preview only; write nothing."},
|
|
222
|
+
"author": {"type": "string", "minLength": 1, "description": "Revision author."},
|
|
223
|
+
"tag": {"type": "string", "minLength": 1, "description": "Appended to the author."},
|
|
224
|
+
},
|
|
225
|
+
"required": ["doc", "op_id"],
|
|
226
|
+
"additionalProperties": False,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
def restore(request: dict[str, Any]) -> Reply:
|
|
230
|
+
path = Path(request["doc"])
|
|
231
|
+
entry = _log(request).get(request["op_id"])
|
|
232
|
+
changes = entry.get("changes", [])
|
|
233
|
+
if "change" in request:
|
|
234
|
+
number = request["change"]
|
|
235
|
+
if not 0 <= number < len(changes) or not _restorable(changes[number]):
|
|
236
|
+
raise DocControlError(
|
|
237
|
+
ErrorCode.HISTORY_NOT_FOUND,
|
|
238
|
+
f"{entry['op_id']} has no restorable paragraph at change {number}",
|
|
239
|
+
op_id=entry["op_id"],
|
|
240
|
+
)
|
|
241
|
+
chosen = [number]
|
|
242
|
+
else:
|
|
243
|
+
chosen = [n for n, c in enumerate(changes) if _restorable(c) and c.get("deleted")]
|
|
244
|
+
if not chosen:
|
|
245
|
+
raise DocControlError(
|
|
246
|
+
ErrorCode.HISTORY_NOT_FOUND,
|
|
247
|
+
f"{entry['op_id']} deleted no paragraph; pass `change` to restore an edited one",
|
|
248
|
+
op_id=entry["op_id"],
|
|
249
|
+
)
|
|
250
|
+
if "after" in request and len(chosen) != 1:
|
|
251
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "`after` needs exactly one `change`")
|
|
252
|
+
pkg = Package(path) if path.is_file() else None
|
|
253
|
+
if pkg is None:
|
|
254
|
+
raise DocControlError(ErrorCode.DOC_NOT_FOUND, f"no such file: {path}", path=str(path))
|
|
255
|
+
anchors = _anchors(pkg, changes, chosen, request)
|
|
256
|
+
edits = [InsertParagraph(changes[n]["text_before"], after=anchors[n]) for n in chosen]
|
|
257
|
+
dry_run = request.get("dry_run", False)
|
|
258
|
+
done = apply_edits(
|
|
259
|
+
path,
|
|
260
|
+
edits,
|
|
261
|
+
expect_sha256=request.get("expect", {}).get("sha256"),
|
|
262
|
+
author=request.get("author", DEFAULT_AUTHOR),
|
|
263
|
+
tag=request.get("tag"),
|
|
264
|
+
dry_run=dry_run,
|
|
265
|
+
kind="restore",
|
|
266
|
+
request={"op": "restore", "op_id": entry["op_id"], "changes": chosen},
|
|
267
|
+
log_extra={"restores": entry["op_id"]},
|
|
268
|
+
)
|
|
269
|
+
result = done.to_dict()
|
|
270
|
+
result["restored"] = [
|
|
271
|
+
{"change": n, "after": anchors[n], "text": _short(changes[n]["text_before"])}
|
|
272
|
+
for n in chosen
|
|
273
|
+
]
|
|
274
|
+
result["from"] = entry["op_id"]
|
|
275
|
+
return _doc_reply(str(path), result)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _anchors(
|
|
279
|
+
pkg: Package, changes: list[dict[str, Any]], chosen: list[int], request: dict[str, Any]
|
|
280
|
+
) -> dict[int, str]:
|
|
281
|
+
"""For each chosen change, the address of the paragraph to insert after."""
|
|
282
|
+
if "after" in request:
|
|
283
|
+
return {chosen[0]: request["after"]}
|
|
284
|
+
data = pkg.read(DOCUMENT_PART)
|
|
285
|
+
spans = scan(data).paragraphs
|
|
286
|
+
index = DocumentIndex.from_package(pkg)
|
|
287
|
+
by_span = {p.span_index: p for p in index.paragraphs}
|
|
288
|
+
out: dict[int, str] = {}
|
|
289
|
+
for n in chosen:
|
|
290
|
+
target = to_bytes(changes[n]["after"])
|
|
291
|
+
found = [i for i, s in enumerate(spans) if data[s.start : s.end] == target]
|
|
292
|
+
para = by_span.get(found[0]) if len(found) == 1 else None
|
|
293
|
+
if para is None:
|
|
294
|
+
raise DocControlError(
|
|
295
|
+
ErrorCode.BATCH_CONFLICT,
|
|
296
|
+
f"the place of change {n} was edited since; pass `after` with an address",
|
|
297
|
+
change=n,
|
|
298
|
+
)
|
|
299
|
+
out[n] = str(para.address)
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# -- history.* ------------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
_READ_PROPS: dict[str, Any] = {"doc": _DOC, "store": _STORE}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _summary(entry: dict[str, Any], undone: set[str]) -> dict[str, Any]:
|
|
309
|
+
out: dict[str, Any] = {
|
|
310
|
+
"op_id": entry["op_id"],
|
|
311
|
+
"kind": entry.get("kind"),
|
|
312
|
+
"time": entry.get("time"),
|
|
313
|
+
}
|
|
314
|
+
if entry.get("kind") == "purge":
|
|
315
|
+
return {**out, "removed": entry.get("removed"), "scope": entry.get("scope")}
|
|
316
|
+
out.update(author=entry.get("author"), tag=entry.get("tag"))
|
|
317
|
+
changes = entry.get("changes", [])
|
|
318
|
+
out["paragraphs"] = len(changes)
|
|
319
|
+
request = entry.get("request")
|
|
320
|
+
if isinstance(request, list):
|
|
321
|
+
out["ops"] = [r.get("op") for r in request if isinstance(r, dict)]
|
|
322
|
+
if entry.get("kind") in UNDOABLE_KINDS:
|
|
323
|
+
out["undone"] = entry["op_id"] in undone
|
|
324
|
+
out["deleted"] = [
|
|
325
|
+
{"change": n, "text": _short(c["text_before"])}
|
|
326
|
+
for n, c in enumerate(changes)
|
|
327
|
+
if _restorable(c) and c.get("deleted")
|
|
328
|
+
][:5]
|
|
329
|
+
for key in ("undoes", "restores", "sha256_after", "backup"):
|
|
330
|
+
if entry.get(key) is not None:
|
|
331
|
+
out[key] = entry[key]
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@operation(
|
|
336
|
+
"history.list",
|
|
337
|
+
"read",
|
|
338
|
+
"The undo log, newest first: what each write changed, whether it was undone, and the "
|
|
339
|
+
"deleted paragraphs `restore` can bring back.",
|
|
340
|
+
{
|
|
341
|
+
"type": "object",
|
|
342
|
+
"properties": {
|
|
343
|
+
**_READ_PROPS,
|
|
344
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_LIMIT},
|
|
345
|
+
"cursor": {**_OP_ID, "description": "The `next` of the previous response."},
|
|
346
|
+
"kind": {"enum": ["batch", "restore", "undo", "open", "purge"]},
|
|
347
|
+
},
|
|
348
|
+
"additionalProperties": False,
|
|
349
|
+
},
|
|
350
|
+
)
|
|
351
|
+
def history_list(request: dict[str, Any]) -> Reply:
|
|
352
|
+
log = _log(request)
|
|
353
|
+
undone = log.undone()
|
|
354
|
+
kind = request.get("kind")
|
|
355
|
+
rows = [e for e in reversed(log.entries) if kind is None or e.get("kind") == kind]
|
|
356
|
+
cursor = request.get("cursor")
|
|
357
|
+
if cursor is not None and history.op_number(cursor) is None:
|
|
358
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, f"bad cursor: {cursor!r}", path="$.cursor")
|
|
359
|
+
if cursor is not None:
|
|
360
|
+
rows = [
|
|
361
|
+
e
|
|
362
|
+
for e in rows
|
|
363
|
+
if (history.op_number(e["op_id"]) or 0) < (history.op_number(cursor) or 0)
|
|
364
|
+
]
|
|
365
|
+
limit = request.get("limit", DEFAULT_LIMIT)
|
|
366
|
+
page = rows[:limit]
|
|
367
|
+
result: dict[str, Any] = {
|
|
368
|
+
"entries": [_summary(e, undone) for e in page],
|
|
369
|
+
"total": len(rows),
|
|
370
|
+
}
|
|
371
|
+
if log.skipped:
|
|
372
|
+
result["unreadable_lines"] = log.skipped
|
|
373
|
+
more = len(rows) > limit
|
|
374
|
+
return Reply(result, next_cursor=page[-1]["op_id"] if more and page else None)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@operation(
|
|
378
|
+
"history.search",
|
|
379
|
+
"read",
|
|
380
|
+
"Find a quote in the undo log: in the requests, and in the text of every paragraph "
|
|
381
|
+
"before and after each write.",
|
|
382
|
+
{
|
|
383
|
+
"type": "object",
|
|
384
|
+
"properties": {
|
|
385
|
+
**_READ_PROPS,
|
|
386
|
+
"query": {"type": "string", "minLength": 1, "description": "Text to find."},
|
|
387
|
+
"ignore_case": {"type": "boolean"},
|
|
388
|
+
"ignore_accents": {"type": "boolean"},
|
|
389
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": MAX_LIMIT},
|
|
390
|
+
},
|
|
391
|
+
"required": ["query"],
|
|
392
|
+
"additionalProperties": False,
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
def history_search(request: dict[str, Any]) -> Reply:
|
|
396
|
+
log = _log(request)
|
|
397
|
+
limit = request.get("limit", DEFAULT_LIMIT)
|
|
398
|
+
hits: list[dict[str, Any]] = []
|
|
399
|
+
total = 0
|
|
400
|
+
for entry in reversed(log.entries):
|
|
401
|
+
if entry.get("kind") == "purge":
|
|
402
|
+
continue
|
|
403
|
+
found = history.search_entry(
|
|
404
|
+
entry,
|
|
405
|
+
request["query"],
|
|
406
|
+
ignore_case=request.get("ignore_case", False),
|
|
407
|
+
ignore_accents=request.get("ignore_accents", False),
|
|
408
|
+
)
|
|
409
|
+
total += len(found)
|
|
410
|
+
hits.extend(found[: max(0, limit - len(hits))])
|
|
411
|
+
return Reply({"matches": hits, "total": total})
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@operation(
|
|
415
|
+
"history.purge",
|
|
416
|
+
"write",
|
|
417
|
+
"Remove undo history (all, or before a date) for privacy, leaving a tombstone. Run with "
|
|
418
|
+
"dry_run first, then repeat with its `confirm`.",
|
|
419
|
+
{
|
|
420
|
+
"type": "object",
|
|
421
|
+
"properties": {
|
|
422
|
+
**_READ_PROPS,
|
|
423
|
+
"all": {"type": "boolean", "description": "Remove the whole log."},
|
|
424
|
+
"before": {
|
|
425
|
+
"type": "string",
|
|
426
|
+
"minLength": 1,
|
|
427
|
+
"description": "Remove entries older than this ISO date or time (UTC).",
|
|
428
|
+
},
|
|
429
|
+
"dry_run": {"type": "boolean", "description": "List what would go; returns confirm."},
|
|
430
|
+
"confirm": {"type": "string", "minLength": 1, "description": "From the dry run."},
|
|
431
|
+
},
|
|
432
|
+
"additionalProperties": False,
|
|
433
|
+
},
|
|
434
|
+
)
|
|
435
|
+
def history_purge(request: dict[str, Any]) -> Reply:
|
|
436
|
+
if "store" in request:
|
|
437
|
+
log_path = Path(request["store"]) / "log.jsonl"
|
|
438
|
+
elif "doc" in request:
|
|
439
|
+
log_path = log_path_for(Path(request["doc"]))
|
|
440
|
+
else:
|
|
441
|
+
raise DocControlError(ErrorCode.SCHEMA_INVALID, "give `doc` or `store`")
|
|
442
|
+
before = _parse_when(request["before"]) if "before" in request else None
|
|
443
|
+
result = history.purge(
|
|
444
|
+
log_path,
|
|
445
|
+
before=before,
|
|
446
|
+
everything=request.get("all", False),
|
|
447
|
+
dry_run=request.get("dry_run", False),
|
|
448
|
+
confirm=request.get("confirm"),
|
|
449
|
+
)
|
|
450
|
+
return Reply(result)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _parse_when(text: str) -> datetime:
|
|
454
|
+
try:
|
|
455
|
+
when = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
456
|
+
except ValueError as exc:
|
|
457
|
+
raise DocControlError(
|
|
458
|
+
ErrorCode.SCHEMA_INVALID, f"`before` is not an ISO date: {text!r}", path="$.before"
|
|
459
|
+
) from exc
|
|
460
|
+
return when if when.tzinfo else when.replace(tzinfo=UTC)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@operation(
|
|
464
|
+
"history.orphans",
|
|
465
|
+
"read",
|
|
466
|
+
"List stores whose document is gone (app-data stores, and `dir`/.doc-control). Their "
|
|
467
|
+
"history is never deleted automatically: restore from it with `store`, or purge it.",
|
|
468
|
+
{
|
|
469
|
+
"type": "object",
|
|
470
|
+
"properties": {
|
|
471
|
+
"dir": {"type": "string", "minLength": 1, "description": "Also scan this folder."}
|
|
472
|
+
},
|
|
473
|
+
"additionalProperties": False,
|
|
474
|
+
},
|
|
475
|
+
)
|
|
476
|
+
def history_orphans(request: dict[str, Any]) -> Reply:
|
|
477
|
+
folder = Path(request["dir"]) if "dir" in request else None
|
|
478
|
+
found = history.find_orphans(folder, os.environ)
|
|
479
|
+
return Reply({**found, "total": len(found["orphans"])})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Layout facts for `para.get` and `outline` (D-18), registered through the
|
|
2
|
+
:data:`doc_control.ops.read.PARA_FACTS` and :data:`doc_control.ops.read.OUTLINE_FACTS` hooks.
|
|
3
|
+
|
|
4
|
+
The definitions live in :mod:`doc_control.model.layout`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from doc_control.model.layout import blank_before, centred, is_empty, page_break_before
|
|
12
|
+
from doc_control.ops.read import OUTLINE_FACTS, PARA_FACTS, ParagraphContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def para_facts(ctx: ParagraphContext) -> dict[str, Any]:
|
|
16
|
+
doc, para = ctx.doc, ctx.paragraph
|
|
17
|
+
return {
|
|
18
|
+
"empty": is_empty(doc, para),
|
|
19
|
+
"blank_before": blank_before(doc, para),
|
|
20
|
+
"heading_level": doc.heading_level(para),
|
|
21
|
+
"centred": centred(doc, para),
|
|
22
|
+
"page_break_before": page_break_before(doc, para),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def outline_facts(ctx: ParagraphContext) -> dict[str, Any]:
|
|
27
|
+
"""Outline entries already carry ``level`` and ``style``; add the rest."""
|
|
28
|
+
doc, para = ctx.doc, ctx.paragraph
|
|
29
|
+
style = doc.style_id(para)
|
|
30
|
+
style_def = doc.styles.get(style) if style is not None else None
|
|
31
|
+
return {
|
|
32
|
+
"style_name": style_def.name if style_def is not None else None,
|
|
33
|
+
"blank_before": blank_before(doc, para),
|
|
34
|
+
"centred": centred(doc, para),
|
|
35
|
+
"page_break_before": page_break_before(doc, para),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
PARA_FACTS.append(para_facts)
|
|
40
|
+
OUTLINE_FACTS.append(outline_facts)
|