From 02dd585a4e1dc805b87c096ba4186bc0ee622a22 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 5 Sep 2026 19:40:14 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=AC=94=E8=AE=B0=20Y?= =?UTF-8?q?AML=20=E6=A0=87=E7=AD=BE=E4=BF=9D=E5=AD=98=E4=B8=8E=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E9=87=8D=E5=BB=BA=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/knowledge/parser.py | 36 ++++++++----- backend/tests/test_note_metadata.py | 46 ++++++++++++++++ frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 45 ++++++++++------ .../src/features/editor/noteMetadata.spec.ts | 38 ++++++++++++- frontend/src/features/editor/noteMetadata.ts | 25 +-------- .../src/services/workspaceService.spec.ts | 19 +++++++ frontend/src/services/workspaceService.ts | 8 ++- frontend/src/utils/noteMetadata.ts | 54 +++++++++++++++++++ 9 files changed, 219 insertions(+), 55 deletions(-) create mode 100644 backend/tests/test_note_metadata.py create mode 100644 frontend/src/utils/noteMetadata.ts diff --git a/backend/app/knowledge/parser.py b/backend/app/knowledge/parser.py index e343d39..5c7f93e 100644 --- a/backend/app/knowledge/parser.py +++ b/backend/app/knowledge/parser.py @@ -20,7 +20,6 @@ from app.errors import ApiError from app.textutils import count_tokens _HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)\s*$") -_FRONTMATTER_KEY_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$") _FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(?:[^`]*)$") @@ -261,16 +260,29 @@ def _embedding_policy(markdown: str) -> bool: return value.value.lower() in {"true", "yes", "on"} -def _extract_frontmatter(markdown: str) -> dict[str, str]: - """极简 frontmatter 解析,只提取 key: value 行。""" +def _extract_frontmatter(markdown: str) -> dict[str, str | list[str]]: + """Read YAML scalars and tag sequences without constructing arbitrary objects.""" header = _frontmatter(markdown) if header is None: return {} - meta: dict[str, str] = {} - for line in header[0].splitlines(): - m = _FRONTMATTER_KEY_RE.match(line) - if m: - meta[m.group(1).lower()] = m.group(2).strip() + try: + node = yaml.compose(header[0], Loader=yaml.SafeLoader) + except yaml.YAMLError as exc: + raise ApiError(422, "INVALID_EMBEDDING_POLICY", "Frontmatter YAML 无效,无法确认本地索引策略。") from exc + meta: dict[str, str | list[str]] = {} + if not isinstance(node, yaml.MappingNode): + return meta # The policy validation below handles unsupported documents. + for key, value in node.value: + if not isinstance(key, yaml.ScalarNode): + continue + name = key.value.lower() + if name not in {"title", "tags"}: + continue + if isinstance(value, yaml.ScalarNode): + # Keep lexical values: YAML 1.1 would otherwise turn tags like on/yes into booleans. + meta[name] = "" if value.tag == "tag:yaml.org,2002:null" else value.value + elif name == "tags" and isinstance(value, yaml.SequenceNode): + meta[name] = [item.value for item in value.value if isinstance(item, yaml.ScalarNode)] return meta @@ -282,10 +294,10 @@ def _first_heading(markdown: str) -> str | None: return None -def _parse_tags(raw: str | None) -> list[str]: +def _parse_tags(raw: str | list[str] | None) -> list[str]: + if isinstance(raw, list): + return raw if not raw: return [] raw = raw.strip() - if raw.startswith("[") and raw.endswith("]"): - raw = raw[1:-1] - return [t.strip().strip("'\"") for t in raw.split(",") if t.strip()] + return [t.strip() for t in raw.split(",") if t.strip()] diff --git a/backend/tests/test_note_metadata.py b/backend/tests/test_note_metadata.py new file mode 100644 index 0000000..7626f5a --- /dev/null +++ b/backend/tests/test_note_metadata.py @@ -0,0 +1,46 @@ +import asyncio +from datetime import datetime, timezone + +import pytest + +from app.contracts import IndexRebuildRequest +from app.knowledge.parser import parse_note +from app.services import index_service, note_service + + +@pytest.mark.parametrize(('header', 'expected'), [ + ('tags:\n- python\n- rust', ['python', 'rust']), + ('tags:\n - python\n - rust', ['python', 'rust']), + ('"tags": ["a,b", "quote\\\"tag", "path\\\\tag"] # comment', ['a,b', 'quote"tag', 'path\\tag']), + ('tags: [on, yes, "true", "001"]', ['on', 'yes', 'true', '001']), + ('tags: python, rust', ['python', 'rust']), + ('tags: []', []), + ('tags: null', []), +]) +def test_yaml_tags_are_parsed_as_complete_values(header, expected): + now = datetime.now(timezone.utc) + note = parse_note( + markdown=f'---\ntitle: "Demo: YAML"\n{header}\n---\n# Body', + file_path='demo.md', folder='', created_at=now, updated_at=now, + ) + assert note.tags == expected + assert note.title == 'Demo: YAML' + + +def test_saved_metadata_survives_full_index_rebuild(): + async def scenario(): + note = await note_service.create_note(title='Demo', markdown='# Body', folder=None, tags=['old']) + for tags, yaml_tags in [ + (['python', 'a,b', 'on'], '\n - python\n - a,b\n - on'), + ([], ' []'), + ]: + markdown = f'---\ntitle: "Demo: updated"\ntags:{yaml_tags}\n---\n# Body\n' + saved = await note_service.update_note(note.note_id, markdown=markdown, tags=tags) + assert saved.tags == tags + job = await index_service.rebuild(IndexRebuildRequest()) + assert job.status == 'completed' + restored = await note_service.get_note(note.note_id) + assert restored.tags == tags + assert restored.title == 'Demo: updated' + assert restored.markdown == markdown + asyncio.run(scenario()) diff --git a/frontend/package.json b/frontend/package.json index ffbaf5a..f6bfc1b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,7 +41,8 @@ "pinia": "^4.0.0", "shiki": "^4.4.3", "vue": "^3.5.0", - "vue-router": "^5.0.0" + "vue-router": "^5.0.0", + "yaml": "^2.9.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c648d1c..5a0cce2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -100,14 +100,17 @@ importers: version: 3.5.42(typescript@5.9.3) vue-router: specifier: ^5.0.0 - version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3)) + version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@types/node': specifier: ^22.0.0 version: 22.20.1 '@vitejs/plugin-vue': specifier: ^5.0.0 - version: 5.2.4(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3)) + version: 5.2.4(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) '@vue/test-utils': specifier: ^2.5.0 version: 2.5.0(@vue/compiler-dom@3.5.42)(@vue/server-renderer@3.5.42)(vue@3.5.42(typescript@5.9.3)) @@ -119,10 +122,10 @@ importers: version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.3(@types/node@22.20.1) + version: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)) + version: 4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)) vue-tsc: specifier: ^2.0.0 version: 2.2.12(typescript@5.9.3) @@ -2150,6 +2153,11 @@ packages: utf-8-validate: optional: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3259,9 +3267,9 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': dependencies: - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) vue: 3.5.42(typescript@5.9.3) '@vitest/expect@4.1.11': @@ -3273,13 +3281,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@6.4.3(@types/node@22.20.1))': + '@vitest/mocker@4.1.11(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -4680,7 +4688,7 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.7 - unplugin@3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)): + unplugin@3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.7 @@ -4688,7 +4696,7 @@ snapshots: optionalDependencies: esbuild: 0.25.12 rollup: 4.63.1 - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) uuid@14.0.2: {} @@ -4702,7 +4710,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.3(@types/node@22.20.1): + vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.7) @@ -4713,11 +4721,12 @@ snapshots: optionalDependencies: '@types/node': 22.20.1 fsevents: 2.3.3 + yaml: 2.9.0 - vitest@4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)): + vitest@4.1.11(@types/node@22.20.1)(happy-dom@20.11.15)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@22.20.1)) + '@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -4734,7 +4743,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 @@ -4746,7 +4755,7 @@ snapshots: vue-component-type-helpers@3.3.11: {} - vue-router@5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1))(vue@3.5.42(typescript@5.9.3)): + vue-router@5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.25.12)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)): dependencies: '@vue-macros/common': 3.1.4(vue@3.5.42(typescript@5.9.3)) '@vue/devtools-api': 8.2.1 @@ -4762,13 +4771,13 @@ snapshots: picomatch: 4.0.7 scule: 1.3.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)) + unplugin: 3.3.0(esbuild@0.25.12)(rollup@4.63.1)(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0)) unplugin-utils: 0.3.2 vue: 3.5.42(typescript@5.9.3) optionalDependencies: '@vue/compiler-sfc': 3.5.42 pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -4808,4 +4817,6 @@ snapshots: ws@8.21.3: {} + yaml@2.9.0: {} + zwitch@2.0.4: {} diff --git a/frontend/src/features/editor/noteMetadata.spec.ts b/frontend/src/features/editor/noteMetadata.spec.ts index f865541..a48473e 100644 --- a/frontend/src/features/editor/noteMetadata.spec.ts +++ b/frontend/src/features/editor/noteMetadata.spec.ts @@ -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() +}) diff --git a/frontend/src/features/editor/noteMetadata.ts b/frontend/src/features/editor/noteMetadata.ts index cd37bcd..a443081 100644 --- a/frontend/src/features/editor/noteMetadata.ts +++ b/frontend/src/features/editor/noteMetadata.ts @@ -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' diff --git a/frontend/src/services/workspaceService.spec.ts b/frontend/src/services/workspaceService.spec.ts index 0092598..d4e634c 100644 --- a/frontend/src/services/workspaceService.spec.ts +++ b/frontend/src/services/workspaceService.spec.ts @@ -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) => { diff --git a/frontend/src/services/workspaceService.ts b/frontend/src/services/workspaceService.ts index c70e99c..78c1383 100644 --- a/frontend/src/services/workspaceService.ts +++ b/frontend/src/services/workspaceService.ts @@ -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 { } export async function saveFileContent(filePath: string, content: string): Promise { - 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( diff --git a/frontend/src/utils/noteMetadata.ts b/frontend/src/utils/noteMetadata.ts new file mode 100644 index 0000000..91ed062 --- /dev/null +++ b/frontend/src/utils/noteMetadata.ts @@ -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 +}