#!/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
|
|
输出文件: <vault>/<out>/tag-<标签>.md
|
重复运行即刷新(覆盖旧文件)。
|
"""
|
|
import argparse
|
import collections
|
import datetime
|
import difflib
|
import hashlib
|
import json
|
import os
|
import re
|
import subprocess
|
import sys
|
import time
|
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 = "<!-- tagfeed" # 生成文件头部的标记,扫描时跳过
|
WEEKDAYS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
MAX_SECTION_LINES = 300 # 小节捕获的行数上限,防止异常文件
|
PREVIEW_MAX_LINES = 3 # 聚合页内每个块最多预览的行数
|
PREVIEW_MAX_CHARS = 220 # 聚合页内每个块最多预览的字符数
|
WATCH_DEFAULT_VAULTS = [ # watch 模式默认监听的 vault
|
os.path.expanduser("~/Downloads/Syn/Ob/Ob.temp"),
|
os.path.expanduser("~/Downloads/Syn/Ob/Ob.bobo"),
|
]
|
DEFAULT_DIARY_DIR = os.path.expanduser("~/Downloads/Syn/Ob/Ob.temp/X0.Diary")
|
DIARY_MARK_BEGIN = "<!-- daily-activity:begin (tagfeed 自动生成,勿手改此块内) -->"
|
DIARY_MARK_END = "<!-- daily-activity:end -->"
|
DIARY_BACKFILL_DAYS = 7 # 回填最近 N 天的活动
|
ACTIVITY_LIST_MAX = 30 # 每种类型最多列出的文件数,超出折叠
|
DIARY_SKIP_DIRS = {"X.Attachment", "attachments", "Assets", "assets"}
|
DIARY_SKIP_REL_PREFIXES = ("00index", "X1.Knomo", "X2.Archived", "02DS/01dril-book")
|
DIARY_COPILOT_ROOT_REL = "02DS/02copilot"
|
DIARY_COPILOT_ALLOWED_REL = "02DS/02copilot/copilot-conversations"
|
TRACKED_SUFFIXES = (".md", ".canvas", ".excalidraw", ".csv", ".tsv",
|
".xlsx", ".xls", ".pdf", ".docx", ".doc", ".epub")
|
DIARY_ACTIVITY_TYPES = [ # 参与"每日足迹"追踪的文件类型(按顺序判定)
|
("白板", lambda f: f.endswith(".excalidraw.md") or f.endswith(".excalidraw") or f.endswith(".canvas")),
|
("表格", lambda f: f.lower().endswith((".csv", ".tsv", ".xlsx", ".xls"))),
|
("文档", lambda f: f.lower().endswith((".pdf", ".docx", ".doc", ".epub"))),
|
("笔记", lambda f: f.endswith(".md")),
|
]
|
CANVAS_DIR = "00index" # 目录脑图索引的文件夹
|
CANVAS_NAME = "00index.md" # 目录索引文件名
|
CANVAS_INDEX_ENABLED = False # 禁止自动生成/复活画布索引
|
CANVAS_FILE_W, CANVAS_FILE_H = 240, 54 # 文件节点尺寸
|
CANVAS_FOLDER_W, CANVAS_FOLDER_H = 150, 60 # 文件夹/根节点尺寸
|
CANVAS_H_GAP, CANVAS_V_GAP = 90, 24 # 列间距 / 节点纵向间距
|
CANVAS_COLORS = ["1", "2", "3", "4", "5", "6"] # 历史兼容:当前不会启用
|
CANVAS_DEFAULT_COLOR = "1" # 历史兼容:当前不会启用
|
CANVAS_SKIP_DIRS = {"tagfeed-md"} # 目录索引时排除 tagfeed 页面目录
|
ACTIVITY_ICONS = {"白板": "🖼", "表格": "📊", "文档": "📄", "笔记": "📝"}
|
# ---- 任务动态(Task Board 任务/子任务变更追踪)----
|
TASK_LINE_RE = re.compile(r"^(\s*)-\s*\[( |x|X|/|-)\]\s+(.*)$")
|
TASK_ID_RE = re.compile(r"🆔\s*(\d+)")
|
TASK_META_RE = re.compile(r"📅\s*[\d\-/]+|🆔\s*\d+|⛔\s*\d+|🔁\s*\S+")
|
WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
|
MDLINK_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
ANY_TAG_RE = re.compile(r"(?<![\w&/])#([\w\u4e00-\u9fff][\w\u4e00-\u9fff/\-]*)")
|
STATUS_LABEL = {" ": "待办", "/": "进行中", "x": "完成", "-": "取消"}
|
TASK_EVENT_MAX_TEXT = 40
|
TASK_EVENT_LIST_MAX = 30 # 日记任务动态小节每天最多显示条数
|
DIARY_STATE_PATH = os.path.expanduser("~/.tagfeed-diary-state.json")
|
HASH_CAP_BYTES = 8 * 1024 * 1024 # 大文件只哈希首尾各 8MB + 文件大小
|
|
|
# ---------------------------------------------------------------- 工具函数
|
|
def tag_regex(tag: str) -> re.Pattern:
|
"""精确匹配一个标签:前面不能是词字符/#,后面不能是词字符 / - (避免 #bobo 匹配 #bobo-xyz)"""
|
return re.compile(r"(?<![\w#])#" + re.escape(tag) + r"(?![\w/\-])")
|
|
|
ALL_TAGS_RE = re.compile(r"(?<![\w#])#([\w\u4e00-\u9fff][\w\u4e00-\u9fff/\-]*)")
|
|
LIST_ITEM_RE = re.compile(r"^(\s*)([-*+]|\d+[.)])(?:\s+(.*))?$")
|
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
DATE_HEADING_RE = re.compile(r"\[\[(\d{4})-(\d{2})-(\d{2})\]\]")
|
TIME_RE = re.compile(r"(\d{1,2}):(\d{2})(?::(\d{2}))?")
|
FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
|
INLINE_COMMENT_RE = re.compile(r"%%.*?%%", re.S)
|
INLINE_HTML_RE = re.compile(r"<!--.*?-->", 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
|
|
|
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") or f.endswith(".excalidraw.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 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
|
|
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("<!--") and "-->" 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
|
|
# 普通段落带标签:吸收到空行为止
|
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 diary_rel_name(d: datetime.date) -> str:
|
"""日记文件名:YYYY-MM-DD.md(如 2026-08-06 → 2026-08-06.md)。
|
2026-08-25 起日记改用 YYYY-MM-DD 格式,与 Obsidian daily-notes 设置一致。"""
|
return d.isoformat() + ".md"
|
|
|
def classify_activity(fname: str):
|
for label, test in DIARY_ACTIVITY_TYPES:
|
if test(fname):
|
return label
|
return None
|
|
|
def should_skip_diary_path(path: str, vault: str) -> bool:
|
rel = os.path.relpath(path, vault)
|
if rel == ".":
|
return False
|
rel = rel.replace(os.sep, "/")
|
parts = rel.split("/")
|
if any(part in SKIP_DIRS or part in DIARY_SKIP_DIRS or part.startswith(".") for part in parts):
|
return True
|
if any(rel_is_or_under(rel, prefix) for prefix in DIARY_SKIP_REL_PREFIXES):
|
return True
|
if rel == DIARY_COPILOT_ROOT_REL:
|
return False
|
if rel_is_or_under(rel, DIARY_COPILOT_ROOT_REL):
|
return not rel_is_or_under(rel, DIARY_COPILOT_ALLOWED_REL)
|
return False
|
|
|
def iter_tracked_files(vault: str):
|
"""所有参与"每日足迹"追踪的文件(含 canvas/表格/文档等非 md 类型)"""
|
for root, dirs, files in os.walk(vault):
|
dirs[:] = [d for d in dirs
|
if d not in SKIP_DIRS and d not in DIARY_SKIP_DIRS
|
and not d.startswith(".")
|
and not should_skip_diary_path(os.path.join(root, d), vault)]
|
for f in files:
|
if f == ".DS_Store" or not f.lower().endswith(TRACKED_SUFFIXES):
|
continue
|
if any(c in f for c in "[]|#\n\r"):
|
continue # 文件名含会破坏 wikilink 的字符,跳过
|
path = os.path.join(root, f)
|
if should_skip_diary_path(path, vault):
|
continue
|
yield path
|
|
|
def in_diary_dir(path: str, vault: str) -> bool:
|
rel = os.path.relpath(os.path.dirname(path), vault)
|
return rel == "X0.Diary" or rel.startswith("X0.Diary" + os.sep)
|
|
|
def is_generated_page(path: str) -> bool:
|
"""tagfeed 生成的聚合页不算用户活动。
|
|
两种系统页都要排除:
|
1) 文件名以 tag- 开头、且头部含 <!-- tagfeed 标记(旧的批量聚合页)
|
2) frontmatter 的 tags: 含 tagfeed 或 tagfeed-live(实时聚合页,
|
如 00index/tagfeed-md/<tag>.md,文件名不以 tag- 开头,
|
头部也无 <!-- tagfeed 标记,单靠前一种判断会漏掉)
|
避免这些"系统自生成页"被当成用户笔记塞进每日活动足迹。"""
|
if not path.endswith(".md"):
|
return False
|
b = os.path.basename(path)
|
if b.startswith("tag-") and b.endswith(".md"):
|
try:
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
return MARKER in fh.read(600)
|
except OSError:
|
return False
|
# 文件名不以 tag- 开头:靠 frontmatter 的 tags 字段判断
|
try:
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
head = fh.read(4000)
|
except OSError:
|
return False
|
if "---" not in head:
|
return False
|
fm = head.split("---", 2)
|
if len(fm) < 3:
|
return False
|
block = fm[1]
|
if re.search(r"^tags:\s*\[(.*?)\]", block, re.M | re.S):
|
if re.search(r"\b(?:tagfeed|tagfeed-live)\b", block):
|
return True
|
if re.search(r"^tags:\s*$", block, re.M):
|
# 列表式 tags:
|
m = re.search(r"^tags:\s*\n((?:[ \t]+-[ \t]+.*\n?)+)", block, re.M)
|
if m:
|
if re.search(r"tagfeed|tagfeed-live", m.group(1)):
|
return True
|
return False
|
|
|
def link_for(path: str, vault: str, name_counts) -> str:
|
"""生成 Obsidian wikilink:名字全库唯一用短链接,否则带目录路径。
|
md 去掉扩展名;canvas/excalidraw 等保留(Obsidian 约定)。"""
|
name = os.path.basename(path)
|
stem = name[:-3] if name.endswith(".md") else name
|
if name_counts[stem] <= 1:
|
return f"[[{stem}]]"
|
rel = os.path.relpath(path, vault)
|
target = rel[:-3] if rel.endswith(".md") else rel
|
return f"[[{target}|{stem}]]"
|
|
|
def file_hash(path: str) -> str:
|
"""内容哈希。小文件全量;大文件只哈希首尾各 HASH_CAP_BYTES + 文件大小。"""
|
try:
|
size = os.path.getsize(path)
|
h = hashlib.sha256()
|
h.update(str(size).encode())
|
with open(path, "rb") as fh:
|
if size <= HASH_CAP_BYTES * 2:
|
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
h.update(chunk)
|
else:
|
h.update(fh.read(HASH_CAP_BYTES))
|
fh.seek(-HASH_CAP_BYTES, os.SEEK_END)
|
h.update(fh.read(HASH_CAP_BYTES))
|
return h.hexdigest()
|
except OSError:
|
return ""
|
|
|
def load_diary_state() -> dict:
|
try:
|
with open(DIARY_STATE_PATH, encoding="utf-8") as fh:
|
return json.load(fh).get("files", {})
|
except (OSError, ValueError):
|
return {}
|
|
|
def save_diary_state(files: dict):
|
"""写状态文件,顺带清理:不存在的文件、超过 14 天的编辑记录。"""
|
cutoff = (datetime.date.today() - datetime.timedelta(days=14)).isoformat()
|
for p in [p for p in files if not os.path.exists(p)]:
|
del files[p]
|
for rec in files.values():
|
rec["edited"] = {d: t for d, t in rec.get("edited", {}).items() if d >= cutoff}
|
tmp = DIARY_STATE_PATH + ".tmp"
|
try:
|
with open(tmp, "w", encoding="utf-8") as fh:
|
json.dump({"files": files}, fh, ensure_ascii=False)
|
os.replace(tmp, DIARY_STATE_PATH)
|
except OSError:
|
pass
|
|
|
def parse_task_snapshot(text: str) -> dict:
|
"""解析笔记里所有复选框任务行 → 快照 {key: [status, 原始文本, 标签串, 父任务key]}。
|
Task Board 管理的任务带 🆔 N,用 "id:N" 作稳定键;无 ID 的用 "txt:文本"。
|
父子关系按缩进判断(子任务缩进更深)。"""
|
tasks = {}
|
stack = [] # [(indent, key)] 父任务链
|
for line in text.split("\n"):
|
m = TASK_LINE_RE.match(line)
|
if not m:
|
continue
|
indent = indent_of(m.group(1))
|
status = m.group(2).lower()
|
raw = m.group(3).strip()
|
mid = TASK_ID_RE.search(raw)
|
key = ("id:" + mid.group(1)) if mid else ("txt:" + raw)
|
if key in tasks:
|
continue # 同键重复极少见,以首个为准
|
while stack and stack[-1][0] >= indent:
|
stack.pop()
|
parent = stack[-1][1] if stack else ""
|
tags_str = " ".join("#" + t for t in ANY_TAG_RE.findall(raw))
|
tasks[key] = [status, raw, tags_str, parent]
|
stack.append((indent, key))
|
return tasks
|
|
|
def diff_task_snapshots(old, new):
|
"""比对新旧任务快照 → 事件列表 (kind, key)。
|
kind: added/status/edited/removed;key 指向新快照(removed 指向旧快照)。
|
无 ID 任务的文本改动用模糊匹配兜底(相似度≥0.6 视为同一任务)。"""
|
pairs = [] # (old_key, new_key)
|
matched_new = set()
|
for k in new:
|
if k in old:
|
pairs.append((k, k))
|
matched_new.add(k)
|
rest_old = set(k for k, _ in pairs)
|
rest_new = [k for k in new if k not in matched_new]
|
used = set()
|
for ok in old:
|
if ok in rest_old or not ok.startswith("txt:"):
|
continue
|
best, br = None, 0.0
|
for nk in rest_new:
|
if nk in used or not nk.startswith("txt:"):
|
continue
|
r = difflib.SequenceMatcher(None, ok[4:], nk[4:]).ratio()
|
if r > br:
|
br, best = r, nk
|
if best is not None and br >= 0.6:
|
pairs.append((ok, best))
|
used.add(best)
|
matched_old = set(p[0] for p in pairs)
|
events = []
|
for ok, nk in pairs:
|
o, n = old[ok], new[nk]
|
if o[0] != n[0]:
|
events.append(("status", nk))
|
elif o[1] != n[1]:
|
events.append(("edited", nk))
|
for k in new:
|
if k not in matched_new and k not in used:
|
events.append(("added", k))
|
for k in old:
|
if k not in matched_old:
|
events.append(("removed", k))
|
return events
|
|
|
def clean_task_text(raw: str) -> str:
|
"""任务行 → 干净的展示文本:去元数据 emoji、链接转显示文字、去标签、超长截断"""
|
t = TASK_META_RE.sub(" ", raw)
|
t = WIKILINK_RE.sub(lambda m: m.group(2) or m.group(1), t)
|
t = MDLINK_RE.sub(lambda m: m.group(1), t)
|
t = ANY_TAG_RE.sub(" ", t)
|
t = re.sub(r"\s+", " ", t).strip()
|
if len(t) > TASK_EVENT_MAX_TEXT:
|
t = t[:TASK_EVENT_MAX_TEXT] + "…"
|
return t
|
|
|
def detect_task_events(path, rec, tstr, d_iso):
|
"""对当前文件做任务快照 diff:有变化就把事件追加进 rec["tasks"][d_iso],
|
并更新快照。首次见到该文件只建基线不产生事件。返回新增事件数。"""
|
try:
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
text = fh.read()
|
except OSError:
|
return 0
|
new_snap = parse_task_snapshot(text)
|
old_snap = rec.get("task_snapshot")
|
rec["task_snapshot"] = new_snap
|
if not old_snap:
|
return 0
|
events = diff_task_snapshots(old_snap, new_snap)
|
if not events:
|
return 0
|
bucket = rec.setdefault("tasks", {}).setdefault(d_iso, [])
|
n = 0
|
for kind, key in events:
|
src = new_snap if key in new_snap else old_snap
|
info = src.get(key)
|
if info is None:
|
continue
|
status, raw, tags_str, parent_key = info
|
pinfo = src.get(parent_key)
|
parent_disp = clean_task_text(pinfo[1]) if pinfo else ""
|
if not tags_str and pinfo:
|
tags_str = pinfo[2] # 子任务没标签 → 继承父任务标签
|
if kind == "status":
|
verb = "→ " + STATUS_LABEL.get(status, status)
|
elif kind == "added":
|
verb = "➕ 新建"
|
elif kind == "edited":
|
verb = "✏️ 编辑"
|
else:
|
verb = "🗑️ 删除"
|
bucket.append([tstr, verb, clean_task_text(raw), tags_str, parent_disp])
|
n += 1
|
if len(bucket) > 100:
|
del bucket[:len(bucket) - 100]
|
return n
|
|
|
def build_activity_block(vault, items_by_type, name_counts, task_events=None) -> str:
|
lines = [DIARY_MARK_BEGIN, "## 🕐 活动足迹(自动记录)", ""]
|
if task_events:
|
lines += ["### ✅ 任务动态", ""]
|
shown = sorted(task_events, key=lambda e: e[0], reverse=True)
|
for tstr, verb, text, tags, parent in shown[:TASK_EVENT_LIST_MAX]:
|
line = f"- {tstr} {verb} · {text}"
|
if tags:
|
line += f" `{tags}`"
|
lines.append(line)
|
if parent:
|
lines.append(f"\t- ↳ 父任务:{parent}")
|
if len(shown) > TASK_EVENT_LIST_MAX:
|
lines.append(f"- …其余 {len(shown) - TASK_EVENT_LIST_MAX} 条省略")
|
lines.append("")
|
lines += ["> ✏️ = 编辑过 · 👀 = 只打开/碰过,内容没变", ""]
|
for label, _ in DIARY_ACTIVITY_TYPES:
|
items = items_by_type.get(label)
|
if not items:
|
continue
|
icon = ACTIVITY_ICONS.get(label, "·")
|
lines.append(f"- {icon} **{label}** · {len(items)} 项")
|
edited = sorted([it for it in items if it[2] == "edited"],
|
key=lambda it: it[0], reverse=True)
|
touched = sorted([it for it in items if it[2] != "edited"],
|
key=lambda it: it[0], reverse=True)
|
shown = (edited + touched)[:ACTIVITY_LIST_MAX]
|
for tstr, path, kind in shown:
|
mark = "✏️" if kind == "edited" else "👀"
|
lines.append(f"\t- {tstr} {mark} {link_for(path, vault, name_counts)}")
|
if len(items) > ACTIVITY_LIST_MAX:
|
lines.append(f"\t- …其余 {len(items) - ACTIVITY_LIST_MAX} 项省略")
|
lines += ["", DIARY_MARK_END]
|
return "\n".join(lines)
|
|
|
def update_diaries(vault: str, days_back: int, log=print, known_written=None):
|
"""扫描 vault,把最近 days_back 天内变动过的文件写进对应日期的日记。
|
用内容哈希区分「✏️ 编辑过」和「👀 只碰过」。
|
只替换日记中标记块内的内容,手写内容不受影响。返回更新的日记数。"""
|
if known_written is None:
|
known_written = {}
|
diary_dir = os.path.join(vault, "X0.Diary")
|
today = datetime.date.today()
|
cutoff = today - datetime.timedelta(days=days_back)
|
state = load_diary_state()
|
state_dirty = False
|
|
by_day = collections.defaultdict(lambda: collections.defaultdict(list))
|
name_counts = collections.Counter()
|
for p in iter_tracked_files(vault):
|
if in_diary_dir(p, vault) or is_generated_page(p):
|
continue
|
prev = known_written.get(p)
|
try:
|
st = os.stat(p)
|
except OSError:
|
continue
|
if prev is not None and abs(st.st_mtime - prev) < 0.01:
|
continue # 刚由本工具写出的文件
|
|
rec = state.get(p)
|
d = datetime.date.fromtimestamp(st.st_mtime)
|
tstr = datetime.datetime.fromtimestamp(st.st_mtime).strftime("%H:%M")
|
d_iso = d.isoformat()
|
|
if rec is not None and abs(rec.get("mtime", 0) - st.st_mtime) < 0.01:
|
# mtime 没变:沿用上次分类,零开销
|
current_kind = "edited" if d_iso in rec.get("edited", {}) else "touched"
|
else:
|
# mtime 变了:对比哈希判断是编辑还是只碰过
|
new_hash = file_hash(p)
|
if rec is not None and rec.get("hash") and rec["hash"] == new_hash:
|
current_kind = "touched"
|
else:
|
current_kind = "edited" # 内容真的变了(或首次见到,保守记为编辑)
|
if rec is None:
|
rec = {"edited": {}}
|
if current_kind == "edited":
|
rec["edited"][d_iso] = tstr
|
rec["mtime"] = st.st_mtime
|
rec["hash"] = new_hash
|
state[p] = rec
|
state_dirty = True
|
# 任务动态:md 文件内容变了 → diff 任务快照(首次只见建基线)
|
if current_kind == "edited" and p.endswith(".md"):
|
try:
|
if detect_task_events(p, rec, tstr, d_iso) > 0:
|
state_dirty = True
|
except Exception:
|
pass # 任务解析失败不影响足迹主流程
|
|
label = classify_activity(os.path.basename(p))
|
if not label:
|
continue
|
stem = os.path.basename(p)
|
name_counts[stem[:-3] if stem.endswith(".md") else stem] += 1
|
|
# 该文件的展示条目:历史编辑记录(窗口内的)+ 当前 mtime 日期的事件
|
entries = {}
|
for ds, et in rec.get("edited", {}).items():
|
try:
|
dd = datetime.date.fromisoformat(ds)
|
except ValueError:
|
continue
|
if cutoff <= dd <= today:
|
entries[ds] = (et, "edited")
|
if cutoff <= d <= today and d_iso not in entries:
|
entries[d_iso] = (tstr, current_kind)
|
for ds, (et, kind) in entries.items():
|
by_day[datetime.date.fromisoformat(ds)][label].append((et, p, kind))
|
|
# 任务动态:汇总所有文件在窗口内各天的事件(顺带清理过期桶和已删文件)
|
task_by_day = collections.defaultdict(list)
|
prune_before = (cutoff - datetime.timedelta(days=7)).isoformat()
|
for p, rec in list(state.items()):
|
if should_skip_diary_path(p, vault) or in_diary_dir(p, vault) or is_generated_page(p):
|
continue
|
if not os.path.exists(p):
|
del state[p] # 文件已删:其任务事件也不再展示
|
state_dirty = True
|
continue
|
tasks = rec.get("tasks")
|
if not tasks:
|
continue
|
for ds in list(tasks.keys()):
|
if ds < prune_before:
|
del tasks[ds]
|
state_dirty = True
|
continue
|
try:
|
dd = datetime.date.fromisoformat(ds)
|
except ValueError:
|
continue
|
if cutoff <= dd <= today:
|
task_by_day[dd].extend(tasks[ds])
|
|
if state_dirty:
|
save_diary_state(state)
|
|
changed = 0
|
days = sorted(set(list(by_day.keys()) + list(task_by_day.keys()) + [
|
today - datetime.timedelta(days=i) for i in range(days_back + 1)
|
]), reverse=True)
|
for d in days:
|
if d < cutoff or d > today:
|
continue
|
dpath = os.path.join(diary_dir, diary_rel_name(d))
|
try:
|
with open(dpath, encoding="utf-8") as fh:
|
existing = fh.read()
|
except OSError:
|
existing = None
|
has_block = existing is not None and DIARY_MARK_BEGIN in existing
|
|
types = by_day.get(d)
|
tev = task_by_day.get(d)
|
if not types and not tev:
|
if not has_block:
|
continue # 没内容也没旧块,跳过(不凭空创建空日记)
|
new = re.sub(re.escape(DIARY_MARK_BEGIN) + r".*?" + re.escape(DIARY_MARK_END),
|
"", existing, flags=re.S)
|
new = re.sub(r"\n{3,}", "\n\n", new).rstrip("\n") + "\n"
|
else:
|
block = build_activity_block(vault, types or {}, name_counts,
|
task_events=tev)
|
if existing is None:
|
new = f"# {diary_rel_name(d)[:-3]}\n\n{block}\n"
|
elif has_block:
|
new = re.sub(re.escape(DIARY_MARK_BEGIN) + r".*?" + re.escape(DIARY_MARK_END),
|
lambda _m: block, existing, flags=re.S)
|
else:
|
new = existing.rstrip("\n") + "\n\n" + block + "\n"
|
|
if existing is not None and new == existing:
|
continue
|
os.makedirs(diary_dir, exist_ok=True)
|
with open(dpath, "w", encoding="utf-8") as fh:
|
fh.write(new)
|
known_written[dpath] = os.stat(dpath).st_mtime
|
changed += 1
|
if log:
|
log(f" 日记 {diary_rel_name(d)}: 活动足迹已更新")
|
return changed
|
|
|
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 cmd_watch(vaults, interval: float):
|
def log(msg):
|
print(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}", flush=True)
|
|
log("tagfeed watch 启动,监听: " + ", ".join(vaults))
|
known_written = {} # 输出页路径 -> 我方写入时的 mtime(防止自我触发)
|
last_max = {} # vault -> 已处理的内容指纹
|
baseline = set()
|
scan_cache = {} # (path, tag) -> (mtime_ns, entries),增量缓存
|
canvas_sigs = {} # vault -> 00index 结构指纹(改名不改 mtime,须独立轮询)
|
|
def cached_scan(path, rel, tag, rx):
|
"""按文件 mtime 缓存解析结果:没改过的文件不重新读盘/跑正则"""
|
try:
|
mt = os.stat(path).st_mtime_ns
|
except OSError:
|
return []
|
key = (path, tag)
|
hit = scan_cache.get(key)
|
if hit is not None and hit[0] == mt:
|
return hit[1]
|
es, _ = scan_file(path, rel, tag, rx)
|
scan_cache[key] = (mt, es)
|
return es
|
|
def content_fingerprint(vault):
|
"""全库内容文件的指纹 (文件数, mtime 总和)。
|
新增/修改/删除文件都会改变指纹。tagfeed 生成页不计入。
|
覆盖所有被追踪的类型(md/白板/表格/文档),日记更新也靠它触发。"""
|
count, total = 0, 0
|
for p in iter_tracked_files(vault):
|
try:
|
st = os.stat(p)
|
except OSError:
|
continue
|
prev = known_written.get(p)
|
if prev is not None and abs(st.st_mtime - prev) < 0.01:
|
continue # tagfeed 刚写出的页面,不算内容变更
|
if os.path.basename(p).startswith("tag-"):
|
try:
|
with open(p, encoding="utf-8", errors="replace") as fh:
|
if MARKER in fh.read(600):
|
known_written[p] = st.st_mtime
|
continue # 别处生成的 tagfeed 页,同样跳过
|
except OSError:
|
pass
|
count += 1
|
total += int(st.st_mtime_ns // 1000000) # 毫秒粒度,避免浮点误差
|
return (count, total)
|
|
def refresh(vault):
|
subs = discover_subscribed(vault)
|
n = 0
|
for tag, out_path in subs.items():
|
out_rel_dir = os.path.relpath(os.path.dirname(out_path), vault)
|
try:
|
status, path, _ = generate_tag(vault, tag, out_rel_dir,
|
allow_empty=True, scan_fn=cached_scan)
|
except Exception as ex:
|
log(f" 更新 #{tag} 失败: {ex}")
|
continue
|
if status == "written":
|
known_written[path] = os.stat(path).st_mtime
|
n += 1
|
# 每日足迹:把变动的文件写进对应日期的日记(只对有日记目录的 vault)
|
if os.path.isdir(os.path.join(vault, "X0.Diary")):
|
try:
|
dn = update_diaries(vault, DIARY_BACKFILL_DAYS, log=log,
|
known_written=known_written)
|
except Exception as ex:
|
log(f" 日记足迹更新失败: {ex}")
|
dn = 0
|
else:
|
dn = 0
|
# 方案3:Excalidraw 版 tagfeed 总入口,当前 00index 的唯一 foldernote。
|
# 只维护 Frame0-all 内的标签卡片,尽量保留用户手工整理后的位置和样式。
|
try:
|
excalidraw_menu_py = os.path.join(vault, "X2.Archived", "scripts", "sync_excalidraw_tagfeed.py")
|
if os.path.isfile(excalidraw_menu_py):
|
r = subprocess.run([sys.executable, excalidraw_menu_py, vault],
|
capture_output=True, text=True, timeout=180)
|
if r.returncode != 0:
|
log(f" Excalidraw-Tagfeed 同步失败: {(r.stderr or '').strip()[-200:]}")
|
else:
|
out = (r.stdout or "").strip()
|
m = re.search(r"Excalidraw-Tagfeed:\s+(\w+)\s+total_tags=(\d+)\s+inserted=(\d+)\s+removed=(\d+)\s+path=(.+)", out)
|
if m:
|
excalidraw_menu_path = m.group(5).strip()
|
if os.path.exists(excalidraw_menu_path):
|
known_written[excalidraw_menu_path] = os.stat(excalidraw_menu_path).st_mtime
|
if m.group(1) == "written":
|
log(f" Excalidraw-Tagfeed 已更新: 补入 {m.group(3)} 个标签,移除 {m.group(4)} 个重复/过期标签")
|
elif out:
|
log(f" Excalidraw-Tagfeed 输出: {out[-200:]}")
|
except Exception as ex:
|
log(f" Excalidraw-Tagfeed 同步异常: {ex}")
|
return n, dn
|
|
while True:
|
for vault in vaults:
|
if not os.path.isdir(vault):
|
continue
|
name = os.path.basename(vault)
|
if CANVAS_INDEX_ENABLED:
|
try:
|
sig = canvas_signature(vault)
|
except Exception:
|
sig = None
|
if sig is not None and sig != canvas_sigs.get(vault):
|
canvas_sigs[vault] = sig
|
try:
|
cstat, cpath = generate_canvas_index(vault)
|
if cstat == "written":
|
known_written[cpath] = os.stat(cpath).st_mtime
|
log(f"{name}: 目录脑图画布已更新 "
|
+ os.path.relpath(cpath, vault))
|
except Exception as ex:
|
log(f"{name}: 目录脑图画布更新失败: {ex}")
|
try:
|
fp = content_fingerprint(vault)
|
except Exception as ex:
|
log(f"扫描出错 {vault}: {ex}")
|
continue
|
if vault not in baseline:
|
baseline.add(vault)
|
n, dn = refresh(vault)
|
last_max[vault] = fp
|
log(f"{name}: 首次追赶完成,更新 {n} 个聚合页、{dn} 个日记")
|
continue
|
if fp != last_max.get(vault):
|
time.sleep(1.5) # 防抖:等保存稳定
|
fp2 = content_fingerprint(vault)
|
n, dn = refresh(vault)
|
last_max[vault] = fp2
|
log(f"{name}: 检测到变更,更新 {n} 个聚合页、{dn} 个日记")
|
time.sleep(interval)
|
|
|
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};watch 模式默认两个 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="常驻监听:vault 有变更时自动刷新所有已生成的聚合页和日记足迹")
|
ap.add_argument("--diary", action="store_true",
|
help=f"一次性回填最近 {DIARY_BACKFILL_DAYS} 天的日记活动足迹后退出")
|
ap.add_argument("--days", type=int, default=DIARY_BACKFILL_DAYS,
|
help=f"--diary 回填的天数(默认 {DIARY_BACKFILL_DAYS})")
|
ap.add_argument("--interval", type=float, default=3.0,
|
help="watch 模式轮询间隔秒数(默认 3)")
|
args = ap.parse_args()
|
|
if args.watch:
|
scheme3_watch = os.path.join(os.path.dirname(__file__), "excalidraw_tagfeed_watch.py")
|
os.execv(sys.executable, [sys.executable, scheme3_watch])
|
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:
|
n = update_diaries(vault, args.days)
|
print(f"✅ 日记活动足迹:更新了 {n} 篇日记(回填最近 {args.days} 天)")
|
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()
|