ARTICLE DETAIL

资讯详情

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

caveman:一个纯文本命令行笔记工具的设计与实现

caveman:一个纯文本命令行笔记工具的设计与实现 最近我一直在维护一个叫caveman的小工具。名字听着像考古项目其实是个特别简单的命令行笔记工具它没有任何数据库不依赖任何云服务甚至连配置文件都懒得写所有的笔记就是一堆纯文本的.md文件放在同一个目录里。之所以叫 caveman是因为我一开始就想做点“原始”的东西——只靠文件系统最基本的本事把记笔记这件事做回最简单、最不容易坏的样子。做这个项目的起因很实在。这几年各种笔记软件换了一轮又一轮有的需要登录账号有的把数据存在私有格式里有的笔记量过万直接卡死。最让我不能忍的是工具一旦停止维护数据导出的折腾程度堪比搬家。于是我决定给自己写一个极简到极致的工具它只负责三件事新建一条笔记、列出所有笔记、按关键词搜笔记。剩下的全部交给文件和系统命令去管。如果你也受够了重工具想找回那种“数据随便复制、随时能读”的踏实感这篇就讲讲我怎么做出来的以及过程中踩过的那些坑。1. Caveman是什么我为什么用穴居人思维做工具1.1 名字背后的一种返璞归真先说名字。caveman直译是“穴居人”在英文语境里常被用来形容一个人行为方式很原始、很直接。我拿它当项目名想表达的是工具设计上的三个态度第一能用文件名表达的信息绝不额外建字段第二能用系统中已存在的命令做的事绝不自己写功能第三能用文本承载的内容绝不用二进制格式。这听起来像是技术上的“懒惰”但实际用久了会发现这种原始恰恰保证了工具的长寿。现代笔记工具最大的问题不是功能少而是功能太多。你刚打开一个新笔记 App迎面是文件夹、标签、双链、看板、提醒、协同编辑还没记录任何想法光是设置结构就花了一小时。caveman反着来它把整个系统收敛成一句话笔记就是一个带文件名的文本文件。你叫它什么它就存在哪个文件里你写了什么就是笔记的全部内容。没有二级概念没有隐藏索引没有后台数据库。对工具来说这是一个非常“野蛮”但极其稳定的设计。1.2 庞大工具带来的真实痛点我个人的数据迁移经历特别能说明问题。前些年我用过一个流行笔记软件累计记了好几千条内容。后来想导出成 Markdown结果发现导出工具只支持一半格式有很多代码块被转成了图片部分插件插入的组件直接丢失。折腾一个周末最后只能手动复制关键内容。也是从那时候起我开始怀疑一切私有数据格式的工具。数据是我一个字一个字写进去的凭什么导出来的时候还要看工具脸色。caveman从根上规避了这个痛点。它存储的就是普通.md文件你不需要任何专用程序也能读取。你用 VSCode 能打开用 Notepad 能打开用cat能打开甚至用手机自带的文件管理器看前缀预览也能猜个大概。就算哪天这个工具彻底不维护了对数据也毫无影响——这就是我理解的“原始”带来的安全感。1.3 边界意识它刻意不做什么做工具的人容易犯一个通病什么都想加进自己的产品里。做caveman时我给自己的第一条禁令就是不允许加入任何花哨功能。具体来说它刻意不做这几件事不做云同步同步交给现有的同步盘或者 Git不做富文本编辑器编辑交给 Vim、VSCode 或者其他你顺手的编辑器不做日历视图不做标签体系不做任务状态机。也许有人会问那它跟直接建一个记事本文件夹有什么区别答案是区别只在入口效率。直接建文件夹当然也能记但你需要手动起文件名、手动整理目录、手动记住某条笔记当时叫什么叫什么才能在需要时靠路径找回来。caveman把这些操作变成了几条简短的命令同时把“当日时间戳关键词”自动组装为文件名让你不用在命名上花太多心思。它的价值在于提供了一个顺手的工作流而不是创造一个新世界。2. 核心设计拆解为什么是纯文本加命令行2.1 三条设计原则缺一不可开发过程中我把设计原则压缩成三条每天新增代码前都会对照检查第一条所有数据必须是人可读的。无论程序崩溃、硬盘损坏还是换电脑拿起任何一款文本编辑器都能恢复数据。第二条工具本身必须是可丢弃的。也就是说如果我把caveman的脚本删了完全不记得它的源码逻辑我也能凭笔记目录里的文件结构反推出当时整理了哪些内容。第三条系统依赖最小化。能用mkdir、ls、grep解决的事情绝不引入 Python 包、数据库引擎或网络服务。这三条原则决定了整个技术架构。目录结构是最原始的~/.caveman/下一堆.md文件。查阅列表时直接用ls -t按修改时间倒序排列。搜索时直接用grep -rin在目录里做大小写不敏感的全文查找。整个思路其实就是把系统自带的能力拼装一下而我要写的代码只承担“拼装逻辑”这一层。2.2 存储方案对比为什么数据库反而是负担在做技术选型时我认真对比过三种方案SQLite 数据库、单个 JSON 文件、多文件纯文本目录。当时我画了一张很简单的对照表结论非常明确。方案优势代价SQLite查询强大支持复杂检索数据固化在二进制文件中备份迁移需要专门工具笔记无法用文本编辑器直接查看单个 JSON 文件结构统一便于解析文件越写越大写入时容易损坏同一个文件被多个编辑器打开时冲突严重多文件纯文本互通性最强系统命令可用数据无锁定没有内置复杂查询需要靠文件命名规范来辅助检索如果分类只有几十条JSON 方案其实也够用。但我日常记录量会增长再加上偶尔要从手机上快速看一眼旧笔记的内容纯文本散文件的优势就体现出来了它们可以被系统的文件搜索直接命中可以被网盘单独同步也可以被 Git 做精细的版本管理。反过来看SQLite 虽然查询强但数据都在一个文件里同步时永远整库搬移还要担心并发写入冲突对轻量笔记来说完全是杀鸡用了牛刀。2.3 命令行交互入口越短记录越勤工具最终采用命令行的交互方式而不是做一个 GUI。原因是记笔记这个动作发生的场景通常是在工作间隙里脑子里的想法转瞬即逝越需要快速调用工具入口摩擦就要越低。GUI 至少要先打开窗口、等界面加载、再找输入框命令行则是一瞬间的事终端里敲几个字母就能完成记录。我把最常用的操作压缩为六条命令caveman init初始化目录caveman add 文本添加一条新笔记caveman list列出最近的笔记并带上序号caveman open 序号用默认编辑器打开指定笔记caveman search 关键词全文搜索caveman rm 序号删除指定笔记。这一套命令按使用频率排列最核心的add和list连参数都尽量简短让人形成肌肉记忆。命令设计上还有一个细节所有命令都支持在第二条参数里缺省时走标准输入方便管道操作。3. 从零实现Caveman核心代码与实操记录3.1 准备阶段建立目录与环境开始动手之前先做两件准备第一件确定笔记归档的根目录。我建议放在用户目录下建隐藏文件夹也就是~/.caveman这样既不会弄乱工作目录也让文件位置足够明确后面做同步备份时直接把整个目录扔进同步盘即可。第二件确定脚本入口。如果你用的是 Linux 或者 macOS可以直接把脚本放到/usr/local/bin/caveman并赋予执行权限。Windows 用户可以用 Git Bash 或 WSL 来跑也可以把脚本放在任意目录后将路径加入PATH环境变量。我自己的机器是 Linux 和 macOS 混用所以脚本写得尽量符合 POSIX 标准尽量不依赖 Linux 特有的扩展命令。初始化逻辑非常简单就是创建那个目录同时生成一个 README 文件来提醒自己这个目录的作用caveman() { CAVE_DIR${CAVE_DIR:-$HOME/.caveman} } caveman_init() { mkdir -p $CAVE_DIR touch $CAVE_DIR/README.md echo Caveman notes initialized at $CAVE_DIR }CAVE_DIR这个环境变量的设计是为了以后万一想切换笔记目录时不用改脚本直接换环境变量就行。这种小设计虽然简单但能避免硬编码路径带来的麻烦。3.2 Bash版实现最原始的版本只用了几十行下面这个版本就是我最早用的脚本它没有任何第三方依赖主体逻辑也就是mkdir、cat、ls、grep这几个命令的排列组合。加笔记的时候脚本会自动生成一个以时间戳加关键词命名的文件caveman_add() { local timestamp timestamp$(date %Y%m%d_%H%M%S) local title${1:0:30} local filename${timestamp}_${title//[^a-zA-Z0-9_-]/_}.md if [ -t 0 ]; then echo $1 $CAVE_DIR/$filename else cat $CAVE_DIR/$filename fi echo saved: $filename }解释一下这段逻辑date %Y%m%d_%H%M%S生成一个精确到秒的时间戳把传入的首个参数截取前 30 个字符作为标题这里花了点功夫把特殊字符替换成下划线避免文件名里出现斜杠或者空格导致路径出问题。[ -t 0 ]判断标准输入是否来自终端如果是来自管道就直接把管道内容当作笔记正文写入。列出笔记我用的是caveman_list() { ls -t $CAVE_DIR/*.md | awk -F/ {print NR . $NF} }这个命令把目录下所有.md文件按修改时间从新到旧列出来awk -F/截取文件名部分展示并且加上简单的序号。虽然实现得非常朴素但已经满足了一个基本诉求一眼看到最近记了哪些东西。搜索功能更加简单直接调用系统grepcaveman_search() { grep -rin $1 $CAVE_DIR }-r递归目录-i忽略大小写-n显示行号。第一次写出来的时候我都愣住了原来“全文搜索”这个功能只需要一行命令。这也正是caveman想要追求的效果借用系统已有的能力而不是重新发明轮子。3.3 增加序号与打开、删除操作纯列出来文件名还不太利于操作于是我在list的基础上关联了序号操作。因为脚本不做状态持久化我采用了一种简单的顺序映射每次根据当前时间排序的结果依次编号然后open和rm都重新执行一次相同排序再按序号定位文件。这样做虽然稍微牺牲了一点性能但对几百条笔记的体量来说绰绰有余也避免了维护索引的复杂度。打开操作我直接用$EDITOR环境变量指定的编辑器caveman_open() { local idx$1 local target target$(ls -t $CAVE_DIR/*.md | sed -n ${idx}p) $EDITOR $target }删除操作则是定位到文件后执行rmcaveman_rm() { local idx$1 local target target$(ls -t $CAVE_DIR/*.md | sed -n ${idx}p) rm $target echo removed: $target }这里我踩过一个小坑sed -n ${idx}p的引号一定不能丢否则变量不会展开命令行会报错。这种细节写的时候很容易忽略但实际调试起来会让人摸不着头脑。3.4 迁移到 Python跨平台与漂亮输出的折中Bash 版本虽然完全可用但在 macOS 与 Linux 混用过程中我还是发现了一些不便macOS 的date命令默认不支持%N纳秒格式化偶尔快速连续加笔记时文件名会重名另外 Bash 版的列出结果没有任何颜色和格式区分屏幕刷屏后很难快速定位。于是我又用 Python 写了一个兼顾跨平台和可读性的版本。#!/usr/bin/env python3 import os import sys import subprocess import tempfile from datetime import datetime CAVE_DIR os.environ.get(CAVE_DIR, os.path.expanduser(~/.caveman)) def ensure_dir(): os.makedirs(CAVE_DIR, exist_okTrue) def note_list(): ensure_dir() files [] for name in os.listdir(CAVE_DIR): if name.endswith(.md): full os.path.join(CAVE_DIR, name) files.append((os.path.getmtime(full), full)) files.sort(reverseTrue) return files def cmd_add(args): ensure_dir() if len(args) 0: content sys.stdin.read() else: content .join(args) if not content: print(empty content, ignored) return now datetime.now().strftime(%Y%m%d_%H%M%S) title content.splitlines()[0][:30] safe .join(c if c.isalnum() or c in -_ else _ for c in title) filename f{now}_{safe}.md with open(os.path.join(CAVE_DIR, filename), w, encodingutf-8) as f: f.write(content \n) print(fsaved: {filename}) def cmd_list(args): files note_list() for i, (_, full) in enumerate(files, 1): name os.path.basename(full) print(f{i:3d} {name}) def cmd_search(args): keyword .join(args) for _, full in note_list(): try: with open(full, r, encodingutf-8) as f: for line_no, line in enumerate(f, 1): if keyword.lower() in line.lower(): name os.path.basename(full) print(f{name}:{line_no}: {line.rstrip()}) except UnicodeDecodeError: continue def cmd_open(args): files note_list() idx int(args[0]) - 1 target files[idx][1] editor os.environ.get(EDITOR, vim) subprocess.call([editor, target]) def cmd_rm(args): files note_list() idx int(args[0]) - 1 target files[idx][1] os.remove(target) print(fremoved: {os.path.basename(target)}) def main(): if len(sys.argv) 2: print(usage: caveman [init|add|list|search|open|rm]) return ensure_dir() cmd sys.argv[1] args sys.argv[2:] if cmd init: print(CAVE_DIR) elif cmd add: cmd_add(args) elif cmd list: cmd_list(args) elif cmd search: cmd_search(args) elif cmd open: cmd_open(args) elif cmd rm: cmd_rm(args) if __name__ __main__: main()这个版本的add命令支持两种输入方式。一种是caveman add 直接写入另一种是通过管道传内容比如echo 临时想法 | caveman add。核心逻辑在cmd_add里先判断有没有命令行参数没有就读取标准输入这就天然兼容管道操作了。同时保存时自动补一个换行符保证后续grep匹配每一行内容时结果干净。列表输出为了对齐序号我使用了f{i:3d}格式化这样超过一百条笔记时也能保持阅读舒适。Python 标准库自带的os和subprocess就能完成上述全部功能依旧保持了“零第三方依赖”的原则。这种克制让脚本在任何安装了 Python3 的机器上都能跑不用pip install任何东西也不需要额外的依赖锁文件和caveman的整体气质一致。3.5 高级细节搜索时如何保证编码处理Python 版搜索中我特意加了一步UnicodeDecodeError处理。刚开始没有这一步时如果目录里混入一个非 UTF-8 编码的文本文件整个搜索就会中断后面所有正常笔记结果都无法显示。后来我改成逐文件读取遇到解码错误就跳过该文件而不是让程序直接崩溃。这种容错虽然看起来很不起眼但真实场景里非常管用因为我偶尔会用scp从旧设备拷贝一些历史文档编码经常不再是标准 UTF-8。4. 把Caveman接入日常工作流这才是关键4.1 和编辑器配合让打开编辑变成顺手的事命令行工具最大的优势之一就是容易和编辑器深度结合。我在日常的编辑器配置里做了一组快捷键比如在普通模式下按leadern就会自动执行caveman add 临时记录并把光标预先放在输入框里。这样我在写代码或者看文档时突然有灵感不会跳出当前上下文也不需要打开浏览器或独立的笔记 App只需要低头敲几个字母就能记录下来。open指令的意义在这里就体现出来了。当list显示出一串文件名而我需要补充昨天写的那条笔记时直接执行caveman open 3就会用$EDITOR打开对应的文件。对我来说$EDITOR指向的是 Vim但在内网服务器上它也可以轻松指向nano一切取决于当前环境工具不强制绑定任何编辑器。4.2 别名和快捷键把记笔记变成零成本动作命令行工具如果每次都要完整敲caveman add用久了还是会累。我在 shell 配置里加了一个别名让命令再短一半alias ccaveman alias clcaveman list alias cscaveman search其实这一步看起来只是减少了一个单词的输入量但对使用习惯的影响非常大。我测试过如果一条命令超过 8 个字符记录动作的启动成本就会显著上升很多一闪而过的想法就这么丢了。有了c这个别名后记录一个想法只需要c xxx整个过程不到半秒几乎等同于随手写便利贴。如果你用的是 macOS还可以借助 Alfred 或 Raycast 这类工具把caveman做成一个全局快捷键触发。按下快捷键后弹出输入框输入文本回车底层执行的就是caveman add。这样就把终端的门槛也拆掉了相当于在操作系统的任何界面下都能快速记录。4.3 同步备份用现成方案而不是做新方案很多占了数据锁定便宜的工具会顺带吹嘘自己“多端同步”做得有多好。但同步这个能力其实本质就是数据复制被无数成熟工具解决了根本不需要笔记工具自己重复实现一遍。caveman因为数据是纯文本散文件可以直接把~/.caveman目录放进各类同步网盘里让目录自动同步。你在一台电脑上写下的笔记几秒后手机上就能看到。我自己的方案是配合 Git 做版本管理。在~/.caveman目录下初始化 Git 仓库每次写了一定量的笔记后手动执行一次提交。这样做的好处是每一版笔记都留下了历史记录即使手滑写错了一个段落也可以随时回滚到上一个提交。纯文本文件配合 Git堪称绝配因为差分算法对文本格式的支持非常成熟每次提交的体积都很小。如果你更愿意走全自动路线也可以配一个简单的定时任务每半小时把目录里新增的文件git add并提交一次。但坦率说笔记不是高频变更数据手动定期提交反而能激发一次整理和回顾比全自动多一份好处。5. 踩过的坑和排查记录都是实际操作积累的5.1 中文文件名与 URL 编码问题最早版本我用标题里的中文直接拼文件名结果发现部分云同步工具会自动把非 ASCII 字符做编码转换导致同步后的文件名出现一串百分号乱码在手机端完全不可读。后来我强制把文件名里的非字母数字字符统一替换成下划线用日期和数字保证绝对安全。这也带来了另一个好处文件名在众多系统里都能兼容不会因为特殊字符导致命令行操作出错。同时搜索时如果输入中文关键词grep本身一般没有问题但前提是终端和文件编码都是 UTF-8。建议你在脚本开头或者 shell 配置里统一设置LC_ALLC.UTF-8避免各种语言环境下的编码混乱。5.2 参数带空格与连字符导致解析错乱用命令行工具最讨厌的场景是参数里有空格和以-开头的敏感字符。比如说caveman add - 今天心情不错如果脚本没有做好参数处理-开头的内容被当成一个选项直接导致命令执行失败。Python 版本里我用 .join(args)来拼接参数可以天然规避这个问题。但如果你在底层调用grep搜索以-开头的内容还是要记得在关键词前加--表示结束选项解析这是一个很少被新手注意但实际经常踩中的细节。5.3 快速连续写入造成的文件名冲突Bash 版用date %Y%m%d_%H%M%S生成时间戳试过在极短时间内连续添加两条笔记因为秒级精度一样后一条直接覆盖了前一条。这个问题在 Python 版中依然存在因为strftime默认也是秒级。解决办法是要么在文件名后缀加 UUID要么至少追加计数器。我采取了后者在同一秒内多次写入时文件名会变成20250220_103112_1.md、20250220_103112_2.md。简单且自然保留了记录顺序。5.4 误删笔记幸好时间戳救了我删除操作虽然简单但误删的代价可不小。有一次我在清理测试笔记时本来想删掉编号 12 的文件结果眼睛一花把编号 13 的真实内容删掉了文件直接没了。好在我的目录用的是 Git 管理一条git checkout就把历史内容恢复了。如果不用 Git最稳妥的做法是把删除改成移动不是真的rm而是把目标文件移到一个.trash子目录里隔一个月再彻底清空。这层保护花不了多少代码却能避免很多心碎时刻。5.5 搜索时遇到二进制文件干扰如果目录里混进了图片或者其他二进制文件grep -r会直接报错甚至把终端刷出一堆乱码。Python 版里我给搜索函数加上了扩展名白名单只扫描.md和.txt文件其他文件一律跳过。更稳妥的做法其实是给笔记目录严格定义一个单一扩展名拒绝任何来路不明的文件混入。5.6 多终端同时编辑的冲突在台式机和笔记本同时开着同一个笔记时如果两个终端都打开了同一个文件各自编辑后保存最后写盘的会覆盖掉先写盘的。这个问题在纯文本方案里无法从工具内部彻底解决我的办法有两个一个是尽量避免同一篇笔记在两台机器上同时编辑另一个是配合同步盘的分身或者 Git 的冲突标记来处理Git 在文本文件冲突时会在文件里写入冲突标记至少你能手工合并不会默默丢数据。6. 一点个人体会极简工具维护起来是什么感觉这个项目前前后后改了近两年功能点基本稳定新增代码量反而越来越少了。最大体会是维持极简并不容易真正难的从来不是写代码而是长期拒绝“顺手加功能”的诱惑。不止一次有朋友问我为什么不做成 App、不上云、不做成多人协作每次我都要重新把这个项目“不要什么”的边界解释一遍。但恰恰是这种克制让caveman异常可靠。我的笔记目录已经有相当多的文件检索基本还行日常操作更是毫无压力。最让我踏实的一点是我完全不需要担心服务商跑路、数据库损坏或者“某天打开软件提示登录失效”。只要我的硬盘还在这些笔记就在。如果你也想动手做一个类似的工具我的建议是先定好“这个工具将来不要什么”再开始写第一行代码。每次新想法冒出来先挪到愿望清单里放两周如果两周后还觉得需要再加再认真考虑。很多时候两周后你就发现自己根本不需要它了。真正经得起时间考验的工具往往就长这样功能少得可怜但每一个功能都耐用到可以依赖终生。
返回列表