<%*
/** ======================
* 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 += ``;
%>