From 0df9c1d707cb82833e6fadd0b0cb1077181796d2 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 5 May 2026 03:41:41 +0200 Subject: [PATCH] fix(types): normalize built-in notes type creation --- src/hooks/useNoteCreation.extra.test.ts | 29 ++++++++++++++++ src/hooks/useNoteCreation.test.ts | 40 ++++++++++++++++++++++ src/hooks/useNoteCreation.ts | 19 +++++++--- tests/smoke/collision-create-flows.spec.ts | 18 ++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/hooks/useNoteCreation.extra.test.ts b/src/hooks/useNoteCreation.extra.test.ts index e74f2482..824803f6 100644 --- a/src/hooks/useNoteCreation.extra.test.ts +++ b/src/hooks/useNoteCreation.extra.test.ts @@ -319,6 +319,15 @@ describe('resolveNewType', () => { expect(entry.path).toBe('/other/vault/responsibility.md') expect(entry.path).not.toContain('/Users/luca/Laputa') }) + + it('normalizes the built-in Notes label to the Note type definition', () => { + const { entry, content } = resolveNewType({ typeName: 'Notes', vaultPath: '/my/vault' }) + + expect(entry.path).toBe('/my/vault/note.md') + expect(entry.filename).toBe('note.md') + expect(entry.title).toBe('Note') + expect(content).toContain('# Note') + }) }) describe('planNewTypeCreation', () => { @@ -344,4 +353,24 @@ describe('planNewTypeCreation', () => { expect(plan.status).toBe('blocked') }) + + it('does not treat an existing notes.md note as a collision for the built-in Notes label', () => { + const plan = planNewTypeCreation({ + entries: [makeEntry({ path: '/my/vault/notes.md', filename: 'notes.md', title: 'Meeting Notes', isA: 'Note' })], + typeName: 'Notes', + vaultPath: '/my/vault', + }) + + expect(plan).toEqual({ + status: 'create', + resolved: expect.objectContaining({ + entry: expect.objectContaining({ + path: '/my/vault/note.md', + filename: 'note.md', + title: 'Note', + isA: 'Type', + }), + }), + }) + }) }) diff --git a/src/hooks/useNoteCreation.test.ts b/src/hooks/useNoteCreation.test.ts index 60d63321..cbf4fcae 100644 --- a/src/hooks/useNoteCreation.test.ts +++ b/src/hooks/useNoteCreation.test.ts @@ -624,6 +624,46 @@ describe('useNoteCreation hook', () => { expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Note" because note.md already exists') }) + it('handleCreateType creates the built-in Note type when notes.md is an existing ordinary note', async () => { + vi.mocked(isTauri).mockReturnValue(true) + vi.mocked(invoke) + .mockRejectedValueOnce(new Error('not found')) + .mockResolvedValueOnce(undefined) + const existingNotes = makeEntry({ + path: '/test/vault/notes.md', + filename: 'notes.md', + title: 'Meeting Notes', + isA: 'Note', + }) + const { result } = renderHook(() => useNoteCreation(makeConfig([existingNotes]), tabDeps)) + + let created = false + await act(async () => { + created = await result.current.handleCreateType('Notes') + }) + + expect(created).toBe(true) + expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', { + path: '/test/vault/note.md', + }) + expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', { + path: '/test/vault/note.md', + content: '---\ntype: Type\n---\n\n# Note\n', + }) + expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({ + path: '/test/vault/note.md', + filename: 'note.md', + title: 'Note', + isA: 'Type', + })) + expect(openTabWithContent).toHaveBeenCalledWith(expect.objectContaining({ + path: '/test/vault/note.md', + title: 'Note', + isA: 'Type', + }), expect.stringContaining('# Note')) + expect(setToastMessage).not.toHaveBeenCalled() + }) + it('handleCreateType blocks when a loaded non-Type entry collides with the target type path', async () => { vi.mocked(isTauri).mockReturnValue(true) const staleEntry = makeEntry({ diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index c6aadaf1..727cf648 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -8,6 +8,7 @@ import { trackEvent } from '../lib/telemetry' import { cacheNoteContent } from './useTabManagement' import { findByCollidingNotePath, joinVaultPath, notePathFilename } from '../utils/notePathIdentity' import { canonicalFrontmatterKey } from '../utils/systemMetadata' +import { canonicalizeTypeName } from '../utils/vaultTypes' export interface NewEntryParams { path: string @@ -233,10 +234,20 @@ export interface NewTypeParams { vaultPath: string } +const TYPE_CREATION_ALIASES = new Map([ + ['notes', 'Note'], +]) + +export function normalizeTypeCreationName(typeName: string): string { + const trimmed = typeName.trim() + return TYPE_CREATION_ALIASES.get(trimmed.toLowerCase()) ?? canonicalizeTypeName(trimmed) ?? trimmed +} + export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } { - const slug = slugify(typeName) - const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title: typeName, type: 'Type', status: null }) - return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n` } + const normalizedTypeName = normalizeTypeCreationName(typeName) + const slug = slugify(normalizedTypeName) + const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title: normalizedTypeName, type: 'Type', status: null }) + return { entry, content: `---\ntype: Type\n---\n\n# ${normalizedTypeName}\n` } } type ResolvedEntry = { entry: VaultEntry; content: string } @@ -300,7 +311,7 @@ function buildCreationCollisionMessage({ noun, title, path }: { noun: 'note' | ' } function findEquivalentTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined { - const trimmed = typeName.trim() + const trimmed = normalizeTypeCreationName(typeName) const targetSlug = slugify(trimmed) return entries.find((entry) => entry.isA === 'Type' && (entry.title === trimmed || slugify(entry.title) === targetSlug) diff --git a/tests/smoke/collision-create-flows.spec.ts b/tests/smoke/collision-create-flows.spec.ts index e774b37f..52c3064b 100644 --- a/tests/smoke/collision-create-flows.spec.ts +++ b/tests/smoke/collision-create-flows.spec.ts @@ -120,6 +120,24 @@ test.describe('Collision-safe create flows', () => { expect(fs.readFileSync(defaultNoteTypePath, 'utf8')).toContain('# Note') }) + test('command palette type creation maps the built-in Notes label away from notes.md collisions', async ({ page }) => { + const existingNotesPath = writeFixtureNote( + tempVaultDir, + 'notes.md', + '---\ntype: Note\n---\n# Meeting Notes\n', + ) + fs.rmSync(path.join(tempVaultDir, 'type', 'note.md'), { force: true }) + const defaultNoteTypePath = path.join(tempVaultDir, 'note.md') + + await openFixtureVaultDesktopHarness(page, tempVaultDir) + await createTypeFromCommandPalette(page, 'Notes') + + await expect.poll(() => fs.existsSync(defaultNoteTypePath)).toBe(true) + await expect(toast(page)).toContainText('Type "Notes" created') + expect(fs.readFileSync(defaultNoteTypePath, 'utf8')).toContain('# Note') + expect(fs.readFileSync(existingNotesPath, 'utf8')).toContain('# Meeting Notes') + }) + test('unicode type creation ignores an unrelated untitled draft filename', async ({ page }) => { const untitledPath = writeFixtureNote( tempVaultDir,