Skip to content

Commit 5b563bb

Browse files
committed
feat: collision-bumped w:date stamping at changeset granularity (ISSUES.md #53)
One changeset (a single edit call, or one whole batch_edit/batch_rewrite call) stamps one whole-second w:date, bumped past the previous stamp when the same author's changesets collide in one real second. Parse-time group reconstruction (#46) therefore never merges two of our own changesets; same-paragraph ops of one batch call share their date and merge by design. - xml_editor: _next_changeset_timestamp() allocates collision-bumped stamps (monotonic per author, also across clock regressions); frozen_timestamp() becomes reentrant-by-reuse so nested per-op freezes join the enclosing batch changeset. - track_changes: batch_edit/batch_rewrite wrap the whole call in one frozen_timestamp() scope — one date per batch call, one group per op. - tests: TestChangesetDateStamping pins the contract (shared batch date under a ticking clock, no bump when seconds differ, bounded drift, distinct dates for two batches in one second, nested freeze reuse, w16du:dateUtc parity); the former same-second over-merge relaxation flips into test_same_paragraph_same_second_two_groups.
1 parent 12ae649 commit 5b563bb

5 files changed

Lines changed: 258 additions & 43 deletions

File tree

‎docs/api.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -653,9 +653,13 @@ in the `.docx` (Word has no grouping concept and strips unknown markup), so
653653
groups are **inferred** at parse time instead
654654
(`Revision.group_source == "inferred"`): contiguous revisions in the same
655655
paragraph sharing identical `w:author` + `w:date` reconstruct as one group.
656-
That heuristic almost always matches the original logical edits — one caveat:
657-
`w:date` has second precision, so two edits to the *same paragraph* within the
658-
same second merge into one inferred group. When Word already resolved part of
656+
That heuristic matches this library's own edits exactly: each changeset (one
657+
edit call, or one whole `batch_edit`/`batch_rewrite` call) stamps a
658+
collision-bumped whole-second date, so two changesets by the same author never
659+
share a second; all ops of one batch call share one date (one changeset) and
660+
same-paragraph batch ops merge by design. Foreign revisions with identical
661+
`w:author` + `w:date` still merge (`w:date` has second precision). When Word
662+
already resolved part of
659663
a former edit, the remainder reconstructs as a smaller (rump) group, and
660664
`accept_group()`/`reject_group()` handle it fine. Revisions missing an author
661665
or date, sitting outside any paragraph (e.g. table-row markers), or sharing a

