diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6db8a0..ebe1453f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,39 @@ All notable changes to this project will be documented in this file. evaluation, counting, and the whole multi-match rewrite share one mutation gate and one undo snapshot, so duplicate text cannot turn a stale plan into a partial replacement. +- **`DocxSession.Batch` / `BeginBatch` / `EndBatch`** — apply a sequence of mutations + as one logical operation: **one pre-op snapshot, one undo step**, and all-or-nothing + application by default. Every mutation records a snapshot, and a snapshot deep-clones + every projected part, so its cost scales with the *document* rather than the edit: a + forty-edit sequence over `TestFiles/NVCA-Model-COI.docx` paid forty ≈7.5 MB clones and + consumed forty entries of a twenty-deep ring — meaning the sequence a caller had just + applied was already only half reversible by the time it finished. Both are properties + of the loop, not of the work. Agent callers hit this hardest, because a plan is + naturally a list of edits. + - `Batch(steps, options)` takes the steps as `Func` closures and pairs the + open/close for you. `BeginBatch`/`EndBatch` is the explicit form for callers whose + steps cannot be expressed as delegates — a JSON dispatcher running its own switch. + - `BatchOptions.Atomic` (default true) reverses everything on the first failure. + Best-effort (`Atomic = false`) tolerates only the failures that provably did not + touch the document; a step that **threw partway or was rejected by the validator + still reverses the whole batch**, because no per-step snapshot survives to unpick + that step alone. Batching therefore cannot be used to opt out of the + half-applied-mutation guarantee that per-op rollback exists to provide. + - Nesting joins the enclosing batch rather than opening a second snapshot, so the whole + tree stays one undo step and the outermost close owns the outcome. +- **The puzzle eval** (`eval/`, `Docxodus.Tests/PuzzleEvalTests.cs`): levels that ask + whether an agent can reach a specified document state using only the grouped tool + surface and anchor addressing, **scored by `DocxDiff` returning zero revisions** against + the target rather than by judgement. A level declares its start and target as paragraph + lists (text, so it diffs in review; built by one function, so a scoring difference can + only come from the player's edits), a `par` call budget, the brief the player is given, + and a reference solution addressed by content via `FindAllByText` — the same op behind + `docxodus_search`, so the reference pays the same discovery cost a player does. CI keeps + the levels honest rather than testing the session: the reference solves the level, does + so within par, and the start document does not already score as solved — the last being + what stops a mis-built target from passing every level with an empty solution. Ships + with `L01-clause-order`. Complements the arcade, which drives `raw.replaceXml` and so + says nothing about the surface agents are actually given. - **`DocxSessionSettings.UndoMemoryBudgetBytes`** (wire `undoMemoryBudgetBytes`, Python `undo_memory_budget_bytes`) — an approximate ceiling on the memory held by undo/redo snapshots, default **128 MiB**. `UndoDepth` never bounded memory: @@ -71,6 +104,21 @@ All notable changes to this project will be documented in this file. explain why undo stops short of the configured depth instead of appearing broken. ### Changed +- **The arcade's social copy says what the Freedoom cartridge actually is** + (`docs/demo/arcade.html`): "a REAL Doom-format level" read as a live WAD parser, where + `tools/wad2cart.mjs` rasterizes E1M1's geometry to a character grid at build time and + ships the result as static data. `freedoom-e1m1.js` already described itself accurately; + only the `og:`/`twitter:` descriptions overstated it. +- **`docxodus_mutations` is now genuinely atomic rather than "atomic-feeling"** + (`tools/mcp-server/Dispatcher.cs`): the step loop runs inside a session batch, so a + forty-step agent plan costs one snapshot and one undo step instead of forty of each. + Two behaviour changes follow. `mode: "preview"` restores the batch's single snapshot + instead of issuing N `Undo()` calls with N counted by hand — the old loop silently + under-reverted whenever a step consumed more than one ring entry, so "nothing is left + changed" was a promise the tool could not keep. And a step that mutated before failing + now reverses the whole batch, reported as a new `rolledBack` field with `editsApplied` + forced to `0`, rather than leaving a half-applied document described as a partial + success. The wire schema is otherwise unchanged; no new tool, no new arguments. - **The GitHub Pages landing page serves THE DOCX ARCADE on a phone, keeps its navigation, and gives the arcade thumb controls** — three fixes to the same problem, that the demo's mobile visit was its worst one: diff --git a/Docxodus.Tests/DocxSessionBatchTests.cs b/Docxodus.Tests/DocxSessionBatchTests.cs new file mode 100644 index 00000000..30a8f6eb --- /dev/null +++ b/Docxodus.Tests/DocxSessionBatchTests.cs @@ -0,0 +1,554 @@ +#nullable enable + +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using DocumentFormat.OpenXml.Packaging; +using Docxodus; +using Xunit; + +namespace Docxodus.Tests; + +/// +/// Batch semantics for (DS43x). +/// +/// exists because a snapshot costs whatever the DOCUMENT +/// costs, not whatever the edit costs: N mutations in a loop paid N whole-document deep clones and +/// consumed N entries of a bounded undo ring, which on a long filing means the sequence a caller +/// just applied is only partially reversible. The contract pinned here is that a batch is one +/// snapshot, one undo step, and all-or-nothing by default. +/// +/// The rollback tests reuse the DS42x trigger — a payload XML cannot encode (U+0000) +/// makes an op throw well after it has started mutating — because a batch's hardest promise is the +/// one that only matters when a step fails partway: no per-step snapshot exists to unpick it with, +/// so the batch must reverse from its own. +/// +public class DocxSessionBatchTests +{ + private static readonly XNamespace W = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + + /// A payload XML cannot encode — the same realistic trigger DS42x uses. + private const string NulPayload = "note\0text"; + + private static string[] BodyParagraphs(DocxSession s) => + s.Project().AnchorIndex.Values + .Where(t => t.Anchor.Scope == "body" && t.Anchor.Kind is "p" or "h") + .Select(t => t.Anchor.Id) + .ToArray(); + + private static string BodyText(DocxSession s) + { + using var ms = new MemoryStream(s.Save()); + using var doc = WordprocessingDocument.Open(ms, false); + return string.Concat( + doc.MainDocumentPart!.GetXDocument().Descendants(W + "t").Select(t => t.Value)); + } + + // ─── One snapshot, one undo step ───────────────────────────────────── + + /// + /// The headline property. Three edits through a batch consume ONE undo entry and one + /// reverses all three — where the same three applied in a loop + /// consume three entries and need three undos. + /// + [Fact] + public void DS430_Batch_RecordsExactlyOneUndoStepForTheWholeSequence() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + var result = s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[0], "first"), + () => s.ReplaceText(paragraphs[1], "second"), + () => s.InsertParagraph(paragraphs[1], Position.After, "third"), + }); + + Assert.True(result.Success); + Assert.Equal(3, result.Applied); + Assert.Equal(3, result.Steps.Count); + Assert.False(result.RolledBack); + Assert.Equal(1, s.UndoCount); + + var after = BodyText(s); + Assert.Contains("first", after); + Assert.Contains("third", after); + + Assert.True(s.Undo()); + Assert.Equal(before, BodyText(s)); + Assert.Equal(0, s.UndoCount); + } + + /// The loop this replaces, asserted directly — otherwise DS430's "one" has nothing to + /// be one INSTEAD OF, and a regression that silently restored per-step snapshots would pass. + [Fact] + public void DS431_TheSameEditsWithoutABatchStillCostOneUndoStepEach() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + + Assert.True(s.ReplaceText(paragraphs[0], "first").Success); + Assert.True(s.ReplaceText(paragraphs[1], "second").Success); + + Assert.Equal(2, s.UndoCount); + } + + /// A batch's single entry must also redo as one step. + [Fact] + public void DS432_Batch_RedoesAsOneStep() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + + Assert.True(s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[0], "alpha"), + () => s.ReplaceText(paragraphs[1], "beta"), + }).Success); + var applied = BodyText(s); + + Assert.True(s.Undo()); + Assert.DoesNotContain("alpha", BodyText(s)); + + Assert.True(s.Redo()); + Assert.Equal(applied, BodyText(s)); + } + + // ─── Failure policy ────────────────────────────────────────────────── + + /// + /// Atomic (the default): a clean failure mid-sequence reverses the steps that already + /// succeeded, so a failed batch never leaves a half-applied document. + /// + [Fact] + public void DS433_AtomicBatch_ReversesEarlierStepsWhenALaterStepFailsCleanly() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + var result = s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[0], "applied"), + () => s.ReplaceText("p:body:deadbeefdeadbeefdeadbeefdeadbeef", "never"), + }); + + Assert.False(result.Success); + Assert.True(result.RolledBack); + Assert.Equal(0, result.Applied); + Assert.Equal(1, result.FailedStep); + Assert.Equal(EditErrorCode.AnchorNotFound, result.Error!.Code); + + Assert.Equal(before, BodyText(s)); + Assert.DoesNotContain("applied", BodyText(s)); + } + + /// A reversed batch leaves the ring as it found it — nothing to undo, and in + /// particular not the caller's PREVIOUS edit. + [Fact] + public void DS434_RolledBackBatch_DoesNotConsumeOrPolluteTheUndoRing() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + + Assert.True(s.ReplaceText(paragraphs[0], "the real edit").Success); + var afterRealEdit = BodyText(s); + Assert.Equal(1, s.UndoCount); + + var result = s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[1], "doomed"), + () => s.ReplaceText("p:body:deadbeefdeadbeefdeadbeefdeadbeef", "never"), + }); + Assert.False(result.Success); + + // The batch is gone from the ring entirely; the next undo reverses the REAL edit. + Assert.Equal(1, s.UndoCount); + Assert.Equal(afterRealEdit, BodyText(s)); + Assert.True(s.Undo()); + Assert.DoesNotContain("the real edit", BodyText(s)); + } + + /// + /// Best-effort tolerates the failures that provably did not touch the document, keeps the + /// successes, and still costs one undo step. + /// + [Fact] + public void DS435_BestEffortBatch_KeepsSuccessesPastACleanFailure() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + var result = s.Batch( + new Func[] + { + () => s.ReplaceText(paragraphs[0], "kept one"), + () => s.ReplaceText("p:body:deadbeefdeadbeefdeadbeefdeadbeef", "never"), + () => s.ReplaceText(paragraphs[1], "kept two"), + }, + new BatchOptions { Atomic = false, StopOnError = false }); + + Assert.False(result.Success); // a step failed… + Assert.False(result.RolledBack); // …but nothing was reversed + Assert.Equal(2, result.Applied); + Assert.Equal(3, result.Steps.Count); + Assert.Equal(1, result.FailedStep); + + var after = BodyText(s); + Assert.Contains("kept one", after); + Assert.Contains("kept two", after); + + // Still ONE undo step, and it reverses both surviving edits together. + Assert.Equal(1, s.UndoCount); + Assert.True(s.Undo()); + Assert.Equal(before, BodyText(s)); + } + + /// Best-effort with stop-on-error attempts nothing after the first failure. + [Fact] + public void DS436_BestEffortBatch_StopOnError_AttemptsNothingAfterTheFailure() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + + var result = s.Batch( + new Func[] + { + () => s.ReplaceText(paragraphs[0], "kept"), + () => s.ReplaceText("p:body:deadbeefdeadbeefdeadbeefdeadbeef", "never"), + () => s.ReplaceText(paragraphs[1], "not attempted"), + }, + new BatchOptions { Atomic = false, StopOnError = true }); + + Assert.False(result.Success); + Assert.Equal(2, result.Steps.Count); // the third was never run + Assert.Equal(1, result.Applied); + Assert.DoesNotContain("not attempted", BodyText(s)); + } + + // ─── Damage overrides policy ───────────────────────────────────────── + + /// + /// The sharp case. A step that throws partway has already mutated, and inside a batch there is + /// no per-step snapshot left to unpick it with — so the batch reverses EVEN under best-effort, + /// where a clean failure would have been tolerated. Without this, batching would silently + /// reintroduce exactly the half-applied-mutation bug the per-op rollback fixed. + /// + [Fact] + public void DS437_BestEffortBatch_StillReversesWhenAStepThrewPartway() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + var result = s.Batch( + new Func[] + { + () => s.ReplaceText(paragraphs[0], "applied first"), + () => s.InsertFootnote(paragraphs[1], 0, NulPayload), // throws mid-op + () => s.ReplaceText(paragraphs[1], "never reached"), + }, + new BatchOptions { Atomic = false, StopOnError = false }); + + Assert.False(result.Success); + Assert.True(result.RolledBack); + Assert.Equal(0, result.Applied); + Assert.Equal(EditErrorCode.InternalError, result.Error!.Code); + + Assert.Equal(before, BodyText(s)); + Assert.DoesNotContain("applied first", BodyText(s)); + Assert.Equal(0, s.UndoCount); + } + + /// The scaffold a thrown step built must not survive the batch either — the DS421 + /// property, one level up. + [Fact] + public void DS438_ThrownStepInABatch_LeavesNoOrphanedScaffold() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + + Assert.False(s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[0], "applied first"), + () => s.InsertFootnote(paragraphs[1], 0, NulPayload), + }).Success); + + using var ms = new MemoryStream(s.Save()); + using var doc = WordprocessingDocument.Open(ms, false); + var main = doc.MainDocumentPart!; + + Assert.Null(main.FootnotesPart); + Assert.Null(main.DocumentSettingsPart?.GetXDocument().Root?.Element(W + "footnotePr")); + Assert.Empty(main.GetXDocument().Descendants(W + "footnoteReference")); + } + + /// A step that throws OUT of the batch — rather than one whose op caught and reported — + /// is contained the same way: the exception does not escape, and the document is restored. + [Fact] + public void DS439_StepThrowingOutOfTheBatch_IsContainedAndReversed() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + var result = s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[0], "applied first"), + () => throw new InvalidOperationException("caller bug"), + }); + + Assert.False(result.Success); + Assert.True(result.RolledBack); + Assert.Equal(EditErrorCode.InternalError, result.Error!.Code); + Assert.Equal("caller bug", result.Error.Message); + Assert.Equal(before, BodyText(s)); + } + + // ─── Shape ─────────────────────────────────────────────────────────── + + /// An empty batch is a success that touches nothing — no snapshot, no undo entry. + [Fact] + public void DS43A_EmptyBatch_IsANoOp() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + + var result = s.Batch(Array.Empty>()); + + Assert.True(result.Success); + Assert.Empty(result.Steps); + Assert.Equal(0, s.UndoCount); + } + + /// The aggregate anchor lists are the union across applied steps, de-duplicated — a + /// host repaints from these, so a paragraph edited twice must appear once. + [Fact] + public void DS43B_Batch_AggregatesAndDedupesTouchedAnchors() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + + var result = s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[0], "once"), + () => s.ReplaceText(paragraphs[0], "twice"), + () => s.InsertParagraph(paragraphs[1], Position.After, "new one"), + }); + + Assert.True(result.Success); + Assert.Single(result.Modified); + Assert.Equal(paragraphs[0], result.Modified[0].Id); + Assert.Single(result.Created); + Assert.Equal(result.Modified.Select(a => a.Id).Distinct().Count(), result.Modified.Count); + } + + /// A batch nested inside a step joins the outer one rather than opening a second + /// snapshot: the whole tree is still one undo step, and the outer batch owns the rollback. + [Fact] + public void DS43C_NestedBatch_JoinsTheOuterOne() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + var result = s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[0], "outer"), + () => s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[1], "inner one"), + () => s.InsertParagraph(paragraphs[1], Position.After, "inner two"), + }).Success + ? new EditResult { Success = true } + : EditResult.Fail(EditErrorCode.InternalError, "nested batch failed"), + }); + + Assert.True(result.Success); + Assert.Equal(1, s.UndoCount); + + var after = BodyText(s); + Assert.Contains("outer", after); + Assert.Contains("inner two", after); + + Assert.True(s.Undo()); + Assert.Equal(before, BodyText(s)); + } + + /// A failure inside a nested batch reverses the whole tree, not just the inner half. + [Fact] + public void DS43D_NestedBatchFailure_ReversesTheOuterBatchToo() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + var result = s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[0], "outer edit"), + () => + { + var inner = s.Batch(new Func[] + { + () => s.ReplaceText(paragraphs[1], "inner edit"), + () => s.InsertFootnote(paragraphs[1], 0, NulPayload), // throws mid-op + }); + return inner.Success + ? new EditResult { Success = true } + : EditResult.Fail(inner.Error!.Code, inner.Error.Message); + }, + }); + + Assert.False(result.Success); + Assert.True(result.RolledBack); + Assert.Equal(before, BodyText(s)); + Assert.DoesNotContain("outer edit", BodyText(s)); + Assert.Equal(0, s.UndoCount); + } + + /// A disposed session reports rather than throws, matching every other op. + [Fact] + public void DS43E_DisposedSession_ReturnsSessionDisposed() + { + var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + s.Dispose(); + + var result = s.Batch(new Func[] { () => new EditResult { Success = true } }); + + Assert.False(result.Success); + Assert.Equal(EditErrorCode.SessionDisposed, result.Error!.Code); + } + + // ─── The explicit scope form ───────────────────────────────────────── + + /// + /// BeginBatch/EndBatch is the form a dispatcher uses when its steps cannot be expressed as + /// -returning delegates — it runs its own switch and reports its own + /// JSON. Committing keeps the edits as one undo step, exactly as does. + /// + [Fact] + public void DS440_BeginEndBatch_CommitsAsOneUndoStep() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + Assert.True(s.BeginBatch()); + Assert.True(s.ReplaceText(paragraphs[0], "scoped one").Success); + Assert.True(s.ReplaceText(paragraphs[1], "scoped two").Success); + var close = s.EndBatch(commit: true); + + Assert.True(close.Success); + Assert.False(close.RolledBack); + Assert.Equal(1, s.UndoCount); + Assert.Contains("scoped two", BodyText(s)); + + Assert.True(s.Undo()); + Assert.Equal(before, BodyText(s)); + } + + /// Closing without committing reverses everything the scope applied. + [Fact] + public void DS441_BeginEndBatch_RollsBackWhenNotCommitted() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + Assert.True(s.BeginBatch()); + Assert.True(s.ReplaceText(paragraphs[0], "discarded").Success); + var close = s.EndBatch(commit: false); + + Assert.False(close.Success); + Assert.True(close.RolledBack); + Assert.Equal(before, BodyText(s)); + Assert.Equal(0, s.UndoCount); + } + + /// + /// A commit is not unconditional: a step that mutated before failing reverses the scope even + /// when the caller asked to keep it. This is the property that stops the scope form from being + /// a way to opt out of the half-applied-mutation guarantee. + /// + [Fact] + public void DS442_BeginEndBatch_CommitIsRefusedWhenAStepMutatedBeforeFailing() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + var before = BodyText(s); + + Assert.True(s.BeginBatch()); + Assert.True(s.ReplaceText(paragraphs[0], "applied").Success); + Assert.False(s.InsertFootnote(paragraphs[1], 0, NulPayload).Success); // throws mid-op + var close = s.EndBatch(commit: true); // asks to keep it anyway + + Assert.False(close.Success); + Assert.True(close.RolledBack); + Assert.Equal(before, BodyText(s)); + Assert.Equal(0, s.UndoCount); + } + + /// An unpaired close reports rather than corrupting the ring — it must never pop an + /// entry that belongs to an ordinary edit. + [Fact] + public void DS443_EndBatch_WithNoBatchOpen_ReportsAndLeavesTheRingAlone() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = BodyParagraphs(s); + + Assert.True(s.ReplaceText(paragraphs[0], "a real edit").Success); + Assert.Equal(1, s.UndoCount); + + var close = s.EndBatch(commit: false); + + Assert.False(close.Success); + Assert.False(close.RolledBack); + Assert.Equal(1, s.UndoCount); + Assert.Contains("a real edit", BodyText(s)); + } + + // ─── The cost this exists to remove ────────────────────────────────── + + /// + /// The reason batching is a feature and not a convenience: a batch takes ONE snapshot, so its + /// retained undo memory is flat in the number of steps, where the loop's grows with it. Asserted + /// as a ratio rather than an absolute so it pins the shape of the cost, not a machine's numbers. + /// + [Fact] + public void DS43F_Batch_RetainedUndoMemoryIsFlatInTheNumberOfSteps() + { + var paragraphTexts = Enumerable.Range(0, 12).Select(i => $"edit {i}").ToArray(); + + long LoopBytes() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = BodyParagraphs(s)[0]; + foreach (var text in paragraphTexts) Assert.True(s.ReplaceText(anchor, text).Success); + return s.UndoMemoryBytes; + } + + long BatchBytes() + { + using var s = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = BodyParagraphs(s)[0]; + var steps = paragraphTexts.Select>( + text => () => s.ReplaceText(anchor, text)).ToArray(); + Assert.True(s.Batch(steps).Success); + return s.UndoMemoryBytes; + } + + var loop = LoopBytes(); + var batch = BatchBytes(); + + // Twelve steps, one snapshot instead of twelve: comfortably under a quarter of the loop's + // retained cost even allowing for per-entry overhead. + Assert.True(batch * 4 < loop, $"batch retained {batch} bytes vs loop {loop} for 12 edits"); + } +} diff --git a/Docxodus.Tests/PuzzleEvalTests.cs b/Docxodus.Tests/PuzzleEvalTests.cs new file mode 100644 index 00000000..b1885568 --- /dev/null +++ b/Docxodus.Tests/PuzzleEvalTests.cs @@ -0,0 +1,333 @@ +#nullable enable + +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using Docxodus; +using Xunit; + +namespace Docxodus.Tests; + +/// +/// The puzzle eval (PE0xx): levels that ask whether the agent editing surface can reach a +/// specified document state, scored by rather than by judgement. +/// +/// What these tests are for. They are not testing — the DS +/// suites do that. They keep the LEVELS honest, which is the only thing a model-facing benchmark +/// needs from CI: that each level's par is actually achievable, that its target is reachable from +/// its start, and that the scoring function cannot be satisfied by doing nothing. A level whose +/// reference solution has quietly stopped solving it is a broken benchmark, and a broken benchmark +/// reports model failures that are really our failures. +/// +/// Why DocxDiff is the scorer. A solve has to be exact and unarguable, and the +/// comparison engine already answers "are these the same document?" in the only vocabulary that +/// matters — zero revisions between the player's result and the target. It also means the benchmark +/// exercises the differentiator the demo surfaces never touch. +/// +public class PuzzleEvalTests +{ + // ─── Level pack ────────────────────────────────────────────────────── + + private sealed record LevelParagraph(string Text, string? Style); + + private sealed record LevelStep( + string Op, + string Find, + string? RelativeTo, + string? Position, + string? Search, + string? Replace); + + private sealed record Level( + string Id, + string Title, + int Par, + string Brief, + IReadOnlyList Start, + IReadOnlyList Target, + IReadOnlyList Reference); + + /// + /// Walks up from the test binary rather than assuming a working directory: the suite runs from + /// bin/Debug/net10.0 under dotnet test and from the repo root under some IDEs. + /// + private static string PuzzlesRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, "eval", "puzzles"); + if (Directory.Exists(candidate)) return candidate; + dir = dir.Parent; + } + + throw new DirectoryNotFoundException("eval/puzzles not found above " + AppContext.BaseDirectory); + } + + public static TheoryData LevelIds() + { + var data = new TheoryData(); + foreach (var dir in Directory.EnumerateDirectories(PuzzlesRoot()).OrderBy(d => d, StringComparer.Ordinal)) + if (File.Exists(Path.Combine(dir, "level.json"))) + data.Add(Path.GetFileName(dir)); + return data; + } + + private static Level LoadLevel(string id) + { + var path = Path.Combine(PuzzlesRoot(), id, "level.json"); + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var root = doc.RootElement; + + static IReadOnlyList Paragraphs(JsonElement side) => + side.GetProperty("paragraphs").EnumerateArray() + .Select(p => new LevelParagraph( + p.GetProperty("text").GetString()!, + p.TryGetProperty("style", out var s) && s.ValueKind == JsonValueKind.String + ? s.GetString() + : null)) + .ToList(); + + static string? Opt(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + return new Level( + root.GetProperty("id").GetString()!, + root.GetProperty("title").GetString()!, + root.GetProperty("par").GetInt32(), + root.GetProperty("brief").GetString()!, + Paragraphs(root.GetProperty("start")), + Paragraphs(root.GetProperty("target")), + root.GetProperty("reference").EnumerateArray() + .Select(s => new LevelStep( + s.GetProperty("op").GetString()!, + s.GetProperty("find").GetString()!, + Opt(s, "relativeTo"), + Opt(s, "position"), + Opt(s, "search"), + Opt(s, "replace"))) + .ToList()); + } + + // ─── Fixture construction ──────────────────────────────────────────── + + /// + /// Both sides of a level are built by this one function, so a scoring difference can only come + /// from the player's edits — never from the two documents having been authored differently. + /// + private static byte[] BuildDocument(IReadOnlyList paragraphs) + { + using var ms = new MemoryStream(); + using (var wDoc = WordprocessingDocument.Create(ms, WordprocessingDocumentType.Document)) + { + var main = wDoc.AddMainDocumentPart(); + main.Document = new Document(); + var body = new Body(); + main.Document.Body = body; + + main.AddNewPart().Styles = DocxSessionTests.BuildHeadingStyles(); + main.AddNewPart().Settings = new Settings(); + + foreach (var p in paragraphs) + { + var paragraph = new Paragraph(new Run(new Text(p.Text) { Space = SpaceProcessingModeValues.Preserve })); + if (p.Style is not null) + paragraph.ParagraphProperties = new ParagraphProperties(new ParagraphStyleId { Val = p.Style }); + body.Append(paragraph); + } + + main.Document.Save(); + } + + return ms.ToArray(); + } + + // ─── Scoring ───────────────────────────────────────────────────────── + + /// + /// The win condition: zero revisions between the player's document and the target. Returning + /// the revision list rather than a bool so a failing assertion can say WHAT still differs, + /// which is the difference between a usable benchmark and a red X. + /// + private static IReadOnlyList ScoreAgainstTarget(byte[] player, byte[] target) => + DocxDiff.GetRevisions( + new WmlDocument("player.docx", player), + new WmlDocument("target.docx", target)); + + private static string Describe(IReadOnlyList revisions) => + revisions.Count == 0 + ? "solved" + : string.Join("; ", revisions.Take(8).Select(r => $"{r.Type}:{Trim(r.Text)}")); + + private static string Trim(string? text) => + text is null ? "" : text.Length <= 40 ? text : text[..40] + "…"; + + // ─── Content-addressed solution runner ─────────────────────────────── + + /// + /// Resolve a block the way a player has to: by what it says, not by an id nobody has yet. The + /// harness deliberately gets no privileged addressing — if a level could only be solved with + /// ids handed out in advance, it would not be measuring the surface an agent actually faces. + /// + private static string FindAnchor(DocxSession session, string needle) + { + // FindAllByText is the same op behind docxodus_search, so the reference solution pays the + // same discovery cost a player does — including the part where a needle has to be chosen + // specifically enough to land on one block. + var hits = session.FindAllByText(needle, null) + .Where(t => t.Anchor.Scope == "body" && t.Anchor.Kind is "p" or "h" or "li") + .ToList(); + + if (hits.Count == 0) throw new InvalidOperationException($"no body block contains '{needle}'"); + return hits[0].Anchor.Id; + } + + /// Applies a level's reference solution and returns the number of mutating calls it + /// took — the value compared against par. + private static int RunReference(DocxSession session, Level level) + { + int calls = 0; + foreach (var step in level.Reference) + { + var anchor = FindAnchor(session, step.Find); + switch (step.Op) + { + case "moveBlock": + { + var relativeTo = FindAnchor(session, step.RelativeTo!); + var position = step.Position == "before" ? Position.Before : Position.After; + var result = session.MoveBlock(anchor, relativeTo, position); + Assert.True(result.Success, $"{level.Id} step {calls}: {result.Error?.Message}"); + break; + } + + case "replaceTextRange": + { + var results = session.ReplaceTextRange(anchor, step.Search!, step.Replace!, null); + Assert.All(results, r => Assert.True(r.Success, $"{level.Id} step {calls}: {r.Error?.Message}")); + Assert.NotEmpty(results); + break; + } + + default: + throw new InvalidOperationException($"unknown reference op '{step.Op}'"); + } + + calls++; + } + + return calls; + } + + // ─── The three properties that keep a level honest ─────────────────── + + /// + /// The level is solvable, and the reference solution solves it. If this fails, the benchmark is + /// reporting a surface limitation as a model failure. + /// + [Theory] + [MemberData(nameof(LevelIds))] + public void PE001_ReferenceSolution_ReachesZeroRevisionsAgainstTheTarget(string levelId) + { + var level = LoadLevel(levelId); + var target = BuildDocument(level.Target); + + using var session = new DocxSession(BuildDocument(level.Start)); + RunReference(session, level); + + var revisions = ScoreAgainstTarget(session.Save(), target); + Assert.True(revisions.Count == 0, $"{level.Id} unsolved: {Describe(revisions)}"); + } + + /// + /// Par is achievable. Par is the score every model run is reported against, so a par nobody can + /// hit is a benchmark that reports everyone as below average. + /// + [Theory] + [MemberData(nameof(LevelIds))] + public void PE002_ReferenceSolution_SolvesWithinPar(string levelId) + { + var level = LoadLevel(levelId); + using var session = new DocxSession(BuildDocument(level.Start)); + + var calls = RunReference(session, level); + + Assert.True(calls <= level.Par, $"{level.Id} reference took {calls} calls against par {level.Par}"); + } + + /// + /// The starting document does NOT already score as solved. Without this, a level whose target + /// was built wrong — or a scorer that silently returns nothing — passes PE001 with an empty + /// solution, and the whole pack reports 100%. + /// + [Theory] + [MemberData(nameof(LevelIds))] + public void PE003_StartDocument_DoesNotAlreadyScoreAsSolved(string levelId) + { + var level = LoadLevel(levelId); + + var revisions = ScoreAgainstTarget(BuildDocument(level.Start), BuildDocument(level.Target)); + + Assert.True(revisions.Count > 0, $"{level.Id} start already equals target — the level asks for nothing"); + } + + /// + /// A near-miss must score as unsolved. The scorer is the whole benchmark, so "does it actually + /// reject a wrong answer" deserves a test of its own rather than being assumed from PE003 — + /// here, the clauses reordered correctly but the defined term left unconformed. + /// + [Fact] + public void PE004_Scorer_RejectsAPartialSolve() + { + var level = LoadLevel("L01-clause-order"); + var target = BuildDocument(level.Target); + + using var session = new DocxSession(BuildDocument(level.Start)); + foreach (var step in level.Reference.Where(s => s.Op == "moveBlock")) + { + var anchor = FindAnchor(session, step.Find); + var relativeTo = FindAnchor(session, step.RelativeTo!); + Assert.True(session + .MoveBlock(anchor, relativeTo, step.Position == "before" ? Position.Before : Position.After) + .Success); + } + + var revisions = ScoreAgainstTarget(session.Save(), target); + Assert.True(revisions.Count > 0, "the scorer accepted a document that still says \"Acme\""); + } + + /// + /// The whole reference solution as ONE batch: same document, same score, one undo step. A level + /// is a plan, and a plan is the case batching exists for — so the pack asserts the property + /// rather than leaving it to the DS suites in the abstract. + /// + [Fact] + public void PE005_ReferenceSolution_AppliedAsOneBatch_ScoresTheSameAndUndoesOnce() + { + var level = LoadLevel("L01-clause-order"); + var target = BuildDocument(level.Target); + var start = BuildDocument(level.Start); + + using var session = new DocxSession(start); + var batch = session.Batch(new[] { (Func)(() => + { + RunReference(session, level); + return new EditResult { Success = true }; + }) }); + + Assert.True(batch.Success); + Assert.Empty(ScoreAgainstTarget(session.Save(), target)); + + Assert.Equal(1, session.UndoCount); + Assert.True(session.Undo()); + Assert.Empty(ScoreAgainstTarget(session.Save(), start)); + } +} diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index 6a618795..37b132ce 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -1279,6 +1279,70 @@ internal static EditResult Fail(EditErrorCode code, string message, string? anch new() { Success = false, Error = new EditError(code, message, anchorId) }; } +/// +/// How a treats a step that fails. +/// +public sealed record BatchOptions +{ + /// + /// Reverse every step when any step fails, leaving the document exactly as the batch found + /// it (default). Set false for best-effort application, where clean failures are recorded + /// and the sequence continues. + /// + /// + /// Best-effort is NOT "no rollback ever". A step that failed with + /// threw partway and may have committed part of + /// its work, and a step rejected by mutated + /// before the validator saw it; neither leaves a state worth keeping, so the batch reverses + /// regardless of this flag. What Atomic = false buys is tolerance of the failures + /// that provably did not touch the document — an anchor that no longer exists, a wrong-kind + /// target, malformed markdown. + /// + public bool Atomic { get; init; } = true; + + /// + /// Stop at the first failing step rather than attempting the rest (default). Ignored when + /// is true, where a failure ends the batch by definition. + /// + public bool StopOnError { get; init; } = true; +} + +/// +/// Aggregate outcome of : one entry per step attempted, plus the +/// union of what the surviving steps touched. +/// +/// +/// // are the union across +/// applied steps, de-duplicated by anchor id and empty when the batch rolled back. They are a +/// summary for repaint, not a replay log: an anchor a step created and a later step deleted +/// appears in both lists, because both things happened. +/// +public sealed record BatchResult +{ + /// True when every attempted step succeeded and nothing was reversed. + public bool Success { get; init; } + + /// One result per step attempted, in order. Shorter than the input when the batch + /// stopped early. + public IReadOnlyList Steps { get; init; } = Array.Empty(); + + /// Steps that succeeded and are still applied. Zero when the batch rolled back. + public int Applied { get; init; } + + /// True when the document was restored to its pre-batch state. + public bool RolledBack { get; init; } + + /// The first failing step's error, or null on success. + public EditError? Error { get; init; } + + /// Zero-based index of the first failing step, or -1 on success. + public int FailedStep { get; init; } = -1; + + public IReadOnlyList Created { get; init; } = Array.Empty(); + public IReadOnlyList Removed { get; init; } = Array.Empty(); + public IReadOnlyList Modified { get; init; } = Array.Empty(); +} + /// /// Partial-update payload for . /// Null fields leave the existing value unchanged. @@ -1384,6 +1448,14 @@ public sealed class DocxSession : IDisposable private long _lastFormatRevisionTicks; private RawDocxOps? _raw; + // Batch state (see Batch). Depth, not a bool, so a Batch nested inside a step of another + // Batch joins the outer one rather than opening a second snapshot. _batchDamaged records + // that some step mutated before failing, which is what forces a rollback even in the + // non-atomic mode — a half-applied step is never a safe thing to keep. + private int _batchDepth; + private bool _batchDamaged; + private BatchOptions? _batchOptions; + // Mutable session configuration (issue #304): seeded from _settings at construction, // switchable mid-session via SetTrackedChanges/SetRevisionAuthor. Session config, not // document state — never captured in undo snapshots. @@ -2255,7 +2327,7 @@ private EditResult ResolveRevision(string revisionId, bool accept) // detach during Apply and can no longer be resolved to a part afterwards. var modified = RevisionGroupAnchors(group, partUri); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var removedElements = Internal.RevisionOps.Apply(group, accept); @@ -3325,7 +3397,7 @@ private IReadOnlyList ReplaceTextRangeCore( if (element is null) return new[] { EditResult.Fail(EditErrorCode.AnchorNotFound, "element resolved null", anchorId) }; - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var tracked = _trackedChanges == TrackedChangeMode.RenderInline; @@ -3455,7 +3527,7 @@ public EditResult ReplaceTextAtSpan(string anchorId, int spanStart, int spanLeng ContextAfter = string.Empty, }; - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { if (_trackedChanges == TrackedChangeMode.RenderInline) @@ -4790,7 +4862,7 @@ public EditResult ReplaceText(string anchorId, string markdownPayload) if (!parsed.Success) return EditResult.Fail(parsed.Error!.Code, parsed.Error.Message, anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { if (_trackedChanges == TrackedChangeMode.RenderInline) @@ -4842,7 +4914,7 @@ public EditResult DeleteBlock(string anchorId) $"cannot delete a Word-reserved {target.Anchor.Kind} of type='{(string?)element.Attribute(W.type)}'", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { // Tracked-change mode wraps removed runs in w:del — only meaningful for @@ -5059,7 +5131,7 @@ private EditResult DeleteSiblingRangeCore( anchorForPatchScope.Anchor.Id); } - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var index = AnchorIndex(); @@ -5281,7 +5353,7 @@ public EditResult MoveBlock(string sourceAnchorId, string targetAnchorId, Positi if (BlockMoveSafetyError(BuildBlockMoveContext(parent), source, target, pos) is { } safetyError) return EditResult.Fail(EditErrorCode.InvalidPosition, safetyError, sourceAnchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { if (_trackedChanges != TrackedChangeMode.RenderInline) @@ -5792,7 +5864,7 @@ public EditResult InsertParagraph(string anchorId, Position pos, string markdown if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element resolved null", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var created = new List(); @@ -5859,7 +5931,7 @@ public EditResult SplitParagraph(string anchorId, int characterOffset) return EditResult.Fail(EditErrorCode.OffsetOutOfRange, $"offset {characterOffset} out of [0, {totalText.Length}]", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var pPr = element.Element(W.pPr); @@ -5986,7 +6058,7 @@ public EditResult MergeParagraphs(string firstAnchorId, string secondAnchorId) return EditResult.Fail(EditErrorCode.AnchorsNotAdjacent, "MergeParagraphs requires second anchor to be the immediate next sibling of first"); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { // Insert a single-space separator if both sides end/start with non-whitespace. @@ -6107,7 +6179,7 @@ internal EditResult RawInsertXmlInternal(string anchorId, Position pos, string x if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element null", anchorId); int baselineErrors = _settings.ValidateRawOps ? CountRealValidationErrors() : 0; - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { UnidHelper.AssignToSelfAndDescendants(parsedXml); @@ -6116,8 +6188,10 @@ internal EditResult RawInsertXmlInternal(string anchorId, Position pos, string x if (_settings.ValidateRawOps && CountRealValidationErrors() > baselineErrors) { - var preOp = _history.PopForUndo(); - if (preOp.ok) RestoreSnapshot(preOp.snapshot); + // A MUTATING clean failure: the element was already swapped in before the + // validator ran, so this path has to reverse its own damage — the same + // obligation a throw has, hence the same helper. + RollbackFailedOp(); return EditResult.Fail(EditErrorCode.ValidationFailed, "OpenXmlValidator found new errors", anchorId); } @@ -6160,7 +6234,7 @@ internal EditResult RawReplaceXmlInternal(string anchorId, string xml) if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element null", anchorId); int baselineErrors = _settings.ValidateRawOps ? CountRealValidationErrors() : 0; - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { UnidHelper.AssignToSelfAndDescendants(parsedXml); @@ -6168,8 +6242,10 @@ internal EditResult RawReplaceXmlInternal(string anchorId, string xml) if (_settings.ValidateRawOps && CountRealValidationErrors() > baselineErrors) { - var preOp = _history.PopForUndo(); - if (preOp.ok) RestoreSnapshot(preOp.snapshot); + // A MUTATING clean failure: the element was already swapped in before the + // validator ran, so this path has to reverse its own damage — the same + // obligation a throw has, hence the same helper. + RollbackFailedOp(); return EditResult.Fail(EditErrorCode.ValidationFailed, "OpenXmlValidator found new errors", anchorId); } @@ -6277,7 +6353,7 @@ public EditResult ReplaceCellContent(string cellAnchorId, string markdownPayload if (!parsed.Success) return EditResult.Fail(parsed.Error!.Code, parsed.Error.Message, cellAnchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { foreach (var p in cell!.Elements(W.p).ToList()) p.Remove(); @@ -6380,7 +6456,7 @@ public EditResult ApplyFormat(string anchorId, CharSpan? span, FormatOp op) return EditResult.Fail(EditErrorCode.OffsetOutOfRange, $"span [{actualSpan.Start},{actualSpan.Start + actualSpan.Length}) out of [0,{totalText.Length})", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { // Inline code references a "Code" character style by id; ensure it actually @@ -6466,7 +6542,7 @@ public EditResult SetParagraphStyle(string anchorId, string styleId) var element = target.Resolve(_doc); if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element null", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var pPr = element.Element(W.pPr); @@ -6602,7 +6678,7 @@ public EditResult SetParagraphFormat(string anchorId, ParagraphFormatOp op) return EditResult.Fail(EditErrorCode.InvalidParagraphFormat, "lineSpacingRule requires lineSpacing (w:lineRule qualifies w:line)", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var pPr = element.Element(W.pPr); @@ -6739,7 +6815,7 @@ public EditResult InsertHorizontalRule(string anchorId, Position pos, ParagraphB if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element resolved null", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var edge = rule ?? new ParagraphBorderEdge { Style = "single", Size = 12, Color = "auto" }; @@ -6863,7 +6939,7 @@ public EditResult EnsureHeaderFooterVisible(string anchorId, HeaderFooterKind ki if (alreadySet) return new EditResult { Success = true, Modified = new[] { target.Anchor } }; - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { if (kind == HeaderFooterKind.First) InsertSectPrTitlePg(sectPr); @@ -6912,7 +6988,7 @@ private EditResult SetHeaderFooterText(bool isHeader, string anchorId, HeaderFoo if (paras.Count == 0) paras.Add(new XElement(W.p)); ApplyHeaderFooterStyle(paras, isHeader); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var sectPr = Internal.BlockMetadataOps.FindGoverningSectPr(element); @@ -7028,7 +7104,7 @@ public EditResult InsertPageNumberField( return EditResult.Fail(EditErrorCode.AnchorWrongKind, "InsertPageNumberField requires a paragraph anchor", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { foreach (var r in BuildPageNumberFieldRuns(field, format)) @@ -7211,7 +7287,7 @@ private EditResult EditSectionPageNumbering(string anchorId, string opName, Func if (!mutate(sectPr is null ? new XElement(W.sectPr) : new XElement(sectPr))) return succeeded; - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { if (sectPr is null) @@ -7318,7 +7394,7 @@ private EditResult InsertNote(bool isFootnote, string anchorId, int characterOff } if (paras.Count == 0) paras.Add(new XElement(W.p)); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var part = EnsureNotePart(main, isFootnote); @@ -7746,7 +7822,7 @@ private EditResult AddCommentCore( } if (paras.Count == 0) paras.Add(new XElement(W.p)); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var part = Internal.CommentOps.EnsureCommentsPart(main); @@ -7871,7 +7947,7 @@ public EditResult AddCommentReply( } if (paras.Count == 0) paras.Add(new XElement(W.p)); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { Internal.StyleFactory.EnsureCommentStyles(_doc!); @@ -7969,7 +8045,7 @@ public EditResult SetCommentResolved(string commentAnchorId, bool resolved) if (main?.WordprocessingCommentsPart is null) return EditResult.Fail(EditErrorCode.InternalError, "no comments part", commentAnchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var rootParaId = Internal.CommentOps.EnsureThreadingMetadata(main, comment, resolved: resolved); @@ -8065,7 +8141,7 @@ public EditResult UpdateComment(string commentAnchorId, string markdownPayload) } if (paras.Count == 0) paras.Add(new XElement(W.p)); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { Internal.StyleFactory.EnsureCommentStyles(_doc!); @@ -8165,7 +8241,7 @@ public EditResult InsertTable(string anchorId, Position pos, int rows, int cols, return EditResult.Fail(EditErrorCode.MalformedMarkdown, $"ColumnWidths must have one positive width per column ({cols}); got {colWidths.Count}", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { const int contentTwips = 9576; // ~6.65", a US-Letter content width @@ -8497,7 +8573,7 @@ public EditResult InsertTableRow(string cellAnchorId, Position pos) return err; var before = CaptureTableMetadata(tbl!); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { // A vertical merge crosses the insertion boundary exactly when the row on the far @@ -8559,7 +8635,7 @@ public EditResult InsertTableColumn(string cellAnchorId, Position pos) int boundary = pos == Position.Before ? anchorCell.Start : anchorCell.End; var before = CaptureTableMetadata(tbl!); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { EnsureGridColumnsForMutation(tbl!); @@ -8641,7 +8717,7 @@ public EditResult DeleteTableRow(string cellAnchorId) return err; var before = CaptureTableMetadata(tbl!); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { if (tbl!.Elements(W.tr).Count() <= 1) tbl.Remove(); @@ -8684,7 +8760,7 @@ public EditResult DeleteTableColumn(string cellAnchorId) int doomed = RowGrid(tr!).First(g => g.Tc == tc).Start; var before = CaptureTableMetadata(tbl!); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { EnsureGridColumnsForMutation(tbl!); @@ -8842,7 +8918,7 @@ public EditResult MergeCells(string cellAnchorId, int rowSpan, int colSpan, cellAnchorId); var before = CaptureTableMetadata(tbl); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { // Content first: everything the merge absorbs MOVES into the surviving cell. Detach @@ -8922,7 +8998,7 @@ public EditResult UnmergeCells(string cellAnchorId) } var before = CaptureTableMetadata(tbl); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var widths = GridColWidths(tbl); @@ -9063,7 +9139,7 @@ public EditResult SetColumnWidths(string cellAnchorId, IReadOnlyList widths $"widths must list one positive twip value per column ({colCount}); got {widthsTwips?.Count ?? 0}", cellAnchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { if (grid is null) @@ -9130,7 +9206,7 @@ public EditResult SetTableBorders(string cellAnchorId, TableBorderSpec? spec = n return EditResult.Fail(EditErrorCode.InvalidTableStyling, "border size (eighths of a point) must be >= 0", cellAnchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var tblPr = GetOrCreateTblPr(tbl!); @@ -9200,7 +9276,7 @@ public EditResult SetCellShading(string cellAnchorId, string? fillColor, else fill = "auto"; } - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var cells = scope == TableShadingScope.Row ? tr!.Elements(W.tc).ToList() : new List { tc! }; @@ -9251,7 +9327,7 @@ public EditResult SetTableRowOptions(string cellAnchorId, TableRowOptions? optio return EditResult.Fail(EditErrorCode.InvalidTableStyling, "row height in twips must be >= 0", cellAnchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var trPr = tr!.Element(W.trPr); @@ -9356,7 +9432,7 @@ public EditResult SetListLevel(string anchorId, int levelDelta) return EditResult.Fail(EditErrorCode.InvalidListLevel, $"resulting list level {next} out of [0,8]", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); // Nesting only renders if the abstractNum actually DEFINES the target level — many docs // define just level 0, so synthesize any missing levels before bumping ilvl. if (effectiveNumId.HasValue) @@ -9438,7 +9514,7 @@ public EditResult RemoveListMembership(string anchorId) // whose Heading styles carry legal-outline numbering. bool needsStyleOverride = ResolveStyleNumbering(element).numId is not null; - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); directNumPr?.Remove(); if (needsStyleOverride) { @@ -9475,7 +9551,7 @@ public EditResult ApplyListFormat(string anchorId, ListFormat kind) var element = target.Resolve(_doc!); if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element null", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var pPr = element.Element(W.pPr); @@ -9568,7 +9644,7 @@ public EditResult ApplyListFormatRange(string firstAnchorId, string lastAnchorId var memberUnids = members.Select(m => (string?)m.Attribute(PtOpenXml.Unid)).ToList(); var partUri = firstTarget.PartUri; - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { if (kind == ListFormat.None) @@ -9673,13 +9749,13 @@ private EditResult ApplyListStartOverride(string anchorId, int? value) if (value is null && Internal.NumberingFactory.GetStartOverride(_doc!, numId.Value, ilvl) is null) return new EditResult { Success = true, Modified = new[] { target.Anchor } }; - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var newNumId = Internal.NumberingFactory.CloneNumWithStartOverride(_doc!, numId.Value, ilvl, value); if (newNumId is null) { - _ = _history.PopForUndo(); + DiscardPreOpSnapshot(); return EditResult.Fail(EditErrorCode.AnchorWrongKind, $"numbering instance {numId} is not defined in the numbering part", anchorId); } @@ -9797,12 +9873,12 @@ public EditResult AddAnnotation(string anchorId, CharSpan? span, DocumentAnnotat if (anchor is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, $"anchor not found: {anchorId}", anchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var result = Internal.AnnotationOps.Add(_doc!, anchor, span, annotation); if (result.Success) InvalidateProjectionCache(); - else _ = _history.PopForUndo(); + else DiscardPreOpSnapshot(); return result; } catch (Exception ex) @@ -9817,12 +9893,12 @@ public EditResult AddAnnotation(string anchorId, CharSpan? span, DocumentAnnotat public EditResult RemoveAnnotation(string annotationId) { if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var result = Internal.AnnotationOps.Remove(_doc!, annotationId, CanonicalizeAnchorByUnid); if (result.Success) InvalidateProjectionCache(); - else _ = _history.PopForUndo(); + else DiscardPreOpSnapshot(); return result; } catch (Exception ex) @@ -9840,11 +9916,11 @@ public EditResult UpdateAnnotation(string annotationId, AnnotationUpdate update) if (update is null) return EditResult.Fail(EditErrorCode.MalformedMarkdown, "update is null"); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var result = Internal.AnnotationOps.Update(_doc!, annotationId, update); - if (!result.Success) _ = _history.PopForUndo(); + if (!result.Success) DiscardPreOpSnapshot(); return result; } catch (Exception ex) @@ -9864,13 +9940,13 @@ public EditResult MoveAnnotation(string annotationId, string newAnchorId, CharSp return EditResult.Fail(EditErrorCode.AnchorNotFound, $"anchor not found: {newAnchorId}", newAnchorId); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); try { var result = Internal.AnnotationOps.Move( _doc!, annotationId, anchor, newSpan, CanonicalizeAnchorByUnid); if (result.Success) InvalidateProjectionCache(); - else _ = _history.PopForUndo(); + else DiscardPreOpSnapshot(); return result; } catch (Exception ex) @@ -9917,7 +9993,7 @@ public EditResult MoveAnnotation(string annotationId, string newAnchorId, CharSp public CompactResult CompactRuns(ProjectionScopes scopes = ProjectionScopes.All) { ThrowIfDisposed(); - _history.RecordPreOp(TakeSnapshot()); + RecordPreOpSnapshot(); int removed = 0; foreach (var part in EnumerateProjectedPartsForScopes(scopes)) @@ -9936,7 +10012,7 @@ public CompactResult CompactRuns(ProjectionScopes scopes = ProjectionScopes.All) part.PutXDocument(); } if (removed > 0) InvalidateProjectionCache(); - else _ = _history.PopForUndo(); + else DiscardPreOpSnapshot(); return new CompactResult { RunsRemoved = removed }; } @@ -9968,8 +10044,290 @@ private IEnumerable EnumerateProjectedPartsForScopes(ProjectionScop yield return main.WordprocessingCommentsPart; } + // ─── Batch ─────────────────────────────────────────────────────────── + + /// + /// Open a batch: subsequent mutations share ONE pre-op snapshot and collapse into ONE undo + /// step, until the matching . + /// + /// + /// Why batching exists. Every mutation records a pre-op snapshot, and a snapshot + /// deep-clones every projected part — so its cost scales with the DOCUMENT, not with the edit. + /// A caller applying forty edits to a long filing therefore paid forty whole-document clones + /// and consumed forty entries of a twenty-deep ring, leaving the sequence it had just applied + /// only partially reversible. Both are properties of the loop, not of the work: N edits that + /// form one intent deserve one snapshot and one undo step. Agent callers hit this hardest, + /// because a plan is naturally a list of edits. + /// + /// Pair it, or lose the rollback. An open batch holds the only snapshot that can + /// reverse its steps. Prefer , which pairs them for you; reach for the + /// explicit form only when the steps cannot be expressed as -returning + /// delegates — a JSON dispatcher running its own switch, for instance. + /// + /// Nesting joins the outer batch rather than opening a second snapshot, so the whole tree + /// remains one undo step and the OUTERMOST owns the outcome. + /// + /// Failure policy, read only when this call opens the outermost batch. + /// False when the session is disposed; true once a batch is open. + public bool BeginBatch(BatchOptions? options = null) + { + if (_disposed) return false; + if (_batchDepth == 0) + { + _batchOptions = options ?? new BatchOptions(); + _batchDamaged = false; + RecordPreOpSnapshot(); + } + + _batchDepth++; + return true; + } + + /// + /// Close the batch opened by , keeping its edits or reversing them. + /// + /// True to keep the batch's edits as one undo step; false to reverse them + /// and leave the document as the batch found it. + /// + /// A commit is not unconditional. If any step reported that it mutated before failing — + /// it threw partway, or the validator rejected what it had already written — the batch reverses + /// regardless, because no per-step snapshot survives to unpick that step alone. This is what + /// stops batching from quietly reintroducing the half-applied mutations that per-op rollback + /// exists to prevent. + /// + /// On a nested call this only decrements: the outermost performs + /// the commit or the rollback for the whole tree. + /// + public BatchResult EndBatch(bool commit = true) + { + if (_disposed) + return new BatchResult { Error = new EditError(EditErrorCode.SessionDisposed, "session disposed") }; + if (_batchDepth == 0) + { + return new BatchResult + { + Error = new EditError(EditErrorCode.InternalError, "EndBatch called with no batch open"), + }; + } + + _batchDepth--; + + // A nested close hands the decision up: damage stays recorded on the session, so the + // outermost EndBatch still sees it even though this level reported "done". + if (_batchDepth > 0) return new BatchResult { Success = commit }; + + bool damaged = _batchDamaged; + _batchDamaged = false; + var opts = _batchOptions ?? new BatchOptions(); + _batchOptions = null; + + if (!commit || damaged) + { + RollbackFailedOp(); + InvalidateProjectionCache(); + return new BatchResult + { + Success = false, + RolledBack = true, + Error = damaged + ? new EditError( + EditErrorCode.InternalError, + "a batch step mutated before failing; the batch was reversed") + : new EditError(EditErrorCode.InternalError, "batch reversed by the caller"), + }; + } + + InvalidateProjectionCache(); + _ = opts; + return new BatchResult { Success = true }; + } + + /// + /// Apply a sequence of mutations as ONE logical operation: one pre-op snapshot, one undo step, + /// and — by default — all-or-nothing application. + /// + /// The mutations, invoked in order. Write them as closures over this + /// session (() => session.ReplaceText(anchor, "…")). A step may open its own + /// , which joins this one. + /// Failure policy. Defaults to atomic, stop-on-error. + /// + /// Undo grain. A batch is one . That is the point, and also the + /// trade: individual steps inside a completed batch are not separately reversible. Group what + /// the user would think of as a single action. + /// + /// Failure. Under the default the first failure + /// reverses everything, so a failed batch never leaves a partial document. Under best-effort, + /// clean failures are recorded and skipped — but a step that threw or was rejected by the + /// validator still reverses the whole batch. See . + /// + /// Repaint. The editor cannot observe session mutations, so a host driving this + /// directly still calls its own refresh when the batch returns — once for the batch, not once + /// per step, which is the second reason batching is worth having. + /// + public BatchResult Batch(IEnumerable> steps, BatchOptions? options = null) + { + if (_disposed) + return new BatchResult { Error = new EditError(EditErrorCode.SessionDisposed, "session disposed") }; + ArgumentNullException.ThrowIfNull(steps); + var opts = options ?? new BatchOptions(); + var stepList = steps.ToList(); + if (stepList.Count == 0) return new BatchResult { Success = true }; + + // Hold the mutation gate for the whole sequence, not per step. Individual ops take it + // themselves (it is reentrant on this thread), but a batch that released it between steps + // could interleave with a concurrent mutation and then reverse that mutation's work as part + // of its own rollback. The explicit BeginBatch/EndBatch form CANNOT offer this — its two + // halves are separate calls, and holding a lock across a JSON-RPC round trip is a deadlock + // waiting for a caller that never closes its batch. + lock (_mutationGate) + return BatchCore(stepList, opts); + } + + private BatchResult BatchCore(List> stepList, BatchOptions opts) + { + if (!BeginBatch(opts)) + return new BatchResult { Error = new EditError(EditErrorCode.SessionDisposed, "session disposed") }; + + var results = new List(stepList.Count); + var created = new List(); + var removed = new List(); + var modified = new List(); + EditError? firstError = null; + int failedStep = -1; + int applied = 0; + bool damaged = false; + + for (int i = 0; i < stepList.Count; i++) + { + var step = stepList[i]; + EditResult stepResult; + if (step is null) + { + stepResult = EditResult.Fail(EditErrorCode.MalformedMarkdown, $"batch step {i} is null"); + } + else + { + try + { + stepResult = step(); + } + catch (Exception ex) + { + // A step that threw OUT of the op, rather than one whose op caught and reported. + // The op's own catch never ran, so nothing marked the batch damaged — do it here, + // because a throw is exactly where partial work is most likely. + LastInternalError = ex; + _batchDamaged = true; + stepResult = EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + // Ops report damage through _batchDamaged (set by RollbackFailedOp, which cannot pop + // inside a batch). Read it per step so it attributes to the step that raised it. + if (_batchDamaged) damaged = true; + + results.Add(stepResult); + + if (stepResult.Success) + { + applied++; + created.AddRange(stepResult.Created); + removed.AddRange(stepResult.Removed); + modified.AddRange(stepResult.Modified); + continue; + } + + if (firstError is null) + { + firstError = stepResult.Error; + failedStep = i; + } + + // ValidationFailed mutated before it was rejected; InternalError may have. Either way + // the document is not in a state worth keeping, whatever the policy says. + if (stepResult.Error?.Code is EditErrorCode.InternalError or EditErrorCode.ValidationFailed) + damaged = true; + + if (opts.Atomic || opts.StopOnError || damaged) break; + } + + bool commit = firstError is null || (!opts.Atomic && !damaged); + var close = EndBatch(commit); + + if (close.RolledBack) + { + return new BatchResult + { + Success = false, + Steps = results, + Applied = 0, + RolledBack = true, + Error = firstError ?? close.Error, + FailedStep = failedStep, + }; + } + + return new BatchResult + { + Success = firstError is null, + Steps = results, + Applied = applied, + RolledBack = false, + Error = firstError, + FailedStep = failedStep, + Created = DedupeAnchors(created), + Removed = DedupeAnchors(removed), + Modified = DedupeAnchors(modified), + }; + } + + /// Union of the anchors a batch's steps touched, first occurrence wins — a summary for + /// repaint, not a replay log. + private static IReadOnlyList DedupeAnchors(List anchors) + { + if (anchors.Count == 0) return Array.Empty(); + var seen = new HashSet(StringComparer.Ordinal); + var deduped = new List(anchors.Count); + foreach (var anchor in anchors) + if (seen.Add(anchor.Id)) + deduped.Add(anchor); + return deduped; + } // ─── Undo / Redo ───────────────────────────────────────────────────── + /// + /// Capture the pre-op snapshot every mutation records before entering its try — + /// unless a is open, in which case the batch already recorded one for + /// the whole sequence. + /// + /// + /// The suppression is where a batch's cost saving actually lives, which is why it belongs + /// here rather than inside : a snapshot deep-clones every + /// projected part, and TakeSnapshot() is evaluated at the CALL SITE before the ring + /// ever sees it. Dropping the entry inside the ring would still pay for the clone; not + /// calling it is the only way to not pay. + /// + private void RecordPreOpSnapshot() + { + if (_batchDepth > 0) return; + _history.RecordPreOp(TakeSnapshot()); + } + + /// + /// Discard the spare pre-op snapshot of a CLEAN failure — an op that detected a problem and + /// returned without mutating, whose snapshot must not evict a real edit from the bounded ring. + /// + /// + /// A no-op inside a batch: there is no per-step entry to discard, and popping would consume + /// the batch's own snapshot — the one thing standing between a later failure and an + /// unreversible half-applied sequence. + /// + private void DiscardPreOpSnapshot() + { + if (_batchDepth > 0) return; + _ = _history.PopForUndo(); + } + /// /// Roll the document back to the pre-op snapshot after a mutation threw partway through. /// @@ -9985,10 +10343,15 @@ private IEnumerable EnumerateProjectedPartsForScopes(ProjectionScop /// /// Safe to call even when nothing was mutated before the throw — the restore just /// re-installs identical XML — so error paths need not reason about how far the op got. - /// Distinct from the CLEAN-failure paths (else _ = _history.PopForUndo()), which + /// Distinct from the CLEAN-failure paths (), which /// discard deliberately: those ops detected a problem and returned WITHOUT mutating, so their /// snapshot is genuinely spare and must not evict a real edit from the bounded ring. /// + /// Inside a there is no per-step snapshot to restore, so this + /// records that the sequence is damaged and returns. The batch reverses the whole thing from + /// its own snapshot — one restore instead of two, and the only correct grain anyway once a + /// step has already committed edits the batch was meant to apply atomically. + /// /// A failure of the restore itself is swallowed into /// rather than thrown: the caller is already receiving an /// for the original fault, and replacing that with a secondary one would hide the real cause. @@ -9997,6 +10360,12 @@ private IEnumerable EnumerateProjectedPartsForScopes(ProjectionScop /// private void RollbackFailedOp() { + if (_batchDepth > 0) + { + _batchDamaged = true; + return; + } + var (preOp, ok) = _history.PopForUndo(); if (!ok) return; try diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs index 89ca521b..c9dd0320 100644 --- a/Docxodus/Internal/DocxSessionJson.cs +++ b/Docxodus/Internal/DocxSessionJson.cs @@ -917,6 +917,36 @@ public static string SerializeEditResults(IReadOnlyList results) return sb.ToString(); } + /// + /// Wire shape for . rolledBack is the field a caller must read + /// before trusting anything else: it is the difference between "your steps failed" and "your + /// steps failed AND the ones that had already succeeded were reversed". + /// + public static string SerializeBatchResult(BatchResult r) + { + var sb = new StringBuilder(256); + sb.Append("{\"success\":").Append(r.Success ? "true" : "false"); + sb.Append(",\"applied\":").Append(r.Applied); + sb.Append(",\"rolledBack\":").Append(r.RolledBack ? "true" : "false"); + sb.Append(",\"failedStep\":").Append(r.FailedStep); + if (r.Error is not null) + { + sb.Append(",\"error\":{") + .Append("\"code\":\"").Append(EnumToSnake(r.Error.Code)).Append('"') + .Append(",\"message\":").Append(JsonString(r.Error.Message)); + if (r.Error.AnchorId is not null) + sb.Append(",\"anchorId\":").Append(JsonString(r.Error.AnchorId)); + sb.Append('}'); + } + + sb.Append(",\"created\":"); AppendAnchorArray(sb, r.Created); + sb.Append(",\"removed\":"); AppendAnchorArray(sb, r.Removed); + sb.Append(",\"modified\":"); AppendAnchorArray(sb, r.Modified); + sb.Append(",\"steps\":").Append(SerializeEditResults(r.Steps)); + sb.Append('}'); + return sb.ToString(); + } + public static void AppendAnchorArray(StringBuilder sb, IReadOnlyList anchors) { sb.Append('['); diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs index 328298e6..0e0db3b3 100644 --- a/Docxodus/Internal/DocxSessionOps.cs +++ b/Docxodus/Internal/DocxSessionOps.cs @@ -639,6 +639,26 @@ public static string RejectRevision(int handle, string revisionId, MutationPreconditions? preconditions = null) => Mutate(handle, preconditions, null, s => s.RejectRevision(revisionId)); + // ─── Batch ────────────────────────────────────────────────────────── + + /// + /// Open a batch on the session: every mutation until shares one pre-op + /// snapshot and collapses into one undo step. + /// + /// + /// The explicit begin/end pair rather than a delegate, because the transports that need this + /// most — the MCP dispatcher, the stdio host — run their steps through their own JSON switch + /// and cannot hand the session a list of Func<EditResult>. Callers who CAN should + /// prefer DocxSession.Batch, which pairs them and cannot leak an open batch. + /// + public static bool BeginBatch(int handle, bool atomic = true, bool stopOnError = true) => + SessionRegistry.Get(handle).BeginBatch( + new BatchOptions { Atomic = atomic, StopOnError = stopOnError }); + + /// Close the batch, keeping its edits as one undo step or reversing them. + public static string EndBatch(int handle, bool commit) => + DocxSessionJson.SerializeBatchResult(SessionRegistry.Get(handle).EndBatch(commit)); + // ─── Undo / Redo ──────────────────────────────────────────────────── public static bool Undo(int handle) => SessionRegistry.Get(handle).Undo(); diff --git a/docs/demo/arcade.html b/docs/demo/arcade.html index 986444f7..1ed8ddff 100644 --- a/docs/demo/arcade.html +++ b/docs/demo/arcade.html @@ -6,7 +6,7 @@ Docxodus — THE DOCX ARCADE: a video game inside a Word document - + @@ -16,7 +16,7 @@ - +