Joplin 同步目标快照(Sync Target Snapshot)v2 格式解析:从 note2 条目文件到同步版本迁移机制
Joplin 通过统一的“文件系统式”同步目标模型,将笔记本、笔记、标签、资源等所有数据以条目文件形式存储在各同步目标(Nextcloud、WebDAV、Joplin Cloud、本地目录等)上。本文以仓库测试夹具 packages/app-cli/tests/support/syncTargetSnapshots/2/normal/edd3cb394ada4d389c01c2bdca09b3ef.md(v2 快照中的 note2 条目)为入口,完整解析 Joplin 同步目标快照的目录结构、条目文件格式、字段语义,并深入同步迁移测试与版本升级机制,读者可据此独立解读任意快照条目、理解 info.json 与 .sync/version.txt 的演进关系,以及快照如何被 Joplin 的迁移测试自动消费。
一、快照体系概览:测试夹具中的“同步目标黄金样本”
在 Joplin 仓库中,packages/app-cli/tests/support/syncTargetSnapshots/ 目录保存了多个历史版本的同步目标快照。每个快照本质上是一份“当时真实同步到目标端后形成的文件集合”,用于保证同步格式升级时数据不被破坏。
目录按 同步版本号(1/2/3) 与 是否启用端到端加密(normal/e2ee) 双维度组织:
packages/app-cli/tests/support/syncTargetSnapshots/
├── 1/
│ ├── normal/ # v1 未加密快照(19 个条目文件)
│ └── e2ee/ # v1 E2EE 快照(20 个条目文件)
├── 2/
│ ├── normal/ # 本文主题快照,含 info.json 与 locks/
│ │ ├── info.json # {"version":2}
│ │ ├── locks/ # 锁目录(快照中为空)
│ │ └── *.md # 20 个条目文件
│ └── e2ee/
└── 3/
├── normal/
└── e2ee/
每个版本目录内都有一个 info.json,内容形如 {"version":2},它直接宣告该同步目标的格式版本——这正是 MigrationHandler.fetchSyncTargetInfo()(见 MigrationHandler.ts)读取并解析的文件。值得注意:v1 快照目录中没有 info.json,只有 .sync/version.txt,这是新旧版本标识机制的分水岭,后文详述。
v2 的 normal/ 快照中同时出现 locks/ 目录,与源码中 Dirnames.Locks = 'locks'(见 utils/types.ts)一一对应。这些目录由 v1→v2 迁移逻辑显式创建(见下文迁移章节),是版本快照之间“可被测试断言”的结构化差异。
二、条目文件格式:以 note2 为例逐字段拆解
快照中的每个条目对应一个 .md 文件,文件名即条目的全局唯一 ID。本文核心文档 edd3cb394ada4d389c01c2bdca09b3ef.md 是一个位于 folder1/subFolder2/ 下的普通笔记 note2,全文如下:
note2
id: edd3cb394ada4d389c01c2bdca09b3ef
parent_id: 2fa39884ba3b47a489dae93dc20021f2
created_time: 2020-07-25T10:55:18.422Z
updated_time: 2020-07-25T10:55:18.422Z
is_conflict: 0
latitude: 0.00000000
longitude: 0.00000000
altitude: 0.0000
author:
source_url:
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplin
source_application: net.cozic.joplintest-cli
application_data:
order: 1595674518422
user_created_time: 2020-07-25T10:55:18.422Z
user_updated_time: 2020-07-25T10:55:18.422Z
encryption_cipher_text:
encryption_applied: 0
markup_language: 1
is_shared: 0
type_: 1
该格式可以归纳为三个逻辑段:
1. 标题行与正文区
- 第一行为条目标题(本文件为
note2); - 若条目有正文,紧跟一个空行后为 Markdown 正文;
note2正文为空,因此空行后直接进入属性块; - 对照同目录下的
note1(2a914b3fb8fb43819b976eb4e5be80e3.md)可看到正文区的实际形态:
note1
[](https://gitcode.com/GitHub_Trending/jo/joplin?utm_source=gitcode_repo_files)
id: 2a914b3fb8fb43819b976eb4e5be80e3
...
其中 [](https://gitcode.com/GitHub_Trending/jo/joplin?utm_source=gitcode_repo_files) 是 Joplin 的资源引用语法::/ 后跟资源 ID,指向同目录下的资源条目 6f60ca35b0e4423fb49f9e097449fd99.md。资源条目文件(如 006a89df4de64a22b4b1fa71f87fd258.md)以 photo.jpg 为标题,额外携带 mime: image/jpeg、file_extension: jpg、size: 2720 等字段。note1 测试脚本正是通过 markdownUtils.extractImageUrls(note.body) 提取正文图片 URL 并加载对应资源来验证引用完整性(见 syncTargetUtils.ts)。
2. 属性块:以 key: value 形式序列化的字段
空行之后的每一行均为 字段名: 值,值可能为空(如 author:)。全部字段取自数据库表列,核心字段语义如下:
| 字段 | 语义 | 备注 |
|---|---|---|
id |
条目全局唯一 ID | 与文件名一致,由 uuid.create 生成(见 BaseModel.ts) |
parent_id |
父目录 ID(笔记→所属文件夹;文件夹→父文件夹) | 根文件夹为空 |
created_time / updated_time |
条目创建/更新时间(ISO 8601 UTC) | |
user_created_time / user_updated_time |
用户可见时间(通常与系统时间一致) | |
is_conflict |
是否为冲突笔记 | 0/1 |
latitude / longitude / altitude |
地理位置元数据 | 未使用时为 0 |
author / source_url |
来源元数据 | 剪藏等场景使用 |
is_todo / todo_due / todo_completed |
待办标记及截止/完成时间 | 普通笔记为 0 |
source |
来源类型(如 joplin) |
|
source_application |
创建条目的客户端标识 | 本快照为 net.cozic.joplintest-cli |
order |
排序权重(毫秒时间戳) | 如 1595674518422 |
application_data |
应用扩展数据 | 本快照为空 |
encryption_cipher_text |
E2EE 密文 | 未加密快照为空 |
encryption_applied |
是否已加密 | 0/1 |
markup_language |
标记语言 | 1 = Markdown |
is_shared |
是否共享(服务端功能) | 0/1 |
type_ |
条目类型枚举 | 见下节,本条为 1(Note) |
3. type_:条目的类型指纹
type_ 是序列化时最重要的判别字段,其枚举定义位于 BaseModel.ts:
| type_ 值 | 常量 | 含义 | 快照中示例 |
|---|---|---|---|
| 1 | ModelType.Note |
笔记 | edd3cb39…(本文档) |
| 2 | ModelType.Folder |
文件夹/笔记本 | c4e45cad…(folder1)、2fa39884…(subFolder2) |
| 4 | ModelType.Resource |
附件资源 | 006a89df…(photo.jpg) |
| 5 | ModelType.Tag |
标签 | 6cb91bb2…(tag1) |
| 6 | ModelType.NoteTag |
笔记-标签关联 | 56748647…(note_id + tag_id 字段) |
不同 type_ 的条目文件携带各自的专属字段,例如标签条目(6cb91bb296ee458589eea0256ada06fa.md)没有正文与正文区,第一行 tag1 即标签名;NoteTag 关联条目(567486477f4249d38feadf6c5ec6e03d.md)则以 note_id、tag_id 两个外键字段表达多对多关系。
三、快照从何而来:测试数据生成器
快照并非手工编写,而是由测试工具 syncTargetUtils.ts 在 CI 或本地自动生成。其核心逻辑(main())大致为:
- 初始化 Node 端 shim(
sharp、nodeSqlite)与测试数据库、同步器; - 调用
createTestData(testData)按预定结构写入数据; - 若为
e2ee类型,则启用加密并加载主密钥(setEncryptionEnabled(true)、loadEncryptionMasterKey()); - 启动同步器执行完整同步;
- 将同步目录整体拷贝到
${snapshotBaseDir}/${syncVersion}/${syncTargetType},即生成当前版本快照。
testData 结构(syncTargetUtils.ts)精确对应快照中的文件夹与笔记,凡名称含 folder 的节点创建为文件夹(Folder.save),其余创建为笔记(Note.save),并可附加资源与标签:
folder1/
├── subFolder1/
├── subFolder2/
│ ├── note1 # 附 photo.jpg 资源 + tag1
│ └── note2 # ← 本文关联文档
├── note3 # tag1 + tag2
└── note4 # tag2
folder2/
folder3/
└── note5 # photo.jpg + tag2
recurseStruct(syncTargetUtils.ts)递归遍历该树,parent_id 沿递归链传递——这解释了为何 note2 的 parent_id 指向 subFolder2(2fa39884…),而 subFolder2 的 parent_id 又指向 folder1(c4e45cad…)。photo.jpg 来自 shim.attachFileToNote(note, ${supportDir}/photo.jpg)。测试代码注释中还给出了手动重建快照的命令:将 test-utils 中 syncTargetName_ 设为 filesystem 后运行 node tests/support/createSyncTargetSnapshot.js normal(及 e2ee),见 synchronizer_MigrationHandler.test.ts。
与之对称的校验函数 checkTestData(syncTargetUtils.ts)在同步/迁移完成后反向验证数据完整性:按标题加载文件夹与笔记、校验父文件夹、提取资源 URL 并加载资源、校验标签关联——任何一环缺失都会抛出错误。
四、快照的消费者:同步版本迁移测试
快照存在的根本目的是服务同步版本迁移测试。synchronizer_MigrationHandler.test.ts(synchronizer_MigrationHandler.test.ts)的测试思路是“取版本 n 的快照,升级到 n+1,验证数据未被改动”:
deploySyncTargetSnapshot('normal', migrationVersion - 1)将对应旧版快照拷贝为当前同步目录(syncTargetUtils.ts,先fs.remove(syncDir)再fs.copy(sourceDir, syncDir));- 通过
fetchSyncInfo(fileApi())断言目标版本确实为migrationVersion - 1; Setting.setConstant('syncVersion', migrationVersion)后调用migrationHandler().upgrade(migrationVersion)执行迁移;- 再次
fetchSyncInfo断言版本已升到migrationVersion; - 校验目录结构断言(见
migrationTests,例如 v2/v3 均需存在.resource、locks、temp目录与info.json文件,且.sync/version.txt内容为2); - 到达最大版本后,运行完整同步并用
checkTestData(testData)校验数据未被迁移改动; - 最后
switchClient(2)切换到第二个客户端再同步一次,模拟多客户端场景。
同样的流程对 e2ee 快照执行(synchronizer_MigrationHandler.test.ts):迁移后需用测试主密钥(密码 123456)加载密钥并启动 decryptionWorker 解密数据,再执行数据校验。
测试还预留了扩展点:新增迁移时需在 MigrationHandler.ts 的 migrations 数组追加迁移函数、在 Setting.syncVersion 提升版本号、并补充 migrationTests 断言(MigrationHandler.ts)。
五、版本标识的演进:info.json 与 .sync/version.txt
快照 v1、v2、v3 之间的差异正是同步版本机制的演进史,三者都体现在 migrations/ 目录下:
v1→v2(migration 2)
migrations/2.ts 一次性完成三项工作:
- 写入
.sync/version.txt(内容2)——注意:新格式下版本号已改存于根目录info.json,但必须保留该旧文件,否则旧客户端会自动重建它并误判目标版本为 1; - 创建
locks/目录(Dirnames.Locks); - 创建
temp/目录(Dirnames.Temp)。
这解释了 v2 快照中同时存在 info.json 与 locks/ 的结构事实。
初版(migration 1)
migrations/1.ts 创建 .resource、.sync、.lock 三个隐藏目录并写入 .sync/version.txt = '1'——即最古老的版本标识方式,也是 fetchSyncTargetInfo 中“无 info.json 时回退读取 .sync/version.txt”逻辑的来源(MigrationHandler.ts)。
v2→v3(migration 3)
migrations/3.ts 不再改动目录结构,而是将本地缓存的 SyncInfo(syncInfo.version = 3)通过 uploadSyncInfo 写入 info.json。
版本探测与防错
fetchSyncInfo(syncInfoUtils.ts)完整呈现了版本判定优先级:
- 存在
info.json→ 解析 JSON,缺失version字段直接抛错; - 无
info.json但有.sync/version.txt→ 视为 v1(需升级); - 两者皆无 → 触发 fail-safe 检查(syncInfoUtils.ts),仅在初始同步(
syncedItems.length === 0)时放行,否则抛出failSafe错误防止空/损坏目标造成数据丢失。
而 checkCanSync(MigrationHandler.ts)则负责双端版本比对:目标版本高于客户端支持版本抛 outdatedClient,低于则抛 outdatedSyncTarget。
六、info.json 的完整形态:SyncInfo 序列化
快照中 v2 的 info.json 只是最简形态 {"version":2},但实际运行时该文件由 SyncInfo 类序列化而来。serialize() 使用带缩进的 JSON 输出(syncInfoUtils.ts),完整字段包括(toObject()):
| 字段 | 说明 |
|---|---|
version |
同步目标版本号 |
e2ee |
是否启用端到端加密(布尔值 + 时间戳) |
activeMasterKeyId |
当前活动主密钥 ID |
masterKeys |
主密钥列表(上传时通过 filterSyncInfo 剥离 content/checksum 等敏感属性) |
noteLockKey |
笔记锁密钥 |
ppk |
端到端加密公私钥对(过滤时截断显示) |
appMinVersion |
可同步的最小应用版本(仓库中为 3.7.0 级常量,见 syncInfoUtils.ts) |
revisionServiceEnabled / revisionServiceTtlDays |
修订历史服务开关与保留天数(默认 90 天) |
其中 e2ee、activeMasterKeyId 等参数带 updatedTime 时间戳用于多客户端冲突仲裁:migrateLocalSyncInfo 将来源不明的时间戳置 0,从而让“后设置的值优先”,避免旧客户端用旧值覆盖新客户端刚开启的加密(syncInfoUtils.ts)。这也是为什么 e2ee/ 快照的条目文件会带有 encryption_applied: 1 与密文字段,而 normal/ 快照(如本文档)中这些字段为空。
七、实战:如何用快照体系验证与调试同步
快速定位某个条目的归属
拿到任意快照条目文件后,可按 type_ 判断类型、按 parent_id 沿树向上回溯。例如 note2:parent_id = 2fa39884…(subFolder2)→ 其 parent_id = c4e45cad…(folder1)→ parent_id 为空,即根文件夹。由此还原出完整层级 folder1/subFolder2/note2。
核对同步目标版本
cat packages/app-cli/tests/support/syncTargetSnapshots/2/normal/info.json 输出 {"version":2};若该文件缺失,则应到 .sync/version.txt 中寻找旧式版本号——这两种文件恰好构成 fetchSyncInfo 的两条读取路径。
手动重放迁移验证
可参照测试流程,在本地以 filesystem 作为同步目标:先按 synchronizer_MigrationHandler.test.ts 的注释生成快照,再编写或复用 deploySyncTargetSnapshot 将旧版快照铺入同步目录,随后以新版 syncVersion 启动同步器观察升级结果与 checkTestData 校验输出。仓库中 deploySyncTargetSnapshot('normal', 1)、('e2ee', 1) 的调用样例见 synchronizer_MigrationHandler.test.ts。
理解快照与客户端代码的版本约束
快照目录 1/2/3 与迁移数组 migrations = [null, migration1, migration2, migration3] 一一对应,最大同步版本由 migrationTests 的键集合推导(maxSyncVersion = Number(Object.keys(migrationTests).sort().pop()),见 synchronizer_MigrationHandler.test.ts)。新增格式改动时,需要同时满足“迁移逻辑、版本号、快照、断言”四者同步更新,这正是该测试基建保证同步格式向后兼容的工程约束。
八、小结
一份看似平淡的 note2 快照条目,实际串联起 Joplin 同步体系的完整链路:条目文件是“标题 + 正文 + 属性块”三段式序列化,type_ 字段驱动多类型模型(笔记/文件夹/资源/标签/关联),info.json 与 .sync/version.txt 承载版本演进与旧客户端兼容,syncTargetSnapshots 目录为同步迁移测试提供黄金样本,而 syncTargetUtils、MigrationHandler、syncInfoUtils 三个模块则分别负责快照的生成、迁移执行与版本探测。理解这份格式,不仅能读懂任意快照内容,也能在排查同步版本冲突、数据完整性校验与多客户端兼容问题时快速定位根因。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.21 K637- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python270
cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端TypeScript2 K146
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python46066
new-apiAI模型聚合管理中转分发系统,一个应用管理您的所有AI模型,支持将多种大模型转为统一格式调用,支持OpenAI、Claude、Gemini等格式,可供个人或者企业内部管理与分发渠道使用。🍥 A Unified AI Model Management & Distribution System. Aggregate all your LLMs into one app and access them via an OpenAI-compatible API, with native support for Claude (Messages) and Gemini formats.Go20143
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java34051