‎docx_editor/track_changes.py‎

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,13 @@ class Revision:
346346
groups created by edits through this open Document, ``"inferred"``
347347
for groups reconstructed at parse time by the heuristic (contiguous
348348
same-paragraph revisions sharing identical ``w:author`` + ``w:date``
349-
are one group). Inferred groups usually match the original logical
350-
edits, but two edits to the same paragraph within the same second
351-
merge into one group (``w:date`` has second precision). None iff
352-
``group_id`` is None.
349+
are one group). Our own writes stamp collision-bumped dates (two
350+
changesets by one author never share a second), so inferred groups
351+
match the original changesets; same-paragraph ops of one
352+
``batch_edit``/``batch_rewrite`` call share their date and merge by
353+
design. Foreign revisions with identical author + date can still
354+
over-merge (``w:date`` has second precision). None iff ``group_id``
355+
is None.
353356
"""
354357

355358
id: int
@@ -584,9 +587,12 @@ def _reconstruct_groups(self) -> None:
584587
585588
Known, accepted imprecisions:
586589
587-
- Two logical edits to the same paragraph within the same second
588-
(w:date has second precision) merge into one group — accepted
589-
over-merge, pinned by test.
590+
- Same-paragraph over-merge is confined to one batch call for our
591+
own writes: each changeset stamps a collision-bumped date (never
592+
reused across an author's changesets), so only ops of a single
593+
batch_edit/batch_rewrite call — which share their date by
594+
design — merge. Foreign producers stamping identical author +
595+
date (w:date has second precision) can still over-merge.
590596
- A revision inside a nested paragraph (e.g. a text box's
591597
w:txbxContent) interrupts the outer paragraph's run in document
592598
order — conservative over-split.
@@ -856,7 +862,9 @@ def batch_edit(self, operations: list[EditOperation]) -> list[int]:
856862
857863
Validates all paragraph hashes before applying any edits.
858864
Applies edits in reverse paragraph order so earlier paragraphs'
859-
hashes remain valid throughout.
865+
hashes remain valid throughout. The whole call is one changeset:
866+
every op's revisions share one ``w:date``, while each op still
867+
records its own revision group in-session.
860868
861869
Args:
862870
operations: List of EditOperation objects (each must have paragraph set)
@@ -901,15 +909,18 @@ def batch_edit(self, operations: list[EditOperation]) -> list[int]:
901909

902910
try:
903911
results = [0] * len(operations)
904-
for original_idx, _ref, op in parsed:
905-
try:
906-
# Each op is its own logical edit: one group per op, so
907-
# callers can accept one op and reject another.
908-
with self._grouped():
909-
change_id = self._apply_single_edit(op)
910-
except (ValueError, DocxEditError) as e:
911-
raise BatchOperationError(original_idx, str(e), original=e) from e
912-
results[original_idx] = change_id
912+
# One batch call = one changeset: every op's revisions share one
913+
# w:date (the per-op _grouped() freezes join this outer scope).
914+
with self.editor.frozen_timestamp():
915+
for original_idx, _ref, op in parsed:
916+
try:
917+
# Each op is its own logical edit: one group per op, so
918+
# callers can accept one op and reject another.
919+
with self._grouped():
920+
change_id = self._apply_single_edit(op)
921+
except (ValueError, DocxEditError) as e:
922+
raise BatchOperationError(original_idx, str(e), original=e) from e
923+
results[original_idx] = change_id
913924
return results
914925
except Exception:
915926
# Restore via the line-tracking parser so parse_position is preserved.
@@ -1053,6 +1064,9 @@ def _validate_single(self, op: EditOperation) -> str | None:
10531064
def batch_rewrite(self, rewrites: list[tuple[str, str]]) -> list[int | None]:
10541065
"""Rewrite multiple paragraphs with upfront hash validation.
10551066
1067+
The whole call is one changeset: all rewrites share one ``w:date``,
1068+
while each rewrite still records its own revision group in-session.
1069+
10561070
Returns:
10571071
One revision group id per rewrite, in input order (None for
10581072
rewrites that created no revisions) — each rewrite gets its own
@@ -1099,13 +1113,18 @@ def batch_rewrite(self, rewrites: list[tuple[str, str]]) -> list[int | None]:
10991113
registry_snapshot = self._registry_snapshot()
11001114

11011115
try:
1102-
# Apply rewrites in reverse paragraph order
1116+
# Apply rewrites in reverse paragraph order. One batch call = one
1117+
# changeset: all rewrites share one w:date (each rewrite still
1118+
# gets its own group; duplicate paragraphs are rejected above, so
1119+
# reconstruction keeps one group per paragraph despite the
1120+
# shared date).
11031121
group_ids: list[int | None] = [None] * len(rewrites)
1104-
for original_idx, ref, new_text in parsed:
1105-
try:
1106-
group_ids[original_idx] = self.rewrite_paragraph(f"P{ref.index}#{ref.hash}", new_text)
1107-
except (ValueError, DocxEditError) as e:
1108-
raise BatchOperationError(original_idx, str(e), original=e) from e
1122+
with self.editor.frozen_timestamp():
1123+
for original_idx, ref, new_text in parsed:
1124+
try:
1125+
group_ids[original_idx] = self.rewrite_paragraph(f"P{ref.index}#{ref.hash}", new_text)
1126+
except (ValueError, DocxEditError) as e:
1127+
raise BatchOperationError(original_idx, str(e), original=e) from e
11091128
return group_ids
11101129
except Exception:
11111130
# Restore via the line-tracking parser so parse_position is preserved.

