Ariver
2026-08-25 38ce52e76469dea05dbcac8b6126c20efa3825f2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
#!/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()