#!/usr/bin/env python3
|
# -*- coding: utf-8 -*-
|
"""
|
tagfeed 菜单维护器
|
====================
|
维护 00index/Menu-Tagfeed.md 这个轻量 Markdown outline 菜单。
|
|
设计边界:
|
- 不修改、不依赖画布索引。
|
- 复用 tagfeed 页面扫描、去噪、排除目录规则。
|
- 只补齐缺失标签,不移动、不重排用户已经整理过的 outline 节点。
|
- 新标签插入「待整理」节点下方的最前面。
|
"""
|
|
import importlib.util
|
import os
|
import re
|
import sys
|
from dataclasses import dataclass
|
from pathlib import Path
|
from typing import Optional
|
|
|
MENU_REL = "00index/Menu-Tagfeed.md"
|
TAGFEED_REL = "00index/tagfeed-md"
|
TODO_TITLE = "待整理"
|
|
|
@dataclass(frozen=True)
|
class TagInfo:
|
tag: str
|
page_rel: str
|
sort_time: float
|
|
|
def load_tagfeed_scan_module():
|
here = Path(__file__).resolve().parent
|
mod_path = here / "sync_0inbox.py"
|
spec = importlib.util.spec_from_file_location("tagfeed_scan", mod_path)
|
if spec is None or spec.loader is None:
|
raise RuntimeError(f"cannot load {mod_path}")
|
mod = importlib.util.module_from_spec(spec)
|
spec.loader.exec_module(mod)
|
return mod
|
|
|
def resolve_vault(explicit=None) -> Path:
|
if explicit:
|
return Path(explicit).expanduser().resolve()
|
return Path(__file__).resolve().parents[2]
|
|
|
def file_sort_time(path: Path) -> float:
|
try:
|
st = path.stat()
|
except OSError:
|
return 0.0
|
return float(getattr(st, "st_birthtime", st.st_mtime) or st.st_mtime)
|
|
|
def is_excluded_rel(sync_mod, rel: str) -> bool:
|
if rel in (".", ""):
|
return False
|
parts = rel.split(os.sep)
|
top = parts[0]
|
if top in sync_mod.EXCLUDE_TOP:
|
return True
|
if any(rel == p or rel.startswith(p + os.sep) for p in sync_mod.EXCLUDE_PATHS):
|
return True
|
return False
|
|
|
def collect_used_tags(vault: Path, sync_mod) -> tuple[dict[str, int], dict[str, float]]:
|
counts: dict[str, int] = {}
|
latest: dict[str, float] = {}
|
for root, dirs, files in os.walk(vault):
|
dirs[:] = [d for d in dirs if d not in sync_mod.EXCLUDE_DIRS and not d.startswith(".")]
|
rel = os.path.relpath(root, vault)
|
if is_excluded_rel(sync_mod, rel):
|
dirs[:] = []
|
continue
|
for fn in files:
|
if fn.startswith("."):
|
continue
|
ext = os.path.splitext(fn)[1].lower()
|
if ext not in sync_mod.SCAN_EXT:
|
continue
|
full = Path(root) / fn
|
tags = sync_mod.collect_tags_in_file(str(full))
|
if not tags:
|
continue
|
try:
|
mt = full.stat().st_mtime
|
except OSError:
|
mt = 0.0
|
for tg in tags:
|
if sync_mod.is_noise(tg):
|
continue
|
counts[tg] = counts.get(tg, 0) + 1
|
latest[tg] = max(latest.get(tg, 0.0), mt)
|
return counts, latest
|
|
|
def parse_target_tags(text: str) -> list[str]:
|
m = re.search(r'const\s+targetTag\s*=\s*(?:\[([^\]]*)\]|["\']([^"\']*)["\'])', text)
|
if not m:
|
return []
|
raw = m.group(1) if m.group(1) is not None else m.group(2)
|
out = []
|
for piece in raw.split(","):
|
tag = piece.strip().strip('"').strip("'").lstrip("#").strip()
|
if tag:
|
out.append(tag)
|
return out
|
|
|
def build_page_map(vault: Path, sync_mod) -> dict[str, str]:
|
"""返回 tag -> tagfeed 页面相对路径。"""
|
page_map: dict[str, str] = {}
|
inbox = vault / TAGFEED_REL
|
if inbox.is_dir():
|
for fp in inbox.glob("*.md"):
|
if fp.name.startswith("."):
|
continue
|
page_map[fp.stem] = fp.relative_to(vault).as_posix()
|
return page_map
|
|
|
def wikilink(page_rel: str, tag: str) -> str:
|
return f"[[{page_rel}|{tag}]]"
|
|
|
def strip_frontmatter(text: str) -> str:
|
if text.startswith("---\n"):
|
m = re.match(r"^---\n.*?\n---\n?", text, flags=re.S)
|
if m:
|
return text[m.end():]
|
return text
|
|
|
def extract_existing_tags(lines: list[str]) -> set[str]:
|
existing = set()
|
link_re = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
|
for line in lines:
|
for m in link_re.finditer(line):
|
path, alias = m.group(1), m.group(2)
|
tag = alias if alias is not None else Path(path).stem
|
tag = tag.strip().lstrip("#")
|
if tag:
|
existing.add(tag)
|
return existing
|
|
|
def rewrite_existing_links(lines: list[str], page_map: dict[str, str], sync_mod) -> list[str]:
|
link_re = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
|
out = []
|
for line in lines:
|
def repl(m):
|
path, alias = m.group(1), m.group(2)
|
stem = Path(path).stem
|
page_rel = page_map.get(stem) or page_map.get(sync_mod.tag_to_relpath(stem))
|
if not page_rel:
|
return m.group(0)
|
label = alias if alias is not None else Path(path).stem
|
return f"[[{page_rel}|{label}]]"
|
|
out.append(link_re.sub(repl, line))
|
return out
|
|
|
def normalize_existing_outline(text: str) -> list[str]:
|
body = strip_frontmatter(text)
|
lines = [ln.rstrip() for ln in body.splitlines()]
|
# 旧文件只有一个 "-",视为尚未初始化。
|
meaningful = [ln for ln in lines if ln.strip()]
|
if meaningful == ["-"]:
|
return []
|
# Menu 文件约定为纯 outline;保留 bullet 行,丢弃空行和旧 frontmatter。
|
return [ln for ln in lines if ln.strip().startswith(("-", "*", "+"))]
|
|
|
def find_todo_line(lines: list[str]) -> Optional[int]:
|
pat = re.compile(rf"^\s*[-*+]\s+{re.escape(TODO_TITLE)}\s*$")
|
for i, line in enumerate(lines):
|
if pat.match(line):
|
return i
|
return None
|
|
|
def merge_menu(existing_text: str, missing: list[TagInfo], page_map: dict[str, str], sync_mod) -> tuple[str, bool, int]:
|
lines = normalize_existing_outline(existing_text)
|
lines = rewrite_existing_links(lines, page_map, sync_mod)
|
todo_idx = find_todo_line(lines)
|
if todo_idx is None:
|
lines.insert(0, f"- {TODO_TITLE}")
|
todo_idx = 0
|
|
indent = len(lines[todo_idx]) - len(lines[todo_idx].lstrip(" "))
|
child_prefix = " " * (indent + 2) + "- "
|
new_lines = [child_prefix + wikilink(t.page_rel, t.tag) for t in missing]
|
if new_lines:
|
lines[todo_idx + 1:todo_idx + 1] = new_lines
|
|
content = "\n".join(lines).rstrip() + "\n"
|
old_outline = "\n".join(normalize_existing_outline(existing_text)).rstrip() + "\n"
|
changed = content != old_outline
|
return content, changed, len(new_lines)
|
|
|
def sync_menu(vault: Path) -> tuple[str, int, int, Path]:
|
sync_mod = load_tagfeed_scan_module()
|
counts, latest_seen = collect_used_tags(vault, sync_mod)
|
page_map = build_page_map(vault, sync_mod)
|
|
menu_path = vault / MENU_REL
|
try:
|
existing_text = menu_path.read_text(encoding="utf-8")
|
except OSError:
|
existing_text = ""
|
existing_tags = extract_existing_tags(normalize_existing_outline(existing_text))
|
|
missing: list[TagInfo] = []
|
for tag in counts:
|
if tag in existing_tags:
|
continue
|
rel_tag = sync_mod.tag_to_relpath(tag)
|
page_rel = page_map.get(tag) or page_map.get(rel_tag) or f"{TAGFEED_REL}/{rel_tag}.md"
|
page_time = file_sort_time(vault / page_rel)
|
sort_time = page_time or latest_seen.get(tag, 0.0)
|
missing.append(TagInfo(tag=tag, page_rel=page_rel, sort_time=sort_time))
|
missing.sort(key=lambda item: (-item.sort_time, item.tag.lower()))
|
|
content, changed, inserted = merge_menu(existing_text, missing, page_map, sync_mod)
|
if changed:
|
menu_path.parent.mkdir(parents=True, exist_ok=True)
|
menu_path.write_text(content, encoding="utf-8")
|
status = "written"
|
else:
|
status = "unchanged"
|
return status, len(counts), inserted, menu_path
|
|
|
def main() -> None:
|
vault = resolve_vault(sys.argv[1] if len(sys.argv) > 1 else None)
|
status, total, inserted, path = sync_menu(vault)
|
print(f"tagfeed-menu: {status} total_tags={total} inserted={inserted} path={path}")
|
|
|
if __name__ == "__main__":
|
main()
|