From db4359981fded9efcadea8908f13f74faf3b4fca Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 10 Apr 2026 20:20:08 +0200 Subject: [PATCH] fix: update rename filename wikilinks --- src/mock-tauri/mock-handlers.ts | 38 ++++++++++++++++++ tests/integration/vault-workflows.spec.ts | 27 +++++++------ vite.config.ts | 49 ++++++++++------------- 3 files changed, 74 insertions(+), 40 deletions(-) diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 7e5b6647..b998047a 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -190,6 +190,43 @@ function handleRenameNote(args: { vault_path: string; old_path: string; new_titl return { new_path: newPath, updated_files: updatedFiles } } +function handleRenameNoteFilename(args: { + vault_path: string + old_path: string + new_filename_stem: string +}) { + const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path) + const oldContent = MOCK_CONTENT[args.old_path] ?? '' + const oldTitle = oldEntry?.title ?? '' + const normalizedStem = args.new_filename_stem.trim().replace(/\.md$/, '') + const oldFilename = args.old_path.split('/').pop() ?? '' + const newFilename = `${normalizedStem}.md` + + if (!normalizedStem) { + throw new Error('Invalid filename') + } + if (oldFilename === newFilename) { + return { new_path: args.old_path, updated_files: 0 } + } + + const parentDir = args.old_path.replace(/\/[^/]+$/, '') + const newPath = `${parentDir}/${newFilename}` + if (newPath !== args.old_path && Object.prototype.hasOwnProperty.call(MOCK_CONTENT, newPath)) { + throw new Error('A note with that name already exists') + } + + delete MOCK_CONTENT[args.old_path] + MOCK_CONTENT[newPath] = oldContent + + const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path }) + const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path }) + const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem }) + const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets }) + + syncWindowContent() + return { new_path: newPath, updated_files: updatedFiles } +} + const trimOrNull = (v: string | null | undefined): string | null => v?.trim() || null // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types @@ -275,6 +312,7 @@ export const mockHandlers: Record any> = { load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }), save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null }, rename_note: handleRenameNote, + rename_note_filename: handleRenameNoteFilename, github_list_repos: () => [ { name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' }, { name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' }, diff --git a/tests/integration/vault-workflows.spec.ts b/tests/integration/vault-workflows.spec.ts index 071d9cb0..5f7ab264 100644 --- a/tests/integration/vault-workflows.spec.ts +++ b/tests/integration/vault-workflows.spec.ts @@ -33,14 +33,14 @@ async function openNote(page: import('@playwright/test').Page, title: string) { await page.waitForTimeout(300) } -/** Helper: rename the active note through the stable TitleField. */ -async function renameActiveNote(page: import('@playwright/test').Page, nextTitle: string) { - const titleInput = page.getByTestId('title-field-input') - await expect(titleInput).toBeVisible({ timeout: 5_000 }) - await titleInput.click() - await titleInput.fill(nextTitle) - await titleInput.press('Enter') - await expect(titleInput).toHaveValue(nextTitle, { timeout: 5_000 }) +/** Helper: rename the active note filename through the breadcrumb control. */ +async function renameActiveNoteFilename(page: import('@playwright/test').Page, nextFilename: string) { + await page.getByTestId('breadcrumb-filename-trigger').dblclick() + const filenameInput = page.getByTestId('breadcrumb-filename-input') + await expect(filenameInput).toBeVisible({ timeout: 5_000 }) + await filenameInput.fill(nextFilename) + await filenameInput.press('Enter') + await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(nextFilename, { timeout: 5_000 }) } // --------------------------------------------------------------------------- @@ -123,7 +123,7 @@ test('rename note updates filename on disk', async ({ page }) => { // Open Note B await openNote(page, 'Note B') - await renameActiveNote(page, 'Note B Renamed') + await renameActiveNoteFilename(page, 'note-b-renamed') // Verify filesystem: old file gone, new file exists const oldPath = path.join(tempVaultDir, 'note', 'note-b.md') @@ -135,7 +135,7 @@ test('rename note updates filename on disk', async ({ page }) => { }).toPass({ timeout: 5_000 }) const newContent = fs.readFileSync(newPath, 'utf-8') - expect(newContent).toContain('# Note B Renamed') + expect(newContent).toContain('# Note B') }) // --------------------------------------------------------------------------- @@ -145,7 +145,7 @@ test('rename note updates filename on disk', async ({ page }) => { test('rename note updates wikilinks in other files', async ({ page }) => { // Open Note B and rename it await openNote(page, 'Note B') - await renameActiveNote(page, 'Note B Updated') + await renameActiveNoteFilename(page, 'note-b-updated') // Wait for rename to complete (file to be moved) const newPath = path.join(tempVaultDir, 'note', 'note-b-updated.md') @@ -153,9 +153,10 @@ test('rename note updates wikilinks in other files', async ({ page }) => { expect(fs.existsSync(newPath)).toBe(true) }).toPass({ timeout: 5_000 }) - // Verify alpha-project.md now references [[Note B Updated]] instead of [[Note B]] + // Verify alpha-project.md now references the canonical path target. const alphaContent = fs.readFileSync(path.join(tempVaultDir, 'project', 'alpha-project.md'), 'utf-8') - expect(alphaContent).toContain('[[Note B Updated]]') + expect(alphaContent).toContain('[[note/note-b-updated]]') + expect(alphaContent).not.toContain('[[note/note-b]]') expect(alphaContent).not.toContain('[[Note B]]') }) diff --git a/vite.config.ts b/vite.config.ts index 57586c9f..6ff3ed4b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -210,18 +210,30 @@ function readExistingQueryPath(url: URL, res: ServerResponse, key: string): stri return filePath } -function updateTitleWikilinks(vaultPath: string, oldTitle: string, newTitle: string, excludePath: string): number { - if (!oldTitle) return 0 +function updateTitleWikilinks(vaultPath: string, oldTitle: string, _newTitle: string, excludePath: string): number { + const newPathStem = path.relative(vaultPath, excludePath).replace(/\.md$/i, '') + const oldTargets = collectLegacyWikilinkTargets(oldTitle, excludePath, vaultPath) + return updateWikilinksForTargets(vaultPath, oldTargets, newPathStem, excludePath) +} + +function collectLegacyWikilinkTargets(oldTitle: string, oldPath: string, vaultPath: string): string[] { + const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '') + const oldFilenameStem = path.basename(oldPath, '.md') + return [...new Set([oldTitle, oldRelativeStem, oldFilenameStem].filter(Boolean))] +} + +function updateWikilinksForTargets(vaultPath: string, oldTargets: string[], newTarget: string, excludePath: string): number { + if (oldTargets.length === 0) return 0 const allFiles = findMarkdownFiles(vaultPath) - const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g') + const escaped = oldTargets.map(target => target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + const pattern = new RegExp(`\\[\\[(?:${escaped.join('|')})(\\|[^\\]]*?)?\\]\\]`, 'g') let updatedFiles = 0 for (const filePath of allFiles) { if (filePath === excludePath) continue try { const content = fs.readFileSync(filePath, 'utf-8') const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) => - pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]` + pipe ? `[[${newTarget}${pipe}]]` : `[[${newTarget}]]` ) if (replaced !== content) { fs.writeFileSync(filePath, replaced, 'utf-8') @@ -234,28 +246,10 @@ function updateTitleWikilinks(vaultPath: string, oldTitle: string, newTitle: str return updatedFiles } -function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string): number { - const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '') +function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string, oldTitle: string): number { const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '') - const escaped = oldRelativeStem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g') - let updatedFiles = 0 - for (const filePath of findMarkdownFiles(vaultPath)) { - if (filePath === newPath) continue - try { - const content = fs.readFileSync(filePath, 'utf-8') - const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) => - pipe ? `[[${newRelativeStem}${pipe}]]` : `[[${newRelativeStem}]]` - ) - if (replaced !== content) { - fs.writeFileSync(filePath, replaced, 'utf-8') - updatedFiles++ - } - } catch { - // Skip unreadable files in the dev vault API. - } - } - return updatedFiles + const oldTargets = collectLegacyWikilinkTargets(oldTitle, oldPath, vaultPath) + return updateWikilinksForTargets(vaultPath, oldTargets, newRelativeStem, newPath) } function handleVaultPing(url: URL, res: ServerResponse): boolean { @@ -388,13 +382,14 @@ async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: Se } const newPath = path.join(path.dirname(oldPath), `${trimmed}.md`) + const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md') if (newPath !== oldPath && fs.existsSync(newPath)) { sendJson(res, { error: 'A note with that name already exists' }, 409) return true } fs.renameSync(oldPath, newPath) - const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath) : 0 + const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath, oldTitle) : 0 sendJson(res, { new_path: newPath, updated_files: updatedFiles }) } catch (err: unknown) { sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500)