#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ tagfeed.py — Logseq 风格的 Obsidian 标签页生成器(无插件依赖) 给定一个标签,扫描整个 vault,把所有带该标签的"块"(bullet / memo / 段落 / 小节) 按日期倒序聚合到一个页面里,样式模仿 Logseq 的 query 结果: ## 📅 2026-08-05 · 周二 - 🔗 [[26.0805]] · GTD - 块内容(子 bullet 原样保留) 日期判定优先级: 1. 块上方最近的日期标题(Knomo 的 `## [[2026-08-04]]` 形式) 2. 文件名里的日期(`26.0805.md` → 2026-08-05,`2026-08-04-xxx.md` 等) 3. frontmatter 里的 date/created 字段 4. 文件修改时间 用法: python3 tagfeed.py <标签> # 生成聚合页(标签可带可不带 #) python3 tagfeed.py <标签> --open # 生成后在 Obsidian 中打开 python3 tagfeed.py <标签> --out 某目录 # 指定输出目录(相对 vault,默认 00index) python3 tagfeed.py <标签> --title 我的标题 # 自定义页面标题 python3 tagfeed.py --tags # 列出全库标签及出现次数 python3 tagfeed.py <标签> --vault 路径 # 指定其他 vault 注意: --watch 已退役。tagfeed 当前只通过 Obsidian 命令“Tagfeed:手动更新全局Tagfeed”触发全局同步。 每日活动足迹已剥离到 daily_activity.py,不再由 tagfeed 负责。 输出文件: //tag-<标签>.md 重复运行即刷新(覆盖旧文件)。 """ import argparse import collections import datetime import hashlib import json import os import re import subprocess import sys import urllib.parse DEFAULT_VAULT = os.environ.get( "TAGFEED_VAULT", os.path.expanduser("~/Downloads/Syn/Ob/Ob.temp") ) SKIP_DIRS = {".obsidian", ".git", ".trash", "X.Attachment", ".hg", ".svn"} TAGFEED_SKIP_REL_PREFIXES = ("00index", "X1.Knomo", "02DS/01dril-book") MARKER = "", re.S) def indent_of(line: str) -> int: """缩进宽度(tab 按 2 空格计)""" expanded = line.expandtabs(2) return len(expanded) - len(expanded.lstrip(" ")) def clean_for_match(line: str) -> str: """去掉行内代码 / %%注释%% / HTML 注释,避免误匹配""" line = INLINE_CODE_RE.sub("", line) line = INLINE_COMMENT_RE.sub("", line) line = INLINE_HTML_RE.sub("", line) return line EXCALIDRAW_BLOCK_REF_RE = re.compile(r"\s+\^[A-Za-z0-9_-]+\s*$") def extract_frontmatter_prefix(raw: str) -> str: match = re.match(r"^---\s*\n.*?\n(?:---|\.\.\.)\s*\n", raw, flags=re.S) return match.group(0).rstrip() if match else "" def extract_excalidraw_text_elements(raw: str) -> str: """只抽取 Excalidraw 的人类可读 Text Elements,跳过 Drawing JSON 和附件区。""" lines = raw.splitlines() start = None for idx, line in enumerate(lines): if re.match(r"^\s*##\s+Text Elements\s*$", line, flags=re.I): start = idx + 1 break if start is None: return "" chunks = [] current = [] def flush(): nonlocal current text = " ".join(part.strip() for part in current if part.strip()) text = re.sub(r"[ \t]{2,}", " ", text).strip() if text: chunks.append(text) current = [] for line in lines[start:]: stripped = line.strip() if stripped == "%%" or re.match(r"^\s*##\s+(?:Embedded Files|Drawing)\s*$", line, flags=re.I): break if not stripped: if current: current.append("") continue had_ref = EXCALIDRAW_BLOCK_REF_RE.search(line) is not None cleaned = EXCALIDRAW_BLOCK_REF_RE.sub("", line).strip() if cleaned: current.append(cleaned) if had_ref: flush() flush() return "\n\n".join(chunks) def readable_excalidraw_markdown(raw: str) -> str: frontmatter = extract_frontmatter_prefix(raw) text_elements = extract_excalidraw_text_elements(raw) parts = [part for part in (frontmatter, text_elements) if part] return "\n\n".join(parts) + ("\n" if parts else "") def valid_date(y: int, m: int, d: int): try: return datetime.date(y, m, d) except ValueError: return None def date_from_filename(basename: str): """支持 YYYY-MM-DD 与日记命名 YY.MMDD(如 26.0805 → 2026-08-05)""" m = re.search(r"(\d{4})-(\d{2})-(\d{2})", basename) if m: dt = valid_date(int(m.group(1)), int(m.group(2)), int(m.group(3))) if dt: return dt m = re.match(r"^(\d{2})\.(\d{2})(\d{2})(?:\D|$)", basename) if m: return valid_date(2000 + int(m.group(1)), int(m.group(2)), int(m.group(3))) return None def parse_frontmatter(text: str): """返回 (tags:set, date:datetime.date|None, body_start_line:int)""" tags, fdate = set(), None lines = text.split("\n") if not lines or lines[0].strip() != "---": return tags, fdate, 0 for i in range(1, min(len(lines), 200)): if lines[i].strip() in ("---", "..."): block = "\n".join(lines[1:i]) tm = re.search(r"^tags:\s*\[(.*?)\]", block, re.M) if tm: for t in tm.group(1).split(","): t = t.strip().strip("\"'").lstrip("#").strip() if t: tags.add(t) else: tm2 = re.search(r"^tags:\s*\n((?:[ \t]+-[ \t]+.*\n?)+)", block, re.M) if tm2: for ln in tm2.group(1).splitlines(): mm = re.match(r"^\s*-\s*[\"']?([\w\u4e00-\u9fff/\-]+)[\"']?\s*$", ln) if mm: tags.add(mm.group(1)) for key in ("date", "created", "published"): dm = re.search(rf"^{key}:\s*[\"']?(\d{{4}})-(\d{{2}})-(\d{{2}})", block, re.M) if dm: fdate = valid_date(*map(int, dm.groups())) break return tags, fdate, i + 1 return set(), None, 0 def strip_tag_from_text(text: str, rx: re.Pattern) -> str: text = rx.sub("", text) text = re.sub(r"[ \t]{2,}", " ", text) return text.strip() # ---------------------------------------------------------------- 扫描 class Entry: """一个命中的块""" __slots__ = ("date", "time", "order", "relpath", "note", "breadcrumb", "lines", "page_level", "notes", "anchor") def __init__(self, date, time, order, relpath, note, breadcrumb, lines, page_level=False, anchor=None): self.date = date self.time = time # (h,m,s) 或 None self.order = order # 文件内原始顺序 self.relpath = relpath self.note = note self.breadcrumb = breadcrumb self.lines = lines # [(indent_level, text)] indent_level: 0=顶层 self.page_level = page_level self.notes = [note] # 来源笔记列表(去重合并后可能有多个) self.anchor = anchor # 所在小节的原始标题(用于 [[笔记#标题]] 跳转) def rel_is_or_under(rel: str, prefix: str) -> bool: return rel == prefix or rel.startswith(prefix + "/") def should_skip_tagfeed_path(path: str, vault: str) -> bool: rel = os.path.relpath(path, vault) if rel == ".": return False rel = rel.replace(os.sep, "/") return any(rel_is_or_under(rel, prefix) for prefix in TAGFEED_SKIP_REL_PREFIXES) def iter_md_files(vault: str): for root, dirs, files in os.walk(vault): dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not should_skip_tagfeed_path(os.path.join(root, d), vault)] for f in files: if not f.endswith(".md"): continue path = os.path.join(root, f) if should_skip_tagfeed_path(path, vault): continue yield path def scan_file(path: str, relpath: str, tag: str, rx: re.Pattern, collect_all: bool = False): """ 扫描单个文件,返回 (entries, all_tags_counter)。 collect_all=True 时只统计全库标签(--tags 模式),不做块提取。 """ try: with open(path, encoding="utf-8", errors="replace") as fh: text = fh.read() except OSError: return [], collections.Counter() if os.path.basename(path).endswith(".excalidraw.md"): text = readable_excalidraw_markdown(text) if MARKER in text[:1000]: return [], collections.Counter() # 跳过本工具生成的聚合页 fm_tags, fm_date, body_start = parse_frontmatter(text) if fm_tags & {"tagfeed", "tagfeed-live"}: return [], collections.Counter() # 跳过 tagfeed 系统页(含 dataviewjs 实时页, # 它们的标题/说明里含 #标签 字样,不能被索引) file_date = date_from_filename(os.path.splitext(os.path.basename(path))[0]) try: mtime_date = datetime.date.fromtimestamp(os.path.getmtime(path)) except OSError: mtime_date = None lines = text.split("\n") all_tags = collections.Counter() if collect_all: in_fence = False for i, ln in enumerate(lines): if i < body_start: continue if FENCE_RE.match(ln): in_fence = not in_fence continue if in_fence: continue for t in ALL_TAGS_RE.findall(clean_for_match(ln)): all_tags[t] += 1 for t in fm_tags: all_tags[t] += 1 return [], all_tags note = os.path.splitext(os.path.basename(path))[0] entries = [] seen_blocks = set() order = 0 in_fence = False in_pct = False # %% 块注释 in_html = False # HTML 块注释 current_heading = None # 最近的小节标题(面包屑用) current_heading_raw = None # 原始标题文本([[笔记#标题]] 跳转锚点用) current_date_heading = None # Knomo 风格 ## [[YYYY-MM-DD]] def entry_date(): return current_date_heading or file_date or fm_date or mtime_date def anchor(): return None if current_date_heading else current_heading_raw def breadcrumb(): if current_heading and not DATE_HEADING_RE.search(current_heading): t = ALL_TAGS_RE.sub("", current_heading) t = re.sub(r"\s+", " ", t).strip() if t: return t return None def add_lines_block(start, content_lines, first_line_idx): """content_lines: [(indent, text)] 已规整""" nonlocal order key = (path, first_line_idx) if key in seen_blocks or not content_lines: return seen_blocks.add(key) d = entry_date() if d is None: return t = None m = TIME_RE.search(content_lines[0][1][:30]) if m: t = (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)) order += 1 entries.append(Entry(d, t, order, relpath, note, breadcrumb(), content_lines, anchor=anchor())) def collect_children(i, parent_indent): """收集 list item i 的子 bullet(缩进更深者),返回规整后的 [(level, text)]""" kids = [] j = i + 1 raw = [] while j < len(lines): ln = lines[j] if ln.strip() == "": # 允许空行,只要后面还有更深的 item k = j + 1 while k < len(lines) and lines[k].strip() == "": k += 1 if k < len(lines): mk = LIST_ITEM_RE.match(lines[k]) if mk and indent_of(lines[k]) > parent_indent: j = k continue break mi = LIST_ITEM_RE.match(ln) if mi and indent_of(ln) > parent_indent: raw.append((indent_of(ln), (mi.group(3) or "").rstrip())) j += 1 else: break for ind, txt in raw: kids.append(((ind - parent_indent) // 2, txt)) return kids def collect_parent_list_block(i): """Return the parent list block when a tag is on one of its indented text lines.""" parent_idx = None parent_indent = 0 for k in range(i - 1, body_start - 1, -1): prev = lines[k] if not prev.strip() or HEADING_RE.match(prev) or FENCE_RE.match(prev): break if LIST_ITEM_RE.match(prev): parent_idx = k parent_indent = indent_of(prev) break if parent_idx is None or indent_of(lines[i]) <= parent_indent: return None block = [] for j in range(parent_idx, i + 1): current = lines[j] if not current.strip(): continue cm = LIST_ITEM_RE.match(current) if cm: current_indent = indent_of(current) if j != parent_idx and current_indent <= parent_indent: return None text = strip_tag_from_text(clean_for_match(cm.group(3) or ""), rx) or (cm.group(3) or "").strip() level = 0 if j == parent_idx else max(1, (current_indent - parent_indent) // 2) else: text = strip_tag_from_text(clean_for_match(current), rx) if not text: continue level = max(1, (indent_of(current) - parent_indent) // 2) block.append((level, text)) return (parent_idx, block) if block else None for i in range(body_start, len(lines)): ln = lines[i] if FENCE_RE.match(ln): in_fence = not in_fence continue if in_fence: continue if in_pct: if "%%" in ln: in_pct = False continue if in_html: if "-->" in ln: in_html = False continue stripped = ln.strip() if stripped.startswith("%%") and "%%" not in stripped[2:]: in_pct = True continue if stripped.startswith("" not in stripped: in_html = True continue hm = HEADING_RE.match(ln) if hm: htext = hm.group(2) dh = DATE_HEADING_RE.search(htext) if dh: current_date_heading = valid_date(*map(int, dh.groups())) current_heading = None current_heading_raw = None else: current_heading = htext current_heading_raw = htext.strip() # 标题行里带标签 → 捕获整个小节 if rx.search(clean_for_match(htext)): sec = [(0, "**" + strip_tag_from_text(htext, rx) + "**")] j = i + 1 level = len(hm.group(1)) count = 0 while j < len(lines) and count < MAX_SECTION_LINES: sl = lines[j] shm = HEADING_RE.match(sl) if shm and len(shm.group(1)) <= level: break if FENCE_RE.match(sl): break if sl.strip(): smi = LIST_ITEM_RE.match(sl) if smi: sec.append((1 + indent_of(sl) // 2, (smi.group(3) or "").rstrip())) else: sec.append((1, sl.strip())) count += 1 j += 1 add_lines_block(i, sec, i) continue clean = clean_for_match(ln) if rx.search(clean) is None: continue # ---- 命中:判断块类型 mi = LIST_ITEM_RE.match(ln) if mi: ind = indent_of(ln) item_text = mi.group(3) or "" cleaned = strip_tag_from_text(item_text, rx) kids = collect_children(i, ind) if cleaned: block = [(0, cleaned)] + kids else: # 纯标签 bullet:保留标签可见,子级按自然深度挂在下面 block = [(0, item_text.strip())] + kids add_lines_block(i, block, i) continue # 段落 / 独立标签行 if ALL_TAGS_RE.sub("", clean).strip() in ("", "%%"): # 1) 向下:吸收紧随其后的连续 list items(section 标签语义,如 #taglauncher 下的任务) sec = [] j = i + 1 while j < len(lines): mj = LIST_ITEM_RE.match(lines[j]) if mj: sec.append((indent_of(lines[j]), (mj.group(3) or "").rstrip())) j += 1 else: break key_idx = i if not sec: # 2) 向上:标签属于紧邻上方的块(Logseq 块尾标签语义,如歌词末尾的 #lyric) # 向上收集到空行/标题/围栏为止;若途中遇到 bullet, # 则从该 bullet 起捕获整块(bullet + 其续行/子级,直到标签行) up = [] k = i - 1 bullet_idx = None while k >= body_start: bk = lines[k] if bk.strip() == "" or HEADING_RE.match(bk) or FENCE_RE.match(bk): break up.append(k) if LIST_ITEM_RE.match(bk): bullet_idx = k break k -= 1 if bullet_idx is not None: inds = [] for bl in lines[bullet_idx:i]: mb = LIST_ITEM_RE.match(bl) if mb: inds.append(indent_of(bl)) sec.append((indent_of(bl), (mb.group(3) or "").rstrip())) else: base = inds[-1] + 2 if inds else 0 # 续行挂在上级 bullet 之下 sec.append((base, bl.strip())) key_idx = bullet_idx elif up: sec = [(0, lines[k2].strip()) for k2 in reversed(up)] if sec: mi0 = min(ind for ind, _ in sec) norm = [((ind - mi0) // 2, t) for ind, t in sec] add_lines_block(i, norm, key_idx) continue # 标签在缩进续行中时,归属到最近的父列表项。 parent_block = collect_parent_list_block(i) if parent_block: parent_idx, block = parent_block add_lines_block(parent_idx, block, parent_idx) continue # 普通段落带标签:吸收到空行为止 para = [strip_tag_from_text(clean, rx)] j = i + 1 while j < len(lines): nl = lines[j] if nl.strip() == "" or HEADING_RE.match(nl) or LIST_ITEM_RE.match(nl) or FENCE_RE.match(nl): break para.append(nl.strip()) j += 1 add_lines_block(i, [(0, " ".join(p for p in para if p))], i) # frontmatter 标签 → 页面级条目(附正文开头预览) if tag in fm_tags: d = fm_date or file_date or mtime_date if d: order += 1 prev = [] for ln2 in lines[body_start:]: s = ln2.strip() if not s: if prev: break continue if s.startswith("#") or s == "---": continue prev.append(ALL_TAGS_RE.sub("", s)) if len(prev) >= 2 or sum(len(x) for x in prev) >= 150: break preview = " ".join(p for p in prev if p)[:160] entries.append(Entry(d, None, order, relpath, note, None, [(0, preview)] if preview else [], page_level=True)) return entries, all_tags def dedupe(entries): """ 同一日期下、同一段内容在多个笔记里重复出现时(典型:日记同步到 Knomo 月度归档), 合并为一条,来源链接并排。归并键 = (日期, 内容指纹)。 不同日期的相同内容不合并(那是每天的重复记录,Logseq 也会逐日显示)。 """ from collections import OrderedDict merged = OrderedDict() for e in entries: fp = (e.date, tuple((lvl, t) for lvl, t in e.lines)) if fp in merged: m = merged[fp] if e.note not in m.notes: m.notes.append(e.note) if e.time and (m.time is None or e.time < m.time): m.time = e.time # 若先扫到的来源没有面包屑,而后面的有,则采用后者(与扫描顺序无关,结果确定) if not m.breadcrumb and e.breadcrumb: m.breadcrumb = e.breadcrumb m.anchor = e.anchor m.relpath = e.relpath m.note = e.note else: clone = Entry(e.date, e.time, e.order, e.relpath, e.note, e.breadcrumb, e.lines, e.page_level, e.anchor) clone.notes = [e.note] merged[fp] = clone return list(merged.values()) # ---------------------------------------------------------------- 渲染 def render(tag: str, entries, vault_name: str) -> str: by_date = collections.defaultdict(list) for e in entries: by_date[e.date].append(e) notes = sorted({n for e in entries for n in e.notes}) out = [] out.append("---") out.append("tags:") out.append(" - tagfeed") out.append("cssclasses:") out.append(" - tagfeed") out.append("---") out.append(f"{MARKER}: {tag} -->") out.append("") out.append(f"# 🏷 #{tag}") out.append("") out.append(f"> **{len(entries)}** 个内容块 | **{len(notes)}** 篇笔记 | 按日期倒序") out.append("") for d in sorted(by_date, reverse=True): group = by_date[d] wd = WEEKDAYS[d.weekday()] out.append(f"## 📅 {d.isoformat()} · {wd}") out.append("") # 按 (笔记, 面包屑) 分组;组内:有时间的倒序(新在上),无时间的保持原顺序 sub = collections.OrderedDict() for e in group: key = (e.relpath, e.breadcrumb or "") sub.setdefault(key, []).append(e) def sub_sort_key(item): key, es = item times = [e.time for e in es if e.time] has_time = bool(times) best = max(times) if times else None return (0 if has_time else 1, tuple(-x for x in best) if best else (0,), key[0]) for (relpath, crumb), es in sorted(sub.items(), key=sub_sort_key): all_notes = [] for e in es: for n in e.notes: if n not in all_notes: all_notes.append(n) links = " · ".join(f"[[{n}]]" for n in sorted(all_notes)) head = f"- 🔗 {links}" + (f" · {crumb}" if crumb else "") out.append(head) for e in sorted(es, key=lambda e: (e.time is None, tuple(-x for x in e.time) if e.time else (0,), e.order)): block_lines = e.lines truncated = False if not e.page_level: shown, chars = [], 0 for lvl, txt in block_lines: if len(shown) >= PREVIEW_MAX_LINES or chars > PREVIEW_MAX_CHARS: truncated = True break shown.append((lvl, txt)) chars += len(txt) block_lines = shown for lvl, txt in block_lines: prefix = "\t" * (lvl + 1) txt = txt if txt.strip() else "" out.append(f"{prefix}- {txt}".rstrip()) if truncated or e.page_level: target = e.note + (f"#{e.anchor}" if e.anchor else "") label = "页面级标签 · 查看全文" if e.page_level else "查看全文" out.append(f"\t- [[{target}|↗ {label}]]") out.append("") out.append("") return "\n".join(out).rstrip() + "\n" # ---------------------------------------------------------------- 主流程 def cmd_tags(vault: str): counter = collections.Counter() for path in iter_md_files(vault): _, tags = scan_file(path, os.path.relpath(path, vault), "", None, collect_all=True) counter.update(tags) if not counter: print("(未发现任何标签)") return print(f"{'次数':>5} 标签") for t, c in counter.most_common(60): print(f"{c:>5} #{t}") def generate_tag(vault: str, tag: str, out_rel_dir: str = "00index", title: str = None, allow_empty: bool = False, scan_fn=None): """生成/更新一个标签的聚合页。 返回 (status, out_path, (blocks, notes, days)), status ∈ {"written", "unchanged", "empty"}。 内容与现有页面相同时不写盘(防止触发文件监听循环)。 scan_fn: 可选的 (path, rel, tag, rx) -> entries 缓存包装。""" rx = tag_regex(tag) entries = [] for path in iter_md_files(vault): rel = os.path.relpath(path, vault) if scan_fn is not None: es = scan_fn(path, rel, tag, rx) else: es, _ = scan_file(path, rel, tag, rx) entries.extend(es) out_dir = os.path.join(vault, out_rel_dir.strip("/")) safe = re.sub(r"[/\\:*?\"<>|]", "-", tag) out_rel = os.path.join(out_rel_dir.strip("/"), f"tag-{safe}.md") out_path = os.path.join(vault, out_rel) if not entries and not allow_empty: return "empty", out_path, (0, 0, 0) entries = dedupe(entries) content = render(tag, entries, os.path.basename(vault)) if title: content = content.replace(f"# 🏷 #{tag}", f"# {title}", 1) # 哈希门控:内容没变就不写,避免无谓的 mtime 变动和自我触发 try: with open(out_path, encoding="utf-8") as fh: if fh.read() == content: dates = len({e.date for e in entries}) notes = len({n for e in entries for n in e.notes}) if entries else 0 return "unchanged", out_path, (len(entries), notes, dates) except OSError: pass os.makedirs(out_dir, exist_ok=True) with open(out_path, "w", encoding="utf-8") as fh: fh.write(content) dates = len({e.date for e in entries}) notes = len({n for e in entries for n in e.notes}) if entries else 0 return "written", out_path, (len(entries), notes, dates) def discover_subscribed(vault: str): """扫描 vault 中已存在的 tagfeed 聚合页,返回 {tag: 页面路径}。 存在聚合页 = 订阅了该标签的自动更新;删掉页面 = 取消订阅。""" subs = {} for root, dirs, files in os.walk(vault): dirs[:] = [d for d in dirs if d not in SKIP_DIRS] for f in files: if not f.endswith(".md") or not f.startswith("tag-"): continue p = os.path.join(root, f) try: with open(p, encoding="utf-8", errors="replace") as fh: head = fh.read(1000) except OSError: continue if MARKER not in head: continue m = re.search(re.escape(MARKER) + r":\s*(.+?)\s*-->", head) if m: subs[m.group(1)] = p return subs def canvas_signature(vault, rel_dir=CANVAS_DIR): """00index 目录结构指纹:所有 md 文件与文件夹的相对路径有序元组。 文件改名不改变 mtime(全局指纹看不见),但会改变路径集合, 因此画布更新必须靠这个独立指纹触发。""" base = os.path.join(vault, rel_dir) if not os.path.isdir(base): return None items = [] for root, dirs, files in os.walk(base): dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".") and d not in CANVAS_SKIP_DIRS] for d in dirs: items.append("d:" + os.path.relpath(os.path.join(root, d), base)) for f in files: if f.startswith(".") or f == ".DS_Store": continue if f.endswith(".md"): items.append("f:" + os.path.relpath(os.path.join(root, f), base)) return tuple(sorted(items)) def generate_canvas_index(vault, rel_dir=CANVAS_DIR, canvas_name=CANVAS_NAME): """为 rel_dir 目录生成向右展开的脑图画布。 当前 foldernote 由方案3的 00index/00index.md 承担;watch 模式默认不会调用此函数。 结构:根节点 → 文件夹节点 → 文件节点(file 类型,可点击打开)。 触发规则:文件增/删/改名才改变画布;文件内容修改不影响画布内容, 内容哈希门控保证无变化时不写盘。返回 (status, canvas_path)。 颜色策略(重要): - 生成器**不再自动给任何节点/连线分配颜色**(旧逻辑用 CANVAS_COLORS 把每个顶层分支染成红/绿/蓝……,会覆盖用户手动设置的颜色,已停用)。 - 改为「保留旧画布里已有的、非默认的颜色」:节点 id 由路径确定性生成, 重画时同一文件 id 不变,因此可从旧画布继承用户手动设定的 color; **新出现的节点不带颜色**,由用户自己上色。 - 旧版遗留的默认色 "1"(浅蓝,曾由 CANVAS_COLORS 自动盖在每个节点上) 视为「无色」,重画时不再继承——历史画布上被自动涂成 "1" 的节点会 自然变回无色;而用户**手动**选过的其它颜色(如 "2"~"6" 或十六进制) 一律保留,生成器绝不改动。 - 画布生成时排除 CANVAS_SKIP_DIRS。 """ base = os.path.join(vault, rel_dir) if not os.path.isdir(base): return "missing", None # ---- 1. 建目录树(同层:文件夹在前、文件在后,各按名称排序)---- def build(abs_dir): children = [] try: entries = sorted(os.listdir(abs_dir)) except OSError: return children for e in entries: if e.startswith(".") or e == ".DS_Store": continue full = os.path.join(abs_dir, e) if os.path.isdir(full): if e in SKIP_DIRS or e in CANVAS_SKIP_DIRS: continue children.append({"dir": e, "children": build(full)}) elif e.endswith(".md"): children.append({"file": e[:-3]}) return children tree = {"dir": os.path.basename(rel_dir.rstrip("/")), "children": build(base)} # ---- 2. 布局:子树高度自底向上,列宽取每层最大节点宽 ---- def node_size(n): return (CANVAS_FILE_W, CANVAS_FILE_H) if "file" in n \ else (CANVAS_FOLDER_W, CANVAS_FOLDER_H) def subtree_h(n): if not n.get("children"): return node_size(n)[1] total = sum(subtree_h(c) for c in n["children"]) \ + CANVAS_V_GAP * (len(n["children"]) - 1) return max(total, node_size(n)[1]) col_w = {} def measure(n, depth): w = node_size(n)[0] if w > col_w.get(depth, 0): col_w[depth] = w for c in n.get("children", []): measure(c, depth + 1) measure(tree, 0) xs = [0] * (max(col_w) + 1) for d in range(1, len(xs)): xs[d] = xs[d - 1] + col_w[d - 1] + CANVAS_H_GAP def node_id(s): return hashlib.md5(s.encode("utf-8")).hexdigest()[:16] nodes, edges = [], [] # ---- 3. 读取旧画布里已有的颜色,按 id 匹配后保留(仅保留、不再自动上色)---- out_path = os.path.join(base, canvas_name) old_node_color, old_edge_color = {}, {} try: with open(out_path, encoding="utf-8") as fh: old = json.load(fh) for nd in old.get("nodes", []): c = nd.get("color") # 跳过旧版生成器遗留的默认色 "1"(视作无色,不继承),其余颜色(用户手动设的)一律保留 if c is not None and c != CANVAS_DEFAULT_COLOR: old_node_color[nd["id"]] = c for ed in old.get("edges", []): c = ed.get("color") if c is not None and c != CANVAS_DEFAULT_COLOR: old_edge_color[ed["id"]] = c except (OSError, ValueError): pass # ---- 4. 放置节点:父节点在其子树跨度内垂直居中 ---- def place(n, depth, top, segs): # segs = 该节点祖先在 rel_dir 之下的路径段 h = subtree_h(n) w, own_h = node_size(n) y = int(round(top + (h - own_h) / 2.0)) if "file" in n: own = segs + [n["file"]] nid = node_id("f:" + "/".join(own)) rel = "/".join([rel_dir.rstrip("/")] + own) # 用文字节点+wikilink 代替 file 节点:file 节点会渲染笔记预览 # (满屏"笔记属性"),文字节点直接显示笔记名,点击同样可打开 if "[[" in n["file"] or "]]" in n["file"]: text = n["file"] # 极端文件名兜底 else: text = "[[" + rel + "|" + n["file"] + "]]" node = {"id": nid, "type": "text", "text": text, "x": xs[depth], "y": y, "width": w, "height": own_h} if nid in old_node_color: # 仅保留旧画布里手动设的颜色 node["color"] = old_node_color[nid] nodes.append(node) return nid # 根节点 = rel_dir 本身,路径段为空;其余目录节点含自身名字 own = [] if depth == 0 else segs + [n["dir"]] nid = node_id("root") if depth == 0 else node_id("d:" + "/".join(own)) label = ("🗂 " if depth == 0 else "📁 ") + n["dir"] node = {"id": nid, "type": "text", "text": label, "x": xs[depth], "y": y, "width": w, "height": own_h} if nid in old_node_color: node["color"] = old_node_color[nid] nodes.append(node) kids = n.get("children", []) total_h = sum(subtree_h(c) for c in kids) + CANVAS_V_GAP * (len(kids) - 1) \ if kids else 0 child_top = top + (h - total_h) / 2.0 for c in kids: cid = place(c, depth + 1, child_top, own) eid = node_id("e:" + nid + cid) edge = {"id": eid, "fromNode": nid, "fromSide": "right", "toNode": cid, "toSide": "left"} if eid in old_edge_color: edge["color"] = old_edge_color[eid] edges.append(edge) child_top += subtree_h(c) + CANVAS_V_GAP return nid place(tree, 0, 0, []) # ---- 5. 内容哈希门控:无变化不写盘 ---- content = json.dumps({"nodes": nodes, "edges": edges}, ensure_ascii=False, indent="\t") + "\n" try: with open(out_path, encoding="utf-8") as fh: if fh.read() == content: return "unchanged", out_path except OSError: pass with open(out_path, "w", encoding="utf-8") as fh: fh.write(content) return "written", out_path def main(): ap = argparse.ArgumentParser(description="Logseq 风格 Obsidian tagfeed 页生成器") ap.add_argument("tag", nargs="?", help="标签名(带不带 # 都行)") ap.add_argument("--vault", action="append", help=f"vault 路径,可多次指定(默认 {DEFAULT_VAULT})") ap.add_argument("--out", default="00index", help="输出目录,相对 vault(默认 00index)") ap.add_argument("--title", default=None, help="自定义页面标题(默认 # 🏷 <标签>)") ap.add_argument("--open", action="store_true", help="生成后在 Obsidian 中打开") ap.add_argument("--tags", action="store_true", help="列出全库标签统计后退出") ap.add_argument("--watch", action="store_true", help="已退役:tagfeed 不再使用系统 watcher,请用 Obsidian 手动命令同步") ap.add_argument("--diary", action="store_true", help="已剥离:每日活动足迹请使用 daily_activity.py") ap.add_argument("--days", type=int, default=7, help="兼容旧参数;tagfeed 不再处理每日活动足迹") ap.add_argument("--interval", type=float, default=3.0, help="兼容旧参数;tagfeed watcher 已退役") args = ap.parse_args() if args.watch: print("Tagfeed watcher 已退役;请在 Obsidian Command+P 执行:Tagfeed:手动更新全局Tagfeed") return vault = os.path.abspath(os.path.expanduser(args.vault[0] if args.vault else DEFAULT_VAULT)) if not os.path.isdir(vault): sys.exit(f"错误:vault 不存在:{vault}") if args.tags: cmd_tags(vault) return if args.diary: print("每日活动足迹已从 tagfeed 剥离;请运行:python3 /Users/ar/bin/daily_activity.py --days N") return if not args.tag: ap.error("请提供标签名,例如:python3 tagfeed.py bobo") tag = args.tag.lstrip("#").strip() if not tag: sys.exit("错误:标签为空") status, out_path, (blocks, notes, days) = generate_tag( vault, tag, args.out, args.title) if status == "empty": print(f"未找到 #{tag} 的任何内容。(用 --tags 查看全库标签列表)") return print(f"✅ #{tag}: {blocks} 个块 / {notes} 篇笔记 / {days} 天") print(f" → {os.path.relpath(out_path, vault)}" + ("(内容无变化,未写盘)" if status == "unchanged" else "")) if args.open: vault_name = os.path.basename(vault) out_rel = os.path.relpath(out_path, vault) uri = ("obsidian://open?vault=" + urllib.parse.quote(vault_name) + "&file=" + urllib.parse.quote(os.path.splitext(out_rel)[0])) subprocess.Popen(["open", uri]) if __name__ == "__main__": main()