ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

caveman:用纯文本与Git构建极简个人笔记系统

caveman:用纯文本与Git构建极简个人笔记系统 1. 项目动机与整体设计为什么把个人笔记系统做成“原始人”前阵子整理电脑里的碎片文档突然意识到一个问题我同时用着三四个笔记软件、一个待办事项App、还有浏览器里的收藏夹数据散落得到处都是。有的存在云端有的只在本地有的格式私有关不掉迁移一次恨不得手动复制粘贴半天。折腾来折腾去我反而开始怀念最原始的文字处理方式——纯文本没有花哨的格式没有任何锁定用户的私有结构。所以我就动手做了一个叫“caveman”的小工具。名字起得挺直白像原始人一样思考用最笨、最简单、最不依赖环境的办法管理个人记录。它的核心思路就是三句话记录用纯文本文件操作用终端命令同步用Git仓库。说白了整个系统就是一堆Markdown或无格式的.txt文件再加几个顺手的小脚本没有任何数据库不依赖任何在线服务离线能用同步靠Git换电脑也就是clone一下仓库的事儿。我写这篇东西不是要推销什么成品而是想完整拆解一下这个小工具的设计思路和实现细节。里面包含了我在踩坑之后整理出来的实操方案、脚本代码和排查经验。如果你也是那种受不了“全家桶”式软件绑架、喜欢折腾命令行、或者单纯想给自己的知识库找一个十年后还能打开的格式那这篇文章应该能给你不少可以直接抄的作业。整个项目大概分成三块数据层纯文本文件的组织规范、处理层一组Shell和Python脚本、同步层Git仓库加定时提交。这三层事情都不复杂但把它们拼到一起还是有几个值得琢磨的取舍点。下面我把每一步的设计逻辑和具体实现都摊开讲。2. 设计与选型背后的核心取舍为什么必须是纯文本加命令行2.1 少即是多数据格式层面的“返祖”在动手写第一个脚本之前我花了大量时间纠结数据格式。用过Notion的人都知道那种块编辑器确实方便可一旦数据量上来、网络一慢或者你某天想批量处理几百条笔记就会发现私有格式的“锁定效应”有多烦人。所以caveman从第一天就定了死规矩一切数据都是纯文本具体来说分成两种——短记录一条待办、一条灵感、一条备忘用单行后缀日期和标签长内容读书笔记、项目文档、日记用Markdown文件。为什么这么选因为纯文本是最抗衰老的格式。哪怕十年后所有商业软件都倒闭了cat命令和文本编辑器依然存在你的数据还是随便哪台机器都能打开。有人可能会问用SQLite不是更好吗查询方便还能做复杂过滤。我一开始也考虑过后来放弃了。原因有两个第一SQLite文件是二进制没法用任何文本工具直接查看和修改出了问题排查起来麻烦第二它不解决“可读性”问题——你拿着手机在外面想随手看一眼今天的记录还得开电脑跑查询而纯文本文件在任何设备上都能直接阅读。权衡下来日常笔记/待办的规模根本到不了需要数据库的程度纯文本的收益远超成本。那为什么还要“一行一条”而不是一整篇文章因为短记录的检索模式通常是“按时间”“按标签”“按关键词”这三种查法用文本工具配正则就够了单行格式处理起来最省事。长内容才用Markdown那是另一个粒度的东西二者分开存储互不干扰。2.2 命令行交互把常用操作压到两三个字母选命令行做交互层并不是因为我有多资深的终端情结而是写脚本的成本最低。图形界面意味着要维护一个前端要处理鼠标事件、窗口布局、跨平台兼容这完全背离了“caveman”的极简定位。终端里跑命令输入几个缩写的动作词回车搞定。我设计的一套动作词如下cav add 买牛奶 errand—— 加一条短记录cav done 12—— 把第12条标记为完成cav list [tag]—— 展示全部或按标签过滤的记录cav tag 收藏—— 列出某个标签下的所有内容cav find 关键词—— 全库搜索文本cav stats—— 按天/周/标签统计记录数量命令的命名逻辑沿着“动词宾语”的直觉走不用记复杂的参数组合。比如add后面直接跟文本done后面跟的是记录的序号find后面跟要搜的词。这种设计看起来简陋但实际用下来反而很顺手——因为我把它定位成“给自己用的小工具”而不是需要交付给陌生用户的商业产品所以交互极简并不会造成使用障碍。2.3 技术栈Shell加Python的组合不引入重依赖整个系统的运行依赖其实只有两个Git用来同步和恢复历史版本、Python 3用来写核心搜索和解析逻辑。Shell脚本负责封装日常命令Python脚本负责做文本解析、过滤、统计这类中等复杂度的处理。为什么不用纯Shell搞定一切因为Shell对“多行文本结构化处理”很别扭。比如“从一堆记录里统计本周末完成的数量”用awk能写但可读性太差改起来容易出错。Python写这种逻辑就清晰多了标准库足够处理字符串和文件不需要装任何第三方包。而Shell负责什么负责胶水——接受输入、调Python、把输出渲染到终端。分工明确各干各擅长的。这套技术栈的好处是随便找一台Linux、macOS甚至装了Git的Windows机器都能跑基本零部署。坏处是如果你完全不会Python和Shell看完代码会有点懵。但这也不难——下面我会把每一段脚本的解释写到能直接照着敲的程度。3. 实操过程与核心环节实现从零搭起一个能用的caveman3.1 初始化目录结构与Git仓库我的数据仓库长这样~/.caveman/ ├── notes/ # 长内容Markdown文件 │ └── 2025/ │ └── 01-读书笔记.md ├── entries.txt # 短记录每行一条 └── archive/ # 已完成的旧记录归档初始化步骤用一条命令就能完成我把它写成了setup.sh#!/bin/bash # setup.sh —— 初始化caveman数据目录和Git仓库 CAV_DIR$HOME/.caveman if [ ! -d $CAV_DIR ]; then mkdir -p $CAV_DIR/notes/$(date %Y) mkdir -p $CAV_DIR/archive # 注意这里用git init -b main避免默认的master/master争议 git -C $CAV_DIR init -b main echo caveman initialized at $CAV_DIR else echo caveman already exists. fi这里有个细节Git的默认分支名在不同版本里可能是master也可能是main直接指定-b main可以避免后续同步时分支名不一致的问题。我第一次没加这个参数后来换了一台电脑用了不同版本的Git导致两边分支对不上折腾了半天。然后创建一个配置文件~/.caveman/config存放用户的标签别名和默认编辑器# config EDITORvim DEFAULT_TAGinbox3.2 单项记录的核心脚本添加、展示、完成这三个动作是使用频率最高的我把它们放在同一个脚本文件cav.py里用argparse做参数解析#!/usr/bin/env python3 # cav.py —— caveman核心命令 import argparse import datetime as dt import os import re CAV_DIR os.path.expanduser(~/.caveman) ENTRIES os.path.join(CAV_DIR, entries.txt) ARCHIVE os.path.join(CAV_DIR, archive) def load_entries(): if not os.path.exists(ENTRIES): return [] with open(ENTRIES, r, encodingutf-8) as f: lines [line.rstrip(\n) for line in f if line.strip()] return lines def save_entries(entries): with open(ENTRIES, w, encodingutf-8) as f: f.write(\n.join(entries) (\n if entries else )) def parse_entry(line): # 格式[状态] 文本 |标签 标签| 2025-01-01 m re.match(r^\[( )\] (.*?) \|(.*)\| (\d{4}-\d{2}-\d{2})$, line) if not m: # 兼容无标签格式 m re.match(r^\[( )\] (.*?) \|(.*)\| (\d{4}-\d{2}-\d{2})$, line) if m: done, text, tags, date m.groups() return {done: done x, text: text, tags: [t for t in tags.split() if t.startswith()], date: date} return None def cmd_add(args): entries load_entries() ts dt.date.today().isoformat() tags .join(%s % t.lstrip() for t in args.tag) # 默认标签处理 if not tags: conf_tags args.config.get(DEFAULT_TAG) tags %s % conf_tags if conf_tags else if tags: line [ ] %s |%s| %s % (args.text, tags, ts) else: line [ ] %s || %s % (args.text, ts) entries.append(line) save_entries(entries) print(added #%d: %s % (len(entries), args.text)) def cmd_list(args): entries load_entries() show_done args.all for idx, line in enumerate(entries, start1): info parse_entry(line) if not info: continue if args.tag and not any(t args.tag.lstrip() for t in info[tags]): continue if info[done] and not show_done: continue status [x] if info[done] else [ ] tag_str .join(info[tags]) if info[tags] else print(%3d %s %s %s %s % (idx, status, info[date], info[text], tag_str)) def cmd_done(args): entries load_entries() try: idx int(args.num) - 1 line entries[idx] except (ValueError, IndexError): print(invalid index) return info parse_entry(line) if not info: print(parse error) return # 在文本内部用x标记完成而不是删除保留历史 new_line line.replace([ ], [x], 1) entries[idx] new_line save_entries(entries) print(done #%d % (idx 1)) if __name__ __main__: parser argparse.ArgumentParser() sub parser.add_subparsers() p_add sub.add_parser(add) p_add.add_argument(text) p_add.add_argument(--tag, -t, actionappend, default[]) p_add.set_defaults(funccmd_add) p_list sub.add_parser(list) p_list.add_argument(--tag, -t) p_list.add_argument(--all, -a, actionstore_true) p_list.set_defaults(funccmd_list) p_done sub.add_parser(done) p_done.add_argument(num) p_done.set_defaults(funccmd_done) args parser.parse_args() args.config {} # 读取配置文件简化处理实际可解析keyvalue cfg_path os.path.join(CAV_DIR, config) if os.path.exists(cfg_path): for line in open(cfg_path, encodingutf-8): if in line and not line.startswith(#): k, v line.strip().split(, 1) args.config[k.strip()] v.strip() args.func(args)这个脚本里有个我自己踩过的坑parse_entry的正则一开始没考虑到记录文本中可能也有竖线字符导致解析错位。后来我统一规定文本中不允许出现连续的空格加竖线这样正则就不会误判。另外标记完成用[x]而不是删掉整行是为了保留“完成历史”——后面做统计的时候这个字段会是核心依据。3.3 搜索与统计让数据产生价值的两个命令数据攒到几百条之后add和list只是记账工具搜索和统计才是能让系统活起来的引擎。搜索命令find实现上是把entries.txt按行读取对每一行做大小写不敏感的in判断命中就打印整条记录和行号。这个逻辑很简单但有一个优化点当文件达到上千行时每次find都全文扫描会有些慢。我用的方案是“倒序搜索”——先从文件末尾往上读因为新记录在底部搜索最新内容时命中率更高早停概率大。虽然对几千行的文本来说这种优化意义不大但这符合一个原则在不需要索引的时候尽量别引入索引保持逻辑简洁。统计命令stats会解析所有记录的日期和完成状态然后按日、周、标签三个维度聚合def cmd_stats(args): entries load_entries() day_count {} week_count {} tag_count {} for line in entries: info parse_entry(line) if not info: continue if info[done]: continue # 统计未完成数量有需要可改 day_count[info[date]] day_count.get(info[date], 0) 1 iso dt.date.fromisoformat(info[date]) week_key iso.strftime(%Y-W%W) week_count[week_key] week_count.get(week_key, 0) 1 for t in info[tags]: tag_count[t] tag_count.get(t, 0) 1 print(-- by day --) for k in sorted(day_count): print(%s: %d % (k, day_count[k])) print(-- by week --) for k in sorted(week_count): print(%s: %d % (k, week_count[k])) print(-- by tag --) for k in sorted(tag_count, keytag_count.get, reverseTrue): print(%s: %d % (k, tag_count[k]))这里有个周统计的细节%W把周一作为一周的第一天这是ISO标准。如果你习惯周日作为一周的开始需要调整strftime格式否则统计会和日历感觉错位。我自己一开始没注意后来对不上才发现的。3.4 同步Git仓库加定时提交本地文件再可靠也怕硬盘坏掉或者电脑丢失。所以我把整个~/.caveman目录丢进一个Git仓库然后利用git push同步到一个私有远端。我的同步策略是“批量提交异步推送”不搞实时同步。原因有两点第一个人笔记的写入频率没那么高实时推送浪费资源和电量第二批量提交可以方便地按天浏览历史哪天写了什么一目了然。我用一个sync.sh完成这个流程#!/bin/bash # sync.sh —— 自动提交并推送 cd $HOME/.caveman || exit 1 # 如果远端不存在则先添加首次运行需要 if ! git remote | grep -q origin; then git remote add origin gitexample.com:caveman.git fi git add -A if git diff --cached --quiet; then echo no changes exit 0 fi git commit -m caveman sync $(date %Y-%m-%d %H:%M) git push origin main 2/dev/null echo synced at $(date)配合Cron或launchd定时任务每30分钟执行一次即可。你可能会问为什么不用git commit --amend把更新合并成一条历史因为笔记系统的意义就在于留下时间线我反而希望每次自动提交都是独立的快照方便git diff查某天改了什么。3.5 归档与长文档别让短记录和长内容混在一起短记录塞多了之后list输出会变得很长。这时候需要“归档”动作把超过30天且已经[x]的记录从entries.txt移到archive/目录下独立文件。移动而不是删除是为了保留历史数据只是让主列表保持清爽。这个归档逻辑我用Python写了单独的archive.py大概流程是读取entries.txt所有记录筛选出完成时间超过30天的[x]记录完成日期用[x]的修改时间简化处理如果记录文本里没有完成日期就以当前系统时间为准但原始创建日期保留在行尾把它们追加写入一个以月份命名的归档文件例如archive/2025-01.txt重写entries.txt只保留未归档内容。这里还引出一个不错的细节归档文件不经过caveman命令读取直接用文本编辑器看就行。这正好呼应了项目理念——数据永远是开放的工具只是方便操作的壳不是数据的监狱。4. 使用中的常见问题与排查技巧实录4.1 中文特殊字符导致解析错位记录内容里如果出现竖线|或者连续空格parse_entry用的正则可能解析失败把整条记录当成无效行跳过。我最开始踩过这个坑的版本是描述里写了“价格25 | 包邮”结果竖线被当成分隔符日期解析全乱了。解决思路有两个一是约定录入时避免用竖线二是把分隔符改得更冷门比如用制表符\t。考虑到手机端编辑纯文本时输入制表符很麻烦我最终选择了保留竖线但在正则里增加了“只在带空格的竖线处分割”的规则。具体做法是把分隔符写成|两侧带空格你的描述里如果也有这种模式需要自行避免。4.2 多设备同时修改产生冲突Git同步最讨厌的就是两个人或者两台设备同时改了文件push时被拒绝。我的处理办法是sync.sh在push前先执行git pull --rebase如果冲突发生就自动创建备份分支git pull --rebase 2/dev/null if [ $? -ne 0 ]; then git branch backup-$(date %s) git rebase --abort git pull --no-edit -X ours fi-X ours的意思是遇到冲突时优先保留本地版本。个人笔记场景下“本地版本优先”通常是可接受的选择——因为冲突往往只发生在entries.txt尾部保留本地的追加顺序远程的改动则在下一轮合并进来。当然这也会丢数据但丢的只是同一条记录的一个版本对于一个待办事项来说损失基本可忽略。4.3 误删记录的恢复用Git管理必然要聊恢复。如果真的执行了cav done之后又后悔了或者某次archive脚本有bug吞掉了记录可以用cd ~/.caveman git log --oneline --all -- entries.txt git show 6a3b2c1:entries.txt /tmp/recover.txt然后把recover.txt里想要的行手工拼回entries.txt。这就是为什么我坚持用Git而不用同步盘自带的历史版本——Git的reflog和分支切换能力比任何云盘的版本管理都细粒度、可脚本化。4.4 脚本路径找不到或Python环境不干净有次在一台新机器上执行cav提示command not found排查后发现是PATH里没有~/.local/bin。我的建议是把封装脚本装到固定目录并追加到PATHmkdir -p ~/.local/bin ln -s ~/.caveman/cav.py ~/.local/bin/cav echo export PATH$HOME/.local/bin:$PATH ~/.bashrcPython环境方面我刻意只用了标准库所以不需要venv和pip这本身就是一种“caveman式”的高容错选择——依赖越少能被环境破坏的地方就越少。4.5 定时同步任务不生效我最初用Cron设置每30分钟跑一次sync.sh结果发现日志里一直报错。排查后定位到问题Cron的环境变量PATH非常精简git命令可能不在里面。解决方案是在脚本顶部显式导出PATHexport PATH/usr/local/bin:/usr/bin:/bin:$PATH这个细节挺容易忽略的。如果你用launchdmacOS或systemd timer也要注意环境变量问题最好在service定义里指定EnvironmentPATH...。5. 经验总结与后续扩展方向5.1 踩过几次坑之后的个人体会这个项目做到现在我最深的体会不是技术多炫而是“约束会产生创新”。一开始我总觉得纯文本会不会功能太弱后来发现正因为它弱所以我不会花时间在美化界面、调布局、玩插件上而是把精力全放在数据如何组织、命令如何高效这些真正重要的事情上。这就像原始人只能用石头和木头反而造出了骨架简单但耐用的工具。另外一个体会是个人工具不需要追求普适追求顺手就行。cavman的很多逻辑比如-X ours冲突解决、统计口径如果给别人用可能很快就骂街但对我自己而言这些取舍让日常使用摩擦最小。这也是为什么我不太建议把一个个人脚本强行包装成面向所有人的产品——定位不同设计决策完全不同。5.2 还可以扩展的几个方向目前caveman还算够用但有几个方向我在考虑后续迭代导出为HTML写一个小脚本把entries.txt渲染成一个静态网页方便在手机上不用终端也能浏览。实现思路很直接读取每行记录、正则解析、套一个HTML模板就行。提醒机制记录里加一个可选的时间字段sync.sh提交后额外跑一次检查如果当天有到期记录就通过notify-send或系统通知弹一下。加密子集某些敏感记录不想明文存放在Git仓库可以给单独的目录启用age或gpg加密提交前自动加解密。这个要谨慎设计别把一个极简工具搞复杂了。我准备先做导出HTML这个方向因为它能最直观地提升移动设备上的可用性。到时候核心的正则解析逻辑基本不变只是在外面套一层生成逻辑。如果你也准备照着这套思路做一个自己的caveman我的建议是先手写20条记录感觉一下文本格式够不够用再动手写脚本。工具永远是从自己的真实使用习惯里长出来的而不是从功能清单里长出来的。
返回列表