Skip to content

List tracked revisions in every story in Rust, Python and the CLI - #186

Open
hadim wants to merge 5 commits into
tensorbee:mainfrom
hadim:feat/story-revisions-listing
Open

hadim wants to merge 5 commits into
tensorbee:mainfrom
hadim:feat/story-revisions-listing

Conversation

@hadim

@hadim hadim commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • rdocx: add Document::story_revisions(), which returns owned
    StoryRevision snapshots (story(), id(), author(), timestamp(),
    kind()) for every story that accept_all and reject_all reach: the main
    document, headers, footers, comments, normal footnotes, endnotes and the
    text boxes inside them. It stages the document as resolution does and scans
    the staged parts with the resolver's own element inventory, so its length is
    the accept and reject count by construction. Each revision carries the
    StoryId that Document::stories reports for its Word story, with table
    cells folded into the story that holds the table. Document::revisions and
    RevisionRef are unchanged.
  • rdocx-py: Document.revisions now lists every story through
    story_revisions, and Revision gains an optional story (the same Story
    snapshot as StoryItem.story). The constructor keeps its four keywords and
    takes story=None as a fifth. Stub, typing_smoke.py and HLD 10 updated.
  • rdocx-cli: rdocx revision list covers every story. The schema-1 JSON
    record says "scope": "all-supported-stories" and each revision gains
    "story": {"kind", "part_name", "owner_index"}. Text mode appends the story
    kind, part name and owner index as three trailing tab columns and prints
    (no revisions) when there is none.
  • rdocx-cli: rdocx compare counts what it created per story. The JSON
    record gains "revisions" (the total) and "stories" (kind, part name,
    owner index and count for each story with a revision) and keeps
    "main_story_revisions" unchanged. The text summary prints
    Created N revision element(s) in K story(ies) and one indented line per
    story.
  • Archive rows of rdocx and rdocx-cli re-recorded and dated 2026-09-27 in
    ARCHIVE_REMEASUREMENT_DATES.

Closes #165

Why

Document.revisions and rdocx revision list read the typed main-document
projection only, while compare() writes revisions into every story and
accept_all() / rdocx revision accept resolve them there. With the issue
script, a footer-only edit gave:

Document.revisions: ()
rdocx revision list: (no revisions in main story)
rdocx revision accept: accept: 2 revision element(s)

and rdocx compare --json reported "main_story_revisions": 0 next to
"scope": "all-supported-stories". A caller checking that no tracked change
is left before releasing a file could not rely on the listing.

The gap also existed inside the main part. The resolver treats
w:txbxContent as modeled anywhere, and the typed walk never enters
drawings, so a text box revision in the body was resolved but never listed.

After this branch the same script prints:

Document.revisions: (<builtins.Revision object ...>, <builtins.Revision object ...>)
rdocx revision list: 0	R	2026-09-27T12:00:00Z	deletion	footer	/word/footer1.xml	0
1	R	2026-09-27T12:00:00Z	insertion	footer	/word/footer1.xml	0
rdocx revision accept: accept: 2 revision element(s)

