From a787cee7939a57eaa0390701636daa8f6de23266 Mon Sep 17 00:00:00 2001 From: Quorafind Date: Wed, 17 Sep 2025 21:09:40 +0800 Subject: [PATCH] fix(write): preserve wiki links and inline code when updating task metadata - Improved metadata detection to avoid matching content inside wiki links [[...]], markdown links [text](url), and inline code blocks - Fixed issue where links in task content could be incorrectly treated as metadata markers - Added comprehensive test to verify links and code blocks are preserved during metadata updates - Ensures task content remains intact when only metadata is being modified --- src/__tests__/writeapi-links-preserve.test.ts | 98 +++++++++++++++++++ src/dataflow/api/WriteAPI.ts | 40 +++++++- 2 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/writeapi-links-preserve.test.ts diff --git a/src/__tests__/writeapi-links-preserve.test.ts b/src/__tests__/writeapi-links-preserve.test.ts new file mode 100644 index 00000000..008c8c5b --- /dev/null +++ b/src/__tests__/writeapi-links-preserve.test.ts @@ -0,0 +1,98 @@ +import { App, MetadataCache } from "obsidian"; +import { WriteAPI } from "@/dataflow/api/WriteAPI"; +import type { Task } from "@/types/task"; + +/** + * Ensures updateTask preserves wiki/markdown links and inline code in task content + * while replacing/rewriting trailing metadata. + */ +describe("WriteAPI.updateTask preserves links in content when regenerating metadata", () => { + it("keeps [[wiki#section|alias]] and [text](url#anchor) and `code` intact and replaces metadata", async () => { + // In-memory vault mock + let fileContent = + "- [ ] Do [[Page#Heading|alias]] and [text](https://ex.com/a#b) `inline` #oldtag πŸ” every day πŸ›« 2024-12-01"; + const filePath = "Test.md"; + + const fakeVault: any = { + getAbstractFileByPath: (path: string) => ({ path }), + read: async (_file: any) => fileContent, + modify: async (_file: any, newContent: string) => { + fileContent = newContent; + }, + }; + + // Minimal app and metadataCache mocks + const app = new App(); + const metadataCache = new MetadataCache(); + + // Ensure workspace has trigger/on for Events.emit compatibility + (app as any).workspace = { + ...(app as any).workspace, + trigger: jest.fn(), + on: jest.fn(() => ({ unload: () => {} })), + }; + + // Minimal plugin settings used by generateMetadata + const plugin: any = { + settings: { + preferMetadataFormat: "tasks", // use emoji/tokens format + projectTagPrefix: { tasks: "project", dataview: "project" }, + contextTagPrefix: { tasks: "@", dataview: "context" }, + taskStatuses: { completed: "x" }, + autoDateManager: { + manageStartDate: false, + manageCancelledDate: false, + }, + }, + }; + + // getTaskById returns a task pointing to line 0 in file + const getTaskById = async (id: string): Promise => { + if (id !== "1") return null; + return { + id: "1", + content: + "Do [[Page#Heading|alias]] and [text](https://ex.com/a#b) `inline`", + filePath, + line: 0, + completed: false, + status: " ", + originalMarkdown: fileContent, + metadata: { + tags: ["oldtag"], + children: [], + } as any, + } as Task; + }; + + const writeAPI = new WriteAPI( + app as any, + fakeVault, + metadataCache as any, + plugin, + getTaskById + ); + + // Act: update metadata only (do not touch content/status) + const due = new Date("2025-01-15").valueOf(); + const res = await writeAPI.updateTask({ + taskId: "1", + updates: { + metadata: { tags: ["newtag"], dueDate: due } as any, + }, + }); + + expect(res.success).toBe(true); + + // Assert: links and inline code remain; old metadata removed; new metadata appended + // Expect tags first then due date (emoji πŸ“…) in tasks format + expect(fileContent).toContain( + "- [ ] Do [[Page#Heading|alias]] and [text](https://ex.com/a#b) `inline` #newtag πŸ“… 2025-01-15" + ); + + // Ensure no remnants of old metadata tokens + expect(fileContent).not.toMatch( + /#oldtag|πŸ”\s+every day|πŸ›«\s+2024-12-01/ + ); + }); +}); diff --git a/src/dataflow/api/WriteAPI.ts b/src/dataflow/api/WriteAPI.ts index 3d432066..14f5a21c 100644 --- a/src/dataflow/api/WriteAPI.ts +++ b/src/dataflow/api/WriteAPI.ts @@ -482,11 +482,41 @@ export class WriteAPI { } } else { // Remove existing metadata and regenerate from merged values - const prefixMatch = taskLine.match( - /^(\s*[-*+]\s*\[[^\]]*\]\s*[^πŸ”Ίβ«πŸ”ΌπŸ”½β¬πŸ›«β³πŸ“…βœ…πŸ”\[#@+]*)/ + // First, extract the checkbox prefix + const checkboxMatch = taskLine.match( + /^(\s*[-*+]\s*\[[^\]]*\]\s*)/ ); - if (prefixMatch) { - const taskPrefix = prefixMatch[0]; + if (checkboxMatch) { + const checkboxPrefix = checkboxMatch[1]; + const afterCheckbox = taskLine.substring( + checkboxPrefix.length + ); + + // Find where metadata starts (look for emoji markers or dataview fields) + // Updated pattern to avoid matching wiki links [[...]] or markdown links [text](url) + // To avoid false positives, sanitize out wiki links [[...]], markdown links [text](url), and inline code `...` + const sanitized = afterCheckbox + // Use non-whitespace placeholders to prevent \s+ from consuming across links/code + .replace(/\[\[[^\]]*\]\]/g, (m) => + "x".repeat(m.length) + ) + .replace(/\[[^\]]*\]\([^\)]*\)/g, (m) => + "x".repeat(m.length) + ) + .replace(/`[^`]*`/g, (m) => "x".repeat(m.length)); + + const metadataMatch = sanitized.match( + /([\s]+(πŸ”Ί|⏫|πŸ”Ό|πŸ”½|⏬|πŸ›«|⏳|πŸ“…|βœ…|πŸ”|\[(?!\[\])[^\[\]\r\n:]+::|#[A-Za-z][\w/-]*|@[A-Za-z][\w/-]*|\+[A-Za-z][\w/-]*).*)?$/ + ); + + // Extract the task content (everything before metadata) + const taskContent = + metadataMatch && metadataMatch.index !== undefined + ? afterCheckbox + .substring(0, metadataMatch.index) + .trim() + : afterCheckbox.trim(); + const mergedMd = { ...originalTask.metadata, ...args.updates.metadata, @@ -510,7 +540,7 @@ export class WriteAPI { dependsOn: mergedMd.dependsOn, id: mergedMd.id, }); - taskLine = `${taskPrefix}${ + taskLine = `${checkboxPrefix}${taskContent}${ newMetadata ? ` ${newMetadata}` : "" }`; }