#!/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 "<vault>" # 默认 dry-run
|
python3 notechain_extract.py --vault "<vault>" --apply # 真正写文件
|
python3 notechain_extract.py --vault "<vault>" --marker "#成链" --apply
|
python3 notechain_extract.py --vault "<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()
|