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