Notes

  • API choices: a Rust story_revisions() of owned
    snapshots at the Word story level (cells fold into their story), a length
    equal to the accept count, text boxes in the main part included, Python
    Document.revisions widened in place with an optional story, and CLI
    schema 1 kept with scope updated and main_story_revisions kept.
  • How the inventory is built. story_revisions stages a copy of the
    document exactly as resolve_revisions does (clone_for_staging then
    prepare_staged_package) and walks the staged main part and then the parts
    the resolver visits, taken from the comparison story-part inventory, which
    now also returns each part's kind (related_story_part_names became
    revision_story_parts, crate-private). Each part is parsed with the
    resolver's XmlTree, and every element with revision metadata is one
    entry. The main part is scanned as staged, not as the typed serialization,
    because staging keeps the original bytes of an unchanged document and
    replays nested namespace declarations on a modified one. The typed
    serialization drops some of those bindings, for example a default-namespace
    main part or a text box prefix declared on w:body, w:p or w:r, and a
    revision behind such a binding was resolved but not listed. A document
    that cannot be staged fails the listing with the error accept returns.
  • How stories are attributed. scan_story_owners runs on the staged bytes
    for the spans and on the bytes stories() scans (the typed main
    serialization, the package bytes for other parts) for the identities, so
    every StoryId is one stories() returns, fingerprint included. The two
    owner lists pair in order when their kinds match. When they differ, root
    owners pair by index and text boxes by identical bytes, and a staged text
    box that stories() does not report is left unpaired, so its revisions
    fold into the story around it. The innermost paired non-cell owner span
    holding the element's start tag names its story. A part reached twice (for
    example one part referenced as a header and a footer) is listed once, which
    matches the resolver, whose second pass finds nothing left.
  • Why not use the stories() inventory directly. story_sources errors on
    a header reference to an external or wrong-type relationship, which the
    resolver skips, and it also collects header references from w:sectPr in
    nested paragraphs, which the resolver does not visit. Building on the
    resolver's part list keeps the listing working exactly when accept works
    and keeps the count identical.
  • Maintainer decision 1, recommended answer: error. A revision outside every
    story owner is an error (tracked revision at byte N of <part> has no story owner). In practice this is a revision inside a footnote or endnote
    separator (id 0 or below, or a separator type), which stories() does not
    expose but accept resolves. Skipping it would silently break the
    count-equals-accept contract, which is the point of the listing. The
    alternative is an Option<StoryId>, which every caller would have to
    handle for a case Word only produces when a user edits a separator in
    Draft view with tracking on. The test pins that accept still resolves it.
  • Maintainer decision 2, recommended answer: keep. main_story_revisions
    keeps its previous computation, Document::revisions().len(), so existing
    consumers read the same number. It excludes main-part text boxes, while the
    new stories entry for the body does not, so the two can differ on a body
    that holds a text box revision.
  • Maintainer decision 3, recommended answer: element-count semantics.
    A text box written as mc:AlternateContent holds its content twice
    (mc:Choice and mc:Fallback). The resolver counts both copies, so the
    listing lists both. stories() does not report a text box under
    mc:AlternateContent as its own owner (the scanner treats that wrapper as
    opaque), so both copies belong to the story that holds the drawing. A bare
    w:drawing or VML w:pict text box is reported as a TextBox story,
    unless the typed serialization drops its namespace binding, in which case
    stories() does not report it either and it belongs to the story that
    holds the drawing.
  • Maintainer decision 4, recommended answer: trailing columns. The CLI text
    mode keeps the four existing columns in place and appends the story, so a
    script reading columns 0 to 3 is unaffected. Kind labels are kebab-case
    like the revision kinds (text-box, table-cell), with unknown for a
    future StoryKind since the enum is non-exhaustive.
  • Behaviour changes on published surfaces:
  • Rust API: additive only (Document::story_revisions, StoryRevision
    re-exported at the crate root). No public type or signature changed.
  • Overlaps. crates/rdocx-cli/src/commands.rs, main.rs, tests/integration.rs
    and README.md are also touched by End the CLIs cleanly on a closed pipe and refuse silent overwrites #174. This branch keeps its code changes
    inside revision_list, compare and two new label helpers next to
    revision_kind_label, and puts its new tests and their helpers after
    compare_accept_and_reject_reproduce_each_input. The rdocx-py files are
    also touched by Expose comparison options as keywords of Python Document.compare #176 and Align split_run, bookmarks and story comments on the direct body index #179, in different functions.
    The rdocx and rdocx-cli archive rows conflict with every PR touching
    those crates and need a re-record after rebasing.
  • Not in this PR: nothing of Feature: list revisions in every story, as accept, reject and compare already act on every story #165 is left.

