From 486c1fd88a3a00fc67d8c14f746a822b510b3270 Mon Sep 17 00:00:00 2001 From: Junerey Date: Wed, 22 Jul 2026 09:04:45 +0700 Subject: [PATCH] ui: include frontmatter tags in tags sidebar section --- apps/desktop/src/main/vault.test.ts | 28 ++++++++- apps/desktop/src/main/vault.ts | 73 ++++++++++++++++++++++-- apps/desktop/src/mcp/vault-ops.ts | 13 ++++- apps/server/internal/vault/parse.go | 24 ++++++-- apps/server/internal/vault/parse_test.go | 24 ++++++++ packages/app-core/src/lib/tags.test.ts | 13 +++++ packages/app-core/src/lib/tags.ts | 64 ++++++++++++++++++++- 7 files changed, 221 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index b2037025..21ef9ea9 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -624,6 +624,32 @@ describe('listNotes metadata parsing', () => { expect(note?.tags).toEqual(['realtag']) }) + it('indexes frontmatter tags as first-class note tags', async () => { + const root = await makeTempDir('zennotes-meta-frontmatter-tags-') + await ensureVaultLayout(root) + const rel = 'inbox/frontmatter.md' + await writeFile( + path.join(root, rel), + '---\ntags: [frontmatter, "#quoted", project/nested]\ntitle: #ignored\n---\n\n#inline\n', + 'utf8' + ) + + const notes = await listNotes(root) + const note = notes.find((n) => n.path === rel) + expect(note?.tags).toEqual(['frontmatter', 'quoted', 'project/nested', 'inline']) + }) + + it('indexes block-list frontmatter tags', async () => { + const root = await makeTempDir('zennotes-meta-frontmatter-tag-list-') + await ensureVaultLayout(root) + const rel = 'inbox/frontmatter-list.md' + await writeFile(path.join(root, rel), '---\ntags:\n - daily\n - "#log"\n---\n\nBody\n', 'utf8') + + const notes = await listNotes(root) + const note = notes.find((n) => n.path === rel) + expect(note?.tags).toEqual(['daily', 'log']) + }) + it('detects only local asset references as attachments', async () => { const root = await makeTempDir('zennotes-meta-assets-') await ensureVaultLayout(root) @@ -733,7 +759,7 @@ describe('listNotes metadata cache', () => { await writeFile( path.join(root, '.zennotes', 'note-meta-cache-v1.json'), `${JSON.stringify({ - version: 2, + version: 3, entries: [ { path: rel, diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 78c3a272..eb561076 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -85,7 +85,7 @@ const DELETED_ASSETS_DIR = 'deleted-assets' const DELETED_ASSET_META = '.zn-deleted.json' const VAULT_SETTINGS_FILE = 'vault.json' const NOTE_META_CACHE_FILE = 'note-meta-cache-v1.json' -const NOTE_META_CACHE_VERSION = 2 +const NOTE_META_CACHE_VERSION = 3 const NOTE_COMMENTS_DIR = 'comments' const NOTE_COMMENTS_SUFFIX = '.comments.json' const RESERVED_ROOT_NAMES = new Set([...FOLDERS, ...ATTACHMENTS_DIRS, INTERNAL_VAULT_DIR]) @@ -1635,16 +1635,77 @@ function localAssetTargetKind(target: string): ImportedAssetKind | null { return 'file' } -/** Pull unique `#tags` out of markdown text, ignoring fenced/inline code. */ +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/ + +/** Pull unique tags from the first-class `tags` frontmatter field and inline `#tags`. */ function extractTags(body: string): string[] { - if (!body.includes('#')) return [] - const stripped = stripCodeContent(body) - const matches = stripped.match(/(?:^|\s)#(\p{L}[\p{L}\d_/-]*)/gu) || [] const seen = new Set() - for (const m of matches) seen.add(m.trim().slice(1)) + for (const tag of extractFrontmatterTags(body)) seen.add(tag) + + const markdownBody = body.replace(FRONTMATTER_RE, '') + if (markdownBody.includes('#')) { + const stripped = stripCodeContent(markdownBody) + const matches = stripped.match(/(?:^|\s)#(\p{L}[\p{L}\d_/-]*)/gu) || [] + for (const m of matches) seen.add(m.trim().slice(1)) + } return [...seen] } +function extractFrontmatterTags(body: string): string[] { + const match = FRONTMATTER_RE.exec(body) + if (!match) return [] + const data = parseSimpleFrontmatter(match[1] ?? '') + return data.get('tags') ?? [] +} + +function parseSimpleFrontmatter(block: string): Map { + const data = new Map() + let listKey: string | null = null + for (const rawLine of block.split(/\r?\n/)) { + const trimmed = rawLine.trim() + if (!trimmed || trimmed.startsWith('#')) continue + + const item = /^\s*-\s+(.*)$/.exec(rawLine) + if (listKey && /^\s/.test(rawLine) && item) { + const value = normalizeFrontmatterTag(item[1] ?? '') + if (value) data.set(listKey, [...(data.get(listKey) ?? []), value]) + continue + } + + const kv = /^([A-Za-z0-9_][\w-]*)\s*:\s*(.*)$/.exec(rawLine) + if (!kv) { + listKey = null + continue + } + + const key = (kv[1] ?? '').toLowerCase() + const rest = (kv[2] ?? '').trim() + if (!rest) { + listKey = key + data.set(key, []) + continue + } + + listKey = null + const values = rest.startsWith('[') && rest.endsWith(']') + ? rest.slice(1, -1).split(',') + : [rest] + data.set(key, values.map(normalizeFrontmatterTag).filter(Boolean)) + } + return data +} + +function normalizeFrontmatterTag(raw: string): string { + let value = raw.trim() + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + return value.trim().replace(/^#/, '') +} + /** * Whether a note body references at least one local asset (any * markdown link / image whose href looks like a relative file path diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 1e1f63e6..ac04c700 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -404,9 +404,16 @@ function stripCodeContent(body: string): string { } function extractTags(body: string): string[] { - const stripped = stripCodeContent(body) - const matches = stripped.match(/(?:^|\s)#(\p{L}[\p{L}\d_/-]*)/gu) || [] const seen = new Set() + const fm = body.match(FRONTMATTER_RE) + for (const tag of asArray(fm ? parseTaskFrontmatter(fm[1] ?? '').tags : undefined)) { + const normalized = tag.trim().replace(/^#/, '') + if (normalized) seen.add(normalized) + } + + const markdownBody = body.replace(FRONTMATTER_RE, '') + const stripped = stripCodeContent(markdownBody) + const matches = stripped.match(/(?:^|\s)#(\p{L}[\p{L}\d_/-]*)/gu) || [] for (const m of matches) seen.add(m.trim().slice(1)) return [...seen] } @@ -876,7 +883,7 @@ export async function searchText( /* ---------- Tasks ---------------------------------------------------- */ -const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/ +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/ // Optional whitespace after the colon so a spaced `due: 2026-01-01` parses like // `due:2026-01-01` (kept in sync with packages/shared-domain/src/tasks.ts). (#343) const INLINE_DUE_RE = /(?:^|\s)due:\s*(\S+)/i diff --git a/apps/server/internal/vault/parse.go b/apps/server/internal/vault/parse.go index 28427449..55db429b 100644 --- a/apps/server/internal/vault/parse.go +++ b/apps/server/internal/vault/parse.go @@ -16,7 +16,7 @@ var ( wikilinkRe = regexp.MustCompile(`(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) linkRe = regexp.MustCompile(`(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`) embedRe = regexp.MustCompile(`!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) - frontmatterRe = regexp.MustCompile(`(?s)\A---\n(.*?)\n---\n?`) + frontmatterRe = regexp.MustCompile(`(?s)\A---\r?\n(.*?)\r?\n---\r?\n?`) headingRe = regexp.MustCompile(`(?m)^#{1,6}\s+`) imageMdRe = regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`) mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`) @@ -77,14 +77,26 @@ func stripCodeContent(body string) string { return out } -// ExtractTags returns unique #tags from a markdown body, ignoring code. +// ExtractTags returns unique tags from first-class frontmatter `tags` and inline #tags. func ExtractTags(body string) []string { - if !strings.Contains(body, "#") { - return []string{} - } - stripped := stripCodeContent(body) seen := map[string]bool{} out := []string{} + if m := frontmatterRe.FindStringSubmatch(body); len(m) >= 2 { + fm := parseTaskFrontmatter(m[1]) + for _, raw := range fm["tags"] { + tag := strings.TrimPrefix(strings.TrimSpace(raw), "#") + if tag != "" && !seen[tag] { + seen[tag] = true + out = append(out, tag) + } + } + } + + markdownBody := frontmatterRe.ReplaceAllString(body, "") + if !strings.Contains(markdownBody, "#") { + return out + } + stripped := stripCodeContent(markdownBody) for _, m := range tagRe.FindAllStringSubmatch(stripped, -1) { if len(m) >= 2 { tag := m[1] diff --git a/apps/server/internal/vault/parse_test.go b/apps/server/internal/vault/parse_test.go index a7bf1181..1d2253f5 100644 --- a/apps/server/internal/vault/parse_test.go +++ b/apps/server/internal/vault/parse_test.go @@ -69,6 +69,30 @@ func TestExtractTagsIgnoresIndentedFence(t *testing.T) { } } +func TestExtractTagsIncludesFrontmatterTags(t *testing.T) { + body := "---\ntags: [frontmatter, \"#quoted\", project/nested]\ntitle: #ignored\n---\n\n#inline" + + tags := ExtractTags(body) + want := []string{"frontmatter", "quoted", "project/nested", "inline"} + if len(tags) != len(want) { + t.Fatalf("ExtractTags() = %#v, want %#v", tags, want) + } + for i := range want { + if tags[i] != want[i] { + t.Fatalf("ExtractTags() = %#v, want %#v", tags, want) + } + } +} + +func TestExtractTagsIncludesFrontmatterTagList(t *testing.T) { + body := "---\ntags:\n - daily\n - \"#log\"\n---\n\nBody" + + tags := ExtractTags(body) + if len(tags) != 2 || tags[0] != "daily" || tags[1] != "log" { + t.Fatalf("ExtractTags() = %#v, want [daily log]", tags) + } +} + // #205: tags in non-Latin scripts (Cyrillic, CJK, …) must be recognized. func TestExtractTagsUnicode(t *testing.T) { body := "Заметки: #тест #ошибка/баг и 笔记 #标签 plus #ascii-1 done" diff --git a/packages/app-core/src/lib/tags.test.ts b/packages/app-core/src/lib/tags.test.ts index ed9652c3..7f4610db 100644 --- a/packages/app-core/src/lib/tags.test.ts +++ b/packages/app-core/src/lib/tags.test.ts @@ -23,6 +23,19 @@ describe('extractTags — code fences are never scanned for tags (#293)', () => it('extracts a real tag sitting right after a closed indented fence', () => { expect(extractTags('- item\n ```\n #include\n ```\n #after')).toEqual(['after']) }) + + it('includes first-class frontmatter tags', () => { + expect(extractTags('---\ntags: [frontmatter, "#quoted", project/nested]\ntitle: #ignored\n---\n\n#inline')).toEqual([ + 'frontmatter', + 'quoted', + 'project/nested', + 'inline' + ]) + }) + + it('supports block-list frontmatter tags', () => { + expect(extractTags('---\ntags:\n - daily\n - "#log"\n---\n\nbody')).toEqual(['daily', 'log']) + }) }) describe('matchesSelectedTags', () => { diff --git a/packages/app-core/src/lib/tags.ts b/packages/app-core/src/lib/tags.ts index 5883a9f8..e80c9384 100644 --- a/packages/app-core/src/lib/tags.ts +++ b/packages/app-core/src/lib/tags.ts @@ -13,10 +13,15 @@ * - Heading markers (`#`, `##`, …) are not a hashtag because the * character after the hash is a space, not a letter. */ +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/ + export function extractTags(body: string): string[] { - const stripped = stripCodeContent(body) - const regex = /(?:^|\s)#(\p{L}[\p{L}\d_/-]*)/gu const seen = new Set() + for (const tag of extractFrontmatterTags(body)) seen.add(tag) + + const markdownBody = body.replace(FRONTMATTER_RE, '') + const stripped = stripCodeContent(markdownBody) + const regex = /(?:^|\s)#(\p{L}[\p{L}\d_/-]*)/gu let m: RegExpExecArray | null while ((m = regex.exec(stripped)) !== null) { seen.add(m[1]) @@ -24,6 +29,61 @@ export function extractTags(body: string): string[] { return [...seen] } +function extractFrontmatterTags(body: string): string[] { + const match = FRONTMATTER_RE.exec(body) + if (!match) return [] + const data = parseSimpleFrontmatter(match[1] ?? '') + return data.get('tags') ?? [] +} + +function parseSimpleFrontmatter(block: string): Map { + const data = new Map() + let listKey: string | null = null + for (const rawLine of block.split(/\r?\n/)) { + const trimmed = rawLine.trim() + if (!trimmed || trimmed.startsWith('#')) continue + + const item = /^\s*-\s+(.*)$/.exec(rawLine) + if (listKey && /^\s/.test(rawLine) && item) { + const value = normalizeFrontmatterTag(item[1] ?? '') + if (value) data.set(listKey, [...(data.get(listKey) ?? []), value]) + continue + } + + const kv = /^([A-Za-z0-9_][\w-]*)\s*:\s*(.*)$/.exec(rawLine) + if (!kv) { + listKey = null + continue + } + + const key = (kv[1] ?? '').toLowerCase() + const rest = (kv[2] ?? '').trim() + if (!rest) { + listKey = key + data.set(key, []) + continue + } + + listKey = null + const values = rest.startsWith('[') && rest.endsWith(']') + ? rest.slice(1, -1).split(',') + : [rest] + data.set(key, values.map(normalizeFrontmatterTag).filter(Boolean)) + } + return data +} + +function normalizeFrontmatterTag(raw: string): string { + let value = raw.trim() + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + return value.trim().replace(/^#/, '') +} + /** * Blank out fenced and inline code so the tag scanner never reads code as a * tag. Fence detection is line-based and indentation-tolerant: a fence nested