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,508 @@
|
|
|
1
|
+
"""The permanent undo log: ``log.jsonl`` in the document store, and undo on top of it.
|
|
2
|
+
|
|
3
|
+
Each write appends one JSON line (``"log_version": 1``; readers ignore unknown keys):
|
|
4
|
+
``op_id`` (``h-000123``, increasing, never reused, not even after a purge), ``time``,
|
|
5
|
+
``kind`` (``batch``, ``restore``, ``undo``, ``open``, ``purge``), author, tag, the
|
|
6
|
+
request, the sha256 of the document before and after, the backup taken for it and
|
|
7
|
+
``changes``: the exact bytes of every touched paragraph before and after, as text
|
|
8
|
+
(``surrogateescape``, so any byte string round-trips through JSON).
|
|
9
|
+
|
|
10
|
+
``undo`` swaps those bytes back: every logged "after" paragraph must still be in the
|
|
11
|
+
document exactly once, byte for byte, or the undo is refused (``BATCH_CONFLICT``, naming
|
|
12
|
+
the changes), so it can never overwrite a later edit. The result goes through
|
|
13
|
+
:func:`write_verified` like any other write.
|
|
14
|
+
|
|
15
|
+
Retention never touches the log. Only :func:`purge` removes lines, after a dry run
|
|
16
|
+
that returned a confirmation token, and it leaves a tombstone line saying what went.
|
|
17
|
+
Concurrent writers to one store are not serialised: the lock check and the document
|
|
18
|
+
replace already make a second writer fail, so two entries racing for one id are not
|
|
19
|
+
expected in practice.
|
|
20
|
+
|
|
21
|
+
Changes outside ``word/document.xml`` are not logged yet; the batch engine only
|
|
22
|
+
writes that part.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import hashlib
|
|
29
|
+
import itertools
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import unicodedata
|
|
34
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from html import unescape
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
42
|
+
from doc_control.ooxml.splice import scan
|
|
43
|
+
from doc_control.ooxml.zipio import Package
|
|
44
|
+
from doc_control.safety.lock import check_changed, check_unlocked
|
|
45
|
+
from doc_control.safety.store import (
|
|
46
|
+
STORE_DIRNAME,
|
|
47
|
+
DocStore,
|
|
48
|
+
_write_text_atomic,
|
|
49
|
+
store_dir,
|
|
50
|
+
user_home,
|
|
51
|
+
)
|
|
52
|
+
from doc_control.safety.verify import DOCUMENT_PART, Footprint, write_verified
|
|
53
|
+
|
|
54
|
+
LOG_VERSION = 1
|
|
55
|
+
UNDOABLE_KINDS = ("batch", "restore")
|
|
56
|
+
_ID = re.compile(r"h-(\d{6,})")
|
|
57
|
+
_LIVE_TEXT = re.compile(rb"<(?:[\w.-]+:)?t[\s>]")
|
|
58
|
+
_XML_TEXT = re.compile(r"<(?:[\w.-]+:)?(t|delText)(?:\s[^>]*)?>([^<]*)</(?:[\w.-]+:)?\1>")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def to_text(data: bytes) -> str:
|
|
62
|
+
"""Bytes to a JSON-safe string that :func:`to_bytes` turns back into the same bytes."""
|
|
63
|
+
return data.decode("utf-8", "surrogateescape")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def to_bytes(text: str) -> bytes:
|
|
67
|
+
return text.encode("utf-8", "surrogateescape")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def op_id_of(number: int) -> str:
|
|
71
|
+
return f"h-{number:06d}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def op_number(op_id: object) -> int | None:
|
|
75
|
+
m = _ID.fullmatch(op_id) if isinstance(op_id, str) else None
|
|
76
|
+
return int(m[1]) if m else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def has_live_text(paragraph_xml: bytes) -> bool:
|
|
80
|
+
"""True if the paragraph holds ``w:t`` text (so it is not wholly deleted)."""
|
|
81
|
+
return _LIVE_TEXT.search(paragraph_xml) is not None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def xml_text(paragraph_xml: str) -> str:
|
|
85
|
+
"""All text of a logged paragraph, inserted and deleted, for searching."""
|
|
86
|
+
return "".join(unescape(m[2]) for m in _XML_TEXT.finditer(paragraph_xml))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Log:
|
|
91
|
+
path: Path
|
|
92
|
+
entries: list[dict[str, Any]]
|
|
93
|
+
skipped: int = 0 # unreadable lines (a write cut short); kept in the file, ignored here
|
|
94
|
+
|
|
95
|
+
def get(self, op_id: str) -> dict[str, Any]:
|
|
96
|
+
for entry in self.entries:
|
|
97
|
+
if entry["op_id"] == op_id:
|
|
98
|
+
return entry
|
|
99
|
+
raise DocControlError(
|
|
100
|
+
ErrorCode.HISTORY_NOT_FOUND,
|
|
101
|
+
f"no history entry {op_id}",
|
|
102
|
+
op_id=op_id,
|
|
103
|
+
path=str(self.path),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def undone(self) -> set[str]:
|
|
107
|
+
return {e["undoes"] for e in self.entries if e.get("kind") == "undo" and "undoes" in e}
|
|
108
|
+
|
|
109
|
+
def next_number(self) -> int:
|
|
110
|
+
numbers = [n for e in self.entries if (n := op_number(e["op_id"])) is not None]
|
|
111
|
+
floor = [int(e.get("last_removed", 0)) for e in self.entries if e.get("kind") == "purge"]
|
|
112
|
+
return max([0, *numbers, *floor]) + 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def read_log(path: Path) -> Log:
|
|
116
|
+
"""The entries of ``path``, oldest first; absent or unreadable lines are skipped."""
|
|
117
|
+
try:
|
|
118
|
+
raw = path.read_bytes()
|
|
119
|
+
except FileNotFoundError:
|
|
120
|
+
return Log(path, [])
|
|
121
|
+
except OSError as exc:
|
|
122
|
+
raise DocControlError(
|
|
123
|
+
ErrorCode.INTERNAL, f"cannot read the history log {path}: {exc}", path=str(path)
|
|
124
|
+
) from exc
|
|
125
|
+
entries: list[dict[str, Any]] = []
|
|
126
|
+
skipped = 0
|
|
127
|
+
for line in raw.split(b"\n"):
|
|
128
|
+
if not line.strip():
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
value = json.loads(line.decode("utf-8"))
|
|
132
|
+
except ValueError:
|
|
133
|
+
skipped += 1
|
|
134
|
+
continue
|
|
135
|
+
if isinstance(value, dict) and op_number(value.get("op_id")) is not None:
|
|
136
|
+
entries.append(value)
|
|
137
|
+
else:
|
|
138
|
+
skipped += 1
|
|
139
|
+
return Log(path, entries, skipped)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _now(now: datetime | None) -> datetime:
|
|
143
|
+
if now is None:
|
|
144
|
+
return datetime.now(UTC)
|
|
145
|
+
return now if now.tzinfo else now.replace(tzinfo=UTC)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _stamp(when: datetime | None) -> str:
|
|
149
|
+
return _now(when).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _line(entry: Mapping[str, Any]) -> bytes:
|
|
153
|
+
return (json.dumps(entry, separators=(",", ":")) + "\n").encode("ascii")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def append_entry(
|
|
157
|
+
store: DocStore, fields: Mapping[str, Any], now: datetime | None
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""Append one entry with the next op id; returns the entry as written."""
|
|
160
|
+
log = read_log(store.log_path)
|
|
161
|
+
entry: dict[str, Any] = {
|
|
162
|
+
"log_version": LOG_VERSION,
|
|
163
|
+
"op_id": op_id_of(log.next_number()),
|
|
164
|
+
"time": _stamp(now),
|
|
165
|
+
"doc_id": store.doc_id,
|
|
166
|
+
**fields,
|
|
167
|
+
}
|
|
168
|
+
data = _line(entry)
|
|
169
|
+
try:
|
|
170
|
+
with store.log_path.open("ab+") as fh:
|
|
171
|
+
fh.seek(0, os.SEEK_END)
|
|
172
|
+
if fh.tell():
|
|
173
|
+
fh.seek(-1, os.SEEK_END)
|
|
174
|
+
if fh.read(1) != b"\n":
|
|
175
|
+
data = b"\n" + data # a torn last line must not swallow this one
|
|
176
|
+
fh.write(data)
|
|
177
|
+
fh.flush()
|
|
178
|
+
os.fsync(fh.fileno())
|
|
179
|
+
except OSError as exc:
|
|
180
|
+
raise DocControlError(
|
|
181
|
+
ErrorCode.INTERNAL,
|
|
182
|
+
f"cannot append to the history log {store.log_path}: {exc}",
|
|
183
|
+
path=str(store.log_path),
|
|
184
|
+
) from exc
|
|
185
|
+
return entry
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def record(
|
|
189
|
+
doc_path: Path,
|
|
190
|
+
kind: str,
|
|
191
|
+
*,
|
|
192
|
+
before_sha256: str,
|
|
193
|
+
after_sha256: str,
|
|
194
|
+
backup: Path | None,
|
|
195
|
+
changes: Sequence[Mapping[str, Any]],
|
|
196
|
+
request: Any = None,
|
|
197
|
+
author: str | None = None,
|
|
198
|
+
tag: str | None = None,
|
|
199
|
+
footprint: Any = None,
|
|
200
|
+
extra: Mapping[str, Any] | None = None,
|
|
201
|
+
now: datetime | None = None,
|
|
202
|
+
env: Mapping[str, str] | None = None,
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Log one write of ``doc_path``; returns its op id."""
|
|
205
|
+
store = DocStore.open(doc_path, env)
|
|
206
|
+
fields: dict[str, Any] = {
|
|
207
|
+
"kind": kind,
|
|
208
|
+
"doc_path": str(doc_path.resolve()),
|
|
209
|
+
"author": author,
|
|
210
|
+
"tag": tag,
|
|
211
|
+
"request": request,
|
|
212
|
+
"footprint": footprint,
|
|
213
|
+
"sha256_before": before_sha256,
|
|
214
|
+
"sha256_after": after_sha256,
|
|
215
|
+
"backup": backup.name if backup else None,
|
|
216
|
+
"changes": list(changes),
|
|
217
|
+
**(extra or {}),
|
|
218
|
+
}
|
|
219
|
+
return str(append_entry(store, fields, now)["op_id"])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# -- undo -----------------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class UndoOutcome:
|
|
227
|
+
before_sha256: str
|
|
228
|
+
after_sha256: str | None
|
|
229
|
+
backup: Path | None
|
|
230
|
+
reverted: list[dict[str, Any]] = field(default_factory=list)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _conflict(entry: Mapping[str, Any], missing: list[int], why: str) -> DocControlError:
|
|
234
|
+
return DocControlError(
|
|
235
|
+
ErrorCode.BATCH_CONFLICT,
|
|
236
|
+
f"cannot undo {entry['op_id']}: {why}; undo later operations first (history.list)",
|
|
237
|
+
op_id=entry["op_id"],
|
|
238
|
+
changes=missing,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def undo_entry(
|
|
243
|
+
doc_path: Path,
|
|
244
|
+
entry: Mapping[str, Any],
|
|
245
|
+
*,
|
|
246
|
+
expect_sha256: str | None = None,
|
|
247
|
+
dry_run: bool = False,
|
|
248
|
+
now: datetime | None = None,
|
|
249
|
+
env: Mapping[str, str] | None = None,
|
|
250
|
+
stage: Callable[[str], None] | None = None,
|
|
251
|
+
) -> UndoOutcome:
|
|
252
|
+
"""Put every paragraph ``entry`` changed back as it was, in one verified write.
|
|
253
|
+
|
|
254
|
+
Entries that also wrote other parts (comments) are refused: only paragraphs are logged.
|
|
255
|
+
"""
|
|
256
|
+
if entry.get("parts"):
|
|
257
|
+
raise DocControlError(
|
|
258
|
+
ErrorCode.UNSUPPORTED_CONTENT,
|
|
259
|
+
f"{entry['op_id']} also changed {', '.join(entry['parts'])}, which the history "
|
|
260
|
+
"does not keep; resolve or edit its comments in Word instead of undoing it",
|
|
261
|
+
op_id=entry["op_id"],
|
|
262
|
+
parts=entry["parts"],
|
|
263
|
+
)
|
|
264
|
+
if not doc_path.is_file():
|
|
265
|
+
raise DocControlError(
|
|
266
|
+
ErrorCode.DOC_NOT_FOUND, f"no such file: {doc_path}", path=str(doc_path)
|
|
267
|
+
)
|
|
268
|
+
if not dry_run:
|
|
269
|
+
check_unlocked(doc_path)
|
|
270
|
+
check_changed(doc_path, expect_sha256)
|
|
271
|
+
pkg = Package(doc_path)
|
|
272
|
+
data = pkg.read(DOCUMENT_PART)
|
|
273
|
+
spans = scan(data).paragraphs
|
|
274
|
+
where: dict[bytes, list[int]] = {}
|
|
275
|
+
for i, span in enumerate(spans):
|
|
276
|
+
where.setdefault(data[span.start : span.end], []).append(i)
|
|
277
|
+
|
|
278
|
+
swaps: dict[int, bytes] = {} # paragraph index in the current document -> bytes it becomes
|
|
279
|
+
reverted: list[dict[str, Any]] = []
|
|
280
|
+
missing: list[int] = []
|
|
281
|
+
for number, change in enumerate(entry.get("changes", [])):
|
|
282
|
+
if change.get("part", DOCUMENT_PART) != DOCUMENT_PART:
|
|
283
|
+
raise DocControlError(
|
|
284
|
+
ErrorCode.UNSUPPORTED_CONTENT, f"change {number} is in {change['part']}"
|
|
285
|
+
)
|
|
286
|
+
found = where.get(to_bytes(change["after"]), [])
|
|
287
|
+
if len(found) != 1 or found[0] in swaps:
|
|
288
|
+
missing.append(number)
|
|
289
|
+
continue
|
|
290
|
+
restored = to_bytes(change["before"]) if change["kind"] == "replace" else b""
|
|
291
|
+
swaps[found[0]] = restored
|
|
292
|
+
reverted.append(
|
|
293
|
+
{
|
|
294
|
+
"kind": "revert",
|
|
295
|
+
"index": found[0],
|
|
296
|
+
"before": change["after"],
|
|
297
|
+
"after": to_text(restored),
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
if missing:
|
|
301
|
+
raise _conflict(entry, missing, "a paragraph it changed was edited since or is gone")
|
|
302
|
+
if not swaps:
|
|
303
|
+
raise DocControlError(
|
|
304
|
+
ErrorCode.HISTORY_NOT_FOUND,
|
|
305
|
+
f"{entry['op_id']} changed no paragraph",
|
|
306
|
+
op_id=entry["op_id"],
|
|
307
|
+
)
|
|
308
|
+
cuts = sorted((spans[i].start, spans[i].end, new) for i, new in swaps.items())
|
|
309
|
+
for (_, end, _), (start, _, _) in itertools.pairwise(cuts):
|
|
310
|
+
if start < end:
|
|
311
|
+
raise _conflict(entry, [], "its paragraphs are nested")
|
|
312
|
+
out = bytearray()
|
|
313
|
+
pos = 0
|
|
314
|
+
for start, end, new in cuts:
|
|
315
|
+
out += data[pos:start] + new
|
|
316
|
+
pos = end
|
|
317
|
+
out += data[pos:]
|
|
318
|
+
if dry_run:
|
|
319
|
+
return UndoOutcome(pkg.sha256, None, None, reverted)
|
|
320
|
+
written = write_verified(
|
|
321
|
+
doc_path,
|
|
322
|
+
{DOCUMENT_PART: bytes(out)},
|
|
323
|
+
Footprint(replaced=frozenset(swaps)),
|
|
324
|
+
pkg.sha256,
|
|
325
|
+
now=now,
|
|
326
|
+
env=env,
|
|
327
|
+
stage=stage,
|
|
328
|
+
)
|
|
329
|
+
return UndoOutcome(written.before_sha256, written.after_sha256, written.backup, reverted)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# -- search ---------------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def fold(text: str, *, ignore_case: bool, ignore_accents: bool) -> str:
|
|
336
|
+
if ignore_accents:
|
|
337
|
+
text = "".join(
|
|
338
|
+
c for c in unicodedata.normalize("NFD", text) if not unicodedata.combining(c)
|
|
339
|
+
)
|
|
340
|
+
return text.casefold() if ignore_case else text
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def search_entry(
|
|
344
|
+
entry: Mapping[str, Any],
|
|
345
|
+
query: str,
|
|
346
|
+
*,
|
|
347
|
+
ignore_case: bool,
|
|
348
|
+
ignore_accents: bool,
|
|
349
|
+
width: int = 40,
|
|
350
|
+
) -> list[dict[str, Any]]:
|
|
351
|
+
"""Where ``query`` occurs in one entry: its request, and each paragraph before and after."""
|
|
352
|
+
needle = fold(query, ignore_case=ignore_case, ignore_accents=ignore_accents)
|
|
353
|
+
sources: list[tuple[str, str]] = [
|
|
354
|
+
("request", json.dumps(entry.get("request"), ensure_ascii=False))
|
|
355
|
+
]
|
|
356
|
+
for number, change in enumerate(entry.get("changes", [])):
|
|
357
|
+
if change.get("kind") == "replace":
|
|
358
|
+
sources.append((f"change {number} before", str(change.get("text_before", ""))))
|
|
359
|
+
sources.append((f"change {number} after", xml_text(str(change.get("after", "")))))
|
|
360
|
+
hits: list[dict[str, Any]] = []
|
|
361
|
+
for where, text in sources:
|
|
362
|
+
folded = fold(text, ignore_case=ignore_case, ignore_accents=ignore_accents)
|
|
363
|
+
at = folded.find(needle)
|
|
364
|
+
if at < 0:
|
|
365
|
+
continue
|
|
366
|
+
# Folding can change the length (accent stripping): then there is no reliable offset.
|
|
367
|
+
start = max(0, at - width) if len(folded) == len(text) else 0
|
|
368
|
+
hits.append({"op_id": entry["op_id"], "in": where, "text": text[start : start + 2 * width]})
|
|
369
|
+
return hits
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# -- purge ----------------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def purge(
|
|
376
|
+
log_path: Path,
|
|
377
|
+
*,
|
|
378
|
+
before: datetime | None,
|
|
379
|
+
everything: bool,
|
|
380
|
+
dry_run: bool,
|
|
381
|
+
confirm: str | None,
|
|
382
|
+
now: datetime | None = None,
|
|
383
|
+
) -> dict[str, Any]:
|
|
384
|
+
"""Remove history (``everything`` or entries older than ``before``), leaving a tombstone.
|
|
385
|
+
|
|
386
|
+
A dry run lists what would go and returns ``confirm``; the real run needs that token
|
|
387
|
+
back, so it removes exactly what the dry run showed. Tombstones are never removed.
|
|
388
|
+
"""
|
|
389
|
+
if everything == (before is not None):
|
|
390
|
+
raise DocControlError(
|
|
391
|
+
ErrorCode.SCHEMA_INVALID, "give exactly one of `all: true` and `before`"
|
|
392
|
+
)
|
|
393
|
+
log = read_log(log_path)
|
|
394
|
+
cutoff = _stamp(before) if before is not None else None
|
|
395
|
+
doomed = [
|
|
396
|
+
e
|
|
397
|
+
for e in log.entries
|
|
398
|
+
if e.get("kind") != "purge" and (cutoff is None or str(e.get("time", "")) < cutoff)
|
|
399
|
+
]
|
|
400
|
+
ids = [e["op_id"] for e in doomed]
|
|
401
|
+
scope = "all" if everything else f"before {cutoff}"
|
|
402
|
+
token = hashlib.sha256(f"{scope}|{','.join(ids)}".encode()).hexdigest()[:12]
|
|
403
|
+
summary: dict[str, Any] = {
|
|
404
|
+
"dry_run": dry_run,
|
|
405
|
+
"scope": scope,
|
|
406
|
+
"removed": 0 if dry_run else len(ids),
|
|
407
|
+
"would_remove": len(ids),
|
|
408
|
+
"first_op_id": ids[0] if ids else None,
|
|
409
|
+
"last_op_id": ids[-1] if ids else None,
|
|
410
|
+
"confirm": token,
|
|
411
|
+
}
|
|
412
|
+
if dry_run or not ids:
|
|
413
|
+
return summary
|
|
414
|
+
if confirm != token:
|
|
415
|
+
raise DocControlError(
|
|
416
|
+
ErrorCode.SCHEMA_INVALID,
|
|
417
|
+
"purging needs `confirm` from a dry run of the same request"
|
|
418
|
+
+ (" (the history changed since)" if confirm else ""),
|
|
419
|
+
path="$.confirm",
|
|
420
|
+
)
|
|
421
|
+
gone = set(ids)
|
|
422
|
+
kept = [ln for ln in log_path.read_bytes().split(b"\n") if ln.strip() and not _is_in(ln, gone)]
|
|
423
|
+
last_removed = max(n for i in ids if (n := op_number(i)) is not None)
|
|
424
|
+
stone = {
|
|
425
|
+
"log_version": LOG_VERSION,
|
|
426
|
+
"op_id": op_id_of(max(log.next_number(), last_removed + 1)),
|
|
427
|
+
"time": _stamp(now),
|
|
428
|
+
"kind": "purge",
|
|
429
|
+
"scope": scope,
|
|
430
|
+
"removed": len(ids),
|
|
431
|
+
"first_op_id": ids[0],
|
|
432
|
+
"last_op_id": ids[-1],
|
|
433
|
+
"last_removed": last_removed,
|
|
434
|
+
}
|
|
435
|
+
text = b"".join(ln + b"\n" for ln in kept) + _line(stone)
|
|
436
|
+
_write_text_atomic(log_path, text.decode("utf-8", "replace"))
|
|
437
|
+
summary["tombstone"] = stone["op_id"]
|
|
438
|
+
return summary
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _is_in(line: bytes, ids: set[str]) -> bool:
|
|
442
|
+
try:
|
|
443
|
+
value = json.loads(line.decode("utf-8"))
|
|
444
|
+
except ValueError:
|
|
445
|
+
return False
|
|
446
|
+
return isinstance(value, dict) and value.get("op_id") in ids and value.get("kind") != "purge"
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# -- stores and orphans ----------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def log_path_for(doc_path: Path, env: Mapping[str, str] | None = None) -> Path:
|
|
453
|
+
return store_dir(doc_path, env) / "log.jsonl"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def last_doc_path(log: Log) -> str | None:
|
|
457
|
+
for entry in reversed(log.entries):
|
|
458
|
+
if isinstance(entry.get("doc_path"), str):
|
|
459
|
+
return str(entry["doc_path"])
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def describe_store(root: Path) -> dict[str, Any]:
|
|
464
|
+
"""One store as ``history.orphans`` reports it."""
|
|
465
|
+
log = read_log(root / "log.jsonl")
|
|
466
|
+
real = [e for e in log.entries if e.get("kind") != "purge"]
|
|
467
|
+
doc_id: str | None = None
|
|
468
|
+
with contextlib.suppress(OSError):
|
|
469
|
+
doc_id = (root / "id").read_text(encoding="utf-8").strip() or None
|
|
470
|
+
return {
|
|
471
|
+
"store": str(root),
|
|
472
|
+
"doc": last_doc_path(log),
|
|
473
|
+
"doc_id": doc_id,
|
|
474
|
+
"entries": len(real),
|
|
475
|
+
"last_time": real[-1]["time"] if real else None,
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _subdirs(path: Path) -> list[Path]:
|
|
480
|
+
try:
|
|
481
|
+
return sorted(p for p in path.iterdir() if p.is_dir())
|
|
482
|
+
except OSError:
|
|
483
|
+
return []
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def find_orphans(
|
|
487
|
+
folder: Path | None = None, env: Mapping[str, str] | None = None
|
|
488
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
489
|
+
"""Stores whose document is gone (``orphans``) or unknown (``unknown``).
|
|
490
|
+
|
|
491
|
+
Looks in the app-data stores and, with ``folder``, in ``folder/.doc-control``.
|
|
492
|
+
Nothing is ever deleted here.
|
|
493
|
+
"""
|
|
494
|
+
orphans: list[dict[str, Any]] = []
|
|
495
|
+
unknown: list[dict[str, Any]] = []
|
|
496
|
+
candidates: list[tuple[Path, Path | None]] = [
|
|
497
|
+
(root, None) for root in _subdirs(user_home(env) / "stores")
|
|
498
|
+
]
|
|
499
|
+
if folder is not None:
|
|
500
|
+
candidates += [(root, folder / root.name) for root in _subdirs(folder / STORE_DIRNAME)]
|
|
501
|
+
for root, sibling in candidates:
|
|
502
|
+
info = describe_store(root)
|
|
503
|
+
doc = sibling if sibling is not None else (Path(info["doc"]) if info["doc"] else None)
|
|
504
|
+
if doc is None:
|
|
505
|
+
unknown.append(info)
|
|
506
|
+
elif not doc.exists():
|
|
507
|
+
orphans.append({**info, "doc": str(doc)})
|
|
508
|
+
return {"orphans": orphans, "unknown": unknown}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Pre-write checks: Word's owner lock file and on-disk change detection.
|
|
2
|
+
|
|
3
|
+
When Word opens ``Name.docx`` it creates a hidden owner file next to it whose
|
|
4
|
+
name is ``~$`` followed by the document name. To keep the result short Word
|
|
5
|
+
truncates long names: with a stem of 7 characters the first character is
|
|
6
|
+
dropped (``Capitul.docx`` -> ``~$apitul.docx``), and with a stem of 8 or more
|
|
7
|
+
the first two are (``Manuscrito.docx`` -> ``~$nuscrito.docx``). Short stems get
|
|
8
|
+
the plain prefix (``Novela.docx`` -> ``~$Novela.docx``). Other Office builds and
|
|
9
|
+
sync clients have been seen to use the plain prefix for any length, so it is
|
|
10
|
+
always checked too: a false positive only refuses a write, a miss could
|
|
11
|
+
overwrite a document Word is editing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from doc_control.errors import DocControlError, ErrorCode
|
|
19
|
+
from doc_control.ooxml.zipio import sha256_file
|
|
20
|
+
|
|
21
|
+
LOCK_PREFIX = "~$"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def lock_file_candidates(doc_path: Path) -> list[Path]:
|
|
25
|
+
"""Every name Word may give the owner lock file for ``doc_path``, plain form first."""
|
|
26
|
+
name = doc_path.name
|
|
27
|
+
stem_len = len(doc_path.stem)
|
|
28
|
+
names = [LOCK_PREFIX + name]
|
|
29
|
+
if stem_len == 7:
|
|
30
|
+
names.append(LOCK_PREFIX + name[1:])
|
|
31
|
+
elif stem_len >= 8:
|
|
32
|
+
names.append(LOCK_PREFIX + name[2:])
|
|
33
|
+
return [doc_path.with_name(n) for n in names]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_lock_file(doc_path: Path) -> Path | None:
|
|
37
|
+
for candidate in lock_file_candidates(doc_path):
|
|
38
|
+
if candidate.exists():
|
|
39
|
+
return candidate
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_unlocked(doc_path: Path) -> None:
|
|
44
|
+
"""Raise ``DOC_LOCKED`` if Word's owner lock file sits next to the document."""
|
|
45
|
+
lock = find_lock_file(doc_path)
|
|
46
|
+
if lock is not None:
|
|
47
|
+
raise DocControlError(
|
|
48
|
+
ErrorCode.DOC_LOCKED,
|
|
49
|
+
f"Word has {doc_path.name} open (lock file {lock.name})",
|
|
50
|
+
path=str(doc_path),
|
|
51
|
+
lock_file=str(lock),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_changed(doc_path: Path, expect_sha256: str | None) -> str:
|
|
56
|
+
"""Raise ``DOC_CHANGED_ON_DISK`` unless the file hashes to ``expect_sha256``.
|
|
57
|
+
|
|
58
|
+
With no expectation the check is skipped. Returns the actual sha256.
|
|
59
|
+
"""
|
|
60
|
+
actual = sha256_file(doc_path)
|
|
61
|
+
if expect_sha256 is not None and actual != expect_sha256.strip().lower():
|
|
62
|
+
raise DocControlError(
|
|
63
|
+
ErrorCode.DOC_CHANGED_ON_DISK,
|
|
64
|
+
f"{doc_path.name} changed on disk since it was read",
|
|
65
|
+
path=str(doc_path),
|
|
66
|
+
expected_sha256=expect_sha256,
|
|
67
|
+
actual_sha256=actual,
|
|
68
|
+
)
|
|
69
|
+
return actual
|