---
tags:
- tagfeed-live
cssclasses:
- tagfeed
words:
本页由 DataviewJS 实时计算,笔记有变动时自动刷新,无需任何后台服务。
// ============================================================
// tagfeed-live:Logseq 风格标签实时聚合(标准 DataviewJS 脚本)
//
// 用法:
// 1. 新建一个笔记(例如 00index/标签聚合/标签名.md)
// 2. 在笔记里插入一个 dataviewjs 代码块(三个反引号 + dataviewjs),
// 把本脚本全部粘贴进去
// 3. 只需要改下面「配置区」的两行:
// - targetTag 换成你要聚合的标签(不带 #,中英文都可以;多个标签写成数组 ["a", "b"])
// - excludedPaths 换成你想排除的文件夹
// 4. 完成。以后任何笔记里出现/删除这个标签,本页自动刷新,
// 不需要跑任何命令(Dataview 会在文件变动后自动重新渲染)。
// ============================================================
// ---------------- 配置区(通常只改这里) ----------------
const targetTag = "Prompt"; // 要聚合的标签(不带 #);多个标签用数组:["lyric", "歌词"]
const excludedPaths = ["X2.Archived", "00index", "X0.Clippings", "X1.Knomo", "02DS/02copilot", ".agents", ".claude", ".copilot", ".opencode", ".smart-env", ".workbuddy", ".trash"]; // 要排除的文件夹/路径前缀(vault 相对路径)
// 例如:["00index", "模板", "X1.Knomo"]
// 补充说明:
// - 所有隐藏文件/文件夹(. 开头:.agents/.claude/.copilot/.opencode/.smart-env/.workbuddy/.trash 等)与 X.Attachment 始终跳过
// - tagfeed 系统页会自动跳过:静态聚合页(文件头含 <!-- tagfeed 标记)
// 和实时页(frontmatter 带 tagfeed / tagfeed-live 标签),
// 无需手动排除 00index
// - 本脚本所在的当前笔记也会自动跳过
// ---------------------------------------------------------
try {
const SKIP_DIRS = [".obsidian", ".git", ".trash", "X.Attachment"];
const MARKER = "<!-- tagfeed";
const WEEKDAYS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const PREV_CHARS = 500; // 预览字数上限:≤上限全文展示;>上限截断并附「查看全文」链接
const esc = s => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const TAG_LIST = Array.isArray(targetTag) ? targetTag : [targetTag];
const TAG_RE = new RegExp("(?<![\\w\\u4e00-\\u9fff#])#(?:" + TAG_LIST.map(esc).join("|") + ")(?![\\w\\u4e00-\\u9fff/\\-])");
const ANY_TAG = /(?<![\w#])#([\w\u4e00-\u9fff][\w\u4e00-\u9fff/\-]*)/g;
const LIST_RE = /^(\s*)([-*+]|\d+[.)])(?:\s+(.*))?$/;
const HEAD_RE = /^(#{1,6})\s+(.*)$/;
const DATEH_RE = /\[\[(\d{4})-(\d{2})-(\d{2})\]\]/;
const TIME_RE = /(\d{1,2}):(\d{2})(?::(\d{2}))?/;
const FENCE_RE = /^\s*(`{3,}|~~~)/;
const indentOf = ln => { // tab 按 2 空格计(与 Python 版一致)
const expanded = ln.replace(/\t/g, " ");
return expanded.length - expanded.replace(/^ +/, "").length;
};
function cleanMatch(ln) {
let s = ln.replace(/`[^`\n]*`/g, "");
s = s.replace(/%%[\s\S]*?%%/g, "");
s = s.replace(/<!--[\s\S]*?-->/g, "");
return s;
}
function vdate(y, m, d) {
const dt = new Date(y, m - 1, d);
return (dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d) ? dt : null;
}
function dateFromName(name) { // 文件名含 YYYY-MM-DD,或日记命名 26.0805 -> 2026-08-05
const m1 = name.match(/(\d{4})-(\d{2})-(\d{2})/);
if (m1) {
const d1 = vdate(+m1[1], +m1[2], +m1[3]);
if (d1) return d1;
}
const m = name.match(/^(\d{2})\.(\d{2})(\d{2})(?:\D|$)/);
return m ? vdate(2000 + +m[1], +m[2], +m[3]) : null;
}
function parseFrontmatter(text) {
const lines = text.split("\n");
if (!lines.length || lines[0].trim() !== "---") return { tags: new Set(), date: null, bodyStart: 0 };
const n = Math.min(lines.length, 200);
for (let i = 1; i < n; i++) {
const t = lines[i].trim();
if (t === "---" || t === "...") {
const block = lines.slice(1, i).join("\n");
const tags = new Set();
const im = block.match(/^tags:\s*\[(.*?)\]/m);
if (im) {
for (const x0 of im[1].split(",")) {
const x = x0.trim().replace(/^["']|["']$/g, "").replace(/^#/, "").trim();
if (x) tags.add(x);
}
} else {
const bm = block.match(/^tags:\s*\n((?:[ \t]+-[ \t]+.*\n?)+)/m);
if (bm) for (const ln of bm[1].split("\n")) {
const m2 = ln.match(/^\s*-\s*["']?([\w\u4e00-\u9fff/\-]+)["']?\s*$/);
if (m2) tags.add(m2[1]);
}
}
let fdate = null;
for (const key of ["date", "created", "published"]) {
const dm = block.match(new RegExp("^" + key + ":\\s*[\"']?(\\d{4})-(\\d{2})-(\\d{2})", "m"));
if (dm) { fdate = vdate(+dm[1], +dm[2], +dm[3]); break; }
}
return { tags, date: fdate, bodyStart: i + 1 };
}
}
return { tags: new Set(), date: null, bodyStart: 0 };
}
const stripTag = s => String(s).replace(TAG_RE, "").replace(/[ \t]{2,}/g, " ").trim();
function scanText(text, note, relpath, fileDate, mtimeDate) {
const fm = parseFrontmatter(text);
if (fm.tags.has("tagfeed") || fm.tags.has("tagfeed-live")) return []; // 跳过 tagfeed 系统页(静态聚合页/实时页)
const lines = text.split("\n");
const entries = [], seen = new Set();
let order = 0, inFence = false, inPct = false, inHtml = false;
let curHead = null, curHeadRaw = null, curDateHead = null;
const entryDate = () => curDateHead || fileDate || fm.date || mtimeDate;
const anchorOf = () => (curDateHead ? null : curHeadRaw);
const breadcrumb = () => {
if (curHead && !DATEH_RE.test(curHead)) {
const t = curHead.replace(ANY_TAG, "").replace(/\s+/g, " ").trim();
if (t) return t;
}
return null;
};
function addBlock(keyIdx, block) {
const key = relpath + "|" + keyIdx;
if (seen.has(key) || !block.length) return;
seen.add(key);
const d = entryDate();
if (!d) return;
let t = null;
const tm = block[0][1].slice(0, 30).match(TIME_RE);
if (tm) t = [+tm[1], +tm[2], +(tm[3] || 0)];
order++;
entries.push({ date: d, time: t, order, relpath, note,
breadcrumb: breadcrumb(), lines: block,
pageLevel: false, notes: [note], anchor: anchorOf() });
}
function collectChildren(i, parentIndent) {
const kids = [], raw = [];
let j = i + 1;
while (j < lines.length) {
const ln = lines[j];
if (ln.trim() === "") {
let k = j + 1;
while (k < lines.length && lines[k].trim() === "") k++;
if (k < lines.length && LIST_RE.test(lines[k]) && indentOf(lines[k]) > parentIndent) { j = k; continue; }
break;
}
const mi = ln.match(LIST_RE);
if (mi && indentOf(ln) > parentIndent) {
raw.push([indentOf(ln), (mi[3] || "").replace(/\s+$/, "")]);
j++;
} else break;
}
for (const [ind, txt] of raw) kids.push([Math.floor((ind - parentIndent) / 2), txt]);
return kids;
}
for (let i = fm.bodyStart; i < lines.length; i++) {
const ln = lines[i];
if (FENCE_RE.test(ln)) { inFence = !inFence; continue; }
if (inFence) continue;
if (inPct) { if (ln.includes("%%")) inPct = false; continue; }
if (inHtml) { if (ln.includes("-->")) inHtml = false; continue; }
const stripped = ln.trim();
if (stripped.startsWith("%%") && !stripped.slice(2).includes("%%")) { inPct = true; continue; }
if (stripped.startsWith("<!--") && !stripped.includes("-->")) { inHtml = true; continue; }
const hm = ln.match(HEAD_RE);
if (hm) {
const htext = hm[2];
const dh = htext.match(DATEH_RE);
if (dh) { curDateHead = vdate(+dh[1], +dh[2], +dh[3]); curHead = null; curHeadRaw = null; }
else { curHead = htext; curHeadRaw = htext.trim(); }
if (TAG_RE.test(cleanMatch(htext))) {
const sec = [[0, "**" + stripTag(htext) + "**"]];
let j = i + 1;
const level = hm[1].length;
let count = 0;
while (j < lines.length && count < 300) {
const sl = lines[j];
const shm = sl.match(HEAD_RE);
if (shm && shm[1].length <= level) break;
if (FENCE_RE.test(sl)) break;
if (sl.trim()) {
const smi = sl.match(LIST_RE);
if (smi) sec.push([1 + Math.floor(indentOf(sl) / 2), (smi[3] || "").replace(/\s+$/, "")]);
else sec.push([1, sl.trim()]);
count++;
}
j++;
}
addBlock(i, sec);
}
continue;
}
const clean = cleanMatch(ln);
if (!TAG_RE.test(clean)) continue;
const mi = ln.match(LIST_RE);
if (mi) {
const ind = indentOf(ln);
const itemText = mi[3] || "";
const cleaned = stripTag(itemText);
const kids = collectChildren(i, ind);
addBlock(i, cleaned ? [[0, cleaned], ...kids] : [[0, itemText.trim()], ...kids]);
continue;
}
// 独立标签行:向下吸收 list items,或向上归属到紧邻的块
if (ln.replace(ANY_TAG, "").trim() === "" || ln.replace(ANY_TAG, "").trim() === "%%") {
let sec = [];
let j = i + 1;
while (j < lines.length) {
const mj = lines[j].match(LIST_RE);
if (mj) { sec.push([indentOf(lines[j]), (mj[3] || "").replace(/\s+$/, "")]); j++; }
else break;
}
let keyIdx = i;
if (!sec.length) {
const up = [];
let k = i - 1, bulletIdx = null;
while (k >= fm.bodyStart) {
const bk = lines[k];
if (bk.trim() === "" || HEAD_RE.test(bk) || FENCE_RE.test(bk)) break;
up.push(k);
if (LIST_RE.test(bk)) { bulletIdx = k; break; }
k--;
}
if (bulletIdx !== null) {
const inds = [];
for (let bl = bulletIdx; bl < i; bl++) {
const mb = lines[bl].match(LIST_RE);
if (mb) { inds.push(indentOf(lines[bl])); sec.push([indentOf(lines[bl]), (mb[3] || "").replace(/\s+$/, "")]); }
else sec.push([(inds.length ? inds[inds.length - 1] + 2 : 0), lines[bl].trim()]);
}
keyIdx = bulletIdx;
} else if (up.length) {
sec = up.slice().reverse().map(k2 => [0, lines[k2].trim()]);
} else {
// 兜底:标签行紧挨在某个标题下方(中间只有空行)→ 捕获该标题下的整个小节
let h = i - 1;
while (h >= fm.bodyStart && lines[h].trim() === "") h--;
const hmk = h >= fm.bodyStart ? lines[h].match(HEAD_RE) : null;
if (hmk) {
const level = hmk[1].length;
const title = hmk[2].replace(ANY_TAG, "").replace(/\s+/g, " ").trim();
const sec2 = title ? [[0, "**" + title + "**"]] : [];
let j2 = i + 1, count2 = 0;
while (j2 < lines.length && count2 < 300) {
const sl = lines[j2];
const shm = sl.match(HEAD_RE);
if (shm && shm[1].length <= level) break;
if (FENCE_RE.test(sl)) break;
if (sl.trim()) {
const smi = sl.match(LIST_RE);
if (smi) sec2.push([1 + Math.floor(indentOf(sl) / 2), (smi[3] || "").replace(/\s+$/, "")]);
else sec2.push([1, sl.trim().replace(/^>\s*/, "")]);
count2++;
}
j2++;
}
if (sec2.length) sec = sec2;
}
}
}
if (sec.length) {
const mi0 = Math.min(...sec.map(x => x[0]));
addBlock(keyIdx, sec.map(([ind, t]) => [Math.floor((ind - mi0) / 2), t]));
}
continue;
}
// 普通段落带标签:吸收到空行为止
const para = [stripTag(clean)];
let j = i + 1;
while (j < lines.length) {
const nl = lines[j];
if (nl.trim() === "" || HEAD_RE.test(nl) || LIST_RE.test(nl) || FENCE_RE.test(nl)) break;
para.push(nl.trim());
j++;
}
addBlock(i, [[0, para.filter(p => p).join(" ")]]);
}
// frontmatter 标签 → 页面级条目(附正文开头预览)
if (TAG_LIST.some(t => fm.tags.has(t))) {
const d = fm.date || fileDate || mtimeDate;
if (d) {
order++;
// 收集正文文本至 PREV_CHARS:超过上限的长文在渲染时截断并附「查看全文」链接
const prev = [];
let prevTotal = 0, prevCount = 0;
for (let k = fm.bodyStart; k < lines.length && prevCount < 200; k++) {
const s = lines[k].trim();
if (!s || s.startsWith("#") || s === "---") continue;
const clean = s.replace(ANY_TAG, "");
if (!clean) continue;
prev.push(clean);
prevTotal += clean.length;
prevCount++;
if (prevTotal > PREV_CHARS) break; // 超过上限:已足够判断,停止收集
}
const preview = prev.join(" ");
entries.push({ date: d, time: null, order, relpath, note, breadcrumb: null,
lines: preview ? [[0, preview]] : [],
pageLevel: true, notes: [note], anchor: null });
}
}
return entries;
}
// ---------------- 扫描整个 vault ----------------
const currentPath = (dv.current() && dv.current().file) ? dv.current().file.path : null;
let all = [];
for (const f of app.vault.getMarkdownFiles()) {
if (f.path === currentPath || f.path.endsWith(".excalidraw.md")) continue;
if (SKIP_DIRS.some(d => f.path.startsWith(d + "/"))) continue;
if (f.path.split("/").some(seg => seg.startsWith("."))) continue; // 排除所有隐藏文件/文件夹(.开头)
if (excludedPaths.some(p => f.path === p || f.path.startsWith(p.endsWith("/") ? p : p + "/"))) continue;
let text;
try { text = await app.vault.cachedRead(f); } catch (e) { continue; }
if (text.slice(0, 1000).includes(MARKER)) continue; // 跳过 tagfeed 生成的静态页
all = all.concat(scanText(text, f.basename, f.path, dateFromName(f.basename), new Date(f.stat.mtime)));
}
// ---------------- 去重:同日期+同内容合并,来源并排 ----------------
const timeCmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || (a[2] - b[2]);
const mergedMap = new Map();
for (const e of all) {
const fp = e.date.getTime() + "|" + e.lines.map(x => x[0] + ":" + x[1]).join("\n");
const m = mergedMap.get(fp);
if (m) {
if (!m.notes.includes(e.note)) m.notes.push(e.note);
if (e.time && (!m.time || timeCmp(e.time, m.time) < 0)) m.time = e.time;
// 若先扫到的来源没有面包屑/锚点,而后面的有,则采用后者(与扫描顺序无关,结果确定)
if (!m.breadcrumb && e.breadcrumb) {
m.breadcrumb = e.breadcrumb;
m.anchor = e.anchor;
m.relpath = e.relpath;
m.note = e.note;
}
} else mergedMap.set(fp, e);
}
const entries = [...mergedMap.values()];
// ---------------- 渲染:按日期倒序 ----------------
const byDate = new Map();
for (const e of entries) {
const k = e.date.getTime();
if (!byDate.has(k)) byDate.set(k, []);
byDate.get(k).push(e);
}
const dates = [...byDate.keys()].sort((a, b) => b - a);
const noteSet = new Set();
for (const e of entries) for (const n of e.notes) noteSet.add(n);
const out = [];
out.push("> **" + entries.length + "** 个内容块 | **" + noteSet.size + "** 篇笔记 | 按日期倒序 | 随笔记变动自动刷新");
const pad2 = x => String(x).padStart(2, "0");
const fmtDate = d => d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate());
const invTime = t => t.map(x => pad2(99 - x)).join("");
for (const dk of dates) {
const d = new Date(dk);
out.push("## 📅 " + fmtDate(d) + " · " + WEEKDAYS[d.getDay() === 0 ? 6 : d.getDay() - 1]);
out.push("");
const group = byDate.get(dk);
const sub = new Map(); // (笔记+面包屑) -> entries
for (const e of group) {
const key = e.relpath + "|" + (e.breadcrumb || "");
if (!sub.has(key)) sub.set(key, []);
sub.get(key).push(e);
}
const subKey = ([key, es]) => {
const times = es.filter(e => e.time).map(e => e.time);
const best = times.length ? times.reduce((a, b) => (timeCmp(a, b) > 0 ? a : b)) : null;
return (best ? "0" : "1") + (best ? invTime(best) : "000000") + key;
};
for (const [key, es] of [...sub.entries()].sort((a, b) => (subKey(a) < subKey(b) ? -1 : subKey(a) > subKey(b) ? 1 : 0))) {
const noteList = [];
for (const e of es) for (const n of e.notes) if (!noteList.includes(n)) noteList.push(n);
noteList.sort();
const crumb = es.map(e => e.breadcrumb).find(b => b) || null;
out.push("- 🔗 " + noteList.map(n => "[[" + n + "]]").join(" · ") + (crumb ? " · " + crumb : ""));
const sorted = es.slice().sort((a, b) => {
const ka = (a.time ? "0" : "1") + (a.time ? invTime(a.time) : "") + String(a.order).padStart(6, "0");
const kb = (b.time ? "0" : "1") + (b.time ? invTime(b.time) : "") + String(b.order).padStart(6, "0");
return ka < kb ? -1 : ka > kb ? 1 : 0;
});
for (const e of sorted) {
let bl = e.lines, truncated = false;
const totalChars = bl.reduce((s, x) => s + x[1].length, 0);
if (totalChars > PREV_CHARS) { // 只有超过上限才截断;上限内全文展示
truncated = true;
const shown = [];
let chars = 0;
for (const [lvl, txt] of bl) {
if (chars + txt.length > PREV_CHARS) {
if (PREV_CHARS - chars > 0) shown.push([lvl, txt.slice(0, PREV_CHARS - chars) + "…"]);
break;
}
shown.push([lvl, txt]);
chars += txt.length;
}
bl = shown;
}
for (const [lvl, txt] of bl) out.push("\t".repeat(lvl + 1) + "- " + txt);
if (truncated) {
const target = e.note + (e.anchor ? "#" + e.anchor : "");
const label = e.pageLevel ? "页面级标签 · 查看全文" : "查看全文";
out.push("\t- [[" + target + "|↗ " + label + "]]");
}
}
out.push("");
}
out.push("");
}
dv.paragraph(out.join("\n"));
} catch (err) {
dv.paragraph("> ❌ tagfeed-live 执行失败:" + err.message);
console.error("tagfeed-live:", err);
}