Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ 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.
- **First-class hyperlinks and bookmarks across every editing surface (#448/#451/#469/#470).**
`DocxSession` can enumerate and mutate external or bookmark-target hyperlinks and paired,
multi-paragraph bookmarks with exact character spans. External relationships are owned and
reused by the containing body/header/footer/footnote/endnote part; internal links use
`w:anchor` without a package relationship. Rename retargets inbound links atomically, removal
refuses live targets, malformed/cross-part ranges return structured errors, destructive edits
cannot orphan markers, and undo/redo restores relationship topology. The same contract is
exposed through JSON ops, WASM/npm, stdio/Python, and MCP (`docxodus_links`); Markdown links now
use the same owner-aware promotion and orphan cleanup. Coverage includes Open XML validation,
save/reopen identity, exact run-format boundaries, repeated story-scoped bookmark ids, tracked
limitations, and relationship cleanup. This supersedes the earlier tracked-move clone policy:
a tracked block move containing bookmark markers now fails before snapshot instead of creating
two simultaneously-live copies of a globally unique bookmark name.
- **Complete inspect-before-edit formatting surface (#448).** `DocxSession` now exposes an explicit
style catalog (`ListStyles`), direct-versus-effective paragraph/run formatting
(`GetFormatting`), and enumerable mutation-compatible run spans (`ListInlineSpans`). Effective
Expand Down
590 changes: 590 additions & 0 deletions Docxodus.Tests/DocxSessionLinkBookmarkTests.cs

Large diffs are not rendered by default.

30 changes: 8 additions & 22 deletions Docxodus.Tests/DocxSessionMoveBlockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,11 @@ public void MoveBlock_RejectsCrossBlockRangeMembershipChange()
Assert.Contains("cross-block comment range", result.Error.Message);
}

// A tracked move clones the source paragraph. Every id-bearing marker in the clone is a
// SECOND live copy, so the ids must be made unique or the document violates the schema's
// id-uniqueness constraint while the revision is pending — the exact state a redline is
// sent out in. Mirrors IrMarkupRenderer.NormalizeBookmarks step (B): both copies keep the
// NAME (each survives its own resolution); only the ids are renumbered.
// A tracked move keeps source and destination copies live simultaneously. A bookmark has
// document-global name identity, so it cannot be duplicated faithfully across both sides;
// moving it to only one side would lose it on either accept or reject. Reject explicitly.
[Fact]
public void MoveBlock_TrackedParagraph_GivesClonedBookmarksFreshIds()
public void MoveBlock_TrackedParagraph_WithBookmark_IsExplicitlyUnsupported()
{
using var session = new DocxSession(
Document(
Expand All @@ -217,23 +215,11 @@ public void MoveBlock_TrackedParagraph_GivesClonedBookmarksFreshIds()
});
var anchors = ParagraphAnchors(session);

Assert.True(session.MoveBlock(anchors[0], anchors[2], Position.After).Success);
var result = session.MoveBlock(anchors[0], anchors[2], Position.After);

var saved = session.Save();
AssertValid(saved);
using var stream = new MemoryStream(saved);
using var document = WordprocessingDocument.Open(stream, false);
var main = document.MainDocumentPart!.GetXDocument();
var starts = main.Descendants(W.bookmarkStart).ToList();
var ends = main.Descendants(W.bookmarkEnd).ToList();

Assert.Equal(2, starts.Count);
Assert.Equal(2, ends.Count);
// Both copies keep the name; the ids are distinct and each start still pairs with an end.
Assert.All(starts, s => Assert.Equal("_Ref1", (string?)s.Attribute(W.name)));
var startIds = starts.Select(s => (string?)s.Attribute(W.id)).ToList();
Assert.Equal(2, startIds.Distinct().Count());
Assert.Equal(startIds.OrderBy(x => x), ends.Select(e => (string?)e.Attribute(W.id)).OrderBy(x => x));
Assert.False(result.Success);
Assert.Equal(EditErrorCode.UnsupportedInlineBoundary, result.Error!.Code);
Assert.Single(session.ListBookmarks());
}

// The drag UI gates its drop indicators on this, so it has to agree with MoveBlock exactly:
Expand Down
70 changes: 68 additions & 2 deletions Docxodus.Tests/McpServerDispatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1311,9 +1311,30 @@ public void MCP097_AtomicStepPreconditionsUseBatchStartState()
// ─── Tool catalog ───────────────────────────────────────────────────

[Fact]
public void MCP100_ToolCatalog_HasSixteenDistinctNamedToolsWithValidSchemas()
public void MCP100_ToolCatalog_HasExpectedDistinctNamedToolsWithValidSchemas()
{
Assert.Equal(16, ToolCatalog.Tools.Count);
string[] expectedNames =
{
"docxodus_annotate",
"docxodus_close",
"docxodus_comment",
"docxodus_create",
"docxodus_edit",
"docxodus_format",
"docxodus_get_content",
"docxodus_links",
"docxodus_list",
"docxodus_mutations",
"docxodus_open",
"docxodus_pagination",
"docxodus_preview",
"docxodus_save",
"docxodus_search",
"docxodus_table",
"docxodus_track_changes",
};

Assert.Equal(expectedNames.Length, ToolCatalog.Tools.Count);
var names = new System.Collections.Generic.HashSet<string>();
foreach (var tool in ToolCatalog.Tools)
{
Expand All @@ -1323,6 +1344,7 @@ public void MCP100_ToolCatalog_HasSixteenDistinctNamedToolsWithValidSchemas()
using var schema = JsonDocument.Parse(tool.InputSchemaJson); // must be valid JSON
Assert.Equal("object", schema.RootElement.GetProperty("type").GetString());
}
Assert.Equal(expectedNames, names.OrderBy(name => name, StringComparer.Ordinal));
}

[Fact]
Expand Down Expand Up @@ -1900,4 +1922,48 @@ string Render(string? anchorId = null)
Save(sessionId, savedPath);
Assert.Contains("trackRevisions", SavedSettingsXml(savedPath));
}