Tests

  • Added to crates/rdocx/tests/regression_test.rs, as a group placed right
    after scoped_revision_resolution_visits_every_compared_story_once:
    • story_revisions_list_a_compared_footer_that_the_main_listing_omits: the
      issue reproduction in Rust. revisions() stays empty, story_revisions()
      has a deletion and an insertion by R at the given timestamp, both in the
      Footer story returned by stories(), accept and reject each resolve 2,
      and the listing is identical after a save and reopen.
    • story_revisions_name_every_compared_story_and_match_resolution_counts:
      a full-story comparison (body, header, footer, comment, footnote,
      endnote) lists each story in resolution order with at least two
      revisions, the body count equals revisions().len(), and the total
      equals both the accept and reject counts.
    • story_revisions_fold_cells_and_report_text_boxes_as_their_own_story: a
      header with a table-cell insertion, a paragraph-mark insertion and a run
      property change sharing one id, and a VML text box deletion. The cell and
      mark revisions belong to the header, the text box revision to the
      header's TextBox story. A body with a VML text box and an
      mc:AlternateContent text box (Choice and Fallback) lists 4 entries
      where revisions() lists 1, and accept and reject each resolve 4.
    • story_revisions_refuse_a_revision_outside_every_story_owner: a
      revision inside a footnote separator makes story_revisions() fail with
      the owner error while stories() succeeds and accept and reject still
      resolve it.
    • story_revisions_scan_the_main_part_bytes_that_resolution_scans: a
      default-namespace main part with an unprefixed ins, and a VML text box
      written as x:txbxContent / x:ins with xmlns:x bound to the Word
      namespace on w:body, w:p or w:r, each list one body revision where
      the typed serialization lists none, and the list length equals the
      accept and reject counts (after a save and reopen, and in memory). After
      a typed edit, the paragraph and run cases replay the binding and still
      list and resolve one, and the body case fails the listing with the same
      error accept returns. A lost text box followed by an ordinary one keeps
      the ordinary one in its TextBox story and folds the lost one into the
      body.
  • Added to crates/rdocx-py/tests/test_core.py, next to the other revision
    tests: test_revisions_list_every_story_and_name_the_story_that_holds_them
    (footer-only compare lists two revisions whose story equals the footer
    Story, body revisions carry the body Story, the constructor without
    story gives None, with story keeps it, and accept_all() equals the
    listed count). typing_smoke.py checks Revision.story is Story | None.
  • Added to crates/rdocx-cli/tests/integration.rs:
    revision_list_names_the_story_of_compared_footer_revisions (empty text
    output, JSON story objects, seven text columns, accept count equal to the
    list length) and compare_counts_the_revisions_it_creates_in_each_story
    (exact JSON record and exact text summary). The existing
    cli_collaboration_commands_are_schema_stable_and_atomic now pins the body
    story in both records.
  • Parity check, not committed: with accept_all and reject_all temporarily
    logging story_revisions() next to their result, the whole
    regression_test binary made 621 such calls. All 614 where both succeeded
    matched (495 with a nonzero count). The others are by design: 2 listing
    errors with a successful resolution (the separator test), 4 resolution
    errors on malformed property revisions that the listing still lists, and 1
    where both fail to stage (the new body case). The lib and
    integration_test binaries showed only their environment failures, and
    the 19 calls from the CLI suite all matched.
  • Gates run:
    • cargo fmt --all --check: clean.
    • cargo clippy -p rdocx -p rdocx-cli -p rdocx-py --all-targets --all-features -- -D warnings: clean.
    • cargo test -p rdocx --no-fail-fast: regression_test 566 passed, 7
      ignored. Lib 465 passed with 2 environment failures
      (large_word_and_presentation_pdfs_preserve_logical_reading_order,
      word_and_powerpoint_chart_pixels_are_identical). integration_test 314
      passed with 5 environment failures (every_conditional_table_region_matches_word,
      section_page_semantics_match_pinned_libreoffice_render,
      odt_reader_matches_pinned_libreoffice_structure,
      sanitized_public_authoring_fixture_passes_every_conformance_stage,
      public_authored_theme_and_fonts_match_pinned_word_resolution). All
      seven pin local tool versions and fail on main too.
    • cargo test -p rdocx-cli: 2 and 19 passed.
    • cargo test -p rdocx-py with DYLD_LIBRARY_PATH set to the Python
      library directory: 2 passed (without it the binary cannot load
      libpython, an environment limit).
    • RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links" cargo doc -p rdocx --no-deps: clean.
    • python3 scripts/hash_harness.py --check: 49 entries match.
    • python3 scripts/prose_check.py: 0 violations.
    • Python gate in a scratch venv (maturin develop, pytest 9.1.1, mypy
      2.3.0, python-docx 1.2.0): pytest crates/rdocx-py/tests 66 passed with
      2 environment failures in test_rendering_threads.py (pinned
      pdfinfo 26.01.0, local 26.09.0). mypy --strict on typing_smoke.py
      and the package: no issues. mypy.stubtest rdocx: no issues. Run before
      the staged-bytes fix, which changes no binding code.
    • Archive evidence: validate_measurement_evidence() passes and both
      re-recorded archives match their rows.
  • The issue script run against this branch (debug builds of the Python
    binding and the CLI) gives the output shown under Why. The Production readiness for editing real docx and pptx files: an acceptance contract, two realistic fixtures and two matrices #158
    fixture-report.docx lists (no revisions) with its 390 table cells, and
    comparing it against an edited copy still stops on the comments root shell
    (Producer traits: a matrix over every operation, and what still fails in it and around it #160, section 3), which this PR does not touch.

Document::revisions walks the typed main-document projection only,
while accept_all, reject_all and compare act on headers, footers,
comments, footnotes, endnotes and text boxes. A redline whose only
change is in a footer therefore listed nothing while accept_all
resolved two revision elements, and a text box revision in the main
part was resolved but never listed either.

Add Document::story_revisions, which returns owned StoryRevision
snapshots. It stages a copy of the document as revision resolution
does, then scans the main part and the related story parts left in
the staged package with the same element inventory the resolver
counts, so the list length equals the accept and reject count by
construction. That includes a main part whose typed serialization
drops a namespace binding a revision depends on, such as a default
namespace or a text box prefix declared on w:body, w:p or w:r, and a
document that cannot be staged fails the listing as it fails accept.

Each revision carries the StoryId of the innermost owner that
Document::stories reports around it, with table cells folded into the
story that holds the table. Where the staged bytes and the typed
serialization disagree on the owners, root owners pair by index and
text boxes by identical bytes, and a text box stories() does not
report folds into the story around it. A revision outside every
owner, such as one in a footnote separator, is an error rather than a
silent omission. Document::revisions and RevisionRef are unchanged.

GitHub issue tensorbee#165.
Document.revisions mapped the main-only native listing, so a redline
whose only change sat in a footer returned an empty tuple while
accept_all() reported two resolved revision elements. A caller
checking that no tracked change is left before releasing a file could
not trust the tuple, and Revision had no way to say where a change
lives.

Build the tuple from Document::story_revisions instead, so its length
is the count accept_all and reject_all return, and give Revision an
optional story carrying the same Story snapshot as StoryItem.story.
The constructor keeps its four keywords and takes story=None as an
extra keyword. The getter now raises RdocxError when the story graph
cannot be inventoried, as Document.stories already does, or when the
document cannot be staged for resolution, as accept_all already does.
The stub, typing smoke test and HLD 10 follow.

GitHub issue tensorbee#165.
rdocx revision list read the main-only typed listing and printed "(no
revisions in main story)" for a redline whose changes sat in a footer,
while rdocx revision accept on the same file resolved two revision
elements. Its schema-1 record also stated scope "main" with no way to
tell where a change lives.