‎docx_editor/xml_editor.py‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from collections.abc import Callable, Iterator
1313
from contextlib import contextmanager
1414
from dataclasses import dataclass
15-
from datetime import datetime, timezone
15+
from datetime import datetime, timedelta, timezone
1616
from pathlib import Path
1717
from typing import Literal
1818
from xml.dom.minidom import Element
@@ -1239,6 +1239,9 @@ def __init__(
12391239
self._max_change_id = -1
12401240
self._tracked_change_collector: list[Element] | None = None
12411241
self._frozen_timestamp: str | None = None
1242+
# Last stamped (author, second) — the one collision counter behind
1243+
# _next_changeset_timestamp.
1244+
self._last_changeset_stamp: tuple[str, datetime] | None = None
12421245

12431246
def _get_next_change_id(self) -> int:
12441247
"""Get the next available change ID by checking all tracked change elements."""
@@ -1276,6 +1279,25 @@ def collect_tracked_changes(self) -> Iterator[list[Element]]:
12761279
finally:
12771280
self._tracked_change_collector = None
12781281

1282+
def _next_changeset_timestamp(self) -> str:
1283+
"""Whole-second UTC stamp for one changeset, collision-bumped.
1284+
1285+
True wall clock, except when this editor's previous changeset by the
1286+
same author already occupies this (or a later) second — then previous
1287+
second + 1, keeping (author, second) unique per changeset so
1288+
parse-time group reconstruction never merges two changesets. The
1289+
same rule keeps stamps monotonic per author across a clock
1290+
regression (e.g. an NTP step back): dates never go backwards,
1291+
pinned at previous + 1 until the clock catches up.
1292+
"""
1293+
now = datetime.now(timezone.utc).replace(microsecond=0)
1294+
if self._last_changeset_stamp is not None:
1295+
last_author, last_second = self._last_changeset_stamp
1296+
if last_author == self.author and now <= last_second:
1297+
now = last_second + timedelta(seconds=1)
1298+
self._last_changeset_stamp = (self.author, now)
1299+
return now.strftime("%Y-%m-%dT%H:%M:%SZ")
1300+
12791301
@contextmanager
12801302
def frozen_timestamp(self) -> Iterator[None]:
12811303
"""Stamp every element injected in this context with ONE ``w:date``.
@@ -1285,12 +1307,19 @@ def frozen_timestamp(self) -> Iterator[None]:
12851307
several times — crossing a second boundary mid-operation would spread
12861308
the operation's revisions across two ``w:date`` values, and parse-time
12871309
group reconstruction (which keys on identical dates) would then reopen
1288-
one edit as several groups. Does not nest — same single-slot design as
1289-
``collect_tracked_changes``.
1310+
one edit as several groups.
1311+
1312+
One changeset = one outermost ``frozen_timestamp()`` scope, stamped
1313+
via ``_next_changeset_timestamp`` (collision-bumped so two changesets
1314+
never share a second). Reentrant by reuse: a nested entry joins the
1315+
enclosing changeset — ``batch_edit``/``batch_rewrite`` open the scope
1316+
once for the whole call and the per-op ``_grouped()`` freezes join
1317+
it. Only the outermost entry allocates a stamp and clears it on exit.
12901318
"""
12911319
if self._frozen_timestamp is not None:
1292-
raise RuntimeError("frozen_timestamp() does not nest")
1293-
self._frozen_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1320+
yield
1321+
return
1322+
self._frozen_timestamp = self._next_changeset_timestamp()
12941323
try:
12951324
yield
12961325
finally:

‎skills/docx/SKILL.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -502,10 +502,12 @@ doc.reject_group(result.group_id) # undo the whole rewrite, or:
502502
# group (r.group_source == "inferred"; session edits are "recorded"). So
503503
# accept_group/reject_group work after reopen too — but always take the
504504
# group_id from THIS session's list_revisions()/EditResult; a stale id
505-
# from a previous session may resolve to a different group. Caveat:
506-
# w:date has second precision, so two edits to the same paragraph in the
507-
# same second merge into one inferred group. save() keeps groups alive
508-
# (the Document stays open).
505+
# from a previous session may resolve to a different group. Own edits
506+
# stamp collision-bumped whole-second dates (two changesets by one author
507+
# never share a second); all ops of one batch_edit/batch_rewrite call
508+
# share one date (one changeset). Foreign revisions with identical
509+
# author + date still merge. save() keeps groups alive (the Document
510+
# stays open).
509511
# Revision ids, unlike group ids, ARE stable: they are the w:id attributes
510512
# stored in the document XML and survive save()/close()/reopen — resolving
511513
# by revision id in a later session is always safe.

0 commit comments

Comments
 (0)