[Fact]
public void MCP141_NativeLinkAndBookmarkCrud_RoundTripsIdsAndTypedFailures()
{
var sessionId = OpenSession();
var sessionArg = JsonSerializer.Serialize(sessionId);
var anchor = FirstBodyAnchorId(sessionId, _store);
Assert.True(ReplaceText(_store, sessionId, anchor, "alpha beta")
.GetProperty("success").GetBoolean());

var bookmark = Parse(Dispatcher.Call(_store, "docxodus_links", J(
$$"""{"sessionId":{{sessionArg}},"action":"add_bookmark","name":"Clause","startAnchorId":"{{anchor}}","startOffset":0,"endAnchorId":"{{anchor}}","endOffset":5}""")));
Assert.True(bookmark.GetProperty("success").GetBoolean());

var added = Parse(Dispatcher.Call(_store, "docxodus_links", J(
$$"""{"sessionId":{{sessionArg}},"action":"add_hyperlink","anchorId":"{{anchor}}","startOffset":6,"length":4,"kind":"external","target":"https://example.test/mcp"}""")));
Assert.True(added.GetProperty("success").GetBoolean());
var hyperlinkId = added.GetProperty("hyperlinkId").GetString()!;

var updated = Parse(Dispatcher.Call(_store, "docxodus_links", J(
$$"""{"sessionId":{{sessionArg}},"action":"update_hyperlink","hyperlinkId":{{JsonSerializer.Serialize(hyperlinkId)}},"kind":"internal","target":"Clause"}""")));
Assert.True(updated.GetProperty("success").GetBoolean());
Assert.True(Parse(Dispatcher.Call(_store, "docxodus_links", J(
$$"""{"sessionId":{{sessionArg}},"action":"rename_bookmark","name":"Clause","newName":"ClauseTwo"}""")))
.GetProperty("success").GetBoolean());

var links = Parse(Dispatcher.Call(_store, "docxodus_links", J(
$$"""{"sessionId":{{sessionArg}},"action":"list_hyperlinks","scope":"body"}""")));
var listed = Assert.Single(links.GetProperty("hyperlinks").EnumerateArray());
Assert.Equal(hyperlinkId, listed.GetProperty("id").GetString());
Assert.Equal("ClauseTwo", listed.GetProperty("target").GetString());

var blocked = Parse(Dispatcher.Call(_store, "docxodus_links", J(
$$"""{"sessionId":{{sessionArg}},"action":"remove_bookmark","name":"ClauseTwo"}""")));
Assert.False(blocked.GetProperty("success").GetBoolean());
Assert.Equal("bookmark_in_use", blocked.GetProperty("error").GetProperty("code").GetString());

Assert.True(Parse(Dispatcher.Call(_store, "docxodus_links", J(
$$"""{"sessionId":{{sessionArg}},"action":"remove_hyperlink","hyperlinkId":{{JsonSerializer.Serialize(hyperlinkId)}}}""")))
.GetProperty("success").GetBoolean());
Assert.True(Parse(Dispatcher.Call(_store, "docxodus_links", J(
$$"""{"sessionId":{{sessionArg}},"action":"remove_bookmark","name":"ClauseTwo"}""")))
.GetProperty("success").GetBoolean());
}
}
Loading