diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..815c0ed --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +src/editor/csl/assets/*.csl text eol=lf +src/editor/csl/assets/*.xml text eol=lf +src/editor/csl/assets/*.json text eol=lf +public/third-party/*.txt text eol=lf +public/typst-fonts/LICENSE-* -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c28bfa..e8cd791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# 三平台冒烟 CI:install → typecheck → lint → test → build → cargo check +# 三平台 CI:install → typecheck → lint → test → build → 独立 Unix 负控 + 全目标生产 cargo test # 供应链约定:仅 actions/* 官方 action,且按完整 commit SHA 锁定; # pnpm 经 corepack(与本地 packageManager 字段一致)、Rust 经 runner 自带 rustup,零第三方 action。 name: CI @@ -16,6 +16,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -26,10 +27,10 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev - - name: Setup Node 22 + - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version-file: .node-version - name: Activate pnpm via corepack run: | @@ -51,10 +52,30 @@ jobs: run: pnpm lint - name: Test - run: pnpm test + timeout-minutes: 15 + run: pnpm test:ci - name: Build run: pnpm build - - name: Cargo check - run: cargo check --manifest-path src-tauri/Cargo.toml + - name: Unix permission negative control (isolated legacy strategy) + if: ${{ !cancelled() && runner.os != 'Windows' }} + timeout-minutes: 3 + run: node scripts/acceptance/run-unix-negative.mjs + + - name: Cargo tests (all production targets, serial) + if: ${{ !cancelled() }} + timeout-minutes: 25 + # --all-targets typechecks and builds the application binary as well as the tested library. + # A separate cargo check would rebuild dependency metadata before this code-generation build. + # The wrapper preserves the hard timeout, process cleanup and Unix production assertion. + # Runs even if the separate negative fixture fails; no production assertion is skipped. + run: node scripts/acceptance/run-rust.mjs + + - name: Upload acceptance evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4; same locked SHA as release.yml + with: + name: inkstream-acceptance-${{ matrix.os }} + path: coverage/acceptance/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82ed9c2..325d545 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,7 @@ jobs: # 草稿 Release 一次性创建(job 永远跑,建 Release 步骤仅 tag push)。bundle 依赖它,杜绝并发建 Release 竞态。 create-release: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Create draft release (tag push only) if: startsWith(github.ref, 'refs/tags/') @@ -40,6 +41,7 @@ jobs: bundle: needs: create-release + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -67,10 +69,10 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev - - name: Setup Node 22 + - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version-file: .node-version - name: Activate pnpm via corepack run: | @@ -141,6 +143,7 @@ jobs: latest-json: needs: bundle + timeout-minutes: 5 if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 71d8665..c03532c 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,9 @@ ## 特性 - **单一内核编辑器**:整个应用就一个 CodeMirror 6 实例,纯文本始终是文档的依据。Source 和 Live Preview 随时切换,光标所在行自动展开源码,支持 11 种语言高亮。 +- **本机项目档案**:`Ctrl+Alt+P` 打开左侧档案,管理名称、封面、收藏和内容目录。每个项目记住标签、光标与布局;独立草稿也会暂存在本机。切换前保存失败会留在原项目。详见[项目与恢复](docs/projects.md)。 - **三类数学块**:math 走 KaTeX,typst 走 typst.ts(wasm 实时 SVG),latex 走 MathJax。都是懒加载,首屏不碰 wasm 和字体。 -- **双向链接知识网络**:`[[wiki-link]]` 全语法(`|别名`、`#标题`、`^块`),SQLite FTS5 全库索引,中文按三字分词;反链面板还会列出未链接的提及。 +- **双向链接知识网络**:`[[wiki-link]]` 支持别名、标题与块定位,SQLite FTS5 索引保存在本机应用数据目录,并支持一至两个字符的短词查找;反链面板列出有上下文的未链接提及。 - **知识图谱**:`Ctrl+G` 打开全库 Graph View,d3-force 力导加 Canvas2D 自绘,布局放到 Worker 里跑。可缩放、拖拽、点击跳转、邻域高亮;右栏能看当前文件的局部图谱。 - **git 原生**:基于 libgit2 的完整命令集,自绘三栏 git-graph(图谱、提交详情、文件 diff)。支持远程 clone / fetch / push / pull 和 SSH 签名提交。 - **句级 diff 与三向合并**:中英混排按句子比较,看的是「哪句话改了」而不是「哪行变了」。合并冲突可以逐句采纳本方或对方。 @@ -68,7 +69,7 @@ - **文件导出**:一键把当前文档导出成 HTML、PDF、DOCX,全本地转换;可加一行自定义水印(默认关)。装了 pandoc 还能导出 ODT、LaTeX、EPUB 等。 - **沉浸阅读模式**:打开 txt、docx、epub、pdf 全屏阅读,自动认出小说还是文献并换排版,三套配色护眼。PDF 逐页懒渲染,编辑器不卸载,随时切回来。 - **应用内自动更新**:启动时静默检查新版本,一键下载并重启升级,更新包经签名验证。 -- **简易模式**:一键收起全部高级功能,也不在工作区建 `.inkstream` 索引库,给轻度用户最干净的纯文本编辑。 +- **简易模式**:一键收起高级功能并关闭索引,保留写作、项目档案和草稿恢复。所有模式都不向内容目录写入新的项目元数据或索引库。 - **中文优先**:中文输入法全程不被预览打断,中英混合字数统计,中文模糊搜索。 ## 下载安装 @@ -110,7 +111,7 @@ ## 从源码构建 -环境要求:**Node 22(LTS)· Rust stable(rustup)· pnpm 11.5.2**(经 corepack 激活)。 +环境要求:**Node 24(见 `.node-version`)· Rust stable(rustup)· pnpm 11.5.2**(经 corepack 激活)。 ```bash corepack enable @@ -119,7 +120,7 @@ pnpm tauri dev # 开发模式启动桌面应用 pnpm tauri build # 打包本平台安装包(产物在 src-tauri/target/release/bundle/) ``` -三道门:`pnpm typecheck && pnpm lint && pnpm test`。Linux 构建需先安装 webkit2gtk 等系统依赖(清单见 [.github/workflows/ci.yml](./.github/workflows/ci.yml));三平台安装包经 [.github/workflows/release.yml](./.github/workflows/release.yml) 在 CI 同时构建。 +本地检查:`pnpm typecheck`、`pnpm lint`、`pnpm test:ci`、`pnpm build`。`test:ci` 串行执行全部前端测试,保留每项测试的原有时限,记录进程清理与结果,避免大文档测试和其他 jsdom 实例争用资源。原生测试运行 `node scripts/acceptance/run-rust.mjs`。Linux 构建需先安装 webkit2gtk 等系统依赖(清单见 [.github/workflows/ci.yml](./.github/workflows/ci.yml));三平台安装包经 [.github/workflows/release.yml](./.github/workflows/release.yml) 在 CI 同时构建。 ## 路线图 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..3529bae --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,29 @@ +# Third-party notices + +InkStream's own code is licensed as described in `LICENSE`. The components below retain their respective licenses. + +## citeproc-js 2.4.63 + +Copyright © 2009–2019 Frank Bennett. InkStream uses the CPAL-1.0-or-later licensing option offered by the original author. The processor is used without source modifications. + +- Original source distribution: https://registry.npmjs.org/citeproc/-/citeproc-2.4.63.tgz +- Upstream project: https://github.com/Juris-M/citeproc-js +- Documentation: https://citeproc-js.readthedocs.io/ +- Original notice: `public/third-party/citeproc-LICENSE.txt` +- Full CPAL 1.0 text: `public/third-party/CPAL-1.0.txt` + +The source distribution remains available under its original licensing terms, independently of InkStream's own license. The package integrity is fixed in `pnpm-lock.yaml`. + +## Citation Style Language styles and locales + +The bundled GB/T 7714-2015 numeric, APA 7, NLM citation-sequence styles and English/Chinese locales are licensed under CC BY-SA 3.0. Original author, contributor and translator metadata remains in the unmodified XML files. + +- Citation Style Language project: https://citationstyles.org/ +- Styles: https://github.com/citation-style-language/styles +- Locales: https://github.com/citation-style-language/locales +- Exact revisions, source URLs, byte lengths and SHA-256: `src/editor/csl/assets/sources.json` +- Full license: `public/third-party/CC-BY-SA-3.0.txt` + +## Typst preview fonts + +Font sources, copyright, exact versions, hashes and their respective OFL/GUST/LPPL notices are distributed in `public/typst-fonts/ASSETS.md` and the adjacent license files. diff --git a/docs/diff-merge.md b/docs/diff-merge.md index 46620ff..7f93d5a 100644 --- a/docs/diff-merge.md +++ b/docs/diff-merge.md @@ -8,6 +8,16 @@ InkStream 的版本对比和冲突解决可以按句子来看,而不只是按 2. 在左栏点选一个提交,中栏会列出该提交改动的文件。 3. 点中栏里的某个文件,右栏就会显示这次改动的对比内容。 +## 比较两个分支的完整正文 + +在 Git Graph 顶部打开「分支比较」,选择基线分支和目标分支,再点「比较完整正文」。左侧按页列出变更文件,右侧完整显示两个版本,包括没有变化的上下文。可以反向比较,或用「定位当前文档」查看正在编辑的文件;能找到唯一对应文字时,会定位到编辑时的段落。 + +普通文档会按句标出差异,可用「上一处/下一处」跳转。两侧均可选择、复制和查找,不能直接编辑;回到编辑器后,原正文、未保存修改、选区和撤销历史仍在。比较不会切换分支或写入 Git 工作树。 + +比较开始时固定两侧提交;分支之后移动不会改变正在阅读的版本。新增或删除文件会明确显示某一侧没有文件。内容完全相同的改名合并为一项;改名同时编辑的文件按新增和删除分别列出。 + +大文档或句子较多时,界面会暂停句级高亮并保留完整正文。单侧超过 100 MiB、二进制或非 UTF-8 文件会给出无法显示的原因,不以截断内容代替全文。 + ## 行 diff 与句 diff 切换 右栏顶部有一个「行 / 句」切换按钮,对应两种看法: diff --git a/docs/equations.md b/docs/equations.md new file mode 100644 index 0000000..27a82c3 --- /dev/null +++ b/docs/equations.md @@ -0,0 +1,36 @@ +# 公式编号与引用 + +在学术工具栏点击“公式编号”,或执行命令“学术:公式编号”。当前 Markdown 文档会启用公式编号,并为尚无标签的块公式补充唯一标签。再次执行会保留已有标签;这次操作可单步撤销。 + +编号适用于 `$$…$$`、`math` / `latex` / `typst` 代码围栏以及 `:::typst`。每个块算一个公式,按文档顺序从 1 编号。数字在预览和导出时计算,不写入 LaTeX 或 Typst 源码。插入、删除或移动公式后,编号和不带别名的引用会随顺序更新。 + +编号状态和标签保存在 Markdown 中,例如: + +````markdown + + +```latex +E=mc^2 +``` + + +见 [[#eq:energy]]。 +```` + +`energy` 是稳定标签;即使公式变为第 3 个,引用仍写 `[[#eq:energy]]`,显示为“式(3)”。Live Preview 中 Ctrl/Cmd+点击引用,或聚焦引用后按 Enter,可跳到当前文档中的公式源码。带 wiki 别名时显示用户指定的别名,目标仍由标签确定。 + +标签应紧随对应公式,中间只留空白。可使用字母(含中文)、数字、下划线、点和连字符,最长 80 个字符。重复、非法、孤立或未闭合的公式块会阻止批量编号并给出提示。重命名标签时,应同步修改引用;未解析引用会明确显示,不能跳到一个猜测目标。 + +新插入的公式会参与编号;再次点击“公式编号”可为其生成引用标签。用公式上的删除按钮删除时,关联标签会一起删除。在 Source 模式手动移动公式时,应把紧随其后的标签一起移动。 + +HTML 导出使用相同的文档序与标签,生成编号和本页引用链接;合法编号及 bibliography 注释不会出现在正文。普通或未知 HTML 仍转义。数学内容沿用现有导出能力:`math` / `$$` 可使用数学渲染器,LaTeX 与 Typst 围栏保留源码,此功能不等同于整篇学术文档编译。 + +大文档的基础编辑档暂停完整编号扫描和 Live Preview。需要新增编号标签时,先显式启用完整排版;基础档不会因点击编号而自动切换模式或改写文档。显式 HTML 导出仍按导出文档内容计算已有编号。 + +## 导出 LaTeX 公式 PDF 片段 + +将光标放入一个 `latex` 代码围栏,点击学术工具栏“公式 PDF”,或执行“学术:导出公式 PDF 片段”。文件菜单的“导出为 → 公式 PDF 片段…”调用同一动作;公式悬浮工具栏也有“导出公式 PDF”按钮。双栏编辑模式下导出的是正在编辑的公式。 + +片段以 12pt 数学字号和四周 8pt 留白输出,保留公式矢量图形。应用先转换当前公式,再让你选择 `.pdf` 保存位置;保存取消不写文件。编译期间编辑、切换或关闭文档会取消旧公式导出,失败时可重试。基础编辑档需要先显式启用完整排版。 + +此入口支持 MathJax 的 LaTeX 数学表达式及已加载的 `base`、`ams`、`newcommand` 扩展;它不执行完整 TeX 文档、任意宏包或外部文件。公式错误会给出原因,不能作为成功的 PDF 保存。公式标签和文档编号不改变片段源码。文件菜单原有的“PDF…”仍用于整篇文档的系统打印。 diff --git a/docs/github.md b/docs/github.md index 19dbc77..cb0668a 100644 --- a/docs/github.md +++ b/docs/github.md @@ -11,17 +11,19 @@ GitHub 登录入口在「设置 ▸ 账户」分区。 1. 按 `Ctrl+,` 打开设置,或从命令面板(`Ctrl+Shift+P`)执行「视图:设置」。 2. 在左侧分类中点「账户」。 -登录有两种方式: +登录有三种方式: -- 个人访问令牌(PAT):在输入框粘贴形如 `ghp_...` 的令牌,点「登录」。令牌需包含 repo 权限,可在 GitHub ▸ Settings ▸ Developer settings ▸ Personal access tokens 创建。 -- 本机 gh CLI 登录:若 InkStream 检测到你已通过 `gh` 命令行工具在 github.com 登录,账户分区会出现「用本机 gh CLI 一键登录」按钮,点一下就行,不用手动粘贴令牌。 +- 浏览器登录:点「通过浏览器登录 GitHub」,在固定的 GitHub 验证页面输入应用显示的验证码,并确认授权。等待期间可以取消或重新获取验证码;关闭账户设置会取消该页面的设备登录。 +- 个人访问令牌(PAT):填写令牌后点「使用 PAT 登录」。访问私有仓库时需授予对应仓库权限;经典 PAT 通常需要 `repo` 权限。 +- 本机 gh CLI:若本机 `gh` 已在 github.com 登录,可点「使用本机 gh CLI 登录」。InkStream 只读取现有令牌,不修改 gh 的登录账户。 -登录成功后,账户分区显示「已登录」并提供「登出」按钮。登录后通过 HTTPS 推送、拉取、克隆会自动带上令牌。 +浏览器登录需要 GitHub OAuth App 的公开 Client ID,并在该 App 设置中启用 Device Flow。发布版本可在构建时通过 `INKSTREAM_GITHUB_CLIENT_ID` 配置;若当前版本没有配置,浏览器登录按钮会暂时禁用,也可填写自己注册的 OAuth App Client ID。客户端不需要也不接受 Client Secret。具体流程见 [GitHub Device Flow 官方说明](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow)。 -注意事项: +登录成功后,账户分区显示「已登录」并提供「登出 GitHub」按钮。浏览器登录会显示刚验证的 GitHub 用户名。设备码、访问令牌和刷新令牌均由原生端处理;界面只接收验证码、状态和用户名。凭据与过期信息保存在本机系统凭据库的同一条目中,旧版本保存的 PAT/gh 令牌可以继续使用。 -- 令牌只保存在本机操作系统凭据库(如 Windows 凭据管理器),不会上传,也不会回显到界面。 -- 如果你偏好用 SSH 同步而非令牌,可改在「设置 ▸ Git ▸ 远程方式」选择「SSH」。 +对于 GitHub 返回的有过期时间的 OAuth 令牌,GitHub API 与 HTTPS Git 操作会在使用前按需刷新。授权被撤销或刷新令牌已过期时,应用会要求重新登录;网络失败时不会把已过期令牌交给 Git。切换 PAT、gh 或登出会取消旧的设备授权和刷新请求,迟到结果不能覆盖新的账户选择。 + +GitHub 账户仅用于通过准入检查的 github.com HTTPS 地址;本地、SSH、自定义地址等方式按「Git ▸ 远程方式」处理。SSH 需要仓库的 SSH remote URL 与本机密钥。点「登出 GitHub」会删除 InkStream 保存的本机凭据;如需在 GitHub 服务端撤销 OAuth App 授权,可在 GitHub 的 Applications 设置中操作。 ## 打开 Git Graph 与切换标签 @@ -30,7 +32,7 @@ GitHub 的 PR / Issues 视图位于 Git Graph 页面内。 - 按 `Ctrl+Shift+G` 打开 Git Graph,或从命令面板执行「Git Graph」。 - 仅在当前工作区是 git 仓库时可用;否则会提示「当前工作区不是 git 仓库」。 -Git Graph 顶部有四个标签:图谱、分支、PR、Issues。点「PR」或「Issues」即可进入对应视图。 +Git Graph 顶部可切换图谱、分支、暂存记录、PR 与 Issues。点「PR」或「Issues」即可进入对应视图。 提示:`Ctrl+G` 打开的是知识图谱(双向链接网络),与 Git Graph 不是同一个功能,请勿混用。 @@ -66,6 +68,8 @@ Git Graph 顶部有四个标签:图谱、分支、PR、Issues。点「PR」或 - 请求修改:要求作者修改(必须填写评语)。 - 评论:仅留言、不表态(必须填写评语)。 +PR 详情中的“代码审阅讨论”按文件位置列出原评论和回复,可展开上下文,再回复对应讨论。旧版本位置会单独标注;读取或发送失败时显示原因,保留尚未发送的文字。切换 PR 后,旧请求不会覆盖新 PR 的讨论或输入。该回复入口使用 PR review comment 线程,与下方普通评论区分开。 + ## Issue 在 Git Graph 顶部切到「Issues」标签: diff --git a/docs/index.md b/docs/index.md index 117885e..89b382c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,8 +8,9 @@ InkStream(墨流)各功能的使用指南。每篇都从用户视角讲怎 - [编辑器与实时预览](./editor.md):单内核、Source ↔ Live Preview 切换(Ctrl+E)、光标行展开、richtext 工具栏 - [三模式与工作区](./modes.md):Standard / Academic / Creative 切换、打开文件夹、文件树 CRUD、非工作区文件、自动保存 +- [项目与恢复](./projects.md):本机项目档案、每项目会话、独立草稿、备份恢复与材质设置 - [快捷键与命令面板](./shortcuts.md):Ctrl+Shift+P 命令面板、快捷键表、F11 Focus Mode -- [简易模式](./simple-mode.md):轻度用户的精简界面、关闭高级功能、不创建 .inkstream 索引库 +- [简易模式](./simple-mode.md):精简界面与高级功能开关 ## 写作能力 diff --git a/docs/links.md b/docs/links.md index ce0f9e1..42f8aec 100644 --- a/docs/links.md +++ b/docs/links.md @@ -64,7 +64,7 @@ wiki-link 是用双方括号包住目标的链接写法,让一篇笔记指向 ## 全库索引与重建 -反链、未链接提及等功能依赖一份覆盖工作区全部 `.md` 笔记的全文索引(保存在工作区内的 `.inkstream/index.db`)。 +反链、未链接提及等功能使用本机应用数据目录中的项目索引,覆盖 `.md` 与 `.markdown` 笔记(扩展名不区分大小写)。一至两个字符的查询也可使用;代码、已有链接和公式中的文字不会被当成未链接提及。 - **自动重建**:每次打开工作区时,索引会全量重建一次,使其与磁盘上的笔记保持一致。 - **增量更新**:会话过程中,笔记自动保存写盘后会更新对应条目的索引,不需要手动操作。 diff --git a/docs/modes.md b/docs/modes.md index aa1a12f..5e5f996 100644 --- a/docs/modes.md +++ b/docs/modes.md @@ -83,6 +83,6 @@ InkStream 以一个文件夹为工作区(同一窗口同时只有一个工作 - 随时可按 **Ctrl+S** 立即保存当前文件。 - 标签页与文件树上的小圆点表示有未保存改动,保存后消失。 - 可在设置中关闭自动保存;关闭后改动仍显示未保存标记,由你用 Ctrl+S 手动保存。 -- 未命名草稿没有磁盘路径,不会被自动保存;按 Ctrl+S 另存为文件后才开始自动保存。 +- 未命名草稿会随项目会话暂存在本机应用数据中;按 Ctrl+S 另存为普通文件。保存到项目外的文件会作为外部文件打开,当前项目不变。 ← 返回 [功能文档总览](./index.md) diff --git a/docs/projects.md b/docs/projects.md new file mode 100644 index 0000000..5abd259 --- /dev/null +++ b/docs/projects.md @@ -0,0 +1,29 @@ +# 项目与恢复 + +点击左侧「项目」或按 `Ctrl+Alt+P` 打开项目档案。选择一个内容文件夹后,InkStream 为它建立本机项目记录。内容目录可以不是 Git 仓库,也可以位于某个仓库的子目录中。 + +项目名称、封面、收藏、文档会话和搜索索引保存在应用数据目录。你的文件仍留在原内容目录;旧目录中已有的 `.inkstream` 或其他未知文件会保留。 + +## 切换项目 + +点击另一项目后,应用先保存当前已修改文件,再保存草稿和会话。保存、快照或目标目录读取失败时,当前项目和正文会保留,档案显示错误。 + +每个项目恢复自己的标签、选区、滚动位置、写作模式和面板布局。同名文件属于各自的项目。已在磁盘更新的干净文件读取最新内容;恢复内容与磁盘不同的未保存文件会显示外部冲突,由你选择后续处理。 + +「独立草稿」用于没有归属项目的写作,同样会保留本机会话。草稿另存为项目外文件时,文件作为外部标签保留,不会把项目切到它的父目录。 + +Git 写入或远程操作仍在进行时,需等操作完成或取消后再切换。 + +## 管理与恢复 + +档案支持按名称或目录搜索、收藏、改名和更换 PNG/JPEG/WebP 封面。封面会复制到本机应用数据目录,单张不超过 8MiB。 + +文件夹被移动后,可在项目资料中「重新定位文件夹」。移除项目档案会保留内容文件夹和恢复数据;再次添加同一目录会复用项目身份。 + +「恢复上次会话备份」恢复本机文档会话。当前尚未保存的新编辑会保留为「恢复前编辑」草稿,不覆盖源文件。原文件缺失时,已有快照会作为恢复副本打开。损坏或未知版本的元数据会显示错误,原件保留;应用不会把它当作空项目覆盖。 + +当前会话最多保留 128 个标签,单篇恢复正文上限为 100MiB。超过限制或磁盘不可写时会明确提示,并保留当前编辑内容。后台干净标签按需读取,切回最近项目时可保留本次运行中的撤销历史;重启恢复正文、选区与布局,不重放撤销历史。 + +## 界面设置 + +档案以浮层展开,正文版心不会被推挤。设置中的「减少动效」和「减少透明」分别关闭纵深动画与背景模糊,也会响应系统的相应偏好。材料效果使用 CSS;空闲时没有持续的装饰绘制循环。 diff --git a/docs/simple-mode.md b/docs/simple-mode.md index 7b81030..088fc51 100644 --- a/docs/simple-mode.md +++ b/docs/simple-mode.md @@ -4,7 +4,7 @@ ## 它解决什么 -InkStream 默认会在打开的工作区里建一个隐藏的 `.inkstream/` 文件夹,用来存全文搜索与双向链接的索引。对重度用户这是必需的;但只想写点纯文本的话用不到它,简易模式可以把它关掉。 +全文搜索与双向链接使用本机应用数据目录中的项目索引。只写纯文本时,简易模式可以关闭索引和相关后台工作。无论是否启用简易模式,新索引都不会写到你的内容文件夹。 ## 怎么开 @@ -19,14 +19,14 @@ InkStream 默认会在打开的工作区里建一个隐藏的 `.inkstream/` 文 - 状态栏:只留文件路径、光标位置、渲染模式,隐藏 Git 分支、字数目标、引用、模式切换。 - 命令面板 / 菜单 / 快捷键:知识图谱(Ctrl+G)、Git Graph、模式切换、插入引用、切换文档语言等高级命令一并隐藏,即使按对应快捷键也不会触发。 -**不创建 `.inkstream/` 索引文件夹**:简易模式下打开或切换工作区都不会建索引库,反链、未链接提及、图谱查询都会返回空结果。 +**关闭索引**:简易模式下打开或切换项目不启动索引,反链、未链接提及和图谱工具隐藏。 -**保留的基础能力**:纯文本编辑、Live Preview 与 Source 切换、文件树增删改、自动保存、文件导出、阅读模式照常可用,这些都不依赖索引。 +**保留的基础能力**:纯文本编辑、项目档案与草稿恢复、Live Preview 与 Source 切换、文件树增删改、自动保存、文件导出和阅读模式照常可用。 ## 随时可逆 关闭简易模式后,高级功能会恢复;下次打开工作区时索引库会自动重建。简易模式只是一个显示和行为上的开关,不会改动你的文档。 -> 注意:如果某个工作区在关闭简易模式时已经建过 `.inkstream/`,开启简易模式并不会去删除它(避免误删你的数据)。简易模式只保证此后不再创建、不再写入。需要的话你可以手动删除该文件夹。 +旧工作区已有的 `.inkstream/` 文件夹会保留;应用使用新的本机项目索引,不自动删除旧目录或其中的未知文件。 ← 返回 [功能文档总览](./index.md) diff --git a/docs/specs/AUTOMATION.md b/docs/specs/AUTOMATION.md new file mode 100644 index 0000000..8e40d57 --- /dev/null +++ b/docs/specs/AUTOMATION.md @@ -0,0 +1,67 @@ +# BDD 自动化绑定状态 + +本目录的 `.feature` 是用户行为规格。`pnpm test:acceptance` 通过[人工映射](../../scripts/acceptance/bindings.mjs)运行现有 Vitest 检查,**不会解释或执行 Gherkin**。Scenario Outline 的一行通过不代表其余 Examples 已通过。 + +```text +pnpm test:acceptance +``` + +命令只选择映射中的具体测试名,串行运行,测试进程硬上限 300 秒;盘点/终止辅助命令各有 10 秒上限。检查测试文件与场景名称是否仍存在,并解析当次新生成的 Vitest JSON:每个选择项必须恰好出现一次且状态为 `passed`;报告缺失、测试未发现、skip/todo、失败、超时或清理未确认都不会被当作通过。未绑定场景不因同文件其它测试绿色而改变状态。 + +当前命令选择五个文件中的 24 项检查:DocumentSession、WorkspaceSession、文件身份迁移、Wiki 实际导航,以及一项同名候选补全检查。既有完整 `pnpm test` 与 CI 的完整 Vitest 步骤保留,不按这份有限映射排除任何测试;CI 不重复运行同一批 Vitest,只新增原生 Rust 验证。 + +这些集成测试使用真实应用模块、状态、CodeMirror 或工作区流程;边界替身按各测试声明使用。它们不能代替原生 WebView/IME、真实磁盘故障、OS watcher 或外部服务验收。运行后只报告“选择的检查通过”,`bddCompletion` 固定为 `false`;全部 BDD 完成仍须逐项补齐下表。 + +## 场景与剩余工作 + +`partial` 表示部分断言已有检查关联;`pending` 表示完整场景尚未绑定到本命令。两者均不是“场景通过”。最新具体测试名及剩余断言以人工映射中的 `checks` / `gap` 字段为准。 + +| 编号 | 规格场景 | 状态 | 尚缺的关键证据 | +|---|---|---|---| +| DOC-01 | 关闭活动 A 后继续编辑的是 B | partial | 补充输入后保存、A 文件不变及真实落盘 | +| DOC-02 | 未成功保存时关闭请求保留正文 | partial | 冲突 Examples 行和明确反馈断言 | +| DOC-03 | 保留我的内容写入失败后仍可继续裁决冲突 | partial | 再次裁决、失败后自动保存约束贯通 | +| DOC-04 | v1 保存期间产生的 v2 仍需保存 | partial | 退出提示及真实磁盘失败 | +| DOC-05 | 干净后台文档外部更新后显示新版本 | partial | 真实 watcher/文件系统往返 | +| DOC-06 | 后台文档有未保存修改时外部更新不得静默覆盖 | pending | 完整后台脏文档场景 | +| DOC-07 | 文件改名或移动后继续保存到新的位置 | partial | 单文件移动 Examples、编辑撤销历史、各行最终落盘 | +| DOC-08 | 删除已保存文档后自动保存不复活旧路径 | pending | “全部已保存”前置与文件树断言;已有脏文档删除回归不替代 | +| DOC-09 | 选择同名链接候选后保留所选文件身份 | partial | 将补全产物与实际点击串在一次场景中 | +| DOC-10 | 双向链接定位到标题或文本块 | partial | 原生滚动可见性与裸 `^block` 的实际手势示例 | +| WS-01 | 连续选择工作区后迟到结果不得覆盖最终选择 | partial | 旧文档保存及迟到搜索结果组合 | +| WS-02 | 切换失败后原工作区继续接收外部修改 | partial | 真实外部事件抵达原编辑缓冲 | +| WS-03 | 旧工作区搜索结果不能用于新工作区替换 | pending | 搜索命中到替换文件的贯通绑定 | +| WS-04 | 经简易模式切库后索引仍按工作区隔离 | pending | 两库真实索引写入与查询 | +| WS-05 | 简易模式打开新工作区不创建索引目录 | pending | 真实磁盘目录未创建 | +| WS-06 | 禁用当前视图的能力后回到原文档 | pending | 五行 Examples 带正文与继续编辑的完整绑定 | +| WS-07 | 索引提交后关系面板反映新增链接 | pending | 同次提交到反链与局部图谱的贯通绑定 | +| WS-08 | 关系查询失败不伪装成没有关系 | pending | 已有面板/索引测试尚待审定完整场景绑定 | + +Save As、删除脏文档、目录移动撤销、旧路径在途写、文件操作失败、Wiki 歧义与创建取消等选择项还提供补充回归证据。没有对应完整 Gherkin 场景时,不借此标记其它场景完成。 + +## Unix 权限:负控与生产断言分开 + +CI 保留 `cargo check`,并在 Windows、Linux、macOS 实际执行: + +```text +cargo test --locked --manifest-path src-tauri/Cargo.toml --lib -- --test-threads=1 +``` + +[原生 runner](../../scripts/acceptance/run-rust.mjs)给该命令 20 分钟硬上限,并在 Unix 输出中要求观察到正式 `files::tests::write_file_atomic_preserves_existing_unix_permissions` 的通过记录。该正式测试保持原有断言,验证生产 `files.rs` 的写入结果;Windows 的 `cfg(unix)` 排除明确记为未执行,不算权限验证。 + +[独立负控](../../scripts/acceptance/unix-permission-negative.rs)只用 Rust 标准库,在本次新建的临时目录重现旧 `File::create → write → sync → rename` 成功写入策略。它不运行旧应用二进制、不包含旧应用其它组件,也不替换正式测试。它记录 `0600` 与 `0751` 的目标权限和实际权限;至少一个权限谓词被违反时输出确认标记并以专用退出码 `42` 退出。 + +[负控 runner](../../scripts/acceptance/run-unix-negative.mjs)仅把“退出码 42 + 确认标记”分类为预期负控;编译失败、其它退出码、超时或未观察到差异均失败。编译上限 90 秒,执行上限 10 秒;编译产物及案例文件都在本次专属临时目录,结束清理。Windows 不运行此 Unix fixture。即使独立负控失败,未被取消的 CI 仍尝试正式 Rust 测试,不把负控的预期失败包装成正式测试通过。 + +## 证据与进程责任 + +每次命令创建新的 `coverage/acceptance/<类型>-<平台>-<时间>-/`,不存在复用旧 JSON 假绿的路径。目录位于既有 `coverage/` 忽略范围,CI 通过已锁 SHA 的官方 upload-artifact 上传;不改发布 workflow。 + +- `*-process.json`:命令、参数、PID/父 PID、工作目录、起止时间、超时与清理结果;启动时先写一份记录。 +- `*-before/after.json`:OS 进程盘点,包含盘点探针自身命令/PID/时间/10 秒上限。OS 没提供的工作目录保留为 unknown,不臆测。 +- `*-idle-*.json`:连续 60 秒无输出时的进程诊断。 +- `*-after-cleanup.json`:发现属于本次进程组/可追溯父子链的残留后,记录清理复查;既有或归属不明进程不终止。 +- `vitest.json` 与 `acceptance-summary.json`:原始测试结果及逐项匹配,保留 partial/pending 状态。 +- `rust-summary.json` 与 `negative-control-summary.json`:生产 Rust 结果和隔离旧策略负控各自的证据边界。 + +正常退出、失败、超时及中断均检查可观察的所属进程树;Windows 用明确 PID 的 `taskkill /T`,Unix 用该次新建的进程组。主动脱离父子链/进程组的未知进程不被推定为本次所有,相关限制写入记录。此设施尚需主 Agent 实际运行审查;文件存在不构成运行通过声明。 diff --git a/docs/specs/document-session.feature b/docs/specs/document-session.feature new file mode 100644 index 0000000..61c538d --- /dev/null +++ b/docs/specs/document-session.feature @@ -0,0 +1,109 @@ +# 旧 v1 功能修复的行为规格;部分检查经人工映射接入 pnpm test:acceptance,详见 AUTOMATION.md。 +# .feature 不由 runner 解释;关联检查通过不代表全部步骤已绑定或通过。 +# 不包含新项目实体、跨重启会话恢复或 UI 材质实现。 +@specification @partially_bound @manual_vitest_mapping @document_session +Feature: 文档身份与保存结果保持一致 + 作为在多个文档间写作的用户 + 我需要标签、正文与保存位置始终对应同一文档 + 并在保存失败或出现外部冲突时保留未保存内容 + + @issue_2 + Scenario: 关闭活动 A 后继续编辑的是 B + Given 已打开的 "A.md" 正文是 "甲文原稿" + And 已打开的 "B.md" 正文是 "乙文原稿" + And 我正在编辑 "A.md" + When 我关闭 "A.md" + Then 活动标签应为 "B.md" 且正文应为 "乙文原稿" + When 我在正文末尾输入 "补充" 并保存 + Then 磁盘上的 "B.md" 应为 "乙文原稿补充" + And 磁盘上的 "A.md" 应保持 "甲文原稿" + + @issue_3 + Scenario Outline: 未成功保存时关闭请求保留正文 + Given "A.md" 有未保存正文 "必须保留的修改" + And "A.md" 的保存状态为 "<状态>" + When 我请求关闭 "A.md" 且没有确认丢弃修改 + Then "A.md" 的标签和 "必须保留的修改" 应仍然可访问 + And "A.md" 应仍显示未保存状态 + And 应显示 "<反馈>" + Examples: + | 状态 | 反馈 | + | 磁盘拒绝写入 | 保存失败 | + | 外部冲突尚未解决 | 需要先解决冲突或选择保留方式 | + + @issue_3 + Scenario: 保留我的内容写入失败后仍可继续裁决冲突 + Given "A.md" 存在未保存修改与外部修改冲突 + When 我选择保留我的内容并确认覆盖磁盘 + And 本次磁盘写入失败 + Then 冲突提示和未保存正文应保留 + And 未再次成功完成裁决前不得自动覆盖外部内容 + + @issue_3 + Scenario: v1 保存期间产生的 v2 仍需保存 + Given 我关闭了自动保存并将 "A.md" 改为 "v1" + When 我保存 "v1" 且磁盘写入尚未完成 + And 我继续把正文改为 "v2" + And "v1" 的磁盘写入完成 + Then 编辑器正文应为 "v2" 且仍显示未保存 + And 我退出应用时应收到未保存内容提示 + When 我再次保存且写入成功 + Then 磁盘正文应为 "v2" 且未保存标记才可消失 + + @issue_4 + Scenario: 干净后台文档外部更新后显示新版本 + Given 后台标签 "A.md" 与磁盘正文均为 "旧版" + And 我正在编辑另一个文档 + When 其他程序将 "A.md" 改为 "新版" + And 我切回 "A.md" + Then 正文应为 "新版" + When 我不作编辑便保存或关闭 "A.md" + Then 磁盘正文仍应为 "新版" + + @issue_4 + Scenario: 后台文档有未保存修改时外部更新不得静默覆盖 + Given 后台标签 "A.md" 有未保存正文 "我的修改" + When 其他程序将 "A.md" 改为 "外部修改" + And 我切回 "A.md" + Then "我的修改" 应保留并显示外部冲突提示 + And 裁决前磁盘应保持 "外部修改" + + @issue_5 + Scenario Outline: 文件改名或移动后继续保存到新的位置 + Given 我打开了 "初稿/A.md" 并有未保存正文 "修订稿" + When 我通过文件树将 "初稿/A.md" <操作> + Then 标签应显示 "<目标>" 对应的文档 + And 正文 "修订稿" 及其撤销历史应保留 + When 我保存该文档 + Then "<目标>" 的磁盘正文应为 "修订稿" + And "初稿/A.md" 不得因保存而重新出现 + Examples: + | 操作 | 目标 | + | 重命名为 B.md | 初稿/B.md | + | 移动到 定稿 文件夹 | 定稿/A.md | + | 随父目录初稿移动到归档下 | 归档/初稿/A.md | + + @issue_5 + Scenario: 删除已保存文档后自动保存不复活旧路径 + Given "A.md" 已打开且全部修改已保存 + When 我在文件树确认将 "A.md" 移到回收站 + Then 工作区文件树不应再列出 "A.md" + And 后续自动保存不得重新创建该文件 + And 编辑区不得将已删除路径显示为可正常保存的文档 + + @issue_23 + Scenario: 选择同名链接候选后保留所选文件身份 + Given 工作区有 "研究/方法.md" 和 "随笔/方法.md" + When 我在双向链接补全中选择 "研究/方法.md" + And 我点击生成的链接 + Then 应打开 "研究/方法.md" 的正文 + + @issue_23 + Scenario Outline: 双向链接定位到标题或文本块 + Given "方法.md" 含标题 "实验设计" 和标识为 "p3" 的文本块 + When 我点击 "<链接>" + Then 应打开 "方法.md" 并将 "<位置>" 显示在可见区域 + Examples: + | 链接 | 位置 | + | [[方法#实验设计]] | 标题实验设计 | + | [[方法^p3]] | 标识为 p3 的文本块 | diff --git a/docs/specs/workspace-session.feature b/docs/specs/workspace-session.feature new file mode 100644 index 0000000..a48cebf --- /dev/null +++ b/docs/specs/workspace-session.feature @@ -0,0 +1,81 @@ +# 旧 v1 功能修复的行为规格;部分检查经人工映射接入 pnpm test:acceptance,详见 AUTOMATION.md。 +# .feature 不由 runner 解释;尚未绑定的场景继续标记待绑定。 +# 串行切换或拒绝过期结果均可;以下只规定用户可见结果,不规定调度实现。 +# 新项目实体与跨重启会话恢复须在旧 v1 验收后另立规格。 +@specification @partially_bound @manual_vitest_mapping @workspace_session +Feature: 工作区切换保持文档与派生信息所属范围一致 + 作为在多个目录之间写作的用户 + 我需要当前工作区的文件、搜索和关系信息保持一致 + 并在切换失败或功能关闭后继续访问我的正文 + + @issue_6 + Scenario: 连续选择工作区后迟到结果不得覆盖最终选择 + Given 当前工作区为 "A" 且打开了其中的 "正文.md" + When 我先请求打开 "B" 再请求打开 "C" + And "C" 已成为当前工作区 + And 先前属于 "B" 的文件或搜索结果迟到 + Then 当前工作区、文件树与搜索范围应仍属于 "C" + And 原 "A/正文.md" 的标签仍应指向其真实文件 + And 保存该标签不得改写 "C/正文.md" + + @issue_6 + Scenario: 切换失败后原工作区继续接收外部修改 + Given 当前工作区为 "A" 且活动文档 "正文.md" 已保存 + When 我尝试打开一个已不存在的工作区 "B" + Then 应提示打开失败并保留工作区 "A" 及其正文 + When 其他程序将 "A/正文.md" 改为 "外部新版" + Then 编辑器应显示 "外部新版" 并提示已重载 + + @issue_6 + Scenario: 旧工作区搜索结果不能用于新工作区替换 + Given 我在工作区 "A" 搜索 "研究方法" 并看到 "笔记.md" 的命中 + When 我切换到工作区 "B" + Then 旧搜索结果不得作为 "B" 的可编辑命中继续呈现 + And 对旧结果发出的替换不得改动 "B/笔记.md" + + @issue_7 + Scenario: 经简易模式切库后索引仍按工作区隔离 + Given 完整模式下工作区 "A" 已有自己的搜索索引 + When 我开启简易模式并切换到工作区 "B" + And 我关闭简易模式 + And 我在 "B/笔记.md" 写入 "仅属于乙库" 并保存 + And 工作区 "B" 的索引更新完成 + Then 在 "B" 搜索 "仅属于乙库" 应找到 "笔记.md" + When 我切回 "A" 并搜索 "仅属于乙库" + Then 不应返回来自 "B" 的正文或链接 + + @issue_7 + Scenario: 简易模式打开新工作区不创建索引目录 + Given 简易模式已开启且新工作区 "B" 没有索引目录 + When 我打开 "B" 并编辑保存 "笔记.md" + Then 正文应正常保存 + And "B" 中不应出现新建的 ".inkstream" 索引目录 + + @issue_8 + Scenario Outline: 禁用当前视图的能力后回到原文档 + Given 我从正文 "继续写作" 打开了 "<视图>" + When 我执行 "<操作>" + Then 中央区应回到正文 "继续写作" 且可以继续编辑 + And 不得只剩空白中央区 + Examples: + | 视图 | 操作 | + | 知识图谱 | 开启简易模式 | + | Git Graph | 开启简易模式 | + | 全库搜索 | 开启简易模式 | + | 冲突解决 | 开启简易模式 | + | 书架 | 关闭书架功能 | + + @issue_24 + Scenario: 索引提交后关系面板反映新增链接 + Given 我正在查看 "目标.md" 的反链与局部图谱 + When 工作区内 "来源.md" 新增指向 "目标.md" 的链接并保存 + And 对应索引更新已完成 + Then 不切换当前文档也应在反链中看到 "来源.md" + And 局部图谱应显示这两个文档之间的连接 + + @issue_24 + Scenario: 关系查询失败不伪装成没有关系 + Given "目标.md" 已有来自 "来源.md" 的反链 + When 我打开关系面板但索引查询失败 + Then 应显示关系数据暂不可用或读取失败 + And 不得将失败显示为已确认的 "暂无反向链接" diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 0000000..e1e5dcd --- /dev/null +++ b/docs/themes.md @@ -0,0 +1,7 @@ +# 导入主题 + +在“设置 → 外观”选择本地 `.css` 主题文件。应用先展示亮色、暗色预览和兼容报告,点击“应用主题”后才保存并改变外观。“取消预览”不改变当前主题;“恢复内置主题”删除导入配置的生效状态。 + +兼容范围是 `:root`、`body`、`.theme-light`、`.theme-dark` 中的通用颜色、语法颜色和本机字体变量,包括变量引用、优先级及亮暗媒体条件。专用于 Obsidian DOM、插件、外部字体或网络资源的规则不进入 InkStream;报告会列出被跳过的规则。原始主题保存在应用配置目录,项目文件夹不会新增主题配置。 + +实现参考 [Obsidian 变量说明](https://docs.obsidian.md/Reference/CSS%20variables/About%20styling)。主题由独立、未挂载的 CSSStyleSheet 解析,只有校验后的兼容变量映射到工作台。 diff --git a/index.html b/index.html index 9dcc905..9c1c1f0 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ InkStream / 墨流 + \n\n[[仍是正文]]"; + assert_eq!(targets(markdown), vec!["正文", "仍是正文"]); +} + +#[test] +fn wiki_link_targets_keep_nfc_and_existing_heading_alias_fields() { + let refs = extract_wiki_links("[[Cafe\u{301}#章节^block|别名]] [[中文目录/页]]"); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].target, "Café"); + assert_eq!(refs[0].heading.as_deref(), Some("章节")); + assert_eq!(refs[0].block.as_deref(), Some("block")); + assert_eq!(refs[0].alias.as_deref(), Some("别名")); +} + +#[test] +fn local_equation_anchors_do_not_create_file_edges() { + let refs = extract_wiki_links("[[#eq:energy]] [[#章节]] [[#^block]] [[paper#章节^block|原文]]"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].target, "paper"); + assert_eq!(refs[0].heading.as_deref(), Some("章节")); + assert_eq!(refs[0].block.as_deref(), Some("block")); + assert_eq!(refs[0].alias.as_deref(), Some("原文")); +} diff --git a/src-tauri/src/index_links.rs b/src-tauri/src/index_links.rs new file mode 100644 index 0000000..798450b --- /dev/null +++ b/src-tauri/src/index_links.rs @@ -0,0 +1,45 @@ +use pulldown_cmark::{Event, LinkType, Options, Parser, Tag}; +use unicode_normalization::UnicodeNormalization; + +pub(super) struct WikiRef { + pub target: String, + pub heading: Option, + pub block: Option, + pub alias: Option, +} + +/// 只接受 Markdown parser 识别出的 wiki-link,代码、转义及 HTML 注释不形成链接。 +pub(super) fn extract_wiki_links(content: &str) -> Vec { + let options = Options::ENABLE_WIKILINKS | Options::ENABLE_TABLES + | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS | Options::ENABLE_MATH; + Parser::new_ext(content, options).into_offset_iter().filter_map(|(event, range)| { + match event { + Event::Start(Tag::Link { link_type: LinkType::WikiLink { .. }, .. }) + | Event::Start(Tag::Image { link_type: LinkType::WikiLink { .. }, .. }) => {} + _ => return None, + } + let raw = content.get(range)?; + let raw = raw.strip_prefix('!').unwrap_or(raw); + let inner = raw.strip_prefix("[[")?.strip_suffix("]]")?; + parse_wiki_ref(inner) + }).collect() +} + +fn parse_wiki_ref(inner: &str) -> Option { + if inner.contains(['\n', '\r']) { return None; } + let (spec, alias) = match inner.find('|') { + Some(p) => (&inner[..p], Some(inner[p + 1..].trim().to_string())), + None => (inner, None), + }; + let hash = spec.find('#'); + let caret = spec.find('^'); + let target_end = [hash, caret].iter().filter_map(|x| *x).min().unwrap_or(spec.len()); + let target: String = spec[..target_end].trim().nfc().collect(); + if target.is_empty() { return None; } + let heading = hash.map(|h| { + let end = caret.filter(|c| *c > h).unwrap_or(spec.len()); + spec[h + 1..end].trim().nfc().collect() + }); + let block = caret.map(|c| spec[c + 1..].trim().nfc().collect()); + Some(WikiRef { target, heading, block, alias }) +} diff --git a/src-tauri/src/index_refresh_tests.rs b/src-tauri/src/index_refresh_tests.rs new file mode 100644 index 0000000..f9ea82c --- /dev/null +++ b/src-tauri/src/index_refresh_tests.rs @@ -0,0 +1,363 @@ +use super::worker::{IndexState, Operation, Scope}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::SqlitePool; +use std::path::PathBuf; + +struct Vault(PathBuf); +impl Vault { + fn new() -> Self { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "inkstream-index-refresh-{}-{nonce}", + std::process::id() + )); + let root = path.join("content"); + std::fs::create_dir_all(&root).unwrap(); + Self(root.canonicalize().unwrap()) + } + fn scope(&self, id: &str) -> Scope { + Scope::new(self.0.to_string_lossy().into_owned(), id.into()).unwrap() + } + async fn pool(&self) -> SqlitePool { + SqlitePoolOptions::new() + .max_connections(1) + .connect_with( + SqliteConnectOptions::new() + .filename(self.0.parent().unwrap().join("app-data/indexes/fixture-project/index.db")) + .busy_timeout(std::time::Duration::from_secs(2)), + ) + .await + .unwrap() + } +} +impl Drop for Vault { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(self.0.parent().unwrap()); + } +} +async fn body(pool: &SqlitePool, path: &str) -> Option { + sqlx::query_scalar("SELECT content FROM files WHERE path=?") + .bind(path) + .fetch_optional(pool) + .await + .unwrap() +} +async fn stop(actor: &IndexState, scope: Scope, pool: SqlitePool) { + actor.submit(scope, Operation::Stop).await.unwrap(); + pool.close().await; +} + +#[test] +fn refresh_reads_the_saved_file_and_only_confirms_after_sql_commit() { + tauri::async_runtime::block_on(async { + let vault = Vault::new(); + let actor = IndexState::start(); + let scope = vault.scope("saved-file"); + actor + .submit(scope.clone(), Operation::Prepare { rebuild: false }) + .await + .unwrap(); + actor + .submit( + scope.clone(), + Operation::Upsert { + path: "other.md".into(), + content: "previous unrelated snapshot".into(), + }, + ) + .await + .unwrap(); + std::fs::write(vault.0.join("other.md"), "different unrelated disk text").unwrap(); + let text = format!("# 已落盘\n\n{}TAIL", "中文🙂\n".repeat(150_000)); + std::fs::write(vault.0.join("中文.md"), &text).unwrap(); + let result = actor + .submit( + scope.clone(), + Operation::Refresh { + path: "中文.md".into(), + }, + ) + .await; + let pool = vault.pool().await; + let indexed = body(&pool, "中文.md").await; + let unrelated = body(&pool, "other.md").await; + stop(&actor, scope, pool).await; + assert!(result.is_ok(), "{result:?}"); + assert!( + indexed.as_deref() == Some(text.as_str()), + "receipt preceded the complete target row" + ); + assert_eq!(unrelated.as_deref(), Some("previous unrelated snapshot")); + }); +} + +#[test] +fn rebuild_and_incremental_refresh_cover_the_same_markdown_extensions() { + tauri::async_runtime::block_on(async { + let vault = Vault::new(); + let actor = IndexState::start(); + let scope = vault.scope("markdown-family"); + for path in ["lower.md", "UPPER.MD", "long.Markdown"] { + std::fs::write(vault.0.join(path), "第一版 [[研究]]").unwrap(); + } + std::fs::write(vault.0.join("plain.txt"), "不进Markdown索引").unwrap(); + actor.submit(scope.clone(), Operation::Prepare { rebuild: true }).await.unwrap(); + let pool = vault.pool().await; + let names: Vec = sqlx::query_scalar("SELECT path FROM files ORDER BY path").fetch_all(&pool).await.unwrap(); + assert_eq!(names, vec!["UPPER.MD", "long.Markdown", "lower.md"]); + std::fs::write(vault.0.join("long.Markdown"), "保存更新后的正文").unwrap(); + actor.submit(scope.clone(), Operation::Refresh { path: "long.Markdown".into() }).await.unwrap(); + assert_eq!(body(&pool, "long.Markdown").await.as_deref(), Some("保存更新后的正文")); + actor.submit(scope.clone(), Operation::Remove { path: "UPPER.MD".into() }).await.unwrap(); + assert_eq!(body(&pool, "UPPER.MD").await, None); + stop(&actor, scope, pool).await; + }); +} + +#[test] +fn missing_and_invalid_utf8_files_fail_without_replacing_the_last_committed_row() { + tauri::async_runtime::block_on(async { + let vault = Vault::new(); + let actor = IndexState::start(); + let scope = vault.scope("failed-read"); + actor + .submit(scope.clone(), Operation::Prepare { rebuild: false }) + .await + .unwrap(); + actor + .submit( + scope.clone(), + Operation::Upsert { + path: "note.md".into(), + content: "last complete row".into(), + }, + ) + .await + .unwrap(); + std::fs::write(vault.0.join("note.md"), "last complete row").unwrap(); + std::fs::remove_file(vault.0.join("note.md")).unwrap(); + let missing = actor + .submit( + scope.clone(), + Operation::Refresh { + path: "note.md".into(), + }, + ) + .await; + std::fs::write(vault.0.join("note.md"), [255, 254]).unwrap(); + let invalid = actor + .submit( + scope.clone(), + Operation::Refresh { + path: "note.md".into(), + }, + ) + .await; + let pool = vault.pool().await; + let indexed = body(&pool, "note.md").await; + stop(&actor, scope, pool).await; + assert!( + missing.is_err(), + "missing file must not be treated as an empty success" + ); + assert!( + invalid.is_err(), + "invalid UTF-8 must not be silently skipped" + ); + assert_eq!(indexed.as_deref(), Some("last complete row")); + }); +} + +#[test] +fn retired_scope_cannot_refresh_its_path_into_the_current_vault() { + tauri::async_runtime::block_on(async { + let a = Vault::new(); + let b = Vault::new(); + let actor = IndexState::start(); + let sa = a.scope("refresh-a"); + let sb = b.scope("refresh-b"); + std::fs::write(a.0.join("same.md"), "A disk").unwrap(); + std::fs::write(b.0.join("same.md"), "B disk").unwrap(); + actor + .submit(sa.clone(), Operation::Prepare { rebuild: false }) + .await + .unwrap(); + actor + .submit( + sa.clone(), + Operation::Refresh { + path: "same.md".into(), + }, + ) + .await + .unwrap(); + actor + .submit(sb.clone(), Operation::Prepare { rebuild: false }) + .await + .unwrap(); + std::fs::write(a.0.join("same.md"), "late A disk").unwrap(); + let late = actor + .submit( + sa, + Operation::Refresh { + path: "same.md".into(), + }, + ) + .await; + actor + .submit( + sb.clone(), + Operation::Refresh { + path: "same.md".into(), + }, + ) + .await + .unwrap(); + let pa = a.pool().await; + let pb = b.pool().await; + let row_a = body(&pa, "same.md").await; + let row_b = body(&pb, "same.md").await; + stop(&actor, sb, pb).await; + pa.close().await; + assert!(late.is_err()); + assert_eq!(row_a.as_deref(), Some("A disk")); + assert_eq!(row_b.as_deref(), Some("B disk")); + }); +} + +#[test] +fn refresh_rejects_non_relative_paths_and_non_files() { + tauri::async_runtime::block_on(async { + let vault = Vault::new(); + let outside = Vault::new(); + let actor = IndexState::start(); + let scope = vault.scope("refresh-path-guards"); + actor + .submit(scope.clone(), Operation::Prepare { rebuild: false }) + .await + .unwrap(); + std::fs::write(outside.0.join("outside.md"), "outside content").unwrap(); + std::fs::create_dir(vault.0.join("directory.md")).unwrap(); + let mut results = Vec::new(); + for path in [ + "../escape.md".into(), + outside.0.join("outside.md").to_string_lossy().into_owned(), + "directory.md".into(), + ] { + results.push( + actor + .submit(scope.clone(), Operation::Refresh { path }) + .await, + ); + } + let pool = vault.pool().await; + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM files") + .fetch_one(&pool) + .await + .unwrap(); + stop(&actor, scope, pool).await; + assert!(results.iter().all(Result::is_err)); + assert_eq!(count, 0); + }); +} + +#[test] +fn oversized_saved_file_is_an_explicit_error_instead_of_a_silent_skip() { + tauri::async_runtime::block_on(async { + let vault = Vault::new(); + let actor = IndexState::start(); + let scope = vault.scope("refresh-size-guard"); + actor + .submit(scope.clone(), Operation::Prepare { rebuild: false }) + .await + .unwrap(); + let file = std::fs::File::create(vault.0.join("large.md")).unwrap(); + file.set_len(100 * 1024 * 1024 + 1).unwrap(); + drop(file); + let result = actor + .submit( + scope.clone(), + Operation::Refresh { + path: "large.md".into(), + }, + ) + .await; + let pool = vault.pool().await; + let indexed = body(&pool, "large.md").await; + stop(&actor, scope, pool).await; + assert!(result.unwrap_err().contains("100MiB")); + assert!(indexed.is_none()); + }); +} + +#[test] +fn refresh_sql_failure_returns_error_and_preserves_the_previous_commit() { + tauri::async_runtime::block_on(async { + let vault = Vault::new(); + let actor = IndexState::start(); + let scope = vault.scope("refresh-sql-error"); + actor + .submit(scope.clone(), Operation::Prepare { rebuild: false }) + .await + .unwrap(); + actor + .submit( + scope.clone(), + Operation::Upsert { + path: "note.md".into(), + content: "previous commit".into(), + }, + ) + .await + .unwrap(); + std::fs::write(vault.0.join("note.md"), "new disk text").unwrap(); + let pool = vault.pool().await; + sqlx::query("CREATE TRIGGER reject_refresh BEFORE INSERT ON files BEGIN SELECT RAISE(FAIL,'fixture write denied'); END") + .execute(&pool).await.unwrap(); + let result = actor + .submit( + scope.clone(), + Operation::Refresh { + path: "note.md".into(), + }, + ) + .await; + let indexed = body(&pool, "note.md").await; + stop(&actor, scope, pool).await; + assert!(result.is_err()); + assert_eq!(indexed.as_deref(), Some("previous commit")); + }); +} + +#[cfg(unix)] +#[test] +fn refresh_does_not_follow_a_symlink_outside_the_owned_vault() { + tauri::async_runtime::block_on(async { + let vault = Vault::new(); + let outside = Vault::new(); + let actor = IndexState::start(); + let scope = vault.scope("refresh-symlink"); + actor + .submit(scope.clone(), Operation::Prepare { rebuild: false }) + .await + .unwrap(); + std::fs::write(outside.0.join("outside.md"), "not owned").unwrap(); + std::os::unix::fs::symlink(outside.0.join("outside.md"), vault.0.join("link.md")).unwrap(); + let result = actor + .submit( + scope.clone(), + Operation::Refresh { + path: "link.md".into(), + }, + ) + .await; + let pool = vault.pool().await; + let indexed = body(&pool, "link.md").await; + stop(&actor, scope, pool).await; + assert!(result.is_err()); + assert!(indexed.is_none()); + }); +} diff --git a/src-tauri/src/index_schema.sql b/src-tauri/src/index_schema.sql index 15f957c..e28d547 100644 --- a/src-tauri/src/index_schema.sql +++ b/src-tauri/src/index_schema.sql @@ -1,12 +1,13 @@ -- Phase 4 W1 FTS5 索引 schema(index.rs 经 include_str! 内嵌,首次开库以 sqlx::raw_sql 执行多语句)。 -- 全部 CREATE IF NOT EXISTS / INSERT OR IGNORE,幂等可重复执行。schema 演进经 index_meta.schema_version 判定。 --- 该库位于 /.inkstream/index.db,连同 -wal/-shm 由 .inkstream/.gitignore('*') 整目录忽略,不入用户 git。 +-- Native ProjectRepository resolves app_data_dir/indexes//index.db. +-- Legacy user-folder .inkstream directories are neither opened nor modified by this schema. -- 文件元数据 + 原文。content 既是 external-content FTS 的真相源(省一半磁盘:FTS 不另存正文副本), -- 也供后续反链解析 / 断链 lint / 高亮回显复用。rowid 显式整数主键,供 files_fts 的 content_rowid 关联。 CREATE TABLE IF NOT EXISTS files ( rowid INTEGER PRIMARY KEY, - path TEXT NOT NULL UNIQUE, -- NFC 规范化的 vault 相对路径('/' 分隔,跨平台键统一) + path TEXT NOT NULL UNIQUE, -- 保留真实 Unicode 身份的 vault 相对路径('/' 分隔) content TEXT NOT NULL DEFAULT '', mtime INTEGER NOT NULL DEFAULT 0, -- 文件 mtime(秒),增量判脏 size INTEGER NOT NULL DEFAULT 0, @@ -43,7 +44,7 @@ END; -- idx_links_resolved 支撑「谁引用我」反链查询;target_resolved IS NULL = 断链(lint);idx_links_raw 供重命名重解析。 CREATE TABLE IF NOT EXISTS links ( id INTEGER PRIMARY KEY, - source_path TEXT NOT NULL, -- 引用方文件(NFC 相对路径),随 source 重索引整体替换 + source_path TEXT NOT NULL, -- 引用方真实相对路径,随 source 重索引整体替换 target_raw TEXT NOT NULL, -- 原始目标内核([[A/B|别名#标题^块]] 的 'A/B') target_resolved TEXT, -- 解析到的真实文件 path;NULL = 断链 alias TEXT, diff --git a/src-tauri/src/index_storage.rs b/src-tauri/src/index_storage.rs new file mode 100644 index 0000000..3692561 --- /dev/null +++ b/src-tauri/src/index_storage.rs @@ -0,0 +1,47 @@ +use super::worker::Scope; +use crate::projects::ProjectRepository; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexLocation { pub project_id: String, pub database_url: String } + +#[derive(Clone, Debug)] +pub(super) struct Storage { pub project_id: String, pub directory: PathBuf } +pub(super) type Resolver = Arc Result + Send + Sync>; + +impl Storage { + pub(super) fn project(repository: &ProjectRepository, scope: &Scope) -> Result { + let root = scope.root.to_str().ok_or("项目目录不是有效 UTF-8 路径。")?; + let project_id = repository.project_id_for_root(root)?.ok_or("项目尚未登记;索引不会写入用户目录。")?; + if scope.project_id.as_ref().is_some_and(|expected| expected != &project_id) { + return Err("项目身份与当前目录不一致,索引未打开。".into()); + } + let directory = repository.index_directory(root)?; + // The catalog can change between the two repository calls; never combine two identities. + if directory.file_name().and_then(|value| value.to_str()) != Some(project_id.as_str()) { + return Err("项目目录绑定在解析期间发生变化,请重试。".into()); + } + Ok(Self { project_id, directory }) + } + pub(super) fn location(&self) -> Result { + Ok(IndexLocation { project_id: self.project_id.clone(), database_url: database_url(&self.directory.join("index.db"))? }) + } +} + +fn database_url(path: &Path) -> Result { + if !path.is_absolute() { return Err("索引数据库必须位于原生解析的应用数据目录。".into()); } + let path = path.to_str().ok_or("索引路径不是有效 UTF-8。")?; + let path = if let Some(unc) = path.strip_prefix("\\\\?\\UNC\\") { format!("//{unc}") } + else { path.strip_prefix("\\\\?\\").unwrap_or(path).to_owned() }; + Ok(format!("sqlite:{}", path.replace('\\', "/"))) +} + +#[cfg(test)] +pub(super) fn fixture(scope: &Scope) -> Result { + // Existing actor tests inject a private fixture app-data sibling, never a production fallback. + let base = scope.root.parent().ok_or("fixture has no owner directory")?; + Ok(Storage { project_id: "fixture-project".into(), directory: base.join("app-data/indexes/fixture-project") }) +} diff --git a/src-tauri/src/index_storage_tests.rs b/src-tauri/src/index_storage_tests.rs new file mode 100644 index 0000000..7ce7791 --- /dev/null +++ b/src-tauri/src/index_storage_tests.rs @@ -0,0 +1,122 @@ +use super::{storage::Storage, worker::{IndexState, Operation, Scope}}; +use crate::projects::ProjectRepository; +use sqlx::sqlite::SqlitePoolOptions; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +const A: &str = "00000000-0000-4000-8000-00000000000a"; +const B: &str = "00000000-0000-4000-8000-00000000000b"; +struct Fixture { base: PathBuf, data: PathBuf, a: PathBuf, b: PathBuf, repository: Arc } +impl Fixture { + fn new() -> Self { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let base = std::env::temp_dir().join(format!("inkstream-index-storage-{}-{nonce}", std::process::id())); + let data = base.join("application data 中文"); let a = base.join("project A"); let b = base.join("project B"); + std::fs::create_dir_all(&a).unwrap(); std::fs::create_dir_all(&b).unwrap(); + let repository = Arc::new(ProjectRepository::new(data.clone())); + repository.register(A.into(), a.to_string_lossy().into_owned(), "A".into()).unwrap(); + repository.register(B.into(), b.to_string_lossy().into_owned(), "B".into()).unwrap(); + Self { base, data, a, b, repository } + } + fn actor(&self) -> IndexState { + let repository = self.repository.clone(); + IndexState::start_with_resolver(Arc::new(move |scope| Storage::project(&repository, scope))) + } + fn scope(&self, root: &Path, project: &str, session: &str) -> Scope { + Scope::new(root.to_string_lossy().into_owned(), session.into()).unwrap().with_project(Some(project.into())) + } +} +impl Drop for Fixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.base); } } + +async fn text(url: &str, path: &str) -> Option { + // The same authoritative URL consumed by the frontend must open the actual writer database. + let pool = SqlitePoolOptions::new().max_connections(1).connect(url).await.unwrap(); + let value = sqlx::query_scalar("SELECT content FROM files WHERE path=?").bind(path).fetch_optional(&pool).await.unwrap(); + pool.close().await; value +} + +#[test] +fn app_data_indexes_are_project_isolated_and_legacy_unknown_files_are_untouched() { + tauri::async_runtime::block_on(async { + let fixture = Fixture::new(); + std::fs::create_dir(fixture.a.join(".inkstream")).unwrap(); + let legacy = fixture.a.join(".inkstream"); + std::fs::write(legacy.join("index.db"), b"unknown old index bytes, deliberately not SQLite").unwrap(); + std::fs::write(legacy.join("notes.md"), "用户未知资料,必须保留").unwrap(); + std::fs::write(legacy.join(".gitignore"), "user-owned rule\n").unwrap(); + std::fs::write(fixture.a.join("same.MD"), "属于 A 的正文").unwrap(); + std::fs::write(fixture.b.join("same.MD"), "属于 B 的正文").unwrap(); + let actor = fixture.actor(); + let sa = fixture.scope(&fixture.a, A, "app-a"); let sb = fixture.scope(&fixture.b, B, "app-b"); + let a = actor.submit(sa.clone(), Operation::Prepare { rebuild: false }).await.unwrap().unwrap(); + let b = actor.submit(sb.clone(), Operation::Prepare { rebuild: false }).await.unwrap().unwrap(); + assert_eq!(a.project_id, A); assert_eq!(b.project_id, B); assert_ne!(a.database_url, b.database_url); + assert!(fixture.data.join("indexes").join(A).join("index.db").is_file()); + assert!(fixture.data.join("indexes").join(B).join("index.db").is_file()); + assert_eq!(text(&a.database_url, "same.MD").await.as_deref(), Some("属于 A 的正文")); + assert_eq!(text(&b.database_url, "same.MD").await.as_deref(), Some("属于 B 的正文")); + assert!(actor.submit(sa, Operation::Upsert { path: "same.MD".into(), content: "迟到 A".into() }).await.is_err()); + actor.submit(sb, Operation::Stop).await.unwrap(); + assert!(!fixture.b.join(".inkstream").exists()); + assert_eq!(std::fs::read(legacy.join("index.db")).unwrap(), b"unknown old index bytes, deliberately not SQLite"); + assert_eq!(std::fs::read_to_string(legacy.join("notes.md")).unwrap(), "用户未知资料,必须保留"); + assert_eq!(std::fs::read_to_string(legacy.join(".gitignore")).unwrap(), "user-owned rule\n"); + }); +} + +#[test] +fn restart_and_relocate_reuse_project_identity_and_rebuild_from_current_content() { + tauri::async_runtime::block_on(async { + let fixture = Fixture::new(); + std::fs::write(fixture.a.join("note.md"), "第一版").unwrap(); + let actor = fixture.actor(); let first_scope = fixture.scope(&fixture.a, A, "first"); + let first = actor.submit(first_scope.clone(), Operation::Prepare { rebuild: true }).await.unwrap().unwrap(); + actor.submit(first_scope, Operation::Stop).await.unwrap(); drop(actor); + let moved = fixture.base.join("relocated project"); + std::fs::rename(&fixture.a, &moved).unwrap(); + fixture.repository.relocate(A.into(), moved.to_string_lossy().into_owned()).unwrap(); + std::fs::write(moved.join("note.md"), "关闭期间修改的第二版").unwrap(); + // Fresh repository instance reads the persisted catalog; there is no second ID registry. + let reloaded = Arc::new(ProjectRepository::new(fixture.data.clone())); + let actor = IndexState::start_with_resolver(Arc::new(move |scope| Storage::project(&reloaded, scope))); + let second_scope = fixture.scope(&moved, A, "restart"); + let second = actor.submit(second_scope.clone(), Operation::Prepare { rebuild: false }).await.unwrap().unwrap(); + assert_eq!(first.database_url, second.database_url); + assert_eq!(text(&second.database_url, "note.md").await.as_deref(), Some("关闭期间修改的第二版")); + assert!(!moved.join(".inkstream").exists()); + actor.submit(second_scope, Operation::Stop).await.unwrap(); + }); +} + +#[test] +fn wrong_project_binding_and_unregistered_roots_never_create_user_folder_metadata() { + tauri::async_runtime::block_on(async { + let fixture = Fixture::new(); let actor = fixture.actor(); + let wrong = fixture.scope(&fixture.a, B, "wrong-binding"); + assert!(actor.submit(wrong.clone(), Operation::Prepare { rebuild: true }).await.unwrap_err().contains("身份")); + actor.submit(wrong, Operation::Stop).await.unwrap(); + let unknown = fixture.base.join("unregistered"); std::fs::create_dir(&unknown).unwrap(); + let scope = fixture.scope(&unknown, A, "unregistered"); + assert!(actor.submit(scope.clone(), Operation::Prepare { rebuild: true }).await.unwrap_err().contains("尚未登记")); + actor.submit(scope, Operation::Stop).await.unwrap(); + assert!(!fixture.a.join(".inkstream").exists()); assert!(!unknown.join(".inkstream").exists()); + }); +} + +#[test] +fn failed_rebuild_does_not_mark_a_partial_index_ready_and_can_be_retried_after_restart() { + tauri::async_runtime::block_on(async { + let fixture = Fixture::new(); let actor = fixture.actor(); + std::fs::write(fixture.a.join("good.md"), "完整正文").unwrap(); + std::fs::write(fixture.a.join("bad.md"), [255, 254]).unwrap(); + let scope = fixture.scope(&fixture.a, A, "failed-initial"); + assert!(actor.submit(scope.clone(), Operation::Prepare { rebuild: false }).await.is_err()); + actor.submit(scope, Operation::Stop).await.unwrap(); drop(actor); + std::fs::write(fixture.a.join("bad.md"), "修复的正文").unwrap(); + let actor = fixture.actor(); let scope = fixture.scope(&fixture.a, A, "retry"); + let ready = actor.submit(scope.clone(), Operation::Prepare { rebuild: false }).await.unwrap().unwrap(); + assert_eq!(text(&ready.database_url, "bad.md").await.as_deref(), Some("修复的正文")); + assert_eq!(text(&ready.database_url, "good.md").await.as_deref(), Some("完整正文")); + actor.submit(scope, Operation::Stop).await.unwrap(); + }); +} diff --git a/src-tauri/src/index_worker.rs b/src-tauri/src/index_worker.rs new file mode 100644 index 0000000..af49233 --- /dev/null +++ b/src-tauri/src/index_worker.rs @@ -0,0 +1,185 @@ +use super::{db, file, storage::{IndexLocation, Resolver, Storage}}; +use sqlx::SqlitePool; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::{mpsc, oneshot, Mutex}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Scope { + pub root: PathBuf, + pub session_id: String, + pub project_id: Option, +} + +impl Scope { + pub fn owned(root: String, session_id: String) -> Result { + if session_id.is_empty() || session_id.len() > 200 { return Err("索引会话标识无效".into()); } + let root = PathBuf::from(root); + if !root.is_absolute() { return Err("索引工作区必须是绝对路径".into()); } + Ok(Self { root, session_id, project_id: None }) + } + + pub fn new(root: String, session_id: String) -> Result { + if session_id.is_empty() || session_id.len() > 200 { return Err("索引会话标识无效".into()); } + let root = Path::new(&root).canonicalize().map_err(|e| format!("索引工作区无效: {e}"))?; + if !root.is_dir() { return Err("索引工作区不是目录".into()); } + Ok(Self { root, session_id, project_id: None }) + } + pub fn with_project(mut self, project_id: Option) -> Self { self.project_id = project_id; self } +} + +pub(super) enum Operation { + Prepare { rebuild: bool }, + Stop, + Upsert { path: String, content: String }, + Refresh { path: String }, + Remove { path: String }, +} + +struct Pending { + scope: Scope, + operation: Operation, + reply: oneshot::Sender, String>>, +} + +#[derive(Default)] +struct Admission { + current: Option, + retired: HashSet, +} + +pub(super) struct IndexState { + tx: mpsc::Sender, + admission: Arc>, +} + +impl IndexState { + pub fn start_with_resolver(resolver: Resolver) -> Self { + let (tx, rx) = mpsc::channel(1024); + let admission = Arc::new(Mutex::new(Admission::default())); + tauri::async_runtime::spawn(worker(rx, admission.clone(), resolver)); + Self { tx, admission } + } + + #[cfg(test)] + pub fn start() -> Self { Self::start_with_resolver(Arc::new(super::storage::fixture)) } + + pub async fn submit(&self, scope: Scope, operation: Operation) -> Result, String> { + let (reply, response) = oneshot::channel(); + let preparing = matches!(operation, Operation::Prepare { .. }); + let stopping = matches!(operation, Operation::Stop); + { + let mut state = self.admission.lock().await; + if !stopping && (state.retired.contains(&scope.session_id) || (!preparing && state.current.as_ref() != Some(&scope))) { + return Err("索引工作区会话已过期或已停用".into()); + } + self.tx.try_send(Pending { scope: scope.clone(), operation, reply }) + .map_err(|_| "索引队列繁忙或已关闭".to_string())?; + if preparing && state.current.as_ref() != Some(&scope) { + if let Some(previous) = state.current.replace(scope.clone()) { state.retired.insert(previous.session_id); } + } + if stopping { + state.retired.insert(scope.session_id.clone()); + if state.current.as_ref() == Some(&scope) { state.current = None; } + } + } + response.await.map_err(|_| "索引任务未完成,需重新准备索引".to_string())? + } +} + +fn current(state: &Admission, scope: &Scope) -> Result<(), String> { + if state.current.as_ref() == Some(scope) && !state.retired.contains(&scope.session_id) { Ok(()) } + else { Err("索引工作区会话已过期或已停用".into()) } +} + +struct Opened { scope: Scope, storage: Storage, pool: SqlitePool } + +async fn worker(mut rx: mpsc::Receiver, admission: Arc>, resolver: Resolver) { + let mut opened: Option = None; + while let Some(job) = rx.recv().await { + let result = execute(&mut opened, &admission, &resolver, &job.scope, job.operation).await; + let _ = job.reply.send(result); // Only SQL commit / completed close reaches the success reply. + } + if let Some(opened) = opened { opened.pool.close().await; } +} + +async fn execute( + opened: &mut Option, admission: &Mutex, resolver: &Resolver, scope: &Scope, operation: Operation, +) -> Result, String> { + if matches!(operation, Operation::Stop) { + if opened.as_ref().is_some_and(|open| &open.scope == scope) { + if let Some(open) = opened.take() { open.pool.close().await; } + } + return Ok(None); + } + let guard = admission.lock().await; + current(&guard, scope)?; + if let Operation::Prepare { rebuild } = operation { + let fresh_scope = opened.as_ref().is_none_or(|open| &open.scope != scope); + if rebuild || fresh_scope { + if let Some(open) = opened.take() { open.pool.close().await; } + let resolve = resolver.clone(); let target = scope.clone(); + let storage = tauri::async_runtime::spawn_blocking(move || resolve(&target)).await + .map_err(|error| format!("项目索引位置解析失败: {error}"))??; + let pool = db::open(&storage.directory).await?; + *opened = Some(Opened { scope: scope.clone(), storage, pool }); + } + let open = opened.as_ref().unwrap(); + let initialized: Option = sqlx::query_scalar("SELECT v FROM index_meta WHERE k='content_root'") + .fetch_optional(&open.pool).await.map_err(|error| error.to_string())?; + let needs_rebuild = rebuild || fresh_scope || initialized.as_deref() != scope.root.to_str(); + drop(guard); + if needs_rebuild { rebuild_scope(&open.pool, admission, scope).await?; } + return Ok(Some(open.storage.location()?)); + } + let open = opened.as_ref().filter(|open| &open.scope == scope).ok_or("索引尚未准备完成")?; + let mut tx = open.pool.begin().await.map_err(|e| e.to_string())?; + match operation { + Operation::Upsert { path, content } => db::upsert(&mut tx, &db::relative_path(&path)?, &content).await?, + Operation::Refresh { path } => { + let root = scope.root.clone(); + let (path, content) = tauri::async_runtime::spawn_blocking(move || file::read_saved(&root, &path)) + .await.map_err(|error| format!("索引读取任务失败: {error}"))??; + db::upsert(&mut tx, &path, &content).await?; + } + Operation::Remove { path } => db::remove(&mut tx, &db::relative_path(&path)?).await?, + _ => unreachable!(), + } + tx.commit().await.map_err(|e| e.to_string())?; + // Keep admission locked through commit: a switch cannot accept a new owner before this write ends. + drop(guard); + Ok(None) +} + +async fn rebuild_scope(pool: &SqlitePool, admission: &Mutex, scope: &Scope) -> Result<(), String> { + let files = db::collect(&scope.root)?; + let chunks = files.chunks(200).collect::>(); + let empty = Vec::new(); + let batches: Vec<&[(String, PathBuf)]> = if chunks.is_empty() { vec![&empty] } else { chunks }; + for (number, batch) in batches.into_iter().enumerate() { + let guard = admission.lock().await; + current(&guard, scope)?; + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + if number == 0 { + sqlx::query("DELETE FROM index_meta WHERE k='content_root'").execute(&mut *tx).await.map_err(|e| e.to_string())?; + sqlx::query("DELETE FROM files").execute(&mut *tx).await.map_err(|e| e.to_string())?; + sqlx::query("DELETE FROM links").execute(&mut *tx).await.map_err(|e| e.to_string())?; + } + for (path, abs) in batch { + if !abs.exists() { continue; } + let root = scope.root.clone(); + let relative = path.clone(); + let (_, content) = tauri::async_runtime::spawn_blocking(move || file::read_saved(&root, &relative)) + .await.map_err(|error| format!("索引读取任务失败: {error}"))??; + db::upsert(&mut tx, path, &content).await?; + } + tx.commit().await.map_err(|e| e.to_string())?; + drop(guard); // Let stop / switch retire this scope between bounded batches. + } + let guard = admission.lock().await; + current(&guard, scope)?; + sqlx::query("INSERT INTO index_meta(k,v) VALUES ('content_root',?) ON CONFLICT(k) DO UPDATE SET v=excluded.v") + .bind(scope.root.to_str().ok_or("项目目录不是有效 UTF-8。")?).execute(pool).await.map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index beac23d..f2251c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod index; mod os_open; mod pandoc; mod path_guard; +pub(crate) mod projects; mod terminal; mod vault; mod watcher; @@ -38,13 +39,33 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ + projects::project_catalog_get, + projects::project_register, + projects::project_update, + projects::project_relocate, + projects::project_remove, + projects::project_activate, + projects::project_import_cover, + projects::project_session_read, + projects::project_session_begin, + projects::project_session_commit, + projects::project_session_abort, + projects::project_restore_backup, vault::open_vault, vault::list_dir, vault::list_files, vault::find_repo_root, os_open::initial_open_file, files::read_file, + files::stream::read_file_stream, + files::stream::ack_file_read, + files::stream::cancel_file_read, files::write_file_atomic, + files::write::write_file_raw, + files::write_session::begin_file_write, + files::write_session::append_file_write, + files::write_session::commit_file_write, + files::write_session::abort_file_write, files::write_file_to_path, files::write_file_bytes, files::read_file_bytes, @@ -53,6 +74,7 @@ pub fn run() { pandoc::pandoc_available, pandoc::pandoc_convert, files::create_file, + files::create::create_text_file, files::create_dir, files::rename_path, files::move_path, @@ -60,6 +82,7 @@ pub fn run() { watcher::start_watch, watcher::stop_watch, index::index_upsert_doc, + index::index_refresh_file, index::index_remove_doc, index::index_rebuild, index::index_switch_vault, @@ -67,12 +90,18 @@ pub fn run() { git::branch::git_branch_list, git::log::git_log, git::diff::git_diff, + git::compare::git_compare_files, + git::compare::git_commit_files, git::refs::git_refs, git::commit::git_commit, git::commit::git_merge, git::commit::git_cherry_pick, git::commit::git_revert, git::commit::git_abort_op, + git::commit::git_cancel_operation, + git::rebase::git_rebase, + git::rebase::git_rebase_status, + git::rebase::git_cancel_rebase, git::refops::git_checkout, git::refops::git_create_branch, git::refops::git_delete_branch, @@ -87,14 +116,28 @@ pub fn run() { git::remote::git_push, git::remote::git_pull, git::remote::git_clone, + git::clone::git_clone_owned, + git::clone::git_cancel_clone, git::auth::git_login_github, git::auth::git_logout_github, git::auth::git_github_status, + git::auth::git_github_device_configuration, + git::auth::git_github_device_start, + git::auth::git_github_device_poll, + git::auth::git_github_device_cancel, git::pr::gh_pr_list, + git::pr::pages::gh_issue_page, + git::pr::pages::gh_pr_page, + git::pr::pages::gh_comment_page, + git::pr::pages::gh_review_page, + git::pr::pages::gh_pr_diff_page, + git::pr::pages::gh_pr_local_base, git::pr::gh_pr_create, git::pr::gh_pr_merge, git::pr::gh_pr_diff, git::pr::gh_pr_reviews, + git::pr::review_comments::gh_pr_review_comments, + git::pr::review_comments::gh_pr_reply, git::pr::gh_pr_review_create, git::pr::gh_issue_list, git::pr::gh_issue_create, @@ -103,6 +146,7 @@ pub fn run() { git::auth::gh_cli_status, git::auth::git_login_github_gh, git::conflict::git_read_conflict, + git::conflict_snapshot::git_conflict_snapshot, git::conflict::git_resolve_conflict, zotero::zotero_cayw, zotero::zotero_citekeys, @@ -119,6 +163,14 @@ pub fn run() { terminal::terminal_resize, terminal::terminal_close ]) + .on_window_event(|window, event| { + if matches!(event, tauri::WindowEvent::Destroyed) { + files::write_session::close_owner(window.label()); + projects::close_owner(window.app_handle(), window.label()); + git::clone::close_owner(window.label()); + git::auth::close_owner(window.label()); + } + }) .setup(|app| { // watcher 单例状态注册(切 vault 时 start/stop_watch 经此句柄换装)。 watcher::init(app); diff --git a/src-tauri/src/projects.rs b/src-tauri/src/projects.rs new file mode 100644 index 0000000..f2302ff --- /dev/null +++ b/src-tauri/src/projects.rs @@ -0,0 +1,83 @@ +//! Application-local project catalog and transactional immutable document checkpoints. +mod catalog; +mod body_cache; +mod io; +mod session; +pub(crate) mod types; +#[cfg(test)] mod tests; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; +use std::time::Instant; +use tauri::{AppHandle, Manager}; +pub use types::{ProjectCatalog, ProjectRecord, Snapshot, StoredSession, Ticket}; + +struct Lease { ownership: types::Ownership, started: Instant } +#[derive(Default)] struct State { leases: HashMap } +pub(crate) struct ProjectRepository { app_data: PathBuf, root: PathBuf, state: Mutex, body_cache: Mutex } + +impl ProjectRepository { + pub(crate) fn new(app_data: PathBuf) -> Self { Self { root: app_data.join("projects"), app_data, state: Mutex::new(State::default()), body_cache: Mutex::new(body_cache::BodyCache::default()) } } + fn body_length(&self, path: &std::path::Path, deadline: Option, force: bool) -> Result { + self.body_cache.lock().map_err(|_| "正文校验缓存状态不可用,未写入。")?.length(path, deadline, force) + } + fn lock(&self) -> Result, String> { self.state.lock().map_err(|_| "本机项目存储状态不可用,未写入。".into()) } + fn ensure(&self) -> Result<(), String> { + if !self.app_data.is_absolute() { return Err("应用数据目录必须为绝对路径。".into()); } + std::fs::create_dir_all(&self.app_data).map_err(|e| format!("无法创建应用数据目录:{e}"))?; + io::plain(&self.app_data, true)?; + io::ensure_directory(&self.root) + } +} + +static STORES: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); +pub(crate) fn repository(app: &AppHandle) -> Result, String> { + let data = app.path().app_data_dir().map_err(|e| format!("无法解析应用数据目录:{e}"))?; + let mut stores = STORES.lock().map_err(|_| "本机项目存储注册表不可用。")?; + Ok(stores.entry(data.clone()).or_insert_with(|| Arc::new(ProjectRepository::new(data))).clone()) +} +async fn blocking(app: AppHandle, action: impl FnOnce(&ProjectRepository) -> Result + Send + 'static) -> Result { + let repository = repository(&app)?; + tauri::async_runtime::spawn_blocking(move || action(&repository)).await.map_err(|e| format!("本机项目存储任务失败:{e}"))? +} + +#[tauri::command] +pub async fn project_catalog_get(app: AppHandle) -> Result { blocking(app, |store| store.catalog_get()).await } +#[tauri::command] +pub async fn project_register(app: AppHandle, id: String, root: String, name: String) -> Result { blocking(app, move |store| store.register(id, root, name)).await } +#[tauri::command] +pub async fn project_update(app: AppHandle, id: String, name: Option, favorite: Option) -> Result { blocking(app, move |store| store.update(id, name, favorite)).await } +#[tauri::command] +pub async fn project_relocate(app: AppHandle, id: String, root: String) -> Result { blocking(app, move |store| store.relocate(id, root)).await } +#[tauri::command] +pub async fn project_remove(app: AppHandle, id: String) -> Result { blocking(app, move |store| store.remove(id)).await } +#[tauri::command] +pub async fn project_activate(app: AppHandle, id: Option) -> Result { blocking(app, move |store| store.activate(id)).await } +#[tauri::command] +pub async fn project_import_cover(app: AppHandle, id: String, path: String) -> Result { blocking(app, move |store| store.import_cover(id, path)).await } +#[tauri::command] +pub async fn project_session_read(app: AppHandle, id: Option) -> Result { blocking(app, move |store| store.session_read(id)).await } +#[tauri::command] +pub async fn project_session_begin(window: tauri::WebviewWindow, app: AppHandle, id: Option, token: String, expected_revision: u64, keys: Vec) -> Result { + let owner = window.label().to_string(); + blocking(app, move |store| store.session_begin(id, token, expected_revision, keys, owner)).await +} +#[tauri::command] +pub async fn project_session_commit(window: tauri::WebviewWindow, app: AppHandle, id: Option, token: String, snapshot: Snapshot) -> Result { + let owner = window.label().to_string(); + blocking(app, move |store| store.session_commit(id, token, snapshot, &owner)).await +} +#[tauri::command] +pub async fn project_session_abort(window: tauri::WebviewWindow, app: AppHandle, id: Option, token: String) -> Result<(), String> { + let owner = window.label().to_string(); + blocking(app, move |store| store.session_abort(id, token, &owner)).await +} +#[tauri::command] +pub async fn project_restore_backup(app: AppHandle, id: Option, kind: String) -> Result<(), String> { blocking(app, move |store| store.restore_backup(id, &kind)).await } + +pub(crate) fn close_owner(app: &AppHandle, owner: &str) { + let Ok(repository) = repository(app) else { return; }; + let owner = owner.to_owned(); + tauri::async_runtime::spawn_blocking(move || repository.close_owner(&owner)); +} diff --git a/src-tauri/src/projects/body_cache.rs b/src-tauri/src/projects/body_cache.rs new file mode 100644 index 0000000..41cd85d --- /dev/null +++ b/src-tauri/src/projects/body_cache.rs @@ -0,0 +1,107 @@ +//! Bounded metadata cache for already validated immutable checkpoint bodies. +use super::io; +use std::collections::HashMap; +use std::fs::{File, Metadata}; +use std::path::{Path, PathBuf}; +use std::time::{Instant, SystemTime}; + +const MAX_ENTRIES: usize = 512; +const MAX_PATH_BYTES: usize = 1024 * 1024; + +#[derive(Clone, PartialEq, Eq)] +struct Fingerprint { length: u64, modified: SystemTime, identity: [u64; 4] } + +#[cfg(unix)] +fn fingerprint(_file: &File, metadata: &Metadata) -> Option { + use std::os::unix::fs::MetadataExt; + Some(Fingerprint { length: metadata.len(), modified: metadata.modified().ok()?, + identity: [metadata.dev(), metadata.ino(), metadata.ctime() as u64, metadata.ctime_nsec() as u64] }) +} +#[cfg(windows)] +fn fingerprint(file: &File, metadata: &Metadata) -> Option { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{GetFileInformationByHandle, GetFileInformationByHandleEx, FileBasicInfo, BY_HANDLE_FILE_INFORMATION, FILE_BASIC_INFO}; + let mut identity: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + let mut basic: FILE_BASIC_INFO = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut identity) } == 0 + || unsafe { GetFileInformationByHandleEx(file.as_raw_handle(), FileBasicInfo, (&mut basic as *mut FILE_BASIC_INFO).cast(), std::mem::size_of::() as u32) } == 0 { return None; } + Some(Fingerprint { length: metadata.len(), modified: metadata.modified().ok()?, identity: [ + identity.dwVolumeSerialNumber as u64, ((identity.nFileIndexHigh as u64) << 32) | identity.nFileIndexLow as u64, + basic.CreationTime as u64, basic.ChangeTime as u64, + ] }) +} +#[cfg(not(any(unix, windows)))] +fn fingerprint(_file: &File, _metadata: &Metadata) -> Option { None } + +struct Entry { fingerprint: Fingerprint, units: u64, used: u64 } +#[derive(Default)] +pub(super) struct BodyCache { + entries: HashMap, path_bytes: usize, clock: u64, + #[cfg(test)] scans: u64, + #[cfg(test)] hits: u64, +} +fn deadline_check(deadline: Option) -> Result<(), String> { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { Err("项目快照超过 120 秒期限,旧会话保留。".into()) } else { Ok(()) } +} +impl BodyCache { + fn remove(&mut self, path: &Path) -> Option { + let (stored, value) = self.entries.remove_entry(path)?; + self.path_bytes -= stored.as_os_str().len(); + Some(value) + } + fn insert(&mut self, path: PathBuf, fingerprint: Fingerprint, units: u64) { + self.remove(&path); + let bytes = path.as_os_str().len(); + if bytes > MAX_PATH_BYTES { return; } + while self.entries.len() >= MAX_ENTRIES || self.path_bytes + bytes > MAX_PATH_BYTES { + let Some(oldest) = self.entries.iter().min_by_key(|(_, entry)| entry.used).map(|(path, _)| path.clone()) else { break; }; + self.remove(&oldest); + } + self.clock = self.clock.saturating_add(1); self.path_bytes += bytes; + self.entries.insert(path, Entry { fingerprint, units, used: self.clock }); + } + pub(super) fn length(&mut self, path: &Path, deadline: Option, force: bool) -> Result { + deadline_check(deadline)?; + if !io::plain(path, false)? { self.remove(path); return Err(format!("快照正文缺失:{}", path.display())); } + let mut file = File::open(path).map_err(|error| error.to_string())?; + let before = file.metadata().map_err(|error| error.to_string())?; + if before.len() > io::BODY_LIMIT { self.remove(path); return Err("单篇快照正文超过 100MiB。".into()); } + let stamp = fingerprint(&file, &before); + if !force { + if let Some(entry) = self.entries.get_mut(path).filter(|entry| stamp.as_ref() == Some(&entry.fingerprint)) { + deadline_check(deadline)?; + self.clock = self.clock.saturating_add(1); entry.used = self.clock; + #[cfg(test)] { self.hits += 1; } + return Ok(entry.units); + } + } + self.remove(path); + #[cfg(test)] { self.scans += 1; } + let units = io::body_length_opened(&mut file, &before, deadline)?; + let after = file.metadata().map_err(|error| error.to_string())?; + let after_stamp = fingerprint(&file, &after); + if let (Some(before), Some(after)) = (&stamp, &after_stamp) { + if before != after { return Err("正文身份或元数据在校验期间变化,未接受缓存或快照。".into()); } + let current = File::open(path).map_err(|error| error.to_string())?; + let current_metadata = current.metadata().map_err(|error| error.to_string())?; + if fingerprint(¤t, ¤t_metadata).as_ref() != Some(after) { return Err("正文路径在校验期间被替换,未接受快照。".into()); } + } + deadline_check(deadline)?; + if let Some(stamp) = after_stamp { self.insert(path.to_owned(), stamp, units); } + Ok(units) + } + /// Moving a whole owned directory usually leaves child identity/metadata intact. + /// If the filesystem changes a stamp, simply revalidate that version on its next use. + pub(super) fn publish(&mut self, stage: &Path, version: &Path) { + let paths: Vec<_> = self.entries.keys().filter(|path| path.parent() == Some(stage)).cloned().collect(); + for old in paths { + let Some(entry) = self.remove(&old) else { continue; }; + let Some(name) = old.file_name() else { continue; }; + let target = version.join(name); + let stamp = File::open(&target).ok().and_then(|file| file.metadata().ok().and_then(|metadata| fingerprint(&file, &metadata))); + if stamp.as_ref() == Some(&entry.fingerprint) { self.insert(target, entry.fingerprint, entry.units); } + } + } + #[cfg(test)] + pub(super) fn statistics(&self) -> (u64, u64, usize, usize) { (self.scans, self.hits, self.entries.len(), self.path_bytes) } +} diff --git a/src-tauri/src/projects/catalog.rs b/src-tauri/src/projects/catalog.rs new file mode 100644 index 0000000..0876128 --- /dev/null +++ b/src-tauri/src/projects/catalog.rs @@ -0,0 +1,136 @@ +use super::{io, types::ProjectCatalog, types::ProjectRecord, ProjectRepository}; +use std::collections::HashSet; +use std::path::Path; + +impl ProjectRepository { + pub(super) fn validate_catalog(&self, value: &ProjectCatalog) -> Result<(), String> { + if value.version != 1 { return Err("项目档案版本不受支持,原件已保留。".into()); } + let mut ids = HashSet::new(); + let mut roots = HashSet::new(); + for record in &value.projects { + io::uuid(&record.id)?; + io::text(&record.name, 256, "项目名称")?; + if !Path::new(&record.root).is_absolute() || record.root.contains('\0') || record.created_at > io::SAFE_INTEGER || record.last_opened_at > io::SAFE_INTEGER { + return Err("项目档案含无效路径或时间,原件已保留。".into()); + } + if !ids.insert(&record.id) || !roots.insert(io::identity(&record.root)) { return Err("项目档案有重复身份或目录,原件已保留。".into()); } + if let Some(cover) = &record.cover { + let cover = Path::new(cover); + if cover.parent() != Some(self.root.join("covers").join(&record.id).as_path()) { return Err("封面路径不属于当前项目。".into()); } + } + } + if value.active_id.as_ref().is_some_and(|id| !value.projects.iter().any(|record| &record.id == id && !record.removed)) { + return Err("上次活动项目身份无效,原件已保留。".into()); + } + io::encoded(value)?; + Ok(()) + } + pub(super) fn load_catalog(&self) -> Result { + self.ensure()?; + let path = self.root.join("catalog.json"); + match io::json::(&path)? { + Some(value) => { self.validate_catalog(&value)?; Ok(value) }, + None if self.root.join("catalog.backup.json").exists() => Err("项目档案主文件缺失,备份仍保留;请显式恢复备份。".into()), + None => Ok(ProjectCatalog::default()), + } + } + fn save_catalog(&self, before: &ProjectCatalog, next: &ProjectCatalog) -> Result<(), String> { + self.validate_catalog(next)?; + let backup = self.root.join("catalog.backup.json"); + if io::plain(&backup, false)? && io::json::(&backup).and_then(|value| value.ok_or_else(|| "备份缺失".into())).and_then(|value| self.validate_catalog(&value)).is_err() { + io::preserve(&backup)?; + } + let initial = !self.root.join("catalog.json").exists(); + io::atomic(&backup, if initial { next } else { before })?; + io::atomic(&self.root.join("catalog.json"), next) + } + pub fn catalog_get(&self) -> Result { let _state = self.lock()?; self.load_catalog() } + pub fn register(&self, id: String, root: String, name: String) -> Result { + io::uuid(&id)?; io::text(&name, 256, "项目名称")?; + let canonical = io::canonical_directory(&root)?; + let _state = self.lock()?; + let before = self.load_catalog()?; + let mut next = before.clone(); + let record = if let Some(record) = next.projects.iter_mut().find(|record| io::identity(&record.root) == io::identity(&canonical)) { + record.removed = false; + record.clone() + } else { + if next.projects.iter().any(|record| record.id == id) { return Err("项目身份已登记到其它目录,请使用重新定位。".into()); } + let record = ProjectRecord { id, root: canonical, name: name.trim().into(), favorite: false, cover: None, created_at: io::now(), last_opened_at: 0, removed: false }; + next.projects.push(record.clone()); record + }; + if before != next { self.save_catalog(&before, &next)?; } + Ok(record) + } + pub fn update(&self, id: String, name: Option, favorite: Option) -> Result { + io::uuid(&id)?; + if let Some(name) = &name { io::text(name, 256, "项目名称")?; } + let _state = self.lock()?; + let before = self.load_catalog()?; let mut next = before.clone(); + let record = next.projects.iter_mut().find(|record| record.id == id).ok_or("项目未登记。")?; + if let Some(name) = name { record.name = name.trim().into(); } + if let Some(favorite) = favorite { record.favorite = favorite; } + let result = record.clone(); + self.save_catalog(&before, &next)?; Ok(result) + } + pub fn relocate(&self, id: String, root: String) -> Result { + io::uuid(&id)?; let root = io::canonical_directory(&root)?; + let _state = self.lock()?; + let before = self.load_catalog()?; let mut next = before.clone(); + if next.projects.iter().any(|record| record.id != id && io::identity(&record.root) == io::identity(&root)) { return Err("此目录已经属于另一个已登记项目。".into()); } + let record = next.projects.iter_mut().find(|record| record.id == id).ok_or("项目未登记。")?; + record.root = root; let result = record.clone(); + self.save_catalog(&before, &next)?; Ok(result) + } + pub fn remove(&self, id: String) -> Result { + io::uuid(&id)?; let _state = self.lock()?; + let before = self.load_catalog()?; let mut next = before.clone(); + if next.active_id.as_deref() == Some(id.as_str()) { return Err("请先保存并切离活动项目,再从档案中移除。".into()); } + next.projects.iter_mut().find(|record| record.id == id).ok_or("项目未登记。")?.removed = true; + self.save_catalog(&before, &next)?; Ok(next) + } + pub fn activate(&self, id: Option) -> Result { + if let Some(id) = &id { io::uuid(id)?; } + let _state = self.lock()?; + let before = self.load_catalog()?; let mut next = before.clone(); + if let Some(id) = &id { + let record = next.projects.iter_mut().find(|record| &record.id == id && !record.removed).ok_or("项目未登记或已移除。")?; + record.last_opened_at = io::now(); + } + next.active_id = id; self.save_catalog(&before, &next)?; Ok(next) + } + pub fn import_cover(&self, id: String, path: String) -> Result { + io::uuid(&id)?; + let source = Path::new(&path); + if !source.is_absolute() { return Err("封面必须来自绝对文件路径。".into()); } + let extension = source.extension().and_then(|extension| extension.to_str()).unwrap_or("").to_ascii_lowercase(); + if !matches!(extension.as_str(), "png" | "jpg" | "jpeg" | "webp") { return Err("封面仅支持 PNG、JPEG、WebP。".into()); } + let resolved = source.canonicalize().map_err(|e| format!("无法读取封面:{e}"))?; + let bytes = io::read(&resolved, 8 * 1024 * 1024)?.ok_or("封面文件不存在。")?; + let valid = match extension.as_str() { + "png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "jpg" | "jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]), + _ => bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(&b"WEBP"[..]), + }; + if !valid { return Err("封面内容与声明的图像格式不符。".into()); } + let _state = self.lock()?; + let before = self.load_catalog()?; let mut next = before.clone(); + let record = next.projects.iter_mut().find(|record| record.id == id).ok_or("项目未登记。")?; + let directory = io::directory_chain(&self.root, &["covers", &id])?; + let destination = directory.join(format!("cover-{}.{}", io::nonce(), extension)); + io::write_new(&destination, &bytes)?; + record.cover = Some(io::display(&destination)?); + let result = record.clone(); + if let Err(error) = self.save_catalog(&before, &next) { let _ = std::fs::remove_file(&destination); return Err(error); } + Ok(result) + } + pub(crate) fn project_id_for_root(&self, root: &str) -> Result, String> { + let canonical = io::canonical_directory(root)?; let _state = self.lock()?; + Ok(self.load_catalog()?.projects.into_iter().find(|record| !record.removed && io::identity(&record.root) == io::identity(&canonical)).map(|record| record.id)) + } + pub(crate) fn index_directory(&self, root: &str) -> Result { + let id = self.project_id_for_root(root)?.ok_or("项目尚未登记,索引未写入内容目录。")?; + let _state = self.lock()?; + io::directory_chain(&self.app_data, &["indexes", &id]) + } +} diff --git a/src-tauri/src/projects/io.rs b/src-tauri/src/projects/io.rs new file mode 100644 index 0000000..12361b7 --- /dev/null +++ b/src-tauri/src/projects/io.rs @@ -0,0 +1,146 @@ +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +pub(super) const JSON_LIMIT: usize = 1024 * 1024; +pub(super) const BODY_LIMIT: u64 = 100 * 1024 * 1024; +pub(super) const SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +pub(super) fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64 } +pub(super) fn nonce() -> String { + static NEXT: AtomicU64 = AtomicU64::new(0); + format!("{}-{}-{}", now(), std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)) +} +pub(super) fn uuid(value: &str) -> Result<(), String> { + if value.len() == 36 && value.bytes().enumerate().all(|(index, byte)| { + if [8, 13, 18, 23].contains(&index) { byte == b'-' } else { byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte) } + }) { Ok(()) } else { Err("项目、快照和文档标识必须为小写 UUID。".into()) } +} +pub(super) fn text(value: &str, limit: usize, label: &str) -> Result<(), String> { + if value.trim().is_empty() || value.chars().count() > limit || value.contains('\0') { + Err(format!("{label}为空、过长或包含无效字符。")) + } else { Ok(()) } +} +pub(super) fn display(path: &Path) -> Result { path.to_str().map(str::to_owned).ok_or_else(|| "路径不是有效 Unicode。".into()) } +pub(super) fn canonical_directory(value: &str) -> Result { + let path = Path::new(value); + if !path.is_absolute() { return Err("项目目录必须是绝对路径。".into()); } + let path = path.canonicalize().map_err(|e| format!("无法打开项目目录:{e}"))?; + if !path.is_dir() { return Err("项目路径不是文件夹。".into()); } + display(&path) +} +pub(super) fn identity(value: &str) -> String { + if cfg!(windows) { value.to_lowercase() } else { value.to_owned() } +} +pub(super) fn plain(path: &Path, directory: bool) -> Result { + match fs::symlink_metadata(path) { + Ok(value) if value.file_type().is_symlink() || (directory && !value.is_dir()) || (!directory && !value.is_file()) => Err(format!("受管理路径不是预期的普通{}:{}", if directory { "目录" } else { "文件" }, path.display())), + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("无法检查受管理路径:{error}")), + } +} +pub(super) fn ensure_directory(path: &Path) -> Result<(), String> { + if plain(path, true)? { return Ok(()); } + #[cfg(unix)] + let builder = { use std::os::unix::fs::DirBuilderExt; let mut builder = fs::DirBuilder::new(); builder.mode(0o700); builder }; + #[cfg(not(unix))] + let builder = fs::DirBuilder::new(); + builder.create(path).map_err(|e| format!("无法建立本机项目目录:{e}")) +} +pub(super) fn directory_chain(base: &Path, parts: &[&str]) -> Result { + let mut path = base.to_path_buf(); + for part in parts { path.push(part); ensure_directory(&path)?; } + Ok(path) +} +pub(super) fn relative(root: &Path, value: &str) -> Result { + if !plain(root, true)? { return Err("会话根目录缺失。".into()); } + let path = Path::new(value); + if value.is_empty() || value.contains('\\') || path.components().any(|part| !matches!(part, Component::Normal(_))) { + return Err("会话正文路径必须位于当前项目的受管理目录。".into()); + } + let mut target = root.to_path_buf(); + let components: Vec<_> = path.components().collect(); + for (index, part) in components.iter().enumerate() { + target.push(part.as_os_str()); + if !plain(&target, index + 1 < components.len())? { return Err(format!("会话正文或目录缺失:{value}")); } + } + Ok(target) +} +pub(super) fn existing_directory(root: &Path, directory: &Path) -> Result { + if !plain(root, true)? { return Ok(false); } + let relative = directory.strip_prefix(root).map_err(|_| "目录不属于本机项目存储。")?; + let mut path = root.to_path_buf(); + for component in relative.components() { + if !matches!(component, Component::Normal(_)) { return Err("本机目录路径无效。".into()); } + path.push(component.as_os_str()); + if !plain(&path, true)? { return Ok(false); } + } + Ok(true) +} +pub(super) fn read(path: &Path, limit: usize) -> Result>, String> { + if !plain(path, false)? { return Ok(None); } + let file = File::open(path).map_err(|e| e.to_string())?; + let before = file.metadata().map_err(|e| e.to_string())?; + if before.len() > limit as u64 { return Err(format!("文件超过 {} 字节读取预算:{}", limit, path.display())); } + let mut value = Vec::with_capacity(before.len() as usize); + file.take(limit as u64 + 1).read_to_end(&mut value).map_err(|e| e.to_string())?; + if value.len() > limit || value.len() as u64 != before.len() { return Err("文件在读取期间改变,未接受不完整结果。".into()); } + Ok(Some(value)) +} +pub(super) fn json(path: &Path) -> Result, String> { + read(path, JSON_LIMIT)?.map(|bytes| serde_json::from_slice(&bytes).map_err(|error| format!("本机项目数据损坏或版本不受支持,原件已保留({}):{error}", path.display()))).transpose() +} +pub(super) fn encoded(value: &T) -> Result, String> { + let bytes = serde_json::to_vec(value).map_err(|e| e.to_string())?; + if bytes.len() > JSON_LIMIT { return Err("项目元数据超过 1MiB,未写入。".into()); } + Ok(bytes) +} +pub(super) fn atomic(path: &Path, value: &T) -> Result<(), String> { + plain(path, false)?; + crate::files::write_atomic_bytes(path, &encoded(value)?) +} +pub(super) fn preserve(path: &Path) -> Result<(), String> { + if !plain(path, false)? { return Ok(()); } + let physical = path.canonicalize().map_err(|e| format!("无法解析要保留的原件:{e}"))?; + let target = physical.with_file_name(format!("{}.preserved-{}", path.file_name().unwrap().to_string_lossy(), nonce())); + // Atomic writes replace the inode, so a same-directory hard link preserves the + // original even when corrupted metadata is too large to decode or copy in memory. + fs::hard_link(&physical, &target).map_err(|e| format!("无法保留原件,未覆盖:{e}"))?; + #[cfg(unix)] if let Some(parent) = path.parent() { File::open(parent).and_then(|file| file.sync_all()).map_err(|e| e.to_string())?; } + Ok(()) +} +pub(super) fn write_new(path: &Path, bytes: &[u8]) -> Result<(), String> { + let mut file = OpenOptions::new().write(true).create_new(true).open(path).map_err(|e| format!("无法创建独占本机文件:{e}"))?; + file.write_all(bytes).and_then(|()| file.flush()).and_then(|()| file.sync_all()).map_err(|e| format!("本机文件写入失败:{e}")) +} +pub(super) fn body_length_opened(file: &mut File, before: &fs::Metadata, deadline: Option) -> Result { + if before.len() > BODY_LIMIT { return Err("单篇快照正文超过 100MiB。".into()); } + let mut chunk = [0u8; 256 * 1024]; + let mut pending = Vec::with_capacity(chunk.len() + 3); + let (mut bytes, mut units) = (0u64, 0u64); + loop { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { return Err("项目快照超过 120 秒期限,旧会话保留。".into()); } + let count = file.read(&mut chunk).map_err(|e| e.to_string())?; + if count == 0 { break; } + bytes += count as u64; + if bytes > before.len() || bytes > BODY_LIMIT { return Err("快照正文在校验时增长,未提交。".into()); } + pending.extend_from_slice(&chunk[..count]); + let valid = match std::str::from_utf8(&pending) { + Ok(_) => pending.len(), + Err(error) if error.error_len().is_none() => error.valid_up_to(), + Err(_) => return Err("快照正文不是有效 UTF-8。".into()), + }; + units += std::str::from_utf8(&pending[..valid]).unwrap().encode_utf16().count() as u64; + pending.drain(..valid); + } + let after = file.metadata().map_err(|e| e.to_string())?; + if !pending.is_empty() || bytes != before.len() || after.len() != before.len() || after.modified().ok() != before.modified().ok() { + return Err("快照正文不完整或在校验时变化,未提交。".into()); + } + Ok(units) +} diff --git a/src-tauri/src/projects/session.rs b/src-tauri/src/projects/session.rs new file mode 100644 index 0000000..264981a --- /dev/null +++ b/src-tauri/src/projects/session.rs @@ -0,0 +1,326 @@ +use super::{io, types::{Ownership, Snapshot, StoredSession, Ticket, TicketEntry}, Lease, ProjectRepository, State}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::time::{Duration, Instant}; + +const MARKER: &str = ".ticket.json"; +const LEASE_LIMIT: Duration = Duration::from_secs(120); +fn key(id: Option<&str>) -> Result { match id { Some(id) => { io::uuid(id)?; Ok(id.into()) }, None => Ok("scratch".into()) } } +fn content_file(value: &str) -> Result<(&str, &str), String> { + let fields: Vec<_> = value.split('/').collect(); + if fields.len() != 3 || fields[0] != "versions" { return Err("快照正文引用不属于不可变版本目录。".into()); } + let document = fields[2].strip_suffix(".txt").ok_or("快照正文扩展名无效。")?; + io::uuid(fields[1])?; io::uuid(document)?; + Ok((fields[1], document)) +} +fn shape(snapshot: &Snapshot) -> Result<(), String> { + if snapshot.version != 1 { return Err("项目会话版本不受支持,原件已保留。".into()); } + if snapshot.revision > io::SAFE_INTEGER || snapshot.documents.len() > 128 { return Err("项目会话修订或文档数量超出支持范围。".into()); } + if !["standard", "academic", "creative"].contains(&snapshot.mode.as_str()) { return Err("项目写作模式无效。".into()); } + io::text(&snapshot.active_tool, 64, "项目活动工具")?; + if snapshot.layouts.len() != 3 { return Err("项目布局缺少写作模式。".into()); } + for mode in ["standard", "academic", "creative"] { + let layout = snapshot.layouts.get(mode).ok_or("项目布局缺少写作模式。")?; + if !layout.sidebar_width.is_finite() || !layout.right_panel_width.is_finite() + || !(0.0..=4096.0).contains(&layout.sidebar_width) || !(0.0..=4096.0).contains(&layout.right_panel_width) { + return Err("项目布局宽度无效。".into()); + } + } + let (mut keys, mut paths) = (HashSet::new(), HashSet::new()); + for document in &snapshot.documents { + io::uuid(&document.key)?; + io::text(&document.name, 1024, "文档名称")?; + io::text(&document.path, 32768, "文档路径")?; + let (_, file_key) = content_file(&document.content_file)?; + if file_key != document.key || !keys.insert(&document.key) || !paths.insert(&document.path) { return Err("快照存在重复文档身份/路径或错配的正文引用。".into()); } + if document.draft { + if document.external || !document.path.starts_with("draft://") || document.path.len() <= 8 { return Err("草稿路径或类型无效。".into()); } + } else if document.external { + if !Path::new(&document.path).is_absolute() { return Err("外部文档路径必须为绝对路径。".into()); } + } else if document.path.contains('\\') || Path::new(&document.path).components().any(|part| !matches!(part, Component::Normal(_))) { + return Err("项目文档路径必须位于内容目录内。".into()); + } + if document.anchor > io::SAFE_INTEGER || document.head > io::SAFE_INTEGER || !document.scroll_top.is_finite() || document.scroll_top < 0.0 { + return Err("文档选区或滚动位置无效。".into()); + } + if document.render_mode.as_deref().is_some_and(|mode| !["source", "live"].contains(&mode)) { return Err("文档渲染模式无效。".into()); } + } + if snapshot.active_path.as_ref().is_some_and(|path| !snapshot.documents.iter().any(|document| &document.path == path)) { return Err("活动文档不在快照内。".into()); } + io::encoded(snapshot)?; + Ok(()) +} +fn references(snapshot: Option<&Snapshot>) -> HashSet { + snapshot.map(|snapshot| snapshot.documents.iter().map(|document| document.content_file.clone()).collect()).unwrap_or_default() +} +/// Preserved originals are recovery inputs too. An unreadable/unknown original +/// prevents confident reference discovery, so retain this session's old versions. +fn preserved_references(root: &Path) -> Option> { + let deadline = Instant::now() + Duration::from_millis(250); + let entries = fs::read_dir(root).ok()?; + let mut kept = HashSet::new(); + for (index, entry) in entries.enumerate() { + if index >= 1024 || Instant::now() >= deadline { return None; } + let entry = entry.ok()?; + let name = entry.file_name(); + let name = name.to_str()?; + if !name.starts_with("session.json.preserved-") && !name.starts_with("session.backup.json.preserved-") { continue; } + let snapshot = io::json::(&entry.path()).ok()??; + shape(&snapshot).ok()?; + kept.extend(references(Some(&snapshot))); + } + Some(kept) +} +fn validate_bodies(repository: &ProjectRepository, root: &Path, snapshot: &Snapshot, deadline: Option) -> Result<(), String> { + shape(snapshot)?; + for document in &snapshot.documents { + let path = version_reference(root, &document.content_file)?; + let units = repository.body_length(&path, deadline, false)?; + if document.anchor > units || document.head > units { return Err("文档选区超出其完整正文,快照未接受。".into()); } + } + Ok(()) +} + +fn version_reference(root: &Path, value: &str) -> Result { + let (token, key) = content_file(value)?; + let path = io::relative(root, value)?; + let marker_path = io::relative(root, &format!("versions/{token}/{MARKER}"))?; + let marker = io::json::(&marker_path)?.ok_or("正文版本缺少归属标记,未接受未知文件。")?; + if !valid_ownership(&marker) || marker.token != token || !root.file_name().is_some_and(|name| name == marker.project.as_str()) || !marker.keys.iter().any(|candidate| candidate == key) { + return Err("正文版本归属与当前项目不一致。".into()); + } + Ok(path) +} +fn valid_ownership(value: &Ownership) -> bool { + value.version == 1 && key(if value.project == "scratch" { None } else { Some(&value.project) }).is_ok() + && io::uuid(&value.token).is_ok() && value.keys.len() <= 128 && value.keys.iter().all(|key| io::uuid(key).is_ok()) + && value.keys.iter().collect::>().len() == value.keys.len() + && value.expected_revision <= io::SAFE_INTEGER && value.created_at <= io::SAFE_INTEGER +} + +impl ProjectRepository { + fn session_root(&self, id: Option<&str>, allow_removed: bool) -> Result { + self.ensure()?; + let key = key(id)?; + if let Some(id) = id { + if !self.load_catalog()?.projects.iter().any(|record| record.id == id && (allow_removed || !record.removed)) { + return Err("项目未登记或已移除。".into()); + } + } + io::directory_chain(&self.root, &["sessions", &key]) + } + fn load_session(&self, root: &Path, bodies: bool) -> Result, String> { + let value = io::json::(&root.join("session.json"))?; + if value.is_none() && root.join("session.backup.json").exists() { return Err("会话主文件缺失,备份仍保留;请显式恢复备份。".into()); } + if let Some(snapshot) = &value { + shape(snapshot)?; + if bodies { validate_bodies(self, root, snapshot, Some(Instant::now() + LEASE_LIMIT))?; } + } + Ok(value) + } + pub fn session_read(&self, id: Option) -> Result { + let _state = self.lock()?; + let root = self.session_root(id.as_deref(), true)?; + let snapshot = self.load_session(&root, true)?; + Ok(StoredSession { root: io::display(&root)?, snapshot }) + } + fn cleanup_owned(&self, directory: &Path, ownership: &Ownership) -> Result<(), String> { + if !io::existing_directory(&self.root, directory)? { return Ok(()); } + let marker = io::json::(&directory.join(MARKER))?.ok_or("快照归属标记缺失,保留全部文件。")?; + if &marker != ownership || !valid_ownership(&marker) { return Err("快照归属不一致,保留全部文件。".into()); } + for key in &ownership.keys { + let file = directory.join(format!("{key}.txt")); + if io::plain(&file, false).unwrap_or(false) { fs::remove_file(&file).map_err(|e| format!("快照临时正文清理失败:{e}"))?; } + } + let entries: Vec<_> = fs::read_dir(directory).map_err(|e| e.to_string())?.collect::>().map_err(|e| e.to_string())?; + if entries.iter().all(|entry| entry.file_name() == MARKER) { + fs::remove_file(directory.join(MARKER)).map_err(|e| e.to_string())?; + fs::remove_dir(directory).map_err(|e| e.to_string())?; + } + Ok(()) // Unknown files remain, along with their ownership marker for a later inspection. + } + fn expire(&self, state: &mut State, root: &Path, project: &str) { + let scan_deadline = Instant::now() + Duration::from_millis(250); + if state.leases.get(project).is_some_and(|lease| lease.started.elapsed() >= LEASE_LIMIT) { + if let Some(lease) = state.leases.remove(project) { let _ = self.cleanup_owned(&root.join(".staging").join(&lease.ownership.token), &lease.ownership); } + } + let stage = root.join(".staging"); + if !io::plain(&stage, true).unwrap_or(false) { return; } + let Ok(entries) = fs::read_dir(stage) else { return; }; + for entry in entries.take(1024).flatten() { + if Instant::now() >= scan_deadline { break; } + let directory = entry.path(); + if !io::plain(&directory, true).unwrap_or(false) { continue; } + let Ok(Some(marker)) = io::json::(&directory.join(MARKER)) else { continue; }; + if valid_ownership(&marker) && marker.project == project && directory.file_name().is_some_and(|name| name == marker.token.as_str()) + && !state.leases.get(project).is_some_and(|lease| lease.ownership.token == marker.token) + && io::now().saturating_sub(marker.created_at) >= LEASE_LIMIT.as_millis() as u64 { + let _ = self.cleanup_owned(&directory, &marker); + } + } + } + pub fn session_begin(&self, id: Option, token: String, expected_revision: u64, keys: Vec, owner: String) -> Result { + io::uuid(&token)?; + if keys.len() > 128 || keys.iter().collect::>().len() != keys.len() || expected_revision >= io::SAFE_INTEGER { return Err("快照文档数量、重复身份或修订无效。".into()); } + for key in &keys { io::uuid(key)?; } + io::text(&owner, 256, "窗口身份")?; + let project = key(id.as_deref())?; + let mut state = self.lock()?; + let root = self.session_root(id.as_deref(), false)?; + self.expire(&mut state, &root, &project); + if state.leases.contains_key(&project) { return Err("当前项目已有快照正在写入。".into()); } + let previous = self.load_session(&root, false)?; + if previous.as_ref().map_or(0, |snapshot| snapshot.revision) != expected_revision { return Err("项目会话修订已变化,请重新读取后保存。".into()); } + let staging = io::directory_chain(&root, &[".staging"])?; + let versions = io::directory_chain(&root, &["versions"])?; + if versions.join(&token).exists() { return Err("快照 token 已发布,不能改写不可变版本。".into()); } + let stage = staging.join(&token); + if stage.exists() { return Err("快照 token 目录已存在,未覆盖任何文件。".into()); } + fs::create_dir(&stage).map_err(|e| e.to_string())?; + let ownership = Ownership { version: 1, project: project.clone(), token: token.clone(), keys: keys.clone(), owner, created_at: io::now(), expected_revision }; + io::write_new(&stage.join(MARKER), &io::encoded(&ownership)?)?; + state.leases.insert(project, Lease { ownership, started: Instant::now() }); + Ok(Ticket { token: token.clone(), root: io::display(&stage)?, entries: keys.into_iter().map(|key| TicketEntry { path: format!("{key}.txt"), content_file: format!("versions/{token}/{key}.txt"), key }).collect() }) + } + pub fn session_abort(&self, id: Option, token: String, owner: &str) -> Result<(), String> { + io::uuid(&token)?; let project = key(id.as_deref())?; + let mut state = self.lock()?; + let Some(lease) = state.leases.get(&project) else { return Ok(()); }; + if lease.ownership.token != token || lease.ownership.owner != owner { return Err("不能取消其它窗口或其它 token 的快照。".into()); } + let lease = state.leases.remove(&project).unwrap(); + self.cleanup_owned(&self.root.join("sessions").join(&project).join(".staging").join(&token), &lease.ownership) + } + pub fn close_owner(&self, owner: &str) { + let Ok(mut state) = self.lock() else { return; }; + let projects: Vec<_> = state.leases.iter().filter(|(_, lease)| lease.ownership.owner == owner).map(|(project, _)| project.clone()).collect(); + for project in projects { + if let Some(lease) = state.leases.remove(&project) { + let _ = self.cleanup_owned(&self.root.join("sessions").join(&project).join(".staging").join(&lease.ownership.token), &lease.ownership); + } + } + } + pub fn session_commit(&self, id: Option, token: String, mut snapshot: Snapshot, owner: &str) -> Result { + io::uuid(&token)?; let project = key(id.as_deref())?; + let mut state = self.lock()?; + let lease = state.leases.get(&project).ok_or("快照租约不存在或已结束。")?; + if lease.ownership.token != token || lease.ownership.owner != owner { return Err("不能提交其它窗口或其它 token 的快照。".into()); } + let lease = state.leases.remove(&project).unwrap(); // An owned commit attempt consumes its ticket. + let root = self.root.join("sessions").join(&project); + let stage = root.join(".staging").join(&token); + let version = root.join("versions").join(&token); + let mut published = false; + let result = (|| { + let root_display = io::display(&root)?; + self.session_root(id.as_deref(), true)?; + if !io::existing_directory(&root, &stage)? || !io::existing_directory(&root, &root.join("versions"))? { return Err("快照目录缺失,旧会话保留。".into()); } + let deadline = lease.started + LEASE_LIMIT; + if Instant::now() >= deadline { return Err("快照超过 120 秒期限,旧会话保留。".into()); } + if id.as_ref().is_some_and(|id| !self.load_catalog().is_ok_and(|catalog| catalog.projects.iter().any(|record| &record.id == id && !record.removed))) { + return Err("项目档案已变化或不可读取,旧会话保留。".into()); + } + let previous = self.load_session(&root, false)?; + let expected = lease.ownership.expected_revision; + if previous.as_ref().map_or(0, |snapshot| snapshot.revision) != expected { return Err("项目会话修订已变化,旧会话保留。".into()); } + if snapshot.revision != expected && snapshot.revision != expected + 1 { return Err("提交快照的修订与租约不符。".into()); } + snapshot.revision = expected + 1; + shape(&snapshot)?; + let marker = io::json::(&stage.join(MARKER))?.ok_or("快照归属标记缺失。")?; + if marker != lease.ownership { return Err("快照归属标记已变化。".into()); } + let keys: HashSet<_> = lease.ownership.keys.iter().map(String::as_str).collect(); + let old = references(previous.as_ref()); + let mut used = HashSet::new(); + let mut lengths = HashMap::new(); + for document in &snapshot.documents { + let (version_token, file_key) = content_file(&document.content_file)?; + let path = if version_token == token && keys.contains(file_key) { + used.insert(file_key.to_string()); io::relative(&stage, &format!("{file_key}.txt"))? + } else if old.contains(&document.content_file) { version_reference(&root, &document.content_file)? } + else { return Err("正文引用不属于本次 ticket 或此前会话,旧会话保留。".into()); }; + let units = self.body_length(&path, Some(deadline), version_token == token)?; + if document.anchor > units || document.head > units { return Err("文档选区超出正文,旧会话保留。".into()); } + lengths.insert(document.content_file.clone(), units); + } + if let Some(previous) = &previous { + for document in &previous.documents { + let units = match lengths.get(&document.content_file) { + Some(units) => *units, + None => self.body_length(&version_reference(&root, &document.content_file)?, Some(deadline), false)?, + }; + if document.anchor > units || document.head > units { return Err("此前会话的正文或选区已损坏,旧原件保留。".into()); } + } + } + if used.len() != keys.len() { return Err("ticket 中有未使用或未完整登记的新正文,旧会话保留。".into()); } + for entry in fs::read_dir(&stage).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + if name == MARKER { continue; } + let name = name.to_str().ok_or("staging 含未知文件,未发布。")?; + if !name.strip_suffix(".txt").is_some_and(|key| keys.contains(key)) { return Err("staging 含未登记文件或仍在写入的 Raw 临时文件,未发布。".into()); } + } + let backup_path = root.join("session.backup.json"); + let old_backup = match io::json::(&backup_path) { + Ok(Some(backup)) if validate_bodies(self, &root, &backup, Some(deadline)).is_ok() => Some(backup), + Ok(None) => None, + _ => { io::preserve(&backup_path)?; None }, + }; + if Instant::now() >= deadline { return Err("快照超过 120 秒期限,旧会话保留。".into()); } + crate::files::exclusive_move::rename_new(&stage, &version)?; + published = true; + if let Ok(mut cache) = self.body_cache.lock() { cache.publish(&stage, &version); } + if Instant::now() >= deadline { return Err("快照超过 120 秒期限,旧会话保留。".into()); } + // Every body is immutable and complete before either manifest may name it. + io::atomic(&backup_path, previous.as_ref().unwrap_or(&snapshot))?; + io::atomic(&root.join("session.json"), &snapshot)?; + // Nothing after the manifest's publish point may turn success into a fake failure. + if let Some(mut keep) = preserved_references(&root) { + keep.extend(references(Some(&snapshot))); + keep.extend(references(previous.as_ref().or(Some(&snapshot)))); + for candidate in references(old_backup.as_ref()).difference(&keep) { + if let Ok(path) = version_reference(&root, candidate) { let _ = fs::remove_file(path); } + } + self.remove_empty_version(&version); + if let Some(backup) = old_backup { for file in references(Some(&backup)) { if let Ok((token, _)) = content_file(&file) { self.remove_empty_version(&root.join("versions").join(token)); } } } + } + Ok(StoredSession { root: root_display, snapshot: Some(snapshot.clone()) }) + })(); + if result.is_err() { + // A first-save backup may already reference the complete new version. + // Never remove bodies referenced by either readable manifest after an error. + let referenced = ["session.json", "session.backup.json"].iter().any(|name| { + io::json::(&root.join(name)).ok().flatten().is_some_and(|snapshot| snapshot.documents.iter().any(|document| document.content_file.starts_with(&format!("versions/{token}/")))) + }); + let protected = preserved_references(&root).is_none_or(|references| references.iter().any(|reference| reference.starts_with(&format!("versions/{token}/")))); + if !published || (!referenced && !protected) { let _ = self.cleanup_owned(if published { &version } else { &stage }, &lease.ownership); } + } + result + } + fn remove_empty_version(&self, directory: &Path) { + if !io::existing_directory(&self.root, directory).unwrap_or(false) { return; } + let Ok(Some(marker)) = io::json::(&directory.join(MARKER)) else { return; }; + if !valid_ownership(&marker) || !directory.file_name().is_some_and(|name| name == marker.token.as_str()) { return; } + let Ok(entries) = fs::read_dir(directory) else { return; }; + if entries.into_iter().all(|entry| entry.is_ok_and(|entry| entry.file_name() == MARKER)) { + let _ = fs::remove_file(directory.join(MARKER)); let _ = fs::remove_dir(directory); + } + } + pub fn restore_backup(&self, id: Option, kind: &str) -> Result<(), String> { + let state = self.lock()?; + let project = key(id.as_deref())?; + if state.leases.contains_key(&project) || (kind == "catalog" && !state.leases.is_empty()) { return Err("项目仍有快照写入,请先等待或取消。".into()); } + self.ensure()?; + match kind { + "catalog" => { + let backup = io::json::(&self.root.join("catalog.backup.json"))?.ok_or("没有可恢复的项目档案备份。")?; + self.validate_catalog(&backup)?; + let target = self.root.join("catalog.json"); io::preserve(&target)?; io::atomic(&target, &backup) + } + "session" => { + let root = self.session_root(id.as_deref(), true)?; + let backup = io::json::(&root.join("session.backup.json"))?.ok_or("没有可恢复的会话备份。")?; + validate_bodies(self, &root, &backup, Some(Instant::now() + LEASE_LIMIT))?; + let target = root.join("session.json"); io::preserve(&target)?; io::atomic(&target, &backup) + } + _ => Err("备份类型必须是 catalog 或 session。".into()), + } + } +} diff --git a/src-tauri/src/projects/tests.rs b/src-tauri/src/projects/tests.rs new file mode 100644 index 0000000..ef18496 --- /dev/null +++ b/src-tauri/src/projects/tests.rs @@ -0,0 +1,355 @@ +use super::{io, types::{Document, Layout}, ProjectRepository, Snapshot}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +const PROJECT: &str = "11111111-1111-4111-8111-111111111111"; +const OTHER: &str = "22222222-2222-4222-8222-222222222222"; +const KEY: &str = "33333333-3333-4333-8333-333333333333"; +const TOKEN1: &str = "44444444-4444-4444-8444-444444444444"; +const TOKEN2: &str = "55555555-5555-4555-8555-555555555555"; +const TOKEN3: &str = "66666666-6666-4666-8666-666666666666"; + +struct Fixture { root: PathBuf, source: PathBuf, store: ProjectRepository } +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("inkstream-project-{}", io::nonce())); + fs::create_dir(&root).unwrap(); let root = root.canonicalize().unwrap(); + let source = root.join("中文项目"); fs::create_dir(&source).unwrap(); + fs::write(source.join("user.md"), "用户原文\n").unwrap(); + let store = ProjectRepository::new(root.join("app-data")); + Self { root, source, store } + } + fn register(&self) { self.store.register(PROJECT.into(), io::display(&self.source).unwrap(), "书稿".into()).unwrap(); } + fn checkpoint(&self, project: Option<&str>, token: &str, revision: u64, body: &str) -> super::StoredSession { + let id = project.map(str::to_owned); + let ticket = self.store.session_begin(id.clone(), token.into(), revision, vec![KEY.into()], "main".into()).unwrap(); + crate::files::write_atomic(&Path::new(&ticket.root).join(&ticket.entries[0].path), body).unwrap(); + let snapshot = snapshot(revision, ticket.entries[0].content_file.clone(), project.is_none()); + self.store.session_commit(id, token.into(), snapshot, "main").unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + self.store.close_owner("main"); self.store.close_owner("other"); + let _ = fs::remove_dir_all(&self.root); + } +} +fn snapshot(revision: u64, content_file: String, draft: bool) -> Snapshot { + let layout = Layout { sidebar_width: 280.0, right_panel_width: 320.0, sidebar_collapsed: false, right_panel_collapsed: false }; + let path = if draft { "draft://1" } else { "user.md" }.to_string(); + Snapshot { version: 1, revision, + documents: vec![Document { key: KEY.into(), path: path.clone(), name: "文稿".into(), external: false, draft, dirty: draft, + content_file, anchor: 0, head: 0, scroll_top: 0.0, render_mode: Some("live".into()) }], + active_path: Some(path), mode: "standard".into(), layouts: ["standard", "academic", "creative"].into_iter().map(|mode| (mode.into(), layout.clone())).collect::>(), active_tool: "outline".into() } +} + +#[test] +fn catalog_identity_favorites_relocation_and_soft_removal_do_not_modify_content() { + let f = Fixture::new(); f.register(); + f.store.update(PROJECT.into(), Some("自定义书名".into()), Some(true)).unwrap(); + let duplicate = f.store.register(OTHER.into(), io::display(&f.source.join(".")).unwrap(), "文件夹默认名".into()).unwrap(); + assert_eq!(duplicate.id, PROJECT); assert_eq!(duplicate.name, "自定义书名"); assert!(duplicate.favorite); + f.store.activate(Some(PROJECT.into())).unwrap(); + assert!(f.store.remove(PROJECT.into()).is_err()); + f.store.activate(None).unwrap(); f.store.remove(PROJECT.into()).unwrap(); + assert!(f.store.catalog_get().unwrap().projects[0].removed); + let restored = f.store.register(OTHER.into(), io::display(&f.source).unwrap(), "新名称".into()).unwrap(); + assert_eq!(restored.id, PROJECT); assert!(!restored.removed); assert!(restored.favorite); + let destination = f.root.join("新位置"); fs::create_dir(&destination).unwrap(); + let relocated = f.store.relocate(PROJECT.into(), io::display(&destination).unwrap()).unwrap(); + assert_eq!(relocated.id, PROJECT); assert_eq!(relocated.name, "自定义书名"); + assert_eq!(fs::read_to_string(f.source.join("user.md")).unwrap(), "用户原文\n"); + assert_eq!(fs::read_dir(&f.source).unwrap().count(), 1); + assert_eq!(fs::read_dir(&destination).unwrap().count(), 0); + let index = f.store.index_directory(&io::display(&destination).unwrap()).unwrap(); + assert!(index.starts_with(f.root.join("app-data/indexes"))); assert!(index.ends_with(PROJECT)); +} + +#[cfg(windows)] +#[test] +fn windows_directory_identity_is_case_insensitive() { + let f = Fixture::new(); f.register(); + let duplicate = f.store.register(OTHER.into(), io::display(&f.source).unwrap().to_uppercase(), "dup".into()).unwrap(); + assert_eq!(duplicate.id, PROJECT); +} + +#[test] +fn relocation_cannot_take_another_registered_directory_and_cover_is_an_owned_copy() { + let f = Fixture::new(); f.register(); + let other = f.root.join("other"); fs::create_dir(&other).unwrap(); + f.store.register(OTHER.into(), io::display(&other).unwrap(), "other".into()).unwrap(); + assert!(f.store.relocate(PROJECT.into(), io::display(&other).unwrap()).is_err()); + let cover = f.root.join("cover.png"); let png = b"\x89PNG\r\n\x1a\nfixture image bytes"; + fs::write(&cover, png).unwrap(); + let record = f.store.import_cover(PROJECT.into(), io::display(&cover).unwrap()).unwrap(); + fs::remove_file(&cover).unwrap(); + let managed = PathBuf::from(record.cover.unwrap()); + assert!(managed.starts_with(f.root.join("app-data/projects/covers").join(PROJECT))); + assert_eq!(fs::read(managed).unwrap(), png); + fs::write(&cover, b"not PNG").unwrap(); assert!(f.store.import_cover(PROJECT.into(), io::display(&cover).unwrap()).is_err()); + let large = fs::File::create(f.root.join("large.jpg")).unwrap(); large.set_len(8 * 1024 * 1024 + 1).unwrap(); drop(large); + assert!(f.store.import_cover(PROJECT.into(), io::display(&f.root.join("large.jpg")).unwrap()).is_err()); +} + +#[test] +fn unknown_and_corrupt_catalogs_are_not_replaced_by_empty_defaults_and_backup_restore_preserves_them() { + let f = Fixture::new(); f.register(); + let catalog = f.root.join("app-data/projects/catalog.json"); + for bad in [b"{broken".as_slice(), b"{\"version\":7,\"activeId\":null,\"projects\":[]}".as_slice()] { + fs::write(&catalog, bad).unwrap(); + assert!(f.store.catalog_get().is_err()); + assert!(f.store.update(PROJECT.into(), Some("must not save".into()), None).is_err()); + assert_eq!(fs::read(&catalog).unwrap(), bad); + f.store.restore_backup(None, "catalog").unwrap(); + assert_eq!(f.store.catalog_get().unwrap().projects[0].id, PROJECT); + assert!(fs::read_dir(catalog.parent().unwrap()).unwrap().flatten().any(|entry| entry.file_name().to_string_lossy().starts_with("catalog.json.preserved-") && fs::read(entry.path()).unwrap() == bad)); + } +} + +#[test] +fn null_project_drafts_survive_restart_as_immutable_versions_with_a_previous_backup() { + let f = Fixture::new(); + let first = f.checkpoint(None, TOKEN1, 0, "原稿🙂\n"); + let original = first.snapshot.unwrap(); + assert_eq!(original.revision, 1); + let old_file = Path::new(&first.root).join(&original.documents[0].content_file); + let second = f.checkpoint(None, TOKEN2, 1, "第二稿🙂\n"); + assert_eq!(fs::read_to_string(&old_file).unwrap(), "原稿🙂\n"); + let restarted = ProjectRepository::new(f.root.join("app-data")); + let current = restarted.session_read(None).unwrap().snapshot.unwrap(); + assert_eq!(current.revision, 2); + assert_eq!(fs::read_to_string(Path::new(&second.root).join(¤t.documents[0].content_file)).unwrap(), "第二稿🙂\n"); + let backup: Snapshot = serde_json::from_slice(&fs::read(Path::new(&second.root).join("session.backup.json")).unwrap()).unwrap(); + assert_eq!(backup, original); +} + +#[test] +fn snapshot_revision_owner_and_content_scope_are_compared_before_publish() { + let f = Fixture::new(); f.register(); + let first = f.checkpoint(Some(PROJECT), TOKEN1, 0, "first"); + let previous = first.snapshot.unwrap(); + assert!(f.store.session_begin(Some(PROJECT.into()), TOKEN2.into(), 0, vec![], "main".into()).is_err()); + let ticket = f.store.session_begin(Some(PROJECT.into()), TOKEN2.into(), 1, vec![KEY.into()], "main".into()).unwrap(); + assert!(f.store.session_begin(Some(PROJECT.into()), TOKEN3.into(), 1, vec![], "other".into()).is_err()); + let next = snapshot(1, ticket.entries[0].content_file.clone(), false); + assert!(f.store.session_commit(Some(PROJECT.into()), TOKEN2.into(), next.clone(), "other").is_err()); + assert!(f.store.session_abort(Some(PROJECT.into()), TOKEN2.into(), "other").is_err()); + assert!(f.store.session_commit(Some(PROJECT.into()), TOKEN2.into(), next, "main").is_err(), "an unwritten body cannot be published"); + assert_eq!(f.store.session_read(Some(PROJECT.into())).unwrap().snapshot.unwrap(), previous); + let ticket = f.store.session_begin(Some(PROJECT.into()), TOKEN3.into(), 1, vec![], "main".into()).unwrap(); + let foreign = snapshot(1, format!("versions/{TOKEN2}/{KEY}.txt"), false); + assert!(f.store.session_commit(Some(PROJECT.into()), ticket.token, foreign, "main").is_err()); + assert_eq!(f.store.session_read(Some(PROJECT.into())).unwrap().snapshot.unwrap(), previous); +} + +#[test] +fn abort_and_expiry_remove_only_registered_stage_files_and_keep_unknown_files() { + let f = Fixture::new(); + let ticket = f.store.session_begin(None, TOKEN1.into(), 0, vec![KEY.into()], "main".into()).unwrap(); + crate::files::write_atomic(&Path::new(&ticket.root).join(&ticket.entries[0].path), "owned").unwrap(); + fs::write(Path::new(&ticket.root).join("unknown.txt"), "preserve").unwrap(); + f.store.session_abort(None, TOKEN1.into(), "main").unwrap(); + assert!(!Path::new(&ticket.root).join(&ticket.entries[0].path).exists()); + assert_eq!(fs::read_to_string(Path::new(&ticket.root).join("unknown.txt")).unwrap(), "preserve"); + let second = f.store.session_begin(None, TOKEN2.into(), 0, vec![KEY.into()], "main".into()).unwrap(); + crate::files::write_atomic(&Path::new(&second.root).join(&second.entries[0].path), "expired").unwrap(); + f.store.state.lock().unwrap().leases.get_mut("scratch").unwrap().started = Instant::now() - Duration::from_secs(121); + let third = f.store.session_begin(None, TOKEN3.into(), 0, vec![], "main".into()).unwrap(); + assert!(!Path::new(&second.root).exists()); + f.store.close_owner("main"); assert!(!Path::new(&third.root).exists()); + assert!(Path::new(&ticket.root).join("unknown.txt").exists()); +} + +use std::time::{Duration, Instant}; + +#[test] +fn invalid_utf8_selections_and_duplicate_documents_do_not_replace_the_current_session() { + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "valid"); + for (token, bytes, bad_selection) in [(TOKEN2, vec![0xff], false), (TOKEN3, b"tiny".to_vec(), true)] { + let ticket = f.store.session_begin(None, token.into(), 1, vec![KEY.into()], "main".into()).unwrap(); + fs::write(Path::new(&ticket.root).join(&ticket.entries[0].path), bytes).unwrap(); + let mut value = snapshot(1, ticket.entries[0].content_file.clone(), true); + if bad_selection { value.documents[0].head = 999; } + assert!(f.store.session_commit(None, token.into(), value, "main").is_err()); + assert_eq!(f.store.session_read(None).unwrap().snapshot, first.snapshot); + } + let ticket = f.store.session_begin(None, TOKEN2.into(), 1, vec![], "main".into()).unwrap(); + let mut duplicate = first.snapshot.clone().unwrap(); duplicate.documents.push(duplicate.documents[0].clone()); + assert!(f.store.session_commit(None, ticket.token, duplicate, "main").is_err()); + assert_eq!(f.store.session_read(None).unwrap().snapshot, first.snapshot); +} + +#[test] +fn collapsed_and_small_window_geometry_does_not_block_a_valid_checkpoint() { + let f = Fixture::new(); + let ticket = f.store.session_begin(None, TOKEN1.into(), 0, vec![KEY.into()], "main".into()).unwrap(); + crate::files::write_atomic(&Path::new(&ticket.root).join(&ticket.entries[0].path), "draft").unwrap(); + let mut value = snapshot(0, ticket.entries[0].content_file.clone(), true); + value.layouts.get_mut("standard").unwrap().sidebar_width = 0.0; + value.layouts.get_mut("standard").unwrap().right_panel_width = 4096.0; + let saved = f.store.session_commit(None, TOKEN1.into(), value, "main").unwrap(); + assert_eq!(saved.snapshot.as_ref().unwrap().layouts["standard"].sidebar_width, 0.0); + let ticket = f.store.session_begin(None, TOKEN2.into(), 1, vec![], "main".into()).unwrap(); + let mut invalid = saved.snapshot.clone().unwrap(); invalid.layouts.get_mut("standard").unwrap().sidebar_width = f64::INFINITY; + assert!(f.store.session_commit(None, ticket.token, invalid, "main").is_err()); + assert_eq!(f.store.session_read(None).unwrap().snapshot, saved.snapshot); +} + +#[test] +fn collection_keeps_current_and_backup_bodies_and_never_removes_unknown_version_contents() { + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "one"); + let first_file = Path::new(&first.root).join(&first.snapshot.unwrap().documents[0].content_file); + let unknown = first_file.parent().unwrap().join("unknown.txt"); fs::write(&unknown, "preserve").unwrap(); + let second = f.checkpoint(None, TOKEN2, 1, "two"); + let second_file = Path::new(&second.root).join(&second.snapshot.unwrap().documents[0].content_file); + let third = f.checkpoint(None, TOKEN3, 2, "three"); + let third_file = Path::new(&third.root).join(&third.snapshot.unwrap().documents[0].content_file); + assert!(!first_file.exists()); assert!(second_file.exists()); assert!(third_file.exists()); + assert_eq!(fs::read_to_string(unknown).unwrap(), "preserve"); +} + +#[test] +fn restored_original_manifest_keeps_its_shared_body_after_later_checkpoint_collection() { + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "original recoverable body"); + let original_file = Path::new(&first.root).join(&first.snapshot.as_ref().unwrap().documents[0].content_file); + // Revision two reuses revision one's immutable body. Restoring revision one + // preserves revision two, which still needs that same body after future edits. + let ticket = f.store.session_begin(None, TOKEN2.into(), 1, vec![], "main".into()).unwrap(); + f.store.session_commit(None, ticket.token, first.snapshot.clone().unwrap(), "main").unwrap(); + f.store.restore_backup(None, "session").unwrap(); + f.checkpoint(None, TOKEN2, 1, "later body"); + f.checkpoint(None, TOKEN3, 2, "newest body"); + assert_eq!(fs::read_to_string(&original_file).unwrap(), "original recoverable body"); + let preserved = fs::read_dir(&first.root).unwrap().flatten().find(|entry| entry.file_name().to_string_lossy().starts_with("session.json.preserved-")).unwrap(); + let recovered: Snapshot = serde_json::from_slice(&fs::read(preserved.path()).unwrap()).unwrap(); + assert_eq!(recovered.revision, 2); + assert_eq!(fs::read_to_string(Path::new(&first.root).join(&recovered.documents[0].content_file)).unwrap(), "original recoverable body"); +} + +#[test] +fn an_unreadable_preserved_manifest_stops_gc_without_blocking_new_checkpoints() { + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "retain while original cannot be decoded"); + let original_file = Path::new(&first.root).join(&first.snapshot.as_ref().unwrap().documents[0].content_file); + fs::write(Path::new(&first.root).join("session.json"), b"{corrupt original").unwrap(); + f.store.restore_backup(None, "session").unwrap(); + f.checkpoint(None, TOKEN2, 1, "later body"); f.checkpoint(None, TOKEN3, 2, "newest body"); + assert!(original_file.is_file()); + assert_eq!(f.store.session_read(None).unwrap().snapshot.unwrap().revision, 3); +} + +#[test] +fn cursor_only_checkpoints_and_reads_reuse_verified_body_lengths_without_rescanning() { + let f = Fixture::new(); + let first = f.checkpoint(None, TOKEN1, 0, &"unchanged 🙂\n".repeat(100_000)); + f.store.session_read(None).unwrap(); // Warm the published path if its rename changed metadata. + let before = f.store.body_cache.lock().unwrap().statistics(); + let ticket = f.store.session_begin(None, TOKEN2.into(), 1, vec![], "main".into()).unwrap(); + let mut moved = first.snapshot.unwrap(); moved.documents[0].anchor = 1; moved.documents[0].head = 7; + f.store.session_commit(None, ticket.token, moved, "main").unwrap(); + f.store.session_read(None).unwrap(); + let after = f.store.body_cache.lock().unwrap().statistics(); + assert_eq!(after.0, before.0, "cursor metadata and backup validation must not reread the unchanged body"); + assert!(after.1 > before.1); +} + +#[test] +fn replacement_with_equal_size_and_modified_time_still_invalidates_the_body_identity_cache() { + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "ABCD"); + f.store.session_read(None).unwrap(); + let document = &first.snapshot.as_ref().unwrap().documents[0]; + let path = Path::new(&first.root).join(&document.content_file); + let modified = fs::metadata(&path).unwrap().modified().unwrap(); + crate::files::write_atomic(&path, "🙂").unwrap(); // Four UTF-8 bytes, but only two UTF-16 units. + fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(fs::FileTimes::new().set_modified(modified)).unwrap(); + let ticket = f.store.session_begin(None, TOKEN2.into(), 1, vec![], "main".into()).unwrap(); + let mut invalid_selection = first.snapshot.unwrap(); invalid_selection.documents[0].head = 4; + assert!(f.store.session_commit(None, ticket.token, invalid_selection, "main").unwrap_err().contains("选区")); + assert_eq!(f.store.body_length(&path, None, false).unwrap(), 2); +} + +#[test] +fn changed_body_bytes_are_revalidated_and_fresh_ticket_bodies_never_take_a_cache_shortcut() { + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "ABCD"); + f.store.session_read(None).unwrap(); + let path = Path::new(&first.root).join(&first.snapshot.as_ref().unwrap().documents[0].content_file); + fs::write(&path, [0xff; 4]).unwrap(); + assert!(f.store.session_read(None).unwrap_err().contains("UTF-8")); + crate::files::write_atomic(&path, "ABCD").unwrap(); + f.store.session_read(None).unwrap(); + let ticket = f.store.session_begin(None, TOKEN2.into(), 1, vec![KEY.into()], "main".into()).unwrap(); + let stage = Path::new(&ticket.root).join(&ticket.entries[0].path); + crate::files::write_atomic(&stage, "new body").unwrap(); + f.store.body_length(&stage, None, false).unwrap(); + let before = f.store.body_cache.lock().unwrap().statistics().0; + f.store.session_commit(None, ticket.token, snapshot(1, ticket.entries[0].content_file.clone(), true), "main").unwrap(); + assert_eq!(f.store.body_cache.lock().unwrap().statistics().0, before + 1, "new ticket content must receive a fresh full validation even when its metadata was cached"); +} + +#[test] +fn body_cache_is_bounded_and_expired_deadlines_fail_even_when_the_body_is_cached() { + let f = Fixture::new(); let directory = f.root.join("cache-fixture"); fs::create_dir(&directory).unwrap(); + for index in 0..520 { + let path = directory.join(format!("{index}.txt")); fs::write(&path, b"body").unwrap(); + f.store.body_length(&path, None, false).unwrap(); + } + let stats = f.store.body_cache.lock().unwrap().statistics(); + assert!(stats.2 <= 512 && stats.3 <= 1024 * 1024); + let path = directory.join("519.txt"); + assert!(f.store.body_length(&path, Some(Instant::now() - Duration::from_millis(1)), false).unwrap_err().contains("期限")); +} + +#[test] +fn failed_manifest_write_preserves_the_valid_session_and_unknown_destination_directory() { + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "first"); + let root = PathBuf::from(&first.root); + fs::remove_file(root.join("session.backup.json")).unwrap(); + fs::create_dir(root.join("session.backup.json")).unwrap(); + fs::write(root.join("session.backup.json/unknown.txt"), "preserve").unwrap(); + let ticket = f.store.session_begin(None, TOKEN2.into(), 1, vec![KEY.into()], "main".into()).unwrap(); + crate::files::write_atomic(&Path::new(&ticket.root).join(&ticket.entries[0].path), "next").unwrap(); + assert!(f.store.session_commit(None, TOKEN2.into(), snapshot(1, ticket.entries[0].content_file.clone(), true), "main").is_err()); + assert_eq!(f.store.session_read(None).unwrap().snapshot, first.snapshot); + assert_eq!(fs::read_to_string(root.join("session.backup.json/unknown.txt")).unwrap(), "preserve"); +} + +#[cfg(windows)] +#[test] +fn a_locked_manifest_cannot_publish_a_new_session_or_damage_the_previous_backup() { + use std::os::windows::fs::OpenOptionsExt; + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "first"); + let root = PathBuf::from(&first.root); + let ticket = f.store.session_begin(None, TOKEN2.into(), 1, vec![KEY.into()], "main".into()).unwrap(); + crate::files::write_atomic(&Path::new(&ticket.root).join(&ticket.entries[0].path), "next").unwrap(); + let held = fs::OpenOptions::new().read(true).share_mode(1).open(root.join("session.json")).unwrap(); + assert!(f.store.session_commit(None, TOKEN2.into(), snapshot(1, ticket.entries[0].content_file.clone(), true), "main").is_err()); + drop(held); + assert_eq!(f.store.session_read(None).unwrap().snapshot, first.snapshot); + assert!(!root.join("versions").join(TOKEN2).exists()); +} + +#[test] +fn oversized_or_unknown_session_metadata_is_preserved_and_explicit_backup_recovery_validates_bodies() { + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "draft\n"); + let root = PathBuf::from(&first.root); + let path = root.join("session.json"); + let bad = vec![b'x'; io::JSON_LIMIT + 1]; fs::write(&path, &bad).unwrap(); + assert!(f.store.session_read(None).is_err()); + f.store.restore_backup(None, "session").unwrap(); + assert_eq!(f.store.session_read(None).unwrap().snapshot, first.snapshot); + assert!(fs::read_dir(&root).unwrap().flatten().any(|entry| entry.file_name().to_string_lossy().starts_with("session.json.preserved-") && fs::read(entry.path()).unwrap() == bad)); + fs::remove_file(root.join(&first.snapshot.unwrap().documents[0].content_file)).unwrap(); + fs::write(&path, b"{damaged").unwrap(); + assert!(f.store.restore_backup(None, "session").is_err()); + assert_eq!(fs::read(path).unwrap(), b"{damaged"); +} + +#[cfg(unix)] +#[test] +fn version_symlinks_cannot_escape_the_managed_session() { + let f = Fixture::new(); let first = f.checkpoint(None, TOKEN1, 0, "owned"); + let body = Path::new(&first.root).join(&first.snapshot.unwrap().documents[0].content_file); + fs::remove_file(&body).unwrap(); std::os::unix::fs::symlink(f.source.join("user.md"), &body).unwrap(); + assert!(f.store.session_read(None).is_err()); + assert_eq!(fs::read_to_string(f.source.join("user.md")).unwrap(), "用户原文\n"); +} diff --git a/src-tauri/src/projects/types.rs b/src-tauri/src/projects/types.rs new file mode 100644 index 0000000..7df33aa --- /dev/null +++ b/src-tauri/src/projects/types.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectRecord { + pub id: String, pub root: String, pub name: String, pub favorite: bool, + pub cover: Option, pub created_at: u64, pub last_opened_at: u64, pub removed: bool, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProjectCatalog { pub version: u32, pub active_id: Option, pub projects: Vec } +impl Default for ProjectCatalog { + fn default() -> Self { Self { version: 1, active_id: None, projects: Vec::new() } } +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Document { + pub key: String, pub path: String, pub name: String, pub external: bool, pub draft: bool, + pub dirty: bool, pub content_file: String, pub anchor: u64, pub head: u64, + pub scroll_top: f64, pub render_mode: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Layout { + pub sidebar_width: f64, pub right_panel_width: f64, + pub sidebar_collapsed: bool, pub right_panel_collapsed: bool, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Snapshot { + pub version: u32, pub revision: u64, pub documents: Vec, + pub active_path: Option, pub mode: String, pub layouts: BTreeMap, pub active_tool: String, +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredSession { pub root: String, pub snapshot: Option } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Ticket { pub token: String, pub root: String, pub entries: Vec } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TicketEntry { pub key: String, pub path: String, pub content_file: String } + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct Ownership { + pub version: u32, pub project: String, pub token: String, pub keys: Vec, + pub owner: String, pub created_at: u64, pub expected_revision: u64, +} diff --git a/src-tauri/src/zotero.rs b/src-tauri/src/zotero.rs index 77e181b..e36a4a3 100644 --- a/src-tauri/src/zotero.rs +++ b/src-tauri/src/zotero.rs @@ -45,6 +45,10 @@ pub async fn zotero_cayw() -> Result { /// BBT JSON-RPC `item.search("")` → 库内全部条目(CSL-JSON Value 数组)。短超时(非交互)。 /// 连接拒绝=未运行、404=BBT 未装、超时分别友好报错。zotero_citekeys / zotero_items 共用。 async fn bbt_search() -> Result, String> { + bbt_search_at(JSONRPC_URL).await +} + +async fn bbt_search_at(url: &str) -> Result, String> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .build() @@ -52,7 +56,7 @@ async fn bbt_search() -> Result, String> { let payload = serde_json::json!({ "jsonrpc": "2.0", "method": "item.search", "params": [""], "id": 1 }); let resp = client - .post(JSONRPC_URL) + .post(url) .header("Accept", "application/json") .json(&payload) .send() @@ -69,14 +73,26 @@ async fn bbt_search() -> Result, String> { if resp.status().as_u16() == 404 { return Err("Zotero 未安装 Better BibTeX 插件".to_string()); } - let v: serde_json::Value = resp + if !resp.status().is_success() { + return Err(format!("Zotero 返回错误: {}", resp.status())); + } + let mut v: serde_json::Value = resp .json() .await .map_err(|e| format!("解析 Zotero 响应失败: {e}"))?; - Ok(v.get("result") - .and_then(|r| r.as_array()) - .cloned() - .unwrap_or_default()) + // BBT reports JSON-RPC errors with HTTP 200 too. Returning [] here would + // falsely clear the online library and bypass the caller's offline fallback. + if let Some(error) = v.get("error").filter(|error| !error.is_null()) { + let message: String = error.get("message").and_then(|value| value.as_str()) + .unwrap_or("Better BibTeX 查询失败").chars().take(240).collect(); + return Err(format!("Zotero 查询失败: {message}")); + } + let items = v.get_mut("result").and_then(|result| result.as_array_mut()) + .ok_or_else(|| "Zotero 返回的文献列表格式错误,请重试。".to_string())?; + if items.iter().any(|item| !item.is_object()) { + return Err("Zotero 返回了格式错误的文献条目,请重试。".into()); + } + Ok(std::mem::take(items)) } /// CSL-JSON 字段名:citekey(BBT)/ citation-key(CSL),都试。 @@ -184,3 +200,7 @@ pub async fn zotero_csl(keys: Vec) -> Result, Str .filter_map(|k| by_key.get(k.as_str()).map(|v| (*v).clone())) .collect()) } + +#[cfg(test)] +#[path = "zotero_tests.rs"] +mod tests; diff --git a/src-tauri/src/zotero_sync.rs b/src-tauri/src/zotero_sync.rs index e3f334d..5eb1be5 100644 --- a/src-tauri/src/zotero_sync.rs +++ b/src-tauri/src/zotero_sync.rs @@ -1,27 +1,28 @@ -//! Zotero Web API 增量同步 + 本地 SQLite 离线缓存(Phase 8 ZOT-02)。 -//! -//! API Key + userID 存 OS 凭据库(keyring,绝不回传前端,零信任);增量同步按 library version -//! (`Last-Modified-Version` 头)拉 `format=csljson` 落地全局 SQLite 缓存。Zotero 离线时 -//! Sidebar 文献库 / 参考文献展开可回退读缓存(zotero_cache_*)。缓存按 citekey(CSL `citation-key` -//! 或 BBT `citekey`)索引——故离线 `[@key]` 解析依赖条目已设原生 Citation Key(Zotero 6.0.27+)。 +//! Zotero Web API 同步入口。凭据留在 OS 凭据库;缓存按官方用户库隔离。 +//! 更新、删除与游标只有在同一远端版本完整下载后才一起提交。 use crate::zotero::{to_zotero_item, ZoteroItem}; use keyring_core::Entry; use serde::Serialize; use serde_json::Value; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use sqlx::sqlite::SqlitePool; use std::sync::Once; use std::time::Duration; use tauri::{AppHandle, Manager}; +mod cache; +mod download; +#[cfg(test)] +use cache::SCHEMA; +use cache::{cached_csl, get_version, open_cache_at, CommitError}; +#[cfg(test)] +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + const SERVICE: &str = "inkstream"; const USER: &str = "zotero-credentials"; const API_BASE: &str = "https://api.zotero.org"; const PAGE: usize = 100; -const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS zotero_items (\ - zkey TEXT PRIMARY KEY, citekey TEXT NOT NULL DEFAULT '', version INTEGER, csl TEXT NOT NULL);\ - CREATE INDEX IF NOT EXISTS idx_zotero_citekey ON zotero_items(citekey);\ - CREATE TABLE IF NOT EXISTS zotero_meta (k TEXT PRIMARY KEY, v TEXT);"; +const MAX_SYNC_ATTEMPTS: usize = 3; static INIT: Once = Once::new(); fn entry() -> Result { @@ -46,21 +47,19 @@ fn read_creds() -> Option { .filter(|c| !c.api_key.trim().is_empty() && !c.user_id.trim().is_empty()) } -/// 保存 Zotero API Key + userID 到 OS 凭据库(单条 JSON 条目)。 #[tauri::command] pub async fn zotero_set_credentials(api_key: String, user_id: String) -> Result<(), String> { let api_key = api_key.trim().to_string(); - let user_id = user_id.trim().to_string(); - if api_key.is_empty() || user_id.is_empty() { - return Err("API Key 与 userID 均不能为空".into()); + if api_key.is_empty() { + return Err("API Key 不能为空".into()); } + let user_id = cache::canonical_user_id(&user_id)?; let json = serde_json::to_string(&Creds { api_key, user_id }).map_err(|e| e.to_string())?; entry()? .set_password(&json) .map_err(|e| format!("保存凭据失败: {e}")) } -/// 清除已存的 Zotero 凭据。 #[tauri::command] pub async fn zotero_clear_credentials() -> Result<(), String> { let _ = entry()?.delete_credential(); @@ -74,7 +73,6 @@ pub struct ZoteroCredStatus { pub user_id: String, } -/// 凭据状态(是否已配置 + userID)。API Key 本身绝不回传。 #[tauri::command] pub async fn zotero_credentials_status() -> Result { Ok(match read_creds() { @@ -89,67 +87,15 @@ pub async fn zotero_credentials_status() -> Result { }) } -async fn open_cache(app: &AppHandle) -> Result { +async fn open_cache(app: &AppHandle, user_id: &str) -> Result { let dir = app .path() .app_data_dir() .map_err(|e| format!("无法解析应用数据目录: {e}"))?; - std::fs::create_dir_all(&dir).map_err(|e| format!("无法创建数据目录: {e}"))?; - let opts = SqliteConnectOptions::new() - .filename(dir.join("zotero-cache.db")) - .create_if_missing(true) - .busy_timeout(Duration::from_secs(5)); - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(opts) - .await - .map_err(|e| format!("无法打开缓存库: {e}"))?; - sqlx::raw_sql(SCHEMA) - .execute(&pool) - .await - .map_err(|e| format!("建表失败: {e}"))?; - Ok(pool) -} - -async fn get_version(pool: &SqlitePool) -> i64 { - sqlx::query_scalar::<_, String>("SELECT v FROM zotero_meta WHERE k='library_version'") - .fetch_optional(pool) - .await - .ok() - .flatten() - .and_then(|s| s.parse().ok()) - .unwrap_or(0) -} - -async fn upsert_item(pool: &SqlitePool, it: &Value, ver: i64) -> Result { - let zkey = it.get("id").and_then(|v| v.as_str()).unwrap_or(""); - if zkey.is_empty() { - return Ok(false); - } - let citekey = crate::zotero::item_citekey(it); - let csl = serde_json::to_string(it).map_err(|e| e.to_string())?; - sqlx::query( - "INSERT INTO zotero_items(zkey,citekey,version,csl) VALUES(?,?,?,?) \ - ON CONFLICT(zkey) DO UPDATE SET citekey=excluded.citekey, version=excluded.version, csl=excluded.csl", - ) - .bind(zkey) - .bind(citekey) - .bind(ver) - .bind(csl) - .execute(pool) - .await - .map_err(|e| e.to_string())?; - Ok(true) -} - -fn header_i64(resp: &reqwest::Response, name: &str) -> Option { - resp.headers() - .get(name) - .and_then(|h| h.to_str().ok()) - .and_then(|s| s.parse().ok()) + open_cache_at(&dir, user_id).await } -#[derive(Serialize)] +#[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ZoteroSyncResult { pub synced: i64, @@ -157,158 +103,85 @@ pub struct ZoteroSyncResult { pub version: i64, } -/// 增量同步:自上次 library version 起拉取改动条目(分页)+ 删除增量,落地缓存。 -/// 首次(version=0)拉全库。403=Key 无效、404=userID 不存在、连接失败=离线,均友好报错。 +struct Snapshot { + version: i64, + items: Vec, + deleted: Vec, +} + +struct CachedItem { + key: String, + csl: Value, +} + #[tauri::command] pub async fn zotero_sync(app: AppHandle) -> Result { - let creds = read_creds().ok_or("尚未配置 Zotero API Key(请在设置中填写)")?; - let pool = open_cache(&app).await?; - let since = get_version(&pool).await; + let mut creds = read_creds().ok_or("尚未配置 Zotero API Key(请在设置中填写)")?; + creds.user_id = cache::canonical_user_id(&creds.user_id)?; + let pool = open_cache(&app, &creds.user_id).await?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .map_err(|e| format!("初始化请求失败: {e}"))?; - let mut start = 0usize; - let mut total = usize::MAX; - let mut synced = 0i64; - let mut new_ver = since; - while start < total { - let url = format!( - "{API_BASE}/users/{}/items?format=csljson&since={since}&start={start}&limit={PAGE}", - creds.user_id - ); - let resp = client - .get(&url) - .header("Zotero-API-Key", &creds.api_key) - .header("Zotero-API-Version", "3") - .send() - .await - .map_err(|e| { - if e.is_connect() { - "无法连接 Zotero 服务器(离线?)".to_string() - } else if e.is_timeout() { - "Zotero 服务器响应超时".to_string() - } else { - format!("同步请求失败: {e}") - } - })?; - let status = resp.status(); - if status.as_u16() == 403 { - return Err("API Key 无效或无访问权限(403)".into()); - } - if status.as_u16() == 404 { - return Err("userID 不存在(404)".into()); - } - if !status.is_success() { - return Err(format!("Zotero 服务器错误: {status}")); - } - if let Some(t) = header_i64(&resp, "Total-Results") { - total = t.max(0) as usize; - } - if let Some(v) = header_i64(&resp, "Last-Modified-Version") { - new_ver = v; - } - let body: Value = resp.json().await.map_err(|e| format!("解析响应失败: {e}"))?; - let items = body - .get("items") - .and_then(|i| i.as_array()) - .cloned() - .or_else(|| body.as_array().cloned()) - .unwrap_or_default(); - if items.is_empty() { - break; - } - for it in &items { - if upsert_item(&pool, it, new_ver).await? { - synced += 1; - } - } - start += PAGE; - } - let removed = sync_deleted(&client, &creds, &pool, since).await.unwrap_or(0); - sqlx::query( - "INSERT INTO zotero_meta(k,v) VALUES('library_version',?) \ - ON CONFLICT(k) DO UPDATE SET v=excluded.v", - ) - .bind(new_ver.to_string()) - .execute(&pool) - .await - .map_err(|e| e.to_string())?; - Ok(ZoteroSyncResult { - synced, - removed, - version: new_ver, - }) + let result = sync_library(&client, &creds, &pool, API_BASE).await; + pool.close().await; + result } -async fn sync_deleted( +/// 公开同步命令与行为测试使用同一缓存/HTTP seam;测试只替换端点与缓存目录。 +async fn sync_library( client: &reqwest::Client, creds: &Creds, pool: &SqlitePool, - since: i64, -) -> Result { - let url = format!("{API_BASE}/users/{}/deleted?since={since}", creds.user_id); - let resp = client - .get(&url) - .header("Zotero-API-Key", &creds.api_key) - .header("Zotero-API-Version", "3") - .send() - .await - .map_err(|e| e.to_string())?; - if !resp.status().is_success() { - return Ok(0); - } - let body: Value = resp.json().await.map_err(|e| e.to_string())?; - let keys = body - .get("items") - .and_then(|i| i.as_array()) - .cloned() - .unwrap_or_default(); - let mut removed = 0i64; - for k in keys.iter().filter_map(|k| k.as_str()) { - sqlx::query("DELETE FROM zotero_items WHERE zkey=?") - .bind(k) - .execute(pool) - .await - .map_err(|e| e.to_string())?; - removed += 1; + api_base: &str, +) -> Result { + // Zotero 协议要求每个响应库版本相同,变化时重启;失败绝不推进游标。 + // https://www.zotero.org/support/dev/web_api/v3/syncing#iii_check_for_concurrent_remote_updates + for attempt in 0..MAX_SYNC_ATTEMPTS { + let since = get_version(pool).await?; + match download::snapshot(client, creds, since, api_base).await { + Ok(snapshot) => match cache::commit_snapshot(pool, since, snapshot).await { + Ok(result) => return Ok(result), + Err(CommitError::Failed(message)) => return Err(message), + Err(CommitError::Changed) => {} // 同库另一同步已提交,重新读取新游标。 + }, + Err(download::DownloadError::Failed(message)) => return Err(message), + Err(download::DownloadError::Changed) => {} + } + if attempt + 1 < MAX_SYNC_ATTEMPTS { + tokio::time::sleep(Duration::from_millis(250 * (attempt as u64 + 1))).await; + } } - Ok(removed) + Err("文献库在同步期间持续变化,已停止本次同步;原缓存与游标保持不变,请稍后重试。".into()) } -/// 离线读缓存:Sidebar 文献库(仅含已设 citekey 的可引用条目)。 #[tauri::command] pub async fn zotero_cache_items(app: AppHandle) -> Result, String> { - let pool = open_cache(&app).await?; - let rows = sqlx::query_scalar::<_, String>("SELECT csl FROM zotero_items WHERE citekey<>''") - .fetch_all(&pool) - .await - .map_err(|e| format!("读取缓存失败: {e}"))?; - Ok(rows + let creds = read_creds().ok_or("请先配置 Zotero 账户,再读取该账户的离线缓存。")?; + let pool = open_cache(&app, &creds.user_id).await?; + let rows = sqlx::query_scalar::<_, String>( + "SELECT csl FROM zotero_items WHERE citekey<>'' ORDER BY zkey", + ) + .fetch_all(&pool) + .await + .map_err(|e| format!("读取缓存失败: {e}")); + pool.close().await; + Ok(rows? .iter() .filter_map(|csl| serde_json::from_str::(csl).ok()) - .filter_map(|v| to_zotero_item(&v)) + .filter_map(|value| to_zotero_item(&value)) .collect()) } -/// 离线读缓存:指定 citekey 的完整 CSL-JSON(参考文献展开回退)。按入参顺序返回,缺失跳过。 #[tauri::command] pub async fn zotero_cache_csl(app: AppHandle, keys: Vec) -> Result, String> { - let pool = open_cache(&app).await?; - let rows = sqlx::query_as::<_, (String, String)>( - "SELECT citekey, csl FROM zotero_items WHERE citekey<>''", - ) - .fetch_all(&pool) - .await - .map_err(|e| format!("读取缓存失败: {e}"))?; - let mut by_key: std::collections::HashMap = std::collections::HashMap::new(); - for (ck, csl) in rows { - if let Ok(v) = serde_json::from_str::(&csl) { - by_key.entry(ck).or_insert(v); - } - } - Ok(keys - .iter() - .filter_map(|k| by_key.get(k).cloned()) - .collect()) + let creds = read_creds().ok_or("请先配置 Zotero 账户,再读取该账户的离线缓存。")?; + let pool = open_cache(&app, &creds.user_id).await?; + let result = cached_csl(&pool, keys).await; + pool.close().await; + result } + +#[cfg(test)] +#[path = "zotero_sync/tests.rs"] +mod tests; diff --git a/src-tauri/src/zotero_sync/cache.rs b/src-tauri/src/zotero_sync/cache.rs new file mode 100644 index 0000000..c2898da --- /dev/null +++ b/src-tauri/src/zotero_sync/cache.rs @@ -0,0 +1,150 @@ +use super::{CachedItem, Snapshot, ZoteroSyncResult}; +use serde_json::Value; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use sqlx::{Executor, Sqlite}; +use std::path::Path; +use std::time::Duration; + +pub(super) const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS zotero_items (\ + zkey TEXT PRIMARY KEY, citekey TEXT NOT NULL DEFAULT '', version INTEGER, csl TEXT NOT NULL);\ + CREATE INDEX IF NOT EXISTS idx_zotero_citekey ON zotero_items(citekey);\ + CREATE TABLE IF NOT EXISTS zotero_meta (k TEXT PRIMARY KEY, v TEXT);"; + +pub(super) fn canonical_user_id(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { + return Err("userID 必须是 Zotero 账户的正整数 ID".into()); + } + value + .parse::() + .ok() + .filter(|id| *id > 0) + .map(|id| id.to_string()) + .ok_or_else(|| "userID 必须是 Zotero 账户的正整数 ID".into()) +} + +pub(super) async fn open_cache_at(dir: &Path, user_id: &str) -> Result { + let user_id = canonical_user_id(user_id)?; + // v1 的 zotero-cache.db 没有归属元数据,原样留存,绝不猜测它属于当前账户。 + // 本路径只容纳官方 Web API 的 users 库;未来 groups/local API 必须使用不同分区。 + let partition = dir.join("zotero-cache-v2"); + std::fs::create_dir_all(&partition).map_err(|e| format!("无法创建缓存目录: {e}"))?; + let options = SqliteConnectOptions::new() + .filename(partition.join(format!("users-{user_id}.db"))) + .create_if_missing(true) + .busy_timeout(Duration::from_secs(5)); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .map_err(|e| format!("无法打开账户缓存: {e}"))?; + sqlx::raw_sql(SCHEMA) + .execute(&pool) + .await + .map_err(|e| format!("建立缓存表失败: {e}"))?; + Ok(pool) +} + +pub(super) async fn get_version(pool: &SqlitePool) -> Result { + let value = + sqlx::query_scalar::<_, String>("SELECT v FROM zotero_meta WHERE k='library_version'") + .fetch_optional(pool) + .await + .map_err(|e| format!("读取同步游标失败: {e}"))?; + parse_version(value) +} + +fn parse_version(value: Option) -> Result { + match value { + None => Ok(0), + Some(value) => value + .parse::() + .ok() + .filter(|v| *v >= 0) + .ok_or_else(|| "账户缓存的同步游标无效,未开始同步。".into()), + } +} + +async fn upsert_item<'e, E>(executor: E, item: &CachedItem, version: i64) -> Result<(), String> +where + E: Executor<'e, Database = Sqlite>, +{ + let citekey = crate::zotero::item_citekey(&item.csl); + let csl = serde_json::to_string(&item.csl).map_err(|e| e.to_string())?; + sqlx::query("INSERT INTO zotero_items(zkey,citekey,version,csl) VALUES(?,?,?,?) \ + ON CONFLICT(zkey) DO UPDATE SET citekey=excluded.citekey, version=excluded.version, csl=excluded.csl") + .bind(&item.key).bind(citekey).bind(version).bind(csl).execute(executor).await + .map_err(|e| format!("写入文献缓存失败: {e}"))?; + Ok(()) +} + +pub(super) enum CommitError { + Changed, + Failed(String), +} + +impl From for CommitError { + fn from(error: sqlx::Error) -> Self { + Self::Failed(format!("提交文献缓存失败: {error}")) + } +} + +pub(super) async fn commit_snapshot( + pool: &SqlitePool, + since: i64, + snapshot: Snapshot, +) -> Result { + let mut transaction = pool.begin().await?; + let current = + sqlx::query_scalar::<_, String>("SELECT v FROM zotero_meta WHERE k='library_version'") + .fetch_optional(&mut *transaction) + .await?; + if parse_version(current).map_err(CommitError::Failed)? != since { + return Err(CommitError::Changed); + } + for item in &snapshot.items { + upsert_item(&mut *transaction, item, snapshot.version) + .await + .map_err(CommitError::Failed)?; + } + let mut removed = 0; + for key in &snapshot.deleted { + removed += sqlx::query("DELETE FROM zotero_items WHERE zkey=?") + .bind(key) + .execute(&mut *transaction) + .await? + .rows_affected() as i64; + } + sqlx::query( + "INSERT INTO zotero_meta(k,v) VALUES('library_version',?) \ + ON CONFLICT(k) DO UPDATE SET v=excluded.v", + ) + .bind(snapshot.version.to_string()) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(ZoteroSyncResult { + synced: snapshot.items.len() as i64, + removed, + version: snapshot.version, + }) +} + +pub(super) async fn cached_csl(pool: &SqlitePool, keys: Vec) -> Result, String> { + let rows = sqlx::query_as::<_, (String, String)>( + "SELECT citekey, csl FROM zotero_items WHERE citekey<>'' ORDER BY zkey", + ) + .fetch_all(pool) + .await + .map_err(|e| format!("读取缓存失败: {e}"))?; + let mut by_key = std::collections::HashMap::new(); + for (key, csl) in rows { + if let Ok(value) = serde_json::from_str::(&csl) { + by_key.entry(key).or_insert(value); + } + } + Ok(keys + .iter() + .filter_map(|key| by_key.get(key).cloned()) + .collect()) +} diff --git a/src-tauri/src/zotero_sync/download.rs b/src-tauri/src/zotero_sync/download.rs new file mode 100644 index 0000000..470c7cc --- /dev/null +++ b/src-tauri/src/zotero_sync/download.rs @@ -0,0 +1,226 @@ +use super::{CachedItem, Creds, Snapshot, PAGE}; +use serde_json::Value; +use std::collections::HashSet; + +pub(super) enum DownloadError { + Changed, + Failed(String), +} + +impl From for DownloadError { + fn from(message: String) -> Self { + Self::Failed(message) + } +} + +fn header_number(response: &reqwest::Response, name: &str) -> Result { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .filter(|value| *value >= 0) + .ok_or_else(|| { + DownloadError::Failed(format!("Zotero 响应缺少有效的 {name},本次同步未提交。")) + }) +} + +async fn get( + client: &reqwest::Client, + creds: &Creds, + url: &str, +) -> Result { + let response = client + .get(url) + .header("Zotero-API-Key", &creds.api_key) + .header("Zotero-API-Version", "3") + .send() + .await + .map_err(|error| { + #[cfg(test)] + { + use std::error::Error; + eprintln!("Zotero fixture request error: {error:?}"); + let mut source = error.source(); + while let Some(cause) = source { + eprintln!(" caused by: {cause:?}"); + source = cause.source(); + } + } + if error.is_timeout() { + "Zotero 服务器响应超时".to_string() + } else if error.is_connect() { + "无法连接 Zotero 服务器".to_string() + } else { + format!("Zotero 同步请求失败: {error}") + } + })?; + match response.status().as_u16() { + 200 => Ok(response), + 403 => Err(DownloadError::Failed( + "API Key 无效或无访问权限(403)".into(), + )), + 404 => Err(DownloadError::Failed("userID 不存在(404)".into())), + status => Err(DownloadError::Failed(format!( + "Zotero 服务器错误: {status};本次同步未提交。" + ))), + } +} + +fn same_version(current: i64, expected: Option, since: i64) -> Result<(), DownloadError> { + if current < since { + return Err(DownloadError::Failed( + "远端版本低于已同步版本,本次同步未提交。".into(), + )); + } + if expected.is_some_and(|version| version != current) { + return Err(DownloadError::Changed); + } + Ok(()) +} + +fn cached_item(item: &Value) -> Result { + let key = item + .get("key") + .and_then(Value::as_str) + .filter(|key| !key.is_empty()) + .ok_or_else(|| { + DownloadError::Failed("Zotero 条目缺少稳定的 itemKey,本次同步未提交。".into()) + })?; + let data = item + .get("data") + .filter(|value| value.is_object()) + .ok_or_else(|| DownloadError::Failed("Zotero 条目缺少元数据,本次同步未提交。".into()))?; + let deleted = data + .get("deleted") + .is_some_and(|value| value.as_i64() == Some(1) || value.as_bool() == Some(true)); + let non_bibliographic = matches!( + data.get("itemType").and_then(Value::as_str), + Some("note" | "attachment" | "annotation") + ); + let mut csl = if deleted || non_bibliographic { + // 用不可引用状态替换同 key 的旧记录,避免条目移入回收站或改类型后仍可被引用。 + Value::Null + } else { + item.get("csljson") + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| { + DownloadError::Failed("Zotero 条目缺少有效 CSL 数据,本次同步未提交。".into()) + })? + }; + if csl.is_object() && crate::zotero::item_citekey(&csl).is_empty() { + let citekey = data + .get("citationKey") + .and_then(Value::as_str) + .map(str::trim) + .filter(|key| !key.is_empty()) + .or_else(|| { + data.get("extra") + .and_then(Value::as_str)? + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + let value = value.trim(); + (name.trim().eq_ignore_ascii_case("citation key") && !value.is_empty()) + .then_some(value) + }) + }); + if let Some(citekey) = citekey { + csl.as_object_mut() + .unwrap() + .insert("citation-key".into(), Value::String(citekey.to_string())); + } + } + Ok(CachedItem { + key: key.to_string(), + csl, + }) +} + +pub(super) async fn snapshot( + client: &reqwest::Client, + creds: &Creds, + since: i64, + api_base: &str, +) -> Result { + let mut items = Vec::new(); + let mut keys = HashSet::new(); + let mut start = 0usize; + let mut version = None; + let mut expected_total = None; + loop { + // JSON envelope 提供不可变 itemKey;CSL id 可能是 URI 或引用键,不能拿来匹配删除日志。 + // includeTrashed=1 确保移入回收站的变化也能使旧可引用缓存失效。 + let url = format!("{api_base}/users/{}/items?format=json&include=data,csljson&includeTrashed=1&since={since}&start={start}&limit={PAGE}", creds.user_id); + let response = get(client, creds, &url).await?; + let page_version = header_number(&response, "Last-Modified-Version")?; + same_version(page_version, version, since)?; + version = Some(page_version); + let total = usize::try_from(header_number(&response, "Total-Results")?) + .map_err(|_| DownloadError::Failed("Zotero 返回的条目总数无效。".into()))?; + if expected_total.is_some_and(|previous| previous != total) { + return Err(DownloadError::Changed); + } + expected_total = Some(total); + let body: Value = response + .json() + .await + .map_err(|e| format!("解析文献响应失败: {e}"))?; + let page = body.as_array().ok_or_else(|| { + DownloadError::Failed("Zotero 文献响应格式无效,本次同步未提交。".into()) + })?; + let expected_count = total.saturating_sub(start).min(PAGE); + if page.len() != expected_count { + return Err(DownloadError::Failed( + "Zotero 分页条目不完整,本次同步未提交。".into(), + )); + } + for item in page { + let item = cached_item(item)?; + if !keys.insert(item.key.clone()) { + return Err(DownloadError::Failed( + "Zotero 分页返回重复条目,本次同步未提交。".into(), + )); + } + items.push(item); + } + if start + page.len() >= total { + break; + } + start = start + .checked_add(PAGE) + .ok_or_else(|| DownloadError::Failed("Zotero 分页范围无效。".into()))?; + } + let response = get( + client, + creds, + &format!("{api_base}/users/{}/deleted?since={since}", creds.user_id), + ) + .await?; + let deleted_version = header_number(&response, "Last-Modified-Version")?; + same_version(deleted_version, version, since)?; + let body: Value = response + .json() + .await + .map_err(|e| format!("解析删除记录失败: {e}"))?; + let deleted = body + .get("items") + .and_then(Value::as_array) + .ok_or_else(|| DownloadError::Failed("Zotero 删除记录格式无效,本次同步未提交。".into()))? + .iter() + .map(|key| { + key.as_str() + .filter(|key| !key.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + DownloadError::Failed("Zotero 删除记录缺少条目标识,本次同步未提交。".into()) + }) + }) + .collect::, _>>()?; + Ok(Snapshot { + version: deleted_version, + items, + deleted, + }) +} diff --git a/src-tauri/src/zotero_sync/failure_tests.rs b/src-tauri/src/zotero_sync/failure_tests.rs new file mode 100644 index 0000000..d6596f9 --- /dev/null +++ b/src-tauri/src/zotero_sync/failure_tests.rs @@ -0,0 +1,72 @@ +use super::*; + +#[test] +fn sqlite_write_failure_rolls_back_all_entries_deletions_and_the_cursor() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let api = Api::new(vec![ + Reply::items( + 1, + 1, + vec![paper("ORIGIN00", "original", "Last complete cache")], + ), + Reply::deleted(1, &[]), + Reply::items( + 2, + 2, + vec![ + paper("GOODKEY0", "good", "Must roll back"), + paper("BADKEY00", "bad", "Rejected by SQLite"), + ], + ), + Reply::deleted(2, &["ORIGIN00"]), + ]); + let pool = open_cache_at(&sandbox.0, "101").await.unwrap(); + let first = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + // A real SQLite failure after the first row was written exercises transaction rollback. + sqlx::raw_sql( + "CREATE TRIGGER reject_fixture_entry BEFORE INSERT ON zotero_items \ + WHEN NEW.zkey='BADKEY00' BEGIN SELECT RAISE(FAIL,'fixture write denied'); END;", + ) + .execute(&pool) + .await + .unwrap(); + let failed = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let visible = titles(&pool, &["original", "good", "bad"]).await; + let cursor = get_version(&pool).await.unwrap(); + pool.close().await; + assert!(first.is_ok()); + assert!(failed.is_err()); + assert_eq!(cursor, 1); + assert_eq!(visible, vec!["Last complete cache"]); + }); +} + +#[test] +fn malformed_successful_deletion_response_does_not_advance_the_cursor() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let mut malformed = Reply::deleted(2, &[]); + malformed.body = serde_json::json!({ "items": "invalid deletion list" }); + let api = Api::new(vec![ + Reply::items( + 1, + 1, + vec![paper("ORIGIN00", "original", "Last complete cache")], + ), + Reply::deleted(1, &[]), + Reply::items(2, 1, vec![paper("NEWKEY00", "new", "Not committed")]), + malformed, + ]); + let pool = open_cache_at(&sandbox.0, "101").await.unwrap(); + let first = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let failed = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let visible = titles(&pool, &["original", "new"]).await; + let cursor = get_version(&pool).await.unwrap(); + pool.close().await; + assert!(first.is_ok()); + assert!(failed.is_err()); + assert_eq!(cursor, 1); + assert_eq!(visible, vec!["Last complete cache"]); + }); +} diff --git a/src-tauri/src/zotero_sync/protocol_tests.rs b/src-tauri/src/zotero_sync/protocol_tests.rs new file mode 100644 index 0000000..1c506f2 --- /dev/null +++ b/src-tauri/src/zotero_sync/protocol_tests.rs @@ -0,0 +1,126 @@ +use super::*; + +fn envelope(key: &str, csl_id: &str, citekey: &str, title: &str, version: i64) -> Value { + serde_json::json!({ + "key": key, "version": version, + "data": { "key": key, "version": version, "itemType": "journalArticle", "citationKey": citekey }, + "csljson": paper(csl_id, citekey, title), + }) +} + +#[test] +fn deleted_item_keys_remove_entries_even_when_the_csl_identifier_is_a_uri() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let api = Api::new(vec![ + Reply::library_items( + 1, + 1, + vec![envelope( + "ABCDEFGH", + "http://zotero.org/users/101/items/ABCDEFGH", + "paperA", + "Delete me", + 1, + )], + ), + Reply::deleted(1, &[]), + Reply::library_items(2, 0, vec![]), + Reply::deleted(2, &["ABCDEFGH"]), + ]); + let pool = open_cache_at(&sandbox.0, "101").await.unwrap(); + let first = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let second = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let visible = titles(&pool, &["paperA"]).await; + pool.close().await; + assert!(first.is_ok(), "initial download failed: {first:?}"); + assert_eq!(second.unwrap().removed, 1); + assert!(visible.is_empty()); + }); +} + +#[test] +fn changing_a_citation_key_replaces_the_same_item_and_repeated_sync_is_idempotent() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let api = Api::new(vec![ + Reply::library_items( + 1, + 1, + vec![envelope( + "ABCDEFGH", + "oldCitationKey", + "oldCitationKey", + "Before", + 1, + )], + ), + Reply::deleted(1, &[]), + Reply::library_items( + 2, + 1, + vec![envelope( + "ABCDEFGH", + "newCitationKey", + "newCitationKey", + "After", + 2, + )], + ), + Reply::deleted(2, &[]), + Reply::library_items(2, 0, vec![]), + Reply::deleted(2, &[]), + ]); + let pool = open_cache_at(&sandbox.0, "101").await.unwrap(); + let first = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let second = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let third = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let visible = titles(&pool, &["oldCitationKey", "newCitationKey"]).await; + pool.close().await; + assert!(first.is_ok(), "initial download failed: {first:?}"); + assert!(second.is_ok(), "citation-key update failed: {second:?}"); + let repeated = third.unwrap(); + assert_eq!( + (repeated.version, repeated.synced, repeated.removed), + (2, 0, 0) + ); + assert_eq!(visible, vec!["After"]); + }); +} + +#[test] +fn moving_a_reference_to_zotero_trash_removes_it_from_the_citable_cache() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let mut trashed = envelope("ABCDEFGH", "paperA", "paperA", "In trash", 2); + trashed["data"]["deleted"] = Value::from(1); + let api = Api::new(vec![ + Reply::library_items( + 1, + 1, + vec![envelope("ABCDEFGH", "paperA", "paperA", "Before trash", 1)], + ), + Reply::deleted(1, &[]), + Reply::library_items(2, 1, vec![trashed]), + Reply::deleted(2, &[]), + ]); + let pool = open_cache_at(&sandbox.0, "101").await.unwrap(); + let first = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let second = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let visible = titles(&pool, &["paperA"]).await; + let requests = api.requests(); + pool.close().await; + assert!( + first.is_ok(), + "initial download failed: {first:?}; fixture={:?}; requests={requests:?}", + api.diagnostics() + ); + assert!( + second.is_ok(), + "trash update failed: {second:?}; fixture={:?}; requests={requests:?}", + api.diagnostics() + ); + assert!(visible.is_empty()); + assert!(requests[2].contains("includeTrashed=1")); + }); +} diff --git a/src-tauri/src/zotero_sync/test_http.rs b/src-tauri/src/zotero_sync/test_http.rs new file mode 100644 index 0000000..b73669d --- /dev/null +++ b/src-tauri/src/zotero_sync/test_http.rs @@ -0,0 +1,228 @@ +use serde_json::Value; +use std::collections::VecDeque; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +pub struct Reply { + pub status: u16, + pub version: i64, + pub total: usize, + pub body: Value, + item_response: bool, +} + +impl Reply { + pub fn items(version: i64, total: usize, items: Vec) -> Self { + let envelopes = items + .into_iter() + .map(|csl| { + serde_json::json!({ + "key": csl["id"], "version": version, + "data": { "citationKey": csl["citation-key"] }, "csljson": csl, + }) + }) + .collect(); + Self::library_items(version, total, envelopes) + } + + pub fn library_items(version: i64, total: usize, items: Vec) -> Self { + Self { + status: 200, + version, + total, + body: Value::Array(items), + item_response: true, + } + } + + pub fn deleted(version: i64, keys: &[&str]) -> Self { + Self { + status: 200, + version, + total: 0, + body: serde_json::json!({ "items": keys }), + item_response: false, + } + } + + pub fn failure() -> Self { + Self { + status: 503, + version: 0, + total: 0, + body: serde_json::json!({ "error": "fixture unavailable" }), + item_response: false, + } + } +} + +/// Loopback-only scripted Zotero API. No credentials or traffic leave this process's host. +/// Each socket has a one-second deadline; the listener has a ten-second lifetime and joins on drop. +pub struct Api { + pub base: String, + requests: Arc>>, + diagnostics: Arc>>, + stopped: Arc, + worker: Option>, +} + +impl Api { + pub fn new(replies: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let diagnostics = Arc::new(Mutex::new(Vec::new())); + let stopped = Arc::new(AtomicBool::new(false)); + let captured = requests.clone(); + let events = diagnostics.clone(); + let stop = stopped.clone(); + let worker = thread::spawn(move || { + let mut replies: VecDeque<_> = replies.into(); + let deadline = Instant::now() + Duration::from_secs(10); + while !stop.load(Ordering::Acquire) && Instant::now() < deadline { + let (mut socket, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => { + events + .lock() + .unwrap() + .push(format!("accept failed: {error:?}")); + break; + } + }; + // On Windows accept() inherits the listener's nonblocking mode. + // Timeouts do not change that mode: an early read would otherwise return + // WouldBlock and consume a scripted reply before any HTTP headers arrive. + socket.set_nonblocking(false).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + socket + .set_write_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut request = Vec::new(); + let mut chunk = [0u8; 1024]; + while request.len() < 16_384 && !request.windows(4).any(|w| w == b"\r\n\r\n") { + match socket.read(&mut chunk) { + Ok(0) => { + events + .lock() + .unwrap() + .push(format!("request EOF after {} bytes", request.len())); + break; + } + Err(error) => { + events.lock().unwrap().push(format!( + "request read failed after {} bytes: {error:?}", + request.len() + )); + break; + } + Ok(count) => request.extend_from_slice(&chunk[..count]), + } + } + if !request.windows(4).any(|window| window == b"\r\n\r\n") { + events.lock().unwrap().push(format!( + "incomplete HTTP headers after {} bytes; reply preserved", + request.len() + )); + continue; + } + let first = String::from_utf8_lossy(&request) + .lines() + .next() + .unwrap_or("") + .to_owned(); + let csl_export = first.contains("format=csljson"); + captured.lock().unwrap().push(first); + let reply = replies.pop_front().unwrap_or_else(Reply::failure); + // Respond in the requested official format. Export IDs need not be Zotero item keys. + let body = if reply.item_response && csl_export { + let items: Vec<_> = reply + .body + .as_array() + .unwrap() + .iter() + .map(|item| item["csljson"].clone()) + .collect(); + serde_json::json!({ "items": items }).to_string() + } else { + reply.body.to_string() + }; + let response = format!( + "HTTP/1.1 {} Fixture\r\nContent-Type: application/json\r\nContent-Length: {}\r\nLast-Modified-Version: {}\r\nTotal-Results: {}\r\nConnection: close\r\n\r\n{}", + reply.status, body.len(), reply.version, reply.total, body, + ); + if let Err(error) = socket.write_all(response.as_bytes()) { + events + .lock() + .unwrap() + .push(format!("response write failed: {error:?}")); + } + } + }); + Self { + base, + requests, + diagnostics, + stopped, + worker: Some(worker), + } + } + + pub fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + pub fn diagnostics(&self) -> Vec { + self.diagnostics.lock().unwrap().clone() + } +} + +#[test] +fn accepted_connection_waits_for_the_complete_request_before_consuming_a_reply() { + let api = Api::new(vec![Reply::deleted(7, &[])]); + let mut stream = std::net::TcpStream::connect(api.base.trim_start_matches("http://")).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + // A connected client may be scheduled later than accept(). It has not sent an HTTP request yet. + thread::sleep(Duration::from_millis(50)); + let requests_before_headers = api.requests(); + let write = + stream.write_all(b"GET /fixture HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + let mut response = String::new(); + let read = stream.read_to_string(&mut response); + assert!(requests_before_headers.is_empty(), "fixture consumed a response before headers: requests={requests_before_headers:?}; diagnostics={:?}", api.diagnostics()); + assert!( + write.is_ok() && read.is_ok(), + "client write={write:?}; read={read:?}; diagnostics={:?}", + api.diagnostics() + ); + assert!(response.starts_with("HTTP/1.1 200 ")); + assert_eq!(api.requests(), vec!["GET /fixture HTTP/1.1"]); + assert!(api.diagnostics().is_empty(), "{:?}", api.diagnostics()); +} + +impl Drop for Api { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Release); + if let Some(worker) = self.worker.take() { + worker.join().unwrap(); + } + } +} diff --git a/src-tauri/src/zotero_sync/tests.rs b/src-tauri/src/zotero_sync/tests.rs new file mode 100644 index 0000000..ddc4b41 --- /dev/null +++ b/src-tauri/src/zotero_sync/tests.rs @@ -0,0 +1,245 @@ +use super::*; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +#[path = "test_http.rs"] +mod http; +use http::{Api, Reply}; + +struct Sandbox(PathBuf); + +impl Sandbox { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "inkstream-zotero-test-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + // This exact, uniquely-created directory contains only this test's caches. + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn credentials(user: &str) -> Creds { + Creds { + api_key: "fixture-key-not-a-real-credential".into(), + user_id: user.into(), + } +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(2)) + .build() + .unwrap() +} + +fn paper(id: &str, citekey: &str, title: &str) -> Value { + serde_json::json!({ "id": id, "citation-key": citekey, "title": title, "type": "article-journal" }) +} + +fn hundred_items(first: Value) -> Vec { + let mut items = vec![first]; + for index in 1..100 { + items.push(serde_json::json!({ "id": format!("FILLER_{index}"), "title": "Uncitable fixture item" })); + } + items +} + +async fn titles(pool: &SqlitePool, keys: &[&str]) -> Vec { + cached_csl(pool, keys.iter().map(|key| key.to_string()).collect()) + .await + .unwrap() + .into_iter() + .map(|item| item["title"].as_str().unwrap().to_string()) + .collect() +} + +#[test] +fn switching_accounts_starts_the_new_library_at_zero_and_keeps_both_caches_separate() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let api = Api::new(vec![ + Reply::items(1000, 1, vec![paper("KEY_A", "paperA", "Account A")]), + Reply::deleted(1000, &[]), + Reply::items(10, 1, vec![paper("KEY_B", "paperB", "Account B")]), + Reply::deleted(10, &[]), + ]); + let client = client(); + let a = open_cache_at(&sandbox.0, "101").await.unwrap(); + sync_library(&client, &credentials("101"), &a, &api.base) + .await + .unwrap(); + let b = open_cache_at(&sandbox.0, "202").await.unwrap(); + sync_library(&client, &credentials("202"), &b, &api.base) + .await + .unwrap(); + let a_titles = titles(&a, &["paperA", "paperB"]).await; + let b_titles = titles(&b, &["paperA", "paperB"]).await; + let requests = api.requests(); + a.close().await; + b.close().await; + assert!( + requests[2].contains("/users/202/items?") && requests[2].contains("&since=0&"), + "new account request: {}", + requests[2] + ); + assert_eq!(a_titles, vec!["Account A"]); + assert_eq!(b_titles, vec!["Account B"]); + }); +} + +#[test] +fn deletion_download_failure_preserves_the_last_complete_cache_and_cursor() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let api = Api::new(vec![ + Reply::items(5, 1, vec![paper("KEY_A", "paperA", "Committed")]), + Reply::deleted(5, &[]), + Reply::items(6, 1, vec![paper("KEY_B", "paperB", "Not committed")]), + Reply::failure(), + ]); + let pool = open_cache_at(&sandbox.0, "101").await.unwrap(); + let client = client(); + sync_library(&client, &credentials("101"), &pool, &api.base) + .await + .unwrap(); + let failed = sync_library(&client, &credentials("101"), &pool, &api.base).await; + let visible = titles(&pool, &["paperA", "paperB"]).await; + let cursor = get_version(&pool).await.unwrap(); + pool.close().await; + assert!( + failed.is_err(), + "deletion failure must fail the whole synchronization" + ); + assert_eq!(cursor, 5); + assert_eq!(visible, vec!["Committed"]); + }); +} + +#[test] +fn changing_page_versions_restarts_the_download_before_committing() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let api = Api::new(vec![ + Reply::items( + 1, + 101, + hundred_items(paper("OLD_A", "oldA", "Discarded first page")), + ), + Reply::items( + 2, + 101, + vec![paper("OLD_B", "oldB", "Discarded second page")], + ), + Reply::items(3, 1, vec![paper("KEY_C", "paperC", "Consistent snapshot")]), + Reply::deleted(3, &[]), + ]); + let pool = open_cache_at(&sandbox.0, "101").await.unwrap(); + let result = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let visible = titles(&pool, &["oldA", "oldB", "paperC"]).await; + let requests = api.requests(); + pool.close().await; + assert_eq!(result.unwrap().version, 3); + assert!(requests[2].contains("/items?") && requests[2].contains("&since=0&start=0&")); + assert_eq!(visible, vec!["Consistent snapshot"]); + }); +} + +#[path = "protocol_tests.rs"] +mod protocol_tests; + +#[path = "failure_tests.rs"] +mod failure_tests; + +#[test] +fn continually_changing_pages_stop_without_committing_a_partial_library() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let mut replies = Vec::new(); + for version in [1, 3, 5] { + replies.push(Reply::items( + version, + 101, + hundred_items(paper("A", "paperA", "Partial")), + )); + replies.push(Reply::items( + version + 1, + 101, + vec![paper("B", "paperB", "Partial")], + )); + } + let api = Api::new(replies); + let pool = open_cache_at(&sandbox.0, "101").await.unwrap(); + let result = sync_library(&client(), &credentials("101"), &pool, &api.base).await; + let visible = titles(&pool, &["paperA", "paperB"]).await; + let requests = api.requests(); + let cursor = get_version(&pool).await.unwrap(); + pool.close().await; + assert!( + result.is_err(), + "a perpetually changing library must stop with an error" + ); + assert!( + requests.len() <= 6, + "at most three two-page attempts are allowed" + ); + assert_eq!(cursor, 0); + assert!(visible.is_empty()); + }); +} + +#[test] +fn legacy_cache_with_unknown_owner_is_preserved_but_not_assigned_to_a_new_account() { + tauri::async_runtime::block_on(async { + let sandbox = Sandbox::new(); + let legacy_path = sandbox.0.join("zotero-cache.db"); + let legacy = SqlitePoolOptions::new() + .max_connections(1) + .connect_with( + SqliteConnectOptions::new() + .filename(&legacy_path) + .create_if_missing(true), + ) + .await + .unwrap(); + sqlx::raw_sql(SCHEMA).execute(&legacy).await.unwrap(); + sqlx::query( + "INSERT INTO zotero_items(zkey,citekey,version,csl) VALUES('UNKNOWN','unknown',1000,?)", + ) + .bind(paper("UNKNOWN", "unknown", "Legacy owner unknown").to_string()) + .execute(&legacy) + .await + .unwrap(); + sqlx::query("INSERT INTO zotero_meta(k,v) VALUES('library_version','1000')") + .execute(&legacy) + .await + .unwrap(); + let current = open_cache_at(&sandbox.0, "202").await.unwrap(); + let current_titles = titles(¤t, &["unknown"]).await; + let current_cursor = get_version(¤t).await.unwrap(); + let legacy_titles = titles(&legacy, &["unknown"]).await; + legacy.close().await; + current.close().await; + assert!( + current_titles.is_empty(), + "unowned legacy entries must not appear under the new account" + ); + assert_eq!(current_cursor, 0); + assert_eq!(legacy_titles, vec!["Legacy owner unknown"]); + assert!(legacy_path.is_file()); + }); +} diff --git a/src-tauri/src/zotero_tests.rs b/src-tauri/src/zotero_tests.rs new file mode 100644 index 0000000..3deec5c --- /dev/null +++ b/src-tauri/src/zotero_tests.rs @@ -0,0 +1,61 @@ +use super::bbt_search_at; +use serde_json::json; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::time::{Duration, Instant}; + +fn search_reply(status: u16, body: serde_json::Value) -> Result, String> { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + let mut socket = loop { + match listener.accept() { + Ok((socket, _)) => break socket, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline => std::thread::sleep(Duration::from_millis(5)), + Err(error) => panic!("fixture accept failed: {error}"), + } + }; + socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + socket.set_write_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut bytes = Vec::new(); + loop { + let mut buffer = [0; 2048]; + let size = socket.read(&mut buffer).unwrap(); + assert!(size > 0, "request ended before its complete body"); + bytes.extend_from_slice(&buffer[..size]); + assert!(bytes.len() < 16_384); + if let Some(end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&bytes[..end]); + let length: usize = head.lines().find_map(|line| line.to_ascii_lowercase().strip_prefix("content-length:").map(|value| value.trim().parse().unwrap())).unwrap(); + if bytes.len() >= end + 4 + length { break; } + } + } + let request = String::from_utf8(bytes).unwrap(); + assert!(request.starts_with("POST /better-bibtex/json-rpc HTTP/1.1")); + let payload: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(payload, json!({"jsonrpc":"2.0","method":"item.search","params":[""],"id":1})); + let body = body.to_string(); + write!(socket, "HTTP/1.1 {status} Fixture\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + }); + let result = tauri::async_runtime::block_on(bbt_search_at(&format!("http://{address}/better-bibtex/json-rpc"))); + server.join().unwrap(); + result +} + +#[test] +fn bbt_failures_are_errors_so_the_caller_can_preserve_or_restore_its_library() { + assert!(search_reply(503, json!({"result":[]})).is_err(), "HTTP failure must not be an empty library"); + assert!(search_reply(200, json!({"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Library unavailable"}})).is_err()); + for response in [json!({}), json!({"result":null}), json!({"result":{}}), json!({"result":[null]})] { + assert!(search_reply(200, response).is_err(), "malformed results must not be an empty library"); + } +} + +#[test] +fn a_valid_empty_library_is_distinct_from_failure_and_unicode_items_survive() { + assert_eq!(search_reply(200, json!({"jsonrpc":"2.0","id":1,"result":[]})).unwrap(), Vec::::new()); + let item = json!({"id":12,"type":"article-journal","citekey":"研究2026","title":"中文研究"}); + assert_eq!(search_reply(200, json!({"jsonrpc":"2.0","id":1,"result":[item.clone()]})).unwrap(), vec![item]); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5f8265f..cd1067b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "InkStream", - "version": "1.3.0", + "version": "2.0.0", "identifier": "com.krpct.inkstream", "build": { "beforeDevCommand": "pnpm dev", @@ -14,15 +14,15 @@ { "label": "main", "title": "InkStream", - "width": 1200, - "height": 800, + "width": 1360, + "height": 860, "minWidth": 900, "minHeight": 600, "visible": false } ], "security": { - "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost https:; font-src 'self'", + "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost https:; font-src 'self'", "assetProtocol": { "enable": true, "scope": ["**"] diff --git a/src/App.tsx b/src/App.tsx index d9d9e7e..a1fd7be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import UpdateDialog from './components/common/UpdateDialog'; import WhatsNewDialog from './components/common/WhatsNewDialog'; import WritingHud from './components/common/WritingHud'; import ImeProbe from './components/dev/ImeProbe'; +import GitCloneDialog from './components/git/GitCloneDialog'; import HelpModal from './components/help/HelpModal'; import OnboardingOverlay from './components/onboarding/OnboardingOverlay'; import CommandPalette from './components/palette/CommandPalette'; @@ -26,20 +27,32 @@ import { getAppVersion } from './ipc/app'; import { loadLastSeenVersion } from './ipc/settings'; import { initPersistence } from './stores/persistSettings'; import { initVaultPersistence } from './stores/persistVault'; +import { initIndexLifecycle } from './ipc/indexService'; +import { initCodexLifecycle } from './editor/codex'; +import { initImportedTheme } from './stores/useImportedThemeStore'; +import { stopProjectCheckpoints } from './projects/session'; +import { useProjectStore } from './stores/useProjectStore'; export default function App() { useEffect(() => { + if (!useProjectStore.getState().ready) useProjectStore.setState({ phase: 'loading' }); + const stopIndex = initIndexLifecycle(); + const stopCodex = initCodexLifecycle(); + void initImportedTheme(); // 持久化 hydrate 先于 show() 发起、不阻塞首帧:首帧由 boot.js 镜像保证, // settings.json 到达后校正(Pattern 6 第 3 步)。initPersistence 幂等。 // 持久化 hydrate(幂等,不阻塞首帧)。restoreLastVault 须等 settings(含 simpleMode)已 apply, // 否则可能在 simpleMode 生效前触发索引重建、在用户库建出 .inkstream(D-08 + 简易模式约束)。 - void Promise.all([initPersistence(), initVaultPersistence()]).then(() => restoreLastVault()); + let disposed = false; + void Promise.all([initPersistence(), initVaultPersistence()]).then(async () => { + await restoreLastVault(); + if (!disposed) initOsFileOpen(); + }); // 外部变更冲突仲裁订阅(D-04,FILE-02):watcher 事件经此按 isDirty 双路径仲裁。 initExternalChangeArbiter(); // 未提交退出提醒(簇①):关窗时若 git 有未提交改动则确认。 initExitGuard(); // #6:OS 文件接入(拖拽 + 「打开方式」冷启动/热转发)→ openExternalFile。 - initOsFileOpen(); // 文件导出:探测一次系统是否装 pandoc,决定「导出为」是否显示 odt/rtf/latex/epub/typst/org。 void usePandocStore.getState().detect(); // 自动更新:启动静默检查(dev / 无网 / 无更新一律静默;有更新弹非侵入对话框)。模块级 checking 守 StrictMode。 @@ -56,6 +69,10 @@ export default function App() { // 首次引导(簇③):延迟到布局渲染后再开,spotlight 才能命中侧栏/状态栏元素。seen 标记防重复弹。 const onboardingTimer = setTimeout(() => initOnboarding(), 1000); return () => { + disposed = true; + stopProjectCheckpoints(); + stopIndex(); + stopCodex(); clearTimeout(onboardingTimer); stopExternalChangeArbiter(); stopExitGuard(); @@ -82,6 +99,7 @@ export default function App() { {/* 文本输入模态:git 分支/tag 名、提交信息(usePromptStore,promptInput 弹出) */} + {/* Toast 通知宿主:错误/警告(useToastStore,持久化读写失败路径消费) */} {/* 写作 HUD(写作模式升级):码字速度/时间/番茄钟,默认关闭,writing.toggle-hud 开启 */} diff --git a/src/commands/academicCommands.ts b/src/commands/academicCommands.ts index b0561bc..7e38d74 100644 --- a/src/commands/academicCommands.ts +++ b/src/commands/academicCommands.ts @@ -1,6 +1,8 @@ import { insertCitation, insertFootnote } from '../editor/academicActions'; import { expandBibliographyAs, insertOrExpandBibliography } from '../editor/bibliography'; import type { Command } from '../types/commands'; +import { numberEquations } from '../editor/equations/commands'; +import { exportEquationPdf } from '../editor/export/equationPdf'; /** * 学术命令组(Phase 8 ZOT / ACAD)。引用/参考文献/脚注等学术写作动作,接 Zotero + Typst。 @@ -19,10 +21,12 @@ export const ACADEMIC_COMMANDS: Command[] = [ run: () => void insertCitation(), }, { id: 'academic.footnote', title: '学术:插入脚注', advanced: true, run: () => insertFootnote() }, + { id: 'academic.number-equations', title: '学术:公式编号', advanced: true, run: numberEquations }, + { id: 'academic.export-equation-pdf', title: '学术:导出公式 PDF 片段', advanced: true, run: exportEquationPdf }, // 无占位 → 插入空占位;有占位 → 按文档已选样式展开/刷新(默认 GB/T 7714)。 { id: 'academic.bibliography', title: '学术:插入参考文献', advanced: true, run: () => void insertOrExpandBibliography() }, // 指定样式展开(写入 `` 标记,doc 即真相源)。 - { id: 'academic.biblio-gbt7714', title: '学术:参考文献(GB/T 7714)', advanced: true, run: () => void expandBibliographyAs('gbt7714') }, - { id: 'academic.biblio-apa', title: '学术:参考文献(APA)', advanced: true, run: () => void expandBibliographyAs('apa') }, - { id: 'academic.biblio-vancouver', title: '学术:参考文献(Vancouver)', advanced: true, run: () => void expandBibliographyAs('vancouver') }, + { id: 'academic.biblio-gbt7714', title: '学术:参考文献(GB/T 7714-2015)', advanced: true, run: () => void expandBibliographyAs('gbt7714') }, + { id: 'academic.biblio-apa', title: '学术:参考文献(APA 第7版)', advanced: true, run: () => void expandBibliographyAs('apa') }, + { id: 'academic.biblio-vancouver', title: '学术:参考文献(Vancouver / NLM)', advanced: true, run: () => void expandBibliographyAs('vancouver') }, ]; diff --git a/src/commands/builtins.test.ts b/src/commands/builtins.test.ts index c9cb7a5..8bcbc2c 100644 --- a/src/commands/builtins.test.ts +++ b/src/commands/builtins.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { windowControls } from '../ipc/window'; import { useAboutStore } from '../stores/useAboutStore'; import { usePaletteStore } from '../stores/usePaletteStore'; +import { useProjectStore } from '../stores/useProjectStore'; +import { registerLayoutRestore } from '../components/workbench/layoutRestore'; import { useSettingsStore } from '../stores/useSettingsStore'; import { useWorkbenchStore } from '../stores/useWorkbenchStore'; import { DEFAULT_LAYOUT } from '../types/workbench'; @@ -10,6 +12,13 @@ import { dispose as disposeKeymap, init as initKeymap, normalizeEvent } from './ import { hydrate } from './mru'; import { execute, getAll } from './registry'; import { toggleRenderMode } from '../editor/livepreview/renderMode'; +import { ACADEMIC_COMMANDS } from './academicCommands'; +import { BOOKSHELF_COMMANDS } from './bookshelfCommands'; +import { CORE_COMMANDS } from './coreCommands'; +import { EXPORT_COMMANDS } from './exportCommands'; +import { GIT_COMMANDS } from './gitCommands'; +import { TEXT_COMMANDS } from './textCommands'; +import { VIEW_COMMANDS } from './viewCommands'; vi.mock('../editor/livepreview/renderMode', () => ({ toggleRenderMode: vi.fn(() => null), @@ -45,6 +54,7 @@ const TITLES: Record = { 'view.toggle-terminal': '视图:内置终端', 'view.command-palette': '视图:命令面板', 'view.settings': '视图:设置', + 'project.archive': '项目:项目档案', 'file.open-file': '文件:打开文件', 'file.open-folder': '文件:打开文件夹', 'file.open-recent': '文件:打开最近', @@ -115,10 +125,11 @@ const TITLES: Record = { // 学术组(Phase 8) 'academic.cite': '学术:插入引用(Zotero)', 'academic.footnote': '学术:插入脚注', + 'academic.number-equations': '学术:公式编号', 'academic.bibliography': '学术:插入参考文献', - 'academic.biblio-gbt7714': '学术:参考文献(GB/T 7714)', - 'academic.biblio-apa': '学术:参考文献(APA)', - 'academic.biblio-vancouver': '学术:参考文献(Vancouver)', + 'academic.biblio-gbt7714': '学术:参考文献(GB/T 7714-2015)', + 'academic.biblio-apa': '学术:参考文献(APA 第7版)', + 'academic.biblio-vancouver': '学术:参考文献(Vancouver / NLM)', // 书架(FEAT-SHELF ×4) 'bookshelf.open': '书架:打开书架', 'bookshelf.add-current': '书架:把当前阅读文档加入书架', @@ -127,7 +138,10 @@ const TITLES: Record = { }; /** 生产命令总数:…前略… + pandoc 格式导出(×6) + 检查更新(×1) + 更新公告(×1) + 书架(open/add/import-files/import-folder ×4) + 内置终端(×1, #3) + 界面缩放(zoom-in/out/reset ×3, v1.2.1) = 91。 */ -const COMMAND_COUNT = 91; +const EXPECTED_COMMAND_IDS = [ + ...CORE_COMMANDS, ...VIEW_COMMANDS, ...EXPORT_COMMANDS, ...TEXT_COMMANDS, + ...GIT_COMMANDS, ...ACADEMIC_COMMANDS, ...BOOKSHELF_COMMANDS, +].map((command) => command.id).sort(); /** 生产命令(剔除 dev.* DEV-only 命令,如 IME 探针 dev.ime-probe)。 */ function prodCommands() { @@ -146,6 +160,7 @@ describe('builtins', () => { useSettingsStore.setState(useSettingsStore.getInitialState(), true); useWorkbenchStore.setState(useWorkbenchStore.getInitialState(), true); usePaletteStore.setState(usePaletteStore.getInitialState(), true); + useProjectStore.setState({ phase: 'idle', archiveOpen: false }); delete document.documentElement.dataset.theme; delete document.documentElement.dataset.mode; requestOpenFolder.mockClear(); @@ -160,7 +175,7 @@ describe('builtins', () => { it('注册全部生产命令,标题与 UI-SPEC / R4 字面逐字一致', () => { const all = prodCommands(); - expect(all).toHaveLength(COMMAND_COUNT); + expect(all.map((command) => command.id).sort()).toEqual(EXPECTED_COMMAND_IDS); for (const [id, title] of Object.entries(TITLES)) { expect(all.find((c) => c.id === id)?.title).toBe(title); } @@ -180,6 +195,7 @@ describe('builtins', () => { expect(byId.get('view.toggle-right-panel')?.shortcut).toBe('Ctrl+Alt+B'); expect(byId.get('view.command-palette')?.shortcut).toBe('Ctrl+Shift+P'); expect(byId.get('go.quick-open')?.shortcut).toBe('Ctrl+P'); + expect(byId.get('project.archive')?.shortcut).toBe('Ctrl+Alt+P'); // Ctrl+O 给打开文件;打开文件夹让位 Ctrl+Shift+O expect(byId.get('file.open-file')?.shortcut).toBe('Ctrl+O'); expect(byId.get('file.open-folder')?.shortcut).toBe('Ctrl+Shift+O'); @@ -215,7 +231,7 @@ describe('builtins', () => { expect(() => { disposeBuiltins = registerBuiltinCommands(); }).not.toThrow(); - expect(prodCommands()).toHaveLength(COMMAND_COUNT); + expect(prodCommands().map((command) => command.id).sort()).toEqual(EXPECTED_COMMAND_IDS); }); it('合成 Ctrl+P 经 keymap 打开无前缀快速打开', () => { @@ -225,6 +241,39 @@ describe('builtins', () => { expect(usePaletteStore.getState().query).toBe(''); }); + it('project archive shares its command and shortcut in simple mode, and cannot toggle during handover', async () => { + initKeymap(); + useSettingsStore.setState({ simpleMode: true }); + window.dispatchEvent(key({ key: 'p', ctrlKey: true, altKey: true })); + expect(useProjectStore.getState().archiveOpen).toBe(true); + await execute('project.archive'); + expect(useProjectStore.getState().archiveOpen).toBe(false); + useProjectStore.setState({ phase: 'saving' }); + await execute('project.archive'); + expect(useProjectStore.getState().archiveOpen).toBe(false); + useProjectStore.setState({ archiveOpen: true }); + window.dispatchEvent(key({ key: 'p', ctrlKey: true, altKey: true })); + expect(useProjectStore.getState().archiveOpen).toBe(true); + }); + + it('explicit layout reset restores the mounted panels once without subscribing to measurement write-back', async () => { + const restore = vi.fn(); + const unregister = registerLayoutRestore(restore); + try { + useWorkbenchStore.getState().setLayout({ sidebarWidth: 410 }); + await execute('view.reset-layout'); + expect(restore).toHaveBeenCalledTimes(1); + expect(useWorkbenchStore.getState().layouts.standard.sidebarWidth).toBe(DEFAULT_LAYOUT.sidebarWidth); + } finally { unregister(); } + }); + + it('the actual app.exit command remains available while a project operation blocks editor commands', async () => { + const close = vi.spyOn(windowControls, 'close').mockResolvedValue(undefined); + useProjectStore.setState({ phase: 'restoring', archiveOpen: true }); + try { await execute('app.exit'); expect(close).toHaveBeenCalledTimes(1); } + finally { close.mockRestore(); } + }); + it('execute mode.switch-academic 切换模式且不占用全局快捷键(D-08)', async () => { await execute('mode.switch-academic'); expect(useWorkbenchStore.getState().mode).toBe('academic'); diff --git a/src/commands/builtins.ts b/src/commands/builtins.ts index 104da1a..bfb609a 100644 --- a/src/commands/builtins.ts +++ b/src/commands/builtins.ts @@ -43,6 +43,7 @@ export function registerBuiltinCommands(): () => void { bind('Ctrl+Shift+P', 'view.command-palette'), bind('Ctrl+,', 'view.settings'), bind('Ctrl+P', 'go.quick-open'), + bind('Ctrl+Alt+P', 'project.archive'), bind('Ctrl+\\', 'view.toggle-sidebar'), bind('Ctrl+Alt+B', 'view.toggle-right-panel'), bind('Ctrl+N', 'file.new-document'), diff --git a/src/commands/coreCommands.ts b/src/commands/coreCommands.ts index cc5ea56..01a194d 100644 --- a/src/commands/coreCommands.ts +++ b/src/commands/coreCommands.ts @@ -5,6 +5,7 @@ import { renameNodeInTree, } from '../components/workbench/fileTreeController'; import { createFileTreeOps } from '../components/workbench/fileTreeOps'; +import { restoreWorkbenchLayout } from '../components/workbench/layoutRestore'; import { newDraftDocument } from '../editor/draftFlow'; import { requestOpenFile, requestOpenFolder, requestOpenRecent } from '../editor/vaultFlow'; import { cycleDocumentLanguage } from '../editor/richtext/switchLanguage'; @@ -15,6 +16,7 @@ import { useEditorStore } from '../stores/useEditorStore'; import { useHelpStore } from '../stores/useHelpStore'; import { useOnboardingStore } from '../stores/useOnboardingStore'; import { usePaletteStore } from '../stores/usePaletteStore'; +import { useProjectStore } from '../stores/useProjectStore'; import { useUpdaterStore } from '../stores/useUpdaterStore'; import { useWhatsNewStore } from '../stores/useWhatsNewStore'; import { useSettingsStore } from '../stores/useSettingsStore'; @@ -72,7 +74,7 @@ export const CORE_COMMANDS: Command[] = [ { id: 'view.reset-layout', title: '视图:重置当前模式布局', - run: () => useWorkbenchStore.getState().resetCurrentLayout(), + run: () => { useWorkbenchStore.getState().resetCurrentLayout(); restoreWorkbenchLayout(); }, }, { id: 'view.command-palette', @@ -104,6 +106,15 @@ export const CORE_COMMANDS: Command[] = [ advanced: true, run: () => useWorkbenchStore.getState().setMode('creative'), }, + { + id: 'project.archive', + title: '项目:项目档案', + shortcut: 'Ctrl+Alt+P', + run: () => { + const projects = useProjectStore.getState(); + if (projects.phase === 'idle') projects.setArchiveOpen(!projects.archiveOpen); + }, + }, { id: 'file.open-file', title: '文件:打开文件', diff --git a/src/commands/gitCommands.ts b/src/commands/gitCommands.ts index 450b2b5..6aff143 100644 --- a/src/commands/gitCommands.ts +++ b/src/commands/gitCommands.ts @@ -2,12 +2,25 @@ import { useGitStore } from '../stores/useGitStore'; import { showToast } from '../stores/useToastStore'; import { useWorkbenchStore } from '../stores/useWorkbenchStore'; import type { Command } from '../types/commands'; +import { abortRebase, continueRebase, requestRebase, skipRebaseCommit } from '../editor/gitRebaseActions'; +import { requestCloneRepository } from '../editor/gitCloneActions'; +import { openBranchComparison } from '../editor/gitCompareActions'; +import { cancelLocalGitOperation } from '../editor/gitLocalOperation'; +import { abortOp } from '../editor/gitActions'; /** * git 命令(Phase 6 GIT-02)。Command 类型无 enabled 字段(菜单仅按未注册置灰),故 run 内守卫仓库根: * 非 git 工作区提示而非打开。Ctrl+Shift+G 沿用 vscode-git-graph 键位降低迁移成本。 */ export const GIT_COMMANDS: Command[] = [ + { id: 'git.cancel-local', title: '停止正在执行的本地 Git 操作', advanced: true, run: cancelLocalGitOperation }, + { id: 'git.abort-local', title: '中止未完成的本地 Git 合并/拣选/回退', advanced: true, run: async () => { await abortOp(); } }, + { id: 'git.compare-branches', title: '比较两个分支的完整正文…', advanced: true, run: openBranchComparison }, + { id: 'git.clone', title: '克隆仓库…', advanced: true, run: requestCloneRepository }, + { id: 'git.rebase', title: '本地变基…', advanced: true, run: requestRebase }, + { id: 'git.rebase-continue', title: '继续本地变基', advanced: true, run: continueRebase }, + { id: 'git.rebase-skip', title: '跳过当前变基提交', advanced: true, run: skipRebaseCommit }, + { id: 'git.rebase-abort', title: '中止本地变基', advanced: true, run: async () => { await abortRebase(); } }, { id: 'git.toggle-graph', title: 'Git Graph', diff --git a/src/commands/keymap.test.ts b/src/commands/keymap.test.ts index 45e2c2b..4aec72d 100644 --- a/src/commands/keymap.test.ts +++ b/src/commands/keymap.test.ts @@ -2,6 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { bind, dispose, init, normalizeEvent } from './keymap'; import { hydrate } from './mru'; import { register } from './registry'; +import { EditorState } from '@codemirror/state'; +import { EditorView, keymap as editorKeymap } from '@codemirror/view'; +import { history, historyKeymap } from '@codemirror/commands'; +import { search, searchKeymap } from '@codemirror/search'; +import { setView } from '../editor/viewHandle'; +import { useWorkbenchStore } from '../stores/useWorkbenchStore'; +import { useSettingsStore } from '../stores/useSettingsStore'; const disposers: Array<() => void> = []; @@ -77,3 +84,157 @@ describe('keymap 分发', () => { expect(run).not.toHaveBeenCalled(); }); }); + +describe('editing shortcuts stay with the surface receiving the key', () => { + let view: EditorView; + let run: Mock<() => void>; + beforeEach(() => { + useWorkbenchStore.setState({ centralView: 'editor' }); + view = new EditorView({ state: EditorState.create({ doc: 'Original document' }) }); + document.body.appendChild(view.dom); + setView(view); + run = vi.fn(); + disposers.push(register({ id: 'edit.copy', title: 'Copy', run })); + disposers.push(bind('Ctrl+C', 'edit.copy')); + init(); + }); + afterEach(() => { + dispose(); + while (disposers.length) disposers.pop()!(); + setView(null); view.destroy(); view.dom.remove(); + useWorkbenchStore.setState({ centralView: 'editor' }); + }); + + it.each(['input', 'textarea', 'select', 'contenteditable'])('%s keeps its native copy without redirecting to the document', (kind) => { + const input = document.createElement(kind === 'contenteditable' ? 'div' : kind); + if (kind === 'contenteditable') input.setAttribute('contenteditable', 'true'); + document.body.appendChild(input); + const event = key({ key: 'c', ctrlKey: true, bubbles: true }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(run).not.toHaveBeenCalled(); + input.remove(); + }); + + it('comparison selection keeps copy and find while the hidden document receives neither command', () => { + disposers.push(register({ id: 'edit.find', title: 'Find', run })); + disposers.push(bind('Ctrl+F', 'edit.find')); + const comparison = new EditorView({ state: EditorState.create({ doc: 'Compared revision', extensions: [EditorState.readOnly.of(true)] }) }); + document.body.appendChild(comparison.dom); + useWorkbenchStore.setState({ centralView: 'gitGraph' }); + try { + for (const keyName of ['c', 'f']) { + const event = key({ key: keyName, ctrlKey: true, bubbles: true }); + comparison.contentDOM.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + } + expect(run).not.toHaveBeenCalled(); + } finally { comparison.destroy(); comparison.dom.remove(); } + }); + + it('the main editor still receives its unhandled command shortcut', () => { + const event = key({ key: 'c', ctrlKey: true, bubbles: true }); + view.contentDOM.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + }); +}); + +describe('browser editing defaults cannot undo a hidden document', () => { + let view: EditorView; + let button: HTMLButtonElement; + const marker = 'THIRD_VISIBLE_DRAFT'; + beforeEach(() => { + useWorkbenchStore.setState({ centralView: 'editor' }); + useSettingsStore.setState({ simpleMode: false, bookshelfEnabled: false }); + view = new EditorView({ state: EditorState.create({ doc: 'Draft ', extensions: [history(), search(), editorKeymap.of([...historyKeymap, ...searchKeymap])] }) }); + document.body.appendChild(view.dom); + setView(view); + view.dispatch({ changes: { from: 6, insert: marker }, userEvent: 'input.type' }); + button = document.createElement('button'); + button.textContent = '打开 Git Graph'; + document.body.appendChild(button); + // No global Ctrl+Z binding: production also leaves history to CodeMirror. + init(); + }); + afterEach(() => { + dispose(); + while (disposers.length) disposers.pop()!(); + setView(null); view.destroy(); view.dom.remove(); button.remove(); + useWorkbenchStore.setState({ centralView: 'editor' }); + }); + + function showGraph(): void { + useWorkbenchStore.setState({ centralView: 'gitGraph' }); + view.dom.style.display = 'none'; + button.focus(); + expect(document.activeElement).toBe(button); + } + + it.each(['z', 'y', 'x', 'v'])('cancels the unbound native Ctrl+%s default on the graph button and preserves the main history', (letter) => { + showGraph(); + const event = key({ key: letter, ctrlKey: true, bubbles: true }); + button.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(view.state.doc.toString()).toBe(`Draft ${marker}`); + useWorkbenchStore.setState({ centralView: 'editor' }); + view.dom.style.display = ''; + view.focus(); + const undo = key({ key: 'z', ctrlKey: true, bubbles: true }); + view.contentDOM.dispatchEvent(undo); + expect(undo.defaultPrevented).toBe(true); + expect(view.state.doc.toString()).toBe('Draft '); + }); + + it.each(['historyUndo', 'historyRedo'])('cancels native beforeinput %s targeted at the hidden main editor', (inputType) => { + showGraph(); + const event = new InputEvent('beforeinput', { inputType, bubbles: true, cancelable: true }); + view.contentDOM.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(view.state.doc.toString()).toBe(`Draft ${marker}`); + }); + + it('lets a writable graph input own its native undo', () => { + showGraph(); + const input = document.createElement('textarea'); + document.body.appendChild(input); + input.focus(); + try { + const event = key({ key: 'z', ctrlKey: true, bubbles: true }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(view.state.doc.toString()).toBe(`Draft ${marker}`); + } finally { input.remove(); } + }); + + it('blocks browser undo on a readonly comparison while keeping copy available', () => { + showGraph(); + const comparison = new EditorView({ state: EditorState.create({ doc: 'Committed text', extensions: [EditorState.readOnly.of(true), EditorView.editable.of(false)] }) }); + document.body.appendChild(comparison.dom); + try { + const undo = key({ key: 'z', ctrlKey: true, bubbles: true }); + comparison.contentDOM.dispatchEvent(undo); + expect(undo.defaultPrevented).toBe(true); + const copy = key({ key: 'c', ctrlKey: true, bubbles: true }); + comparison.contentDOM.dispatchEvent(copy); + expect(copy.defaultPrevented).toBe(false); + expect(view.state.doc.toString()).toBe(`Draft ${marker}`); + expect(comparison.state.doc.toString()).toBe('Committed text'); + } finally { comparison.destroy(); comparison.dom.remove(); } + }); + + it('reserves Ctrl+Shift+G for Git Graph before CodeMirror search consumes it', () => { + const toggle = vi.fn(() => useWorkbenchStore.getState().toggleCentralView('gitGraph')); + disposers.push(register({ id: 'git.toggle-graph', title: 'Git Graph', run: toggle })); + disposers.push(bind('Ctrl+Shift+G', 'git.toggle-graph')); + view.focus(); + const selection = view.state.selection; + const event = key({ key: 'G', ctrlKey: true, shiftKey: true, bubbles: true }); + view.contentDOM.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(toggle).toHaveBeenCalledTimes(1); + expect(useWorkbenchStore.getState().centralView).toBe('gitGraph'); + expect(view.state.selection).toBe(selection); + expect(view.state.doc.toString()).toBe(`Draft ${marker}`); + }); +}); diff --git a/src/commands/keymap.ts b/src/commands/keymap.ts index 2ce8128..2d2e11d 100644 --- a/src/commands/keymap.ts +++ b/src/commands/keymap.ts @@ -1,4 +1,7 @@ import { execute } from './registry'; +import { getCommandView } from '../editor/commandView'; +import { getView } from '../editor/viewHandle'; +import { EditorView } from '@codemirror/view'; /** * window 级单一 keydown 分发器(D-05 VSCode 惯例):accelerator 字符串 → 命令 id。 @@ -43,16 +46,81 @@ export function bind(accelerator: string, commandId: string): () => void { }; } +function eventElement(event: Event): HTMLElement | null { + const target = event.target; + return target instanceof HTMLElement ? target : target instanceof Element ? target.parentElement : null; +} + +/** The focused control, not the last browser editing host, must own a native write. */ +function ownsNativeEditing(target: HTMLElement | null): boolean { + if (!target) return false; + const main = getView(); + if (main?.dom.contains(target) && getCommandView() !== main) return false; + const input = target.closest('input, textarea'); + if (input instanceof HTMLTextAreaElement) return !input.readOnly && !input.disabled; + if (input instanceof HTMLInputElement) { + return !input.readOnly && !input.disabled && ['text', 'search', 'url', 'tel', 'email', 'password', 'number'].includes(input.type); + } + const editor = EditorView.findFromDOM(target); + if (editor) return !editor.state.readOnly && editor.state.facet(EditorView.editable); + const editable = target.closest('[contenteditable]')?.getAttribute('contenteditable'); + return editable === '' || editable === 'true' || editable === 'plaintext-only'; +} + +function isNativeWriteKey(event: KeyboardEvent): boolean { + if (event.altKey) return false; + if (event.ctrlKey || event.metaKey) return ['z', 'y', 'x', 'v'].includes(event.key.toLowerCase()); + return event.shiftKey && (event.key === 'Delete' || event.key === 'Insert'); +} + +function onKeydownCapture(event: KeyboardEvent): void { + if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return; + // Chromium can apply browser historyUndo to a former contenteditable even when + // a button is focused and that editor is hidden. This must run before !bindings. + if (!getCommandView() && isNativeWriteKey(event) && !ownsNativeEditing(eventElement(event))) { + event.preventDefault(); + event.stopPropagation(); + return; + } + // This application shortcut is reserved; CodeMirror's searchKeymap otherwise + // consumes Ctrl+Shift+G as findPrevious before the window bubble listener sees it. + const accelerator = normalizeEvent(event); + if (accelerator && bindings.get(accelerator) === 'git.toggle-graph') { + event.preventDefault(); + event.stopPropagation(); + void execute('git.toggle-graph'); + } +} + +function onBeforeInput(event: Event): void { + if (!(event instanceof InputEvent) || event.isComposing || !['historyUndo', 'historyRedo'].includes(event.inputType)) return; + const target = eventElement(event); + const main = getView(); + const editor = target ? EditorView.findFromDOM(target) : null; + if ((main && target && main.contentDOM.contains(target) && getCommandView() !== main) || editor?.state.readOnly) { + event.preventDefault(); + event.stopPropagation(); + } +} + function onKeydown(e: KeyboardEvent): void { // Pitfall 4:中文 IME 组合中(含旧引擎/WebView 的 keyCode 229)一律不分发 if (e.isComposing || e.keyCode === 229) return; - // 防双消费(PROD-RELAY-DESIGN §2.4):中继 keydown 桥经 runScopeHandlers 消费的键 - // (Ctrl+Z/F 等)已 preventDefault 后仍冒泡到 window——此处必须短路,避免命令二次执行。 + // CodeMirror 已处理的快捷键仍会冒泡;避免主编辑器命令被再次执行。 if (e.defaultPrevented) return; const accel = normalizeEvent(e); if (!accel) return; const id = bindings.get(accel); if (!id) return; // 未绑定组合不拦截默认行为 + // A separate editor, comparison pane, or form control owns its native editing + // shortcuts. Never redirect them to the persistent document behind that surface. + if (/^(?:edit\.|fmt\.|para\.|academic\.|file\.export-)/.test(id)) { + const view = getCommandView(); + if (!view) return; + const target = e.target instanceof Element ? e.target : null; + if (target?.closest('input, textarea, select, [contenteditable], .cm-editor') && + !view.contentDOM.contains(target)) return; + } e.preventDefault(); void execute(id); } @@ -60,6 +128,8 @@ function onKeydown(e: KeyboardEvent): void { /** 挂载全局监听(幂等),main.tsx 启动时调用一次。 */ export function init(): void { if (listening) return; + window.addEventListener('keydown', onKeydownCapture, true); + window.addEventListener('beforeinput', onBeforeInput, true); window.addEventListener('keydown', onKeydown); listening = true; } @@ -67,6 +137,8 @@ export function init(): void { /** 卸载监听并清空绑定(测试复位用)。 */ export function dispose(): void { if (listening) { + window.removeEventListener('keydown', onKeydownCapture, true); + window.removeEventListener('beforeinput', onBeforeInput, true); window.removeEventListener('keydown', onKeydown); listening = false; } diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 7d1eb71..847ebc5 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -1,6 +1,7 @@ import { useSettingsStore } from '../stores/useSettingsStore'; import type { Command } from '../types/commands'; import { record } from './mru'; +import { projectBlocksEditing } from '../stores/useProjectStore'; /** * 命令注册表(Pattern 3):全项目命令统一入口。 @@ -40,6 +41,7 @@ export function getAll(): Command[] { export async function execute(id: string): Promise { const command = commands.get(id); if (!command) return; + if (projectBlocksEditing() && !['project.archive', 'app.exit', 'app.quit', 'window.close'].includes(id)) return; // 简易模式:高级命令一律 no-op(统一收口快捷键 / 命令面板 / 菜单点击三条触发路径)。 if (command.advanced && useSettingsStore.getState().simpleMode) return; // 书架未开启:书架命令一律 no-op(同 pandocOnly 门控)。 diff --git a/src/components/common/AboutDialog.tsx b/src/components/common/AboutDialog.tsx index 8d43abe..9d3a387 100644 --- a/src/components/common/AboutDialog.tsx +++ b/src/components/common/AboutDialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { getAppVersion } from '../../ipc/app'; +import { openExternal } from '../../ipc/opener'; import { useAboutStore } from '../../stores/useAboutStore'; import { useUpdaterStore } from '../../stores/useUpdaterStore'; @@ -57,6 +58,13 @@ function AboutPanel() {

版本 {version ?? '读取中'}

+

+ 参考文献排版:{' '} + + {' '}© Frank Bennett(CPAL)与{' '} + + {' '}样式(CC BY-SA 3.0)。许可随应用附带。 +

+ {change + 1} / {count} + + : null} + {!located ? 编辑位置在提交中没有唯一对应文本,已定位第一处差异。 : null} + +
+ {(['old', 'new'] as const).map((side) =>
+

+ {side === 'old' ? '基线' : '目标'} · {body.file[side]?.path ?? '此侧无文件'} · 只读 +

+ +
)} +
+ ; +} + +export default function BranchCompareView({ initial }: { initial?: { comparison: GitComparison; path: string | null } | null }) { + const repoRoot = useGitStore((s) => s.repoRoot); + const branches = useGitStore((s) => s.branches); + const vault = useVaultStore((s) => s.vault); + const [fromChoice, setFromChoice] = useState(''); + const [toChoice, setToChoice] = useState(''); + const [request, setRequest] = useState(() => initial ? { comparison: initial.comparison, skip: 0, focusPath: initial.path, snippet: '' } : null); + const [page, setPage] = useState<{ request: Request; value: GitComparePage } | null>(null); + const [selected, setSelected] = useState(null); + const [body, setBody] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [bodyError, setBodyError] = useState(''); + const [message, setMessage] = useState(''); + const defaultFrom = branches.find((b) => !b.isHead) ?? branches[0]; + const defaultTo = branches.find((b) => b.isHead) ?? branches[0]; + const fromName = fromChoice || (defaultFrom ? branchKey(defaultFrom) : ''); + const toName = toChoice || (defaultTo ? branchKey(defaultTo) : ''); + const activePage = page?.request === request ? page.value : null; + const activeBody = body?.file === selected && activePage?.files.includes(selected!) ? body : null; + + useEffect(() => { + if (!request || !repoRoot) { + setLoading(false); setError(''); setBodyError(''); setSelected(null); setBody(null); setPage(null); + return; + } + let cancelled = false; + setLoading(true); setError(''); setBodyError(''); setSelected(null); setBody(null); + const current = () => !cancelled && useGitStore.getState().repoRoot === repoRoot && useVaultStore.getState().vault === vault; + void gitCompareFiles(repoRoot, request.comparison.from.oid, request.comparison.to.oid, request.skip, request.focusPath) + .then((value) => { + if (!current()) return; + setPage({ request, value }); setSelected(value.files[0] ?? null); setLoading(false); + }, (reason: unknown) => { if (current()) { setError(String(reason)); setLoading(false); } }); + return () => { cancelled = true; }; + }, [repoRoot, request, vault]); + + useEffect(() => { + if (!selected || !repoRoot || !request || !activePage?.files.includes(selected)) return; + const controller = new AbortController(); + setBody(null); setBodyError(''); + const current = () => !controller.signal.aborted && useGitStore.getState().repoRoot === repoRoot && useVaultStore.getState().vault === vault; + void (async () => { + // Sequential reads keep native and renderer memory admission bounded for large files. + const oldText = comparisonDisplayText(await gitCompareText(repoRoot, selected.old, controller.signal)); + const newText = comparisonDisplayText(await gitCompareText(repoRoot, selected.new, controller.signal)); + if (!current()) return; + const comparison = await compareBranchText(oldText, newText, controller.signal); + if (current()) setBody({ file: selected, oldText, newText, comparison, snippet: request.snippet }); + })().catch((reason: unknown) => { if (current()) setBodyError(String(reason)); }); + return () => controller.abort(); + }, [selected, repoRoot, request, vault, activePage]); + + const start = () => { + const from = branches.find((b) => branchKey(b) === fromName); + const to = branches.find((b) => branchKey(b) === toName); + if (!from?.target || !to?.target) { setMessage('请选择已有提交的两个分支。'); return; } + const pair = { from: { name: from.name, oid: from.target }, to: { name: to.name, oid: to.target } }; + setMessage(''); setRequest({ comparison: pair, skip: 0, focusPath: null, snippet: '' }); + }; + const locateCurrent = () => { + if (!repoRoot || !request) return; + const current = currentComparisonDocument(repoRoot); + if (!current) { setMessage('当前文档不在此仓库内,无法定位到分支版本。'); return; } + setMessage(''); setRequest({ comparison: request.comparison, skip: 0, focusPath: current.path, snippet: current.snippet }); + }; + + if (!repoRoot) return

当前工作区不是 Git 仓库。

; + return
+
+ + + + + + {request?.focusPath ? : null} +
+ {request ?

+ {request.comparison.from.name} · {request.comparison.from.oid.slice(0, 10)} → {request.comparison.to.name} · {request.comparison.to.oid.slice(0, 10)} · 已固定提交;重新比较可更新。 +

:

选择两个分支查看完整正文;编辑文档保留在原标签页。

} + {message ?

{message}

: null} + {loading && request ?

正在读取比较文件清单…

: null} + {error && request ?

{error}

: null} + {activePage && !loading ? activePage.total === 0 ?

{request?.focusPath ? '当前文档在两个提交中均不存在。' : '两个分支没有文件差异。'}

: +
+ + {bodyError ?

{bodyError}

: activeBody ? + :

正在读取两侧完整正文…

} +
: null} +
; +} diff --git a/src/components/git/BranchManager.rebase.test.tsx b/src/components/git/BranchManager.rebase.test.tsx new file mode 100644 index 0000000..3d367f5 --- /dev/null +++ b/src/components/git/BranchManager.rebase.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, expect, it, vi } from 'vitest'; + +const rebaseCurrentOnto = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +vi.mock('../../editor/gitActions', () => ({ + checkoutTarget: vi.fn(), createBranchAt: vi.fn(), deleteBranchNamed: vi.fn(), + mergeBranchInto: vi.fn(), pullCurrent: vi.fn(), pushCurrent: vi.fn(), rebaseCurrentOnto, +})); + +import { useGitStore } from '../../stores/useGitStore'; +import BranchManager from './BranchManager'; + +beforeEach(() => { + rebaseCurrentOnto.mockClear(); + useGitStore.setState({ repoRoot: '/repo', branches: [ + { name: 'topic', isRemote: false, isHead: true, ahead: 0, behind: 0, upstream: null, target: 'topic-oid' }, + { name: 'main', isRemote: false, isHead: false, ahead: 0, behind: 0, upstream: null, target: 'main-oid' }, + ] }); +}); + +it('从已有分支管理入口把当前分支变基到选定本地分支', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: '将当前分支变基到此分支' })); + expect(rebaseCurrentOnto).toHaveBeenCalledWith('main'); +}); diff --git a/src/components/git/BranchManager.tsx b/src/components/git/BranchManager.tsx index 7658300..249ead3 100644 --- a/src/components/git/BranchManager.tsx +++ b/src/components/git/BranchManager.tsx @@ -15,6 +15,7 @@ import { mergeBranchInto, pullCurrent, pushCurrent, + rebaseCurrentOnto, } from '../../editor/gitActions'; import { useGitStore } from '../../stores/useGitStore'; import type { BranchInfo } from '../../types/git'; @@ -69,6 +70,7 @@ function BranchRow({ b }: { b: BranchInfo }) {
void checkoutTarget(b.name)} /> void mergeBranchInto(b.name)} /> + void rebaseCurrentOnto(b.name)} /> {b.isRemote ? null : ( void deleteBranchNamed(b.name)} /> )} diff --git a/src/components/git/CommentThread.scope.test.tsx b/src/components/git/CommentThread.scope.test.tsx new file mode 100644 index 0000000..d934ef3 --- /dev/null +++ b/src/components/git/CommentThread.scope.test.tsx @@ -0,0 +1,29 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, expect, it, vi } from 'vitest'; +import CommentThread from './CommentThread'; + +const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn() })); +vi.mock('../../ipc/git', () => ({ ghCommentList: (...args: unknown[]) => api.list(...args), ghCommentCreate: (...args: unknown[]) => api.create(...args) })); +vi.mock('../../ipc/githubPage', () => ({ githubCommentPage: async (...args: unknown[]) => ({ items: await api.list(...args), nextPage: null }) })); +beforeEach(() => { api.list.mockReset().mockResolvedValue([]); api.create.mockReset(); }); + +it('a late post in PR A cannot clear the new PR B comment draft', async () => { + let finish!: () => void; + api.create.mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + const rendered = render(); + fireEvent.change(screen.getByPlaceholderText('写下评论…'), { target: { value: 'A comment' } }); + fireEvent.click(screen.getByRole('button', { name: '评论' })); + rendered.rerender(); + fireEvent.change(screen.getByPlaceholderText('写下评论…'), { target: { value: 'B draft' } }); + await act(async () => { finish(); }); + expect(screen.getByPlaceholderText('写下评论…')).toHaveValue('B draft'); + expect(api.create).toHaveBeenCalledWith('/repo', 1, 'A comment'); +}); + +it('a newly selected PR immediately stops showing comments loaded for the previous one', async () => { + api.list.mockResolvedValueOnce([{ id: 1, author: 'A', body: 'Only A' }]).mockImplementation(() => new Promise(() => {})); + const rendered = render(); + await screen.findByText('Only A'); + rendered.rerender(); + expect(screen.queryByText('Only A')).not.toBeInTheDocument(); +}); diff --git a/src/components/git/CommentThread.tsx b/src/components/git/CommentThread.tsx index 4673712..4c12459 100644 --- a/src/components/git/CommentThread.tsx +++ b/src/components/git/CommentThread.tsx @@ -1,6 +1,9 @@ -import { useEffect, useState } from 'react'; -import { ghCommentCreate, ghCommentList } from '../../ipc/git'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ghCommentCreate } from '../../ipc/git'; +import { githubCommentPage } from '../../ipc/githubPage'; import { showToast } from '../../stores/useToastStore'; +import { useGithubPage } from './useGithubPage'; +import GithubPageControls from './GithubPageControls'; import type { Comment } from '../../types/git'; /** @@ -8,43 +11,39 @@ import type { Comment } from '../../types/git'; * (GitHub 中 PR 即 issue,评论同走 /issues/{n}/comments)。 */ export default function CommentThread({ repoRoot, number }: { repoRoot: string; number: number }) { - const [comments, setComments] = useState([]); + return ; +} + +function SelectedComments({ repoRoot, number }: { repoRoot: string; number: number }) { + const load = useCallback((page: number) => githubCommentPage(repoRoot, number, page), [repoRoot, number]); + const { data, error, loading, pageNumber, setPageNumber, refresh } = useGithubPage(load); + const comments = data?.items ?? []; const [body, setBody] = useState(''); const [busy, setBusy] = useState(false); - const [tick, setTick] = useState(0); - - // 竞态守卫:number/repoRoot 变化或重载时,旧请求回填判废(与 store selectPr 同纪律)。 - useEffect(() => { - let cancelled = false; - void ghCommentList(repoRoot, number) - .then((cs) => { - if (!cancelled) setComments(cs); - }) - .catch(() => { - /* 评论加载失败静默,不阻断详情主体 */ - }); - return () => { - cancelled = true; - }; - }, [repoRoot, number, tick]); + const [posted, setPosted] = useState(null); + const alive = useRef(true); + useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); const submit = async (): Promise => { - if (!body.trim()) return; + if (!body.trim() || busy) return; setBusy(true); try { - await ghCommentCreate(repoRoot, number, body.trim()); + const created = await ghCommentCreate(repoRoot, number, body.trim()); + if (!alive.current) return; + setPosted(created); setBody(''); - setTick((t) => t + 1); + refresh(); } catch (e) { - showToast('error', e instanceof Error ? e.message : String(e)); + if (alive.current) showToast('error', e instanceof Error ? e.message : String(e)); } finally { - setBusy(false); + if (alive.current) setBusy(false); } }; return (
- {comments.map((c) => ( + {error &&

评论读取失败:{error}

} + {loading ?

评论加载中…

: comments.map((c) => (
))} + + {posted && !comments.some((comment) => comment.id === posted.id) ?
+

刚发表的评论

{posted.body}

+
: null}