# 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 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`：

```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：

```text
协议默认值
  -> 用户 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`

返回：

```json
{
  "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：

```json
{
  "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。

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

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

协议层可分两步：

1. MVP：保持 `PageOptions` 结构不变，只改变读取与写入来源。
2. 后续：把 `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 文件，避免破坏历史证据。

建议后续提供显式迁移工具：

```bash
node scripts/migrate-local-page-options-to-sqlite.js --root /path/to/root --actor mnote-e2e
```

迁移工具只在用户明确执行时运行。

## 10. 实施切片

### Phase A：SQLite 偏好底座

- 新增 `user_ui_preferences` migration。
- 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_preferences` migration/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/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. 只迁移 `hideTitleHeader` 与 `showHeadingNumbers` 两个问题最明确的字段。
4. 通过跨浏览器 smoke 验证后，再迁移 `wideLayout/smallText/layoutDensity/pageFont`。

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