fix: 修复笔记 YAML 标签保存与索引重建一致性
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { parseDocument } from 'yaml'
|
||||
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
|
||||
|
||||
it('renders legacy properties and saves real frontmatter without losing other fields', () => {
|
||||
@@ -8,7 +9,6 @@ it('renders legacy properties and saves real frontmatter without losing other fi
|
||||
expect(metadata.body).toBe('\n# 正文\n')
|
||||
const prefix = updateMetadataTags(metadata, ['编程', '学习', '学习'])
|
||||
expect(prefix).toContain('embedding_local_only: true')
|
||||
expect(prefix).toContain('tags: ["编程","学习"]')
|
||||
expect(prefix.startsWith('---\n')).toBe(true)
|
||||
expect(splitNoteMetadata(prefix + metadata.body)!.tags).toEqual(['编程', '学习'])
|
||||
})
|
||||
@@ -16,3 +16,39 @@ it('renders legacy properties and saves real frontmatter without losing other fi
|
||||
it('does not mistake ordinary Markdown for metadata', () => {
|
||||
expect(splitNoteMetadata('---\nA paragraph\n---\n')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['- python\n- rust', ' - python\n - rust', '[python, rust]'])('replaces the complete YAML tag list: %s', (list) => {
|
||||
const metadata = splitNoteMetadata(`---\ntitle: Demo\ntags:\n${list.startsWith('[') ? ' ' : ''}${list}\nextra:\n enabled: true # keep this\n---\n# Body\n`)!
|
||||
expect(metadata.tags).toEqual(['python', 'rust'])
|
||||
const prefix = updateMetadataTags(metadata, [...metadata.tags, 'new'])
|
||||
const updated = splitNoteMetadata(prefix + metadata.body)!
|
||||
expect(updated.tags).toEqual(['python', 'rust', 'new'])
|
||||
expect(updated.body).toBe('# Body\n')
|
||||
const document = parseDocument(updated.yaml)
|
||||
expect(document.errors).toEqual([])
|
||||
expect(document.toJS().extra).toEqual({ enabled: true })
|
||||
expect(prefix).toContain('# keep this')
|
||||
expect(splitNoteMetadata(updateMetadataTags(updated, []))!.tags).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves quoted commas, escapes, multiline titles and nested properties', () => {
|
||||
const tags = ['a,b', 'quote"tag', 'path\\tag', 'true']
|
||||
const metadata = splitNoteMetadata(`---\ntitle: |\n A multiline\n title\ntags: ${JSON.stringify(tags)}\nextra: {count: 2, enabled: false}\n---\n正文`)!
|
||||
expect(metadata.tags).toEqual(tags)
|
||||
const updated = splitNoteMetadata(updateMetadataTags(metadata, tags) + metadata.body)!
|
||||
expect(updated.tags).toEqual(tags)
|
||||
expect(updated.title).toBe(metadata.title)
|
||||
expect(parseDocument(updated.yaml).toJS().extra).toEqual({ count: 2, enabled: false })
|
||||
})
|
||||
|
||||
it('preserves document encoding markers and tag anchors', () => {
|
||||
const metadata = splitNoteMetadata('\uFEFF---\r\ntitle: Demo\r\ntags: &labels [python]\r\nrelated: *labels\r\n---\r\nBody')!
|
||||
const prefix = updateMetadataTags(metadata, ['rust'])
|
||||
expect(prefix.startsWith('\uFEFF---\r\n')).toBe(true)
|
||||
expect(prefix.replace(/\r\n/g, '')).not.toContain('\n')
|
||||
expect(parseDocument(splitNoteMetadata(prefix)!.yaml).toJS().related).toEqual(['rust'])
|
||||
})
|
||||
|
||||
it.each(['tags: [broken', 'tags: [one]\ntags: [two]', 'tags: {nested: value}', 'tags: [1, true]', 'tags: [&label python]\nother: *label'])('leaves invalid or unsupported tag data in source mode: %s', (yaml) => {
|
||||
expect(splitNoteMetadata(`---\ntitle: Demo\n${yaml}\n---\nBody`)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1,23 +1,2 @@
|
||||
// TODO(desktop): 第三阶段顶部「段落 → 导入为笔记属性」复用属性解析边界,
|
||||
// 补齐无损 YAML、冲突合并与可撤销事务;见 docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md。
|
||||
export interface NoteMetadata { prefix: string; yaml: string; body: string; title: string; tags: string[] }
|
||||
|
||||
export function splitNoteMetadata(source: string): NoteMetadata | null {
|
||||
const match = source.match(/^\uFEFF?(---|\*\*\*)[ \t]*\r?\n([\s\S]*?)\r?\n(?:-{3,}|\.\.\.)[ \t]*(?:\r?\n|$)/)
|
||||
if (!match) return null
|
||||
const yaml = match[2]!
|
||||
// Only recognize metadata with explicit fields, not ordinary thematic breaks.
|
||||
const title = yaml.match(/^title:[ \t]*(.*)$/m)?.[1]?.trim() ?? ''
|
||||
const rawTags = yaml.match(/^tags:[ \t]*(.*)$/m)?.[1]?.trim()
|
||||
if (!title && rawTags === undefined) return null
|
||||
// Complex YAML values remain editable in source mode, never partially rewritten.
|
||||
if (/^(?:[|>]|\{)/.test(title) || (rawTags === '' && /^\s+-\s/m.test(yaml))) return null
|
||||
const tags = rawTags?.replace(/^\[|\]$/g, '').split(',').map(tag => tag.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean) ?? []
|
||||
return { prefix: match[0], yaml, body: source.slice(match[0].length), title: title.replace(/^['"]|['"]$/g, ''), tags }
|
||||
}
|
||||
|
||||
export function updateMetadataTags(metadata: NoteMetadata, tags: string[]): string {
|
||||
const line = `tags: ${JSON.stringify([...new Set(tags)])}`
|
||||
const yaml = /^tags:/m.test(metadata.yaml) ? metadata.yaml.replace(/^tags:.*$/m, () => line) : `${metadata.yaml}\n${line}`
|
||||
return `---\n${yaml.trim()}\n---\n`
|
||||
}
|
||||
export { splitNoteMetadata, updateMetadataTags } from '@/utils/noteMetadata'
|
||||
export type { NoteMetadata } from '@/utils/noteMetadata'
|
||||
|
||||
@@ -50,6 +50,25 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('workspaceService backend adapter', () => {
|
||||
it.each([
|
||||
['tags:\n- python\n- rust', { tags: ['python', 'rust'] }],
|
||||
['tags: []', { tags: [] }],
|
||||
['tags:', { tags: [] }],
|
||||
['tags: ["a,b", rust]', { tags: ['a,b', 'rust'] }],
|
||||
['title: Demo', {}],
|
||||
['tags: [broken', {}],
|
||||
])('saves explicit metadata tags with the same Markdown snapshot: %s', async (yaml, tagPayload) => {
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
fetchMock.mockImplementation(async (input) => String(input) === '/api/workspace/open'
|
||||
? jsonResponse(workspaceSnapshot) : jsonResponse({}))
|
||||
await workspaceService.openVault('C:\\data\\vault')
|
||||
const markdown = `---\n${yaml}\n---\n# Body\n`
|
||||
await workspaceService.saveFileContent('/课程/操作系统.md', markdown)
|
||||
const patchCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')
|
||||
expect(String(patchCall?.[0])).toBe('/api/notes/note-os')
|
||||
expect(JSON.parse(String(patchCall?.[1]?.body))).toEqual({ markdown, ...tagPayload })
|
||||
})
|
||||
|
||||
it('opens the configured Vault and reads/saves Markdown through Note API', async () => {
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import apiClient from './apiClient'
|
||||
import { t } from '@/i18n'
|
||||
import * as noteService from './noteService'
|
||||
import { splitNoteMetadata } from '@/utils/noteMetadata'
|
||||
|
||||
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
||||
export interface VaultInfo {
|
||||
@@ -122,7 +123,12 @@ export async function getNoteId(filePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function saveFileContent(filePath: string, content: string): Promise<void> {
|
||||
await noteService.updateNote(await requireNoteId(filePath), { markdown: content })
|
||||
const metadata = splitNoteMetadata(content)
|
||||
await noteService.updateNote(await requireNoteId(filePath), {
|
||||
markdown: content,
|
||||
// Explicit [] clears the index; absent tags retain API-managed tags.
|
||||
...(metadata?.hasTags ? { tags: metadata.tags } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFile(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { isMap, isScalar, isSeq, parseDocument } from 'yaml'
|
||||
|
||||
export interface NoteMetadata {
|
||||
prefix: string
|
||||
yaml: string
|
||||
body: string
|
||||
title: string
|
||||
tags: string[]
|
||||
hasTags: boolean
|
||||
}
|
||||
|
||||
function parseProperties(yaml: string) {
|
||||
const document = parseDocument(yaml)
|
||||
// Unsupported YAML stays available in source mode without partial rewriting.
|
||||
if (document.errors.length || document.warnings.length || !isMap(document.contents)) return null
|
||||
return document
|
||||
}
|
||||
|
||||
export function splitNoteMetadata(source: string): NoteMetadata | null {
|
||||
const match = source.match(/^\uFEFF?(---|\*\*\*)[ \t]*\r?\n([\s\S]*?)\r?\n(?:-{3,}|\.\.\.)[ \t]*(?:\r?\n|$)/)
|
||||
if (!match) return null
|
||||
const yaml = match[2]!
|
||||
const document = parseProperties(yaml)
|
||||
if (!document || (!document.has('title') && !document.has('tags'))) return null
|
||||
const title = document.get('title') ?? ''
|
||||
if (typeof title !== 'string') return null
|
||||
const tagNode = document.get('tags', true)
|
||||
let tags: string[] = []
|
||||
if (isSeq(tagNode)) {
|
||||
// Do not remove anchored list items that other properties may reference.
|
||||
if (!tagNode.items.every(item => isScalar(item) && typeof item.value === 'string' && !item.anchor)) return null
|
||||
tags = tagNode.items.map(item => (item as { value: string }).value)
|
||||
} else if (isScalar(tagNode)) {
|
||||
if (typeof tagNode.value === 'string') tags = tagNode.value.split(',').map(tag => tag.trim()).filter(Boolean)
|
||||
else if (tagNode.value !== null) return null
|
||||
} else if (tagNode !== undefined) return null
|
||||
return { prefix: match[0], yaml, body: source.slice(match[0].length), title, tags, hasTags: document.has('tags') }
|
||||
}
|
||||
|
||||
export function updateMetadataTags(metadata: NoteMetadata, tags: string[]): string {
|
||||
const document = parseProperties(metadata.yaml)
|
||||
if (!document) throw new Error('Invalid note metadata')
|
||||
const previous = document.get('tags', true)
|
||||
const replacement = document.createNode([...new Set(tags)])
|
||||
if (isScalar(previous) || isSeq(previous)) {
|
||||
replacement.anchor = previous.anchor
|
||||
replacement.comment = previous.comment
|
||||
replacement.commentBefore = previous.commentBefore
|
||||
}
|
||||
document.set('tags', replacement)
|
||||
const newline = metadata.prefix.includes('\r\n') ? '\r\n' : '\n'
|
||||
const prefix = `---\n${document.toString()}---\n`.replace(/\n/g, newline)
|
||||
return (metadata.prefix.startsWith('\uFEFF') ? '\uFEFF' : '') + prefix
|
||||
}
|
||||
Reference in New Issue
Block a user