Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion apps/desktop/src/main/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,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)
Expand Down Expand Up @@ -707,7 +733,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,
Expand Down
73 changes: 67 additions & 6 deletions apps/desktop/src/main/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([...FOLDERS, ...ATTACHMENTS_DIRS, INTERNAL_VAULT_DIR])
Expand Down Expand Up @@ -1632,16 +1632,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<string>()
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<string, string[]> {
const data = new Map<string, string[]>()
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
Expand Down
13 changes: 10 additions & 3 deletions apps/desktop/src/mcp/vault-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,9 +402,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<string>()
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]
}
Expand Down Expand Up @@ -874,7 +881,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
Expand Down
24 changes: 18 additions & 6 deletions apps/server/internal/vault/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`\[([^\]]+)\]\([^)]*\)`)
Expand Down Expand Up @@ -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]
Expand Down
24 changes: 24 additions & 0 deletions apps/server/internal/vault/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions packages/app-core/src/lib/tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
64 changes: 62 additions & 2 deletions packages/app-core/src/lib/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,77 @@
* - 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<string>()
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])
}
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<string, string[]> {
const data = new Map<string, string[]>()
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
Expand Down