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:
@@ -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<VaultEntry>` |
|
||||
| `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 |
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user