diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 141da325..652cc042 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -569,7 +569,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days | | `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault | | `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames | -| `migration.rs` | Frontmatter migration utilities | +| `migration.rs` | Frontmatter migration: `migrate_is_a_to_type`, `migrate_to_flat_vault` | | `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files | | `getting_started.rs` | Creates the Getting Started demo vault | @@ -611,6 +611,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `purge_trash` | Delete notes trashed >30 days ago | | `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec` | | `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` | +| `migrate_to_flat_vault` | Move notes from type subfolders to vault root, update wikilinks | | `check_vault_exists` | Check if vault path exists | | `create_getting_started_vault` | Bootstrap demo vault | diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 18082112..56c56738 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -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) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 1ce6c158..5fff6a2e 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -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 " name by checking existing entries and pending names. */ export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set): 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 => { 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 { diff --git a/src/utils/wikilink.ts b/src/utils/wikilink.ts index ea6798a8..4940000e 100644 --- a/src/utils/wikilink.ts +++ b/src/utils/wikilink.ts @@ -20,23 +20,28 @@ export function wikilinkDisplay(ref: string): string { /** * Unified wikilink resolution: find the VaultEntry matching a wikilink target. - * Handles pipe syntax, case-insensitive matching, title/alias/filename/path lookup. + * Resolution order: filename stem (primary) → alias → title (fallback). + * Handles pipe syntax, case-insensitive matching. */ export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined { const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget const keyLower = key.toLowerCase() - const suffix = '/' + key + '.md' const lastSegment = key.split('/').pop() ?? key const asWords = lastSegment.replace(/-/g, ' ').toLowerCase() - return entries.find(e => { - if (e.title.toLowerCase() === keyLower) return true - if (e.aliases.some(a => a.toLowerCase() === keyLower)) return true + // 1. Filename stem match (primary — filename IS identity in flat vault) + const byStem = entries.find(e => { const stem = e.filename.replace(/\.md$/, '') - if (stem.toLowerCase() === keyLower) return true - if (e.path.endsWith(suffix)) return true - if (stem.toLowerCase() === lastSegment.toLowerCase()) return true - if (e.title.toLowerCase() === asWords) return true - return false + return stem.toLowerCase() === keyLower || stem.toLowerCase() === lastSegment.toLowerCase() }) + if (byStem) return byStem + + // 2. Alias match + const byAlias = entries.find(e => e.aliases.some(a => a.toLowerCase() === keyLower)) + if (byAlias) return byAlias + + // 3. Title match (fallback) + return entries.find(e => + e.title.toLowerCase() === keyLower || e.title.toLowerCase() === asWords, + ) }