#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ NoteChain 复制提取工具 (copy-extraction) ========================================= 用途:把 Knomo 月归档(Memos-YYYY-MM.md)里、你标记为“要成链”的碎碎念, 用「复制」方式拆成独立 .md 文件,放到 X1.Knomo/Atom/。 设计原则(对应你对 NoteChain 的 4 条约束): 2. 复制提取:只读源、写新文件,绝不删除/移动 Knomo 原记录。 输出统一放在较深的 X1.Knomo/Atom/ 一个文件夹里。 3. 清理 todo 状态:去掉复选框(- [ ]/- [x])、任务标签(#todo/#done/#task…)、 任务插件 emoji,避免污染你的 GTD 工作流。 4. 不污染 tag 检索:去掉正文中所有 #标签(含选择用的标记标签本身), 副本不带任何 tag,因此不会出现在 00index 的标签 MOC 页面里。 副本仅带 notechain_copy / source / source_date / created 等 frontmatter 字段 (这些是字段,不是 tag,不会进入 tag: 检索)。 选择机制(控制影响、默认零提取): 只提取“正文中含标记标签”的碎碎念。默认标记标签 = #成链。 你在 Knomo 里给某条碎碎念加上 #成链,它才会被提取;没加 = 完全不动。 标记标签本身在副本中会被移除(它只是“选择信号”,不是内容)。 用法: python3 notechain_extract.py --vault "" # 默认 dry-run python3 notechain_extract.py --vault "" --apply # 真正写文件 python3 notechain_extract.py --vault "" --marker "#成链" --apply python3 notechain_extract.py --vault "" --months 2026-08 默认 dry-run(只预览,不写文件)。 """ import re import os import argparse DEFAULT_MARKER = "#成链" def parse_memos(month_file): """解析 Knomo 月归档,返回 memo 列表。每个 memo: date, time, raw(含多行)。""" memos = [] cur_date = None cur = None def flush(): if cur is not None: memos.append(cur) with open(month_file, encoding="utf-8") as f: for line in f: lr = line.rstrip("\n") # 日期标题: ## [[2026-08-04]] m = re.match(r"^##\s*\[\[(\d{4}-\d{2}-\d{2})\]\]", lr) if m: flush() cur = None cur_date = m.group(1) continue # 带时间戳的 bullet: - 14:15:41 正文 b = re.match(r"^\s*-\s*(\d{2}:\d{2}:\d{2})\s+(.*)", lr) if b: flush() cur = {"date": cur_date, "time": b.group(1), "raw": b.group(2)} continue # 续行(子要点/换行) if cur is not None: cur["raw"] += "\n" + lr continue # 其他行(# 标题、html 注释、空行)忽略 flush() return memos def clean_body(raw, marker): """清理:去标记标签、去所有 #标签、去复选框/任务状态/任务 emoji。""" text = raw # 1) 移除“选择信号”标记标签 text = text.replace(marker, " ") # 2) 移除所有 #标签(中文/英文/数字/连字符) text = re.sub(r"#[\w\u4e00-\u9fff\-]+", "", text) # 3) 行首复选框 text = re.sub(r"^\s*[-*]\s*\[[ xX]\]\s*", "", text, flags=re.M) # 4) 行内复选框 text = re.sub(r"\[[ xX]\]\s*", "", text) # 5) 任务 emoji for em in ["✅", "☑️", "☑", "🔲", "⬜", "🔼", "⏫", "🔽", "⤵️", "🔁", "⏰", "📅", "🆗"]: text = text.replace(em, "") # 6) 压缩多余空白 text = re.sub(r"[ \t]{2,}", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def slugify(text, n=8): chars = [c for c in text if re.match(r"[\u4e00-\u9fffA-Za-z0-9]", c)] s = "".join(chars)[:n] return s if s else "memo" def main(): ap = argparse.ArgumentParser(description="Knomo -> X1.Knomo/Atom/ 复制提取") ap.add_argument("--vault", required=True, help="vault 根目录") ap.add_argument("--marker", default=DEFAULT_MARKER, help="选择标记标签(默认 #成链)") ap.add_argument("--months", nargs="*", default=None, help="限定月份,如 2026-08;默认全部 Memos-*.md") ap.add_argument("--apply", action="store_true", help="真正写文件(默认 dry-run)") ap.add_argument("--all", action="store_true", help="提取该月所有碎碎念(忽略标记标签),用于整月批量实验") args = ap.parse_args() vault = os.path.abspath(args.vault) knomo_dir = os.path.join(vault, "X1.Knomo") out_dir = os.path.join(knomo_dir, "Atom") # 找月归档文件 if args.months: month_files = [os.path.join(knomo_dir, f"Memos-{m}.md") for m in args.months] else: month_files = sorted( os.path.join(knomo_dir, f) for f in os.listdir(knomo_dir) if re.match(r"^Memos-\d{4}-\d{2}\.md$", f) ) month_files = [f for f in month_files if os.path.isfile(f)] if not month_files: print("未找到任何 Memos-YYYY-MM.md 月归档文件。") return print(f"vault : {vault}") print(f"marker : {args.marker}") print(f"mode : {'APPLY (写文件)' if args.apply else 'DRY-RUN (仅预览)'}") print(f"月归档文件 : {len(month_files)} 个") print("-" * 60) selected = [] for mf in month_files: rel = os.path.relpath(mf, vault) memos = parse_memos(mf) for mem in memos: if not args.all and mem["raw"].find(args.marker) == -1: continue if not mem["date"]: continue y, mo, d = mem["date"].split("-") hh, mm, ss = (mem["time"] + ":00")[:8].split(":") yy = y[2:] fname = f"{yy}{mo}-{d}-{hh}{mm}{ss}-{slugify(clean_body(mem['raw'], args.marker))}.md" body = clean_body(mem["raw"], args.marker) front = ( "---\n" "notechain_copy: true\n" f'source: "{rel}"\n' f"source_date: {mem['date']}\n" f"created: {mem['date']} {mem['time']}\n" "---\n\n" ) selected.append({ "rel": rel, "fname": fname, "front": front, "body": body, "out_path": os.path.join(out_dir, fname), }) if not selected: print("未选中任何碎碎念(没有带 %s 的记录)。" % args.marker) print("→ 当前状态安全:零提取、零新文件。") print(" 想提取某条时,在 Knomo 那条文里加上 %s 即可。" % args.marker) return print(f"将提取 {len(selected)} 条 → {os.path.relpath(out_dir, vault)}/\n") for i, s in enumerate(selected, 1): print(f"[{i}] 源: {s['rel']}") print(f" 新文件: {s['fname']}") preview = s["body"].replace("\n", " ⏎ ") print(f" 清理后正文预览: {preview[:120]}") if not args.apply: print(f" (dry-run, 未写入)") print() if args.apply: os.makedirs(out_dir, exist_ok=True) written = 0 skipped = 0 for s in selected: if os.path.exists(s["out_path"]): print(f"跳过(已存在): {s['fname']}") skipped += 1 continue with open(s["out_path"], "w", encoding="utf-8") as f: f.write(s["front"] + s["body"] + "\n") written += 1 print("-" * 60) print(f"完成:写入 {written} 个文件,跳过 {skipped} 个已存在文件。") print(f"位置:{out_dir}") print("注意:Knomo 原记录未被删除/修改(复制提取)。") else: print("这是 dry-run 预览,未写入任何文件。加 --apply 才真正生成。") if __name__ == "__main__": main()