fix: create type files in type folder
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '停智慧')
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user