From c6f3d9d945ea6bb3c20998022fd6d4e13a849340 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 02:24:01 +0200 Subject: [PATCH] fix: create type files in type folder --- src/hooks/useNoteCreation.extra.test.ts | 6 +-- src/hooks/useNoteCreation.test.ts | 39 +++++++++++++++--- src/hooks/useNoteCreation.ts | 46 ++++++++++++++++++++-- tests/smoke/collision-create-flows.spec.ts | 8 ++-- tests/smoke/missing-type-warning.spec.ts | 8 ++-- 5 files changed, 87 insertions(+), 20 deletions(-) diff --git a/src/hooks/useNoteCreation.extra.test.ts b/src/hooks/useNoteCreation.extra.test.ts index e1ebd496..e3a32d93 100644 --- a/src/hooks/useNoteCreation.extra.test.ts +++ b/src/hooks/useNoteCreation.extra.test.ts @@ -304,9 +304,9 @@ describe('resolveNewNote', () => { }) describe('resolveNewType', () => { - it('creates a type entry at vault root', () => { + it('creates a type entry in the canonical type directory', () => { const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/my/vault' }) - expect(entry.path).toBe('/my/vault/recipe.md') + expect(entry.path).toBe('/my/vault/type/recipe.md') expect(entry.isA).toBe('Type') expect(entry.status).toBeNull() expect(content).toContain('type: Type') @@ -315,7 +315,7 @@ describe('resolveNewType', () => { it('uses provided vault path instead of hardcoded path', () => { const { entry } = resolveNewType({ typeName: 'Responsibility', vaultPath: '/other/vault' }) - expect(entry.path).toBe('/other/vault/responsibility.md') + expect(entry.path).toBe('/other/vault/type/responsibility.md') expect(entry.path).not.toContain('/Users/luca/Laputa') }) }) diff --git a/src/hooks/useNoteCreation.test.ts b/src/hooks/useNoteCreation.test.ts index 341197a4..46a0bc9a 100644 --- a/src/hooks/useNoteCreation.test.ts +++ b/src/hooks/useNoteCreation.test.ts @@ -172,18 +172,23 @@ describe('resolveNewNote', () => { }) describe('resolveNewType', () => { - it('creates a type entry', () => { + it('creates a type entry in the canonical type directory', () => { const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/vault' }) - expect(entry.path).toBe('/vault/recipe.md') + expect(entry.path).toBe('/vault/type/recipe.md') expect(entry.isA).toBe('Type') expect(content).toContain('type: Type') }) it('uses the unicode title when the type name has no ASCII characters', () => { const { entry } = resolveNewType({ typeName: '停智慧', vaultPath: '/vault' }) - expect(entry.path).toBe('/vault/停智慧.md') + expect(entry.path).toBe('/vault/type/停智慧.md') expect(entry.filename).toBe('停智慧.md') }) + + it('can target an existing plural types directory', () => { + const { entry } = resolveNewType({ typeName: 'Recipe', vaultPath: '/vault', typeDirectory: 'types' }) + expect(entry.path).toBe('/vault/types/recipe.md') + }) }) describe('resolveTemplate', () => { @@ -424,8 +429,8 @@ describe('useNoteCreation hook', () => { expect(addEntry.mock.calls[0][0].title).toBe('Recipe') }) - it('handleCreateType blocks when a non-type file already uses the target filename', async () => { - const existing = makeEntry({ path: '/test/vault/briefing.md', filename: 'briefing.md', title: 'Briefing', isA: 'Note' }) + it('handleCreateType blocks when the target type file already exists', async () => { + const existing = makeEntry({ path: '/test/vault/type/briefing.md', filename: 'briefing.md', title: 'Briefing', isA: 'Note' }) const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps)) let created = true @@ -439,6 +444,28 @@ describe('useNoteCreation hook', () => { expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Briefing" because briefing.md already exists') }) + it('handleCreateType uses an existing plural types folder convention', async () => { + const existingType = makeEntry({ + path: '/test/vault/types/project.md', + filename: 'project.md', + title: 'Project', + isA: 'Type', + }) + const { result } = renderHook(() => useNoteCreation(makeConfig([existingType]), tabDeps)) + + await act(async () => { + await result.current.handleCreateType('Hotel') + }) + + expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({ + path: '/test/vault/types/hotel.md', + filename: 'hotel.md', + title: 'Hotel', + isA: 'Type', + })) + expect(setToastMessage).not.toHaveBeenCalled() + }) + it('handleCreateType ignores an existing untitled draft when a unicode filename is unique', async () => { const existing = makeEntry({ path: '/test/vault/untitled.md', filename: 'untitled.md', title: 'Untitled', isA: 'Note' }) const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps)) @@ -450,7 +477,7 @@ describe('useNoteCreation hook', () => { expect(created).toBe(true) expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({ - path: '/test/vault/停智慧.md', + path: '/test/vault/type/停智慧.md', filename: '停智慧.md', title: '停智慧', isA: 'Type', diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index 13a88680..9a7c1533 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -123,11 +123,50 @@ export function resolveNewNote({ title, type, vaultPath, template }: NewNotePara export interface NewTypeParams { typeName: string vaultPath: string + typeDirectory?: string } -export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } { +const DEFAULT_TYPE_DEFINITION_DIR = 'type' +const TYPE_DEFINITION_DIRS = new Set([DEFAULT_TYPE_DEFINITION_DIR, 'types']) + +interface RelativePathParams { + entryPath: string + vaultPath: string +} + +interface TypeDirectoryParams { + entry: VaultEntry + vaultPath: string +} + +function relativePathFromVault({ entryPath, vaultPath }: RelativePathParams): string | null { + const normalizedPath = entryPath.replace(/\\/g, '/') + const normalizedVaultPath = vaultPath.replace(/\\/g, '/').replace(/\/+$/, '') + const prefix = `${normalizedVaultPath}/` + if (normalizedPath.toLocaleLowerCase().startsWith(prefix.toLocaleLowerCase())) { + return normalizedPath.slice(prefix.length) + } + return null +} + +function typeDirectoryFromEntry({ entry, vaultPath }: TypeDirectoryParams): string | null { + const relativePath = relativePathFromVault({ entryPath: entry.path, vaultPath }) + const directory = relativePath?.replace(/\\/g, '/').split('/').filter(Boolean)[0] ?? null + return directory && TYPE_DEFINITION_DIRS.has(directory.toLocaleLowerCase()) ? directory : null +} + +function resolveTypeDefinitionDirectory(entries: VaultEntry[], vaultPath: string): string { + for (const entry of entries) { + if (entry.isA !== 'Type') continue + const directory = typeDirectoryFromEntry({ entry, vaultPath }) + if (directory?.toLocaleLowerCase() === 'types') return directory + } + return DEFAULT_TYPE_DEFINITION_DIR +} + +export function resolveNewType({ typeName, vaultPath, typeDirectory = DEFAULT_TYPE_DEFINITION_DIR }: NewTypeParams): { entry: VaultEntry; content: string } { const slug = slugify(typeName) - const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null }) + const entry = buildNewEntry({ path: `${vaultPath}/${typeDirectory}/${slug}.md`, slug, title: typeName, type: 'Type', status: null }) return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n` } } @@ -199,7 +238,8 @@ export function planNewTypeCreation({ const existingType = findEquivalentTypeEntry(entries, typeName) if (existingType) return { status: 'existing', entry: existingType } - const resolved = resolveNewType({ typeName, vaultPath }) + const typeDirectory = resolveTypeDefinitionDirectory(entries, vaultPath) + const resolved = resolveNewType({ typeName, vaultPath, typeDirectory }) const collision = findPathCollision(entries, resolved.entry.path) if (collision) { return { diff --git a/tests/smoke/collision-create-flows.spec.ts b/tests/smoke/collision-create-flows.spec.ts index 28712386..fa1fa47c 100644 --- a/tests/smoke/collision-create-flows.spec.ts +++ b/tests/smoke/collision-create-flows.spec.ts @@ -40,7 +40,7 @@ test.describe('Collision-safe create flows', () => { removeFixtureVaultCopy(tempVaultDir) }) - test('missing-type creation keeps the dialog open when a root filename already exists', async ({ page }) => { + test('missing-type creation ignores a root filename collision and writes the type document under type/', async ({ page }) => { const collidingPath = writeFixtureNote( tempVaultDir, 'hotel.md', @@ -66,8 +66,8 @@ test.describe('Collision-safe create flows', () => { await expect(dialog.getByPlaceholder('e.g. Recipe, Book, Habit...')).toHaveValue('Hotel') await page.keyboard.press('Enter') - await expect(dialog).toBeVisible() - await expect(toast(page)).toContainText('Cannot create type "Hotel" because hotel.md already exists') + await expect(dialog).toHaveCount(0) + await expect.poll(() => fs.existsSync(path.join(tempVaultDir, 'type', 'hotel.md'))).toBe(true) expect(fs.readFileSync(collidingPath, 'utf8')).toContain('# Existing Hotel Note') }) @@ -103,7 +103,7 @@ test.describe('Collision-safe create flows', () => { 'untitled.md', '---\ntype: Note\n---\n# Existing Untitled Note\n', ) - const createdTypePath = path.join(tempVaultDir, '停智慧.md') + const createdTypePath = path.join(tempVaultDir, 'type', '停智慧.md') await openFixtureVaultDesktopHarness(page, tempVaultDir) await createTypeFromCommandPalette(page, '停智慧') diff --git a/tests/smoke/missing-type-warning.spec.ts b/tests/smoke/missing-type-warning.spec.ts index 8ee8f3ef..8ab4e1ec 100644 --- a/tests/smoke/missing-type-warning.spec.ts +++ b/tests/smoke/missing-type-warning.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test' import fs from 'fs' import path from 'path' import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault' +import { sendShortcut } from './helpers' let tempVaultDir: string @@ -26,14 +27,13 @@ test.describe('Missing type warning', () => { test('lets keyboard users inspect, cancel, and create a missing type from the Properties panel', async ({ page }) => { await page.getByText('Hotel Guide', { exact: true }).click() - await page.keyboard.press('Control+Shift+i') + await sendShortcut(page, 'i', ['Control', 'Shift']) const warning = page.getByTestId('missing-type-warning') await expect(warning).toBeVisible() + await expect(warning).toHaveAttribute('aria-label', 'Missing type Hotel. Click to create this type.') await warning.focus() - await expect(page.getByText('There is no type file for this type')).toBeVisible() - await page.keyboard.press('Enter') const dialog = page.getByRole('dialog') const typeNameInput = dialog.getByPlaceholder('e.g. Recipe, Book, Habit...') @@ -52,7 +52,7 @@ test.describe('Missing type warning', () => { await expect(warning).toHaveCount(0) await expect(page.getByRole('combobox')).toContainText('Hotel') - await expect.poll(() => fs.existsSync(path.join(tempVaultDir, 'hotel.md'))).toBe(true) + await expect.poll(() => fs.existsSync(path.join(tempVaultDir, 'type', 'hotel.md'))).toBe(true) await page.getByText('Alpha Project', { exact: true }).click() await page.getByText('Hotel Guide', { exact: true }).click()