Files
mnote/design/05-editor-mainline/done/5-31-page-settings-sqlite-preference-convergence-v1.md
T
lix-2026 1882db7681 收口 MNote P0 P1 P2 审查尾项
- 归档 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 .
2026-06-01 09:29:12 +08:00

16 KiB
Raw Blame History

5-31 页面设置 SQLite 状态收口设计 v1

状态:done

Owner05-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 workspace
    • external_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.updateOptions compat command。
  • showHeadingNumbers 已经被前端单独放进 localStorage,说明当前系统事实上承认存在“用户级 UI 偏好”,但没有 SQLite-backed 实现。

4. 核心判断

页面设置需要拆成两类概念:

  1. 用户视图偏好:同一用户在不同浏览器应该一致,但不应改变文件、页面正文或其他用户视图。
  2. 页面共享属性 / 策略:属于页面或工作区事实,可能影响所有用户,必须与权限、分享和协作边界一起设计。

当前暴露问题的字段基本属于第 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_xxxlocal:_mnt_Data1T_mnote 单工作区偏好
document local-md:README.md 保留能力,MVP 不用于 hideTitleHeader

5.2 字段归属

MVP 先迁移当前已接通且影响 UI 的字段:

字段 SQLite scope 理由
hideTitleHeader source_family 用户明确希望只区分“我的空间”和“其它本地文件夹”,不按文件保存
showHeadingNumbers globalworkspace 当前 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 默认 false
    • external_local_folder 默认 true
  • 一旦用户修改 hideTitleHeader,写入对应 source_family scope,之后所有同类页面使用该值。

6. API 设计

新增最小 API

6.1 读取有效页面偏好

GET /api/ui/preferences/effective

Query

  • workspaceId
  • sourceKind
  • rootUri
  • documentId

返回:

{
  "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_family
  • showHeadingNumbers 写入 global
  • wideLayout/smallText/layoutDensity/pageFont/showToc/... 写入 workspace

返回同一份 effective preferences,前端用返回值刷新当前 shell。

7. Page Aggregate 合同调整

Page Aggregate 继续输出:

  • layout.pageOptions
  • layout_options

但含义调整为:

layout.pageOptions 是后端合并后的 effective UI options。

为避免后续再混淆,建议追加调试字段:

"layout": {
  "pageOptions": {},
  "pageOptionsSource": {
    "wideLayout": "workspace",
    "hideTitleHeader": "source_family",
    "showHeadingNumbers": "global"
  }
}

协议层可分两步:

  1. MVP:保持 PageOptions 结构不变,只改变读取与写入来源。
  2. 后续:把 PageOptions 拆为 effectiveUiOptionspagePolicyOptions,再处理 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 ASQLite 偏好底座

  • 新增 user_ui_preferences migration。
  • control-plane model/store/sqlite 增加 upsert/list/effective merge 方法。
  • 增加单测覆盖 scope 优先级与 user 隔离。

验收:

  • 同一用户同一 key 在不同 scope 下可合并。
  • 不同用户互不影响。
  • migration idempotent。

Phase Bmnote-web API 与 Page Aggregate 合并

  • 新增 /api/ui/preferences/effectivePUT /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_spaceexternal_local_folderhideTitleHeader 独立
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_preferences migration/store、/api/ui/preferences、Page Aggregate SQLite 合并、前端页面设置写链迁移。
  • 已完成 Phase D 的旧写链收口:local folder /api/documents/optionsmnote.page.update_options 均不再写 .mnote/page-options.json
  • 已新增 task493-page-settings-sqlite-preferences-smoke.js,覆盖两个 browser context 的同用户同步、不同用户隔离和不生成 sidecar。
  • 已更新旧 smoke 的页面设置写链断言,从 /api/documents/options UI 主链改为 /api/ui/preferences/api/documents/options 仅保留兼容入口。

12. 风险与边界

  • 如果把所有字段都做成 user preference,未来协作页面的共享设置会缺位。因此必须保留“用户偏好”和“页面策略”两类边界。
  • 如果继续让 Page Aggregate 暴露字段但不标注来源,后续仍可能误以为它们是页面事实源。建议尽快追加 pageOptionsSource
  • 对 local folder 的旧 sidecar 只能读兼容,不能自动删除。删除或批量迁移需要用户显式确认。
  • 当前工作区已有 sidebar shortcuts / control-plane 未提交改动;实施时需要先确认是否基于那些改动继续,避免 migration 编号冲突。

13. 推荐下一步

先做 Phase A + B 的最小闭环:

  1. 在当前 control-plane migration 链上新增 user_ui_preferences
  2. 增加 effective preference merge API。
  3. 只迁移 hideTitleHeadershowHeadingNumbers 两个问题最明确的字段。
  4. 通过跨浏览器 smoke 验证后,再迁移 wideLayout/smallText/layoutDensity/pageFont

这样可以先解决“状态恢复原样”和“不同浏览器不同步”两个根问题,同时控制改动范围。