Joplin 只读项机制:无写权限共享笔记在模型、同步与 UI 三层是如何落地的
【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin
本文围绕 Joplin 仓库中的设计规范文档 read_only.md 展开,讲清楚 Joplin 中"只读项(read-only item)"的完整技术图景:哪些数据对象支持只读、判定只读的具体条件、服务端如何返回isReadOnly错误码,以及客户端在模型层、同步层、UI 层的防御式实现。读完本文,你可以掌握在 Joplin Cloud 共享文件夹(write 权限被禁用)场景下,一条只读规则从服务端到桌面端编辑器被逐层阻断的全过程,并能定位到每一层的源码入口与对应测试用例。
一、只读项是什么:共享文件夹的无写权限场景
Joplin 中的某些数据项(item)可以被标记为只读。支持该机制的对象类型有三类:
- 笔记(Note)
- 文件夹(Folder)
- 附件资源(Resource)
目前该机制的实际使用场景是:当一个 Joplin Cloud 文件夹被共享给其他用户,且共享权限中禁用了 "write"(写)时,被共享者本地的这些笔记、文件夹与资源即被视为只读项。
需要强调的是,只读不是数据库里的一个布尔字段,而是一个运行时推导出来的状态:由"该项是否属于某个 share"以及"当前用户在该 share 中是否有写权限"两个条件共同判定。这个设计决定了 Joplin 必须在多个层面做检查——服务端负责最终兜底,客户端则在模型层、UI 层提前拦截,并在同步层处理极端情况下仍然到达服务端的写请求。
二、服务端层:Joplin Cloud 如何拒绝写入
服务端是只读约束的最终防线。当客户端尝试向一个只读共享写入数据时,除非操作者是 share 的所有者,Joplin Cloud 会直接拒绝请求:
- 返回 HTTP403 Forbidden;
- 响应体中附带机器可读的错误码:
{ "code": "isReadOnly" }。
客户端侧对应的错误码枚举定义在 errors.ts 中:
// packages/lib/errors.ts IsReadOnly = 'isReadOnly',Joplin Server 端在 server/src/utils/errors.ts 中定义了同名错误码,两端以此作为契约。客户端在传输层对这类错误做了"非致命"处理,例如 file-api.ts 中对'rejectedByTarget'和'isReadOnly'错误码会返回false(不视为需要重试或中断同步的致命错误),从而把错误交给同步器按只读语义专门处理。
三、模型层:readOnly.ts 如何判定一个项是否只读
规范文档指出:lib/models/utils/readOnly.ts提供了一系列工具函数来判断某个项是否应被视为只读,并且大部分只读处理逻辑集中在BaseItem中,因此笔记、文件夹、资源的处理方式高度一致。对应源码为 readOnly.ts。
3.1 前置快速退出:needsShareReadOnlyChecks
在真正判定之前,needsShareReadOnlyChecks函数先做一轮廉价的快速退出检查,避免对不适用场景做无谓查询:
export const needsShareReadOnlyChecks = (itemType: ModelType, changeSource: number, shareState: ShareState, disableReadOnlyCheck = false) => { if (disableReadOnlyCheck) return false; if (!isJoplinServerVariant(Setting.value('sync.target'))) return false; if (changeSource === ItemChange.SOURCE_SYNC) return false; if (!Setting.value('sync.userId')) return false; if (![ModelType.Note, ModelType.Folder, ModelType.Resource].includes(itemType)) return false; if (!shareState) throw new Error('Share state must be provided'); if (!shareState.shareInvitations.length) return false; return true; };从源码结构看,只有当以下条件全部满足时才会执行只读检查:
- 同步目标是 Joplin Cloud / Joplin Server 一类的服务变体(
isJoplinServerVariant判断sync.target); - 变更来源不是同步本身(
changeSource !== ItemChange.SOURCE_SYNC)——同步下载数据时天然要跳过只读检查,否则无法写入本地数据库; - 用户已登录(存在
sync.userId); - 对象类型是 Note、Folder 或 Resource 三者之一;
- 当前用户至少有一个共享邀请(
shareInvitations非空)。
3.2 核心判定:itemIsReadOnlySync
itemIsReadOnlySync是判定函数本体,接收一个最小化的项切片(id、share_id、deleted_time):
export const itemIsReadOnlySync = (itemType, changeSource, item, userId, shareState, sharePermissionCheckOnly = false): boolean => { // Item is in trash if (!sharePermissionCheckOnly && item.deleted_time) return true; if (!needsShareReadOnlyChecks(itemType, changeSource, shareState)) return false; checkObjectHasProperties(item, ['share_id']); // Item is not shared if (!item.share_id) return false; // Item belongs to the user const parentShare = shareState.shares.find(s => s.id === item.share_id); if (parentShare && parentShare.user?.id === userId) return false; const shareUser = shareState.shareInvitations.find(si => si.share.id === item.share_id); // Shouldn't happen if (!shareUser) return false; return !shareUser.can_write; };判定链路可以拆解为四步:
- 回收站检查:只要项带有
deleted_time(已在回收站),直接视为只读。源码注释说明这个函数最初是为共享权限设计的,后来复用来表达"回收站中的笔记只读"这一语义,因此多了sharePermissionCheckOnly开关——共享检查不需要deleted_time字段(Resource 对象上根本没有这个字段); - 非共享项直接放行:
share_id为空的项不受共享只读约束; - 共享所有者放行:若
share_id对应的 share 的所有者就是当前用户(parentShare.user?.id === userId),说明是自己发起的共享,可以写入; - 按邀请权限判定:查找当前用户在该 share 下的邀请记录(
shareInvitations),最终返回!shareUser.can_write——即只有"受邀且未授予写权限"的项才是只读的。
此外还有一个异步包装itemIsReadOnly,它通过BaseItem.loadItem只加载id、share_id、deleted_time三个字段后调用同步版函数,供 UI 层按需查询。
3.3 四种被拦截的操作
规范文档明确列出了模型层处理的四种情况:
| 情况 | 拦截函数 | 行为 |
|---|---|---|
| 修改只读项 | checkIfItemCanBeChanged | 抛出JoplinError('Cannot change or delete a read-only item: <id>', ErrorCode.IsReadOnly) |
| 删除只读项 | checkIfItemCanBeChanged | 同上(修改与删除共用检查) |
| 在只读项下添加子项 | checkIfItemCanBeAddedToFolder | 抛出JoplinError('Cannot add an item as a child of a read-only item', ErrorCode.IsReadOnly) |
| 修改只读资源文件内容 | BaseItem 层 | 走同样的只读检查路径 |
其中checkIfItemCanBeAddedToFolder有一个值得注意的细节:它按parentId加载父文件夹后检查其只读状态;若父文件夹不存在则跳过检查并记录警告。源码注释解释了这个历史原因——同步过程中项的下载顺序是随机的,允许把笔记的parent_id指向一个尚未下载的文件夹;即使该文件夹最终是只读的,问题也会在同步阶段被解决。
这些检查被统一注入到BaseItem的保存/删除路径中,意味着三个模型(Note、Folder、Resource)无需各自重复实现。
四、同步层:为什么"理论上不会发生"的错误仍要兜底
这是规范文档中最有设计思想的一部分。文档明确写道:由于模型层和 UI 层已经拦截,这些只读错误理论上永远不会发生;但同步器仍然必须处理它们,原因有二:
- 如果同步器无法处理只读错误,同步会永久卡死;
- 用户的本地数据会与共享文件夹不一致,且没有任何途径拿到最新数据。
同步器对三种情形分别定义了恢复策略:
| 本地操作 | 服务端响应 | 同步器的恢复动作 |
|---|---|---|
| 修改了本地只读项并尝试上传 | isReadOnly错误 | 本地项被复制到冲突文件夹(conflict folder),远端项覆盖本地项 |
| 删除了本地只读项并尝试删除远端项 | isReadOnly错误 | 下载远端项,恢复本地项 |
| 把某项添加为只读文件夹的子项 | isReadOnly错误 | 本地项被复制到冲突文件夹,然后删除 |
4.1 Synchronizer 中的错误捕获
在 Synchronizer.ts 的上传阶段,有两处关键的IsReadOnly捕获点:
// packages/lib/Synchronizer.ts(资源 blob 上传路径) } else if (error && error.code === ErrorCode.IsReadOnly) { action = getConflictType(local); itemIsReadOnly = true; logger.info('Resource is readonly and cannot be modified - handling it as a conflict:', local); } // packages/lib/Synchronizer.ts(项元数据上传路径) } else if (error && error.code === ErrorCode.IsReadOnly) { action = getConflictType(local); itemIsReadOnly = true; canSync = false; }两处逻辑一致:一旦上传收到isReadOnly错误,就把当前操作改判为冲突动作(NoteConflict/ResourceConflict/ItemConflict),并置位itemIsReadOnly标志传给后续的冲突处理函数。此外,同步开始前的Folder.updateAllShareIds/shareService.checkShareConsistency步骤若因只读项报错,也会被捕获并仅记录错误日志而不中断同步——源码注释同样强调"正常情况下 UI 应该拦截,但如果 UI 有 bug,不希望同步因此失败"。
4.2 handleConflictAction:只读项的差异化冲突处理
冲突处理统一收敛在 handleConflictAction.ts 中,itemIsReadOnly参数在其中起决定性的差异化作用:
- 跳过自动合并:普通笔记冲突若开启了自动合并(auto-merge),会尝试合并本地与远端的标题/正文差异;但对只读项,本地修改根本推不上去,合并毫无意义,源码注释写得很直白:"Skipped for content that can't be merged safely: read-only items (the local change can't be pushed)"。因此自动合并条件中包含
!itemIsReadOnly; - 跳过"冲突是否重要"的判断:
mustHandleConflict的计算同样被!itemIsReadOnly前置短路,只读项直接走"创建冲突副本 + 远端覆盖本地"的路径,与文档描述的"本地项复制到冲突文件夹,远端项覆盖本地"完全对应; - 文件夹冲突不建冲突副本:对于
ItemConflict(文件夹等非笔记项),处理是直接以远端内容覆盖本地(远端存在时)或删除本地(远端已删时),不创建冲突副本——这也是文档所说"把某项加到只读文件夹下时,本地项复制到冲突文件夹后被删除"之外,文件夹自身修改被直接回滚的原因。
4.3 测试用例印证
上述行为在 Synchronizer.basics.test.ts 中有直接覆盖。测试通过synchronizer().testingHooks_ = ['itemIsReadOnly']这一测试钩子,让 Synchronizer 在上传时主动抛出ErrorCode.IsReadOnly错误来模拟服务端拒绝:
it('should handle items that are read-only on the sync target', (async () => { const folder = await Folder.save({ title: 'folder' }); const note = await Note.save({ title: 'un', is_todo: 1, parent_id: folder.id }); await synchronizerStart(); await Note.save({ id: note.id, title: 'un mod' }); synchronizer().testingHooks_ = ['itemIsReadOnly']; await synchronizerStart(); const noteReload = await Note.load(note.id); expect(noteReload.title).toBe(note.title); // 本地被远端覆盖 const conflictNote = (await Note.all()).find(n => !!n.is_conflict); expect(conflictNote).toBeTruthy(); // 本地修改保留在冲突副本 expect(conflictNote.title).toBe('un mod'); })); it('should revert local changes to read-only folders', (async () => { // ... 修改文件夹并触发只读错误 const reloadedFolder = await Folder.load(folder.id); expect(reloadedFolder.title).toBe('folder'); // 标题被回滚 expect(reloadedFolder.share_id).toBe(''); // Should not have created a conflict expect(await Folder.all()).toHaveLength(1); // 文件夹冲突不产生副本 }));这两组测试恰好印证了文档描述的两种恢复语义:笔记类只读冲突"本地副本进冲突文件夹 + 远端覆盖本地",文件夹类只读冲突"直接回滚本地修改、不产生冲突副本"。
五、UI 层:编辑器与菜单的禁用
规范文档指出 UI 层"同样使用readOnly.ts判断项是否只读,从而禁用菜单项、编辑器、命令等"。在桌面端源码中可以看到这一机制的具体落点,以笔记编辑器 NoteEditor.tsx 为例:
import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly'; const [isReadOnly, setIsReadOnly] = useState<boolean>(false); // 打开笔记时查询只读状态 const result = await itemIsReadOnly(BaseItem, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, formNote.id, props.syncUserId, shareCache); // 编辑器输入控件根据该状态禁用 disabled: isReadOnly || reloadInProgress,即:编辑器在加载笔记后调用与模型层同一个itemIsReadOnly工具函数查询状态,并把富文本编辑区的disabled直接绑定到该状态。除了正文编辑器,桌面端还会把只读状态传递给上下文菜单(contextMenuUtils.ts 中定义了isReadOnly?: boolean参数,由 CodeMirror 与 TinyMCE 两套编辑器的右键菜单各自消费),以及笔记属性对话框 NotePropertiesDialog.tsx 中的相关操作项,实现"从入口上就不让用户发起会被拒绝的操作"。
移动端与 CLI 同样依赖packages/lib中的这套共享工具函数,因此三端的只读判定逻辑保持一致。
六、总结:一条规则的四层防线
把规范文档 read_only.md 的脉络与源码对应起来,Joplin 的只读机制是一条清晰的纵深防御链:
- 服务端(Joplin Cloud / Joplin Server):唯一权威,对无写权限的共享写入返回
403 + {"code": "isReadOnly"}; - 模型层:readOnly.ts 基于
share_id+can_write推导只读状态,BaseItem统一在修改、删除、添加子项、改写资源内容四类路径上抛出IsReadOnly错误; - UI 层:复用同一套判定函数禁用编辑器、菜单与命令,让用户根本无法触发违规操作;
- 同步层:作为最后的健壮性兜底,把意外到达服务端的只读错误转化为可控的冲突恢复(本地副本保留、远端覆盖本地),保证同步永不卡死、本地数据最终与共享文件夹一致。
理解这套机制的关键在于:只读是一个由共享状态推导出来的临时属性,而非持久化标记,因此它必须出现在所有可能绕过 UI 的路径上(插件 API、命令行、同步竞态),这正是 Joplin 选择"多层检查 + 可恢复冲突"而非单一拦截点的原因。
【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考