fix: create type files in type folder

This commit is contained in:
lucaronin
2026-04-29 02:24:01 +02:00
parent 65e54f108a
commit c6f3d9d945
5 changed files with 87 additions and 20 deletions

View File

@@ -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')
})
})

View File

@@ -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',

View File

@@ -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 {

View File

@@ -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, '停智慧')

View File

@@ -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()