- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目 - 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线 - 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径 验证: - cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1 - cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1 - git diff --check - git diff --cached --check - codegraph index . --force && codegraph status . - codegraph sync . && codegraph status .
16 KiB
5-31 页面设置 SQLite 状态收口设计 v1
状态:done
Owner:05-editor-mainline / control-plane / mnote-web
更新时间:2026-05-26
背景:
隐藏本地 Markdown 文件标题在普通 local folder 中暴露出多状态覆盖问题。进一步复核后,问题不只属于该字段:当前页面设置同时散落在.mnote/page-options.json、Page Aggregate runtime cache、浏览器localStorage和前端临时状态里,导致同一用户不同浏览器之间不能稳定同步,也让 local folder watcher / stale aggregate 更容易把 UI 状态恢复成旧值。
1. 目标
把页面设置与阅读偏好统一收口到 Rust SQLite control-plane,形成跨浏览器一致的状态来源。
- 用户在一个浏览器中修改页面显示设置后,另一个浏览器刷新或重新打开同一用户空间时能读到同一状态。
- 普通 local folder 不再把页面设置长期写入项目目录下的
.mnote/page-options.json。 hideTitleHeader不再按单个 Markdown 文件保存,改为按用户和 source family 保存:my_space:我的空间 / 托管 local workspaceexternal_local_folder:其它本地文件夹
- Page Aggregate 继续作为前端读取页面运行态的统一入口,但其
layout.pageOptions应是 SQLite 偏好合并后的有效值,而不是前端本地缓存或 sidecar 文件的直接回声。 - 前端页面设置 runtime 不再直接管理多个长期状态来源,只负责乐观更新、失败回滚和重新拉取。
2. 非目标
- 本阶段不改变 Markdown 正文真相;本地
.md仍是正文事实源。 - 不把 UI 偏好写入 Markdown frontmatter。
- 不用
.mnote/page-options.json作为长期写入目标;它只保留 legacy read fallback。 - 不把所有字段无脑变成全局开关。SQLite 是存储层,字段仍需按语义选择作用域。
- 不在本阶段实现多人协作共享页面设置。当前目标是“同一用户跨浏览器同步”。
3. 当前状态盘点
3.1 前端页面设置字段
sidebar-page-settings-runtime.js 当前默认字段:
| 字段 | 当前用途 | 当前长期状态来源 | 问题 |
|---|---|---|---|
wideLayout |
主内容列宽 | Page Aggregate / .mnote/page-options.json / compat command |
local folder 写入项目目录;跨浏览器不稳定 |
smallText |
正文小字体 | Page Aggregate / .mnote/page-options.json |
同上 |
layoutDensity |
段落密度 | Page Aggregate / .mnote/page-options.json |
同上 |
pageFont |
页面字体 | Page Aggregate runtime 字段 | 当前更像用户阅读偏好,不应是文件属性 |
showHeadingNumbers |
标题编号 | localStorage 强制覆盖 |
只能单浏览器生效 |
hideTitleHeader |
本地 Markdown 页头标题显隐 | Page Aggregate / .mnote/page-options.json / 前端竞态保护 |
不应按文件保存,容易被 stale aggregate 覆盖 |
showToc |
目录面板 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 |
showStructure |
结构显示 | PageOptions 字段,当前未完整接线 | 语义更像用户视图偏好 |
showWordCount |
字数统计 | PageOptions 字段 | 语义更像用户视图偏好 |
collapseBacklinks |
折叠反链 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 |
hideChildPages |
隐藏子页面 | PageOptions 字段,当前待接线 | 语义可能是用户视图偏好 |
showBlockRefCount |
块引用数字 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 |
protectEditing |
编辑保护 | PageOptions 字段,当前待接线 | 语义可能是页面策略,不应直接归入用户偏好 |
embedDefaultBlockId |
嵌入默认块 | PageOptions 字段 | 语义更像页面/嵌入属性,不是纯 UI 偏好 |
3.2 后端与协议现状
core-protocol::PageOptions当前把页面布局、阅读偏好、编辑策略和嵌入属性都放在一个结构里。mnote-web的 local folder 写链是POST /api/documents/options -> update_local_page_options -> .mnote/page-options.json。- local folder aggregate 构建会读取
.mnote/page-options.json,没有值时用“托管工作区显示标题、普通本地文件夹隐藏标题”的默认规则。 - 非 local folder 仍走
page.layout.updateOptionscompat command。 showHeadingNumbers已经被前端单独放进localStorage,说明当前系统事实上承认存在“用户级 UI 偏好”,但没有 SQLite-backed 实现。
4. 核心判断
页面设置需要拆成两类概念:
- 用户视图偏好:同一用户在不同浏览器应该一致,但不应改变文件、页面正文或其他用户视图。
- 页面共享属性 / 策略:属于页面或工作区事实,可能影响所有用户,必须与权限、分享和协作边界一起设计。
当前暴露问题的字段基本属于第 1 类。它们应进入 SQLite control-plane 的用户偏好层,而不是继续写 local folder sidecar。
5. 推荐方案
5.1 新增 SQLite 用户偏好表
新增 control-plane migration,例如 004-user-ui-preferences.sql:
CREATE TABLE user_ui_preferences (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
source_kind TEXT,
scope_kind TEXT NOT NULL,
scope_id TEXT NOT NULL,
key TEXT NOT NULL,
value_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
UNIQUE(user_id, workspace_id, source_kind, scope_kind, scope_id, key)
);
CREATE INDEX idx_user_ui_preferences_lookup
ON user_ui_preferences(user_id, workspace_id, source_kind, scope_kind, scope_id);
scope_kind 建议取值:
| scope_kind | scope_id 示例 | 用途 |
|---|---|---|
global |
default |
同用户全局偏好 |
source_family |
my_space / external_local_folder |
同一类数据源的默认显示策略 |
workspace |
ws_xxx 或 local:_mnt_Data1T_mnote |
单工作区偏好 |
document |
local-md:README.md |
保留能力,MVP 不用于 hideTitleHeader |
5.2 字段归属
MVP 先迁移当前已接通且影响 UI 的字段:
| 字段 | SQLite scope | 理由 |
|---|---|---|
hideTitleHeader |
source_family |
用户明确希望只区分“我的空间”和“其它本地文件夹”,不按文件保存 |
showHeadingNumbers |
global 或 workspace |
当前 localStorage 字段,迁 SQLite 后才能跨浏览器同步 |
wideLayout |
workspace,可 fallback 到 global |
阅读布局偏好,通常用户对某类工作区有稳定习惯 |
smallText |
workspace,可 fallback 到 global |
同上 |
layoutDensity |
workspace,可 fallback 到 global |
同上 |
pageFont |
workspace,可 fallback 到 global |
字体是用户阅读偏好,不是 Markdown 文件属性 |
showToc |
workspace,可 fallback 到 global |
目录面板属于用户视图 |
collapseBacklinks |
workspace,可 fallback 到 global |
反链面板展示偏好 |
showWordCount |
global |
统计展示偏好 |
hideChildPages |
workspace |
页面树/子页面展示偏好 |
showBlockRefCount |
workspace |
块引用计数展示偏好 |
暂不迁入用户偏好的字段:
| 字段 | 处理 |
|---|---|
protectEditing |
暂停作为可写 UI 偏好;后续进入页面策略 / 权限设计 |
embedDefaultBlockId |
保留在 Page Aggregate / 页面属性合同中;不作为用户偏好 |
5.3 默认值与合并顺序
页面打开时,后端合并出 effective options:
协议默认值
-> 用户 global 偏好
-> 用户 source_family 偏好
-> 用户 workspace 偏好
-> 用户 document 偏好(MVP 只保留能力,不主动使用)
-> legacy .mnote/page-options.json fallback(只在 SQLite 完全没有对应值时生效)
关键规则:
- SQLite 值永远优先于
.mnote/page-options.json。 .mnote/page-options.json只读,不再由页面设置写链更新。hideTitleHeader的默认值仍保持当前体验:my_space默认falseexternal_local_folder默认true
- 一旦用户修改
hideTitleHeader,写入对应source_familyscope,之后所有同类页面使用该值。
6. API 设计
新增最小 API:
6.1 读取有效页面偏好
GET /api/ui/preferences/effective
Query:
workspaceIdsourceKindrootUridocumentId
返回:
{
"ok": true,
"owner": "mnote-web",
"result": {
"scope": {
"sourceFamily": "external_local_folder",
"workspaceId": "local:_mnt_Data1T_mnote",
"documentId": "local-md:README.md"
},
"pageOptions": {
"wideLayout": false,
"smallText": false,
"layoutDensity": "normal",
"pageFont": "default",
"showHeadingNumbers": false,
"hideTitleHeader": true
},
"sources": {
"hideTitleHeader": "source_family",
"showHeadingNumbers": "global"
}
}
}
6.2 写入偏好
PUT /api/ui/preferences
Body:
{
"workspaceId": "local:_mnt_Data1T_mnote",
"sourceKind": "local_folder",
"rootUri": "file:///mnt/Data1T/mnote",
"documentId": "local-md:README.md",
"updates": {
"hideTitleHeader": false,
"layoutDensity": "compact"
}
}
后端根据字段决定 scope,不信任前端直接指定 scope:
hideTitleHeader写入source_familyshowHeadingNumbers写入globalwideLayout/smallText/layoutDensity/pageFont/showToc/...写入workspace
返回同一份 effective preferences,前端用返回值刷新当前 shell。
7. Page Aggregate 合同调整
Page Aggregate 继续输出:
layout.pageOptionslayout_options
但含义调整为:
layout.pageOptions是后端合并后的 effective UI options。
为避免后续再混淆,建议追加调试字段:
"layout": {
"pageOptions": {},
"pageOptionsSource": {
"wideLayout": "workspace",
"hideTitleHeader": "source_family",
"showHeadingNumbers": "global"
}
}
协议层可分两步:
- MVP:保持
PageOptions结构不变,只改变读取与写入来源。 - 后续:把
PageOptions拆为effectiveUiOptions与pagePolicyOptions,再处理protectEditing/embedDefaultBlockId。
8. 前端 runtime 调整
sidebar-page-settings-runtime.js 的长期职责应简化为:
- 从
__MNOTE_PAGE_AGGREGATE__读取 effective options 初始化 UI。 - 用户修改设置时调用
PUT /api/ui/preferences。 - 用返回的 effective options 更新当前 shell 和 aggregate script。
- 不再写
localStorage保存showHeadingNumbers。 - 不再把 local folder options 写入
/api/documents/options。 - 不再需要
data-mnote-page-options-local-write-at这类 stale aggregate 竞态补丁作为长期机制。
/api/documents/options 的定位调整:
- 保留为 cloud/compat 或未来页面策略命令入口。
- local folder 的 UI 偏好写入不再走这里。
- 如果 body 中包含
protectEditing/embedDefaultBlockId这类非用户偏好字段,再进入页面策略链路;MVP 可以保持 disabled。
9. local folder sidecar 兼容策略
.mnote/page-options.json 保留读兼容:
- 读取时只作为 fallback。
- 写入时不再更新。
- 如果同一字段在 SQLite 已存在,以 SQLite 为准。
- 不自动删除用户已有 sidecar 文件,避免破坏历史证据。
建议后续提供显式迁移工具:
node scripts/migrate-local-page-options-to-sqlite.js --root /path/to/root --actor mnote-e2e
迁移工具只在用户明确执行时运行。
10. 实施切片
Phase A:SQLite 偏好底座
- 新增
user_ui_preferencesmigration。 - control-plane model/store/sqlite 增加 upsert/list/effective merge 方法。
- 增加单测覆盖 scope 优先级与 user 隔离。
验收:
- 同一用户同一 key 在不同 scope 下可合并。
- 不同用户互不影响。
- migration idempotent。
Phase B:mnote-web API 与 Page Aggregate 合并
- 新增
/api/ui/preferences/effective与PUT /api/ui/preferences。 - local folder aggregate 构建时合并 SQLite preferences。
hideTitleHeader默认策略改为 source_family 默认值。.mnote/page-options.json只作为 fallback。
验收:
- 普通 local folder 默认隐藏标题。
- 我的空间默认显示标题。
- SQLite 写入后优先于 sidecar。
Phase C:前端页面设置写链迁移
sidebar-page-settings-runtime.js改写设置保存 API。- 删除
showHeadingNumbers的 localStorage 长期真相。 persistPageOptionsPatch用后端返回 effective options 更新 UI。- 保留失败回滚。
验收:
- 一个浏览器修改设置,另一个浏览器刷新后同步。
- 切换文档、local folder watcher refresh 后不恢复旧状态。
hideTitleHeader对同类 source 生效,不按单个文件漂移。
Phase D:兼容与清理
- local folder
/api/documents/options不再写.mnote/page-options.json,或只保留 legacy endpoint 并标记 deprecated。 - 移除上一轮针对 per-file sidecar 竞态的长期补丁,只保留必要的 aggregate script 同步事件。
- 更新
hermes_tools::page中页面 options 写入语义,避免 AI tool 写 UI 偏好到 sidecar。
验收:
- 不再产生新的
.mnote/page-options.json写入。 - 旧 sidecar 存在时不破坏读取。
- CodeGraph 可清楚定位 UI preference API、control-plane store、Page Aggregate merge 三个边界。
11. Smoke / 测试计划
新增或更新 smoke:
| Smoke | 断言 |
|---|---|
task493-page-settings-sqlite-preferences-smoke.js |
页面设置写入 SQLite,刷新后保持 |
task494-page-settings-cross-browser-sync-smoke.js |
同一用户两个 browser context 同步页面设置 |
task495-local-title-source-family-smoke.js |
my_space 与 external_local_folder 的 hideTitleHeader 独立 |
task496-page-options-sidecar-readonly-compat-smoke.js |
旧 .mnote/page-options.json 只读 fallback,不再被写入 |
Rust 单测:
- control-plane migration / store merge
- mnote-web preference API route
- local folder aggregate merge preference
- Page Aggregate effective options source 标记
当前实施记录(2026-05-26):
- 已完成 Phase A/B/C 最小闭环:
user_ui_preferencesmigration/store、/api/ui/preferences、Page Aggregate SQLite 合并、前端页面设置写链迁移。 - 已完成 Phase D 的旧写链收口:local folder
/api/documents/options与mnote.page.update_options均不再写.mnote/page-options.json。 - 已新增
task493-page-settings-sqlite-preferences-smoke.js,覆盖两个 browser context 的同用户同步、不同用户隔离和不生成 sidecar。 - 已更新旧 smoke 的页面设置写链断言,从
/api/documents/optionsUI 主链改为/api/ui/preferences;/api/documents/options仅保留兼容入口。
12. 风险与边界
- 如果把所有字段都做成 user preference,未来协作页面的共享设置会缺位。因此必须保留“用户偏好”和“页面策略”两类边界。
- 如果继续让 Page Aggregate 暴露字段但不标注来源,后续仍可能误以为它们是页面事实源。建议尽快追加
pageOptionsSource。 - 对 local folder 的旧 sidecar 只能读兼容,不能自动删除。删除或批量迁移需要用户显式确认。
- 当前工作区已有 sidebar shortcuts / control-plane 未提交改动;实施时需要先确认是否基于那些改动继续,避免 migration 编号冲突。
13. 推荐下一步
先做 Phase A + B 的最小闭环:
- 在当前 control-plane migration 链上新增
user_ui_preferences。 - 增加 effective preference merge API。
- 只迁移
hideTitleHeader与showHeadingNumbers两个问题最明确的字段。 - 通过跨浏览器 smoke 验证后,再迁移
wideLayout/smallText/layoutDensity/pageFont。
这样可以先解决“状态恢复原样”和“不同浏览器不同步”两个根问题,同时控制改动范围。