fix: 修复笔记 YAML 标签保存与索引重建一致性
This commit is contained in:
@@ -20,7 +20,6 @@ from app.errors import ApiError
|
|||||||
from app.textutils import count_tokens
|
from app.textutils import count_tokens
|
||||||
|
|
||||||
_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)\s*$")
|
_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,})(?:[^`]*)$")
|
_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"}
|
return value.value.lower() in {"true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
def _extract_frontmatter(markdown: str) -> dict[str, str]:
|
def _extract_frontmatter(markdown: str) -> dict[str, str | list[str]]:
|
||||||
"""极简 frontmatter 解析,只提取 key: value 行。"""
|
"""Read YAML scalars and tag sequences without constructing arbitrary objects."""
|
||||||
header = _frontmatter(markdown)
|
header = _frontmatter(markdown)
|
||||||
if header is None:
|
if header is None:
|
||||||
return {}
|
return {}
|
||||||
meta: dict[str, str] = {}
|
try:
|
||||||
for line in header[0].splitlines():
|
node = yaml.compose(header[0], Loader=yaml.SafeLoader)
|
||||||
m = _FRONTMATTER_KEY_RE.match(line)
|
except yaml.YAMLError as exc:
|
||||||
if m:
|
raise ApiError(422, "INVALID_EMBEDDING_POLICY", "Frontmatter YAML 无效,无法确认本地索引策略。") from exc
|
||||||
meta[m.group(1).lower()] = m.group(2).strip()
|
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
|
return meta
|
||||||
|
|
||||||
|
|
||||||
@@ -282,10 +294,10 @@ def _first_heading(markdown: str) -> str | None:
|
|||||||
return 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:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
if raw.startswith("[") and raw.endswith("]"):
|
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||||
raw = raw[1:-1]
|
|
||||||
return [t.strip().strip("'\"") for t in raw.split(",") if t.strip()]
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -41,7 +41,8 @@
|
|||||||
"pinia": "^4.0.0",
|
"pinia": "^4.0.0",
|
||||||
"shiki": "^4.4.3",
|
"shiki": "^4.4.3",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^5.0.0"
|
"vue-router": "^5.0.0",
|
||||||
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
Generated
+28
-17
@@ -100,14 +100,17 @@ importers:
|
|||||||
version: 3.5.42(typescript@5.9.3)
|
version: 3.5.42(typescript@5.9.3)
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^5.0.0
|
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:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.20.1
|
version: 22.20.1
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^5.0.0
|
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':
|
'@vue/test-utils':
|
||||||
specifier: ^2.5.0
|
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))
|
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
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^6.0.0
|
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:
|
vitest:
|
||||||
specifier: ^4.1.11
|
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:
|
vue-tsc:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.2.12(typescript@5.9.3)
|
version: 2.2.12(typescript@5.9.3)
|
||||||
@@ -2150,6 +2153,11 @@ packages:
|
|||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
yaml@2.9.0:
|
||||||
|
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||||
|
engines: {node: '>= 14.6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
zwitch@2.0.4:
|
zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
|
|
||||||
@@ -3259,9 +3267,9 @@ snapshots:
|
|||||||
d3-selection: 3.0.0
|
d3-selection: 3.0.0
|
||||||
d3-transition: 3.0.1(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:
|
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)
|
vue: 3.5.42(typescript@5.9.3)
|
||||||
|
|
||||||
'@vitest/expect@4.1.11':
|
'@vitest/expect@4.1.11':
|
||||||
@@ -3273,13 +3281,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.1
|
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:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.11
|
'@vitest/spy': 4.1.11
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
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':
|
'@vitest/pretty-format@4.1.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4680,7 +4688,7 @@ snapshots:
|
|||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
picomatch: 4.0.7
|
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:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
@@ -4688,7 +4696,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
rollup: 4.63.1
|
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: {}
|
uuid@14.0.2: {}
|
||||||
|
|
||||||
@@ -4702,7 +4710,7 @@ snapshots:
|
|||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
vfile-message: 4.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:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
fdir: 6.5.0(picomatch@4.0.7)
|
fdir: 6.5.0(picomatch@4.0.7)
|
||||||
@@ -4713,11 +4721,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.20.1
|
'@types/node': 22.20.1
|
||||||
fsevents: 2.3.3
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.11
|
'@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/pretty-format': 4.1.11
|
||||||
'@vitest/runner': 4.1.11
|
'@vitest/runner': 4.1.11
|
||||||
'@vitest/snapshot': 4.1.11
|
'@vitest/snapshot': 4.1.11
|
||||||
@@ -4734,7 +4743,7 @@ snapshots:
|
|||||||
tinyexec: 1.3.0
|
tinyexec: 1.3.0
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tinyrainbow: 3.1.1
|
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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.20.1
|
'@types/node': 22.20.1
|
||||||
@@ -4746,7 +4755,7 @@ snapshots:
|
|||||||
|
|
||||||
vue-component-type-helpers@3.3.11: {}
|
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:
|
dependencies:
|
||||||
'@vue-macros/common': 3.1.4(vue@3.5.42(typescript@5.9.3))
|
'@vue-macros/common': 3.1.4(vue@3.5.42(typescript@5.9.3))
|
||||||
'@vue/devtools-api': 8.2.1
|
'@vue/devtools-api': 8.2.1
|
||||||
@@ -4762,13 +4771,13 @@ snapshots:
|
|||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
scule: 1.3.0
|
scule: 1.3.0
|
||||||
tinyglobby: 0.2.17
|
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
|
unplugin-utils: 0.3.2
|
||||||
vue: 3.5.42(typescript@5.9.3)
|
vue: 3.5.42(typescript@5.9.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@vue/compiler-sfc': 3.5.42
|
'@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))
|
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:
|
transitivePeerDependencies:
|
||||||
- '@farmfe/core'
|
- '@farmfe/core'
|
||||||
- '@rspack/core'
|
- '@rspack/core'
|
||||||
@@ -4808,4 +4817,6 @@ snapshots:
|
|||||||
|
|
||||||
ws@8.21.3: {}
|
ws@8.21.3: {}
|
||||||
|
|
||||||
|
yaml@2.9.0: {}
|
||||||
|
|
||||||
zwitch@2.0.4: {}
|
zwitch@2.0.4: {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
|
import { parseDocument } from 'yaml'
|
||||||
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
|
import { splitNoteMetadata, updateMetadataTags } from './noteMetadata'
|
||||||
|
|
||||||
it('renders legacy properties and saves real frontmatter without losing other fields', () => {
|
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')
|
expect(metadata.body).toBe('\n# 正文\n')
|
||||||
const prefix = updateMetadataTags(metadata, ['编程', '学习', '学习'])
|
const prefix = updateMetadataTags(metadata, ['编程', '学习', '学习'])
|
||||||
expect(prefix).toContain('embedding_local_only: true')
|
expect(prefix).toContain('embedding_local_only: true')
|
||||||
expect(prefix).toContain('tags: ["编程","学习"]')
|
|
||||||
expect(prefix.startsWith('---\n')).toBe(true)
|
expect(prefix.startsWith('---\n')).toBe(true)
|
||||||
expect(splitNoteMetadata(prefix + metadata.body)!.tags).toEqual(['编程', '学习'])
|
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', () => {
|
it('does not mistake ordinary Markdown for metadata', () => {
|
||||||
expect(splitNoteMetadata('---\nA paragraph\n---\n')).toBeNull()
|
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): 第三阶段顶部「段落 → 导入为笔记属性」复用属性解析边界,
|
export { splitNoteMetadata, updateMetadataTags } from '@/utils/noteMetadata'
|
||||||
// 补齐无损 YAML、冲突合并与可撤销事务;见 docs/contracts/Tauri-Rust桌面客户端需求说明-第三阶段.md。
|
export type { NoteMetadata } from '@/utils/noteMetadata'
|
||||||
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`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -50,6 +50,25 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('workspaceService backend adapter', () => {
|
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 () => {
|
it('opens the configured Vault and reads/saves Markdown through Note API', async () => {
|
||||||
const fetchMock = vi.mocked(fetch)
|
const fetchMock = vi.mocked(fetch)
|
||||||
fetchMock.mockImplementation(async (input, init) => {
|
fetchMock.mockImplementation(async (input, init) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
import apiClient from './apiClient'
|
import apiClient from './apiClient'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import * as noteService from './noteService'
|
import * as noteService from './noteService'
|
||||||
|
import { splitNoteMetadata } from '@/utils/noteMetadata'
|
||||||
|
|
||||||
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
||||||
export interface VaultInfo {
|
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> {
|
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(
|
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