<%* /** ====================== * Batch Frontmatter → Body Snapshot (Markdown Table, preserves [[wikilinks]]) * ====================== * Configure filters here: */ const TARGET_FOLDER = null; // e.g. "Notes/Projects" or null for whole vault const REQUIRED_TAG = null; // e.g. "publish" (without #) or null to ignore const SKIP_FILES_WITHOUT_FM = true; // skip notes that have no frontmatter const START = "<!-- YAML-SNAPSHOT:START -->"; const END = "<!-- YAML-SNAPSHOT:END -->"; /** ---------- Helpers ---------- */ function fmtScalar(v) { if (v === null || v === undefined) return 'null'; if (typeof v === 'boolean' || typeof v === 'number') return String(v); const s = String(v); // Preserve Obsidian wiki-links as-is if (/^\[\[.*\]\]$/.test(s)) return s; return s; } function flattenOnce(x) { if (Array.isArray(x) && x.length === 1 && Array.isArray(x[0])) return x[0]; return x; } /** Extract raw YAML frontmatter block text (without --- lines) */ function extractFrontmatterBlock(text) { if (!text.startsWith('---')) return null; const lines = text.split('\n'); let endIdx = -1; for (let i = 1; i < lines.length; i++) { if (/^---\s*$/.test(lines[i])) { endIdx = i; break; } } if (endIdx === -1) return null; return lines.slice(1, endIdx).join('\n'); } /** Locate the closing '---' (second delimiter) and return the index AFTER it */ function findAfterClosingFrontmatterIndex(text) { if (!text.startsWith('---')) return -1; const lines = text.split('\n'); let endLine = -1; for (let i = 1; i < lines.length; i++) { if (/^---\s*$/.test(lines[i])) { endLine = i; break; } } if (endLine === -1) return -1; // Compute char index just after that line (including its newline) let pos = 0; for (let i = 0; i <= endLine; i++) pos += lines[i].length + 1; return pos; } function parseRawTopListsByKey(fmText) { const map = {}; if (!fmText) return map; const lines = fmText.split('\n'); let currentKey = null; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const keyMatch = line.match(/^([^\s][^:]*):\s*(.*)$/); if (keyMatch) { currentKey = keyMatch[1].trim(); if (!map[currentKey]) map[currentKey] = []; continue; } if (currentKey) { const bulletMatch = line.match(/^\s*-\s+(.*)$/); if (bulletMatch) { const rawVal = bulletMatch[1].trim(); map[currentKey].push(rawVal); continue; } if (/^[^\s]/.test(line)) currentKey = null; } } return map; } /** Convert frontmatter to table rows */ function toTableRows(obj, rawTopLists = {}, parentKey = null) { const rows = []; if (Array.isArray(obj)) { const arr = flattenOnce(obj); if (!arr.length) return ['_none_']; const rawList = parentKey ? (rawTopLists[parentKey] || []) : []; return arr.map((item, i) => { if (rawList[i]) return rawList[i]; if (Array.isArray(item)) return flattenOnce(item).map(sub => fmtScalar(sub)).join(', '); if (item && typeof item === 'object') return JSON.stringify(item); return fmtScalar(item); }); } for (const [k, v] of Object.entries(obj || {})) { if (Array.isArray(v)) { const vals = toTableRows(v, rawTopLists, k); rows.push([`**${k}**`, vals.join(', ')]); } else if (v && typeof v === 'object') { rows.push([`**${k}**`, JSON.stringify(v)]); } else { rows.push([`**${k}**`, fmtScalar(v)]); } } return rows; } /** ---------- Filters ---------- */ function hasRequiredTag(cache, tag) { if (!tag) return true; const allTags = (cache?.tags || []).map(t => t.tag.replace(/^#/, '')); return allTags.includes(tag); } function inTargetFolder(tfile, folder) { if (!folder) return true; return tfile.path.startsWith(folder.endsWith('/') ? folder : folder + '/'); } /** Replace ALL existing snapshot blocks, then inject AFTER frontmatter closing --- * with clean spacing (exactly one newline after END). */ function upsertAfterFrontmatter(content, block) { // 1) Remove existing snapshot blocks AND any trailing blank space they left const blockRegex = new RegExp(`${START}[\\s\\S]*?${END}[ \\t]*(\\r?\\n)*`, 'g'); let stripped = content.replace(blockRegex, ''); // 2) Find canonical insertion point: right after closing frontmatter --- const insertPos = findAfterClosingFrontmatterIndex(stripped); if (insertPos === -1) { // No frontmatter present; just put block at the top with clean spacing stripped = stripped.replace(/^\s+/, ''); // trim leading whitespace let next = `${block}\n\n${stripped}`; // Safety collapse if anything created 2+ newlines after END const collapseAfterEnd = new RegExp(`${END}[ \\t]*\\r?\\n{2,}`, 'g'); next = next.replace(collapseAfterEnd, `${END}\n`); return next; } // 3) Split around insertion point and normalize whitespace const beforeRaw = stripped.slice(0, insertPos); const afterRaw = stripped.slice(insertPos); // Ensure exactly one newline at end of "before" const before = beforeRaw.replace(/[ \t]*(\r?\n)*$/, '\n'); // Collapse 2+ leading newlines in "after" to a single newline const after = afterRaw.replace(/^[ \t]*(\r?\n){2,}/, '\n'); // 4) Insert with exactly one newline after END let next = `${before}${block}\n${after}`; // 5) Final safety: collapse any 2+ blank lines after END to exactly one const collapseAfterEnd = new RegExp(`${END}[ \\t]*\\r?\\n{2,}`, 'g'); next = next.replace(collapseAfterEnd, `${END}\n`); return next; } /** ---------- Main ---------- */ const files = app.vault.getMarkdownFiles(); let processed = 0, skipped = 0, changed = 0; for (const f of files) { if (!inTargetFolder(f, TARGET_FOLDER)) { skipped++; continue; } const cache = app.metadataCache.getFileCache(f) || {}; if (!hasRequiredTag(cache, REQUIRED_TAG)) { skipped++; continue; } const fm = cache.frontmatter; if (!fm && SKIP_FILES_WITHOUT_FM) { skipped++; continue; } const cur = await app.vault.read(f); const fmRaw = extractFrontmatterBlock(cur); const rawTopLists = parseRawTopListsByKey(fmRaw); const rows = toTableRows(fm || {}, rawTopLists); let table = ''; if (rows.length) { table += `| Key | Value |\n| --- | ----- |\n`; for (const row of rows) { if (Array.isArray(row)) { const [key, val] = row; table += `| ${key} | ${val} |\n`; } else { table += `| | ${row} |\n`; } } } const block = `${START}\n${table}\n${END}`; const next = upsertAfterFrontmatter(cur, block); if (next !== cur) { await app.vault.modify(f, next); changed++; } processed++; } tR += ``; %>