---
tags:
- tagfeed-live
cssclasses:
- tagfeed
words:
本页由 DataviewJS 实时计算,笔记有变动时自动刷新,无需任何后台服务。
// ============================================================
// tagfeed-live:Logseq 风格标签实时聚合(标准 DataviewJS 脚本)
//
// 用法:
// 1. 新建一个笔记(例如 00index/tagfeed-md/标签名.md)
// 2. 在笔记里插入一个 dataviewjs 代码块(三个反引号 + dataviewjs),
// 把本脚本全部粘贴进去
// 3. 只需要改下面「配置区」的两行:
// - targetTag 换成你要聚合的标签(不带 #,中英文都可以;多个标签写成数组 ["a", "b"])
// - excludedPaths 换成你想排除的文件夹
// 4. 完成。以后任何笔记里出现/删除这个标签,本页自动刷新,
// 不需要跑任何命令(Dataview 会在文件变动后自动重新渲染)。
// ============================================================
// ---------------- 配置区(通常只改这里) ----------------
const targetTag = "GTD"; // 要聚合的标签(不带 #);多个标签用数组:["lyric", "歌词"]
const excludedPaths = ["X2.Archived", "00index", "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 tagKey = s => String(s).toLowerCase();
const TAG_LIST_LOWER = new Set(TAG_LIST.map(tagKey));
const hasFmTag = (tags, wanted) => {
const key = tagKey(wanted);
for (const tag of tags) if (tagKey(tag) === key) return true;
return false;
};
const hasAnyTargetFmTag = tags => {
for (const tag of tags) if (TAG_LIST_LOWER.has(tagKey(tag))) return true;
return false;
};
const TAG_RE = new RegExp("(?<![\\w\\u4e00-\\u9fff#])#(?:" + TAG_LIST.map(esc).join("|") + ")(?![\\w\\u4e00-\\u9fff/\\-])", "i");
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, "");
return s;
}
const MACHINE_LINE_RE = [
/Switch to EXCALIDRAW VIEW/i,
/MORE OPTIONS menu of this document/i,
/Decompress current Excalidraw file/i,
/plugin settings under ['"]Saving['"]/i,
/^\s*#{1,6}\s*Drawing\s*$/i,
/^\s*```(?:compressed-json|json)?\s*$/i,
/^\s*[\{\[]\s*"type"\s*:\s*"excalidraw"/i,
/^\s*"elements"\s*:/i,
];
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
const TEMPLATE_RE = /<%[\s\S]*?%>/g;
const HTML_TAG_RE = /<\/?[A-Za-z][^>\n]*>/g;
const PASTED_IMAGE_MD_RE = /!?\[[^\]\n]*Pasted Image[^\]\n]*\]\([^)]+?\.(?:png|jpe?g|gif|webp)\)/gi;
const PASTED_IMAGE_WIKI_RE = /!?\[\[[^\]\n]*Pasted Image[^\]\n]*\.(?:png|jpe?g|gif|webp)(?:\|[^\]\n]*)?\]\]/gi;
const PASTED_IMAGE_TEXT_RE = /\bPasted Image[^\n]*?\.(?:png|jpe?g|gif|webp)\b/gi;
const SENSITIVE_TOKEN_LINE_RE = /\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})\b/i;
const SENSITIVE_TOKEN_RE = /\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g;
const LONG_HEX_RE = /\b[0-9a-fA-F]{32,}\b/g;
const EXCALIDRAW_DATA_HEADING_RE = /^\s*#{1,6}\s+Excalidraw Data\s*$/i;
const isExcalidrawDataHeading = ln => EXCALIDRAW_DATA_HEADING_RE.test(String(ln || "").trim());
const EXCALIDRAW_BLOCK_REF_RE = /\s+\^[A-Za-z0-9_-]+\s*$/;
function extractFrontmatterPrefix(text) {
const m = String(text || "").match(/^---\s*\n[\s\S]*?\n(?:---|\.\.\.)\s*\n/);
return m ? m[0].trimEnd() : "";
}
function extractExcalidrawTextElements(text) {
const lines = String(text || "").split("\n");
const start = lines.findIndex(ln => /^\s*##\s+Text Elements\s*$/i.test(ln));
if (start < 0) return "";
const chunks = [];
let current = [];
const flush = () => {
const joined = current.map(x => x.trim()).filter(Boolean).join(" ").replace(/[ \t]{2,}/g, " ").trim();
if (joined) chunks.push(joined);
current = [];
};
for (let i = start + 1; i < lines.length; i++) {
const raw = lines[i];
const stripped = raw.trim();
if (stripped === "%%" || /^\s*##\s+(?:Embedded Files|Drawing)\s*$/i.test(raw)) break;
if (!stripped) {
if (current.length) current.push("");
continue;
}
const hadRef = EXCALIDRAW_BLOCK_REF_RE.test(raw);
const cleaned = raw.replace(EXCALIDRAW_BLOCK_REF_RE, "").trim();
if (cleaned) current.push(cleaned);
if (hadRef) flush();
}
flush();
return chunks.join("\n\n");
}
function readableExcalidrawMarkdown(text) {
const frontmatter = extractFrontmatterPrefix(text);
const body = extractExcalidrawTextElements(text);
return [frontmatter, body].filter(Boolean).join("\n\n");
}
function sanitizeHumanLine(ln) {
let s = String(ln || "");
if (!s.trim()) return null;
if (MACHINE_LINE_RE.some(re => re.test(s))) return null;
if (SENSITIVE_TOKEN_LINE_RE.test(s)) return null;
s = s.replace(HTML_COMMENT_RE, "");
s = s.replace(TEMPLATE_RE, "");
s = s.replace(PASTED_IMAGE_MD_RE, "");
s = s.replace(PASTED_IMAGE_WIKI_RE, "");
s = s.replace(PASTED_IMAGE_TEXT_RE, "");
s = s.replace(SENSITIVE_TOKEN_RE, "[已隐藏敏感内容]");
s = s.replace(LONG_HEX_RE, "[已隐藏机器ID]");
s = s.replace(HTML_TAG_RE, "");
s = s.replace(/[ \t]{2,}/g, " ").trim();
const residue = s.replace(/\[已隐藏机器ID\]|\[已隐藏敏感内容\]/g, "").replace(/[^A-Za-z0-9\u4e00-\u9fff]+/g, "");
if (!s || !residue || /^[-*+>]*$/.test(s)) return null;
return s;
}
function humanizeBlock(block) {
const out = [];
for (const row of block) {
const lvl = Array.isArray(row) ? row[0] : 0;
const txt = sanitizeHumanLine(Array.isArray(row) ? row[1] : row);
if (txt) out.push([lvl, txt]);
}
if (!out.length) return [];
const minLevel = Math.min(...out.map(x => x[0]));
return out.map(([lvl, txt]) => [Math.max(0, lvl - minLevel), txt]);
}
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 (hasFmTag(fm.tags, "tagfeed") || hasFmTag(fm.tags, "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) {
block = humanizeBlock(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;
}
// A tag on an indented continuation line belongs to its nearest list item.
function collectParentListBlock(i) {
let parentIdx = null;
let parentIndent = 0;
for (let k = i - 1; k >= fm.bodyStart; k--) {
const prev = lines[k];
if (!prev.trim() || HEAD_RE.test(prev) || FENCE_RE.test(prev)) break;
if (prev.match(LIST_RE)) {
parentIdx = k;
parentIndent = indentOf(prev);
break;
}
}
if (parentIdx === null || indentOf(lines[i]) <= parentIndent) return null;
const block = [];
for (let j = parentIdx; j <= i; j++) {
const current = lines[j];
if (!current.trim()) continue;
const cm = current.match(LIST_RE);
if (cm) {
const currentIndent = indentOf(current);
if (j !== parentIdx && currentIndent <= parentIndent) return null;
const text = stripTag(cleanMatch(cm[3] || "")) || (cm[3] || "").trim();
block.push([j === parentIdx ? 0 : Math.max(1, Math.floor((currentIndent - parentIndent) / 2)), text]);
} else {
const text = stripTag(cleanMatch(current));
if (text) block.push([Math.max(1, Math.floor((indentOf(current) - parentIndent) / 2)), text]);
}
}
return block.length ? { keyIdx: parentIdx, block } : null;
}
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 (isExcalidrawDataHeading(ln)) break;
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];
if (isExcalidrawDataHeading(sl)) break;
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];
if (isExcalidrawDataHeading(sl)) break;
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 parentBlock = collectParentListBlock(i);
if (parentBlock) {
addBlock(parentBlock.keyIdx, parentBlock.block);
continue;
}
// 普通段落带标签:吸收到空行为止
const para = [stripTag(clean)];
let j = i + 1;
while (j < lines.length) {
const nl = lines[j];
if (isExcalidrawDataHeading(nl) || 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 (hasAnyTargetFmTag(fm.tags)) {
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 (isExcalidrawDataHeading(s)) break;
if (!s || s.startsWith("#") || s === "---") continue;
const clean = sanitizeHumanLine(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) 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 (f.path.endsWith(".excalidraw.md")) text = readableExcalidrawMarkdown(text);
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);
}