fix: update rename filename wikilinks
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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]]')
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user