Build the command on Document::story_revisions. The JSON record now
states scope "all-supported-stories" and gives each revision a story
object with its kebab-case kind, part name and owner index. Text mode
appends the same three values as trailing tab columns, so the existing
column indexes keep their meaning, and prints "(no revisions)" when
there is none. The list length now equals the count revision accept
and reject report. The clap help, the CLI README and HLD 10 follow.

GitHub issue tensorbee#165.
rdocx compare counted Document::revisions, the main-only typed
listing, so a footer-only edit reported "main_story_revisions": 0 next
to "scope": "all-supported-stories" and the text summary said it
created 0 main-story revision elements, although the redline carried a
deletion and an insertion in the footer.

Count Document::story_revisions after the comparison instead. The
schema-1 JSON record gains "revisions", the total that revision accept
and reject resolve, and "stories", one entry per story with at least
one revision giving its kind, part name, owner index and count in
listing order. "main_story_revisions" keeps its previous meaning for
existing consumers. The text summary names the total and prints one
indented line per story. The CLI README and HLD 10 follow.

GitHub issue tensorbee#165.
The all-story revision listing, the widened CLI records and their
tests grow the rdocx and rdocx-cli packages, so the crates.io archive
rows in README.md and crates/rdocx-cli/README.md and their
ARCHIVE_MEASUREMENTS entries are re-measured, with both crates dated
2026-09-27 in ARCHIVE_REMEASUREMENT_DATES.

GitHub issue tensorbee#165.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: list revisions in every story, as accept, reject and compare already act on every story

1 participant