diff --git a/src/main/storage/providers/markdown/notes/runtime/backlinks.ts b/src/main/storage/providers/markdown/notes/runtime/backlinks.ts index 860f8010..25d77b7f 100644 --- a/src/main/storage/providers/markdown/notes/runtime/backlinks.ts +++ b/src/main/storage/providers/markdown/notes/runtime/backlinks.ts @@ -1,45 +1,93 @@ import type { InternalLinkLookupItem, - InternalLinkMatch, + InternalLinkType, } from '../../../../../../shared/notes/internalLinks' import type { MarkdownNote, NotesPaths, NotesState } from './types' import { normalizeInternalLinkLookupKey, resolveInternalLinkTargetByTitle, - rewriteInternalLinkTarget, + rewriteInternalLinks, } from '../../../../../../shared/notes/internalLinks' import { getPaths, getVaultPath } from '../../runtime/paths' import { getRuntimeCache } from '../../runtime/sync' import { writeNoteToFile } from './notes' +import { buildNotesFolderPathMap } from './paths' import { invalidateNotesSearchIndex } from './search' -interface RewriteBacklinksAfterRenameInput { +interface RewriteBacklinksAfterNoteUpdateInput { paths: NotesPaths state: NotesState notes: MarkdownNote[] - renamedNoteId: number + updatedNoteId: number previousName: string nextName: string + previousFolderId: number | null + nextFolderId: number | null } -export function rewriteBacklinksAfterNoteRename( - input: RewriteBacklinksAfterRenameInput, -): number { - const { paths, state, notes, renamedNoteId, previousName, nextName } = input +interface PromoteBareBacklinksOnConflictInput { + paths: NotesPaths + state: NotesState + notes: MarkdownNote[] + preLookup: InternalLinkLookupItem[] + postLookup: InternalLinkLookupItem[] + conflictNoteIds: number[] +} - const previousKey = normalizeInternalLinkLookupKey(previousName) - if (!previousKey) { - return 0 - } +interface RewriteBacklinksAfterFolderUpdateInput { + paths: NotesPaths + state: NotesState + notes: MarkdownNote[] + oldFolderPathMap: Map + newFolderPathMap: Map +} - if (previousKey === normalizeInternalLinkLookupKey(nextName)) { - return 0 +interface ShortestUniqueLookupItem { + id: number + type: InternalLinkType + name: string + folderPath: string +} + +function pickShortestUniqueLinkTarget( + selected: ShortestUniqueLookupItem, + candidates: ShortestUniqueLookupItem[], +): string { + const selectedKey = normalizeInternalLinkLookupKey(selected.name) + const hasCollision = candidates.some( + candidate => + !(candidate.id === selected.id && candidate.type === selected.type) + && normalizeInternalLinkLookupKey(candidate.name) === selectedKey, + ) + + if (!hasCollision || !selected.folderPath) { + return selected.name } - let snippetLookup: InternalLinkLookupItem[] = [] + return `${selected.folderPath}/${selected.name}` +} + +function buildNoteLookupFromState( + notes: MarkdownNote[], + state: NotesState, +): InternalLinkLookupItem[] { + const folderPathMap = buildNotesFolderPathMap(state) + + return notes + .filter(note => note.isDeleted === 0) + .map(note => ({ + folderPath: + note.folderId === null ? '' : (folderPathMap.get(note.folderId) ?? ''), + id: note.id, + name: note.name, + type: 'note' as const, + })) +} + +function loadSnippetLookup(): InternalLinkLookupItem[] { try { const markdownCache = getRuntimeCache(getPaths(getVaultPath())) - snippetLookup = markdownCache.snippets + return markdownCache.snippets .filter(snippet => snippet.isDeleted === 0) .map(snippet => ({ id: snippet.id, @@ -48,50 +96,126 @@ export function rewriteBacklinksAfterNoteRename( })) } catch { - snippetLookup = [] + return [] } +} - if ( - snippetLookup.some( - snippet => normalizeInternalLinkLookupKey(snippet.name) === previousKey, - ) - ) { +export function rewriteBacklinksAfterNoteUpdate( + input: RewriteBacklinksAfterNoteUpdateInput, +): number { + const { + paths, + state, + notes, + updatedNoteId, + previousName, + nextName, + previousFolderId, + nextFolderId, + } = input + + const previousKey = normalizeInternalLinkLookupKey(previousName) + if (!previousKey) { return 0 } - const noteLookup: InternalLinkLookupItem[] = notes - .filter(note => note.isDeleted === 0) - .map(note => ({ - id: note.id, - name: note.id === renamedNoteId ? previousName : note.name, - type: 'note' as const, - })) + const nameChanged = previousKey !== normalizeInternalLinkLookupKey(nextName) + const folderChanged = previousFolderId !== nextFolderId + if (!nameChanged && !folderChanged) { + return 0 + } - const lookup = [...snippetLookup, ...noteLookup] + const snippetLookup = loadSnippetLookup() + const folderPathMap = buildNotesFolderPathMap(state) - const shouldRewriteMatch = (match: InternalLinkMatch): boolean => { - const resolved = resolveInternalLinkTargetByTitle(match.target, lookup) - return ( - resolved !== null - && resolved.type === 'note' - && resolved.id === renamedNoteId - ) - } + const resolveFolderPath = (folderId: number | null): string => + folderId === null ? '' : (folderPathMap.get(folderId) ?? '') + + const previousFolderPath = resolveFolderPath(previousFolderId) + const nextFolderPath = resolveFolderPath(nextFolderId) + + const buildNoteLookup = ( + appliedNameAndFolder: 'previous' | 'next', + ): InternalLinkLookupItem[] => + notes + .filter(note => note.isDeleted === 0) + .map((note) => { + if (note.id !== updatedNoteId) { + return { + folderPath: + note.folderId === null + ? '' + : (folderPathMap.get(note.folderId) ?? ''), + id: note.id, + name: note.name, + type: 'note' as const, + } + } + + return { + folderPath: + appliedNameAndFolder === 'previous' + ? previousFolderPath + : nextFolderPath, + id: note.id, + name: appliedNameAndFolder === 'previous' ? previousName : nextName, + type: 'note' as const, + } + }) + + const preLookup = [...snippetLookup, ...buildNoteLookup('previous')] + const postLookup = [...snippetLookup, ...buildNoteLookup('next')] + + const shortestUniqueCandidates: ShortestUniqueLookupItem[] = postLookup.map( + item => ({ + folderPath: item.folderPath ?? '', + id: item.id, + name: item.name, + type: item.type, + }), + ) + const updatedTarget = pickShortestUniqueLinkTarget( + { + folderPath: nextFolderPath, + id: updatedNoteId, + name: nextName, + type: 'note', + }, + shortestUniqueCandidates, + ) let rewrittenCount = 0 const now = Date.now() for (const note of notes) { - if (note.id === renamedNoteId || note.isDeleted || !note.content) { + if (note.id === updatedNoteId || note.isDeleted || !note.content) { continue } - const rewritten = rewriteInternalLinkTarget( - note.content, - previousName, - nextName, - shouldRewriteMatch, - ) + const linkerFolderPath + = note.folderId === null ? '' : (folderPathMap.get(note.folderId) ?? '') + + const rewritten = rewriteInternalLinks(note.content, (match) => { + if (match.legacyTarget) { + return null + } + + const resolved = resolveInternalLinkTargetByTitle( + match.target, + preLookup, + { linkerFolderPath }, + ) + if ( + resolved === null + || resolved.type !== 'note' + || resolved.id !== updatedNoteId + ) { + return null + } + + return updatedTarget + }) + if (rewritten === null) { continue } @@ -102,9 +226,305 @@ export function rewriteBacklinksAfterNoteRename( rewrittenCount++ } + if (nameChanged) { + const nextKey = normalizeInternalLinkLookupKey(nextName) + const otherConflictIds = postLookup + .filter( + item => + item.type === 'note' + && item.id !== updatedNoteId + && normalizeInternalLinkLookupKey(item.name) === nextKey, + ) + .map(item => item.id) + + if (otherConflictIds.length > 0) { + rewrittenCount += promoteBareBacklinksOnConflict({ + conflictNoteIds: otherConflictIds, + notes, + paths, + postLookup, + preLookup, + state, + }) + } + } + if (rewrittenCount > 0) { invalidateNotesSearchIndex(state) } return rewrittenCount } + +export function promoteBareBacklinksOnConflict( + input: PromoteBareBacklinksOnConflictInput, +): number { + const { paths, state, notes, preLookup, postLookup, conflictNoteIds } = input + + if (conflictNoteIds.length === 0) { + return 0 + } + + const folderPathMap = buildNotesFolderPathMap(state) + const noteFolderPath = (note: MarkdownNote): string => + note.folderId === null ? '' : (folderPathMap.get(note.folderId) ?? '') + + let total = 0 + const now = Date.now() + const rewrittenLinkers = new Set() + + for (const noteId of conflictNoteIds) { + const targetItem = postLookup.find( + item => item.type === 'note' && item.id === noteId, + ) + if (!targetItem || !targetItem.folderPath) { + continue + } + + const targetKey = normalizeInternalLinkLookupKey(targetItem.name) + const hasCollision = postLookup.some( + item => + !(item.id === noteId && item.type === 'note') + && normalizeInternalLinkLookupKey(item.name) === targetKey, + ) + if (!hasCollision) { + continue + } + + const newTarget = `${targetItem.folderPath}/${targetItem.name}` + + for (const linker of notes) { + if (linker.id === noteId || linker.isDeleted || !linker.content) { + continue + } + + const linkerFolderPath = noteFolderPath(linker) + const rewritten = rewriteInternalLinks(linker.content, (match) => { + if (match.legacyTarget) { + return null + } + if (match.pathSegments.length > 0) { + return null + } + if (normalizeInternalLinkLookupKey(match.basename) !== targetKey) { + return null + } + + const resolved = resolveInternalLinkTargetByTitle( + match.target, + preLookup, + { linkerFolderPath }, + ) + if ( + resolved === null + || resolved.type !== 'note' + || resolved.id !== noteId + ) { + return null + } + + return newTarget + }) + + if (rewritten === null) { + continue + } + + linker.content = rewritten + linker.updatedAt = now + writeNoteToFile(paths, linker) + if (!rewrittenLinkers.has(linker.id)) { + rewrittenLinkers.add(linker.id) + total++ + } + } + } + + if (total > 0) { + invalidateNotesSearchIndex(state) + } + + return total +} + +export function rewriteBacklinksAfterFolderUpdate( + input: RewriteBacklinksAfterFolderUpdateInput, +): number { + const { paths, state, notes, oldFolderPathMap, newFolderPathMap } = input + + const affectedFolderIds = new Set() + for (const folder of state.folders) { + if (oldFolderPathMap.get(folder.id) !== newFolderPathMap.get(folder.id)) { + affectedFolderIds.add(folder.id) + } + } + + if (affectedFolderIds.size === 0) { + return 0 + } + + const affectedNoteIds = new Set() + for (const note of notes) { + if ( + note.isDeleted === 0 + && note.folderId !== null + && affectedFolderIds.has(note.folderId) + ) { + affectedNoteIds.add(note.id) + } + } + + if (affectedNoteIds.size === 0) { + return 0 + } + + const snippetLookup = loadSnippetLookup() + + const buildLookup = ( + folderPathMap: Map, + ): InternalLinkLookupItem[] => [ + ...snippetLookup, + ...notes + .filter(note => note.isDeleted === 0) + .map(note => ({ + folderPath: + note.folderId === null + ? '' + : (folderPathMap.get(note.folderId) ?? ''), + id: note.id, + name: note.name, + type: 'note' as const, + })), + ] + + const preLookup = buildLookup(oldFolderPathMap) + const postLookup = buildLookup(newFolderPathMap) + + const shortestUniqueCandidates: ShortestUniqueLookupItem[] = postLookup.map( + item => ({ + folderPath: item.folderPath ?? '', + id: item.id, + name: item.name, + type: item.type, + }), + ) + + let rewrittenCount = 0 + const now = Date.now() + + for (const linker of notes) { + if (linker.isDeleted || !linker.content) { + continue + } + + const oldLinkerFolderPath + = linker.folderId === null + ? '' + : (oldFolderPathMap.get(linker.folderId) ?? '') + + const rewritten = rewriteInternalLinks(linker.content, (match) => { + if (match.legacyTarget) { + return null + } + if (match.pathSegments.length === 0) { + return null + } + + const resolved = resolveInternalLinkTargetByTitle( + match.target, + preLookup, + { linkerFolderPath: oldLinkerFolderPath }, + ) + if ( + resolved === null + || resolved.type !== 'note' + || !affectedNoteIds.has(resolved.id) + ) { + return null + } + + const targetItem = postLookup.find( + item => item.type === 'note' && item.id === resolved.id, + ) + if (!targetItem) { + return null + } + + const newTarget = pickShortestUniqueLinkTarget( + { + folderPath: targetItem.folderPath ?? '', + id: targetItem.id, + name: targetItem.name, + type: 'note', + }, + shortestUniqueCandidates, + ) + + if (newTarget === match.target) { + return null + } + + return newTarget + }) + + if (rewritten === null) { + continue + } + + linker.content = rewritten + linker.updatedAt = now + writeNoteToFile(paths, linker) + rewrittenCount++ + } + + if (rewrittenCount > 0) { + invalidateNotesSearchIndex(state) + } + + return rewrittenCount +} + +export function promoteBareBacklinksAfterNoteCreate(input: { + paths: NotesPaths + state: NotesState + notes: MarkdownNote[] + newNoteId: number +}): number { + const { paths, state, notes, newNoteId } = input + + const newNote = notes.find(note => note.id === newNoteId) + if (!newNote || newNote.isDeleted) { + return 0 + } + + const newNameKey = normalizeInternalLinkLookupKey(newNote.name) + const conflictNoteIds = notes + .filter( + note => + note.id !== newNoteId + && note.isDeleted === 0 + && normalizeInternalLinkLookupKey(note.name) === newNameKey, + ) + .map(note => note.id) + + if (conflictNoteIds.length === 0) { + return 0 + } + + const snippetLookup = loadSnippetLookup() + const noteLookup = buildNoteLookupFromState(notes, state) + const postLookup = [...snippetLookup, ...noteLookup] + const preLookup = [ + ...snippetLookup, + ...noteLookup.filter(item => item.id !== newNoteId), + ] + + return promoteBareBacklinksOnConflict({ + conflictNoteIds, + notes, + paths, + postLookup, + preLookup, + state, + }) +} diff --git a/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts b/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts index 28bd5aae..3eebc23c 100644 --- a/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts +++ b/src/main/storage/providers/markdown/notes/storages/__tests__/folders.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ensureNotesStateFile } from '../../runtime/state' import { resetNotesRuntimeCache } from '../../runtime/sync' import { createNotesFoldersStorage } from '../folders' +import { createNotesNotesStorage } from '../notes' let tempVaultPath = '' @@ -131,4 +132,85 @@ describe('folders storage validations', () => { 'NAME_CONFLICT', ) }) + + it('rewrites path-based backlinks when a folder is renamed', () => { + const folders = createNotesFoldersStorage() + const notes = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A' }) + const folderB = folders.createFolder({ name: 'Folder B' }) + + notes.createNote({ name: 'Foo', folderId: folderA.id }) + notes.createNote({ name: 'Foo', folderId: folderB.id }) + + const linker = notes.createNote({ name: 'Linker' }) + notes.updateNoteContent(linker.id, 'See [[Folder A/Foo]] here') + + folders.updateFolder(folderA.id, { name: 'Renamed' }) + + expect(notes.getNoteById(linker.id)?.content).toBe( + 'See [[Renamed/Foo]] here', + ) + }) + + it('rewrites path-based backlinks when a folder is moved into another', () => { + const folders = createNotesFoldersStorage() + const notes = createNotesNotesStorage() + + const root = folders.createFolder({ name: 'Root' }) + const folderA = folders.createFolder({ name: 'Folder A' }) + const folderB = folders.createFolder({ name: 'Folder B' }) + + notes.createNote({ name: 'Foo', folderId: folderA.id }) + notes.createNote({ name: 'Foo', folderId: folderB.id }) + + const linker = notes.createNote({ name: 'Linker' }) + notes.updateNoteContent(linker.id, 'See [[Folder A/Foo]] here') + + folders.updateFolder(folderA.id, { parentId: root.id }) + + expect(notes.getNoteById(linker.id)?.content).toBe( + 'See [[Root/Folder A/Foo]] here', + ) + }) + + it('cascades path rewrite when an ancestor folder is renamed', () => { + const folders = createNotesFoldersStorage() + const notes = createNotesNotesStorage() + + const projects = folders.createFolder({ name: 'Projects' }) + const child = folders.createFolder({ + name: 'Active', + parentId: projects.id, + }) + const other = folders.createFolder({ name: 'Other' }) + + notes.createNote({ name: 'Foo', folderId: child.id }) + notes.createNote({ name: 'Foo', folderId: other.id }) + + const linker = notes.createNote({ name: 'Linker' }) + notes.updateNoteContent(linker.id, 'See [[Projects/Active/Foo]] here') + + folders.updateFolder(projects.id, { name: 'Workspace' }) + + expect(notes.getNoteById(linker.id)?.content).toBe( + 'See [[Workspace/Active/Foo]] here', + ) + }) + + it('leaves bare backlinks unchanged when a folder is renamed', () => { + const folders = createNotesFoldersStorage() + const notes = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A' }) + + notes.createNote({ name: 'Foo', folderId: folderA.id }) + + const linker = notes.createNote({ name: 'Linker' }) + notes.updateNoteContent(linker.id, 'See [[Foo]] here') + + folders.updateFolder(folderA.id, { name: 'Renamed' }) + + expect(notes.getNoteById(linker.id)?.content).toBe('See [[Foo]] here') + }) }) diff --git a/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts b/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts index 6fe29ad6..1140ecad 100644 --- a/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts +++ b/src/main/storage/providers/markdown/notes/storages/__tests__/notes.test.ts @@ -326,4 +326,240 @@ describe('notes storage validations', () => { 'See [[Old Name]] for context', ) }) + + it('rewrites a path-based backlink when the target note is renamed', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folder = folders.createFolder({ name: 'Projects', parentId: null }) + const target = storage.createNote({ + name: 'Repository Pattern', + folderId: folder.id, + }) + const linker = storage.createNote({ name: 'Linker' }) + + storage.updateNoteContent( + linker.id, + 'See [[Projects/Repository Pattern]] here', + ) + + storage.updateNote(target.id, { name: 'Repository Cache' }) + + expect(storage.getNoteById(linker.id)?.content).toBe( + 'See [[Repository Cache]] here', + ) + }) + + it('writes path-based target when next name collides with another note', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + const folderB = folders.createFolder({ name: 'Folder B', parentId: null }) + + const target = storage.createNote({ + name: 'Foo', + folderId: folderA.id, + }) + storage.createNote({ name: 'Bar', folderId: folderB.id }) + + const linker = storage.createNote({ name: 'Linker' }) + storage.updateNoteContent(linker.id, 'See [[Foo]] for context') + + storage.updateNote(target.id, { name: 'Bar' }) + + expect(storage.getNoteById(linker.id)?.content).toBe( + 'See [[Folder A/Bar]] for context', + ) + }) + + it('rewrites a path-based backlink to a different path when folder differs', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + const folderB = folders.createFolder({ name: 'Folder B', parentId: null }) + + const target = storage.createNote({ + name: 'Foo', + folderId: folderA.id, + }) + storage.createNote({ name: 'Bar', folderId: folderB.id }) + + const linker = storage.createNote({ name: 'Linker' }) + storage.updateNoteContent(linker.id, 'See [[Folder A/Foo]] here') + + storage.updateNote(target.id, { name: 'Bar' }) + + expect(storage.getNoteById(linker.id)?.content).toBe( + 'See [[Folder A/Bar]] here', + ) + }) + + it('preserves alias when rewriting path-based backlinks', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + const folderB = folders.createFolder({ name: 'Folder B', parentId: null }) + + const target = storage.createNote({ name: 'Foo', folderId: folderA.id }) + storage.createNote({ name: 'Bar', folderId: folderB.id }) + + const linker = storage.createNote({ name: 'Linker' }) + storage.updateNoteContent(linker.id, 'See [[Foo|the foo]] for context') + + storage.updateNote(target.id, { name: 'Bar' }) + + expect(storage.getNoteById(linker.id)?.content).toBe( + 'See [[Folder A/Bar|the foo]] for context', + ) + }) + + it('rewrites path-based backlink when the target note is moved between folders', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + const folderB = folders.createFolder({ name: 'Folder B', parentId: null }) + + const target = storage.createNote({ name: 'Foo', folderId: folderA.id }) + storage.createNote({ name: 'Foo', folderId: folderB.id }) + + const linker = storage.createNote({ name: 'Linker' }) + storage.updateNoteContent(linker.id, 'See [[Folder A/Foo]] here') + + const folderC = folders.createFolder({ name: 'Folder C', parentId: null }) + storage.updateNote(target.id, { folderId: folderC.id }) + + expect(storage.getNoteById(linker.id)?.content).toBe( + 'See [[Folder C/Foo]] here', + ) + }) + + it('promotes a bare backlink to a path when the moved note loses uniqueness', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + const folderB = folders.createFolder({ name: 'Folder B', parentId: null }) + const folderC = folders.createFolder({ name: 'Folder C', parentId: null }) + + const target = storage.createNote({ name: 'Foo', folderId: folderA.id }) + storage.createNote({ name: 'Foo', folderId: folderB.id }) + + const linker = storage.createNote({ name: 'Linker', folderId: folderA.id }) + storage.updateNoteContent(linker.id, 'See [[Foo]] here') + + storage.updateNote(target.id, { folderId: folderC.id }) + + expect(storage.getNoteById(linker.id)?.content).toBe( + 'See [[Folder C/Foo]] here', + ) + }) + + it('skips backlink rewrite when neither name nor folder changes', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + const target = storage.createNote({ name: 'Foo', folderId: folderA.id }) + const linker = storage.createNote({ name: 'Linker' }) + storage.updateNoteContent(linker.id, 'See [[Folder A/Foo]] here') + + storage.updateNote(target.id, { isFavorites: 1 }) + + expect(storage.getNoteById(linker.id)?.content).toBe( + 'See [[Folder A/Foo]] here', + ) + }) + + it('handles simultaneous rename and move in a single update', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + const folderB = folders.createFolder({ name: 'Folder B', parentId: null }) + + const target = storage.createNote({ name: 'Foo', folderId: folderA.id }) + const linker = storage.createNote({ name: 'Linker' }) + storage.updateNoteContent(linker.id, 'See [[Folder A/Foo]] here') + + storage.updateNote(target.id, { folderId: folderB.id, name: 'Bar' }) + + expect(storage.getNoteById(linker.id)?.content).toBe('See [[Bar]] here') + }) + + it('promotes pre-existing note bare backlinks when a new colliding note is created', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + const folderB = folders.createFolder({ name: 'Folder B', parentId: null }) + + const existing = storage.createNote({ name: 'Foo', folderId: folderA.id }) + + const linker = storage.createNote({ name: 'Linker', folderId: folderA.id }) + storage.updateNoteContent(linker.id, 'See [[Foo]] here') + + storage.createNote({ name: 'Foo', folderId: folderB.id }) + + expect(storage.getNoteById(linker.id)?.content).toBe( + 'See [[Folder A/Foo]] here', + ) + expect(storage.getNoteById(existing.id)?.name).toBe('Foo') + }) + + it('promotes other same-named note bare backlinks when rename creates a collision', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + const folderB = folders.createFolder({ name: 'Folder B', parentId: null }) + + const renamed = storage.createNote({ name: 'Foo', folderId: folderA.id }) + storage.createNote({ name: 'Bar', folderId: folderB.id }) + + const otherLinker = storage.createNote({ + name: 'Linker', + folderId: folderB.id, + }) + storage.updateNoteContent(otherLinker.id, 'See [[Bar]] for context') + + storage.updateNote(renamed.id, { name: 'Bar' }) + + expect(storage.getNoteById(otherLinker.id)?.content).toBe( + 'See [[Folder B/Bar]] for context', + ) + }) + + it('does not promote bare backlinks when no collision is introduced', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderA = folders.createFolder({ name: 'Folder A', parentId: null }) + storage.createNote({ name: 'Foo', folderId: folderA.id }) + + const linker = storage.createNote({ name: 'Linker', folderId: folderA.id }) + storage.updateNoteContent(linker.id, 'See [[Foo]] here') + + storage.createNote({ name: 'Bar', folderId: folderA.id }) + + expect(storage.getNoteById(linker.id)?.content).toBe('See [[Foo]] here') + }) + + it('leaves bare backlinks unchanged when colliding note has no folder path', () => { + const folders = createNotesFoldersStorage() + const storage = createNotesNotesStorage() + + const folderB = folders.createFolder({ name: 'Folder B', parentId: null }) + storage.createNote({ name: 'Foo' }) + + const linker = storage.createNote({ name: 'Linker' }) + storage.updateNoteContent(linker.id, 'See [[Foo]] here') + + storage.createNote({ name: 'Foo', folderId: folderB.id }) + + expect(storage.getNoteById(linker.id)?.content).toBe('See [[Foo]] here') + }) }) diff --git a/src/main/storage/providers/markdown/notes/storages/folders.ts b/src/main/storage/providers/markdown/notes/storages/folders.ts index 8e5c8e06..e9793ac0 100644 --- a/src/main/storage/providers/markdown/notes/storages/folders.ts +++ b/src/main/storage/providers/markdown/notes/storages/folders.ts @@ -28,6 +28,7 @@ import { throwStorageError, validateEntryName, } from '../../runtime/validation' +import { rewriteBacklinksAfterFolderUpdate } from '../runtime/backlinks' import { getNotesPaths, META_DIR_NAME, @@ -239,6 +240,14 @@ export function createNotesFoldersStorage(): NotesFoldersStorage { } }, }) + + rewriteBacklinksAfterFolderUpdate({ + newFolderPathMap, + notes, + oldFolderPathMap, + paths, + state, + }) } } diff --git a/src/main/storage/providers/markdown/notes/storages/notes.ts b/src/main/storage/providers/markdown/notes/storages/notes.ts index e9f68442..612515e3 100644 --- a/src/main/storage/providers/markdown/notes/storages/notes.ts +++ b/src/main/storage/providers/markdown/notes/storages/notes.ts @@ -28,7 +28,10 @@ import { throwStorageError, validateEntryName, } from '../../runtime/validation' -import { rewriteBacklinksAfterNoteRename } from '../runtime/backlinks' +import { + promoteBareBacklinksAfterNoteCreate, + rewriteBacklinksAfterNoteUpdate, +} from '../runtime/backlinks' import { getNotesPaths } from '../runtime/constants' import { findNoteById, persistNote, writeNoteToFile } from '../runtime/notes' import { findNotesFolderById } from '../runtime/paths' @@ -158,6 +161,13 @@ export function createNotesNotesStorage(): NotesStorage { }), }) + promoteBareBacklinksAfterNoteCreate({ + newNoteId: result.id, + notes, + paths, + state, + }) + saveNotesState(paths, state) return result @@ -218,14 +228,16 @@ export function createNotesNotesStorage(): NotesStorage { writeNoteToFile(paths, note) } - if (note.name !== previousName) { - rewriteBacklinksAfterNoteRename({ - notes, + if (note.name !== previousName || note.folderId !== previousFolderId) { + rewriteBacklinksAfterNoteUpdate({ + nextFolderId: note.folderId, nextName: note.name, + notes, paths, + previousFolderId, previousName, - renamedNoteId: note.id, state, + updatedNoteId: note.id, }) } diff --git a/src/renderer/components/notes/cm-extensions/internalLinks/__tests__/parser.test.ts b/src/renderer/components/notes/cm-extensions/internalLinks/__tests__/parser.test.ts index 7e56184c..d506da89 100644 --- a/src/renderer/components/notes/cm-extensions/internalLinks/__tests__/parser.test.ts +++ b/src/renderer/components/notes/cm-extensions/internalLinks/__tests__/parser.test.ts @@ -10,8 +10,10 @@ describe('parseInternalLink', () => { it('parses a link without alias', () => { expect(parseInternalLink('[[Repository Pattern with Cache]]')).toEqual({ alias: null, + basename: 'Repository Pattern with Cache', legacyTarget: null, label: 'Repository Pattern with Cache', + pathSegments: [], raw: '[[Repository Pattern with Cache]]', target: 'Repository Pattern with Cache', }) @@ -22,8 +24,10 @@ describe('parseInternalLink', () => { parseInternalLink('[[Repository Pattern with Cache|Repo Pattern]]'), ).toEqual({ alias: 'Repo Pattern', + basename: 'Repository Pattern with Cache', legacyTarget: null, label: 'Repo Pattern', + pathSegments: [], raw: '[[Repository Pattern with Cache|Repo Pattern]]', target: 'Repository Pattern with Cache', }) @@ -38,8 +42,10 @@ describe('parseInternalLink', () => { it('handles escaped characters in the target and alias', () => { expect(parseInternalLink('[[Array \\] draft|foo \\| bar]]')).toEqual({ alias: 'foo | bar', + basename: 'Array ] draft', legacyTarget: null, label: 'foo | bar', + pathSegments: [], raw: '[[Array \\] draft|foo \\| bar]]', target: 'Array ] draft', }) @@ -48,8 +54,10 @@ describe('parseInternalLink', () => { it('handles escaped backslashes', () => { expect(parseInternalLink('[[path\\\\file|Alias\\\\Text]]')).toEqual({ alias: 'Alias\\Text', + basename: 'path\\file', legacyTarget: null, label: 'Alias\\Text', + pathSegments: [], raw: '[[path\\\\file|Alias\\\\Text]]', target: 'path\\file', }) @@ -60,8 +68,10 @@ describe('parseInternalLink', () => { parseInternalLink('[[snippet:57|Repository Pattern with Cache]]'), ).toEqual({ alias: 'Repository Pattern with Cache', + basename: 'snippet:57', legacyTarget: { id: 57, type: 'snippet' }, label: 'Repository Pattern with Cache', + pathSegments: [], raw: '[[snippet:57|Repository Pattern with Cache]]', target: 'snippet:57', }) diff --git a/src/renderer/components/notes/cm-extensions/internalLinks/__tests__/trigger.test.ts b/src/renderer/components/notes/cm-extensions/internalLinks/__tests__/trigger.test.ts index 31021b86..2aac1d12 100644 --- a/src/renderer/components/notes/cm-extensions/internalLinks/__tests__/trigger.test.ts +++ b/src/renderer/components/notes/cm-extensions/internalLinks/__tests__/trigger.test.ts @@ -9,6 +9,7 @@ import { handleInternalLinksPickerKey, internalLinksPickerState, isInternalLinkPickerEnabled, + pickShortestUniqueInsertTarget, shouldOpenInternalLinksPicker, } from '../trigger' @@ -245,7 +246,7 @@ describe('buildInternalLinkInsertChange', () => { expect( buildInternalLinkInsertChange( { anchor: 16, from: 10, query: 'Repo', to: 16 }, - { type: 'note', id: 15, label: 'Repository Pattern with Cache' }, + 'Repository Pattern with Cache', ), ).toEqual({ from: 10, @@ -254,3 +255,120 @@ describe('buildInternalLinkInsertChange', () => { }) }) }) + +describe('pickShortestUniqueInsertTarget', () => { + it('returns bare name for snippets regardless of duplicates', () => { + const selected = { + folderPath: 'A', + id: 1, + locationLabel: 'A', + name: 'Shared', + type: 'snippet' as const, + } + const items = [ + selected, + { + folderPath: 'B', + id: 2, + locationLabel: 'B', + name: 'Shared', + type: 'note' as const, + }, + ] + + expect(pickShortestUniqueInsertTarget(selected, items)).toBe('Shared') + }) + + it('returns bare name when no other note shares the same name', () => { + const selected = { + folderPath: 'Projects', + id: 1, + locationLabel: 'Projects', + name: 'Repository Pattern', + type: 'note' as const, + } + const items = [ + selected, + { + folderPath: 'Other', + id: 2, + locationLabel: 'Other', + name: 'Other Note', + type: 'note' as const, + }, + ] + + expect(pickShortestUniqueInsertTarget(selected, items)).toBe( + 'Repository Pattern', + ) + }) + + it('returns full folder path when another note shares the same name', () => { + const selected = { + folderPath: 'Projects/Active', + id: 1, + locationLabel: 'Active', + name: 'Repository Pattern', + type: 'note' as const, + } + const items = [ + selected, + { + folderPath: 'Archive', + id: 2, + locationLabel: 'Archive', + name: 'Repository Pattern', + type: 'note' as const, + }, + ] + + expect(pickShortestUniqueInsertTarget(selected, items)).toBe( + 'Projects/Active/Repository Pattern', + ) + }) + + it('matches duplicate names case-insensitively', () => { + const selected = { + folderPath: 'Projects', + id: 1, + locationLabel: 'Projects', + name: 'Repository Pattern', + type: 'note' as const, + } + const items = [ + selected, + { + folderPath: 'Archive', + id: 2, + locationLabel: 'Archive', + name: 'repository pattern', + type: 'note' as const, + }, + ] + + expect(pickShortestUniqueInsertTarget(selected, items)).toBe( + 'Projects/Repository Pattern', + ) + }) + + it('returns bare name for a root-level note even with duplicates', () => { + const selected = { + id: 1, + locationLabel: 'inbox', + name: 'Shared', + type: 'note' as const, + } + const items = [ + selected, + { + folderPath: 'Folder', + id: 2, + locationLabel: 'Folder', + name: 'Shared', + type: 'note' as const, + }, + ] + + expect(pickShortestUniqueInsertTarget(selected, items)).toBe('Shared') + }) +}) diff --git a/src/renderer/components/notes/cm-extensions/internalLinks/decorations.ts b/src/renderer/components/notes/cm-extensions/internalLinks/decorations.ts index b44f4716..660a842a 100644 --- a/src/renderer/components/notes/cm-extensions/internalLinks/decorations.ts +++ b/src/renderer/components/notes/cm-extensions/internalLinks/decorations.ts @@ -10,10 +10,12 @@ import { api } from '@/services/api' import { StateEffect } from '@codemirror/state' import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view' import { entityCache } from './cache' +import { buildNoteFolderPathMap } from './folderPath' import { findInternalLinks, normalizeInternalLinkLookupKey, resolveInternalLinkTargetByTitle, + splitInternalLinkTarget, } from './parser' type InternalLinksMode = 'raw' | 'livePreview' | 'preview' @@ -131,7 +133,7 @@ class InternalLinkWidget extends WidgetType { const label = document.createElement('span') label.className = 'cm-internal-link__label' - label.textContent = this.link.label + label.textContent = this.link.alias ?? this.link.basename root.append(label) if (this.status === 'broken') { @@ -215,10 +217,19 @@ async function resolveInternalLinkByTitle( title: string, ): Promise { try { - const [{ data: snippets }, { data: notes }] = await Promise.all([ - api.snippets.getSnippets({ search: title, isDeleted: 0 }), - api.notes.getNotes({ search: title, isDeleted: 0 }), - ]) + const { basename } = splitInternalLinkTarget(title) + if (!basename) { + return { exists: false } + } + + const [{ data: snippets }, { data: notes }, { data: noteFolders }] + = await Promise.all([ + api.snippets.getSnippets({ search: basename, isDeleted: 0 }), + api.notes.getNotes({ search: basename, isDeleted: 0 }), + api.noteFolders.getNoteFolders(), + ]) + + const folderPathById = buildNoteFolderPathMap(noteFolders) const resolvedTarget = resolveInternalLinkTargetByTitle(title, [ ...snippets.map(snippet => ({ @@ -227,6 +238,9 @@ async function resolveInternalLinkByTitle( type: 'snippet', })), ...notes.map(note => ({ + folderPath: note.folder + ? folderPathById.get(note.folder.id) + : undefined, id: note.id, name: note.name, type: 'note', diff --git a/src/renderer/components/notes/cm-extensions/internalLinks/folderPath.ts b/src/renderer/components/notes/cm-extensions/internalLinks/folderPath.ts new file mode 100644 index 00000000..5c6c0767 --- /dev/null +++ b/src/renderer/components/notes/cm-extensions/internalLinks/folderPath.ts @@ -0,0 +1,44 @@ +interface FolderLike { + id: number + name: string + parentId: number | null +} + +export function buildNoteFolderPathMap( + folders: FolderLike[], +): Map { + const folderById = new Map() + folders.forEach(folder => folderById.set(folder.id, folder)) + + const resolved = new Map() + const visiting = new Set() + + const resolve = (folderId: number): string => { + const cached = resolved.get(folderId) + if (cached !== undefined) { + return cached + } + + const folder = folderById.get(folderId) + if (!folder) { + return '' + } + + if (visiting.has(folderId)) { + return folder.name + } + + visiting.add(folderId) + const parentPath = folder.parentId !== null ? resolve(folder.parentId) : '' + const currentPath = parentPath + ? `${parentPath}/${folder.name}` + : folder.name + + resolved.set(folderId, currentPath) + visiting.delete(folderId) + return currentPath + } + + folders.forEach(folder => resolve(folder.id)) + return resolved +} diff --git a/src/renderer/components/notes/cm-extensions/internalLinks/parser.ts b/src/renderer/components/notes/cm-extensions/internalLinks/parser.ts index 1094d778..8f2279cf 100644 --- a/src/renderer/components/notes/cm-extensions/internalLinks/parser.ts +++ b/src/renderer/components/notes/cm-extensions/internalLinks/parser.ts @@ -5,6 +5,7 @@ export { normalizeInternalLinkLookupKey, parseInternalLink, resolveInternalLinkTargetByTitle, + splitInternalLinkTarget, } from '../../../../../shared/notes/internalLinks' export type { diff --git a/src/renderer/components/notes/cm-extensions/internalLinks/trigger.ts b/src/renderer/components/notes/cm-extensions/internalLinks/trigger.ts index d3bfd804..8de550b9 100644 --- a/src/renderer/components/notes/cm-extensions/internalLinks/trigger.ts +++ b/src/renderer/components/notes/cm-extensions/internalLinks/trigger.ts @@ -5,7 +5,12 @@ import { api } from '@/services/api' import { Prec } from '@codemirror/state' import { keymap, ViewPlugin } from '@codemirror/view' import { reactive, shallowRef } from 'vue' -import { buildLinkMarkdown, parseInternalLink } from './parser' +import { buildNoteFolderPathMap } from './folderPath' +import { + buildLinkMarkdown, + normalizeInternalLinkLookupKey, + parseInternalLink, +} from './parser' type InternalLinksMode = 'raw' | 'livePreview' | 'preview' @@ -24,6 +29,7 @@ export interface InternalLinkPickerItem { name: string type: InternalLinkType locationLabel: string + folderPath?: string } interface InternalLinkTriggerOptions { @@ -177,17 +183,36 @@ export function getInternalLinkTokenState( } } +export function pickShortestUniqueInsertTarget( + selected: InternalLinkPickerItem, + items: InternalLinkPickerItem[], +): string { + if (selected.type !== 'note') { + return selected.name + } + + const selectedKey = normalizeInternalLinkLookupKey(selected.name) + const hasNameCollision = items.some( + candidate => + candidate !== selected + && candidate.type === 'note' + && normalizeInternalLinkLookupKey(candidate.name) === selectedKey, + ) + + if (!hasNameCollision || !selected.folderPath) { + return selected.name + } + + return `${selected.folderPath}/${selected.name}` +} + export function buildInternalLinkInsertChange( range: InternalLinkSearchMatch, - item: { - id: number - label: string - type: InternalLinkType - }, + target: string, ) { return { from: range.from, - insert: buildLinkMarkdown(item.label), + insert: buildLinkMarkdown(target), to: range.to, } } @@ -205,10 +230,14 @@ function getLocationLabel(entity: SearchableEntity): string { } async function searchItems(query: string): Promise { - const [{ data: snippets }, { data: notes }] = await Promise.all([ - api.snippets.getSnippets({ search: query, isDeleted: 0 }), - api.notes.getNotes({ search: query, isDeleted: 0 }), - ]) + const [{ data: snippets }, { data: notes }, { data: noteFolders }] + = await Promise.all([ + api.snippets.getSnippets({ search: query, isDeleted: 0 }), + api.notes.getNotes({ search: query, isDeleted: 0 }), + api.noteFolders.getNoteFolders(), + ]) + + const folderPathById = buildNoteFolderPathMap(noteFolders) return [ ...snippets.map(snippet => ({ @@ -218,6 +247,7 @@ async function searchItems(query: string): Promise { type: 'snippet' as const, })), ...notes.map(note => ({ + folderPath: note.folder ? folderPathById.get(note.folder.id) : undefined, id: note.id, locationLabel: getLocationLabel(note), name: note.name, @@ -354,11 +384,11 @@ export function selectInternalLinksPickerItem(index?: number) { return } - const change = buildInternalLinkInsertChange(range, { - id: item.id, - label: item.name, - type: item.type, - }) + const target = pickShortestUniqueInsertTarget( + item, + internalLinksPickerState.items, + ) + const change = buildInternalLinkInsertChange(range, target) view.dispatch({ changes: change, diff --git a/src/shared/notes/__tests__/internalLinks.test.ts b/src/shared/notes/__tests__/internalLinks.test.ts index 9e22e94c..2b62a091 100644 --- a/src/shared/notes/__tests__/internalLinks.test.ts +++ b/src/shared/notes/__tests__/internalLinks.test.ts @@ -6,14 +6,17 @@ import { parseInternalLink, resolveInternalLinkTargetByTitle, rewriteInternalLinkTarget, + splitInternalLinkTarget, } from '../internalLinks' describe('parseInternalLink', () => { it('parses a link without alias', () => { expect(parseInternalLink('[[Repository Pattern with Cache]]')).toEqual({ alias: null, + basename: 'Repository Pattern with Cache', legacyTarget: null, label: 'Repository Pattern with Cache', + pathSegments: [], raw: '[[Repository Pattern with Cache]]', target: 'Repository Pattern with Cache', }) @@ -24,13 +27,51 @@ describe('parseInternalLink', () => { parseInternalLink('[[Repository Pattern with Cache|Repo Pattern]]'), ).toEqual({ alias: 'Repo Pattern', + basename: 'Repository Pattern with Cache', legacyTarget: null, label: 'Repo Pattern', + pathSegments: [], raw: '[[Repository Pattern with Cache|Repo Pattern]]', target: 'Repository Pattern with Cache', }) }) + it('parses a link with a folder path', () => { + expect(parseInternalLink('[[Projects/Repository Pattern]]')).toEqual({ + alias: null, + basename: 'Repository Pattern', + legacyTarget: null, + label: 'Projects/Repository Pattern', + pathSegments: ['Projects'], + raw: '[[Projects/Repository Pattern]]', + target: 'Projects/Repository Pattern', + }) + }) + + it('parses a link with a nested folder path', () => { + expect(parseInternalLink('[[Work/Projects/Repository Pattern]]')).toEqual({ + alias: null, + basename: 'Repository Pattern', + legacyTarget: null, + label: 'Work/Projects/Repository Pattern', + pathSegments: ['Work', 'Projects'], + raw: '[[Work/Projects/Repository Pattern]]', + target: 'Work/Projects/Repository Pattern', + }) + }) + + it('parses a path-based link with alias', () => { + expect(parseInternalLink('[[Projects/Repository Pattern|Repo]]')).toEqual({ + alias: 'Repo', + basename: 'Repository Pattern', + legacyTarget: null, + label: 'Repo', + pathSegments: ['Projects'], + raw: '[[Projects/Repository Pattern|Repo]]', + target: 'Projects/Repository Pattern', + }) + }) + it('returns null for invalid input', () => { expect(parseInternalLink('[[]]')).toBeNull() expect(parseInternalLink('[[|Alias]]')).toBeNull() @@ -40,8 +81,10 @@ describe('parseInternalLink', () => { it('handles escaped characters in the target and alias', () => { expect(parseInternalLink('[[Array \\] draft|foo \\| bar]]')).toEqual({ alias: 'foo | bar', + basename: 'Array ] draft', legacyTarget: null, label: 'foo | bar', + pathSegments: [], raw: '[[Array \\] draft|foo \\| bar]]', target: 'Array ] draft', }) @@ -50,8 +93,10 @@ describe('parseInternalLink', () => { it('handles escaped backslashes', () => { expect(parseInternalLink('[[path\\\\file|Alias\\\\Text]]')).toEqual({ alias: 'Alias\\Text', + basename: 'path\\file', legacyTarget: null, label: 'Alias\\Text', + pathSegments: [], raw: '[[path\\\\file|Alias\\\\Text]]', target: 'path\\file', }) @@ -62,14 +107,64 @@ describe('parseInternalLink', () => { parseInternalLink('[[snippet:57|Repository Pattern with Cache]]'), ).toEqual({ alias: 'Repository Pattern with Cache', + basename: 'snippet:57', legacyTarget: { id: 57, type: 'snippet' }, label: 'Repository Pattern with Cache', + pathSegments: [], raw: '[[snippet:57|Repository Pattern with Cache]]', target: 'snippet:57', }) }) }) +describe('splitInternalLinkTarget', () => { + it('treats a target without slashes as a bare basename', () => { + expect(splitInternalLinkTarget('My Note')).toEqual({ + basename: 'My Note', + pathSegments: [], + }) + }) + + it('extracts the trailing segment as the basename', () => { + expect(splitInternalLinkTarget('Projects/My Note')).toEqual({ + basename: 'My Note', + pathSegments: ['Projects'], + }) + }) + + it('handles deeply nested paths', () => { + expect(splitInternalLinkTarget('Work/2026/Q2/Plans')).toEqual({ + basename: 'Plans', + pathSegments: ['Work', '2026', 'Q2'], + }) + }) + + it('strips leading slashes and empty segments', () => { + expect(splitInternalLinkTarget('/Projects//My Note')).toEqual({ + basename: 'My Note', + pathSegments: ['Projects'], + }) + }) + + it('does not split legacy id-based targets', () => { + expect(splitInternalLinkTarget('snippet:57')).toEqual({ + basename: 'snippet:57', + pathSegments: [], + }) + expect(splitInternalLinkTarget('note:42')).toEqual({ + basename: 'note:42', + pathSegments: [], + }) + }) + + it('returns empty fields for trailing-slash-only targets', () => { + expect(splitInternalLinkTarget('Projects/')).toEqual({ + basename: 'Projects', + pathSegments: [], + }) + }) +}) + describe('escapeLinkPart', () => { it('escapes backslashes', () => { expect(escapeLinkPart('path\\file')).toBe('path\\\\file') @@ -251,4 +346,108 @@ describe('resolveInternalLinkTargetByTitle', () => { ]), ).toBeNull() }) + + it('resolves a path-based target to the matching note in that folder', () => { + expect( + resolveInternalLinkTargetByTitle('Projects/Architecture', [ + { folderPath: '', id: 1, name: 'Architecture', type: 'note' }, + { folderPath: 'Projects', id: 2, name: 'Architecture', type: 'note' }, + { folderPath: 'Other', id: 3, name: 'Architecture', type: 'note' }, + ]), + ).toEqual({ id: 2, type: 'note' }) + }) + + it('resolves a nested path-based target', () => { + expect( + resolveInternalLinkTargetByTitle('Work/2026/Plans', [ + { folderPath: 'Work', id: 1, name: 'Plans', type: 'note' }, + { folderPath: 'Work/2026', id: 2, name: 'Plans', type: 'note' }, + ]), + ).toEqual({ id: 2, type: 'note' }) + }) + + it('matches path-based targets case-insensitively', () => { + expect( + resolveInternalLinkTargetByTitle('projects/architecture', [ + { folderPath: 'Projects', id: 1, name: 'Architecture', type: 'note' }, + ]), + ).toEqual({ id: 1, type: 'note' }) + }) + + it('returns null when path-based target has no matching note', () => { + expect( + resolveInternalLinkTargetByTitle('Missing/Architecture', [ + { folderPath: 'Projects', id: 1, name: 'Architecture', type: 'note' }, + ]), + ).toBeNull() + }) + + it('does not match snippets via path-based targets', () => { + expect( + resolveInternalLinkTargetByTitle('Projects/Architecture', [ + { id: 1, name: 'Architecture', type: 'snippet' }, + ]), + ).toBeNull() + }) + + it('prefers a note in the linker folder when multiple notes share the basename', () => { + expect( + resolveInternalLinkTargetByTitle( + 'Foo', + [ + { folderPath: '', id: 1, name: 'Foo', type: 'note' }, + { folderPath: 'Work', id: 2, name: 'Foo', type: 'note' }, + { folderPath: 'Work/Projects', id: 3, name: 'Foo', type: 'note' }, + ], + { linkerFolderPath: 'Work/Projects' }, + ), + ).toEqual({ id: 3, type: 'note' }) + }) + + it('walks up to the linker parent when no match is in the same folder', () => { + expect( + resolveInternalLinkTargetByTitle( + 'Foo', + [ + { folderPath: '', id: 1, name: 'Foo', type: 'note' }, + { folderPath: 'Work', id: 2, name: 'Foo', type: 'note' }, + ], + { linkerFolderPath: 'Work/Projects' }, + ), + ).toEqual({ id: 2, type: 'note' }) + }) + + it('reaches the root folder during the ancestor walk', () => { + expect( + resolveInternalLinkTargetByTitle( + 'Foo', + [ + { folderPath: '', id: 1, name: 'Foo', type: 'note' }, + { folderPath: 'Other', id: 2, name: 'Foo', type: 'note' }, + ], + { linkerFolderPath: 'Work/Projects' }, + ), + ).toEqual({ id: 1, type: 'note' }) + }) + + it('falls back to the first candidate when no ancestor matches', () => { + expect( + resolveInternalLinkTargetByTitle( + 'Foo', + [ + { folderPath: 'Other', id: 1, name: 'Foo', type: 'note' }, + { folderPath: 'Stuff', id: 2, name: 'Foo', type: 'note' }, + ], + { linkerFolderPath: 'Work/Projects' }, + ), + ).toEqual({ id: 1, type: 'note' }) + }) + + it('returns the single matching note without proximity context', () => { + expect( + resolveInternalLinkTargetByTitle('Foo', [ + { folderPath: 'Work/Projects', id: 1, name: 'Foo', type: 'note' }, + ]), + ).toEqual({ id: 1, type: 'note' }) + }) }) diff --git a/src/shared/notes/internalLinks.ts b/src/shared/notes/internalLinks.ts index 1c479ef4..58c515a7 100644 --- a/src/shared/notes/internalLinks.ts +++ b/src/shared/notes/internalLinks.ts @@ -2,8 +2,10 @@ export type InternalLinkType = 'snippet' | 'note' export interface InternalLink { alias: string | null + basename: string legacyTarget: { id: number, type: InternalLinkType } | null label: string + pathSegments: string[] raw: string target: string } @@ -17,9 +19,38 @@ export interface InternalLinkLookupItem { id: number name: string type: InternalLinkType + folderPath?: string +} + +export interface ResolveInternalLinkOptions { + linkerFolderPath?: string } const ESCAPABLE_LINK_CHARACTERS = new Set(['\\', '|', ']']) +const LEGACY_TARGET_RE = /^(?:snippet|note):\d+$/ + +export function splitInternalLinkTarget(target: string): { + basename: string + pathSegments: string[] +} { + if (!target || LEGACY_TARGET_RE.test(target)) { + return { basename: target, pathSegments: [] } + } + + const segments = target + .split('/') + .map(segment => segment.trim()) + .filter(segment => segment.length > 0) + + if (segments.length === 0) { + return { basename: '', pathSegments: [] } + } + + return { + basename: segments[segments.length - 1], + pathSegments: segments.slice(0, -1), + } +} export function escapeLinkPart(value: string): string { let result = '' @@ -147,11 +178,15 @@ function parseLinkAt(text: string, from: number): InternalLinkMatch | null { } : null + const { basename, pathSegments } = splitInternalLinkTarget(target) + return { alias, + basename, from, legacyTarget, label: alias ?? target, + pathSegments, raw, target, to, @@ -167,8 +202,10 @@ export function parseInternalLink(text: string): InternalLink | null { return { alias: match.alias, + basename: match.basename, legacyTarget: match.legacyTarget, label: match.label, + pathSegments: match.pathSegments, raw: match.raw, target: match.target, } @@ -208,6 +245,35 @@ export function normalizeInternalLinkLookupKey(name: string): string { return name.trim().toLocaleLowerCase() } +export function rewriteInternalLinks( + text: string, + mapMatch: (match: InternalLinkMatch) => string | null, +): string | null { + const matches = findInternalLinks(text) + let result = '' + let cursor = 0 + let changed = false + + for (const match of matches) { + const nextTarget = mapMatch(match) + if (nextTarget === null) { + continue + } + + result += text.slice(cursor, match.from) + result += buildLinkMarkdown(nextTarget, match.alias ?? undefined) + cursor = match.to + changed = true + } + + if (!changed) { + return null + } + + result += text.slice(cursor) + return result +} + export function rewriteInternalLinkTarget( text: string, oldTarget: string, @@ -219,43 +285,97 @@ export function rewriteInternalLinkTarget( return null } - const matches = findInternalLinks(text) - let result = '' - let cursor = 0 - let changed = false - - for (const match of matches) { + return rewriteInternalLinks(text, (match) => { if (match.legacyTarget) { - continue + return null } if (normalizeInternalLinkLookupKey(match.target) !== oldKey) { - continue + return null } if (shouldRewriteMatch && !shouldRewriteMatch(match)) { - continue + return null } - result += text.slice(cursor, match.from) - result += buildLinkMarkdown(newTarget, match.alias ?? undefined) - cursor = match.to - changed = true + return newTarget + }) +} + +function normalizeFolderPath(folderPath: string | undefined): string { + if (!folderPath) { + return '' } - if (!changed) { - return null + return folderPath + .split('/') + .map(segment => segment.trim()) + .filter(segment => segment.length > 0) + .join('/') +} + +function buildFolderAncestorWalk(folderPath: string): string[] { + const normalized = normalizeFolderPath(folderPath) + + if (!normalized) { + return [''] } - result += text.slice(cursor) - return result + const segments = normalized.split('/') + const ancestors: string[] = [] + + for (let index = segments.length; index >= 0; index -= 1) { + ancestors.push(segments.slice(0, index).join('/')) + } + + return ancestors +} + +function buildPathLookupKey(pathSegments: string[], basename: string): string { + return [...pathSegments, basename] + .map(segment => normalizeInternalLinkLookupKey(segment)) + .join('/') +} + +function buildCandidatePathLookupKey(item: InternalLinkLookupItem): string { + const folderSegments = normalizeFolderPath(item.folderPath) + .split('/') + .filter(segment => segment.length > 0) + + return buildPathLookupKey(folderSegments, item.name) } export function resolveInternalLinkTargetByTitle( - title: string, + target: string, items: InternalLinkLookupItem[], + options?: ResolveInternalLinkOptions, ): { id: number, type: InternalLinkType } | null { - const normalizedTitle = normalizeInternalLinkLookupKey(title) + const { basename, pathSegments } = splitInternalLinkTarget(target) + + if (!basename) { + return null + } + + if (pathSegments.length > 0) { + const targetPathKey = buildPathLookupKey(pathSegments, basename) + + const noteMatch = items.find( + item => + item.type === 'note' + && buildCandidatePathLookupKey(item) === targetPathKey, + ) + + if (noteMatch) { + return { + id: noteMatch.id, + type: 'note', + } + } + + return null + } + + const normalizedTitle = normalizeInternalLinkLookupKey(basename) const snippet = items.find( item => @@ -270,18 +390,40 @@ export function resolveInternalLinkTargetByTitle( } } - const note = items.find( + const noteCandidates = items.filter( item => item.type === 'note' && normalizeInternalLinkLookupKey(item.name) === normalizedTitle, ) - if (note) { + if (noteCandidates.length === 0) { + return null + } + + if (noteCandidates.length === 1) { return { - id: note.id, - type: note.type, + id: noteCandidates[0].id, + type: 'note', } } - return null + const ancestors = buildFolderAncestorWalk(options?.linkerFolderPath ?? '') + + for (const ancestorPath of ancestors) { + const match = noteCandidates.find( + candidate => normalizeFolderPath(candidate.folderPath) === ancestorPath, + ) + + if (match) { + return { + id: match.id, + type: 'note', + } + } + } + + return { + id: noteCandidates[0].id, + type: 'note', + } }