fix: update rename filename wikilinks

This commit is contained in:
lucaronin
2026-04-10 20:20:08 +02:00
parent 98d19e4c41
commit db4359981f
3 changed files with 74 additions and 40 deletions

View File

@@ -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<string, (args: any) => 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' },

View File

@@ -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]]')
})

View File

@@ -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)