From c0ebed66f5549e0c92125a120dadce16778cb019 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 9 Apr 2026 20:57:02 -0400 Subject: [PATCH] feat: Add patch support to MCP (#11987) --- server/commands/documentUpdater.test.ts | 1639 ++++++++++++++++++---- server/commands/documentUpdater.ts | 8 +- server/models/helpers/DocumentHelper.tsx | 497 ++++++- server/routes/api/documents/schema.ts | 20 +- server/tools/documents.ts | 16 +- shared/editor/lib/markdown/serializer.ts | 46 +- shared/types.ts | 2 + 7 files changed, 1947 insertions(+), 281 deletions(-) diff --git a/server/commands/documentUpdater.test.ts b/server/commands/documentUpdater.test.ts index c5cc38983d..f329138971 100644 --- a/server/commands/documentUpdater.test.ts +++ b/server/commands/documentUpdater.test.ts @@ -3,6 +3,7 @@ import * as Y from "yjs"; import { TextEditMode } from "@shared/types"; import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension"; import { Event } from "@server/models"; +import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; import { buildDocument, buildUser } from "@server/test/factories"; import { withAPIContext } from "@server/test/support"; @@ -46,276 +47,6 @@ describe("documentUpdater", () => { expect(document.lastModifiedById).not.toEqual(user.id); }); - it("should update document content when changing text", async () => { - const user = await buildUser(); - let document = await buildDocument({ - teamId: user.teamId, - }); - - document = await withAPIContext(user, (ctx) => - documentUpdater(ctx, { - text: "Changed", - document, - }) - ); - - expect(document.text).toEqual("Changed"); - expect(document.content).toEqual({ - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Changed", - }, - ], - }, - ], - }); - }); - - it("should append document content when requested", async () => { - const user = await buildUser(); - let document = await buildDocument({ - teamId: user.teamId, - text: "Initial", - }); - - document = await withAPIContext(user, (ctx) => - documentUpdater(ctx, { - text: "Appended", - document, - editMode: TextEditMode.Append, - }) - ); - - expect(document.text).toEqual("InitialAppended"); - expect(document.content).toMatchObject({ - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "InitialAppended" }], - }, - ], - }); - }); - - it("should preserve rich content when appending", async () => { - const user = await buildUser(); - let document = await buildDocument({ - teamId: user.teamId, - text: "**Bold**", - }); - - document = await withAPIContext(user, (ctx) => - documentUpdater(ctx, { - text: "Appended", - document, - editMode: TextEditMode.Append, - }) - ); - - expect(document.content).toMatchObject({ - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - marks: [{ type: "strong" }], - text: "Bold", - }, - { - type: "text", - text: "Appended", - }, - ], - }, - ], - }); - }); - - it("should preserve rich content from JSON when appending", async () => { - const user = await buildUser(); - let document = await buildDocument({ - teamId: user.teamId, - }); - const id = randomUUID(); - document.content = { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - marks: [{ type: "comment", attrs: { id, userId: id } }], - text: "Italic", - }, - ], - }, - ], - }; - await document.save(); - - document = await withAPIContext(user, (ctx) => - documentUpdater(ctx, { - text: "Appended", - document, - editMode: TextEditMode.Append, - }) - ); - - expect(document.content).toMatchObject({ - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - marks: [{ type: "comment", attrs: { id, userId: id } }], - text: "Italic", - }, - { - type: "text", - text: "Appended", - }, - ], - }, - ], - }); - }); - - it("should create new paragraph when appending with newline", async () => { - const user = await buildUser(); - let document = await buildDocument({ - teamId: user.teamId, - text: "Initial", - }); - - document = await withAPIContext(user, (ctx) => - documentUpdater(ctx, { - text: "\n\nAppended", - document, - editMode: TextEditMode.Append, - }) - ); - - expect(document.text).toEqual("Initial\n\nAppended"); - expect(document.content).toMatchObject({ - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Initial" }], - }, - { - type: "paragraph", - content: [{ type: "text", text: "Appended" }], - }, - ], - }); - }); - - it("should prepend document content when requested", async () => { - const user = await buildUser(); - let document = await buildDocument({ - teamId: user.teamId, - text: "Existing", - }); - - document = await withAPIContext(user, (ctx) => - documentUpdater(ctx, { - text: "Prepended", - document, - editMode: TextEditMode.Prepend, - }) - ); - - expect(document.text).toEqual("PrependedExisting"); - expect(document.content).toMatchObject({ - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "PrependedExisting" }], - }, - ], - }); - }); - - it("should preserve rich content when prepending", async () => { - const user = await buildUser(); - let document = await buildDocument({ - teamId: user.teamId, - text: "**Bold**", - }); - - document = await withAPIContext(user, (ctx) => - documentUpdater(ctx, { - text: "Prepended", - document, - editMode: TextEditMode.Prepend, - }) - ); - - expect(document.content).toMatchObject({ - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Prepended", - }, - { - type: "text", - marks: [{ type: "strong" }], - text: "Bold", - }, - ], - }, - ], - }); - }); - - it("should create new paragraph when prepending with newline", async () => { - const user = await buildUser(); - let document = await buildDocument({ - teamId: user.teamId, - text: "Existing", - }); - - document = await withAPIContext(user, (ctx) => - documentUpdater(ctx, { - text: "Prepended\n\n", - document, - editMode: TextEditMode.Prepend, - }) - ); - - expect(document.text).toEqual("Prepended\n\nExisting"); - expect(document.content).toMatchObject({ - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Prepended" }], - }, - { - type: "paragraph", - content: [{ type: "text", text: "Existing" }], - }, - ], - }); - }); - it("should notify collaboration server when text changes", async () => { const notifyUpdateSpy = jest .spyOn(APIUpdateExtension, "notifyUpdate") @@ -363,4 +94,1372 @@ describe("documentUpdater", () => { expect(notifyUpdateSpy).not.toHaveBeenCalled(); notifyUpdateSpy.mockRestore(); }); + + describe("replace", () => { + it("should update document content when changing text", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + + document = await withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "Changed", + document, + }) + ); + + expect(document.text).toEqual("Changed"); + expect(document.content).toEqual({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Changed", + }, + ], + }, + ], + }); + }); + }); + + describe("append", () => { + it("should append document content when requested", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "Initial", + }); + + document = await withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "Appended", + document, + editMode: TextEditMode.Append, + }) + ); + + expect(document.text).toEqual("InitialAppended"); + expect(document.content).toMatchObject({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "InitialAppended" }], + }, + ], + }); + }); + + it("should preserve rich content when appending", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "**Bold**", + }); + + document = await withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "Appended", + document, + editMode: TextEditMode.Append, + }) + ); + + expect(document.content).toMatchObject({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [{ type: "strong" }], + text: "Bold", + }, + { + type: "text", + text: "Appended", + }, + ], + }, + ], + }); + }); + + it("should preserve rich content from JSON when appending", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const id = randomUUID(); + document.content = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [{ type: "comment", attrs: { id, userId: id } }], + text: "Italic", + }, + ], + }, + ], + }; + await document.save(); + + document = await withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "Appended", + document, + editMode: TextEditMode.Append, + }) + ); + + expect(document.content).toMatchObject({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [{ type: "comment", attrs: { id, userId: id } }], + text: "Italic", + }, + { + type: "text", + text: "Appended", + }, + ], + }, + ], + }); + }); + + it("should create new paragraph when appending with newline", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "Initial", + }); + + document = await withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "\n\nAppended", + document, + editMode: TextEditMode.Append, + }) + ); + + expect(document.text).toEqual("Initial\n\nAppended"); + expect(document.content).toMatchObject({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Initial" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Appended" }], + }, + ], + }); + }); + }); + + describe("prepend", () => { + it("should prepend document content when requested", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "Existing", + }); + + document = await withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "Prepended", + document, + editMode: TextEditMode.Prepend, + }) + ); + + expect(document.text).toEqual("PrependedExisting"); + expect(document.content).toMatchObject({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "PrependedExisting" }], + }, + ], + }); + }); + + it("should preserve rich content when prepending", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "**Bold**", + }); + + document = await withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "Prepended", + document, + editMode: TextEditMode.Prepend, + }) + ); + + expect(document.content).toMatchObject({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Prepended", + }, + { + type: "text", + marks: [{ type: "strong" }], + text: "Bold", + }, + ], + }, + ], + }); + }); + + it("should create new paragraph when prepending with newline", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "Existing", + }); + + document = await withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "Prepended\n\n", + document, + editMode: TextEditMode.Prepend, + }) + ); + + expect(document.text).toEqual("Prepended\n\nExisting"); + expect(document.content).toMatchObject({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Prepended" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Existing" }], + }, + ], + }); + }); + }); + + describe("patch", () => { + it("should patch specific text in document content", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "Hello world\n\nThis is a test", + }); + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Hello earth", + TextEditMode.Patch, + "Hello world" + ); + const content = result.content!.content!; + + expect(content).toHaveLength(2); + expect(content[0]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "Hello earth" }], + }); + expect(content[1]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "This is a test" }], + }); + }); + + it("should throw when findText is not found in document", async () => { + const user = await buildUser(); + const document = await buildDocument({ + teamId: user.teamId, + text: "Hello world", + }); + + await expect( + withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "replacement", + findText: "nonexistent text", + document, + editMode: TextEditMode.Patch, + }) + ) + ).rejects.toThrow("The specified text was not found in the document"); + }); + + it("should handle dollar signs in replacement text literally", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "The price is TBD", + }); + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "$100 & $200", + TextEditMode.Patch, + "TBD" + ); + const content = result.content!.content!; + + expect(content).toHaveLength(1); + expect(content[0]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "The price is $100 & $200" }], + }); + }); + + it("should delete matched text when replacement is empty string", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "Hello beautiful world", + }); + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "", + TextEditMode.Patch, + "beautiful " + ); + const content = result.content!.content!; + + expect(content).toHaveLength(1); + expect(content[0]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "Hello world" }], + }); + }); + + it("should handle multi-block replacement in a single paragraph", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + + document.content = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Replace this text here" }], + }, + ], + }; + await document.save(); + + // Replace with content that parses to multiple blocks + const result = DocumentHelper.applyMarkdownToDocument( + document, + "First paragraph\n\nSecond paragraph", + TextEditMode.Patch, + "Replace this text here" + ); + const content = result.content!.content!; + + // Should produce two paragraphs, not silently drop the second + expect(content.length).toBeGreaterThanOrEqual(2); + expect(content[0]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "First paragraph" }], + }); + expect(content[1]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "Second paragraph" }], + }); + }); + + it("should patch multi-block content", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "# Heading\n\nOld content\n\nKeep this", + }); + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "# New Heading\n\nNew content", + TextEditMode.Patch, + "# Heading\n\nOld content" + ); + const content = result.content!.content!; + + expect(content).toHaveLength(3); + expect(content[0]).toMatchObject({ + type: "heading", + content: [{ type: "text", text: "New Heading" }], + }); + expect(content[1]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "New content" }], + }); + expect(content[2]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "Keep this" }], + }); + }); + + it("should patch the middle item in a list", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "- First item\n- Second item\n- Third item", + }); + + document = await withAPIContext(user, (ctx) => + documentUpdater(ctx, { + text: "* Updated item", + findText: "* Second item", + document, + editMode: TextEditMode.Patch, + }) + ); + + const listItem = (text: string) => ({ + type: "list_item", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }); + + expect(document.content).toMatchObject({ + type: "doc", + content: [ + { + type: "bullet_list", + content: [ + listItem("First item"), + listItem("Updated item"), + listItem("Third item"), + ], + }, + ], + }); + }); + + it("should preserve comment marks on untouched blocks when patching", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const commentId = randomUUID(); + + // Build content with a comment mark (not representable in markdown) + // on the LAST paragraph, then patch the FIRST paragraph. + document.content = { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First paragraph" }], + }, + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "comment", + attrs: { id: commentId, userId: commentId }, + }, + ], + text: "Commented text", + }, + ], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Third paragraph" }], + }, + ], + }; + await document.save(); + + // Capture the ProseMirror JSON of untouched blocks BEFORE patching + // (with schema defaults already applied via toProsemirror → toJSON) + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + const secondParaBefore = beforeDoc.content[1]; + const thirdParaBefore = beforeDoc.content[2]; + + // Call DocumentHelper directly to inspect the ProseMirror result + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Updated first", + TextEditMode.Patch, + "First paragraph" + ); + const content = result.content!.content!; + + // Verify untouched blocks are byte-for-byte identical + expect(content[1]).toEqual(secondParaBefore); + expect(content[2]).toEqual(thirdParaBefore); + + // Verify the patched block was actually updated + expect(content[0]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "Updated first" }], + }); + + // Verify we still have exactly 3 blocks + expect(content).toHaveLength(3); + }); + + it("should preserve comment marks when patching a later block", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const commentId = randomUUID(); + + // Comment mark on the FIRST paragraph, patch the THIRD. + document.content = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "comment", + attrs: { id: commentId, userId: commentId }, + }, + ], + text: "Commented text", + }, + ], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Middle paragraph" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Last paragraph" }], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + const firstParaBefore = beforeDoc.content[0]; + const secondParaBefore = beforeDoc.content[1]; + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Updated last", + TextEditMode.Patch, + "Last paragraph" + ); + const content = result.content!.content!; + + // Both untouched blocks must be identical + expect(content[0]).toEqual(firstParaBefore); + expect(content[1]).toEqual(secondParaBefore); + + // Patched block updated + expect(content[2]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "Updated last" }], + }); + expect(content).toHaveLength(3); + }); + + it("should preserve comment marks in complex document when patching middle content", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const commentId = randomUUID(); + + // Complex document: heading, paragraph with comment, bullet list, paragraph to patch + document.content = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Section Title" }], + }, + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "comment", + attrs: { id: commentId, userId: commentId }, + }, + ], + text: "This has a comment", + }, + ], + }, + { + type: "bullet_list", + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item one" }], + }, + ], + }, + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Item two" }], + }, + ], + }, + ], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Final paragraph to edit" }], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Edited final paragraph", + TextEditMode.Patch, + "Final paragraph to edit" + ); + const content = result.content!.content!; + + // All three untouched blocks must be byte-for-byte identical + expect(content[0]).toEqual(beforeDoc.content[0]); + expect(content[1]).toEqual(beforeDoc.content[1]); + expect(content[2]).toEqual(beforeDoc.content[2]); + + // Patched block updated + expect(content[3]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "Edited final paragraph" }], + }); + expect(content).toHaveLength(4); + }); + + it("should preserve comment mark when patching adjacent text in the same paragraph", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const commentId = randomUUID(); + + // Paragraph: "Hello world " + "commented"(with comment mark) + " end" + document.content = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hello world " }, + { + type: "text", + marks: [ + { + type: "comment", + attrs: { id: commentId, userId: commentId }, + }, + ], + text: "commented", + }, + { type: "text", text: " end" }, + ], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + const commentNode = beforeDoc.content[0].content[1]; + const endNode = beforeDoc.content[0].content[2]; + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Goodbye world ", + TextEditMode.Patch, + "Hello world " + ); + const content = result.content!.content!; + + expect(content).toHaveLength(1); + // Comment mark and trailing text must be preserved exactly + expect(content[0]).toMatchObject({ + type: "paragraph", + content: [ + { type: "text", text: "Goodbye world " }, + commentNode, + endNode, + ], + }); + }); + + it("should preserve rich content in other checklist items when patching one item", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const commentId = randomUUID(); + + // Checklist with 3 items; second item has a comment mark + document.content = { + type: "doc", + content: [ + { + type: "checkbox_list", + content: [ + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First task" }], + }, + ], + }, + { + type: "checkbox_item", + attrs: { checked: true }, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "comment", + attrs: { id: commentId, userId: commentId }, + }, + ], + text: "Commented task", + }, + ], + }, + ], + }, + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Third task" }], + }, + ], + }, + ], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + const secondItem = beforeDoc.content[0].content[1]; + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Updated task", + TextEditMode.Patch, + "First task" + ); + const list = result.content!.content![0]; + + // The second checklist item with its comment mark must be preserved + expect(list.content![1]).toEqual(secondItem); + + // The first item should be updated + expect(list.content![0].content![0].content![0].text).toEqual( + "Updated task" + ); + + // All three items should still exist + expect(list.content).toHaveLength(3); + }); + + it("should patch checklist item checked state while preserving rich content in siblings", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const commentId = randomUUID(); + + document.content = { + type: "doc", + content: [ + { + type: "checkbox_list", + content: [ + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Buy groceries" }], + }, + ], + }, + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "comment", + attrs: { id: commentId, userId: commentId }, + }, + ], + text: "Review PR", + }, + ], + }, + ], + }, + ], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + const secondItem = beforeDoc.content[0].content[1]; + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "- [x] Buy groceries", + TextEditMode.Patch, + "- [ ] Buy groceries" + ); + const list = result.content!.content![0]; + + // Checked state should be updated + expect(list.content![0].attrs!.checked).toBe(true); + + // Text should remain the same + expect(list.content![0].content![0].content![0].text).toEqual( + "Buy groceries" + ); + + // Second item with comment mark must be preserved exactly + expect(list.content![1]).toEqual(secondItem); + }); + + it("should uncheck a checkbox item while preserving rich content in siblings", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const commentId = randomUUID(); + + // First item is checked, second has a comment mark + document.content = { + type: "doc", + content: [ + { + type: "checkbox_list", + content: [ + { + type: "checkbox_item", + attrs: { checked: true }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Buy groceries" }], + }, + ], + }, + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "comment", + attrs: { id: commentId, userId: commentId }, + }, + ], + text: "Review PR", + }, + ], + }, + ], + }, + ], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + const secondItem = beforeDoc.content[0].content[1]; + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "- [ ] Buy groceries", + TextEditMode.Patch, + "- [x] Buy groceries" + ); + const list = result.content!.content![0]; + + // Checked state should be updated to false + expect(list.content![0].attrs!.checked).toBe(false); + + // Text should remain the same + expect(list.content![0].content![0].content![0].text).toEqual( + "Buy groceries" + ); + + // Second item with comment mark must be preserved exactly + expect(list.content![1]).toEqual(secondItem); + }); + + it("should preserve mention node when patching adjacent text in the same paragraph", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const mentionId = randomUUID(); + + // Paragraph: "Hello " + @mention + " please review this" + document.content = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hello " }, + { + type: "mention", + attrs: { + type: "user", + label: "Tom", + modelId: mentionId, + actorId: null, + id: mentionId, + }, + }, + { type: "text", text: " please review this" }, + ], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + const mentionNode = beforeDoc.content[0].content[1]; + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "please approve this", + TextEditMode.Patch, + "please review this" + ); + const content = result.content!.content!; + + expect(content).toHaveLength(1); + expect(content[0].type).toEqual("paragraph"); + // The mention node must be preserved + expect(content[0].content![1]).toEqual(mentionNode); + }); + + it("should preserve table colwidth when patching a different cell", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + + // Table with colwidth set on cells (not representable in markdown) + document.content = { + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "th", + attrs: { + colspan: 1, + rowspan: 1, + colwidth: [150], + alignment: null, + }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header A" }], + }, + ], + }, + { + type: "th", + attrs: { + colspan: 1, + rowspan: 1, + colwidth: [250], + alignment: null, + }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Header B" }], + }, + ], + }, + ], + }, + { + type: "tr", + content: [ + { + type: "td", + attrs: { + colspan: 1, + rowspan: 1, + colwidth: [150], + alignment: null, + }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Cell to edit" }], + }, + ], + }, + { + type: "td", + attrs: { + colspan: 1, + rowspan: 1, + colwidth: [250], + alignment: null, + }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Keep this" }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + // Second cell in second row should retain colwidth + const unchangedCell = beforeDoc.content[0].content[1].content[1]; + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Updated cell", + TextEditMode.Patch, + "Cell to edit" + ); + const table = result.content!.content![0]; + const secondRow = table.content![1]; + + // The unchanged cell must preserve its colwidth attr + expect(secondRow.content![1]).toEqual(unchangedCell); + expect(secondRow.content![1].attrs!.colwidth).toEqual([250]); + }); + + it("should preserve trailing whitespace in checklist items when patching", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + + // Checklist where an item ends with a hard break (trailing spaces matter) + document.content = { + type: "doc", + content: [ + { + type: "checkbox_list", + content: [ + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Edit me" }], + }, + ], + }, + { + type: "checkbox_item", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Line one" }, + { type: "br" }, + { type: "text", text: "Line two" }, + ], + }, + ], + }, + ], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + const secondItem = beforeDoc.content[0].content[1]; + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Edited", + TextEditMode.Patch, + "Edit me" + ); + const list = result.content!.content![0]; + + // The second item with its hard break must be preserved exactly + expect(list.content![1]).toEqual(secondItem); + }); + + it("should preserve rich content in blockquote when patching", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + const commentId = randomUUID(); + + // Blockquote with two paragraphs; second has a comment mark + document.content = { + type: "doc", + content: [ + { + type: "blockquote", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Edit this line" }], + }, + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "comment", + attrs: { id: commentId, userId: commentId }, + }, + ], + text: "Keep this comment", + }, + ], + }, + ], + }, + ], + }; + await document.save(); + + const beforeDoc = DocumentHelper.toProsemirror(document).toJSON(); + const secondPara = beforeDoc.content[0].content[1]; + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Edited line", + TextEditMode.Patch, + "Edit this line" + ); + const blockquote = result.content!.content![0]; + + // Patched paragraph updated + expect(blockquote.content![0].content![0].text).toEqual("Edited line"); + + // Second paragraph with comment mark preserved exactly + expect(blockquote.content![1]).toEqual(secondPara); + }); + + it("should always patch the first occurrence when findText appears multiple times", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "TODO item\n\nSome details\n\nTODO item", + }); + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "DONE item", + TextEditMode.Patch, + "TODO item" + ); + const content = result.content!.content!; + + expect(content).toHaveLength(3); + // First occurrence replaced + expect(content[0]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "DONE item" }], + }); + // Middle paragraph unchanged + expect(content[1]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "Some details" }], + }); + // Second occurrence untouched + expect(content[2]).toMatchObject({ + type: "paragraph", + content: [{ type: "text", text: "TODO item" }], + }); + }); + + it("should patch text containing inline formatting", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + text: "This is **bold** text", + }); + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "This is **strong** text", + TextEditMode.Patch, + "This is **bold** text" + ); + const content = result.content!.content!; + + expect(content).toHaveLength(1); + expect(content[0]).toMatchObject({ + type: "paragraph", + content: [ + { type: "text", text: "This is " }, + { + type: "text", + marks: [{ type: "strong" }], + text: "strong", + }, + { type: "text", text: " text" }, + ], + }); + }); + + it("should preserve ordered list container attrs when patching an item", async () => { + const user = await buildUser(); + let document = await buildDocument({ + teamId: user.teamId, + }); + + // Ordered list starting at 3 with lower-alpha style + document.content = { + type: "doc", + content: [ + { + type: "ordered_list", + attrs: { order: 3, listStyle: "lower-alpha" }, + content: [ + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First item" }], + }, + ], + }, + { + type: "list_item", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Second item" }], + }, + ], + }, + ], + }, + ], + }; + await document.save(); + + const result = DocumentHelper.applyMarkdownToDocument( + document, + "Updated item", + TextEditMode.Patch, + "First item" + ); + const list = result.content!.content![0]; + + // Container attrs must be preserved + expect(list.attrs!.order).toEqual(3); + expect(list.attrs!.listStyle).toEqual("lower-alpha"); + + // Patched item updated + expect(list.content![0].content![0].content![0].text).toEqual( + "Updated item" + ); + + // Unchanged item preserved + expect(list.content![1].content![0].content![0].text).toEqual( + "Second item" + ); + }); + }); }); diff --git a/server/commands/documentUpdater.ts b/server/commands/documentUpdater.ts index d0b1bc125c..0a19209b31 100644 --- a/server/commands/documentUpdater.ts +++ b/server/commands/documentUpdater.ts @@ -25,8 +25,10 @@ type Props = { fullWidth?: boolean; /** Whether insights should be visible on the document */ insightsEnabled?: boolean; - /** The edit mode: "replace", "append", or "prepend" */ + /** The edit mode: "replace", "append", "prepend", or "patch" */ editMode?: TextEditMode; + /** The markdown text to find when using "patch" edit mode */ + findText?: string; /** Whether the document should be published to the collection */ publish?: boolean; /** The ID of the collection to publish the document to */ @@ -53,6 +55,7 @@ export default async function documentUpdater( fullWidth, insightsEnabled, editMode, + findText, publish, collectionId, done, @@ -89,7 +92,8 @@ export default async function documentUpdater( await TextHelper.replaceImagesWithAttachments(ctx, text, user, { base64Only: true, }), - editMode + editMode, + findText ); } diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index c24815b123..4ee42ee067 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -1,5 +1,5 @@ import { JSDOM } from "jsdom"; -import { Node, Fragment } from "prosemirror-model"; +import { Node, Fragment, type NodeType } from "prosemirror-model"; import ukkonen from "ukkonen"; import { updateYFragment, yDocToProsemirrorJSON } from "y-prosemirror"; import * as Y from "yjs"; @@ -13,6 +13,7 @@ import type { NavigationNode, ProsemirrorData } from "@shared/types"; import { IconType, TextEditMode } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; import { parser, serializer, schema } from "@server/editor"; +import { ValidationError } from "@server/errors"; import { addTags } from "@server/logging/tracer"; import { trace } from "@server/logging/tracing"; import type { Template } from "@server/models"; @@ -21,6 +22,32 @@ import type { MentionAttrs } from "./ProsemirrorHelper"; import { ProsemirrorHelper } from "./ProsemirrorHelper"; import { TextHelper } from "./TextHelper"; +/** Maps a range of text-content offsets to ProseMirror Fragment offsets. */ +interface InlineSegment { + textFrom: number; + textTo: number; + pmFrom: number; + pmTo: number; + /** Whether this segment is an atom node whose text length differs from nodeSize. */ + isAtom?: boolean; +} + +/** Context for a patch operation, shared across surgical patch methods. */ +interface PatchContext { + /** The full document markdown string. */ + markdown: string; + /** Start of the findText match in the markdown. */ + matchIndex: number; + /** End of the findText match in the markdown. */ + matchEnd: number; + /** Start of the target node's markdown in the full string. */ + nodeMdFrom: number; + /** End of the target node's markdown in the full string. */ + nodeMdTo: number; + /** The markdown replacement text. */ + replacementText: string; +} + type HTMLOptions = { /** Whether to include the document title in the generated HTML (defaults to true) */ includeTitle?: boolean; @@ -473,17 +500,87 @@ export class DocumentHelper { * * @param document The document to apply the changes to * @param text The markdown to apply - * @param editMode The edit mode to use: "replace" (default), "append", or "prepend" + * @param editMode The edit mode to use: "replace" (default), "append", "prepend", or "patch" + * @param findText The markdown text to find when using "patch" edit mode * @returns The document */ static applyMarkdownToDocument( document: Document, text: string, - editMode: TextEditMode = TextEditMode.Replace + editMode: TextEditMode = TextEditMode.Replace, + findText?: string ) { let doc: Node; - if (editMode === TextEditMode.Append) { + if (editMode === TextEditMode.Patch) { + if (!findText) { + throw ValidationError( + "findText is required when using patch edit mode" + ); + } + + const existingDoc = DocumentHelper.toProsemirror(document); + const { markdown, blockMap } = + serializer.serializeWithPositions(existingDoc); + + const matchIndex = markdown.indexOf(findText); + if (matchIndex === -1) { + throw ValidationError( + "The specified text was not found in the document" + ); + } + const matchEnd = matchIndex + findText.length; + + // Find which top-level blocks overlap the matched range + const affected = blockMap.filter( + (b) => b.mdTo > matchIndex && b.mdFrom < matchEnd + ); + + if (affected.length === 0) { + throw ValidationError( + "Could not map the matched text to document content" + ); + } + + const pmFrom = affected[0].pmFrom; + const pmTo = affected[affected.length - 1].pmTo; + + // Try a surgical patch that preserves sibling nodes and their rich + // content. Falls back to a full markdown re-parse of the affected + // blocks when a surgical patch is not possible. + const patch: PatchContext = { + markdown, + matchIndex, + matchEnd, + nodeMdFrom: affected[0].mdFrom, + nodeMdTo: affected[0].mdTo, + replacementText: text, + }; + + const surgicalResult = + affected.length === 1 + ? DocumentHelper.trySurgicalPatch(existingDoc, pmFrom, pmTo, patch) + : undefined; + + if (surgicalResult) { + doc = surgicalResult; + } else { + const regionMdFrom = affected[0].mdFrom; + const regionMdTo = affected[affected.length - 1].mdTo; + const regionMarkdown = markdown.slice(regionMdFrom, regionMdTo); + const localMatchStart = matchIndex - regionMdFrom; + const localMatchEnd = matchEnd - regionMdFrom; + const modifiedRegion = + regionMarkdown.slice(0, localMatchStart) + + text + + regionMarkdown.slice(localMatchEnd); + const newContent = parser.parse(modifiedRegion); + + const before = existingDoc.content.cut(0, pmFrom); + const after = existingDoc.content.cut(pmTo); + doc = existingDoc.copy(before.append(newContent.content).append(after)); + } + } else if (editMode === TextEditMode.Append) { const existingDoc = DocumentHelper.toProsemirror(document); const newDoc = parser.parse(text); const lastChild = existingDoc.lastChild; @@ -564,6 +661,398 @@ export class DocumentHelper { return document; } + /** + * Attempt a surgical patch on a single affected top-level block. For + * textblocks this does an inline replacement. For container nodes (lists, + * blockquotes, etc.) it re-parses the container markdown with the + * modification and merges with the original to preserve rich content in + * unchanged children. + * + * @param existingDoc The full ProseMirror document. + * @param pmFrom Start of the affected block in the document content. + * @param pmTo End of the affected block in the document content. + * @param patch The patch context. + * @returns A new document Node on success, or undefined. + */ + private static trySurgicalPatch( + existingDoc: Node, + pmFrom: number, + pmTo: number, + patch: PatchContext + ): Node | undefined { + const blockNode = existingDoc.nodeAt(pmFrom); + if (!blockNode) { + return undefined; + } + + const patchedBlock = DocumentHelper.patchNode(blockNode, patch); + + if (!patchedBlock) { + return undefined; + } + + const before = existingDoc.content.cut(0, pmFrom); + const after = existingDoc.content.cut(pmTo); + return existingDoc.copy( + before.append(Fragment.from(patchedBlock)).append(after) + ); + } + + /** + * Recursively patch a single node. For textblocks, performs an inline + * replacement. For container nodes, serializes children to find which + * child contains the match, patches that child, and preserves siblings. + * + * @param node The node to patch. + * @param patch The patch context. + * @returns The patched node, or undefined to fall back. + */ + private static patchNode(node: Node, patch: PatchContext): Node | undefined { + if (node.isTextblock) { + return DocumentHelper.tryInlinePatch(node, patch); + } + + const { + markdown, + matchIndex, + matchEnd, + nodeMdFrom, + nodeMdTo, + replacementText, + } = patch; + + // Container node (list, blockquote, etc.): re-parse the container's + // markdown with the modification applied, then merge with the original + // to preserve rich content (comment marks, highlight colors, etc.) in + // children whose text content did not change. + const containerMd = markdown.slice(nodeMdFrom, nodeMdTo); + const localStart = matchIndex - nodeMdFrom; + const localEnd = matchEnd - nodeMdFrom; + const modifiedMd = + containerMd.slice(0, localStart) + + replacementText + + containerMd.slice(localEnd); + + const parsed = parser.parse(modifiedMd.replace(/^\n+/, "")); + const newContainer = DocumentHelper.findChildOfType(parsed, node.type); + + if (!newContainer) { + return undefined; + } + + // Parse the original (unmodified) container markdown to get a round-trip + // baseline. This lets mergeNodes distinguish attrs that were intentionally + // changed by the modification from attrs lost during markdown round-trip. + const originalParsed = parser.parse(containerMd.replace(/^\n+/, "")); + const roundTripped = DocumentHelper.findChildOfType( + originalParsed, + node.type + ); + + return DocumentHelper.mergeNodes(node, newContainer, roundTripped); + } + + /** + * Find the first child of a parsed document that matches the given type. + * + * @param doc The parsed document to search. + * @param type The node type to find. + * @returns The first matching child, or undefined. + */ + private static findChildOfType(doc: Node, type: NodeType): Node | undefined { + let result: Node | undefined; + doc.forEach((child: Node) => { + if (child.type === type && !result) { + result = child; + } + }); + return result; + } + + /** + * Recursively merge two structurally similar nodes. Children whose text + * content is unchanged are kept from the original (preserving attributes + * that cannot be represented in markdown, such as comment marks or + * highlight colors). Children whose content changed use the updated version. + * + * @param original The original node with rich content to preserve. + * @param updated The re-parsed node with the modification applied. + * @param roundTripped The original node after a markdown round-trip, used + * to distinguish intentional attr changes from round-trip losses. + * @returns The merged node. + */ + private static mergeNodes( + original: Node, + updated: Node, + roundTripped?: Node + ): Node { + if (original.isTextblock || original.isLeaf) { + return updated; + } + + const oldChildren: Node[] = []; + const newChildren: Node[] = []; + const rtChildren: Node[] = []; + original.forEach((child: Node) => oldChildren.push(child)); + updated.forEach((child: Node) => newChildren.push(child)); + roundTripped?.forEach((child: Node) => rtChildren.push(child)); + + // If structure changed significantly, use the fully re-parsed version. + if (oldChildren.length !== newChildren.length) { + return updated; + } + + const merged: Node[] = []; + for (let i = 0; i < oldChildren.length; i++) { + const oldChild = oldChildren[i]; + const newChild = newChildren[i]; + const rtChild = rtChildren[i]; + + if (oldChild.type !== newChild.type) { + return updated; + } + + const textSame = oldChild.textContent === newChild.textContent; + + if (textSame && oldChild.sameMarkup(newChild)) { + // Fully unchanged — keep original with its rich content + merged.push(oldChild); + } else if (textSame) { + // Attrs changed (e.g. checked state) but content same — merge attrs + // so that non-markdown-representable values (colwidth, highlight + // colors, etc.) are preserved from the original while intentional + // changes from the re-parsed version are applied. + const mergedAttrs = DocumentHelper.mergeAttrs( + oldChild, + newChild, + rtChild + ); + merged.push( + oldChild.type.create(mergedAttrs, oldChild.content, oldChild.marks) + ); + } else if (!oldChild.isTextblock && !oldChild.isLeaf) { + // Both are containers — recurse to preserve rich content deeper + merged.push(DocumentHelper.mergeNodes(oldChild, newChild, rtChild)); + } else { + merged.push(newChild); + } + } + + // Merge container attrs so markdown-driven changes (e.g. ordered list + // order/listStyle) are applied while preserving non-markdown attrs. + const mergedAttrs = DocumentHelper.mergeAttrs( + original, + updated, + roundTripped + ); + + return original.type.create( + mergedAttrs, + Fragment.from(merged), + original.marks + ); + } + + /** + * Merge attrs from an original and re-parsed node. When a round-tripped + * baseline is available, attrs whose updated value matches the round-trip + * value are considered unchanged (possibly lost in the round-trip) and the + * original is preserved. Attrs that differ from the round-trip baseline + * were intentionally changed and the updated value is used. + * + * @param original The original node with potentially rich attrs. + * @param updated The re-parsed node with the modification applied. + * @param roundTripped The original node after a markdown round-trip. + * @returns The merged attrs object. + */ + private static mergeAttrs( + original: Node, + updated: Node, + roundTripped?: Node + ): Record { + if (!roundTripped) { + return updated.attrs; + } + + const result: Record = {}; + for (const key of Object.keys(original.attrs)) { + const newVal = updated.attrs[key]; + const oldVal = original.attrs[key]; + const rtVal = roundTripped.attrs[key]; + + // If the updated value matches what a round-trip of the original + // produces, the modification did not change this attr — preserve the + // original (which may have richer data lost in markdown round-trip). + if (JSON.stringify(newVal) === JSON.stringify(rtVal)) { + result[key] = oldVal; + } else { + result[key] = newVal; + } + } + return result; + } + + /** + * Attempt an inline-level patch within a single textblock node. Returns the + * patched block node on success, or undefined if an inline patch is not + * possible and the caller should fall back to block-level replacement. + * + * @param blockNode The textblock node containing the match. + * @param patch The patch context. + * @returns The patched block node, or undefined. + */ + private static tryInlinePatch( + blockNode: Node, + patch: PatchContext + ): Node | undefined { + const { + markdown, + matchIndex, + matchEnd, + nodeMdFrom, + nodeMdTo, + replacementText, + } = patch; + // Strip the leading block separator (newlines) to get the block's own + // markdown content and the offset of that content within the full string. + const blockMdRaw = markdown.slice(nodeMdFrom, nodeMdTo); + const separatorLen = + blockMdRaw.length - blockMdRaw.replace(/^\n+/, "").length; + const contentMdStart = nodeMdFrom + separatorLen; + + // Positions of the match relative to the block's content markdown. + const localMdFrom = matchIndex - contentMdStart; + const localMdTo = matchEnd - contentMdStart; + + if (localMdFrom < 0) { + return undefined; + } + + // Build a map from text-content offset to PM Fragment offset by walking + // the block's inline children. For text nodes each character maps 1:1; + // for atom inline nodes (images, mentions) the nodeSize may differ from + // the text they contribute. + const blockText = blockNode.textContent; + const segments: InlineSegment[] = []; + let textOffset = 0; + + blockNode.forEach((child: Node, offset: number) => { + const childTextLen = child.isText + ? child.text!.length + : child.type.spec.leafText + ? (child.type.spec.leafText as (n: Node) => string)(child).length + : 0; + + segments.push({ + textFrom: textOffset, + textTo: textOffset + childTextLen, + pmFrom: offset, + pmTo: offset + child.nodeSize, + isAtom: !child.isText && childTextLen !== child.nodeSize, + }); + textOffset += childTextLen; + }); + + // Map the match to text-content positions. When the block's markdown + // equals its plain text, markdown offsets map 1:1. When they differ + // (atoms like mentions, or formatting marks), locate the match in the + // block's textContent directly. + let localTextFrom = localMdFrom; + let localTextTo = localMdTo; + + const contentMd = markdown.slice( + contentMdStart, + contentMdStart + blockText.length + ); + if (contentMd !== blockText) { + const findStr = markdown.slice(matchIndex, matchEnd); + const textIdx = blockText.indexOf(findStr); + if (textIdx < 0) { + return undefined; + } + localTextFrom = textIdx; + localTextTo = textIdx + findStr.length; + } + + // Atom inline nodes (mentions, images) have text representations that + // don't map 1:1 to PM offsets. Only bail when atoms overlap the match + // range — atoms outside the range are safely preserved by the splice. + const hasOverlappingAtom = segments.some( + (seg) => + seg.isAtom && seg.textTo > localTextFrom && seg.textFrom < localTextTo + ); + if (hasOverlappingAtom) { + return undefined; + } + + // Resolve text-content positions to PM Fragment positions. + const pmInlineFrom = DocumentHelper.textToPmOffset(segments, localTextFrom); + const pmInlineTo = DocumentHelper.textToPmOffset(segments, localTextTo); + + if (pmInlineFrom < 0 || pmInlineTo < 0) { + return undefined; + } + + // Parse the replacement markdown and extract inline content only when it + // resolves to a single textblock. Multi-block or non-textblock content + // should fall back to block-level replacement rather than being silently + // truncated during inline patching. Markdown parsing can trim whitespace, + // so when the parsed result is plain text we use the raw replacement + // string to preserve exact whitespace. + const parsed = parser.parse(replacementText); + const firstBlock = parsed.firstChild; + + if (parsed.childCount !== 1 || !firstBlock?.isTextblock) { + return undefined; + } + + let replacementContent: Fragment; + + if ( + firstBlock.content.childCount === 1 && + firstBlock.firstChild?.isText && + !firstBlock.firstChild.marks.length + ) { + // Plain text replacement — use the raw string to avoid whitespace trimming + replacementContent = Fragment.from(schema.text(replacementText)); + } else { + replacementContent = firstBlock.content; + } + + // Splice: keep inline content before match + replacement + after match. + const inlineBefore = blockNode.content.cut(0, pmInlineFrom); + const inlineAfter = blockNode.content.cut(pmInlineTo); + return blockNode.copy( + inlineBefore.append(replacementContent).append(inlineAfter) + ); + } + + /** + * Convert a text-content offset to a ProseMirror Fragment offset using a + * pre-built segment map. + * + * @param segments The segment map from text offsets to PM offsets. + * @param textPos The text-content offset to convert. + * @returns The corresponding PM Fragment offset, or -1. + */ + private static textToPmOffset( + segments: InlineSegment[], + textPos: number + ): number { + for (const seg of segments) { + if (textPos >= seg.textFrom && textPos <= seg.textTo) { + return seg.pmFrom + (textPos - seg.textFrom); + } + } + if (segments.length > 0) { + const last = segments[segments.length - 1]; + if (textPos >= last.textTo) { + return last.pmTo; + } + } + return -1; + } + /** * Compares two documents or revisions and returns whether the text differs by more than the threshold. * diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index 8eee5d31a6..d4e176d0d4 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -274,9 +274,12 @@ export const DocumentsUpdateSchema = BaseSchema.extend({ /** @deprecated Use editMode instead */ append: z.boolean().optional(), - /** The edit mode for text updates: "replace", "append", or "prepend" */ + /** The edit mode for text updates: "replace", "append", "prepend", or "patch" */ editMode: z.enum(TextEditMode).optional(), + /** The markdown text to find when using "patch" edit mode */ + findText: z.string().optional(), + /** @deprecated Version of the API to be used, remove in a few releases */ apiVersion: z.number().optional(), @@ -296,6 +299,21 @@ export const DocumentsUpdateSchema = BaseSchema.extend({ message: "text is required when using append, prepend, or editMode", } ) + .refine( + (req) => + !( + req.body.editMode === TextEditMode.Patch && req.body.text === undefined + ), + { + message: "text is required when using patch editMode", + } + ) + .refine( + (req) => !(req.body.editMode === TextEditMode.Patch && !req.body.findText), + { + message: "findText is required when using patch editMode", + } + ) .transform((req) => { // Transform deprecated append to editMode for backwards compatibility if (req.body.append && !req.body.editMode) { diff --git a/server/tools/documents.ts b/server/tools/documents.ts index 8589ea5cff..048fa8c824 100644 --- a/server/tools/documents.ts +++ b/server/tools/documents.ts @@ -444,7 +444,7 @@ export function documentTools(server: McpServer, scopes: string[]) { { title: "Update document", description: - "Updates an existing document by its ID. Only the fields provided will be updated.", + 'Updates an existing document by its ID. Only the fields provided will be updated. IMPORTANT: When editing an existing document\'s content, always prefer editMode "patch" with findText and text — this surgically replaces only the matched section and preserves all rich formatting (highlights, comments, table widths, etc) in the rest of the document. Using "replace" will overwrite the entire document and lose any formatting that cannot be represented in markdown.', annotations: { idempotentHint: true, readOnlyHint: false, @@ -460,11 +460,21 @@ export function documentTools(server: McpServer, scopes: string[]) { text: z .string() .optional() - .describe("The new markdown content for the document."), + .describe( + 'The markdown content to apply. In "replace" mode this becomes the entire document. In "append"/"prepend" mode it is added to the end/beginning. In "patch" mode this is the replacement text for the matched findText.' + ), editMode: z .enum(TextEditMode) .optional() - .describe("How to apply the text update. Defaults to replace."), + .describe( + 'How to apply the text update. "replace" (default) replaces the entire document content. "append" adds text to the end. "prepend" adds text to the beginning. "patch" finds the exact markdown specified in findText and replaces only that portion, preserving the rest of the document including any rich formatting that cannot be represented in markdown.' + ), + findText: z + .string() + .optional() + .describe( + 'Required when editMode is "patch". The exact markdown substring to find in the document. This should be copied verbatim from the document\'s existing markdown content. The first occurrence will be replaced with the text parameter. Can span multiple blocks (paragraphs, headings, etc).' + ), collectionId: z .string() .optional() diff --git a/shared/editor/lib/markdown/serializer.ts b/shared/editor/lib/markdown/serializer.ts index 3691733a62..85a1d82ad3 100644 --- a/shared/editor/lib/markdown/serializer.ts +++ b/shared/editor/lib/markdown/serializer.ts @@ -57,6 +57,30 @@ export class MarkdownSerializer { state.renderContent(content); return state.out; } + + // Serialize the content and return both the markdown string and a + // block-level position map that records the ProseMirror position range + // and markdown character range for each top-level child node. + serializeWithPositions( + content, + options?: Options + ): { markdown: string; blockMap: BlockMapEntry[] } { + const state = new MarkdownSerializerState(this.nodes, this.marks, options); + state.blockMap = []; + state.renderContent(content); + return { markdown: state.out, blockMap: state.blockMap }; + } +} + +export interface BlockMapEntry { + /** Start position in the ProseMirror document (offset within parent content). */ + pmFrom: number; + /** End position in the ProseMirror document. */ + pmTo: number; + /** Start character offset in the serialized markdown string. */ + mdFrom: number; + /** End character offset in the serialized markdown string. */ + mdTo: number; } // ::- This is an object used to track state and expose @@ -70,6 +94,7 @@ export class MarkdownSerializerState { delim = ""; out = ""; options: Options; + blockMap = null; constructor(nodes, marks, options) { this.nodes = nodes; @@ -185,7 +210,26 @@ export class MarkdownSerializerState { // :: (Node) // Render the contents of `parent` as block nodes. renderContent(parent) { - parent.forEach((node, _, i) => this.render(node, parent, i)); + parent.forEach((node, offset, i) => { + const trackingMap = this.blockMap; + const mdFrom = trackingMap ? this.out.length : 0; + // Suppress tracking during render so that nested renderContent calls + // (e.g. inside list items, blockquotes) don't push entries with + // parent-relative positions into the top-level map. + if (trackingMap) { + this.blockMap = null; + } + this.render(node, parent, i); + if (trackingMap) { + this.blockMap = trackingMap; + trackingMap.push({ + pmFrom: offset, + pmTo: offset + node.nodeSize, + mdFrom, + mdTo: this.out.length, + }); + } + }); } // :: (Node) diff --git a/shared/types.ts b/shared/types.ts index 9b5c02c80a..4736a33582 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -718,6 +718,8 @@ export enum TextEditMode { Append = "append", /** Prepend new content to the beginning of the document. */ Prepend = "prepend", + /** Patch specific content within the document by finding and replacing text. */ + Patch = "patch", } export enum EmojiCategory {