--- tags: - tagfeed-live cssclasses: - tagfeed words: 2026-08-23: 2728 --- # 🏷 #project(实时聚合页) > 本页由 DataviewJS 实时计算,笔记有变动时自动刷新,无需任何后台服务。 ```dataviewjs // ============================================================ // tagfeed-live:Logseq 风格标签实时聚合(标准 DataviewJS 脚本) // // 用法: // 1. 新建一个笔记(例如 00index/标签聚合/标签名.md) // 2. 在笔记里插入一个 dataviewjs 代码块(三个反引号 + dataviewjs), // 把本脚本全部粘贴进去 // 3. 只需要改下面「配置区」的两行: // - targetTag 换成你要聚合的标签(不带 #,中英文都可以;多个标签写成数组 ["a", "b"]) // - excludedPaths 换成你想排除的文件夹 // 4. 完成。以后任何笔记里出现/删除这个标签,本页自动刷新, // 不需要跑任何命令(Dataview 会在文件变动后自动重新渲染)。 // ============================================================ // ---------------- 配置区(通常只改这里) ---------------- const targetTag = "project"; // 要聚合的标签(不带 #);多个标签用数组:["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 系统页会自动跳过:静态聚合页(文件头含 /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("")) { 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); } ```