Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/main/storage/providers/markdown/notes/runtime/backlinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type {
InternalLinkLookupItem,
InternalLinkMatch,
} from '../../../../../../shared/notes/internalLinks'
import type { MarkdownNote, NotesPaths, NotesState } from './types'
import {
normalizeInternalLinkLookupKey,
resolveInternalLinkTargetByTitle,
rewriteInternalLinkTarget,
} from '../../../../../../shared/notes/internalLinks'
import { getPaths, getVaultPath } from '../../runtime/paths'
import { getRuntimeCache } from '../../runtime/sync'
import { writeNoteToFile } from './notes'
import { invalidateNotesSearchIndex } from './search'

interface RewriteBacklinksAfterRenameInput {
paths: NotesPaths
state: NotesState
notes: MarkdownNote[]
renamedNoteId: number
previousName: string
nextName: string
}

export function rewriteBacklinksAfterNoteRename(
input: RewriteBacklinksAfterRenameInput,
): number {
const { paths, state, notes, renamedNoteId, previousName, nextName } = input

const previousKey = normalizeInternalLinkLookupKey(previousName)
if (!previousKey) {
return 0
}

if (previousKey === normalizeInternalLinkLookupKey(nextName)) {
return 0
}

let snippetLookup: InternalLinkLookupItem[] = []
try {
const markdownCache = getRuntimeCache(getPaths(getVaultPath()))
snippetLookup = markdownCache.snippets
.filter(snippet => snippet.isDeleted === 0)
.map(snippet => ({
id: snippet.id,
name: snippet.name,
type: 'snippet' as const,
}))
}
catch {
snippetLookup = []
}

if (
snippetLookup.some(
snippet => normalizeInternalLinkLookupKey(snippet.name) === 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 lookup = [...snippetLookup, ...noteLookup]

const shouldRewriteMatch = (match: InternalLinkMatch): boolean => {
const resolved = resolveInternalLinkTargetByTitle(match.target, lookup)
return (
resolved !== null
&& resolved.type === 'note'
&& resolved.id === renamedNoteId
)
}

let rewrittenCount = 0
const now = Date.now()

for (const note of notes) {
if (note.id === renamedNoteId || note.isDeleted || !note.content) {
continue
}

const rewritten = rewriteInternalLinkTarget(
note.content,
previousName,
nextName,
shouldRewriteMatch,
)
if (rewritten === null) {
continue
}

note.content = rewritten
note.updatedAt = now
writeNoteToFile(paths, note)
rewrittenCount++
}

if (rewrittenCount > 0) {
invalidateNotesSearchIndex(state)
}

return rewrittenCount
}
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,109 @@ describe('notes storage validations', () => {
vi.useRealTimers()
}
})

it('rewrites internal links in backlinking notes when a note is renamed', () => {
const storage = createNotesNotesStorage()

const target = storage.createNote({ name: 'Old Name' })
const linker = storage.createNote({ name: 'Linker' })
const aliasLinker = storage.createNote({ name: 'Alias Linker' })
const unrelated = storage.createNote({ name: 'Unrelated' })

storage.updateNoteContent(linker.id, 'See [[Old Name]] for context')
storage.updateNoteContent(
aliasLinker.id,
'See [[old name|the old]] for context',
)
storage.updateNoteContent(unrelated.id, 'See [[Other Note]] here')

storage.updateNote(target.id, { name: 'New Name' })

expect(storage.getNoteById(linker.id)?.content).toBe(
'See [[New Name]] for context',
)
expect(storage.getNoteById(aliasLinker.id)?.content).toBe(
'See [[New Name|the old]] for context',
)
expect(storage.getNoteById(unrelated.id)?.content).toBe(
'See [[Other Note]] here',
)
})

it('does not touch the renamed note own content during backlink rewrite', () => {
const storage = createNotesNotesStorage()

const target = storage.createNote({ name: 'Old Name' })
storage.updateNoteContent(
target.id,
'Self reference [[Old Name]] should remain',
)

storage.updateNote(target.id, { name: 'New Name' })

expect(storage.getNoteById(target.id)?.content).toBe(
'Self reference [[Old Name]] should remain',
)
})

it('rewrites only links that resolved to the renamed note when duplicate names exist', () => {
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 earlier = storage.createNote({
name: 'Shared Name',
folderId: folderA.id,
})
storage.createNote({ name: 'Shared Name', folderId: folderB.id })

const linker = storage.createNote({ name: 'Linker' })
storage.updateNoteContent(linker.id, 'See [[Shared Name]] here')

storage.updateNote(earlier.id, { name: 'Renamed One' })

expect(storage.getNoteById(linker.id)?.content).toBe(
'See [[Renamed One]] here',
)
})

it('does not rewrite links that resolve to a different note than the renamed one', () => {
const folders = createNotesFoldersStorage()
const storage = createNotesNotesStorage()

const folderA = folders.createFolder({ name: 'Folder A', parentId: null })
const folderB = folders.createFolder({ name: 'Folder B', parentId: null })

storage.createNote({ name: 'Shared Name', folderId: folderA.id })
const later = storage.createNote({
name: 'Shared Name',
folderId: folderB.id,
})

const linker = storage.createNote({ name: 'Linker' })
storage.updateNoteContent(linker.id, 'See [[Shared Name]] here')

storage.updateNote(later.id, { name: 'Renamed Two' })

expect(storage.getNoteById(linker.id)?.content).toBe(
'See [[Shared Name]] here',
)
})

it('skips backlink rewrite for deleted notes', () => {
const storage = createNotesNotesStorage()

const target = storage.createNote({ name: 'Old Name' })
const linker = storage.createNote({ name: 'Linker' })
storage.updateNoteContent(linker.id, 'See [[Old Name]] for context')
storage.updateNote(linker.id, { isDeleted: 1 })

storage.updateNote(target.id, { name: 'New Name' })

expect(storage.getNoteById(linker.id)?.content).toBe(
'See [[Old Name]] for context',
)
})
})
13 changes: 13 additions & 0 deletions src/main/storage/providers/markdown/notes/storages/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
throwStorageError,
validateEntryName,
} from '../../runtime/validation'
import { rewriteBacklinksAfterNoteRename } from '../runtime/backlinks'
import { getNotesPaths } from '../runtime/constants'
import { findNoteById, persistNote, writeNoteToFile } from '../runtime/notes'
import { findNotesFolderById } from '../runtime/paths'
Expand Down Expand Up @@ -172,6 +173,7 @@ export function createNotesNotesStorage(): NotesStorage {
}

