Skip to content

Commit 1ace4be

Browse files
authored
feat: reconstruct revision groups at parse time (ISSUES.md #46) (#61)
* feat: reconstruct revision groups at parse time — inferred groups for pre-existing revisions (ISSUES.md #46) Revisions already in the file (previous sessions, foreign reviewers, Word round-trips) now get inferred groups at RevisionManager construction: maximal runs of contiguous same-paragraph revisions sharing identical raw w:author + w:date become one group, so whole logical edits resolve as a unit after reopen too. New Revision.group_source ("recorded" | "inferred") reports provenance; split-off tails of own pre-session insertions are adopted into their origin's inferred group; ungroupable revisions (missing author/date, outside any paragraph, nonconforming ids) stay group_id=None and break contiguity runs. Known imprecisions (same-paragraph same-second over-merge, nested-paragraph over-split, rump groups) are documented and pinned by tests. * fix: review round 1 — freeze one w:date per edit, safe nonconforming-id listing, snapshot dataclass - frozen_timestamp(): one logical operation now stamps all its revisions with a single w:date (previously each injection call read the clock, so a multi-hunk edit crossing a second boundary would reconstruct as several inferred groups after reopen); adversarial ticking-clock test. - list_revisions() no longer crashes on a non-numeric w:id (omitted: unrepresentable by int-keyed operations); a duplicated w:id is now wholly ungrouped — every occurrence — since id-keyed lookup cannot tell occurrences apart. Docs aligned. - _RegistrySnapshot is a named dataclass (CLAUDE.md: named objects over 3+-tuples); Revision.group_source reuses the _GroupSource alias. * fix: review round 2 — duplicate-id pre-scan, doc parity for foreign split halves, public GroupSource alias - _reconstruct_groups now pre-scans for duplicated w:ids independent of groupability: an ungroupable occurrence (e.g. missing date) no longer lets its groupable twin win a group that id-keyed lookup would report for both elements; replaces the round-1 demotion surgery with a simpler never-group-duplicates rule. - docs/api.md and SKILL.md now list the fourth group_id=None case (mid-session split half of a foreign insertion), matching the Revision docstring. - _GroupSource renamed to GroupSource: it types the public Revision.group_source field (same convention as session.ExecStatus).
1 parent 818580a commit 1ace4be

7 files changed

Lines changed: 723 additions & 103 deletions

File tree

‎docs/api.md‎

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -626,23 +626,38 @@ revision. Accepting the group applies the whole edit — the safe alternative to
626626
resolving a multi-revision edit (especially a rewrite) revision by revision,
627627
which garbles the text if only some are applied.
628628

629-
Groups are **in-memory and per-open-Document**: after `close()`/reopen,
630-
revisions report `group_id=None` and old group ids raise
631-
[`RevisionError`](#revisionerror). `save()` does not invalidate groups (the
632-
Document stays open and revision ids are preserved). Revisions authored
633-
outside the current session (foreign reviewers, previous sessions) have no
634-
group — accept/reject them by id or author as before. If groups ever need to
635-
survive across sessions, the workspace `meta.json` (which never enters the
636-
`.docx`) is the natural extension point; that persistence is intentionally not
637-
implemented today.
629+
Group ids are **in-memory and per-open-Document**, renumbered on each open.
630+
Edits made through the open Document **record** their group
631+
(`Revision.group_source == "recorded"`). For revisions already in the file —
632+
previous sessions, foreign reviewers, Word round-trips — nothing is persisted
633+
in the `.docx` (Word has no grouping concept and strips unknown markup), so
634+
groups are **inferred** at parse time instead
635+
(`Revision.group_source == "inferred"`): contiguous revisions in the same
636+
paragraph sharing identical `w:author` + `w:date` reconstruct as one group.
637+
That heuristic almost always matches the original logical edits — one caveat:
638+
`w:date` has second precision, so two edits to the *same paragraph* within the
639+
same second merge into one inferred group. When Word already resolved part of
640+
a former edit, the remainder reconstructs as a smaller (rump) group, and
641+
`accept_group()`/`reject_group()` handle it fine. Revisions missing an author
642+
or date, sitting outside any paragraph (e.g. table-row markers), or sharing a
643+
duplicated id stay ungrouped (`group_id=None`), as does the trailing half
644+
created when an edit splits a *foreign* author's pending insertion mid-session
645+
(foreign grouping is best-effort); revisions with non-numeric ids are omitted
646+
from `list_revisions()` entirely (no id-keyed operation could target them).
647+
648+
Never carry a `group_id` across sessions: reopening renumbers groups from 1,
649+
so a stale id from a previous session may silently resolve to a *different*
650+
group rather than raise. Always take group ids from the current session's
651+
`EditResult` or `list_revisions()`. `save()` does not invalidate groups (the
652+
Document stays open and revision ids are preserved).
638653

639654
**Parameters:**
640655

641656
- `group_id` (int): Group id from an `EditResult` (or a `Revision.group_id`)
642657

643658
**Returns:** Number of revisions accepted (int). Members already resolved individually are skipped (and not counted).
644659

645-
**Raises:** [`RevisionError`](#revisionerror) if the group id is unknown (never allocated, or allocated by a previous session on this document).
660+
**Raises:** [`RevisionError`](#revisionerror) if the group id is unknown to this open Document.
646661

647662
**Example:**
648663

@@ -656,7 +671,8 @@ doc.accept_group(result.group_id) # apply the whole rewrite
656671
Reject every revision created by one logical edit operation — the counterpart
657672
of [`accept_group()`](#accept_groupgroup_id). Rejecting the group undoes the
658673
whole edit, restoring the exact pre-edit text (deletions restored, insertions
659-
removed). Same group semantics and lifetime as `accept_group()`.
674+
removed). Same group semantics and lifetime as `accept_group()` — including
675+
recorded vs inferred groups and per-open renumbering.
660676

661677
**Parameters:**
662678

@@ -806,7 +822,8 @@ from docx_editor import Revision
806822
| `author` | str | The revision author |
807823
| `date` | datetime or None | When the revision was made |
808824
| `text` | str | The inserted or deleted text |
809-
| `group_id` | int or None | Revision group of the edit that created it in this session (see [`accept_group()`](#accept_groupgroup_id)); None for foreign or pre-session revisions |
825+
| `group_id` | int or None | Revision group this revision belongs to (see [`accept_group()`](#accept_groupgroup_id)): recorded for this session's edits, inferred by reconstruction for revisions already in the file; None only for ungroupable revisions (missing author/date, outside any paragraph, duplicated id, or a mid-session split half of a foreign insertion) |
826+
| `group_source` | str or None | Provenance of `group_id`: `"recorded"` (created through this open Document) or `"inferred"` (reconstructed at parse time from same-paragraph contiguity + identical author and date); None iff `group_id` is None |
810827

811828
### Example
812829

@@ -890,7 +907,7 @@ from docx_editor import EditResult
890907

891908
| Attribute | Type | Description |
892909
|-----------|------|-------------|
893-
| `group_id` | int or None | Revision group holding every revision this edit created, for [`accept_group()`](#accept_groupgroup_id) / [`reject_group()`](#reject_groupgroup_id). None when the edit created no new revisions (e.g. text spliced into one of your own pending insertions, a no-change rewrite, or a rewrite whose changes all merged into your own pending insertions). Valid only while this Document stays open. |
910+
| `group_id` | int or None | Revision group holding every revision this edit created, for [`accept_group()`](#accept_groupgroup_id) / [`reject_group()`](#reject_groupgroup_id). None when the edit created no new revisions (e.g. text spliced into one of your own pending insertions, a no-change rewrite, or a rewrite whose changes all merged into your own pending insertions). Valid only while this Document stays open — after reopen the same revisions belong to a freshly inferred group with a new id. |
894911
| `revision_ids` | tuple[int, ...] | The `w:id`s of the group's member revisions, in creation order; `()` when `group_id` is None |
895912

896913
### Example
@@ -1085,7 +1102,7 @@ except CommentError as e:
10851102

10861103
### `RevisionError`
10871104

1088-
Raised when a revision operation fails — most commonly an unknown group id passed to `accept_group()` / `reject_group()`. Revision groups are in-memory and per-open-`Document`, so ids from a previous session are unknown; resolve those revisions by id or author instead. Structured fields: `revision_id` and `group_id` — set when the error is about that specific id (`group_id` for unknown-group errors), `None` otherwise.
1105+
Raised when a revision operation fails — most commonly an unknown group id passed to `accept_group()` / `reject_group()`. Group ids are per-open-`Document` and renumbered on each open (recorded for this session's edits, inferred by reconstruction for revisions already in the file), so always use a group id from the current session's `EditResult` or `list_revisions()` — a stale id from a previous session may raise this, or worse, silently resolve to a different group. Structured fields: `revision_id` and `group_id` — set when the error is about that specific id (`group_id` for unknown-group errors), `None` otherwise.
10891106

10901107
```python
10911108
from docx_editor.exceptions import RevisionError

‎docx_editor/document.py‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,10 @@ class Document:
6969
result = doc.replace("30 days", "60 days", paragraph=ref)
7070
doc.add_comment("Section 5", "Please review")
7171
72-
# Every edit's revisions form a group — accept/reject them as a unit
73-
# (group ids live only while this Document is open):
72+
# Every edit's revisions form a group — accept/reject them as a unit.
73+
# Group ids are per-open-Document (renumbered on each open; revisions
74+
# already in the file get inferred groups reconstructed at parse
75+
# time), so always use ids from this session:
7476
result = doc.rewrite_paragraph(result, "The board shall approve.")
7577
doc.reject_group(result.group_id) # undo the whole rewrite
7678
@@ -1276,9 +1278,11 @@ def list_revisions(self, author: str | None = None, paragraph: str | None = None
12761278
locatable, e.g. nested revisions), plus ``nested_under`` and
12771279
``contains_ids`` describing revision nesting (e.g. a foreign
12781280
deletion inside another author's pending insertion), and
1279-
``group_id`` linking revisions created by the same edit
1280-
operation in this session (None for foreign or pre-session
1281-
revisions).
1281+
``group_id``/``group_source`` linking revisions from the same
1282+
logical edit — recorded for this session's edits, inferred by
1283+
parse-time reconstruction for revisions already in the file
1284+
(``group_id`` is None only for ungroupable revisions, e.g.
1285+
missing author/date).
12821286
12831287
Raises:
12841288
ValueError: If ``paragraph`` is malformed
@@ -1343,9 +1347,15 @@ def accept_group(self, group_id: int) -> int:
13431347
resolving a multi-revision edit (especially a rewrite) revision by
13441348
revision can leave the text garbled if only some are applied.
13451349
1346-
Groups are in-memory and live only while this Document is open:
1347-
after close()/reopen, revisions report ``group_id=None`` and old
1348-
group ids raise. save() does not invalidate groups.
1350+
Group ids are in-memory and per-open-Document, renumbered on each
1351+
open. Revisions already in the file (previous sessions, foreign
1352+
reviewers) get inferred groups reconstructed at parse time —
1353+
contiguous same-paragraph revisions sharing identical author and
1354+
date — so whole logical edits resolve as a unit after reopen too
1355+
(see ``Revision.group_source``). Always use a group id from this
1356+
session's EditResult or list_revisions(); a stale id from a
1357+
previous session may resolve to a different group. save() does not
1358+
invalidate groups.
13491359
13501360
Args:
13511361
group_id: Group id from an EditResult (or a Revision's
@@ -1356,8 +1366,7 @@ def accept_group(self, group_id: int) -> int:
13561366
individually are skipped (and not counted).
13571367
13581368
Raises:
1359-
RevisionError: If the group id is unknown (never allocated, or
1360-
allocated by a previous session on this document).
1369+
RevisionError: If the group id is unknown to this open Document.
13611370
13621371
Example:
13631372
result = doc.rewrite_paragraph(ref, "New text.")
@@ -1371,7 +1380,8 @@ def reject_group(self, group_id: int) -> int:
13711380
13721381
The counterpart of :meth:`accept_group` — rejecting the group undoes
13731382
the whole edit, restoring the exact pre-edit text (deletions are
1374-
restored, insertions removed).
1383+
restored, insertions removed). Same group semantics and lifetime as
1384+
accept_group(), including inferred groups after reopen.
13751385
13761386
Args:
13771387
group_id: Group id from an EditResult (or a Revision's
@@ -1382,8 +1392,7 @@ def reject_group(self, group_id: int) -> int:
13821392
individually are skipped (and not counted).
13831393
13841394
Raises:
1385-
RevisionError: If the group id is unknown (never allocated, or
1386-
allocated by a previous session on this document).
1395+
RevisionError: If the group id is unknown to this open Document.
13871396
13881397
Example:
13891398
result = doc.rewrite_paragraph(ref, "New text.")

0 commit comments

Comments
 (0)