UNPKG

obsidian-mcp-server

Version:

MCP server for Obsidian vaults — read, write, search, and surgically edit notes, tags, and frontmatter via the Local REST API plugin. STDIO or Streamable HTTP.

230 lines 7.93 kB
/** * @fileoverview Read-modify-write helpers for the YAML frontmatter block of a * note's raw content. Used by the composed manage-frontmatter / manage-tags * tools when the upstream Local REST API has no single-call equivalent. * @module services/obsidian/frontmatter-ops */ import { dump as yamlDump, load as yamlLoad } from 'js-yaml'; const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; function splice(content) { const m = FM_RE.exec(content); if (!m) return { hasFrontmatter: false, yamlText: '', body: content }; return { hasFrontmatter: true, yamlText: m[1] ?? '', body: content.slice(m[0].length), }; } function loadFrontmatter(yamlText) { const loaded = yamlLoad(yamlText); return loaded && typeof loaded === 'object' ? loaded : {}; } function emit(frontmatter, body) { const keys = Object.keys(frontmatter); if (keys.length === 0) { return body.replace(/^\s+/, ''); } const yamlText = yamlDump(frontmatter, { lineWidth: 1000, noRefs: true }); return `---\n${yamlText.trimEnd()}\n---\n${body.startsWith('\n') ? body.slice(1) : body}`; } /** * Returns the full file content with `key` removed from the frontmatter. * If the file has no frontmatter or the key isn't present, returns content * unchanged. */ export function deleteFrontmatterKey(content, key) { const { hasFrontmatter, yamlText, body } = splice(content); if (!hasFrontmatter) return content; const fm = loadFrontmatter(yamlText); if (!(key in fm)) return content; delete fm[key]; return emit(fm, body); } /** * Add or remove tags across frontmatter (`tags:` array) and inline `#tag` * syntax. Inline occurrences inside fenced code blocks are left alone — they * are code, not tags. */ export function reconcileTags(content, tags, operation, location) { const norm = (t) => t.replace(/^#+/, '').trim(); const wanted = tags.map(norm).filter((t) => t.length > 0); const applied = new Set(); const skipped = new Set(); let updated = content; if (location === 'frontmatter' || location === 'both') { updated = mutateFrontmatterTags(updated, wanted, operation, applied, skipped); } if (location === 'inline' || location === 'both') { updated = mutateInlineTags(updated, wanted, operation, applied, skipped); } // For location='both', a tag that was already-in-frontmatter may have been // missing inline (or vice versa). If applied is non-empty for the tag, drop // it from skipped. for (const t of applied) skipped.delete(t); return { content: updated, applied: [...applied], skipped: [...skipped] }; } function mutateFrontmatterTags(content, tags, operation, applied, skipped) { const { hasFrontmatter, yamlText, body } = splice(content); const fm = hasFrontmatter ? { ...loadFrontmatter(yamlText) } : {}; const existing = normalizeTagList(fm.tags); const set = new Set(existing); let changed = false; for (const tag of tags) { if (operation === 'add') { if (set.has(tag)) skipped.add(tag); else { set.add(tag); applied.add(tag); changed = true; } } else { if (set.has(tag)) { set.delete(tag); applied.add(tag); changed = true; } else { skipped.add(tag); } } } if (!changed) return content; const ordered = [...set]; if (ordered.length === 0) { delete fm.tags; } else { fm.tags = ordered; } if (Object.keys(fm).length === 0) { return body.replace(/^\s+/, ''); } return emit(fm, body); } function normalizeTagList(value) { if (Array.isArray(value)) { return value .filter((v) => typeof v === 'string') .map((v) => v.replace(/^#+/, '').trim()) .filter((v) => v.length > 0); } if (typeof value === 'string') { return value .split(/[\s,]+/) .map((v) => v.replace(/^#+/, '').trim()) .filter((v) => v.length > 0); } return []; } const FENCED_CODE_BLOCK = /(```[\s\S]*?```|~~~[\s\S]*?~~~)/g; const INLINE_CODE = /(`[^`\n]+`)/g; function mutateInlineTags(content, tags, operation, applied, skipped) { const segments = splitProtectedSegments(content); let updatedNonCode = false; if (operation === 'add') { for (const tag of tags) { const re = makeInlineTagRegex(tag); const present = segments.some((s) => !s.protected && re.test(s.text)); if (present) { skipped.add(tag); } else { applied.add(tag); } } const additions = tags .filter((t) => applied.has(t)) .map((t) => `#${t}`) .join(' '); if (additions.length > 0) { const trailing = segments.length > 0 ? (segments[segments.length - 1] ?? null) : null; if (trailing && !trailing.protected) { const sep = trailing.text.endsWith('\n') ? '' : '\n'; trailing.text = `${trailing.text}${sep}${additions}\n`; updatedNonCode = true; } else { segments.push({ protected: false, text: `\n${additions}\n` }); updatedNonCode = true; } } } else { for (const tag of tags) { const re = makeInlineTagRegex(tag); let found = false; for (const s of segments) { if (s.protected) continue; if (re.test(s.text)) { s.text = s.text.replace(re, (_full, leading) => leading); // collapse double spaces left behind s.text = s.text.replace(/[ \t]{2,}/g, ' ').replace(/ \n/g, '\n'); found = true; updatedNonCode = true; } } if (found) applied.add(tag); else skipped.add(tag); } } if (!updatedNonCode) return content; return segments.map((s) => s.text).join(''); } function splitProtectedSegments(content) { const segments = []; let cursor = 0; const re = new RegExp(`${FENCED_CODE_BLOCK.source}|${INLINE_CODE.source}`, 'g'); for (;;) { const m = re.exec(content); if (!m) break; const matched = m[0] ?? ''; if (m.index > cursor) { segments.push({ protected: false, text: content.slice(cursor, m.index) }); } segments.push({ protected: true, text: matched }); cursor = m.index + matched.length; } if (cursor < content.length) { segments.push({ protected: false, text: content.slice(cursor) }); } return segments; } function makeInlineTagRegex(tag) { const escaped = tag.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); return new RegExp(`(^|[^\\w/])#${escaped}(?![\\w/-])`, 'g'); } /** Read-only helpers for `obsidian_manage_tags list`. */ export function listTagsFromContent(content, frontmatter) { const fmTags = normalizeTagList(frontmatter.tags); const inline = []; const seen = new Set(); for (const seg of splitProtectedSegments(content)) { if (seg.protected) continue; const re = /(^|[^\w/])#([a-zA-Z][\w/-]*)/g; for (;;) { const m = re.exec(seg.text); if (!m) break; const t = m[2]; if (t && !seen.has(t)) { seen.add(t); inline.push(t); } } } return { frontmatter: fmTags, inline }; } //# sourceMappingURL=frontmatter-ops.js.map