const previousFilePath = note.filePath
const previousName = note.name
const previousFolderId = note.folderId
const updateResult = applyEntityUpdateFields({
entity: note,
Expand Down Expand Up @@ -216,6 +218,17 @@ export function createNotesNotesStorage(): NotesStorage {
writeNoteToFile(paths, note)
}

if (note.name !== previousName) {
rewriteBacklinksAfterNoteRename({
notes,
nextName: note.name,
paths,
previousName,
renamedNoteId: note.id,
state,
})
}

saveNotesState(paths, state)
return { invalidInput: false, notFound: false }
},
Expand Down
80 changes: 80 additions & 0 deletions src/shared/notes/__tests__/internalLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
findInternalLinks,
parseInternalLink,
resolveInternalLinkTargetByTitle,
rewriteInternalLinkTarget,
} from '../internalLinks'

describe('parseInternalLink', () => {
Expand Down Expand Up @@ -138,6 +139,85 @@ describe('findInternalLinks', () => {
})
})

describe('rewriteInternalLinkTarget', () => {
it('rewrites a plain link target', () => {
expect(
rewriteInternalLinkTarget(
'See [[Old Name]] for details',
'Old Name',
'New Name',
),
).toBe('See [[New Name]] for details')
})

it('keeps the alias when rewriting a target', () => {
expect(
rewriteInternalLinkTarget(
'See [[Old Name|shown text]] for details',
'Old Name',
'New Name',
),
).toBe('See [[New Name|shown text]] for details')
})

it('matches case-insensitively', () => {
expect(
rewriteInternalLinkTarget(
'See [[old name]] and [[OLD NAME]]',
'Old Name',
'New Name',
),
).toBe('See [[New Name]] and [[New Name]]')
})

it('rewrites multiple occurrences in one pass', () => {
expect(
rewriteInternalLinkTarget(
'[[Old]] before [[Old|alias]] middle [[Other]] end',
'Old',
'New',
),
).toBe('[[New]] before [[New|alias]] middle [[Other]] end')
})

it('escapes special characters in the new target', () => {
expect(
rewriteInternalLinkTarget('See [[Old]] here', 'Old', 'foo | bar'),
).toBe('See [[foo \\| bar]] here')
})

it('returns null when nothing changes', () => {
expect(
rewriteInternalLinkTarget('See [[Other]] end', 'Old', 'New'),
).toBeNull()
expect(rewriteInternalLinkTarget('no links here', 'Old', 'New')).toBeNull()
})

it('does not rewrite legacy id-based targets', () => {
expect(
rewriteInternalLinkTarget(
'See [[note:42]] and [[Old]]',
'note:42',
'Something',
),
).toBeNull()
expect(
rewriteInternalLinkTarget('See [[note:42|Old]]', 'Old', 'New'),
).toBeNull()
})

it('skips matches that the predicate rejects', () => {
expect(
rewriteInternalLinkTarget(
'[[Old]] keep [[Old|alias]] rewrite',
'Old',
'New',
match => match.alias !== null,
),
).toBe('[[Old]] keep [[New|alias]] rewrite')
})
})

describe('resolveInternalLinkTargetByTitle', () => {
it('prefers an exact snippet title match over a note title match', () => {
expect(
Expand Down
43 changes: 43 additions & 0 deletions src/shared/notes/internalLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,49 @@ export function normalizeInternalLinkLookupKey(name: string): string {
return name.trim().toLocaleLowerCase()
}

export function rewriteInternalLinkTarget(
text: string,
oldTarget: string,
newTarget: string,
shouldRewriteMatch?: (match: InternalLinkMatch) => boolean,
): string | null {
const oldKey = normalizeInternalLinkLookupKey(oldTarget)
if (!oldKey) {
return null
}

const matches = findInternalLinks(text)
let result = ''
let cursor = 0
let changed = false

for (const match of matches) {
if (match.legacyTarget) {
continue
}

if (normalizeInternalLinkLookupKey(match.target) !== oldKey) {
continue
}

if (shouldRewriteMatch && !shouldRewriteMatch(match)) {
continue
}

result += text.slice(cursor, match.from)
result += buildLinkMarkdown(newTarget, match.alias ?? undefined)
cursor = match.to
changed = true
}

if (!changed) {
return null
}

result += text.slice(cursor)
return result
}

export function resolveInternalLinkTargetByTitle(
title: string,
items: InternalLinkLookupItem[],
Expand Down