feat: title = filename — wikilink resolution + slug collision detection

- Wikilink resolution order: filename stem (primary) → alias → title (fallback)
- Slug collision detection: auto-suffix (-2, -3, etc.) on note creation
- Add slugCollides utility for frontend collision checking
- resolveNewNote accepts entries param for collision-aware creation
- Title editing via H1 heading already triggers file rename (existing flow)
- Update docs/ARCHITECTURE.md with migrate_to_flat_vault command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-15 19:50:22 +01:00
parent 112f68c66d
commit b2bb7cf661
4 changed files with 71 additions and 18 deletions

View File

@@ -20,6 +20,7 @@ import {
useNoteActions,
DEFAULT_TEMPLATES,
resolveTemplate,
slugCollides,
} from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -111,6 +112,38 @@ describe('needsRenameOnSave', () => {
})
})
describe('slugCollides', () => {
it('detects collision with existing filename', () => {
const entries = [makeEntry({ filename: 'my-note.md', path: '/vault/my-note.md' })]
expect(slugCollides('My Note', entries)).toBe(true)
})
it('returns false when no collision', () => {
const entries = [makeEntry({ filename: 'other.md', path: '/vault/other.md' })]
expect(slugCollides('My Note', entries)).toBe(false)
})
it('excludes the current note path from collision check', () => {
const entries = [makeEntry({ filename: 'my-note.md', path: '/vault/my-note.md' })]
expect(slugCollides('My Note', entries, '/vault/my-note.md')).toBe(false)
})
})
describe('resolveNewNote slug collision', () => {
it('auto-suffixes slug when collision detected', () => {
const entries = [makeEntry({ filename: 'my-project.md', path: '/vault/my-project.md' })]
const { entry } = resolveNewNote('My Project', 'Project', '/vault', null, entries)
expect(entry.path).toBe('/vault/my-project-2.md')
expect(entry.filename).toBe('my-project-2.md')
})
it('skips suffix when no collision', () => {
const entries = [makeEntry({ filename: 'other.md', path: '/vault/other.md' })]
const { entry } = resolveNewNote('My Project', 'Project', '/vault', null, entries)
expect(entry.path).toBe('/vault/my-project.md')
})
})
describe('buildNewEntry', () => {
it('creates a VaultEntry with correct fields', () => {
const entry = buildNewEntry({
@@ -190,8 +223,8 @@ describe('entryMatchesTarget', () => {
expect(entryMatchesTarget(entry, 'mp')).toBe(true)
})
it('matches by path suffix (type/slug)', () => {
const entry = makeEntry({ path: '/Users/luca/Laputa/project/my-project.md' })
it('matches by filename stem from path-style wikilink', () => {
const entry = makeEntry({ path: '/Users/luca/Laputa/my-project.md', filename: 'my-project.md' })
expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true)
})

View File

@@ -95,6 +95,12 @@ export function needsRenameOnSave(title: string, filename: string): boolean {
return `${slugify(title)}.md` !== filename
}
/** Check if a slug would collide with an existing entry's filename. */
export function slugCollides(title: string, entries: VaultEntry[], excludePath?: string): boolean {
const slug = slugify(title)
return entries.some(e => e.filename === `${slug}.md` && e.path !== excludePath)
}
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set<string>): string {
const baseName = `Untitled ${type.toLowerCase()}`
@@ -190,8 +196,16 @@ export function buildNoteContent(title: string, type: string, status: string | n
return `${lines.join('\n')}\n\n# ${title}\n${body}`
}
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { entry: VaultEntry; content: string } {
const slug = slugify(title)
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null, entries?: VaultEntry[]): { entry: VaultEntry; content: string } {
let slug = slugify(title)
// Detect slug collision and auto-suffix
if (entries) {
let counter = 2
while (entries.some(e => e.filename === `${slug}.md`)) {
slug = `${slugify(title)}-${counter}`
counter++
}
}
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title, type, status })
return { entry, content: buildNoteContent(title, type, status, template) }
@@ -363,7 +377,7 @@ export function useNoteActions(config: NoteActionsConfig) {
const handleCreateNote = useCallback((title: string, type: string) => {
const template = resolveTemplate(entries, type)
persistNew(resolveNewNote(title, type, config.vaultPath, template))
persistNew(resolveNewNote(title, type, config.vaultPath, template, entries))
}, [entries, persistNew, config.vaultPath])
const handleCreateNoteImmediate = useCallback((type?: string) => {
@@ -372,7 +386,7 @@ export function useNoteActions(config: NoteActionsConfig) {
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
pendingNamesRef.current.add(title)
const template = resolveTemplate(entries, noteType)
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
const resolved = resolveNewNote(title, noteType, config.vaultPath, template, entries)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
config.trackUnsaved?.(resolved.entry.path)
@@ -389,7 +403,7 @@ export function useNoteActions(config: NoteActionsConfig) {
* Returns true on success, false on failure (shows toast on error). */
const handleCreateNoteForRelationship = useCallback(async (title: string): Promise<boolean> => {
const template = resolveTemplate(entries, 'Note')
const resolved = resolveNewNote(title, 'Note', config.vaultPath, template)
const resolved = resolveNewNote(title, 'Note', config.vaultPath, template, entries)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
try {