diff --git a/.env b/.env index a1e854b0..266c61d0 100644 --- a/.env +++ b/.env @@ -1,19 +1,19 @@ # ReadMD 全局统一版本配置 (Single Source of Truth for Global Versioning) # 修改此文件中的 READMD_VERSION 或运行 cargo xtask sync-version 即可一键同步全库所有文件 -READMD_VERSION=0.0.4 -READMD_VERSION_TAG=v0.0.4 -READMD_VERSION_SEMVER=0.0.4 -READMD_VERSION_TRIPLET=0.0.4 +READMD_VERSION=0.0.5 +READMD_VERSION_TAG=v0.0.5 +READMD_VERSION_SEMVER=0.0.5 +READMD_VERSION_TRIPLET=0.0.5 READMD_VERSION_MAJOR=0 READMD_VERSION_MINOR=0 -READMD_VERSION_PATCH=4 +READMD_VERSION_PATCH=5 READMD_VERSION_PRERELEASE= -READMD_VERSION_LINGLONG=0.0.4.0 -READMD_VERSION_HARMONY_CODE=4 -READMD_VERSION_HARMONY_NAME=0.0.4 -READMD_VERSION_WINDOWS_FILEVER=0.0.4.0 -READMD_VERSION_VSCODE=0.0.4 +READMD_VERSION_LINGLONG=0.0.5.0 +READMD_VERSION_HARMONY_CODE=5 +READMD_VERSION_HARMONY_NAME=0.0.5 +READMD_VERSION_WINDOWS_FILEVER=0.0.5.0 +READMD_VERSION_VSCODE=0.0.5 READMD_VERSION_RPM_REL=1 READMD_VERSION_ARCH_REL=1 -READMD_VERSION_BUILD_DATE=2026-10-04 +READMD_VERSION_BUILD_DATE=2026-10-07 diff --git a/.env.example b/.env.example index a1e854b0..266c61d0 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,19 @@ # ReadMD 全局统一版本配置 (Single Source of Truth for Global Versioning) # 修改此文件中的 READMD_VERSION 或运行 cargo xtask sync-version 即可一键同步全库所有文件 -READMD_VERSION=0.0.4 -READMD_VERSION_TAG=v0.0.4 -READMD_VERSION_SEMVER=0.0.4 -READMD_VERSION_TRIPLET=0.0.4 +READMD_VERSION=0.0.5 +READMD_VERSION_TAG=v0.0.5 +READMD_VERSION_SEMVER=0.0.5 +READMD_VERSION_TRIPLET=0.0.5 READMD_VERSION_MAJOR=0 READMD_VERSION_MINOR=0 -READMD_VERSION_PATCH=4 +READMD_VERSION_PATCH=5 READMD_VERSION_PRERELEASE= -READMD_VERSION_LINGLONG=0.0.4.0 -READMD_VERSION_HARMONY_CODE=4 -READMD_VERSION_HARMONY_NAME=0.0.4 -READMD_VERSION_WINDOWS_FILEVER=0.0.4.0 -READMD_VERSION_VSCODE=0.0.4 +READMD_VERSION_LINGLONG=0.0.5.0 +READMD_VERSION_HARMONY_CODE=5 +READMD_VERSION_HARMONY_NAME=0.0.5 +READMD_VERSION_WINDOWS_FILEVER=0.0.5.0 +READMD_VERSION_VSCODE=0.0.5 READMD_VERSION_RPM_REL=1 READMD_VERSION_ARCH_REL=1 -READMD_VERSION_BUILD_DATE=2026-10-04 +READMD_VERSION_BUILD_DATE=2026-10-07 diff --git a/.github/workflows/repo-quality.yml b/.github/workflows/repo-quality.yml index 19cd0a95..46001e02 100644 --- a/.github/workflows/repo-quality.yml +++ b/.github/workflows/repo-quality.yml @@ -90,6 +90,7 @@ jobs: cargo fetch --locked --manifest-path rust/Cargo.toml npm ci --ignore-scripts --prefix packages/vscode-extension npm ci --ignore-scripts --prefix ui-tests + npm ci --ignore-scripts --prefix website cd ui-tests npx --no-install playwright install --with-deps chromium webkit - name: Compile and test integrations offline @@ -109,6 +110,13 @@ jobs: run: npx --no-install playwright test --config vscode-preview.config.cjs - name: Package integrations without downloads run: node tools/package-integrations.mjs dist/integrations + - name: Build website offline and verify scroll video in Chrome + env: + NODE_PATH: ${{ github.workspace }}/ui-tests/node_modules + READMD_MOTION_CHANNEL: chrome + run: | + npm run verify:release --prefix website + node website/tools/motion-smoke.cjs - name: Upload integration packages uses: actions/upload-artifact@v4 with: @@ -145,4 +153,4 @@ jobs: READMD_BIN: ${{ github.workspace }}/rust/target/debug/readmd READMD_PANEL_TEST_LOCALES: zh-CN,en,zh-TW,de,ar READMD_PANEL_TEST_THEMES: light,dark,sepia - run: npx playwright test ui-quality.spec.js interaction-regressions.spec.js ai-model-discovery.spec.js all-panels-layout.spec.js editor-layout-production.spec.js language-production.spec.js panel-ux-production.spec.js community-upgrade.spec.js --project=desktop --project=mobile + run: npx playwright test ui-quality.spec.js interaction-regressions.spec.js ai-model-discovery.spec.js all-panels-layout.spec.js editor-layout-production.spec.js language-production.spec.js panel-ux-production.spec.js community-upgrade.spec.js document-intelligence.spec.js --project=desktop --project=mobile diff --git a/.gitignore b/.gitignore index 8a7bff19..f4c6608a 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ packages/readmd-pet-rust/target/ ReadMD.exe /test-results-pagination/ + +# Local incremental videos and production scripts never enter software releases. +/showcase/updates/ diff --git a/README.md b/README.md index b052ea7e..baee6107 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ ReadMD 解决的是长期资料库里的实际问题:大文件能继续读, ## 多系统与信创国产适配 ### 1. Linux 与国产操作系统适配(统信 UOS / 银河麒麟 / 深度) -- **直接安装使用**:下载 [`ReadMD-linux-x86_64.deb`](https://github.com/Natsummerance/rust-ReadMD/releases/download/V0.0.4/ReadMD-linux-x86_64.deb) 双击安装,或直接运行 [`ReadMD-linux-x86_64.tar.gz`](https://github.com/Natsummerance/rust-ReadMD/releases/download/V0.0.4/ReadMD-linux-x86_64.tar.gz)。银河麒麟 V10 + 飞腾 ARM64 设备使用 [`readmd_0.0.4_arm64.deb`](https://github.com/Natsummerance/rust-ReadMD/tree/main/scripts/linux)。 +- **直接安装使用**:下载 [`ReadMD-linux-x86_64.deb`](https://github.com/Natsummerance/rust-ReadMD/releases/download/V0.0.4/ReadMD-linux-x86_64.deb) 双击安装,或直接运行 [`ReadMD-linux-x86_64.tar.gz`](https://github.com/Natsummerance/rust-ReadMD/releases/download/V0.0.4/ReadMD-linux-x86_64.tar.gz)。银河麒麟 V10 + 飞腾 ARM64 设备使用 [`readmd_0.0.5_arm64.deb`](https://github.com/Natsummerance/rust-ReadMD/tree/main/scripts/linux)。 - **飞腾安全回退**:自动识别 Phytium D2000/E2000/FT 系列;WebKitGTK 启动时优先选择 UKUI/X11、禁用 DMABUF 合成并回退 llvmpipe,避免旧 GPU 驱动白屏或崩溃。 - **系统与环境识别**:100% 纯原生 Rust 内核(`native_system`)自动识别系统发行版,自适应配置 Wayland / X11 显示后端。 - **桌面与深色模式**:自动侦测 DDE(统信/Deepin)、UKUI(银河麒麟)与 GNOME/KDE 的外观主题,实时同步深色/浅色配色。 @@ -123,15 +123,15 @@ ReadMD 解决的是长期资料库里的实际问题:大文件能继续读, ## VSCode 扩展与 MCP Server 生态 ### 1. VSCode 插件安装与使用 -- **界面安装**:下载 [`readmd-vscode-0.0.4.vsix`](https://github.com/Natsummerance/rust-ReadMD/tree/main/packages/vscode-extension) -> 在 VSCode 扩展面板选择 `... -> 从 VSIX 安装...`。 +- **界面安装**:下载 [`readmd-vscode-0.0.5.vsix`](https://github.com/Natsummerance/rust-ReadMD/tree/main/packages/vscode-extension) -> 在 VSCode 扩展面板选择 `... -> 从 VSIX 安装...`。 - **命令行安装**: ```bash - code --install-extension readmd-vscode-0.0.4.vsix + code --install-extension readmd-vscode-0.0.5.vsix ``` - **核心功能**:打开 Markdown 文件点击右上角书本图标开启同步预览;右键菜单支持一键自动修复语法错误与转换为 LaTeX 源码。 ### 2. MCP (Model Context Protocol) Server 配置 -直接下载解压 [`readmd-mcp-server-0.0.4.zip`](https://github.com/Natsummerance/rust-ReadMD/tree/main/rust/readmd-kernel/src/mcp_server.rs),在 Claude Desktop、Cursor、Antigravity 与 Cline 中配置(请将 `args` 中的路径替换为本机解压的**绝对路径**): +直接下载解压 [`readmd-mcp-server-0.0.5.zip`](https://github.com/Natsummerance/rust-ReadMD/tree/main/rust/readmd-kernel/src/mcp_server.rs),在 Claude Desktop、Cursor、Antigravity 与 Cline 中配置(请将 `args` 中的路径替换为本机解压的**绝对路径**): **Windows**: ```json diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ac0b67c9..ef9dd8b6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,21 @@ +# ReadMD V0.0.5 · 发布候选 + +本轮补强本地 Markdown 工作流,复用现有入口,重点提升文档检查、工作区检索、VS Code 与 MCP 文件操作。正式发布前需完成平台打包与验收;当前公开下载仍为 V0.0.4。 + +- **文档检查**:当前草稿的标题、双链、本地链接与锚点由共用 Rust AST 检查,跳过代码示例;结果过期、处理上限和检查失败明确显示。桌面端放入原有修复报告。 + +- **VS Code**:原生“问题”面板诊断和可点击 Wiki 双链;命令面板搜索当前工作区,支持短语、路径、标题、YAML 标签及排除词。保持工具栏不增加新入口,缓存跟随文档版本与文件变化失效。 + +- **MCP**:22 → 27 项工具。新增文档检查、工作区搜索、带版本读取、默认预览的文字替换与恢复记录读取。提交需明确确认及匹配文件版本;保护编码、未改部分的换行,拒绝不可无损编码及超限结果。恢复集中保存,不污染原目录。 + +- **网站**:保留原有 Apple 风格,补齐四语言集成说明及搜索收录信息。修复旧仓库动态版本请求、大小写标签、过期版本说明、源码误标为安装包;区分公开正式版和待发布候选,避免无效下载。 + +- **验证与演示**:增量操作使用真实桌面应用、VS Code Extension Host 和 Rust MCP;合成哲学阅读材料,2K 原生录制,软件画面不含文件夹或录制编号。制作工程和视频仅保存在本机,不进入软件提交。 + +操作清单、测试证据与边界见 docs/reviews/readmd-v005-upgrade-2026-10-07.md。 + +--- + # ReadMD V0.0.4 本次版本整合桌宠、编辑保存、功能面板、原生窗口与完整演示网站的升级。程序、安装包和更新检查统一使用 `0.0.4`,更新源切换到 `Natsummerance/rust-ReadMD`。 diff --git a/VERSION b/VERSION index 81340c7e..bbdeab62 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.4 +0.0.5 diff --git a/assets/css/panels.css b/assets/css/panels.css index 0a993779..5ec06716 100644 --- a/assets/css/panels.css +++ b/assets/css/panels.css @@ -1353,3 +1353,6 @@ body:has(#edit-bar:not(.hidden)) .statusbar-left-group { flex:0 1 24%; } #share-refresh { margin-inline-start: 0; } #search-count { max-width: 30%; font-size: var(--text-xs); } } +/* Current-draft inspection shares the existing repair report. */ +#fix-list .document-inspection-summary { font-weight: 600; margin-top: 12px; } +#fix-list .document-inspection-issue { overflow-wrap: anywhere; } diff --git a/assets/i18n/ar.json b/assets/i18n/ar.json index 2fc01e6a..f325166c 100644 --- a/assets/i18n/ar.json +++ b/assets/i18n/ar.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "إجراءات عامة", "window.assocChoose": "اختر ReadMD لملفات Markdown في تطبيقات Windows الافتراضية.", "window.close": "إغلاق النافذة", diff --git a/assets/i18n/bn.json b/assets/i18n/bn.json index 9ac8ec2f..138613ae 100644 --- a/assets/i18n/bn.json +++ b/assets/i18n/bn.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "সার্বিক কাজ", "window.assocChoose": "উইন্ডোজ ডিফল্ট অ্যাপসে মার্কডাউন ফাইলের জন্য ReadMD বেছে নিন।", "window.close": "উইন্ডো বন্ধ করুন", diff --git a/assets/i18n/bo.json b/assets/i18n/bo.json index 8ba7a10c..2e28e1ce 100644 --- a/assets/i18n/bo.json +++ b/assets/i18n/bo.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "སྤྱི་ཁྱབ་བྱ་ཐབས།", "window.assocChoose": "Windows ཀྱི་སྔོན་སྒྲིག་ཉེར་སྤྱོད་ནང་ Markdown ཡིག་ཆའི་ཆེད་དུ་ ReadMD འདེམས་རོགས།", "window.close": "སྒེའུ་ཁུང་རྒྱག་པ།", diff --git a/assets/i18n/da.json b/assets/i18n/da.json index d323414f..18005a60 100644 --- a/assets/i18n/da.json +++ b/assets/i18n/da.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Globale handlinger", "window.assocChoose": "Vælg ReadMD til Markdown-filer i Standardapps i Windows.", "window.close": "Luk vindue", diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 920bd3f0..4b80b13e 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Globale Aktionen", "window.assocChoose": "Wählen Sie ReadMD für Markdown-Dateien in den Windows-Standard-Apps.", "window.close": "Fenster schließen", diff --git a/assets/i18n/el.json b/assets/i18n/el.json index 791e13bd..de9290b2 100644 --- a/assets/i18n/el.json +++ b/assets/i18n/el.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Καθολικές ενέργειες", "window.assocChoose": "Επιλέξτε το ReadMD για αρχεία Markdown στις Προεπιλεγμένες εφαρμογές των Windows.", "window.close": "Κλείσιμο παραθύρου", diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 444e42fc..0fd1ee22 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Global actions", "window.assocChoose": "Choose ReadMD for Markdown files in Windows Default apps.", "window.close": "Close window", diff --git a/assets/i18n/eo.json b/assets/i18n/eo.json index 58e1336e..48acbdda 100644 --- a/assets/i18n/eo.json +++ b/assets/i18n/eo.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Tutmondaj agoj", "window.assocChoose": "Elektu ReadMD por Markdown-dosieroj en la defaŭltaj aplikaĵoj de Vindozo.", "window.close": "Fermi fenestron", diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 7992ef4f..2b91d7af 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Acciones globales", "window.assocChoose": "Elegir ReadMD para archivos Markdown en las aplicaciones predeterminadas de Windows.", "window.close": "Cerrar ventana", diff --git a/assets/i18n/fi.json b/assets/i18n/fi.json index 5611180b..b5f05bbb 100644 --- a/assets/i18n/fi.json +++ b/assets/i18n/fi.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Yleiset toiminnot", "window.assocChoose": "Valitse ReadMD Markdown-tiedostojen oletussovellukseksi Windowsin oletussovelluksissa.", "window.close": "Sulje ikkuna", diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index 43d795bf..babeaa9c 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Actions globales", "window.assocChoose": "Choisissez ReadMD pour les fichiers Markdown dans les applications par défaut de Windows.", "window.close": "Fermer la fenêtre", diff --git a/assets/i18n/ga.json b/assets/i18n/ga.json index 726705e0..48bf2bdf 100644 --- a/assets/i18n/ga.json +++ b/assets/i18n/ga.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Gníomhartha domhanda", "window.assocChoose": "Roghnaigh ReadMD do chomhaid Markdown i gCláir Réamhshocraithe Windows.", "window.close": "Dún an fhuinneog", diff --git a/assets/i18n/he.json b/assets/i18n/he.json index a7506967..35982ce7 100644 --- a/assets/i18n/he.json +++ b/assets/i18n/he.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "פעולות גלובליות", "window.assocChoose": "בחר ב-ReadMD עבור קבצי Markdown באפליקציות ברירת המחדל של Windows.", "window.close": "סגור חלון", diff --git a/assets/i18n/hi.json b/assets/i18n/hi.json index d4bb5e49..315d6c6a 100644 --- a/assets/i18n/hi.json +++ b/assets/i18n/hi.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "वैश्विक क्रियाएँ", "window.assocChoose": "Windows डिफ़ॉल्ट ऐप्स में Markdown फ़ाइलों के लिए ReadMD चुनें।", "window.close": "विंडो बंद करें", diff --git a/assets/i18n/hr.json b/assets/i18n/hr.json index 544b2fd1..9002c6c5 100644 --- a/assets/i18n/hr.json +++ b/assets/i18n/hr.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Globalne radnje", "window.assocChoose": "Odaberite ReadMD za Markdown datoteke u zadanim aplikacijama sustava Windows.", "window.close": "Zatvori prozor", diff --git a/assets/i18n/hu.json b/assets/i18n/hu.json index b08c4e37..8ff884a5 100644 --- a/assets/i18n/hu.json +++ b/assets/i18n/hu.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Globális műveletek", "window.assocChoose": "A ReadMD választása Markdown fájlokhoz a Windows alapértelmezett alkalmazásaiban.", "window.close": "Ablak bezárása", diff --git a/assets/i18n/id.json b/assets/i18n/id.json index d66d787b..f927a250 100644 --- a/assets/i18n/id.json +++ b/assets/i18n/id.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Acciones globales", "window.assocChoose": "Elegir ReadMD para archivos Markdown en las aplicaciones predeterminadas de Windows.", "window.close": "Cerrar ventana", diff --git a/assets/i18n/it.json b/assets/i18n/it.json index 3311b406..8cdc6b03 100644 --- a/assets/i18n/it.json +++ b/assets/i18n/it.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Azioni globali", "window.assocChoose": "Scegli ReadMD per i file Markdown nelle app predefinite di Windows.", "window.close": "Chiudi finestra", diff --git a/assets/i18n/ja.json b/assets/i18n/ja.json index 407a196e..230711ac 100644 --- a/assets/i18n/ja.json +++ b/assets/i18n/ja.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "グローバルアクション", "window.assocChoose": "Windowsの既定のアプリでMarkdownファイルのReadMDを選択します。", "window.close": "ウィンドウを閉じる", diff --git a/assets/i18n/kg.json b/assets/i18n/kg.json index 4fa5e50c..cd14a38c 100644 --- a/assets/i18n/kg.json +++ b/assets/i18n/kg.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Bisalu ya mvimba", "window.assocChoose": "Ponaka ReadMD sambu na balaki ya Markdown na bisalu ya luyantiku ya Windows.", "window.close": "Kanga diwindu", diff --git a/assets/i18n/kl.json b/assets/i18n/kl.json index 8ed39acb..3968836f 100644 --- a/assets/i18n/kl.json +++ b/assets/i18n/kl.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Iliuutsit tamakkiisut", "window.assocChoose": "Windows-ip nalinginnaasumik atortuini Markdown fiilit ReadMD atorlugit toqqakkit.", "window.close": "Igalaaq matuuk", diff --git a/assets/i18n/km.json b/assets/i18n/km.json index eeb24883..07d5caea 100644 --- a/assets/i18n/km.json +++ b/assets/i18n/km.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "សកម្មភាពទូទៅ", "window.assocChoose": "ជ្រើសរើស ReadMD សម្រាប់ឯកសារ Markdown ក្នុងកម្មវិធីលំនាំដើមរបស់ Windows។", "window.close": "បិទបង្អួច", diff --git a/assets/i18n/ko.json b/assets/i18n/ko.json index 65e5295a..a5548f0a 100644 --- a/assets/i18n/ko.json +++ b/assets/i18n/ko.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "전역 작업", "window.assocChoose": "Windows 기본 앱에서 Markdown 파일의 ReadMD를 선택하세요.", "window.close": "창 닫기", diff --git a/assets/i18n/lo.json b/assets/i18n/lo.json index 5fe86233..cd580d56 100644 --- a/assets/i18n/lo.json +++ b/assets/i18n/lo.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "ການກະທຳທົ່ວໄປ", "window.assocChoose": "ເລືອກ ReadMD ສຳລັບໄຟລ໌ Markdown ໃນແອັບເລີ່ມຕົ້ນຂອງ Windows.", "window.close": "ປິດໜ້າຕ່າງ", diff --git a/assets/i18n/mn.json b/assets/i18n/mn.json index b37b9b3f..e6d5b405 100644 --- a/assets/i18n/mn.json +++ b/assets/i18n/mn.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Ерөнхий үйлдлүүд", "window.assocChoose": "Windows-ийн үндсэн аппууд дотроос Markdown файлуудад ReadMD-г сонгоно уу.", "window.close": "Цонх хаах", diff --git a/assets/i18n/ms.json b/assets/i18n/ms.json index 0b1aa528..dd64b8ce 100644 --- a/assets/i18n/ms.json +++ b/assets/i18n/ms.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Tindakan global", "window.assocChoose": "Pilih ReadMD untuk fail Markdown dalam Apl Lalai Windows.", "window.close": "Tutup tetingkap", diff --git a/assets/i18n/mt.json b/assets/i18n/mt.json index 9c21ec84..cafd8f5b 100644 --- a/assets/i18n/mt.json +++ b/assets/i18n/mt.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Azzjonijiet globali", "window.assocChoose": "Agħżel ReadMD għall-fajls Markdown fl-apps default tal-Windows.", "window.close": "Agħlaq it-tieqa", diff --git a/assets/i18n/my.json b/assets/i18n/my.json index 3efbcb81..1442780d 100644 --- a/assets/i18n/my.json +++ b/assets/i18n/my.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "ကမ္ဘာလုံးဆိုင်ရာ လုပ်ဆောင်ချက်များ", "window.assocChoose": "Windows ပုံသေ အက်ပ်များတွင် Markdown ဖိုင်များအတွက် ReadMD ကို ရွေးချယ်ပါ။", "window.close": "ဝင်းဒိုးကို ပိတ်မည်", diff --git a/assets/i18n/ne.json b/assets/i18n/ne.json index 6949658e..2a5bd08d 100644 --- a/assets/i18n/ne.json +++ b/assets/i18n/ne.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "वैश्विक कार्यहरू", "window.assocChoose": "विन्डोज डिफल्ट एप्समा मार्कडाउन फाइलहरूका लागि ReadMD छान्नुहोस्।", "window.close": "विन्डो बन्द गर्नुहोस्", diff --git a/assets/i18n/nl.json b/assets/i18n/nl.json index 85a96fde..2ee76901 100644 --- a/assets/i18n/nl.json +++ b/assets/i18n/nl.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Algemene acties", "window.assocChoose": "Kies ReadMD voor Markdown-bestanden in Standaard-apps van Windows.", "window.close": "Venster sluiten", diff --git a/assets/i18n/no.json b/assets/i18n/no.json index 75dead68..f2a3cab6 100644 --- a/assets/i18n/no.json +++ b/assets/i18n/no.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Globale handlinger", "window.assocChoose": "Velg ReadMD for Markdown-filer i Windows-standardapper.", "window.close": "Lukk vindu", diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index d6021b61..b8abd47d 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Ações globais", "window.assocChoose": "Escolher o ReadMD para ficheiros Markdown nas aplicações predefinidas do Windows.", "window.close": "Fechar janela", diff --git a/assets/i18n/ro.json b/assets/i18n/ro.json index 74b36980..98e01492 100644 --- a/assets/i18n/ro.json +++ b/assets/i18n/ro.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Acțiuni globale", "window.assocChoose": "Alegeți ReadMD pentru fișierele Markdown în Aplicațiile implicite din Windows.", "window.close": "Închide fereastra", diff --git a/assets/i18n/ru.json b/assets/i18n/ru.json index 754f7521..5f0631c6 100644 --- a/assets/i18n/ru.json +++ b/assets/i18n/ru.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Общие действия", "window.assocChoose": "Выберите ReadMD для файлов Markdown в приложениях по умолчанию Windows.", "window.close": "Закрыть окно", diff --git a/assets/i18n/rw.json b/assets/i18n/rw.json index dde2b8cb..355a5f45 100644 --- a/assets/i18n/rw.json +++ b/assets/i18n/rw.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Ibikorwa rusange", "window.assocChoose": "Hitamo ReadMD kuri dosiye za Markdown muri Porogaramu zisanzwe za Windows.", "window.close": "Gufunga idirishya", diff --git a/assets/i18n/sl.json b/assets/i18n/sl.json index 201a2ba9..804f1732 100644 --- a/assets/i18n/sl.json +++ b/assets/i18n/sl.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Globalna dejanja", "window.assocChoose": "Izberite ReadMD za datoteke Markdown v privzetih aplikacijah sistema Windows.", "window.close": "Zapri okno", diff --git a/assets/i18n/sv.json b/assets/i18n/sv.json index ddf83085..96d88c2e 100644 --- a/assets/i18n/sv.json +++ b/assets/i18n/sv.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Globala åtgärder", "window.assocChoose": "Välj ReadMD för Markdown-filer i Windows standardappar.", "window.close": "Stäng fönster", diff --git a/assets/i18n/th.json b/assets/i18n/th.json index e2840868..e4c75d2b 100644 --- a/assets/i18n/th.json +++ b/assets/i18n/th.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "การดำเนินการทั่วไป", "window.assocChoose": "เลือก ReadMD สำหรับไฟล์ Markdown ในแอปเริ่มต้นของ Windows", "window.close": "ปิดหน้าต่าง", diff --git a/assets/i18n/tl.json b/assets/i18n/tl.json index 3d3fc352..7f39cd1e 100644 --- a/assets/i18n/tl.json +++ b/assets/i18n/tl.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Mga pangkalahatang aksyon", "window.assocChoose": "Piliin ang ReadMD para sa mga Markdown file sa Windows Default apps.", "window.close": "Isara ang bintana", diff --git a/assets/i18n/tr.json b/assets/i18n/tr.json index d25ca138..685c9f5a 100644 --- a/assets/i18n/tr.json +++ b/assets/i18n/tr.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Genel eylemler", "window.assocChoose": "Windows Varsayılan uygulamalarında Markdown dosyaları için ReadMD'yi seçin.", "window.close": "Pencereyi kapat", diff --git a/assets/i18n/ug.json b/assets/i18n/ug.json index dffb9a28..5139d967 100644 --- a/assets/i18n/ug.json +++ b/assets/i18n/ug.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "ئومۇمىي مەشغۇلاتلار", "window.assocChoose": "Windows كۆڭۈلدىكى ئەپلىرىدە Markdown ھۆججەتلىرى ئۈچۈن ReadMD نى تاللاڭ.", "window.close": "كۆزنەكنى تاقاش", diff --git a/assets/i18n/uk.json b/assets/i18n/uk.json index bdcedd3c..5f2466df 100644 --- a/assets/i18n/uk.json +++ b/assets/i18n/uk.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Загальні дії", "window.assocChoose": "Вибрати ReadMD для файлів Markdown у програмах за замовчуванням Windows.", "window.close": "Закрити вікно", diff --git a/assets/i18n/vi.json b/assets/i18n/vi.json index 780795f8..b5d82c5d 100644 --- a/assets/i18n/vi.json +++ b/assets/i18n/vi.json @@ -1,4 +1,15 @@ { + "inspection.changed": "The document changed. Reopen this report to inspect the latest content.", + "inspection.ambiguous_link": "Multiple files match this link", + "inspection.atLine": "Line {line}", + "inspection.failed": "Document inspection is unavailable. Check the local service and try again.", + "inspection.limited": "Some checks reached processing limits; unchecked content may still contain issues.", + "inspection.loading": "Checking links and headings in the current document…", + "inspection.missing_anchor": "Heading anchor not found", + "inspection.missing_file": "Local file not found", + "inspection.missing_footnote": "Footnote definition not found", + "inspection.outside_workspace": "Link is outside the inspected folder and was not read", + "inspection.summary": "Document inspection: {count} items to review", "window.globalActions": "Thao tác chung", "window.assocChoose": "Chọn ReadMD cho các tệp Markdown trong ứng dụng Mặc định của Windows.", "window.close": "Đóng cửa sổ", diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index 678b303e..6e67ff37 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -1,4 +1,15 @@ { + "inspection.changed": "文档已变化,请重新打开报告以检查最新内容。", + "inspection.ambiguous_link": "同名链接目标不唯一", + "inspection.atLine": "第 {line} 行", + "inspection.failed": "文档检查暂不可用,请确认本地服务正常后重试。", + "inspection.limited": "部分检查达到处理上限,未检查的内容不代表没有问题。", + "inspection.loading": "正在检查当前文档的链接与标题…", + "inspection.missing_anchor": "未找到目标标题", + "inspection.missing_file": "未找到本地文件", + "inspection.missing_footnote": "未找到脚注定义", + "inspection.outside_workspace": "链接位于检查文件夹以外,未读取", + "inspection.summary": "文档检查:{count} 项待检查", "window.globalActions": "全局操作", "window.assocChoose": "请在 Windows 默认应用中,将 Markdown 文件的打开方式选为 ReadMD。", "window.close": "关闭窗口", diff --git a/assets/i18n/zh-HK.json b/assets/i18n/zh-HK.json index 9fdbd770..c1b7e1bc 100644 --- a/assets/i18n/zh-HK.json +++ b/assets/i18n/zh-HK.json @@ -1,4 +1,15 @@ { + "inspection.changed": "文件已變更,請重新開啟報告以檢查最新內容。", + "inspection.ambiguous_link": "同名連結目標不唯一", + "inspection.atLine": "第 {line} 行", + "inspection.failed": "文件檢查暫不可用,請確認本機服務正常後重試。", + "inspection.limited": "部分檢查已達處理上限,未檢查的內容不代表沒有問題。", + "inspection.loading": "正在檢查目前文件的連結與標題…", + "inspection.missing_anchor": "找不到目標標題", + "inspection.missing_file": "找不到本機檔案", + "inspection.missing_footnote": "找不到註腳定義", + "inspection.outside_workspace": "連結位於檢查資料夾以外,未讀取", + "inspection.summary": "文件檢查:{count} 項待檢查", "window.globalActions": "全域操作", "window.assocChoose": "請在 Windows 預設應用程式中,將 Markdown 檔案的開啟方式選為 ReadMD。", "window.close": "關閉視窗", diff --git a/assets/i18n/zh-TW.json b/assets/i18n/zh-TW.json index 64201895..bafa8ba2 100644 --- a/assets/i18n/zh-TW.json +++ b/assets/i18n/zh-TW.json @@ -1,4 +1,15 @@ { + "inspection.changed": "文件已變更,請重新開啟報告以檢查最新內容。", + "inspection.ambiguous_link": "同名連結目標不唯一", + "inspection.atLine": "第 {line} 行", + "inspection.failed": "文件檢查暫不可用,請確認本機服務正常後重試。", + "inspection.limited": "部分檢查已達處理上限,未檢查的內容不代表沒有問題。", + "inspection.loading": "正在檢查目前文件的連結與標題…", + "inspection.missing_anchor": "找不到目標標題", + "inspection.missing_file": "找不到本機檔案", + "inspection.missing_footnote": "找不到註腳定義", + "inspection.outside_workspace": "連結位於檢查資料夾以外,未讀取", + "inspection.summary": "文件檢查:{count} 項待檢查", "window.globalActions": "全域操作", "window.assocChoose": "請在 Windows 預設應用程式中,將 Markdown 檔案的開啟方式選為 ReadMD。", "window.close": "關閉視窗", diff --git a/assets/index.html b/assets/index.html index 53139709..e458608e 100644 --- a/assets/index.html +++ b/assets/index.html @@ -1,5 +1,5 @@ - + @@ -9,16 +9,16 @@ ReadMD - - - - - - - - - - + + + + + + + + + +
@@ -122,7 +122,7 @@ - + @@ -1475,7 +1475,7 @@

插入表格

- v0.0.4 + v0.0.5
@@ -1707,6 +1707,6 @@

配置文档 - + diff --git a/assets/js/reader/fixes.js b/assets/js/reader/fixes.js index 1e439a09..4d640c5e 100644 --- a/assets/js/reader/fixes.js +++ b/assets/js/reader/fixes.js @@ -5,6 +5,7 @@ /* ---------------- 修正详情 ---------------- */ +let documentInspectionGeneration = 0; function showFixModal() { const _t = (k, p) => window.i18n ? window.i18n.t(k, p) : k; const list = $('fix-list'); @@ -28,6 +29,43 @@ function showFixModal() { } const modal = $('fix-modal'); if (modal) modal.classList.remove('hidden'); + void inspectCurrentDocument(++documentInspectionGeneration); +} + +async function inspectCurrentDocument(generation) { + const text = state.editing ? getEditContent() : (state.original ?? state.fixed ?? ''); + const tabId = state.activeTabId; + const file = state.file && !state.virtualSource ? state.file : ''; + const list = $('fix-list'); + const status = document.createElement('li'); + status.className = 'document-inspection-summary'; + status.textContent = i18n.t('inspection.loading'); + list?.appendChild(status); + try { + const response = await apiFetch('/api/document/analyze', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: text, ...(file && /^(?:[A-Za-z]:[\\/]|\/)/.test(file) ? { file_path: file } : {}) }) + }); + const result = await response.json(); + if (generation !== documentInspectionGeneration || tabId !== state.activeTabId || $('fix-modal').classList.contains('hidden')) return; + if (text !== (state.editing ? getEditContent() : (state.original ?? state.fixed ?? ''))) { + status.textContent = i18n.t('inspection.changed'); return; + } + if (!response.ok || !result.ok) throw new Error('inspection_failed'); + status.textContent = i18n.t('inspection.summary', { count: result.diagnostics.length }); + for (const issue of result.diagnostics) { + const row = document.createElement('li'); + row.className = 'document-inspection-issue'; + const key = 'inspection.' + issue.code; + row.textContent = i18n.t('inspection.atLine', { line: issue.position.line }) + ' · ' + i18n.t(key) + ' · ' + issue.target; + list?.appendChild(row); + } + if (result.truncated) { + const row = document.createElement('li'); row.textContent = i18n.t('inspection.limited'); list?.appendChild(row); + } + } catch (_) { + if (generation === documentInspectionGeneration && tabId === state.activeTabId && status.isConnected) status.textContent = i18n.t('inspection.failed'); + } } async function handleAiDocumentFix() { diff --git a/assets/readmd.boot.js b/assets/readmd.boot.js index 44a977e7..4300358b 100644 --- a/assets/readmd.boot.js +++ b/assets/readmd.boot.js @@ -3339,6 +3339,7 @@ function renderMath(body) { /* ---------------- 修正详情 ---------------- */ +let documentInspectionGeneration = 0; function showFixModal() { const _t = (k, p) => window.i18n ? window.i18n.t(k, p) : k; const list = $('fix-list'); @@ -3362,6 +3363,43 @@ function showFixModal() { } const modal = $('fix-modal'); if (modal) modal.classList.remove('hidden'); + void inspectCurrentDocument(++documentInspectionGeneration); +} + +async function inspectCurrentDocument(generation) { + const text = state.editing ? getEditContent() : (state.original ?? state.fixed ?? ''); + const tabId = state.activeTabId; + const file = state.file && !state.virtualSource ? state.file : ''; + const list = $('fix-list'); + const status = document.createElement('li'); + status.className = 'document-inspection-summary'; + status.textContent = i18n.t('inspection.loading'); + list?.appendChild(status); + try { + const response = await apiFetch('/api/document/analyze', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: text, ...(file && /^(?:[A-Za-z]:[\\/]|\/)/.test(file) ? { file_path: file } : {}) }) + }); + const result = await response.json(); + if (generation !== documentInspectionGeneration || tabId !== state.activeTabId || $('fix-modal').classList.contains('hidden')) return; + if (text !== (state.editing ? getEditContent() : (state.original ?? state.fixed ?? ''))) { + status.textContent = i18n.t('inspection.changed'); return; + } + if (!response.ok || !result.ok) throw new Error('inspection_failed'); + status.textContent = i18n.t('inspection.summary', { count: result.diagnostics.length }); + for (const issue of result.diagnostics) { + const row = document.createElement('li'); + row.className = 'document-inspection-issue'; + const key = 'inspection.' + issue.code; + row.textContent = i18n.t('inspection.atLine', { line: issue.position.line }) + ' · ' + i18n.t(key) + ' · ' + issue.target; + list?.appendChild(row); + } + if (result.truncated) { + const row = document.createElement('li'); row.textContent = i18n.t('inspection.limited'); list?.appendChild(row); + } + } catch (_) { + if (generation === documentInspectionGeneration && tabId === state.activeTabId && status.isConnected) status.textContent = i18n.t('inspection.failed'); + } } async function handleAiDocumentFix() { diff --git a/docs/reviews/readmd-v005-upgrade-2026-10-07.md b/docs/reviews/readmd-v005-upgrade-2026-10-07.md new file mode 100644 index 00000000..44cb3813 --- /dev/null +++ b/docs/reviews/readmd-v005-upgrade-2026-10-07.md @@ -0,0 +1,96 @@ +# ReadMD V0.0.5 迭代与验收清单 + +目标:从已有社区调研吸收适合本地文档工作流的能力,复用原入口,完成可验证的发布候选。范围并非声称覆盖所有 Markdown 项目,也不承诺任意输入永不出错。 + +## 取舍依据 + +沿用 [社区调研矩阵](readmd-community-research-2026-10-07.md),覆盖阅读、即时预览、结构编辑、文档转换、学术写作、知识链接、演讲、编辑器与 AI 协作。既有公式、图表、目录、插件、托盘、桌宠、转换及导出入口继续使用,不创建平行的功能面板。 + +| 参考能力 | 本轮落地 | 入口 | +| --- | --- | --- | +| [Obsidian 本地链接](https://obsidian.md/help/links)、[属性](https://obsidian.md/help/properties) | AST 双链解析、同名歧义检测、本地目标及锚点检查、常用 YAML 标签检索 | 桌面修复报告、VS Code、MCP | +| [VS Code Markdown](https://code.visualstudio.com/docs/languages/markdown) | 原生 Problems 诊断与 Wiki DocumentLink,版本过期结果丢弃 | VS Code 原生面板与命令 | +| 常见笔记库检索 | 当前文件直接检索,组合短语、路径、标题、标签与排除词;不依赖过期索引 | VS Code Quick Pick、MCP、共用 HTTP 接口 | +| 文字处理软件的保存与审阅原则 | AI 工具先预览、后确认;字节版本冲突检测、原子写入、编码及未改部分换行保留、集中恢复历史 | MCP;复用桌面保存与恢复底层 | +| [Google 搜索入门](https://developers.google.com/search/docs/fundamentals/seo-starter-guide) | 四语言集成页、准确版本和下载入口、canonical/hreflang、站点地图与 Atom/crawler 文本同步 | 现有网站 | + +不增加独立知识库数据库、同步服务、第二套编辑器或新的顶部工具条。搜索不执行正则表达式、不联网抓取链接、不扫描隐藏或生成目录。 + +## 增量操作清单 + +1. **桌面文档检查**:打开文档或编辑草稿 → 更多/互动/修复详情 → 查看链接检查。只检查当前内容;异步返回后若切换标签或修改文本,结果作废。不自动改写链接。 +2. **VS Code 文档检查**:打开已信任工作区的 Markdown 文件 → 命令面板“ReadMD: 检查文档链接与标题” → 原生“问题”面板定位。活动草稿修改后防抖更新,保存、文件创建/删除/重命名后失效旧缓存;外部程序修改工作区文件也会触发刷新,忽略生成目录的噪声。 +3. **VS Code Wiki 跳转**:打开包含双链的文档 → 原生链接操作跳转;明确工作区范围,重名目标不猜测。复用 VS Code 的标准 Markdown 链接能力。 +4. **工作区检索**:命令面板“ReadMD: 搜索本地 Markdown 工作区” → 输入标签、短语与排除词 → Quick Pick 显示标题、相对路径和摘要 → 打开结果行。多工作区先选择范围,取消请求不应用迟到结果。 +5. **MCP 读取**:readmd_read_document 返回指定行段、原始字节 SHA-256、编码及换行信息。 +6. **MCP 修改预览**:readmd_edit_document 携带文件版本及文字替换,默认只预览。多处匹配须明确 replace_all,不执行模糊替换。 +7. **MCP 提交**:相同版本 + dry_run:false, confirm:true → 校验内容大小与编码 → 集中恢复记录 → 重新检查版本 → 原子写入。文件已变化则停止,不生成原目录副本。 +8. **MCP 历史**:readmd_document_history 列表或读取同一文档记录;原文件删除后仍可读取历史。恢复需先审阅并获取当前版本,不自动覆盖未保存的编辑器草稿。 + +MCP 从 22 项增加到 27 项;VS Code 从 22 条贡献命令增加到 24 条。新增桌面文案经 i18n-add.mjs 同步 46 个语言包,简中、繁中、英文提供原生文案,其余新增项使用英文回退。 + +## 文件安全与上限 + +- 单次检查/编辑文件最多 2 MiB;读取最多 1,000 行。拒绝不能无损重新编码的输入。 +- 搜索最多 5,000 个 Markdown 文件、累计 64 MiB;返回最多 100 项。处理上限及跳过数明确返回,不把未检查内容当作通过。 +- 标题与链接最多各 2,000 项、诊断最多 200 项、跨文档锚点最多检查 32 个目标。大目标无法检查时报告截断,避免误报缺失。 +- 恢复沿用应用数据区:每文档最多 10 项、总计 64 MiB、最长 30 天。原目录不会为 MCP 编辑增加 .bak。 +- MCP 最多并行 8 个工具,超额明确 server_busy。取消不会撤回已提交写入;跨程序写入仍可能在最终版本检查与原子替换之间竞争,不能把它描述成操作系统事务锁。 +- MCP 文件工具处理磁盘文件;未知其他编辑器的未保存缓冲区,不应绕过宿主编辑器直接替换其草稿。 + +## 网站与后续复用 + +网站沿用现有 Apple 风格。website/release.json 区分公开正式版与候选版,离线构建使用它生成 version.json。正式发布并验证完整资产后再将 stable 改为新版本;同步工具不会把未发布候选写进公开下载链接。 + +四语言首页恢复六段滚动视频:滚动决定视频帧,支持倒退及章节切换,窗口随滚动缩放、轻微透视和位移。全站 50 个 HTML 页面共用组件入场、卡片悬停、指针光边、按钮按压及键盘焦点反馈;保留原版排版和明暗主题,视频不叠加提亮滤镜。最多两个视频节点,后台标签暂停动效,停止滚动后停止逐帧计算。减少动态效果或禁用 JavaScript 时所有章节仍可阅读,视频失败保留海报与真实操作链接。离线媒体派生与复验方法见 [网站动效说明](../../website/MOTION.md)。历史六段源录屏为 1280×800,转码不提升真实细节,不标成 2K;新增四条功能录屏为原生 2560×1600。 + +node website/tools/build-integrations.mjs 生成四语言集成说明。所有正式安装、VSIX、MCP 连接包和校验文件均指向对应发布资产;MCP 连接包不被描述成独立服务端二进制。 + +增量演示位于本机 showcase/updates/v0.0.5/,使用合成哲学阅读材料、真实桌面应用、真实 Extension Host 与 Rust MCP。新管线不改写旧 104 项录制清单。画面不包含系统文件夹、私人路径、录制编号或提示叠层;媒体和制作脚本不进入软件提交。 + +## 验收记录 + +### 本机完成的检查 + +| 验收层 | 结果与实际边界 | +| --- | --- | +| Rust 完整回归 | 本机完整运行 1,875 项通过、3 项保留原有忽略;116 项系统/原生测试通过,xtask 12 项通过。随后新增的编码与宿主锚点用例纳入 9 项文档智能定向测试,全部通过。最终完整数量以 PR 的 Windows/Linux CI 为准。 | +| JavaScript / TypeScript | 离线 TypeScript 严格检查 8 个源文件;扩展 46 项及前端/工具 49 项,共 95 项通过。 | +| 桌面 UI 全量回归 | 298 项通过,31 项按既有环境条件跳过;不能将跳过项目算作实测通过。新增用例覆盖编辑草稿、过期响应和请求边界。 | +| 解压后的 Windows 候选包 | 真实 WebView2 与原有 CSP,7 个操作流程通过;46 种语言切换保持草稿及单行工具栏,3 个主题、插入/撤销、图像保存、局域网分享均验证,零页面脚本错误。 | +| 安装后的 VSIX | VS Code 真实 Extension Host,14 个流程通过;含原生 Problems、外部重命名自动刷新、跨文档/同文档 Wiki 链接、检索、预览、确认写入、历史读取、演讲及 DOCX 导出。使用隔离配置,不覆盖日常扩展设置。 | +| MCP 真实 stdio | 27 工具发现,15 类验收检查通过,含新工具完整流程、版本冲突、原文件删除后的恢复、AI 模拟服务协议、取消、重复请求 ID 与超大输入后连接恢复。这里的 AI 服务是本机合成测试服务,不代表公网模型成功率。 | +| 打包后二进制压力 | 700 份合成文档;128 个瞬时请求中 8 个执行、120 个明确返回忙碌。客户端限制并发为 8 后,128 个请求全部完成。16 个竞争写入只有 1 次提交,其余为版本冲突或忙碌;默认预览不改文件,删除原文件后历史仍可读取。 | +| 真实文件 | 使用两份本机真实 Markdown 的隔离副本,大小 1,836,325 与 7,807 字节;读取版本、AST 检查、检索和修改预览通过,原件与副本的 SHA-256 均未变化。未把私人内容上传给 AI 或纳入视频。 | +| 网站 | 离线构建与发布校验通过,49 个 canonical 页面及既有 104 项演示清单检查通过;四语言集成页在两种宽度、明暗主题共 16 组浏览器检查通过。新增滚动动效在真实 Edge 完成 44 组检查,含六段定位、倒退、章节边界、320/390/1024 窄屏、暗色、MP4 回退、媒体失败、减少动态效果及禁用 JavaScript;89 个连续帧间隔的 p95 为 12.6ms,页面脚本错误为零。Windows 测试版 WebKit 未能解码该 WebM,不将它标为视频通过,也不能据此代替 macOS Safari 实测。 | +| 增量视频 | 4 条,原生 2560×1600、30 fps;分别展示当前草稿检查、Wiki/Problems、工作区检索、MCP 预览/提交/恢复。94 个抽样帧经过 OCR 隐私检查,全部通过;不宣称逐帧 OCR。 | +| 静态门禁 | i18n、样式、控件接线、资源、无 Python 构建链、隐私扫描和 Git 空白检查通过。媒体及制作文件均未进入软件提交。 | + +验收日志保留在本机 .cache/v005/,候选包、校验和及源提交记录在 dist/candidate-v0.0.5/,VSIX 与 MCP 连接包位于 .cache/v005/integrations/。增量素材、录像脚本、清单、字幕和本地查看页位于 showcase/updates/v0.0.5/;这部分已被 Git 忽略,可在后续版本复制同一管线并替换合成材料。 + +### CI 与正式发布关卡 + +[V0.0.5 草稿 PR](https://github.com/Natsummerance/rust-ReadMD/pull/1) 的最新检查是跨平台结果的依据。CI 构建 Windows x64、Linux x64、macOS Intel 和 ARM64,并分别执行 Windows/Linux 内核测试、前端门禁、浏览器测试及 VS Code/MCP 集成验收。构建成功代表这些环境完成编译与对应自动检查,不代表已经在每个系统的实体机器上交互验收。 + +首轮 CI 揭示 MCP 验收脚本仍硬编码旧版 22 工具。已更新到 27 工具,并加入新工具实际读取、检查、检索、预览、确认、冲突与历史恢复操作;新增桌面检查用例也补入 CI。此前本机压力脚本与已安装扩展测到了新工具,但未运行这条独立的旧 stdio 入口,因此没有及时发现过期断言。后续发布须同时运行协议入口、宿主扩展和安装包验收,不能用源文件存在或单元测试代替可用性证明。 + +本轮只准备发布候选,没有合并 main、创建或移动 V0.0.5 标签,也没有替换已安装的正式 V0.0.4。正式发布前必须确认 PR 最新检查全部通过,复核平台资产和 SHA256SUMS.txt,再以同一提交创建正式发布。网站的 stable 和 published 只能在正式发布资产可下载后更新;candidate_ref 指向候选文档分支,正式版文档随合并进入 main。 + +不能从以上结果推断公网 AI 服务、全部操作系统/驱动、所有损坏文件或任意网络永不失败。受限输入、忙碌、取消、冲突和不可无损编码会明确拒绝;未运行的第三方环境须保留为边界,不标成通过。 + +### 离线复验入口 + +以下命令沿用已准备的工具链与依赖,不执行安装或下载: + + cargo test --offline --locked --manifest-path rust/Cargo.toml -p readmd-kernel -p xtask + node --test packages/vscode-extension/test/*.test.js + node packages/mcp-server/test/stdio.mjs + node tools/package-integrations.mjs dist/integrations + node tools/package-windows-candidate.mjs <已构建的ReadMD.exe> dist/candidate-v0.0.5 dist/ReadMD-Pet-Rust.zip + node website/tools/build-integrations.mjs + node website/tools/build-motion-pages.mjs + node website/tools/sync-version.mjs + node website/tools/validate-website.mjs --release + node website/tools/motion-smoke.cjs + +stdio 验收要求 READMD_BIN、READMD_ASSETS_DIR、隔离的 READMD_MCP_TEST_ROOT;原生验收使用 ui-tests/panel-native-smoke.cjs,按脚本要求指定独立资料目录。Playwright 使用本机已安装 CLI,运行桌面项目及 CI 列出的移动项目;不使用会隐式联网安装的命令。 diff --git a/packages/harmonyos-app/AppScope/app.json5 b/packages/harmonyos-app/AppScope/app.json5 index e3872d16..18a02049 100644 --- a/packages/harmonyos-app/AppScope/app.json5 +++ b/packages/harmonyos-app/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "io.github.natsummerance.readmd", "vendor": "Natsummerance", - "versionCode": 4, - "versionName": "0.0.4", + "versionCode": 5, + "versionName": "0.0.5", "icon": "$media:app_icon", "label": "$string:app_name" } diff --git a/packages/harmonyos-app/entry/oh-package.json5 b/packages/harmonyos-app/entry/oh-package.json5 index b372e24a..d1c04e9d 100644 --- a/packages/harmonyos-app/entry/oh-package.json5 +++ b/packages/harmonyos-app/entry/oh-package.json5 @@ -1,6 +1,6 @@ { "name": "entry", - "version": "0.0.4", + "version": "0.0.5", "description": "ReadMD HarmonyOS entry module", "license": "MIT", "dependencies": {} diff --git a/packages/harmonyos-app/oh-package.json5 b/packages/harmonyos-app/oh-package.json5 index 60d81cbf..f82ac291 100644 --- a/packages/harmonyos-app/oh-package.json5 +++ b/packages/harmonyos-app/oh-package.json5 @@ -1,6 +1,6 @@ { "name": "readmd-harmonyos", - "version": "0.0.4", + "version": "0.0.5", "description": "ReadMD HarmonyOS NEXT / OpenHarmony shell", "license": "MIT", "dependencies": {} diff --git a/packages/harmonyos-app/package.json b/packages/harmonyos-app/package.json index dd2aedc0..9ab1d538 100644 --- a/packages/harmonyos-app/package.json +++ b/packages/harmonyos-app/package.json @@ -1,6 +1,6 @@ { "name": "readmd-harmonyos", - "version": "0.0.4", + "version": "0.0.5", "description": "ReadMD for HarmonyOS NEXT & OpenHarmony", "main": "index.ets", "author": "Natsummerance", diff --git a/packages/linglong/linglong.yaml b/packages/linglong/linglong.yaml index edf1c013..318232b1 100644 --- a/packages/linglong/linglong.yaml +++ b/packages/linglong/linglong.yaml @@ -3,7 +3,7 @@ version: "1" package: id: io.github.natsummerance.readmd name: ReadMD - version: 0.0.4.0 + version: 0.0.5.0 kind: app description: | ReadMD —— 轻量级本地 Markdown 阅读器与编辑器。 diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 718d03a3..9c20d9a7 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -2,7 +2,7 @@ ReadMD 的 MCP 服务内置在桌面端可执行程序中:`readmd --mcp` 在标准输入输出上提供 JSON-RPC。支持 MCP 2026-07-28 的逐请求协议发现,以及 2025-11-25 / 2025-06-18 / 2025-03-26 / 2024-11-05 的初始化握手。它调用与桌面端相同的 Rust 转换、导出、OCR、Skills 和 AI 代码,不需要 Python、窗口或监听端口。 -`readmd-mcp-server-0.0.4.zip` 是文档与连接模板包;可执行内核随桌面应用提供,不在这个小型配置包中重复附带。 +`readmd-mcp-server-0.0.5.zip` 是文档与连接模板包;可执行内核随桌面应用提供,不在这个小型配置包中重复附带。 ## 快速开始 @@ -39,7 +39,7 @@ VS Code 扩展的「ReadMD: 一键配置工作区 MCP Server」会自动探测 R ## 工具 -以客户端返回的 `tools/list` 为准,共 22 项: +以客户端返回的 `tools/list` 为准,V0.0.5 共 27 项: `readmd_fix_markdown`、`readmd_convert_to_markdown`、`readmd_web_to_markdown`、`readmd_ocr_to_markdown`、`readmd_export_document`、`readmd_latex_to_md`、`readmd_md_to_latex`、`readmd_parse_bibtex`、`readmd_latex_to_omml`、`readmd_ai_assistant`、`readmd_ai_providers`、`readmd_ai_chat`、`readmd_process_imports`、`readmd_generate_toc`、`readmd_export_presentation`、`readmd_export_epub`、`readmd_run_code_chunk`、`readmd_pdf_audit`、`readmd_pdf_rollback`。 @@ -47,6 +47,18 @@ VS Code 扩展的「ReadMD: 一键配置工作区 MCP Server」会自动探测 R 对象结果同时提供兼容文本和 `structuredContent`,工具带有只读/副作用等注解。参数按公开 schema 校验,错误类型、未知字段、非法目录深度不会被静默转换。 +V0.0.5 新增五项(无需新界面): + +| 工具 | 用途与边界 | +| --- | --- | +| `readmd_analyze_document` | 用 AST 检查当前内容的标题、双链、本地文件和锚点,跳过代码示例;返回 UTF-16 行列、任务统计、常用 YAML 元数据。`content` 必填,`file_path` 与 `workspace_root` 为可选绝对路径。不请求外链。 | +| `readmd_search_workspace` | `workspace_root` 与 `query` 必填;支持短语、`path:`、`title:`、`tag:`、`-排除词`,`limit` 为 1–100,默认 20。直接读取当前文件,略过隐藏、生成目录与符号链接;报告截断和跳过数。 | +| `readmd_read_document` | 读取绝对 `file_path`,单次最多 1,000 行,返回原始字节 SHA-256 `revision`、编码与换行形式;不修改文件。 | +| `readmd_edit_document` | `file_path`、`expected_revision`、`old_text`、`new_text` 必填;默认 `dry_run:true`。写入必须 `dry_run:false, confirm:true`,版本冲突停止;多处匹配必须明确 `replace_all:true`。保留编码及未修改部分的换行,原子写入前保留恢复记录。 | +| `readmd_document_history` | 按绝对 `file_path` 列出恢复记录;`operation:read` 与 `checkpoint_id` 读取同一文件的历史内容,只读,不自动覆盖。 | + +检查及编辑的单文档上限为 2 MiB,工作区检索最多 5,000 个 Markdown 文件、累计 64 MiB。恢复区复用桌面端上限(每文档最多 10 项、总计 64 MiB、最长 30 天),不会在原目录留下 `.bak`。模型可先读版本、预览,再经用户确认提交;要撤回时先读取历史,重新获取当前版本后预览恢复,禁止盲目覆盖。其他应用写入与最终原子替换之间仍存在操作系统层面的竞争窗口;版本检查会尽量缩小该窗口,不能宣称跨程序事务锁。 + `resources/list` 公开 Skills(`readmd://skills/`)、Provider 目录(`readmd://providers`,不含密钥)和本地会话记录(`readmd://sessions`);`prompts/list` 与当前 Skill Registry 一一对应。旧的 workflow id(如 `polish`、`summary`)仍可在 `prompts/get` 和 `readmd_ai_assistant` 中使用。 ## 安全边界 diff --git a/packages/mcp-server/test/stdio.mjs b/packages/mcp-server/test/stdio.mjs index b683ac2b..d5bae2de 100644 --- a/packages/mcp-server/test/stdio.mjs +++ b/packages/mcp-server/test/stdio.mjs @@ -45,7 +45,33 @@ try { const discovery=await call('server/discover',{_meta:meta}); assert.equal(discovery.result.resultType,'complete'); report.checks.push('modern discovery'); const hello=await call('initialize',{protocolVersion:'2025-11-25',capabilities:{},clientInfo:{name:'readmd-fixture',version:'1'}}); assert.equal(hello.result.protocolVersion,'2025-11-25'); send({jsonrpc:'2.0',method:'notifications/initialized'}); report.checks.push('legacy handshake'); - const catalog=(await call('tools/list')).result.tools; assert.equal(catalog.length,22); report.tools=catalog.length; + const catalog=(await call('tools/list')).result.tools; assert.equal(catalog.length,27); report.tools=catalog.length; + for (const name of ['readmd_analyze_document','readmd_search_workspace','readmd_read_document','readmd_edit_document','readmd_document_history']) { + assert.ok(catalog.some(item=>item.name===name),name); + } + const source=path.join(root,'reading.md'), linked=path.join(root,'Plato.md'); + fs.writeFileSync(linked,'---\ntags:\n - philosophy\n---\n# Plato\n\nA careful argument.\n'); + fs.writeFileSync(source,'# Reading\n\n[[Plato]]\n\n[Broken](#missing)\n'); + const analyzed=unwrap(await tool('readmd_analyze_document',{content:fs.readFileSync(source,'utf8'),file_path:source,workspace_root:root})); + assert.equal(path.basename(analyzed.links[0].resolved_path),'Plato.md'); + assert.ok(analyzed.diagnostics.some(item=>item.code==='missing_anchor')); + report.checks.push('AST wiki targets and missing anchors'); + const search=unwrap(await tool('readmd_search_workspace',{workspace_root:root,query:'tag:philosophy "careful argument"'})); + assert.equal(search.results[0].relative_path,'Plato.md'); + report.checks.push('fresh workspace phrase and YAML tag search'); + const original=fs.readFileSync(linked,'utf8'), read=unwrap(await tool('readmd_read_document',{file_path:linked})); + const edit={file_path:linked,expected_revision:read.revision,old_text:'careful',new_text:'clear'}; + assert.equal(unwrap(await tool('readmd_edit_document',edit)).dry_run,true); + assert.equal(fs.readFileSync(linked,'utf8'),original); + assert.equal((await tool('readmd_edit_document',{...edit,dry_run:false})).result.structuredContent.error_code,'confirmation_required'); + const committed=unwrap(await tool('readmd_edit_document',{...edit,dry_run:false,confirm:true})); + assert.match(fs.readFileSync(linked,'utf8'),/clear argument/); + assert.equal((await tool('readmd_edit_document',edit)).result.structuredContent.error_code,'document_conflict'); + report.checks.push('revision-protected preview and confirmed commit'); + fs.unlinkSync(linked); + const history=unwrap(await tool('readmd_document_history',{file_path:linked,operation:'read',checkpoint_id:committed.checkpoint_id})); + assert.equal(history.content,original); + report.checks.push('recovery after original file deletion'); const presets=unwrap(await tool('readmd_export_presets',{})); assert.ok(presets.presets.classic); report.checks.push('real export preset catalog'); const models=unwrap(await tool('readmd_ai_models',{provider:'custom:fixture',confirm:true})); assert.ok(models.models.includes('fixture-model')); report.checks.push('model discovery over loopback HTTP'); const aiArgs={provider:'custom:fixture',model:'fixture-model',skill_id:'readmd-summary',markdown_content:'# Synthetic source'}; diff --git a/packages/vscode-extension/README.md b/packages/vscode-extension/README.md index b36b3abc..c0c6b94e 100644 --- a/packages/vscode-extension/README.md +++ b/packages/vscode-extension/README.md @@ -1,10 +1,12 @@ -# ReadMD for VS Code · V0.0.4 +# ReadMD for VS Code · V0.0.5 + +V0.0.5 新增原生“问题”面板链接诊断、可点击 Wiki 双链与工作区检索。使用命令面板中的“ReadMD: 检查文档链接与标题”和“ReadMD: 搜索本地 Markdown 工作区”;搜索可组合短语、`path:`、`title:`、`tag:` 和排除词。修改内容后检查会防抖更新,旧版本结果不会覆盖最新文档;不信任的工作区不调用本地内核。需配合 V0.0.5 桌面内核,单份文档上限 2 MiB,扫描范围及截断明确提示。 在 VS Code、Cursor 等兼容编辑器中阅读、修复和转换 Markdown,使用 ReadMD 的 AI Skills,并导出文档与演讲。保留编辑器原生的保存、撤销和未保存提示。 ## 安装和连接 -安装 ReadMD 桌面应用,再用 **Extensions: Install from VSIX…** 安装 `readmd-vscode-0.0.4.vsix`。打开 Markdown,执行 ReadMD 命令即可。 +安装 ReadMD 桌面应用,再用 **Extensions: Install from VSIX…** 安装 `readmd-vscode-0.0.5.vsix`。打开 Markdown,执行 ReadMD 命令即可。 扩展按需启动一个持久的 `readmd --mcp` 子进程,通过标准输入输出通信;没有 HTTP 服务地址设置,不需要打开桌面窗口,也不需要 Python。探测顺序:`readmd.executablePath` → 随 VSIX 附带的内核 → 桌面应用默认安装目录 → PATH。Windows 包括用户的 `Applications/ReadMD`、`LocalAppData/Programs` 和 Program Files。 diff --git a/packages/vscode-extension/l10n/bundle.l10n.json b/packages/vscode-extension/l10n/bundle.l10n.json index 94b38ea5..d38dfaab 100644 --- a/packages/vscode-extension/l10n/bundle.l10n.json +++ b/packages/vscode-extension/l10n/bundle.l10n.json @@ -179,5 +179,23 @@ "noBibFound": "No .bib file found", "bibtexParseFailed": "BibTeX parsing failed: {error}", "openWorkspaceFirst": "Open a workspace first.", - "writeMcpFailed": "Cannot write MCP configuration: {error}" + "writeMcpFailed": "Cannot write MCP configuration: {error}", + "inspection_missing_anchor": "Heading anchor not found", + "inspection_missing_file": "Local file not found", + "inspection_ambiguous_link": "Multiple files match this link", + "inspection_outside_workspace": "Link is outside the inspected workspace", + "inspection_missing_footnote": "Footnote definition not found", + "inspectionOpen": "Open a Markdown file in a trusted workspace first.", + "inspectionSize": "Inspection supports documents up to 2 MB.", + "inspectionLimited": "Inspection reached a processing limit; some content was not checked.", + "inspectionDone": "Document inspection complete: {count} items.", + "inspectionUnavailable": "Update ReadMD Core to use document inspection.", + "searchOpenWorkspace": "Open a workspace folder first.", + "searchPrompt": "Search text, \"phrases\", path:, title:, tag: and -exclusions", + "searchProgress": "Searching local Markdown files", + "searchEmpty": "No matches in the scanned files.", + "searchLimited": "Results limited to 50; refine your query", + "searchResults": "Select a result to open", + "searchUnavailable": "Search could not finish. Check the query and update ReadMD Core.", + "inspectionChanged": "The document changed during inspection. Run it again." } diff --git a/packages/vscode-extension/l10n/bundle.l10n.zh-cn.json b/packages/vscode-extension/l10n/bundle.l10n.zh-cn.json index 53d53adb..0415a856 100644 --- a/packages/vscode-extension/l10n/bundle.l10n.zh-cn.json +++ b/packages/vscode-extension/l10n/bundle.l10n.zh-cn.json @@ -179,5 +179,23 @@ "noBibFound": "没有找到 .bib 文件", "bibtexParseFailed": "BibTeX 解析失败:{error}", "openWorkspaceFirst": "请先打开工作区。", - "writeMcpFailed": "无法写入 MCP 配置:{error}" + "writeMcpFailed": "无法写入 MCP 配置:{error}", + "inspection_missing_anchor": "未找到目标标题", + "inspection_missing_file": "未找到本地文件", + "inspection_ambiguous_link": "同名链接目标不唯一", + "inspection_outside_workspace": "链接位于检查工作区以外", + "inspection_missing_footnote": "未找到脚注定义", + "inspectionOpen": "请先在已信任的工作区打开 Markdown 文件。", + "inspectionSize": "文档检查支持不超过 2 MB 的文件。", + "inspectionLimited": "检查达到处理上限,部分内容尚未检查。", + "inspectionDone": "文档检查完成:{count} 项待检查。", + "inspectionUnavailable": "请更新 ReadMD 核心后使用文档检查。", + "searchOpenWorkspace": "请先打开工作区文件夹。", + "searchPrompt": "搜索正文、\"短语\"、path: 路径、title: 标题、tag: 标签及 -排除词", + "searchProgress": "正在搜索本地 Markdown 文件", + "searchEmpty": "已扫描的文件中没有匹配结果。", + "searchLimited": "结果或扫描达到上限,请缩小查询范围", + "searchResults": "选择结果以打开文件", + "searchUnavailable": "搜索未能完成,请检查查询语法并更新 ReadMD 核心。", + "inspectionChanged": "检查期间文档或关联文件已变化,请重新检查。" } diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 0d017fae..452b3776 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "readmd-vscode", "displayName": "%extension.displayName%", "description": "%extension.description%", - "version": "0.0.4", + "version": "0.0.5", "publisher": "Natsummerance", "icon": "icon.png", "license": "MIT", @@ -58,7 +58,9 @@ "onCommand:readmd.convertFileToMarkdown", "onCommand:readmd.fetchWebToMarkdown", "onCommand:readmd.setupMcpServer", - "onCommand:readmd.openSkillByUri" + "onCommand:readmd.openSkillByUri", + "onCommand:readmd.inspectDocument", + "onCommand:readmd.searchWorkspace" ], "contributes": { "snippets": [ @@ -177,6 +179,14 @@ "command": "readmd.openSkillByUri", "title": "%command.openSkillByUri%", "icon": "$(library)" + }, + { + "command": "readmd.inspectDocument", + "title": "%command.inspectDocument%" + }, + { + "command": "readmd.searchWorkspace", + "title": "%command.searchWorkspace%" } ], "viewsContainers": { diff --git a/packages/vscode-extension/package.nls.json b/packages/vscode-extension/package.nls.json index 423a4095..7395612f 100644 --- a/packages/vscode-extension/package.nls.json +++ b/packages/vscode-extension/package.nls.json @@ -29,5 +29,7 @@ "configuration.executablePath": "ReadMD executable (leave empty to use the installed desktop app or `readmd` on PATH); it serves the core via `readmd --mcp`", "configuration.customCssPath": "Absolute path to a user custom.css file", "capabilities.untrusted": "Preview and editing are available. Native tools, AI, imports, code execution and MCP configuration require a trusted workspace.", - "capabilities.virtual": "ReadMD uses a local Rust executable and local files." + "capabilities.virtual": "ReadMD uses a local Rust executable and local files.", + "command.inspectDocument": "ReadMD: Inspect document links and headings", + "command.searchWorkspace": "ReadMD: Search local Markdown workspace" } diff --git a/packages/vscode-extension/package.nls.zh-cn.json b/packages/vscode-extension/package.nls.zh-cn.json index e2c71a0c..8625cb70 100644 --- a/packages/vscode-extension/package.nls.zh-cn.json +++ b/packages/vscode-extension/package.nls.zh-cn.json @@ -29,5 +29,7 @@ "configuration.executablePath": "ReadMD 可执行程序路径(留空则使用已安装的桌面端或 PATH 中的 readmd),核心通过 `readmd --mcp` 提供", "configuration.customCssPath": "用户 custom.css 文件的绝对路径", "capabilities.untrusted": "可预览和编辑。原生工具、AI、导入、代码执行及 MCP 配置需要受信任的工作区。", - "capabilities.virtual": "ReadMD 需要本地 Rust 程序和本地文件。" + "capabilities.virtual": "ReadMD 需要本地 Rust 程序和本地文件。", + "command.inspectDocument": "ReadMD: 检查文档链接与标题", + "command.searchWorkspace": "ReadMD: 搜索本地 Markdown 工作区" } diff --git a/packages/vscode-extension/src/bridge.ts b/packages/vscode-extension/src/bridge.ts index b738fa47..a8192444 100644 --- a/packages/vscode-extension/src/bridge.ts +++ b/packages/vscode-extension/src/bridge.ts @@ -207,8 +207,8 @@ export class ReadMDBridge { /** * 调用 MCP 工具调度器执行核心能力。 */ - public async callMcpTool(name: string, args: Record): Promise { - const result = await this.callMcpMethod('tools/call', { name, arguments: args }); + public async callMcpTool(name: string, args: Record, token?: vscode.CancellationToken): Promise { + const result = await this.callMcpMethodInternal('tools/call', { name, arguments: args }, undefined, token); return this.unwrapToolResult(result); } diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 6fce37f6..3e9de0d1 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -6,6 +6,7 @@ import { ReadMDToolboxProvider } from './sidebarProvider'; import { l10n } from './localization'; import { getWebviewContent, WebviewAssets } from './webview'; import { fencedCodeAt } from './fences'; +import { registerIntelligence } from './intelligence'; let diagnosticStatusBarItem: vscode.StatusBarItem; let coreStatusBarItem: vscode.StatusBarItem; @@ -149,6 +150,7 @@ export function parseJsoncSafely(text: string): Record { export function activate(context: vscode.ExtensionContext) { const bridge = new ReadMDBridge(context); context.subscriptions.push({ dispose: () => bridge.dispose() }); + registerIntelligence(context, bridge); const trusted = () => { if (vscode.workspace.isTrusted !== false) return true; void vscode.window.showWarningMessage(l10n('trustRequired', 'Trust this workspace before using native tools, AI or code execution.')); diff --git a/packages/vscode-extension/src/intelligence.ts b/packages/vscode-extension/src/intelligence.ts new file mode 100644 index 00000000..df1bf8a4 --- /dev/null +++ b/packages/vscode-extension/src/intelligence.ts @@ -0,0 +1,152 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { ReadMDBridge } from './bridge'; +import { l10n } from './localization'; + +interface Inspection { + diagnostics: { code: string; target: string; position: { line: number; column: number } }[]; + links: { kind: string; target: string; resolved_path?: string; resolved_anchor?: string; ambiguous?: boolean; position: { line: number; column: number } }[]; + truncated: boolean; +} +const messages: Record = { + missing_anchor: 'Heading anchor not found', missing_file: 'Local file not found', + ambiguous_link: 'Multiple files match this link', outside_workspace: 'Link is outside the inspected workspace', + missing_footnote: 'Footnote definition not found', +}; + +/** Native Problems, document links and Quick Pick; no extra editor toolbar. */ +export function registerIntelligence(context: vscode.ExtensionContext, bridge: ReadMDBridge) { + const diagnostics = vscode.languages.createDiagnosticCollection('ReadMD'); + const cache = new Map }>(); + const timers = new Map>(); + let disposed = false; + let fileChangeTimer: ReturnType | undefined; + const eligible = (doc: vscode.TextDocument) => vscode.workspace.isTrusted !== false + && doc.languageId === 'markdown' && doc.uri.scheme === 'file'; + const inspect = async (doc: vscode.TextDocument): Promise => { + if (!eligible(doc) || Buffer.byteLength(doc.getText(), 'utf8') > 2 * 1024 * 1024) { + diagnostics.delete(doc.uri); return; + } + const key = doc.uri.toString(); + const root = vscode.workspace.getWorkspaceFolder(doc.uri)?.uri.fsPath || path.dirname(doc.uri.fsPath); + let item = cache.get(key); + if (!item || item.version !== doc.version || item.root !== root) { + item = { version: doc.version, root, promise: bridge.callMcpTool('readmd_analyze_document', { + content: doc.getText(), file_path: doc.uri.fsPath, workspace_root: root, + }) }; + cache.set(key, item); + } + try { + const result = await item.promise; + if (disposed || cache.get(key) !== item || doc.isClosed || doc.version !== item.version) return; + diagnostics.set(doc.uri, result.diagnostics.map(issue => { + const line = Math.min(Math.max(0, issue.position.line - 1), doc.lineCount - 1); + const column = Math.min(Math.max(0, issue.position.column - 1), doc.lineAt(line).text.length); + const diagnostic = new vscode.Diagnostic(new vscode.Range(line, column, line, + Math.min(column + 2, doc.lineAt(line).text.length)), + l10n('inspection_' + issue.code, messages[issue.code] || 'Check link') + ': ' + issue.target, + vscode.DiagnosticSeverity.Warning); + diagnostic.source = 'ReadMD'; diagnostic.code = issue.code; return diagnostic; + })); + return result; + } catch (error) { + if (cache.get(key) === item) { cache.delete(key); diagnostics.delete(doc.uri); } + throw error; + } + }; + const schedule = (doc: vscode.TextDocument) => { + if (!eligible(doc)) return; + const key = doc.uri.toString(); clearTimeout(timers.get(key)); + timers.set(key, setTimeout(() => { timers.delete(key); void inspect(doc).catch(() => {}); }, 700)); + }; + const fileChanged = (uri: vscode.Uri) => { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (!folder || path.relative(folder.uri.fsPath, uri.fsPath).split(/[\\/]/) + .some(part => ['.git', 'node_modules', 'target', 'dist', 'build', 'vendor', '__pycache__'].includes(part))) return; + // VS Code's create/delete/rename events omit changes made by other programs. + // Invalidate in-flight results now, debounce the native reinspection. + cache.clear(); clearTimeout(fileChangeTimer); + fileChangeTimer = setTimeout(() => { + for (const doc of vscode.workspace.textDocuments) schedule(doc); + }, 250); + }; + const watcher = vscode.workspace.createFileSystemWatcher('**/*'); + context.subscriptions.push(diagnostics, + watcher, watcher.onDidCreate(fileChanged), watcher.onDidChange(fileChanged), watcher.onDidDelete(fileChanged), + { dispose() { disposed = true; clearTimeout(fileChangeTimer); for (const timer of timers.values()) clearTimeout(timer); cache.clear(); } }, + vscode.workspace.onDidOpenTextDocument(schedule), + vscode.workspace.onDidSaveTextDocument(() => { cache.clear(); for (const doc of vscode.workspace.textDocuments) schedule(doc); }), + vscode.workspace.onDidChangeTextDocument(event => { + if (vscode.window.activeTextEditor?.document === event.document) schedule(event.document); + }), + vscode.workspace.onDidCloseTextDocument(doc => { + const key = doc.uri.toString(); clearTimeout(timers.get(key)); timers.delete(key); + cache.delete(key); diagnostics.delete(doc.uri); + }), + vscode.workspace.onDidChangeWorkspaceFolders(() => { cache.clear(); diagnostics.clear(); for (const doc of vscode.workspace.textDocuments) schedule(doc); }), + vscode.workspace.onDidCreateFiles(() => { cache.clear(); for (const doc of vscode.workspace.textDocuments) schedule(doc); }), + vscode.workspace.onDidDeleteFiles(() => { cache.clear(); for (const doc of vscode.workspace.textDocuments) schedule(doc); }), + vscode.workspace.onDidRenameFiles(() => { cache.clear(); for (const doc of vscode.workspace.textDocuments) schedule(doc); }), + vscode.languages.registerDocumentLinkProvider({ language: 'markdown', scheme: 'file' }, { + async provideDocumentLinks(doc, token) { + if (token.isCancellationRequested) return []; + let result: Inspection | undefined; + try { result = await inspect(doc); } catch { return []; } + if (!result || token.isCancellationRequested) return []; + return result.links.filter(link => link.kind === 'wiki' && link.resolved_path && !link.ambiguous).flatMap(link => { + const row = link.position.line - 1; + if (row < 0 || row >= doc.lineCount) return []; + const line = doc.lineAt(row).text, start = link.position.column - 1, end = line.indexOf(']]', start); + if (start < 0 || end < start) return []; + const target = vscode.Uri.file(link.resolved_path!); + const anchor = link.resolved_anchor || link.target.split('#')[1]; + return [new vscode.DocumentLink(new vscode.Range(row, start, row, end + 2), + anchor ? target.with({ fragment: anchor }) : target)]; + }); + }, + }), + vscode.commands.registerCommand('readmd.inspectDocument', async () => { + const doc = vscode.window.activeTextEditor?.document; + if (!doc || !eligible(doc)) { void vscode.window.showInformationMessage(l10n('inspectionOpen', 'Open a Markdown file in a trusted workspace first.')); return; } + if (Buffer.byteLength(doc.getText(), 'utf8') > 2 * 1024 * 1024) { + void vscode.window.showInformationMessage(l10n('inspectionSize', 'Inspection supports documents up to 2 MB.')); return; + } + cache.delete(doc.uri.toString()); + try { + const result = await inspect(doc); + if (!result) { void vscode.window.showInformationMessage(l10n('inspectionChanged', 'The document changed during inspection. Run it again.')); return; } + await vscode.commands.executeCommand('workbench.actions.view.problems'); + void vscode.window.showInformationMessage(l10n(result.truncated ? 'inspectionLimited' : 'inspectionDone', + result.truncated ? 'Inspection reached a processing limit; some content was not checked.' : 'Document inspection complete: {count} items.', + { count: result.diagnostics.length })); + } catch { void vscode.window.showErrorMessage(l10n('inspectionUnavailable', 'Update ReadMD Core to use document inspection.')); } + }), + vscode.commands.registerCommand('readmd.searchWorkspace', async () => { + if (vscode.workspace.isTrusted === false) { void vscode.window.showWarningMessage(l10n('trustRequired', 'Trust this workspace before using native tools, AI or code execution.')); return; } + const roots = vscode.workspace.workspaceFolders; + if (!roots?.length) { void vscode.window.showInformationMessage(l10n('searchOpenWorkspace', 'Open a workspace folder first.')); return; } + const root = roots.length === 1 ? roots[0] : await vscode.window.showWorkspaceFolderPick(); + if (!root) return; + const query = await vscode.window.showInputBox({ prompt: l10n('searchPrompt', 'Search text, "phrases", path:, title:, tag: and -exclusions'), ignoreFocusOut: true }); + if (!query?.trim()) return; + try { + const result = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, + title: l10n('searchProgress', 'Searching local Markdown files'), cancellable: true }, + (_, token) => bridge.callMcpTool('readmd_search_workspace', { workspace_root: root.uri.fsPath, query, limit: 50 }, token)); + if (!result.results.length) { void vscode.window.showInformationMessage(l10n('searchEmpty', 'No matches in the scanned files.')); return; } + const picks = result.results.map((hit: any) => ({ label: hit.title, description: hit.relative_path, detail: hit.snippet, hit })); + const pick = await vscode.window.showQuickPick<{ label: string; description: string; detail: string; hit: any }>(picks, + { matchOnDescription: true, matchOnDetail: true, placeHolder: l10n(result.truncated ? 'searchLimited' : 'searchResults', + result.truncated ? 'Results limited to 50; refine your query' : 'Select a result to open') }); + if (!pick) return; + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(pick.hit.path)); + const editor = await vscode.window.showTextDocument(doc); + const row = Math.min(Math.max(0, pick.hit.line - 1), doc.lineCount - 1); + editor.selection = new vscode.Selection(row, 0, row, 0); editor.revealRange(new vscode.Range(row, 0, row, 0)); + } catch (error) { + if (error instanceof Error && ['ai_cancelled', 'cancelled'].includes(error.message)) return; + void vscode.window.showErrorMessage(l10n('searchUnavailable', 'Search could not finish. Check the query and update ReadMD Core.')); + } + })); + for (const doc of vscode.workspace.textDocuments) schedule(doc); +} diff --git a/packages/vscode-extension/test/commands.test.js b/packages/vscode-extension/test/commands.test.js index 98c9c103..b9b7aa3f 100644 --- a/packages/vscode-extension/test/commands.test.js +++ b/packages/vscode-extension/test/commands.test.js @@ -42,6 +42,10 @@ const zhBundle = JSON.parse(fs.readFileSync(path.join(extDir, 'l10n', 'bundle.l1 const enBundle = JSON.parse(fs.readFileSync(path.join(extDir, 'l10n', 'bundle.l10n.json'), 'utf-8')); const vscodeStub = { + languages: { + createDiagnosticCollection: () => ({ set() {}, delete() {}, clear() {}, dispose() {} }), + registerDocumentLinkProvider: () => ({ dispose() {} }), + }, l10n: { t: (opts, ...args) => { const key = opts && opts.key; @@ -100,6 +104,15 @@ const vscodeStub = { showTextDocument: async doc => { openedDocs.push(doc); return doc; }, }, workspace: { + createFileSystemWatcher: () => ({ onDidCreate: () => ({ dispose() {} }), onDidChange: () => ({ dispose() {} }), onDidDelete: () => ({ dispose() {} }), dispose() {} }), + textDocuments: [], + onDidOpenTextDocument: () => ({ dispose() {} }), + onDidSaveTextDocument: () => ({ dispose() {} }), + onDidCloseTextDocument: () => ({ dispose() {} }), + onDidChangeWorkspaceFolders: () => ({ dispose() {} }), + onDidRenameFiles: () => ({ dispose() {} }), + onDidCreateFiles: () => ({ dispose() {} }), + onDidDeleteFiles: () => ({ dispose() {} }), onDidChangeTextDocument: () => ({ dispose() {} }), findFiles: async () => [], workspaceFolders: undefined, @@ -231,12 +244,12 @@ test('AI status messages do not enter the result and only the selected source is finally { fakeBridgeInstance.aiChatStreaming = originalAi; vscodeStub.window.showInformationMessage = originalInfo; } }); -test('activate registers exactly the 22 commands contributed in package.json', () => { +test('activate registers every command contributed in package.json', () => { freshState(); const context = activateExtension(); const registeredIds = Object.keys(registered).sort(); assert.deepStrictEqual(registeredIds, contributedCommands); - assert.strictEqual(registeredIds.length, 22); + assert.strictEqual(registeredIds.length, contributedCommands.length); assert.ok(context.subscriptions.length > 0); for (const disposable of context.subscriptions) { assert.strictEqual(typeof disposable.dispose, 'function'); diff --git a/packages/vscode-extension/test/host.cjs b/packages/vscode-extension/test/host.cjs index e41eafcf..78e20d0c 100644 --- a/packages/vscode-extension/test/host.cjs +++ b/packages/vscode-extension/test/host.cjs @@ -35,6 +35,46 @@ exports.run = async function () { fs.writeFileSync(source, markdown); const document = await vscode.workspace.openTextDocument(vscode.Uri.file(source)); await vscode.window.showTextDocument(document); + const linked = path.join(root, 'Plato.md'); fs.writeFileSync(linked, '# Plato\n\nA careful argument.\n'); + const inspected = await bridge.callMcpTool('readmd_analyze_document', { + content: '[[Plato]]\n\n[Broken](#missing)\n', file_path: source, workspace_root: root, + }); + assert.equal(inspected.diagnostics[0].code, 'missing_anchor'); + assert.equal(path.basename(inspected.links[0].resolved_path), 'Plato.md'); + results.push('shared AST link inspection'); + const search = await bridge.callMcpTool('readmd_search_workspace', { workspace_root: root, query: 'title:Plato "careful argument"' }); + assert.equal(search.results[0].relative_path, 'Plato.md'); results.push('fresh workspace phrase and title search'); + const read = await bridge.callMcpTool('readmd_read_document', { file_path: linked }); + const args = { file_path: linked, expected_revision: read.revision, old_text: 'careful', new_text: 'clear' }; + const preview = await bridge.callMcpTool('readmd_edit_document', args); + assert.equal(preview.dry_run, true); assert.match(fs.readFileSync(linked, 'utf8'), /careful/); + const applied = await bridge.callMcpTool('readmd_edit_document', { ...args, dry_run: false, confirm: true }); + assert.match(fs.readFileSync(linked, 'utf8'), /clear/); + const history = await bridge.callMcpTool('readmd_document_history', { file_path: linked, operation: 'read', checkpoint_id: applied.checkpoint_id }); + assert.match(history.content, /careful/); results.push('safe edit preview, commit and recovery history'); + const inspectionFile=path.join(root,'Links.md'); + fs.writeFileSync(inspectionFile,'# Links\n\n[[Plato]]\n\n[[#Local section]]\n\n## Local section\n\n[Broken](#missing)\n'); + const inspectionDoc=await vscode.workspace.openTextDocument(vscode.Uri.file(inspectionFile)); + await vscode.window.showTextDocument(inspectionDoc); + await vscode.commands.executeCommand('readmd.inspectDocument'); + assert.ok(vscode.languages.getDiagnostics(inspectionDoc.uri).some(d=>d.source==='ReadMD'&&d.code==='missing_anchor')); + const documentLinks=await vscode.commands.executeCommand('vscode.executeLinkProvider',inspectionDoc.uri); + fs.writeFileSync(path.join(root,'link-provider.json'),JSON.stringify(documentLinks.map(link=>({target:link.target?.fsPath,text:inspectionDoc.getText(link.range)})),null,2)); + const wiki=documentLinks.find(link=>link.target?.fsPath.toLowerCase()===linked.toLowerCase()); + assert.ok(wiki,'Wiki target resolves to the real local file'); + assert.equal(inspectionDoc.getText(wiki.range),'[[Plato]]'); + const local=documentLinks.find(link=>inspectionDoc.getText(link.range)==='[[#Local section]]'); + assert.equal(local?.target?.fsPath.toLowerCase(),inspectionFile.toLowerCase()); + assert.equal(local?.target?.fragment,'local-section'); + results.push('native Problems diagnostics and clickable Wiki provider'); + const moved=path.join(root,'Renamed-Plato.md'); + const until=async predicate=>{const end=Date.now()+15000;while(Date.now()setTimeout(resolve,80));}throw Error('External file changes did not refresh diagnostics');}; + fs.renameSync(linked,moved); + await until(()=>vscode.languages.getDiagnostics(inspectionDoc.uri).some(d=>d.source==='ReadMD'&&d.code==='missing_file')); + fs.renameSync(moved,linked); + await until(()=>!vscode.languages.getDiagnostics(inspectionDoc.uri).some(d=>d.source==='ReadMD'&&d.code==='missing_file')); + results.push('external file rename refreshes diagnostics without editing the draft'); + await vscode.window.showTextDocument(document); await vscode.commands.executeCommand('readmd.preview'); results.push('real preview command'); await vscode.window.showTextDocument(document); await vscode.commands.executeCommand('readmd.openPresentation'); results.push('real presentation command'); diff --git a/packages/vscode-extension/test/intelligence.test.js b/packages/vscode-extension/test/intelligence.test.js new file mode 100644 index 00000000..ebca0ef6 --- /dev/null +++ b/packages/vscode-extension/test/intelligence.test.js @@ -0,0 +1,54 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const path=require('node:path'); +const Module=require('node:module'); +const entry=path.resolve(__dirname,'../out/intelligence.js'); +function fixture(){ + const root=path.resolve('synthetic-workspace'),events={},subscriptions=[],values=new Map(); + const uri=file=>({scheme:'file',fsPath:file,toString:()=>file,with(options){return {...this,...options};}}); + const doc={uri:uri(path.join(root,'Reading.md')),languageId:'markdown',version:1,isClosed:false, + lineCount:1,getText:()=> '[[Target]]',lineAt:()=>({text:'[[Target]]'})}; + const disposable=()=>({dispose(){}}),listener=name=>handler=>{events[name]=handler;return disposable();}; + let provider; + const stub={env:{language:'en'},Uri:{file:uri},DiagnosticSeverity:{Warning:1}, + Range:class{constructor(...args){this.args=args;}},Diagnostic:class{constructor(range,message,severity){Object.assign(this,{range,message,severity});}}, + DocumentLink:class{constructor(range,target){Object.assign(this,{range,target});}}, + languages:{createDiagnosticCollection:()=>({set:(key,value)=>values.set(key.toString(),value),delete:key=>values.delete(key.toString()),clear:()=>values.clear(),dispose(){}}), + registerDocumentLinkProvider:(_,value)=>{provider=value;return disposable();}}, + commands:{registerCommand:()=>disposable()},window:{activeTextEditor:{document:doc}}, + workspace:{isTrusted:true,textDocuments:[doc],getWorkspaceFolder:key=>key.fsPath.startsWith(root+path.sep)?{uri:uri(root)}:undefined, + createFileSystemWatcher:()=>({onDidCreate:listener('create'),onDidChange:listener('change'),onDidDelete:listener('delete'),dispose(){}}), + onDidOpenTextDocument:listener('open'),onDidSaveTextDocument:listener('save'),onDidChangeTextDocument:listener('edit'), + onDidCloseTextDocument:listener('close'),onDidChangeWorkspaceFolders:listener('folders'),onDidCreateFiles:listener('createFiles'),onDidDeleteFiles:listener('deleteFiles'),onDidRenameFiles:listener('renameFiles')} + }; + const original=Module._load; + Module._load=function(name,...args){return name==='vscode'?stub:original.call(this,name,...args);}; + let register;try{delete require.cache[entry];register=require(entry).registerIntelligence;}finally{Module._load=original;} + let exists=false,calls=0; + const result=()=>({truncated:false,links:exists?[{kind:'wiki',target:'Target',resolved_path:path.join(root,'Target.md'),position:{line:1,column:1}}]:[], + diagnostics:exists?[]:[{code:'missing_file',target:'Target',position:{line:1,column:1}}]}); + const bridge={callMcpTool:async()=>{calls++;return result();}}; + register({subscriptions},bridge); + return {events,doc,bridge,values,result,target:uri(path.join(root,'Target.md')),generated:uri(path.join(root,'node_modules','package.json')), + links:()=>provider.provideDocumentLinks(doc,{isCancellationRequested:false}),setExists:value=>{exists=value;},calls:()=>calls, + dispose:()=>subscriptions.forEach(s=>s.dispose())}; +} +test('external create/change/delete refresh native diagnostics without editing the source document',async()=>{ + const f=fixture();try{ + await f.links();assert.equal(f.values.get(f.doc.uri.toString())[0].code,'missing_file'); + await f.links();assert.equal(f.calls(),1,'Unchanged source reuses the current analysis'); + f.events.create(f.generated);await f.links();assert.equal(f.calls(),1,'Generated tree churn does not invalidate the cache'); + f.setExists(true);f.events.create(f.target);assert.equal((await f.links()).length,1);assert.deepEqual(f.values.get(f.doc.uri.toString()),[]); + f.events.change(f.target);await f.links();assert.equal(f.calls(),3); + f.setExists(false);f.events.delete(f.target);await f.links();assert.equal(f.values.get(f.doc.uri.toString())[0].code,'missing_file'); + assert.equal(f.doc.version,1,'External target updates do not mutate the source draft'); + }finally{f.dispose();} +}); +test('a filesystem invalidation discards a late analysis result',async()=>{ + const f=fixture();try{ + let resolve;f.bridge.callMcpTool=()=>new Promise(done=>{resolve=done;}); + const pending=f.links();f.events.change(f.target);resolve(f.result()); + assert.deepEqual(await pending,[]);assert.equal(f.values.size,0,'Late diagnostics cannot reappear'); + f.setExists(true);f.bridge.callMcpTool=async()=>f.result();assert.equal((await f.links()).length,1); + }finally{f.dispose();} +}); diff --git a/release/release_notes.md b/release/release_notes.md index 635d226e..b274e9c1 100644 --- a/release/release_notes.md +++ b/release/release_notes.md @@ -1,4 +1,4 @@ -# ReadMD v0.0.4 更新说明 +# ReadMD v0.0.5 更新说明 ReadMD 是本地优先的 Markdown 阅读、编辑与格式转换工具。v2.3.9 在延续本地优先、全语种母语化与格式转换能力的基础上,正式推出 **CC0 许可的 Arch-Chan Live2D 与 73 款 Petdex 精灵图桌面伴读宠物系统(含活态微呼吸自适应引擎与全端台词联动)**,重构 **纯正苹果风原生拖动条(Apple HIG 4px 细轨道与 18px 物理触觉旋钮)**,新增 **原生矢量高保真 PDF 编辑器及 FastMCP 21 项扩展工具链**,强化 **纯 Python Word .doc 二进制流(FIB/CLX)与复杂表格聚类重构**,达成 **46 种语言 100% 字典对齐(1563 词条零裸露、零空值、零英文照搬)**,并实现 **打包彻底瘦身至 28.2MB 与 2 秒极速冷启动**。 @@ -6,12 +6,12 @@ ReadMD 是本地优先的 Markdown 阅读、编辑与格式转换工具。v2.3.9 | 系统 | 架构 | 交付物 | 资产文件名 | | --- | --- | --- | --- | -| Windows 10/11 | x64、ARM64 | 安装版、便携版 | `ReadMDSetup-v0.0.4.exe`、`ReadMD-portable-v0.0.4.exe`(x64);`ReadMDSetup-arm64-v0.0.4.exe`、`ReadMD-portable-arm64-v0.0.4.exe`(ARM64) | -| macOS 13+ | Intel x64、Apple Silicon ARM64 | 原生压缩包 | `ReadMD-macos-x64-v0.0.4.zip` / `ReadMD-macos-arm64-v0.0.4.zip` | -| Ubuntu 22.04/24.04、Debian 12 | x64、ARM64 | AppImage、Deb | `ReadMD-linux-x86_64-v0.0.4.AppImage` / `ReadMD-linux-aarch64-v0.0.4.AppImage`;`readmd_0.0.4_amd64.deb` / `readmd_0.0.4_arm64.deb` | -| 统信 UOS 20、银河麒麟 V10、Deepin 23 | x64、ARM64 | 目标 Deb | `readmd_0.0.4_amd64.deb` / `readmd_0.0.4_arm64.deb`;真实系统证据完成前不构成正式支持承诺 | -| VS Code | Extension Host 支持的桌面架构 | VSIX | `readmd-vscode-0.0.4.vsix` | -| MCP 客户端 | Python 3.11+ / stdio | MCP ZIP | `readmd-mcp-server-0.0.4.zip` | +| Windows 10/11 | x64、ARM64 | 安装版、便携版 | `ReadMDSetup-v0.0.5.exe`、`ReadMD-portable-v0.0.5.exe`(x64);`ReadMDSetup-arm64-v0.0.5.exe`、`ReadMD-portable-arm64-v0.0.5.exe`(ARM64) | +| macOS 13+ | Intel x64、Apple Silicon ARM64 | 原生压缩包 | `ReadMD-macos-x64-v0.0.5.zip` / `ReadMD-macos-arm64-v0.0.5.zip` | +| Ubuntu 22.04/24.04、Debian 12 | x64、ARM64 | AppImage、Deb | `ReadMD-linux-x86_64-v0.0.5.AppImage` / `ReadMD-linux-aarch64-v0.0.5.AppImage`;`readmd_0.0.5_amd64.deb` / `readmd_0.0.5_arm64.deb` | +| 统信 UOS 20、银河麒麟 V10、Deepin 23 | x64、ARM64 | 目标 Deb | `readmd_0.0.5_amd64.deb` / `readmd_0.0.5_arm64.deb`;真实系统证据完成前不构成正式支持承诺 | +| VS Code | Extension Host 支持的桌面架构 | VSIX | `readmd-vscode-0.0.5.vsix` | +| MCP 客户端 | Python 3.11+ / stdio | MCP ZIP | `readmd-mcp-server-0.0.5.zip` | | 校验清单 | 全架构 | SHA-256 | `SHA256SUMS.txt` | 本版本经 `READMD_SELF_USE_RELEASE` 自用通道发布(仅 `v2.3.9` tag 生效):资产未做 Authenticode / macOS codesign 签名,原生平台证据清单按参考口径(informational)提供;多平台原生构建、冒烟自检、隐私扫描与校验和生成照常执行。首次运行如遇系统"未知发布者"提示,请先核对 `SHA256SUMS.txt` 再安装。 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e983304d..7abeb104 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2225,7 +2225,7 @@ dependencies = [ [[package]] name = "readmd-kernel" -version = "0.0.4" +version = "0.0.5" dependencies = [ "aes", "base64", @@ -3631,7 +3631,7 @@ dependencies = [ [[package]] name = "xtask" -version = "0.0.4" +version = "0.0.5" dependencies = [ "base64", "flate2", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 73ad4444..dca653fb 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["readmd-kernel", "xtask"] [workspace.package] -version = "0.0.4" +version = "0.0.5" edition = "2021" rust-version = "1.85" license = "MIT" diff --git a/rust/readmd-kernel/src/document_intelligence.rs b/rust/readmd-kernel/src/document_intelligence.rs new file mode 100644 index 00000000..d7665218 --- /dev/null +++ b/rust/readmd-kernel/src/document_intelligence.rs @@ -0,0 +1,1142 @@ +//! Shared, bounded document inspection and local workspace operations. +//! No network, third-party runtime, hidden document copies, or UI dependency. +use crate::{content, App}; +use pulldown_cmark::{Event, LinkType, Options, Parser, Tag, TagEnd}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, HashSet}, + fs, + io::Read, + path::{Component, Path, PathBuf}, + sync::atomic::{AtomicBool, Ordering}, +}; + +pub const MAX_DOCUMENT_BYTES: usize = 2 * 1024 * 1024; +const MAX_FILES: usize = 5000; +const MAX_SCAN_BYTES: usize = 64 * 1024 * 1024; +const MAX_ITEMS: usize = 2000; + +pub fn revision(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn cancelled(cancel: Option<&AtomicBool>) -> Result<(), String> { + if cancel.is_some_and(|c| c.load(Ordering::SeqCst)) { + Err("cancelled".into()) + } else { + Ok(()) + } +} +fn absolute_file(path: &Path) -> Result { + if !path.is_absolute() { + return Err("file_path_must_be_absolute".into()); + } + if fs::symlink_metadata(path) + .map_err(|_| "file_not_found")? + .file_type() + .is_symlink() + { + return Err("symlink_not_allowed".into()); + } + let canonical = fs::canonicalize(path).map_err(|_| "file_not_found")?; + if !canonical.is_file() { + return Err("file_not_found".into()); + } + if !content::is_readable(&canonical) { + return Err("unsupported_text_file".into()); + } + Ok(crate::paths::canonicalize_or_clean(&canonical)) +} +fn bytes_bounded(path: &Path) -> Result, String> { + let file = fs::File::open(path).map_err(|_| "file_read_failed")?; + if file.metadata().map_err(|_| "file_read_failed")?.len() > MAX_DOCUMENT_BYTES as u64 { + return Err("document_too_large".into()); + } + let mut bytes = Vec::new(); + file.take(MAX_DOCUMENT_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "file_read_failed")?; + if bytes.len() > MAX_DOCUMENT_BYTES { + return Err("document_too_large".into()); + } + Ok(bytes) +} +fn markdown(path: &Path) -> bool { + matches!( + content::ext_of(path).as_str(), + "md" | "markdown" | "mdown" | "mkd" | "mdx" | "txt" + ) +} +fn visible(entry: &walkdir::DirEntry) -> bool { + if entry.depth() == 0 { + return true; + } + let name = entry.file_name().to_string_lossy(); + !name.starts_with('.') + && !matches!( + name.as_ref(), + "node_modules" | "target" | "dist" | "build" | "vendor" | "venv" | "__pycache__" + ) +} +fn workspace(root: &Path) -> Result { + if !root.is_absolute() { + return Err("workspace_must_be_absolute".into()); + } + let root = fs::canonicalize(root).map_err(|_| "workspace_not_found")?; + if !root.is_dir() { + return Err("workspace_not_found".into()); + } + Ok(crate::paths::canonicalize_or_clean(&root)) +} +fn local_path(base: &Path, raw: &str, root: &Path) -> Option { + if raw.contains('\0') || raw.starts_with("//") || raw.starts_with("\\\\") { + return None; + } + let decoded = percent_encoding::percent_decode_str(raw) + .decode_utf8() + .ok()?; + if decoded.contains('\0') || decoded.contains('\\') || decoded.contains(':') { + return None; + } + let (start, relative) = if decoded.starts_with('/') { + (root, decoded.trim_start_matches('/')) + } else { + (base, decoded.as_ref()) + }; + let mut candidate = start.to_path_buf(); + for part in Path::new(relative).components() { + match part { + Component::Normal(p) => candidate.push(p), + Component::CurDir => (), + Component::ParentDir => { + candidate.pop(); + } + _ => return None, + } + } + if !candidate.starts_with(root) { + return None; + } + let resolved = crate::paths::canonical_existing(&candidate).ok()?; + let resolved = crate::paths::canonicalize_or_clean(&resolved); + if resolved.starts_with(root) { + Some(resolved) + } else { + None + } +} +fn options() -> Options { + Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_TASKLISTS + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_WIKILINKS + | Options::ENABLE_YAML_STYLE_METADATA_BLOCKS +} +fn line_starts(text: &str) -> Vec { + std::iter::once(0) + .chain(text.match_indices('\n').map(|(n, _)| n + 1)) + .collect() +} +fn position(text: &str, starts: &[usize], offset: usize) -> Value { + let offset = offset.min(text.len()); + let row = starts.partition_point(|n| *n <= offset).saturating_sub(1); + json!({"line": row + 1, "column": text[starts[row]..offset].encode_utf16().count() + 1}) +} +#[derive(Clone, Copy)] +enum AnchorStyle { + Standard, + Desktop, +} +fn heading_slug(title: &str, index: usize, style: AnchorStyle) -> String { + let filtered: String = title + .trim() + .to_lowercase() + .chars() + .filter(|c| { + c.is_whitespace() + || *c == '_' + || *c == '-' + || match style { + AnchorStyle::Standard => c.is_alphanumeric(), + AnchorStyle::Desktop => { + c.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(c) + } + } + }) + .collect(); + match style { + AnchorStyle::Standard => filtered + .chars() + .map(|c| if c.is_whitespace() { '-' } else { c }) + .collect(), + AnchorStyle::Desktop => { + let slug = filtered.split_whitespace().collect::>().join("-"); + if slug.is_empty() { + format!("toc-h-{index}") + } else { + slug + } + } + } +} +fn heading_data(text: &str, style: AnchorStyle) -> Vec { + let starts = line_starts(text); + let mut current: Option<(u8, usize, String, Option)> = None; + let mut headings = Vec::new(); + let mut used: HashMap = HashMap::new(); + for (event, span) in Parser::new_ext(text, options()).into_offset_iter() { + match event { + Event::Start(Tag::Heading { level, id, .. }) => { + current = Some(( + level as u8, + span.start, + String::new(), + id.map(|s| s.to_string()), + )) + } + Event::Text(t) | Event::Code(t) => { + if let Some(h) = current.as_mut() { + h.2.push_str(&t); + } + } + Event::SoftBreak | Event::HardBreak => { + if let Some(h) = current.as_mut() { + h.2.push(' '); + } + } + Event::End(TagEnd::Heading(_)) => { + if let Some((level, offset, title, explicit)) = current.take() { + let base = + explicit.unwrap_or_else(|| heading_slug(&title, headings.len(), style)); + let count = used.entry(base.clone()).or_insert(0); + let suffix = *count + + if matches!(style, AnchorStyle::Desktop) { + 1 + } else { + 0 + }; + let id = if *count == 0 { + base + } else { + format!("{base}-{suffix}") + }; + *count += 1; + headings.push(json!({"level":level,"text":title,"id":id,"position":position(text,&starts,offset)})); + if headings.len() >= MAX_ITEMS { + break; + } + } + } + _ => (), + } + } + headings +} + +// Keep the legacy conversion parser unchanged. Accept the common YAML tags +// list without evaluating YAML directives, aliases or custom types. +fn metadata(text: &str) -> Value { + let mut result = content::front_matter(text); + let mut lines = text.trim_start_matches('\u{feff}').lines(); + if lines.next() != Some("---") { + return result; + } + let mut tags = Vec::new(); + let mut in_tags = false; + let mut size = 0; + for line in lines { + size += line.len(); + if size > 16 * 1024 || line == "---" || line == "..." { + break; + } + if line.trim() == "tags:" { + in_tags = true; + continue; + } + if in_tags { + if let Some(tag) = line.trim().strip_prefix("- ") { + let tag = tag.trim().trim_matches(|c| c == '"' || c == '\''); + if !tag.is_empty() { + tags.push(json!(tag)); + } + } else if !line.trim().is_empty() { + in_tags = false; + } + } + } + if !tags.is_empty() { + result["tags"] = json!(tags); + } + result +} + +/// AST-based inspection. Filesystem checking is explicit and stays in root. +pub fn analyze(text: &str, file: Option<&Path>, root: Option<&Path>) -> Result { + analyze_with_style(text, file, root, AnchorStyle::Standard) +} +pub fn analyze_for_reader( + text: &str, + file: Option<&Path>, + root: Option<&Path>, +) -> Result { + analyze_with_style(text, file, root, AnchorStyle::Desktop) +} +fn analyze_with_style( + text: &str, + file: Option<&Path>, + root: Option<&Path>, + style: AnchorStyle, +) -> Result { + if text.len() > MAX_DOCUMENT_BYTES { + return Err("document_too_large".into()); + } + let owned_root = root.map(workspace).transpose()?; + let file = file.map(|p| crate::paths::canonicalize_or_clean(p)); + let base = file + .as_deref() + .and_then(Path::parent) + .or(owned_root.as_deref()); + let root = owned_root.as_deref().or(base); + let starts = line_starts(text); + let headings = heading_data(text, style); + let own_ids: HashSet = headings + .iter() + .filter_map(|h| h["id"].as_str().map(str::to_string)) + .collect(); + let mut links = Vec::new(); + let mut diagnostics = Vec::new(); + let mut tasks = (0usize, 0usize); + let mut code_depth = 0usize; + let mut footnotes = HashSet::new(); + let mut foot_refs = Vec::new(); + let mut target_headings: HashMap>> = HashMap::new(); + let mut truncated = headings.len() == MAX_ITEMS; + let mut wiki_files: Option>> = None; + let mut add = |code: &str, offset: usize, target: &str| { + if diagnostics.len() < 200 { + diagnostics.push(json!({"code":code,"severity":"warning", + "target":target,"position":position(text,&starts,offset)})); + } + }; + for (event, span) in Parser::new_ext(text, options()).into_offset_iter() { + match event { + Event::Start(Tag::CodeBlock(_)) => { + code_depth += 1; + } + Event::End(TagEnd::CodeBlock) => { + code_depth = code_depth.saturating_sub(1); + } + Event::TaskListMarker(done) => { + tasks.0 += 1; + if done { + tasks.1 += 1; + } + } + Event::Start(Tag::FootnoteDefinition(label)) => { + footnotes.insert(label.to_string()); + } + Event::FootnoteReference(label) => foot_refs.push((label.to_string(), span.start)), + Event::Start(Tag::Link { + link_type, + dest_url, + .. + }) + | Event::Start(Tag::Image { + link_type, + dest_url, + .. + }) if code_depth == 0 => { + if links.len() >= MAX_ITEMS { + truncated = true; + continue; + } + let raw = dest_url.to_string(); + let wiki = matches!(link_type, LinkType::WikiLink { .. }); + let external = raw.starts_with("//") + || raw.split_once(':').is_some_and(|(scheme, _)| { + !scheme.is_empty() + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) + }); + let mut link = json!({"target":raw,"kind":if wiki {"wiki"} else {"markdown"}, + "external":external,"position":position(text,&starts,span.start)}); + if !external { + let (path_part, anchor) = raw + .split_once('#') + .map(|(p, a)| (p, Some(a))) + .unwrap_or((&raw, None)); + let path_part = path_part.split('?').next().unwrap_or(path_part); + if path_part.is_empty() { + if wiki { + if let Some(file) = file.as_ref() { + link["resolved_path"] = json!(file); + } + } + if let Some(anchor) = anchor { + let anchor = + percent_encoding::percent_decode_str(anchor).decode_utf8_lossy(); + let normalized = heading_slug(&anchor, 0, style); + if !anchor.is_empty() + && !own_ids.contains(anchor.as_ref()) + && !(wiki && own_ids.contains(&normalized)) + { + add("missing_anchor", span.start, &raw); + } else if wiki { + link["resolved_anchor"] = + json!(if own_ids.contains(anchor.as_ref()) { + anchor.into_owned() + } else { + normalized + }); + } + } + } else if let (Some(base), Some(root)) = (base, root) { + if let Some(mut target) = local_path(base, path_part, root) { + if wiki && !target.is_file() && target.extension().is_none() { + target.set_extension("md"); + } + if wiki && !target.is_file() && !path_part.contains('/') { + let files = wiki_files.get_or_insert_with(|| { + let mut map: HashMap> = HashMap::new(); + for e in walkdir::WalkDir::new(root) + .follow_links(false) + .sort_by_file_name() + .into_iter() + .filter_entry(visible) + .filter_map(Result::ok) + .take(MAX_FILES) + { + if e.file_type().is_file() && markdown(e.path()) { + if let Ok(p) = fs::canonicalize(e.path()) + .map(|p| crate::paths::canonicalize_or_clean(&p)) + { + if p.starts_with(root) { + map.entry( + e.path() + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(), + ) + .or_default() + .push(p); + } + } + } + } + map + }); + let key = Path::new(path_part) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + if let Some(found) = files.get(&key) { + if found.len() == 1 { + target = found[0].clone(); + } else { + add("ambiguous_link", span.start, &raw); + link["ambiguous"] = json!(true); + } + } + } + if target.is_file() { + link["resolved_path"] = json!(target); + if let Some(anchor) = anchor.filter(|a| !a.is_empty()) { + if markdown(&target) + && (target_headings.contains_key(&target) + || target_headings.len() < 32) + { + let ids = target_headings + .entry(target.clone()) + .or_insert_with(|| { + bytes_bounded(&target).ok().map(|b| { + heading_data( + &crate::text_encoding::detect_and_decode( + &b, + ) + .0, + style, + ) + .iter() + .filter_map(|h| { + h["id"].as_str().map(str::to_string) + }) + .collect() + }) + }); + let anchor = percent_encoding::percent_decode_str(anchor) + .decode_utf8_lossy(); + if let Some(ids) = ids { + if !ids.contains(anchor.as_ref()) + && !(wiki + && ids + .contains(&heading_slug(&anchor, 0, style))) + { + add("missing_anchor", span.start, &raw); + } else if wiki { + link["resolved_anchor"] = + json!(if ids.contains(anchor.as_ref()) { + anchor.into_owned() + } else { + heading_slug(&anchor, 0, style) + }); + } + } else { + truncated = true; + } + } else if markdown(&target) { + truncated = true; + } + } + } else if link.get("ambiguous").is_none() { + add("missing_file", span.start, &raw); + } + } else { + add("outside_workspace", span.start, &raw); + } + } + } + links.push(link); + } + _ => (), + } + } + for (label, offset) in foot_refs { + if !footnotes.contains(&label) { + add("missing_footnote", offset, &label); + } + } + Ok( + json!({"ok":true,"headings":headings,"links":links,"diagnostics":diagnostics, + "stats":{"words":content::count_words(text),"lines":starts.len(),"tasks":tasks.0,"completed_tasks":tasks.1}, + "metadata":metadata(text),"truncated":truncated || diagnostics.len() == 200, + "limits":{"document_bytes":MAX_DOCUMENT_BYTES,"links":MAX_ITEMS,"diagnostics":200,"anchor_files":32}}), + ) +} + +pub fn read_document(path: &Path, start: usize, end: usize) -> Result { + if start == 0 || end < start || end - start >= 1000 { + return Err("invalid_line_range".into()); + } + let path = absolute_file(path)?; + let bytes = bytes_bounded(&path)?; + let (text, encoding) = crate::text_encoding::detect_and_decode(&bytes); + let lines: Vec<&str> = text.split_inclusive('\n').collect(); + if start > lines.len().max(1) { + return Err("line_out_of_range".into()); + } + let eol = if text.contains("\r\n") { "crlf" } else { "lf" }; + let selected = lines + .iter() + .skip(start - 1) + .take(end - start + 1) + .copied() + .collect::(); + Ok( + json!({"ok":true,"path":path,"revision":revision(&bytes),"encoding":encoding,"eol":eol, + "start_line":start,"end_line":end.min(lines.len().max(1)),"total_lines":lines.len(), + "content":selected,"truncated":start>1 || end Result, String> { + if query.len() > 4096 { + return Err("query_too_large".into()); + } + let mut words = Vec::new(); + let mut word = String::new(); + let mut quote = false; + for c in query.chars() { + if c == '"' { + quote = !quote; + } else if c.is_whitespace() && !quote { + if !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + } else { + word.push(c); + } + } + if quote { + return Err("unclosed_search_quote".into()); + } + if !word.is_empty() { + words.push(word); + } + if words.is_empty() || words.len() > 16 { + return Err("invalid_search_query".into()); + } + Ok(words + .into_iter() + .map(|word| { + let exclude = word.starts_with('-'); + let word = word.trim_start_matches('-'); + let (field, value) = word + .split_once(':') + .filter(|(f, _)| matches!(*f, "path" | "tag" | "title")) + .unwrap_or(("text", word)); + Term { + field: field.into(), + value: value.to_lowercase(), + exclude, + } + }) + .collect()) +} +pub fn search_workspace( + root: &Path, + query: &str, + limit: usize, + cancel: Option<&AtomicBool>, +) -> Result { + if !(1..=100).contains(&limit) { + return Err("invalid_search_limit".into()); + } + let root = workspace(root)?; + let terms = query_terms(query)?; + if terms.iter().any(|t| t.value.is_empty()) { + return Err("invalid_search_query".into()); + } + let mut hits = Vec::new(); + let mut files = 0; + let mut scanned = 0; + let mut skipped = 0; + let mut truncated = false; + for entry in walkdir::WalkDir::new(&root) + .follow_links(false) + .sort_by_file_name() + .into_iter() + .filter_entry(visible) + { + cancelled(cancel)?; + let Ok(entry) = entry else { + skipped += 1; + continue; + }; + if !entry.file_type().is_file() || !markdown(entry.path()) { + continue; + } + if files >= MAX_FILES || scanned >= MAX_SCAN_BYTES { + truncated = true; + break; + } + files += 1; + let Ok(path) = + fs::canonicalize(entry.path()).map(|p| crate::paths::canonicalize_or_clean(&p)) + else { + skipped += 1; + continue; + }; + if !path.starts_with(&root) { + skipped += 1; + continue; + } + let Ok(bytes) = bytes_bounded(&path) else { + skipped += 1; + continue; + }; + if bytes.len() > MAX_SCAN_BYTES - scanned { + truncated = true; + break; + } + scanned += bytes.len(); + let (text, _) = crate::text_encoding::detect_and_decode(&bytes); + let relative = entry + .path() + .strip_prefix(&root) + .unwrap_or(entry.path()) + .to_string_lossy() + .replace('\\', "/"); + let title = content::title_of(&path, &text); + let body = text.to_lowercase(); + let title_lower = title.to_lowercase(); + let metadata = metadata(&text); + let tags = metadata + .get("tags") + .map(|v| { + v.as_array() + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_lowercase) + .collect::>() + }) + .unwrap_or_else(|| { + v.as_str() + .unwrap_or("") + .split(',') + .map(|s| s.trim().to_lowercase()) + .collect() + }) + }) + .unwrap_or_default(); + if !terms.iter().all(|t| { + let found = match t.field.as_str() { + "path" => relative.to_lowercase().contains(&t.value), + "title" => title_lower.contains(&t.value), + "tag" => tags + .iter() + .any(|tag| tag.trim_start_matches('#') == t.value.trim_start_matches('#')), + _ => body.contains(&t.value), + }; + found != t.exclude + }) { + continue; + } + let line = text + .lines() + .position(|line| { + terms + .iter() + .filter(|t| !t.exclude && t.field == "text") + .any(|t| line.to_lowercase().contains(&t.value)) + }) + .unwrap_or(0) + + 1; + let snippet = text + .lines() + .nth(line - 1) + .unwrap_or("") + .chars() + .take(240) + .collect::(); + let score = terms + .iter() + .filter(|t| !t.exclude) + .map(|t| { + if title_lower.contains(&t.value) { + 10 + } else { + 1 + } + }) + .sum::(); + hits.push(json!({"path":path,"relative_path":relative,"title":title,"line":line,"snippet":snippet,"score":score})); + } + hits.sort_by(|a, b| { + b["score"].as_u64().cmp(&a["score"].as_u64()).then_with(|| { + a["relative_path"] + .as_str() + .cmp(&b["relative_path"].as_str()) + }) + }); + let matched = hits.len(); + hits.truncate(limit); + Ok( + json!({"ok":true,"results":hits,"matched":matched,"truncated":truncated || matched>limit, + "scanned_files":files,"scanned_bytes":scanned,"skipped_files":skipped, + "limits":{"files":MAX_FILES,"total_bytes":MAX_SCAN_BYTES,"document_bytes":MAX_DOCUMENT_BYTES}}), + ) +} + +/// Literal edits default to preview; committing requires a byte revision and +/// confirmation, preserves encoding/EOL, and records bounded recovery history. +pub fn edit_document( + app: &App, + path: &Path, + expected: &str, + old: &str, + new: &str, + replace_all: bool, + dry_run: bool, + confirm: bool, + cancel: Option<&AtomicBool>, +) -> Result { + if expected.len() != 64 || !expected.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("expected_revision_required".into()); + } + if old.is_empty() { + return Err("empty_edit_match".into()); + } + if new.len() > MAX_DOCUMENT_BYTES { + return Err("document_too_large".into()); + } + if !dry_run && !confirm { + return Err("confirmation_required".into()); + } + let _guard = crate::document_history::SAVE_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + cancelled(cancel)?; + let path = absolute_file(path)?; + let bytes = bytes_bounded(&path)?; + if revision(&bytes) != expected.to_lowercase() { + return Err("document_conflict".into()); + } + let (text, encoding) = crate::text_encoding::detect_and_decode(&bytes); + if crate::text_encoding::encode(&text, encoding).map_err(|_| "encoding_unrepresentable")? + != bytes + { + return Err("encoding_not_lossless".into()); + } + let old = old.replace("\r\n", "\n"); + let new = new.replace("\r\n", "\n"); + let normalized = text.replace("\r\n", "\n"); + let count = normalized.matches(&old).count(); + if count == 0 { + return Err("edit_match_not_found".into()); + } + if count > 1 && !replace_all { + return Err("ambiguous_edit_match".into()); + } + let replacements = if replace_all { count } else { 1 }; + let proposed = normalized + .len() + .checked_sub(old.len().saturating_mul(replacements)) + .and_then(|n| { + new.len() + .checked_mul(replacements) + .and_then(|added| n.checked_add(added)) + }); + if proposed.is_none_or(|n| n > MAX_DOCUMENT_BYTES) { + return Err("document_too_large".into()); + } + // Map normalized match boundaries back to source bytes. Untouched text, + // including mixed line endings, remains byte-for-byte equivalent. + let mut offsets = Vec::with_capacity(normalized.len() + 1); + for (i, byte) in text.bytes().enumerate() { + if byte == b'\n' && i > 0 && text.as_bytes()[i - 1] == b'\r' { + continue; + } + offsets.push(i); + } + offsets.push(text.len()); + let mut edited = String::with_capacity(text.len()); + let mut previous = 0; + for (at, _) in normalized.match_indices(&old).take(replacements) { + let begin = offsets[at]; + let end = offsets[at + old.len()]; + let crlf = text[begin..end].contains("\r\n") + || (!text[begin..end].contains('\n') && text.contains("\r\n")); + let replacement = if crlf { + new.replace('\n', "\r\n") + } else { + new.clone() + }; + if edited.len() + (begin - previous) + replacement.len() > MAX_DOCUMENT_BYTES { + return Err("document_too_large".into()); + } + edited.push_str(&text[previous..begin]); + edited.push_str(&replacement); + previous = end; + } + if edited.len() + text.len() - previous > MAX_DOCUMENT_BYTES { + return Err("document_too_large".into()); + } + edited.push_str(&text[previous..]); + let encoded = + crate::text_encoding::encode(&edited, encoding).map_err(|_| "encoding_unrepresentable")?; + if encoded.len() > MAX_DOCUMENT_BYTES { + return Err("document_too_large".into()); + } + let changed = encoded != bytes; + let mut checkpoint = Value::Null; + if !dry_run && changed { + cancelled(cancel)?; + if revision(&bytes_bounded(&path)?) != expected.to_lowercase() { + return Err("document_conflict".into()); + } + checkpoint = json!(crate::document_history::checkpoint_file( + &app.paths.data_dir, + &path, + "mcp_edit" + )? + .map(|entry| entry.id)); + cancelled(cancel)?; + if revision(&bytes_bounded(&path)?) != expected.to_lowercase() { + return Err("document_conflict".into()); + } + content::write_bytes_atomic(&path, &encoded).map_err(|_| "file_write_failed")?; + let _ = content::index(app, &path); + } + Ok( + json!({"ok":true,"dry_run":dry_run,"changed":changed,"replacements":if replace_all{count}else{1}, + "revision":revision(if dry_run {&bytes}else{&encoded}),"proposed_revision":revision(&encoded), + "encoding":encoding,"checkpoint_id":checkpoint, + "preview":{"before":old.chars().take(1600).collect::(),"after":new.chars().take(1600).collect::(), + "truncated":old.chars().count()>1600 || new.chars().count()>1600}}), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + fn app(root: &Path) -> App { + App::bootstrap(crate::paths::AppPaths::with_dirs( + &root.join("data"), + root, + &root.join("assets"), + )) + .unwrap() + } + #[test] + fn each_host_checks_the_anchor_ids_it_actually_renders() { + assert!( + analyze("# Alpha title\n\n[[#Alpha title]]\n", None, None).unwrap()["diagnostics"] + .as_array() + .unwrap() + .is_empty() + ); + let source = "# Alpha\n\n# Alpha\n\n[duplicate](#alpha-1)\n"; + assert!(analyze(source, None, None).unwrap()["diagnostics"] + .as_array() + .unwrap() + .is_empty()); + assert_eq!( + analyze_for_reader(source, None, None).unwrap()["diagnostics"][0]["code"], + "missing_anchor" + ); + assert!( + analyze_for_reader(&source.replace("#alpha-1", "#alpha-2"), None, None).unwrap() + ["diagnostics"] + .as_array() + .unwrap() + .is_empty() + ); + let unicode = "# Café\n\n[valid](#café)\n"; + assert!(analyze(unicode, None, None).unwrap()["diagnostics"] + .as_array() + .unwrap() + .is_empty()); + assert!( + analyze_for_reader(&unicode.replace("(#café)", "(#caf)"), None, None).unwrap() + ["diagnostics"] + .as_array() + .unwrap() + .is_empty() + ); + } + #[test] + fn edits_require_revision_confirmation_and_keep_recovery_out_of_document_folder() { + let dir = tempfile::tempdir().unwrap(); + let app = app(dir.path()); + let file = dir.path().join("original.md"); + let original = b"# Notes\r\n\r\nOriginal text.\r\n"; + fs::write(&file, original).unwrap(); + let rev = revision(original); + let preview = edit_document( + &app, &file, &rev, "Original", "Revised", false, true, false, None, + ) + .unwrap(); + assert_eq!(preview["changed"], true); + assert_eq!(fs::read(&file).unwrap(), original); + assert_eq!( + edit_document(&app, &file, &rev, "Original", "Revised", false, false, false, None) + .unwrap_err(), + "confirmation_required" + ); + let result = edit_document( + &app, &file, &rev, "Original", "Revised", false, false, true, None, + ) + .unwrap(); + assert_eq!( + fs::read(&file).unwrap(), + b"# Notes\r\n\r\nRevised text.\r\n" + ); + let id = result["checkpoint_id"].as_str().unwrap(); + assert_eq!( + crate::document_history::read(&app.paths.data_dir, id) + .unwrap() + .1, + "# Notes\r\n\r\nOriginal text.\r\n" + ); + assert!(!dir.path().join("original.md.bak").exists()); + assert_eq!( + edit_document(&app, &file, &rev, "Revised", "Lost", false, false, true, None) + .unwrap_err(), + "document_conflict" + ); + } + #[test] + fn editing_preserves_utf16_bom_mixed_eol_and_rejects_ambiguous_or_explosive_changes() { + let dir = tempfile::tempdir().unwrap(); + let app = app(dir.path()); + let file = dir.path().join("mixed.md"); + let text = "# Notes\r\nsame\nsame\r\n"; + let bytes = crate::text_encoding::encode(text, "utf-16le").unwrap(); + fs::write(&file, &bytes).unwrap(); + let rev = revision(&bytes); + assert_eq!( + edit_document(&app, &file, &rev, "same", "new", false, true, false, None).unwrap_err(), + "ambiguous_edit_match" + ); + let result = + edit_document(&app, &file, &rev, "same", "new", true, false, true, None).unwrap(); + assert_eq!(result["encoding"], "utf-16-le"); + assert_eq!( + fs::read(&file).unwrap(), + crate::text_encoding::encode("# Notes\r\nnew\nnew\r\n", "utf-16le").unwrap() + ); + fs::write(&file, "a".repeat(64 * 1024)).unwrap(); + assert_eq!( + edit_document( + &app, + &file, + &revision(&fs::read(&file).unwrap()), + "a", + &"b".repeat(64 * 1024), + true, + true, + false, + None + ) + .unwrap_err(), + "document_too_large" + ); + } + #[test] + fn legacy_chinese_encoding_is_preserved_and_lossy_input_is_never_overwritten() { + let dir = tempfile::tempdir().unwrap(); + let app = app(dir.path()); + let file = dir.path().join("reading.md"); + let bytes = crate::text_encoding::encode("# 阅读\n\n你好,世界。\n", "gb18030").unwrap(); + fs::write(&file, &bytes).unwrap(); + let changed = edit_document( + &app, + &file, + &revision(&bytes), + "世界", + "哲学", + false, + false, + true, + None, + ) + .unwrap(); + assert_eq!(changed["encoding"], "gb18030"); + assert_eq!( + fs::read(&file).unwrap(), + crate::text_encoding::encode("# 阅读\n\n你好,哲学。\n", "gb18030").unwrap() + ); + let invalid = b"\xef\xbb\xbf\xff"; + fs::write(&file, invalid).unwrap(); + assert_eq!( + edit_document( + &app, + &file, + &revision(invalid), + "x", + "y", + false, + false, + true, + None + ) + .unwrap_err(), + "encoding_not_lossless" + ); + assert_eq!(fs::read(&file).unwrap(), invalid); + } + #[test] + fn cancellation_and_invalid_ranges_do_not_change_files() { + let dir = tempfile::tempdir().unwrap(); + let app = app(dir.path()); + let file = dir.path().join("notes.md"); + fs::write(&file, "# Notes\n").unwrap(); + let cancel = AtomicBool::new(true); + assert_eq!( + edit_document( + &app, + &file, + &revision(b"# Notes\n"), + "Notes", + "Change", + false, + false, + true, + Some(&cancel) + ) + .unwrap_err(), + "cancelled" + ); + assert_eq!( + search_workspace(dir.path(), "Notes", 20, Some(&cancel)).unwrap_err(), + "cancelled" + ); + assert_eq!(read_document(&file, 5, 8).unwrap_err(), "line_out_of_range"); + assert_eq!(fs::read_to_string(&file).unwrap(), "# Notes\n"); + } + #[test] + fn block_tags_and_unchecked_large_anchor_targets_are_reported_honestly() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("index.md"); + fs::write( + &file, + "---\ntags:\n - philosophy\n - 'reading'\n---\n# Reading\n", + ) + .unwrap(); + assert_eq!( + search_workspace(dir.path(), "tag:reading", 20, None).unwrap()["matched"], + 1 + ); + fs::write( + dir.path().join("large.md"), + "x".repeat(MAX_DOCUMENT_BYTES + 1), + ) + .unwrap(); + let result = analyze("[large](large.md#unknown)", Some(&file), Some(dir.path())).unwrap(); + assert_eq!(result["truncated"], true); + assert!(result["diagnostics"].as_array().unwrap().is_empty()); + } + #[test] + fn inspection_uses_ast_and_reports_utf16_locations() { + let report=analyze("# Alpha\n\n- [x] done\n- [ ] next\n\n[bad](#missing)\n\n~~~md\n[ignored](gone.md)\n~~~\n",None,None).unwrap(); + assert_eq!(report["links"].as_array().unwrap().len(), 1); + assert_eq!(report["diagnostics"][0]["code"], "missing_anchor"); + assert_eq!(report["stats"]["completed_tasks"], 1); + assert_eq!(report["headings"][0]["position"]["line"], 1); + } + #[test] + fn workspace_search_combines_phrases_paths_tags_and_exclusions() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("reading.md"), + "---\ntags: [philosophy, reading]\n---\n# Plato\n\nA careful argument.\n", + ) + .unwrap(); + fs::write( + dir.path().join("other.md"), + "# Other\nA careless argument.\n", + ) + .unwrap(); + let root = crate::paths::canonicalize_or_clean(dir.path()); + let report = search_workspace( + &root, + "tag:philosophy \"careful argument\" -careless", + 10, + None, + ) + .unwrap(); + assert_eq!(report["matched"], 1); + assert_eq!(report["results"][0]["relative_path"], "reading.md"); + assert!(search_workspace(&root, "\"unfinished", 10, None).is_err()); + } + #[test] + fn local_checks_do_not_read_outside_workspace_and_wiki_resolves_subfolder() { + let dir = tempfile::tempdir().unwrap(); + let root = crate::paths::canonicalize_or_clean(dir.path()); + fs::create_dir(root.join("notes")).unwrap(); + fs::write(root.join("notes/Plato.md"), "# Plato").unwrap(); + let file = root.join("index.md"); + let report = analyze( + "[[Plato]]\n\n[escape](../secret.md)\n", + Some(&file), + Some(&root), + ) + .unwrap(); + assert!(report["links"][0]["resolved_path"] + .as_str() + .unwrap() + .ends_with("Plato.md")); + assert_eq!(report["diagnostics"][0]["code"], "outside_workspace"); + } +} diff --git a/rust/readmd-kernel/src/lib.rs b/rust/readmd-kernel/src/lib.rs index f821a3cc..380f1ebc 100644 --- a/rust/readmd-kernel/src/lib.rs +++ b/rust/readmd-kernel/src/lib.rs @@ -13,6 +13,7 @@ pub mod code_chunk_runner; pub mod codecs; pub mod content; pub mod document_history; +pub mod document_intelligence; pub mod convert; pub mod convert_ext; pub mod crypto; diff --git a/rust/readmd-kernel/src/mcp.rs b/rust/readmd-kernel/src/mcp.rs index 190d38e2..4cc8ed9e 100644 --- a/rust/readmd-kernel/src/mcp.rs +++ b/rust/readmd-kernel/src/mcp.rs @@ -168,6 +168,23 @@ pub fn tools() -> Value { { "name": "readmd_pdf_audit", "description": "Inspect a PDF: page count, page sizes, rotation, text layer, read-only flag and processes holding it open.", "inputSchema": schema(json!({ "pdf_path": { "type": "string" } }), &["pdf_path"]) }, + { "name": "readmd_analyze_document", + "description": "Inspect current Markdown without changing it: AST headings, local/wiki links, anchors, task counts and UTF-16 diagnostic locations. Optional workspace checks never fetch URLs.", + "inputSchema": schema(json!({"content":{"type":"string"},"file_path":{"type":"string"},"workspace_root":{"type":"string"}}), &["content"]) }, + { "name": "readmd_search_workspace", + "description": "Read-only, bounded search of current local Markdown files. Combine quoted phrases, path:, title:, tag:, and -exclusions. Ignores hidden and generated directories; reports skipped/truncated results.", + "inputSchema": schema(json!({"workspace_root":{"type":"string"},"query":{"type":"string"},"limit":{"type":"integer","default":20}}), &["workspace_root","query"]) }, + { "name": "readmd_read_document", + "description": "Read at most 1000 lines of a local text document with its byte SHA-256 revision, encoding and EOL. Limited to 2 MiB; use the revision for conflict-protected edits.", + "inputSchema": schema(json!({"file_path":{"type":"string"},"start_line":{"type":"integer","default":1},"end_line":{"type":"integer","default":1000}}), &["file_path"]) }, + { "name": "readmd_edit_document", + "description": "Preview a literal replacement by default. Commit only with dry_run:false, confirm:true and a matching byte revision. Ambiguous matches require replace_all. Preserves encoding/EOL; records recoverable history outside the document folder.", + "inputSchema": schema(json!({"file_path":{"type":"string"},"expected_revision":{"type":"string"}, + "old_text":{"type":"string"},"new_text":{"type":"string"},"replace_all":{"type":"boolean","default":false}, + "dry_run":{"type":"boolean","default":true},"confirm":confirm_prop()}), &["file_path","expected_revision","old_text","new_text"]) }, + { "name": "readmd_document_history", + "description": "List or read bounded recovery checkpoints belonging to one local document. Read-only; never restores or overwrites automatically.", + "inputSchema": schema(json!({"file_path":{"type":"string"},"operation":{"type":"string","enum":["list","read"],"default":"list"},"checkpoint_id":{"type":"string"}}), &["file_path"]) }, { "name": "readmd_pdf_rollback", "description": "Restore a PDF from its .bak backup written by a previous ReadMD edit.", "inputSchema": schema(json!({ "pdf_path": { "type": "string" }, "confirm": confirm_prop() }), &["pdf_path", "confirm"]) }, @@ -175,6 +192,7 @@ pub fn tools() -> Value { for tool in list.as_array_mut().unwrap() { let name = tool["name"].as_str().unwrap_or(""); let read_only = matches!(name, "readmd_fix_markdown" | "readmd_generate_toc" | "readmd_latex_to_md" | "readmd_md_to_latex" | "readmd_latex_to_omml" | "readmd_parse_bibtex" | "readmd_process_imports" | "readmd_ai_assistant" | "readmd_ai_providers" | "readmd_export_presets" | "readmd_pdf_audit"); + let read_only = read_only || matches!(name, "readmd_analyze_document" | "readmd_search_workspace" | "readmd_read_document" | "readmd_document_history"); let open_world = matches!(name, "readmd_web_to_markdown" | "readmd_ai_chat" | "readmd_ai_models" | "readmd_run_code_chunk"); tool["annotations"] = json!({ "readOnlyHint": read_only, "destructiveHint": !read_only, "idempotentHint": read_only, "openWorldHint": open_world }); } @@ -405,6 +423,45 @@ fn validate_tool_arguments(name: &str, args: &Value) -> Result<(), &'static str> fn dispatch_tool(app: &Arc, name: &str, args: &Value, cancel: Option<&AtomicBool>) -> Result { match name { + "readmd_analyze_document" => { + let file = arg_str(args, "file_path", ""); let root = arg_str(args, "workspace_root", ""); + if (!file.is_empty() && !Path::new(&file).is_absolute()) || (!root.is_empty() && !Path::new(&root).is_absolute()) { + return Err(error_result("file_path_must_be_absolute", None)); + } + crate::document_intelligence::analyze(&arg_str(args,"content",""), + (!file.is_empty()).then(||Path::new(&file)), (!root.is_empty()).then(||Path::new(&root))) + .map(|v|json_result(&v)).map_err(|code|error_result(&code,None)) + } + "readmd_search_workspace" => crate::document_intelligence::search_workspace( + Path::new(&arg_str(args,"workspace_root","")), &arg_str(args,"query",""), + arg_i64(args,"limit",20).try_into().unwrap_or(0),cancel) + .map(|v|json_result(&v)).map_err(|code|error_result(&code,None)), + "readmd_read_document" => crate::document_intelligence::read_document( + Path::new(&arg_str(args,"file_path","")),arg_i64(args,"start_line",1).try_into().unwrap_or(0), + arg_i64(args,"end_line",1000).try_into().unwrap_or(0)) + .map(|v|json_result(&v)).map_err(|code|error_result(&code,None)), + "readmd_edit_document" => crate::document_intelligence::edit_document(app, + Path::new(&arg_str(args,"file_path","")), &arg_str(args,"expected_revision",""), + &arg_str(args,"old_text",""),&arg_str(args,"new_text",""),arg_bool(args,"replace_all",false), + arg_bool(args,"dry_run",true),arg_bool(args,"confirm",false),cancel) + .map(|v|json_result(&v)).map_err(|code|error_result(&code,None)), + "readmd_document_history" => { + let raw=arg_str(args,"file_path",""); + let source=Path::new(&raw); + if !source.is_absolute() { return Err(error_result("file_path_must_be_absolute",None)); } + let path=crate::paths::canonicalize_or_clean(source); + if path.is_dir() || !crate::content::is_readable(&path) { return Err(error_result("unsupported_text_file",None)); } + let key=crate::paths::canonicalize_or_clean(&path).to_string_lossy().into_owned(); + if arg_str(args,"operation","list")=="read" { + let (entry,text)=crate::document_history::read(&app.paths.data_dir,&arg_str(args,"checkpoint_id","")) + .map_err(|_|error_result("history_not_found",None))?; + if entry.doc_key!=key { return Err(error_result("history_document_mismatch",None)); } + Ok(json_result(&json!({"ok":true,"entry":entry,"content":text}))) + } else { + let entries=crate::document_history::list(&app.paths.data_dir,Some(&key)).map_err(|_|error_result("history_read_failed",None))?; + Ok(json_result(&json!({"ok":true,"entries":entries}))) + } + } "readmd_fix_markdown" => { let res = crate::readmd_fix::fix_markdown(&arg_str(args, "content", "")); Ok(json_result(&json!({ diff --git a/rust/readmd-kernel/src/server.rs b/rust/readmd-kernel/src/server.rs index 3d94b80b..5cf764be 100644 --- a/rust/readmd-kernel/src/server.rs +++ b/rust/readmd-kernel/src/server.rs @@ -621,6 +621,8 @@ pub const ROUTES: &[(&str, Handler)] = &[ ("/api/ping", h_ping), ("/api/kernel/status", h_kernel_status), // KERNEL BRIDGE — non-parity, see its doc comment ("/api/file", h_file), + ("/api/document/analyze", h_document_analyze), // V0.0.5 KERNEL BRIDGE, non-parity + ("/api/workspace/search", h_workspace_search), // V0.0.5 KERNEL BRIDGE, non-parity ("/api/list", h_list), ("/raw", h_raw), ("/api/save", h_save), @@ -3524,6 +3526,32 @@ fn h_ai_models(app: &Arc, req: &Request) -> ApiResult { } } +/// V0.0.5 KERNEL BRIDGE: inspect a current draft, without repairing or saving it. +fn h_document_analyze(app: &Arc, req: &Request) -> ApiResult { + if req.method != "POST" { return Err(ApiError::bad_request("method_not_allowed")); } + let body=body_value(req); + let Some(text)=body.get("content").and_then(Value::as_str) else { return Err(ApiError::bad_request("invalid_request")); }; + let file=body.get("file_path").and_then(Value::as_str).filter(|p|!p.is_empty()).map(Path::new); + if file.is_some_and(|p|!p.is_absolute()) { return Err(ApiError::bad_request("file_path_must_be_absolute")); } + let root=body.get("workspace_root").and_then(Value::as_str).filter(|p|!p.is_empty()).map(Path::new) + .or_else(||file.and_then(Path::parent)).or(Some(app.paths.workspace.as_path())); + crate::document_intelligence::analyze_for_reader(text,file,root).map_err(|e|ApiError::bad_request(&e)).and_then(ok_json) +} + +/// V0.0.5 KERNEL BRIDGE: bounded, read-only search over current workspace files. +fn h_workspace_search(app: &Arc, req: &Request) -> ApiResult { + if req.method != "POST" { return Err(ApiError::bad_request("method_not_allowed")); } + let body=body_value(req); + let Some(query)=body.get("query").and_then(Value::as_str) else { return Err(ApiError::bad_request("invalid_request")); }; + let root=body.get("workspace_root").and_then(Value::as_str).map(Path::new).unwrap_or(&app.paths.workspace); + let limit=match body.get("limit") { + None => 20, + Some(v) => v.as_u64().filter(|n| (1..=100).contains(n)) + .ok_or_else(||ApiError::bad_request("invalid_search_limit"))? as usize, + }; + crate::document_intelligence::search_workspace(root,query,limit,None).map_err(|e|ApiError::bad_request(&e)).and_then(ok_json) +} + // --------------------------------------------------------------------------- // `ai_providers` wiring // diff --git a/rust/xtask/src/version.rs b/rust/xtask/src/version.rs index ee28bfae..4456f01d 100644 --- a/rust/xtask/src/version.rs +++ b/rust/xtask/src/version.rs @@ -234,6 +234,19 @@ fn build_plan(root: &Path, v: &str) -> Result { let hc = sv.harmony_code(); let mut plan = Plan::default(); + // Workspace package metadata must match the executable's VERSION too. + if let Some(t) = read_text(&root.join("rust/Cargo.toml")) { + let s = sub(&t.original, r#"(?m)^version = "[^"]+"$"#, &format!("version = \"{v}\"")); + plan.push_if_changed(&t,s); + } + if let Some(t) = read_text(&root.join("rust/Cargo.lock")) { + let mut s=t.original.clone(); + for name in ["readmd-kernel","xtask"] { + s=sub(&s,&format!(r#"(name = "{name}"\nversion = ")[^"]+""#),&format!("${{1}}{v}\"")); + } + plan.push_if_changed(&t,s); + } + // .env / .env.example / VERSION let env_path = root.join(".env"); let old_env = read_text(&env_path).unwrap_or(Text { path: env_path.clone(), crlf: false, original: String::new() }); @@ -336,6 +349,11 @@ fn build_plan(root: &Path, v: &str) -> Result { // Website let site = root.join("website/public"); if site.is_dir() { + // Public links follow the published release, not the local candidate. + let published = fs::read_to_string(root.join("website/release.json")).ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|j| j["stable"].as_str().map(str::to_owned)); + let v = published.as_deref().unwrap_or(v); let mut files: Vec = walkdir::WalkDir::new(&site) .sort_by_file_name() .into_iter() @@ -348,8 +366,8 @@ fn build_plan(root: &Path, v: &str) -> Result { for p in files { let Some(t) = read_text(&p) else { continue }; let s = artifact_names(&t.original, v); - let s = sub(&s, r"(releases/tag/v)[0-9a-zA-Z.-]+", &format!("${{1}}{v}")); - let s = sub(&s, r"(releases/download/v)[0-9a-zA-Z.-]+", &format!("${{1}}{v}")); + let s = sub(&s, r"releases/tag/[vV][0-9a-zA-Z.-]+", &format!("releases/tag/V{v}")); + let s = sub(&s, r"releases/download/[vV][0-9a-zA-Z.-]+", &format!("releases/download/V{v}")); let s = sub(&s, r#"("softwareVersion":\s*")[^"]+""#, &format!("${{1}}{v}\"")); let s = sub(&s, r#"("artifactSection":\s*"v)[^"]+""#, &format!("${{1}}{v}\"")); let s = sub(&s, r"2\.3\.7-beta\.\d+", v); diff --git a/scripts/linux/PKGBUILD b/scripts/linux/PKGBUILD index 4778facb..0f3c45a9 100644 --- a/scripts/linux/PKGBUILD +++ b/scripts/linux/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Natsummerance pkgname=readmd-bin -pkgver=0.0.4 +pkgver=0.0.5 pkgrel=1 pkgdesc="Lightweight native Markdown viewer and editor with auto-repair and presentation mode" arch=('x86_64' 'aarch64') diff --git a/scripts/linux/linglong.yaml b/scripts/linux/linglong.yaml index 51223a6e..9b05f01a 100644 --- a/scripts/linux/linglong.yaml +++ b/scripts/linux/linglong.yaml @@ -1,7 +1,7 @@ package: id: io.github.natsummerance.readmd name: ReadMD - version: 0.0.4.0 + version: 0.0.5.0 kind: app description: | ReadMD 是全功能、本地隐私优先的 Markdown 阅读、编辑与排版套件。 diff --git a/tools/package-windows-candidate.mjs b/tools/package-windows-candidate.mjs new file mode 100644 index 00000000..8c744760 --- /dev/null +++ b/tools/package-windows-candidate.mjs @@ -0,0 +1,35 @@ +// Offline, reviewable portable candidate. No installer or registry mutation. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {zip} from './lib/zip.mjs'; +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +const binary=path.resolve(process.argv[2]||''); +const output=path.resolve(process.argv[3]||'dist/candidate'); +const pet=path.resolve(process.argv[4]||'dist/ReadMD-Pet-Rust.zip'); +const version=fs.readFileSync(path.join(root,'VERSION'),'utf8').trim(); +if(!binary||!fs.statSync(binary,{throwIfNoEntry:false})?.isFile())throw Error('Provide a built Windows binary'); +if(execFileSync(binary,['--version'],{encoding:'utf8',windowsHide:true}).trim()!=='readmd-rust '+version)throw Error('Binary version differs from VERSION'); +if(!fs.statSync(pet,{throwIfNoEntry:false})?.isFile())throw Error('Provide the verified desktop pet archive'); +const entries=[]; +const add=(name,file)=>entries.push({name,data:fs.readFileSync(file)}); +function walk(directory,prefix){ + for(const entry of fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name,'en'))){ + const file=path.join(directory,entry.name),name=prefix+'/'+entry.name; + if(entry.isSymbolicLink())throw Error('Candidate assets must not contain symlinks'); + if(entry.isDirectory())walk(file,name);else if(entry.isFile())add(name,file); + } +} +add('ReadMD.exe',binary);add('ReadMD-Pet-Rust.zip',pet); +for(const name of ['LICENSE','README.md','RELEASE_NOTES.md'])add(name,path.join(root,name)); +walk(path.join(root,'assets'),'assets'); +fs.mkdirSync(output,{recursive:true}); +const name='ReadMD-windows-x64.zip',file=path.join(output,name); +const bytes=zip(entries);fs.writeFileSync(file,bytes); +const sha=crypto.createHash('sha256').update(bytes).digest('hex'); +fs.writeFileSync(path.join(output,'candidate.json'),JSON.stringify({version,channel:'candidate',platform:'windows-x64', + file:name,sha256:sha,files:entries.length,bytes:bytes.length,source_commit:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim()},null,2)+'\n'); +fs.writeFileSync(path.join(output,'SHA256SUMS.txt'),sha+' '+name+'\n'); +console.log('Prepared '+version+' portable candidate: '+entries.length+' files; '+bytes.length+' bytes.'); diff --git a/ui-tests/document-intelligence.spec.js b/ui-tests/document-intelligence.spec.js new file mode 100644 index 00000000..5a438878 --- /dev/null +++ b/ui-tests/document-intelligence.spec.js @@ -0,0 +1,37 @@ +const { test, expect } = require('@playwright/test'); +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => window.__readmdAppReady); + await page.evaluate(() => i18n.setLanguage('zh-CN', false)); +}); +test('repair report inspects the current unsaved draft without changing it', async ({ page }) => { + await page.evaluate(async () => { + await renderVirtual('clipboard', '检查演示.md', '', '# Original\n', []); + await toggleEdit(); + cmView.dispatch({ changes: { from: 0, to: cmView.state.doc.length, + insert: '# 新标题\n\n[需要检查](#missing)\n\n~~~md\n[代码示例](missing.md)\n~~~\n' } }); + showFixModal(); + }); + await expect(page.locator('.document-inspection-summary')).toHaveText('文档检查:1 项待检查'); + await expect(page.locator('.document-inspection-issue')).toHaveCount(1); + await expect(page.locator('.document-inspection-issue')).toContainText('未找到目标标题'); + expect(await page.evaluate(() => getEditContent())).toContain('[需要检查](#missing)'); + expect(await page.evaluate(() => state.original)).toBe('# Original\n'); +}); +test('report handles service failure and language changes without stale or raw keys', async ({ page }) => { + await page.route('**/api/document/analyze', route => route.fulfill({ status: 503, contentType: 'application/json', body: '{"ok":false}' })); + await page.evaluate(async () => { await renderVirtual('clipboard', '检查演示.md', '', '# Title\n', []); showFixModal(); }); + await expect(page.locator('.document-inspection-summary')).toContainText('文档检查暂不可用'); + await page.unroute('**/api/document/analyze'); + await page.evaluate(async () => { document.getElementById('fix-modal').classList.add('hidden'); await i18n.setLanguage('en', false); showFixModal(); }); + await expect(page.locator('.document-inspection-summary')).toHaveText('Document inspection: 0 items to review'); +}); +test('workspace search rejects invalid limits rather than silently defaulting', async ({ page }) => { + const result = await page.evaluate(async () => { + const response = await apiFetch('/api/workspace/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: 'title:Plato', limit: -1 }) }); + return { status: response.status, body: await response.json() }; + }); + expect(result.status).toBe(400); + expect(result.body.error_code).toBe('invalid_search_limit'); +}); diff --git a/ui-tests/package.json b/ui-tests/package.json index 09758175..2e0aba45 100644 --- a/ui-tests/package.json +++ b/ui-tests/package.json @@ -1,7 +1,7 @@ { "name": "readmd-ui-tests", "private": true, - "version": "0.0.4", + "version": "0.0.5", "devDependencies": { "@playwright/test": "1.54.0" }, diff --git a/website/MOTION.md b/website/MOTION.md new file mode 100644 index 00000000..dba39e45 --- /dev/null +++ b/website/MOTION.md @@ -0,0 +1,25 @@ +# Website motion and offline verification + +The existing Apple layout, typography, themes and genuine recordings remain the source of the site. `tools/build-motion-pages.mjs` adds the shared motion assets to all 50 HTML pages and generates six localized scroll chapters on the four homepages. Components reveal on entry, cards respond to the pointer, controls respond to hover/press/focus, and the sticky product window follows scroll position in both directions. + +`public/assets/motion.js` uses native browser observers and animation frames. At most two video elements are kept, seeking is coalesced, and offscreen work stops when it settles. Background tabs pause motion. Reduced motion exposes every chapter without requesting video; disabling JavaScript leaves readable content and working links. Videos stay muted and paused: scroll position selects the displayed frame. No brightness filter covers the software interface. + +Build with the already prepared dependency tree: + +```sh +npm run verify:release --prefix website +``` + +The build does not install packages or download assets. If FFmpeg is already on PATH, `tools/build-scroll-media.mjs` creates six VP9 derivatives in ignored `dist/motion-clips/`. Original MP4s remain unchanged. Source and output hashes permit reuse. Without FFmpeg the existing MP4 sources and poster fallback remain usable; do not add an installation step to the build. Media derivatives never enter the software commit. + +The six historical recordings are natively 1280×800; converting their codec does not increase their detail. New V0.0.5 incremental recordings are natively 2560×1600 and remain in the local production directory. Do not label the historical footage as 2K or 4K. + +Run actual browser verification with the existing Playwright installation: + +```sh +NODE_PATH=../ui-tests/node_modules READMD_MOTION_CHANNEL=chrome node tools/motion-smoke.cjs +``` + +On Windows, set those environment variables in PowerShell and use `msedge` for the installed Edge. `READMD_MOTION_WEBKIT=1` adds WebKit on hosts with a working VP9 decoder; the prepared Windows WebKit could not decode these clips, so its video test is not marked as passing. This does not verify macOS Safari. `READMD_MOTION_TEST_MP4=1` also aborts WebM requests to verify actual MP4 fallback in branded Chrome/Edge. `READMD_WEBSITE_MOTION_REPORT` optionally writes evidence and screenshots. Tests cover four languages, six chapters, reverse and exact-boundary seeking, narrow layouts, dark mode, repeated scrolling, failed media, reduced motion and JavaScript disabled. CI uses the prepared Chrome browser; codec support differs between bundled Chromium and branded browsers. + +Public version/download metadata continues to use `release.json.stable` until the candidate is officially published. Deploying this site is a separate step from building or verifying it. diff --git a/website/package.json b/website/package.json index 328a208a..fb785989 100644 --- a/website/package.json +++ b/website/package.json @@ -5,9 +5,10 @@ "scripts": { "clean": "node tools/clean.mjs", "build:css": "tailwindcss -i ./src/site.css -o ./dist/assets/site.css --minify", - "build": "npm run clean && npm run build:css && node tools/copy-static.mjs && node tools/sync-version.mjs", + "build": "node tools/build-integrations.mjs && node tools/build-motion-pages.mjs && npm run clean && npm run build:css && node tools/copy-static.mjs && node tools/sync-version.mjs && node tools/build-scroll-media.mjs", "verify": "node tools/validate-website.mjs", "verify:themes": "node tools/theme-smoke.cjs", + "verify:motion": "node tools/motion-smoke.cjs", "verify:release": "npm run build && node tools/validate-website.mjs --release", "indexnow": "node tools/indexnow-submit.mjs https://rust.readmd.asia", "deploy": "npm run verify:release && npx wrangler deploy --config wrangler.worker.jsonc" diff --git a/website/public/.well-known/security.txt b/website/public/.well-known/security.txt index 9989b308..e8b62fab 100644 --- a/website/public/.well-known/security.txt +++ b/website/public/.well-known/security.txt @@ -1,4 +1,4 @@ -Contact: https://github.com/Natsummerance/readMD/security/advisories/new +Contact: https://github.com/Natsummerance/rust-ReadMD/security/advisories/new Expires: 2027-08-26T00:00:00Z Preferred-Languages: en, zh-CN, zh-TW, ja Canonical: https://rust.readmd.asia/.well-known/security.txt diff --git a/website/public/404.html b/website/public/404.html index ab5bbfa0..0ea02f4d 100644 --- a/website/public/404.html +++ b/website/public/404.html @@ -8,6 +8,7 @@ +
@@ -30,5 +31,5 @@

Choose your language

- + diff --git a/website/public/assets/motion.css b/website/public/assets/motion.css new file mode 100644 index 00000000..abb73311 --- /dev/null +++ b/website/public/assets/motion.css @@ -0,0 +1,56 @@ +/* Compositor motion for the existing Apple visual language. */ +html{scroll-behavior:smooth;scroll-padding-top:76px} +.motion-item{--motion-parallax:0px;--motion-lift:0px;--motion-reveal-y:0px;--motion-scale:1;translate:0 calc(var(--motion-parallax) + var(--motion-lift) + var(--motion-reveal-y));scale:var(--motion-scale)} +.motion-ready .motion-item{--motion-reveal-y:22px;opacity:0;transition:opacity .72s cubic-bezier(.16,1,.3,1),translate .95s cubic-bezier(.16,1,.3,1),scale .65s cubic-bezier(.16,1,.3,1);transition-delay:var(--motion-delay,0ms)} +.motion-ready .motion-item.motion-seen{--motion-reveal-y:0px;opacity:1} +.motion-ready h1.motion-item{clip-path:inset(0 0 100%);transition:clip-path 1.1s cubic-bezier(.16,1,.3,1),opacity .8s,translate 1s} +.motion-ready h2.motion-item{clip-path:inset(0 100% 0 0);transition:clip-path 1s cubic-bezier(.16,1,.3,1),opacity .8s,translate 1s} +.motion-ready :is(h1,h2).motion-item.motion-seen{clip-path:inset(0)} +.motion-ready .motion-item:focus-within{opacity:1;--motion-reveal-y:0px} +.motion-ready .hero{position:relative;isolation:isolate} +.motion-ready .hero::before{content:"";position:absolute;z-index:-1;inset:5% 0 25%;border-radius:50%;background:radial-gradient(ellipse at 30% 40%,rgba(0,113,227,.11),transparent 58%),radial-gradient(ellipse at 75% 65%,rgba(140,90,245,.08),transparent 55%);filter:blur(24px);animation:readmd-aurora 16s ease-in-out infinite alternate;pointer-events:none} +.home-title-phrase{background:linear-gradient(110deg,var(--color-action),#8863d4 48%,var(--color-action));background-size:200% 100%;background-clip:text;-webkit-background-clip:text;color:transparent;animation:readmd-title-light 12s ease-in-out infinite alternate} +.motion-ready .nav-shell{transition:background-color .4s,box-shadow .4s;border-bottom-color:color-mix(in srgb,var(--color-action) calc(var(--motion-page-progress,0)*50%),var(--color-line))} +.motion-interactive{position:relative;isolation:isolate} +.motion-interactive::after{content:"";position:absolute;inset:0;pointer-events:none;border-radius:inherit;padding:1px;background:radial-gradient(280px circle at var(--motion-pointer-x,50%) var(--motion-pointer-y,50%),color-mix(in srgb,var(--color-action) 48%,transparent),transparent 70%);mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);mask-composite:exclude;opacity:0;transition:opacity .4s} +.motion-interactive:hover::after{opacity:1} +@media(hover:hover) and (prefers-reduced-motion:no-preference){ + .motion-seen:is(.demo-card,.platform-card,.apple-platform-card,article):hover{--motion-lift:-6px;--motion-scale:1.008;transition-delay:0ms} + a:is(.button-primary,.button-secondary,.apple-pill-primary,.apple-pill-secondary),button{transition:scale .25s cubic-bezier(.16,1,.3,1),box-shadow .3s,background-color .3s} + a:is(.button-primary,.button-secondary,.apple-pill-primary,.apple-pill-secondary):hover,button:hover{scale:1.035} +} +a:is(.button-primary,.button-secondary,.apple-pill-primary,.apple-pill-secondary):active,button:active{scale:.965} +a:focus-visible,button:focus-visible,summary:focus-visible{outline:2px solid var(--color-action);outline-offset:5px} +.link-action span[aria-hidden]{display:inline-block;transition:translate .35s cubic-bezier(.16,1,.3,1)} +.link-action:hover span[aria-hidden]{translate:4px 0} +.journey{height:auto;background:var(--home-bg,var(--color-card));isolation:isolate} +.journey-sticky{position:relative;min-height:auto;overflow:clip;padding-block:80px} +.journey-grid{max-width:1380px;grid-template-columns:minmax(260px,.7fr) minmax(0,1.3fr);padding:48px;gap:44px} +.journey-heading{text-wrap:balance;max-width:12ch} +.journey-caption-stage{display:grid;gap:24px;min-height:180px} +.journey-caption{position:relative;inset:auto;opacity:1;pointer-events:auto;transform:none;transition:opacity .45s cubic-bezier(.16,1,.3,1),transform .65s cubic-bezier(.16,1,.3,1)} +.motion-ready .journey-caption-stage{gap:0} +.motion-ready .journey-caption{grid-area:1/1;opacity:0;pointer-events:none;transform:translateY(22px)} +.motion-ready .journey-caption.is-active{opacity:1;pointer-events:auto;transform:translateY(0)} +.journey-caption .link-action{display:inline-flex;min-height:44px;align-items:center;margin-top:16px;font-size:14px} +.journey-stage{aspect-ratio:8/5;background:var(--color-surface);border:1px solid var(--color-line);border-radius:24px;overflow:hidden;box-shadow:0 28px 80px -28px rgba(0,0,0,.22);transform:perspective(1500px) translateY(var(--cinema-lift,0px)) rotateY(var(--cinema-rotate,0deg)) scale(var(--cinema-scale,1));transform-origin:center;will-change:transform} +.journey-stage>img,.journey-stage>video{display:block;position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:var(--color-surface)} +.journey-stage>video{opacity:0;pointer-events:none} +.journey-stage>video.cinema-visible.cinema-decoded{opacity:1} +.journey-progress{height:3px;left:0;right:0;bottom:0;background:var(--color-line);z-index:3} +.journey-progress span{background:linear-gradient(90deg,var(--color-action),#8863d4);transform:scaleX(var(--cinema-progress,0))} +.journey-aura{position:absolute;inset:18% -10%;z-index:-1;background:radial-gradient(ellipse,hsl(var(--cinema-hue,205) 80% 70%/.16),transparent 65%);filter:blur(60px);transform:translateX(calc((var(--cinema-progress,0) - .5)*40px));pointer-events:none} +.motion-ready .journey{height:480vh} +.motion-ready .journey-sticky{position:sticky;top:56px;min-height:calc(100svh - 56px);padding-block:0} +.home-logo{display:inline-flex;align-items:center;gap:9px;min-height:44px} +.home-logo img{width:26px;height:26px;flex-shrink:0} +.motion-paused *,.motion-paused *::before,.motion-paused *::after{animation-play-state:paused!important} +@keyframes readmd-aurora{from{transform:translate3d(-2%,0,0) scale(.96)}to{transform:translate3d(3%,5%,0) scale(1.05)}} +@keyframes readmd-title-light{from{background-position:0% 50%}to{background-position:100% 50%}} +@media(max-width:1023px){.journey-grid{grid-template-columns:1fr;gap:18px;padding:28px 24px;max-width:860px}.journey-heading{max-width:22ch;font-size:30px}.journey-caption-stage{margin-top:16px;min-height:130px}.journey-caption strong{font-size:21px}.journey-caption p{font-size:15px;max-width:60ch}.journey-stage{width:100%;max-height:45svh}.motion-ready .journey{height:400vh}} +@media(max-width:560px){.journey-grid{padding:18px 20px;gap:12px}.journey-heading{font-size:26px}.journey-caption-stage{min-height:158px}.journey-kicker{font-size:11px}.journey-stage{border-radius:14px}.journey-caption .link-action{margin-top:6px}.journey-aura{filter:blur(32px)}} +@media(max-width:380px){.readmd-home .home-logo{font-size:0;gap:0}} +@media(prefers-reduced-motion:reduce){ + html{scroll-behavior:auto}.motion-item{opacity:1!important;translate:none!important;scale:1!important;transition:none!important}.hero::before,.journey-aura{display:none}.home-title-phrase{animation:none;background:none;color:var(--color-action)} + .journey,.motion-ready .journey{height:auto}.journey-sticky,.motion-ready .journey-sticky{position:relative;top:0;min-height:auto;padding-block:48px}.journey-stage{transform:none;will-change:auto}.journey-stage>video{display:none}.journey-progress{display:none}.journey-caption-stage{display:grid;gap:24px}.journey-caption{grid-area:auto;position:relative;opacity:1;transform:none;pointer-events:auto;transition:none} +} diff --git a/website/public/assets/motion.js b/website/public/assets/motion.js new file mode 100644 index 00000000..25ab9296 --- /dev/null +++ b/website/public/assets/motion.js @@ -0,0 +1,194 @@ +/* Native browser motion. Existing videos, no frame archives or animation runtime. */ +(() => { + 'use strict'; + const root = document.documentElement; + const reduced = matchMedia('(prefers-reduced-motion: reduce)'); + const finePointer = matchMedia('(hover: hover) and (pointer: fine)'); + const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v)); + const items = new Set(), visible = new Set(); + const selector = 'main h1,main h2,main h3,main p,main article,main pre,main details,main button,main summary,main input,main select,main a,main li,main figure,nav a,main .demo-card,main .product-frame,main .platform-card,main .apple-platform-card,main .button-primary,main .button-secondary,main .apple-pill-primary,main .apple-pill-secondary,footer'; + let raf = 0, last = 0, progress = 0, pageProgress = 0; + const journey = document.querySelector('[data-readmd-cinema]'); + const stage = journey?.querySelector('.journey-stage'); + const captions = [...(journey?.querySelectorAll('.journey-caption') || [])]; + const poster = stage?.querySelector('img'); + const clips = captions.map(c => ({video: c.dataset.video, webm: c.dataset.webm, poster: c.dataset.poster})); + let journeyTop = 0, journeyRange = 1, journeyVisible = false, active = -1; + const slots = []; + + const wake = () => { if (!raf && !document.hidden && !reduced.matches) raf = requestAnimationFrame(tick); }; + const measure = () => { + if (journey) { + journeyTop = journey.getBoundingClientRect().top + scrollY; + journeyRange = Math.max(1, journey.offsetHeight - innerHeight + 56); + } + wake(); + }; + const observer = new IntersectionObserver(entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + visible.add(entry.target); entry.target.classList.add('motion-seen'); + } else visible.delete(entry.target); + } + wake(); + }, {rootMargin: '0px 0px 40px', threshold: 0.08}); + function enroll(scope) { + const found = [...(scope.matches?.(selector) ? [scope] : []), ...scope.querySelectorAll(selector)]; + for (const node of found) { + if (items.has(node) || node.closest('[data-readmd-cinema],dialog,[role="dialog"]')) continue; + // Observe every component; only visible components enter frame work. + items.add(node); node.classList.add('motion-item'); + node.style.setProperty('--motion-delay', (items.size % 4) * 45 + 'ms'); + observer.observe(node); + } + } + + function present(slot) { + const video = slot.video; + if (!slot.visible || video.readyState < 2 || video.seeking) return; + video.classList.add('cinema-decoded'); + stage.dataset.mediaReady = 'true'; + stage.dataset.presentedTime = video.currentTime.toFixed(3); + } + function seek(slot) { + const video = slot.video; + if (reduced.matches || document.hidden || !slot.visible || video.readyState < 1 || video.seeking) return; + const target = clamp(slot.target, 0, Math.max(0, video.duration - 0.045)); + if (Math.abs(video.currentTime - target) > 0.018) { + try { video.currentTime = target; } catch { /* Poster remains usable. */ } + } else present(slot); + } + function createSlot() { + const video = document.createElement('video'); + video.muted = true; video.playsInline = true; video.preload = 'metadata'; + video.setAttribute('aria-hidden', 'true'); video.tabIndex = -1; + const slot = {video, index: -1, target: 0, visible: false}; + const decoded = () => { + if (!slot.visible) return; + present(slot); + seek(slot); + }; + video.addEventListener('loadedmetadata', () => { seek(slot); wake(); }); + video.addEventListener('loadeddata', decoded); + video.addEventListener('seeked', decoded); + video.addEventListener('error', () => { + video.classList.remove('cinema-decoded'); + if (slot.visible) stage.dataset.mediaReady = 'poster'; + }); + stage.append(video); slots.push(slot); return slot; + } + function loadSlot(slot, index) { + if (slot.index === index) return; + slot.index = index; slot.target = 0; + slot.video.classList.remove('cinema-decoded'); + slot.video.removeAttribute('src'); + slot.video.replaceChildren(...[[clips[index].webm,'video/webm'],[clips[index].video,'video/mp4']] + .filter(([src]) => src).map(([src,type]) => { + const source = document.createElement('source'); source.src = src; source.type = type; return source; + })); + slot.video.load(); + } + function updateCinema(p) { + if (!journey || !journeyVisible || reduced.matches || !clips.length) return; + const scaled = clamp(p) * clips.length; + const index = Math.min(clips.length - 1, Math.floor(scaled)); + const local = clamp(scaled - index); + let slot = slots.find(s => s.index === index); + if (!slot) { + slot = slots.find(s => !s.visible) || (slots.length < 2 ? createSlot() : slots[0]); + loadSlot(slot, index); + } + if (active !== index) { + active = index; + stage.dataset.clipIndex = String(index); stage.dataset.mediaReady = 'poster'; + poster.src = clips[index].poster; + poster.alt = captions[index].querySelector('strong').textContent + ' · ReadMD'; + for (let n = 0; n < captions.length; n++) { + captions[n].classList.toggle('is-active', n === index); + captions[n].inert = n !== index; + captions[n].setAttribute('aria-hidden', String(n !== index)); + } + } + for (const other of slots) { + other.visible = other === slot; + other.video.classList.toggle('cinema-visible', other.visible); + } + const video = slot.video; + slot.target = Number.isFinite(video.duration) ? local * Math.max(0, video.duration - 0.045) : 0; + stage.dataset.targetTime = slot.target.toFixed(3); + seek(slot); + // Warm only the adjacent clip; never decode six simultaneous videos. + if (index + 1 < clips.length && local > 0.65 && !slots.some(s => s.index === index + 1)) { + const next = slots.find(s => s !== slot) || createSlot(); loadSlot(next, index + 1); + } + const focus = Math.sin(local * Math.PI); + stage.style.setProperty('--cinema-scale', (0.965 + focus * 0.035).toFixed(4)); + stage.style.setProperty('--cinema-rotate', ((0.5 - local) * 1.8).toFixed(3) + 'deg'); + stage.style.setProperty('--cinema-lift', (-focus * 12).toFixed(2) + 'px'); + journey.style.setProperty('--cinema-progress', p.toFixed(5)); + journey.style.setProperty('--cinema-hue', (205 + p * 65).toFixed(2)); + journey.dataset.motionProgress = p.toFixed(5); + } + function tick(time) { + raf = 0; + if (document.hidden || reduced.matches) return; + const dt = Math.min(48, last ? time - last : 16.7); last = time; + const target = journey ? clamp((scrollY - journeyTop + 56) / journeyRange) : 0; + const mix = 1 - Math.exp(-dt / 90); + progress += (target - progress) * mix; + const targetPage = clamp(scrollY / Math.max(1, document.documentElement.scrollHeight - innerHeight)); + pageProgress += (targetPage - pageProgress) * mix; + // Read layout together before setting compositor-only properties. + const measurements = [...visible].map(node => [node, node.getBoundingClientRect()]); + root.style.setProperty('--motion-page-progress', pageProgress.toFixed(5)); + for (const [node, rect] of measurements) { + if (node.matches('.product-frame,.demo-card,.apple-platform-card')) { + const depth = clamp((innerHeight / 2 - rect.top - rect.height / 2) / innerHeight, -0.5, 0.5); + node.style.setProperty('--motion-parallax', (depth * 18).toFixed(2) + 'px'); + } + } + updateCinema(progress); + if (Math.abs(target - progress) > 0.00015 || Math.abs(targetPage - pageProgress) > 0.00015) wake(); + } + const mediaObserver = journey && new IntersectionObserver(entries => { + journeyVisible = entries.some(e => e.isIntersecting); + if (journeyVisible) { measure(); updateCinema(progress); } + wake(); + }, {rootMargin: '400px 0px'}); + mediaObserver?.observe(journey); + enroll(document); + new MutationObserver(records => { + for (const record of records) for (const node of record.addedNodes) { + if (node.nodeType === 1 && !node.closest('[data-readmd-cinema]')) enroll(node); + } + }).observe(document.querySelector('main') || document.body, {childList: true, subtree: true}); + + document.addEventListener('pointermove', event => { + if (!finePointer.matches || reduced.matches) return; + const card = event.target.closest?.('.demo-card,.platform-card,.apple-platform-card,main article'); + if (!card) return; + const rect = card.getBoundingClientRect(); + card.classList.add('motion-interactive'); + card.style.setProperty('--motion-pointer-x', clamp((event.clientX - rect.left) / rect.width) * 100 + '%'); + card.style.setProperty('--motion-pointer-y', clamp((event.clientY - rect.top) / rect.height) * 100 + '%'); + }, {passive: true}); + addEventListener('scroll', wake, {passive: true}); + addEventListener('resize', measure, {passive: true}); + new ResizeObserver(measure).observe(document.querySelector('main') || document.body); + document.addEventListener('visibilitychange', () => { + root.classList.toggle('motion-paused', document.hidden); + if (document.hidden) { cancelAnimationFrame(raf); raf = 0; last = 0; } + else wake(); + }); + const preference = () => { + root.classList.toggle('motion-ready', !reduced.matches); + for (const caption of captions) { + caption.inert = !reduced.matches && !caption.classList.contains('is-active'); + caption.setAttribute('aria-hidden', String(caption.inert)); + } + if (reduced.matches) { cancelAnimationFrame(raf); raf = 0; } + measure(); + }; + reduced.addEventListener('change', preference); + preference(); +})(); diff --git a/website/public/assets/site.js b/website/public/assets/site.js index 25b66255..913f571e 100644 --- a/website/public/assets/site.js +++ b/website/public/assets/site.js @@ -64,21 +64,19 @@ }); // Initialize links with data-mirror-url if present - document.querySelectorAll('a[href^="https://github.com/Natsummerance/readMD/releases/download/"]').forEach((link) => { + document.querySelectorAll('a[href^="https://github.com/Natsummerance/rust-ReadMD/releases/download/"]').forEach((link) => { if (!link.dataset.mirrorUrl) link.dataset.mirrorUrl = link.href; }); updateDownloadLinks(activeMirror); // Dual-Layer Dynamic Version & Release Synchronizer - const GITHUB_REPO = 'Natsummerance/readMD'; - const CACHE_KEY = 'readmd_release_cache_v2'; - const CACHE_TTL = 10 * 60 * 1000; // 10 minutes + const GITHUB_REPO = 'Natsummerance/rust-ReadMD'; const applyVersionData = (versionTag, pureVersion) => { if (!versionTag) return; - const cleanTag = versionTag.startsWith('v') ? versionTag : `v${versionTag}`; - const cleanPure = pureVersion || cleanTag.replace(/^v/, ''); + const cleanTag = /^[vV]/.test(versionTag) ? versionTag : `v${versionTag}`; + const cleanPure = pureVersion || cleanTag.replace(/^[vV]/, ''); document.querySelectorAll('.latest-version-badge, [data-version-slot]').forEach((el) => { el.textContent = cleanTag; @@ -99,7 +97,7 @@ const oldTagMatch = orig.match(/\/releases\/download\/([^/]+)\//); if (oldTagMatch && oldTagMatch[1] && oldTagMatch[1] !== cleanTag) { const oldTag = oldTagMatch[1]; - const oldPure = oldTag.replace(/^v/, ''); + const oldPure = oldTag.replace(/^[vV]/, ''); let updated = orig.replace(new RegExp(`/releases/download/${oldTag}/`, 'g'), `/releases/download/${cleanTag}/`); updated = updated.replace(new RegExp(`-${oldPure}\\.`, 'g'), `-${cleanPure}.`); link.dataset.mirrorUrl = updated; @@ -110,51 +108,13 @@ updateDownloadLinks(activeMirror); }; - // 1. Try Cached GitHub Release - let isApplied = false; - try { - const cached = sessionStorage.getItem(CACHE_KEY); - if (cached) { - const parsed = JSON.parse(cached); - if (Date.now() - parsed.timestamp < CACHE_TTL && parsed.tag_name) { - applyVersionData(parsed.tag_name, parsed.pure_version); - isApplied = true; - } - } - } catch (e) {} - - // 2. Fetch Latest from GitHub API (or fallback to /version.json) - if (!isApplied) { - fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { - headers: { 'Accept': 'application/vnd.github.v3+json' } - }) - .then((res) => (res.ok ? res.json() : Promise.reject(new Error('Rate limited / network')))) - .then((data) => { - if (data && data.tag_name) { - const tag = data.tag_name; - const pure = tag.replace(/^v/, ''); - try { - sessionStorage.setItem(CACHE_KEY, JSON.stringify({ - timestamp: Date.now(), - tag_name: tag, - pure_version: pure - })); - } catch (e) {} - applyVersionData(tag, pure); - } - }) - .catch(() => { - // Fallback to local build-time version.json - fetch('/version.json') - .then((res) => (res.ok ? res.json() : null)) - .then((vData) => { - if (vData && vData.releaseTag) { - applyVersionData(vData.releaseTag, vData.version); - } - }) - .catch(() => {}); - }); - } + // Published manifest is same-origin and independent of API rate limits. + fetch('/version.json', { signal: AbortSignal.timeout(5000) }) + .then(response => response.ok ? response.json() : null) + .then(data => { + if (data && /^V\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(data.releaseTag)) + applyVersionData(data.releaseTag, data.version); + }).catch(() => {}); }; /* Interactive MCP Configuration & 1-Click Multi-Harness Generator */ diff --git a/website/public/bibtex-citations/index.html b/website/public/bibtex-citations/index.html index 668abdfb..494fa83b 100644 --- a/website/public/bibtex-citations/index.html +++ b/website/public/bibtex-citations/index.html @@ -187,6 +187,7 @@ } ] } +

','

'+esc(c.title)+'

'); + fs.writeFileSync(home,text); +} +const sitemap=new URL('sitemap.xml',site);let xml=fs.readFileSync(sitemap,'utf8'); +for(const c of Object.values(locales)){ + const url=origin+'/'+c.prefix+'integrations/'; + const alternates=Object.entries(locales).map(([l,v])=>'').join('') + +''; + const entry=' '+url+''+alternates+'2026-10-07'; + const existing=''+url+''; + if(xml.includes(existing)){ + const start=xml.indexOf(existing),end=xml.indexOf('',start)+6;xml=xml.slice(0,start)+entry.trim()+xml.slice(end); + }else xml=xml.replace('',entry+'\n'); +} +fs.writeFileSync(sitemap,xml); +console.log('Generated four localized integration pages with published/candidate distinction.'); diff --git a/website/tools/build-motion-pages.mjs b/website/tools/build-motion-pages.mjs new file mode 100644 index 00000000..a2581487 --- /dev/null +++ b/website/tools/build-motion-pages.mjs @@ -0,0 +1,50 @@ +// Restore scroll storytelling around existing, genuine recordings. No media copy. +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +const site=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../public'); +const release=JSON.parse(fs.readFileSync(new URL('../release.json',import.meta.url),'utf8')); +const locales={ + en:{prefix:'',kicker:'SCROLL TO EXPLORE',title:'Your documents.
In motion.',watch:'Watch the real operation', + support:'ReadMD V'+release.stable+' provides Windows x64, macOS Intel and Apple Silicon, and Linux x86_64 packages. VS Code and MCP use the installed Rust engine. UOS and Kylin still require native installation testing.'}, + 'zh-CN':{prefix:'zh-cn/',kicker:'滚动,探索每一步',title:'读、写、想。
随你心意。',watch:'观看真实操作', + support:'ReadMD V'+release.stable+' 提供 Windows x64、macOS Intel / Apple Silicon 和 Linux x86_64 安装包。VS Code 与 MCP 共用已安装的 Rust 内核;统信 UOS、银河麒麟仍需原生安装验收。'}, + 'zh-TW':{prefix:'zh-tw/',kicker:'捲動,探索每一步',title:'讀、寫、想。
隨你心意。',watch:'觀看實際操作', + support:'ReadMD V'+release.stable+' 提供 Windows x64、macOS Intel / Apple Silicon 與 Linux x86_64 安裝套件。VS Code 與 MCP 共用已安裝的 Rust 核心;統信 UOS、銀河麒麟仍需原生安裝驗收。'}, + ja:{prefix:'ja/',kicker:'スクロールして体験',title:'読む、書く、考える。
思いのままに。',watch:'実際の操作を見る', + support:'ReadMD V'+release.stable+' は Windows x64、macOS Intel / Apple Silicon、Linux x86_64 のパッケージを提供します。VS Code と MCP はインストール済み Rust エンジンを共有。UOS と Kylin はネイティブ環境での検証が必要です。'} +}; +for(const c of Object.values(locales)){ + const file=path.join(site,c.prefix,'index.html');let html=fs.readFileSync(file,'utf8'); + const cards=[...html.matchAll(/([\s\S]*?)<\/a>/g)].map(m=>({ + href:m[1],id:m[1].split('#')[1],title:m[2].match(/

([\s\S]*?)<\/h3>/)?.[1], + copy:m[2].match(/

([\s\S]*?)<\/p>/)?.[1],poster:m[2].match(/!v.title||!v.copy||!v.poster||!/^F\d{3}$/.test(v.id)))throw Error('Six genuine demo cards are required'); + const captions=cards.map((v,i)=>'

').join(''); + const section='

'+c.kicker+'

'+c.title+'

'+captions+'
'+cards[0].title+' · ReadMD
'; + if(html.includes('data-readmd-cinema'))html=html.replace(/
/,section); + else html=html.replace('
ReadMD', + 'class="home-logo text-lg font-semibold tracking-tight" href="/'+c.prefix+'">ReadMD'); + html=html.replace('',''); + for(const match of html.matchAll(/'); + fs.writeFileSync(file,html);count++; +}} +walk(site);console.log('Enhanced '+count+' pages; four scroll-video stories use existing recordings.'); diff --git a/website/tools/build-scroll-media.mjs b/website/tools/build-scroll-media.mjs new file mode 100644 index 00000000..d77d47e1 --- /dev/null +++ b/website/tools/build-scroll-media.mjs @@ -0,0 +1,36 @@ +// Offline derivatives of existing recordings. Only ignored dist/ receives media. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import {execFileSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +const site=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +const output=path.join(site,'dist/motion-clips');fs.mkdirSync(output,{recursive:true}); +const html=fs.readFileSync(path.join(site,'public/index.html'),'utf8'); +const ids=[...new Set([...html.matchAll(/data-video="\/showcase\/videos\/(F\d{3})\.mp4"/g)].map(m=>m[1]))]; +if(ids.length!==6)throw Error('Build the six localized scroll chapters before media'); +try{execFileSync('ffmpeg',['-version'],{stdio:'ignore',windowsHide:true});}catch{ + if(process.argv.includes('--require'))throw Error('The existing FFmpeg tool is required; this build never installs or downloads tools.'); + console.log('FFmpeg is not prepared: using the existing MP4 sources and poster fallback.');process.exit(0); +} +const report=[]; +const recipe='vp9-crf20-g15-v1'; +let cached;try{cached=JSON.parse(fs.readFileSync(path.join(output,'manifest.json'),'utf8'));}catch{} +for(const id of ids){ + const source=path.join(site,'public/showcase/videos',id+'.mp4'),target=path.join(output,id+'.webm'); + const sourceSha256=crypto.createHash('sha256').update(fs.readFileSync(source)).digest('hex'); + const previous=cached?.recipe===recipe&&cached.derivatives?.find(v=>v.id===id&&v.sourceSha256===sourceSha256); + const reusable=previous&&fs.existsSync(target)&&crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex')===previous.sha256; + if(!reusable)execFileSync('ffmpeg',['-nostdin','-hide_banner','-loglevel','error','-y','-i',source,'-map','0:v:0','-an', + '-vf','scale=in_range=auto:out_range=tv,format=yuv420p','-color_range','tv','-c:v','libvpx-vp9','-b:v','0','-crf','20', + '-g','15','-keyint_min','15','-auto-alt-ref','0','-row-mt','1','-cpu-used','4','-threads','2',target],{windowsHide:true}); + report.push({id,sourceSha256,sha256:crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex'),bytes:fs.statSync(target).size}); +} +fs.writeFileSync(path.join(output,'manifest.json'),JSON.stringify({source:'existing genuine showcase recordings',recipe,codec:'VP9',derivatives:report},null,2)+'\n'); +for(const prefix of ['','zh-cn/','zh-tw/','ja/']){ + const file=path.join(site,'dist',prefix,'index.html'); + const html=fs.readFileSync(file,'utf8').replace(/data-video="\/showcase\/videos\/(F\d{3})\.mp4"(?: data-webm="[^"]+")?/g, + (_,id)=>'data-video="/showcase/videos/'+id+'.mp4" data-webm="/motion-clips/'+id+'.webm"'); + fs.writeFileSync(file,html); +} +console.log('Built six seekable WebM clips offline ('+report.reduce((n,r)=>n+r.bytes,0)+' bytes); original recordings unchanged.'); diff --git a/website/tools/lib/serve-static.cjs b/website/tools/lib/serve-static.cjs new file mode 100644 index 00000000..d1d3c835 --- /dev/null +++ b/website/tools/lib/serve-static.cjs @@ -0,0 +1,30 @@ +'use strict'; +const fs=require('node:fs'),path=require('node:path'),http=require('node:http'); +exports.start=async root=>{ + root=path.resolve(root); + const types={'.html':'text/html','.css':'text/css','.js':'text/javascript','.json':'application/json','.xml':'application/xml','.webp':'image/webp','.png':'image/png','.mp4':'video/mp4','.webm':'video/webm','.vtt':'text/vtt'}; + const server=http.createServer((request,response)=>{ + let file; + try{file=path.resolve(root,'.'+decodeURIComponent(new URL(request.url,'http://local').pathname));}catch{response.writeHead(400).end();return;} + if(file!==root&&!file.startsWith(root+path.sep)){response.writeHead(403).end();return;} + if(fs.statSync(file,{throwIfNoEntry:false})?.isDirectory())file=path.join(file,'index.html'); + const stat=fs.statSync(file,{throwIfNoEntry:false}); + if(!stat?.isFile()){response.writeHead(404).end();return;} + let start=0,end=stat.size-1,status=200; + const headers={'Content-Type':types[path.extname(file)]||'application/octet-stream','Accept-Ranges':'bytes', + 'Content-Security-Policy':"default-src 'self'; img-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'"}; + if(request.headers.range){ + const match=request.headers.range.match(/^bytes=(\d*)-(\d*)$/); + if(!match||(!match[1]&&!match[2])){response.writeHead(416).end();return;} + if(match[1]){start=Number(match[1]);if(match[2])end=Math.min(end,Number(match[2]));} + else start=Math.max(0,stat.size-Number(match[2])); + if(start>end||start>=stat.size){response.writeHead(416,{'Content-Range':'bytes */'+stat.size}).end();return;} + status=206;headers['Content-Range']='bytes '+start+'-'+end+'/'+stat.size; + } + headers['Content-Length']=Math.max(0,end-start+1);response.writeHead(status,headers); + if(request.method==='HEAD'||!stat.size){response.end();return;} + const stream=fs.createReadStream(file,{start,end});stream.on('error',()=>response.destroy());stream.pipe(response); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + return {server,url:'http://127.0.0.1:'+server.address().port}; +}; diff --git a/website/tools/motion-smoke.cjs b/website/tools/motion-smoke.cjs new file mode 100644 index 00000000..20363a84 --- /dev/null +++ b/website/tools/motion-smoke.cjs @@ -0,0 +1,93 @@ +'use strict'; +// Real Chromium/WebKit decoding and scroll control. All requests stay local. +const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +const {start}=require('./lib/serve-static.cjs'); +function playwright(){for(const name of ['playwright','../../ui-tests/node_modules/playwright']){try{return require(name);}catch{}}throw Error('Use the prepared Playwright installation');} +const {chromium,webkit}=playwright(); +const output=process.env.READMD_WEBSITE_MOTION_REPORT&&path.resolve(process.env.READMD_WEBSITE_MOTION_REPORT); +async function until(page,predicate,arg){const end=Date.now()+15000;while(Date.now()({progress:document.querySelector('[data-readmd-cinema]')?.dataset.motionProgress,stage:{...document.querySelector('.journey-stage')?.dataset},videos:[...document.querySelectorAll('.journey-stage video')].map(v=>({src:v.currentSrc,time:v.currentTime,ready:v.readyState,seeking:v.seeking,error:v.error?.message}))}));throw Error('Scroll video did not settle: '+JSON.stringify(state));} +const settle=async(page,p)=>{ + await page.evaluate(p=>{const j=document.querySelector('[data-readmd-cinema]');const top=j.getBoundingClientRect().top+scrollY;const range=j.offsetHeight-innerHeight+56;scrollTo({top:top-56+range*p,behavior:'instant'});},p); + const index=Math.min(5,Math.floor(p*6)); + await until(page,({p,index})=>{ + const j=document.querySelector('[data-readmd-cinema]'),s=j.querySelector('.journey-stage'),v=s.querySelector('video.cinema-visible'); + return Math.abs(Number(j.dataset.motionProgress)-p)<.004&&Number(s.dataset.clipIndex)===index&&s.dataset.mediaReady==='true'&&v?.readyState>=2&&!v.seeking&&Math.abs(v.currentTime-Number(s.dataset.targetTime))<.1; + },{p,index}); + const state=await page.evaluate(()=>{ + const j=document.querySelector('[data-readmd-cinema]'),s=j.querySelector('.journey-stage'),v=s.querySelector('video.cinema-visible'); + const box=s.getBoundingClientRect(),caption=j.querySelector('.journey-caption.is-active').getBoundingClientRect(); + return {time:v.currentTime,target:Number(s.dataset.targetTime),videos:s.querySelectorAll('video').length,paused:v.paused,muted:v.muted, + active:j.querySelectorAll('.journey-caption:not([inert])').length,transform:getComputedStyle(s).transform, + bounded:box.left>=-1&&box.right<=innerWidth+1&&box.top>=54&&box.bottom<=innerHeight+1, + separate:innerWidth>=1024?caption.right{ + const local=await start(path.join(__dirname,'../dist')),report={passed:false,cases:[],pageErrors:[],frameTiming:null}; + try{ + const engines=[['chromium',chromium],...(process.env.READMD_MOTION_WEBKIT==='1'?[['webkit',webkit]]:[])]; + for(const [engineName,engine] of engines){ + const browser=await engine.launch({headless:true,...(engineName==='chromium'&&process.env.READMD_MOTION_CHANNEL?{channel:process.env.READMD_MOTION_CHANNEL}:{})}); + try{ + const page=await browser.newPage({viewport:{width:1440,height:900},colorScheme:'light'}); + page.on('pageerror',e=>report.pageErrors.push(e.message)); + for(const prefix of engineName==='chromium'?['','zh-cn/','zh-tw/','ja/']:['zh-cn/']){ + console.log('Verify scroll story: '+engineName+' /'+prefix); + await page.goto(local.url+'/'+prefix);await page.waitForSelector('html.motion-ready'); + assert.ok(await page.locator('main h1.motion-item').count()); + assert.equal(await page.locator('main a:not(.motion-item):not(.journey-caption a)').count(),0,'Every ordinary link participates in component motion'); + for(const progress of [.04,.23,.41,.59,.77,.96]){ + const evidence=await settle(page,progress);report.cases.push({engine:engineName,prefix,progress,...evidence}); + if(output&&prefix==='zh-cn/'&&engineName==='chromium'&&progress===.23){fs.mkdirSync(path.dirname(output),{recursive:true});await page.screenshot({path:path.join(path.dirname(output),'scroll-story.png')});} + } + if(prefix==='zh-cn/')for(const progress of [.77,.41,1/6,.04]){ + report.cases.push({engine:engineName,prefix,reverse:true,progress,...await settle(page,progress)}); + } + } + if(engineName==='chromium'){ + for(const [width,height] of [[320,568],[390,740],[1024,680]])for(const prefix of ['','zh-cn/','zh-tw/','ja/']){ + await page.setViewportSize({width,height});await page.goto(local.url+'/'+prefix);await settle(page,.41); + assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'No page overflow'); + report.cases.push({engine:engineName,prefix,width,height,layout:true}); + } + await page.setViewportSize({width:1440,height:900});await page.goto(local.url+'/zh-cn/');await settle(page,.23); + await page.locator('#home-theme').click();assert.equal(await page.getAttribute('html','data-site-theme'),'dark');await settle(page,.59); + if(output)await page.screenshot({path:path.join(path.dirname(output),'scroll-story-dark.png')}); + report.frameTiming=await page.evaluate(()=>new Promise(resolve=>{ + const samples=[];let last=0,n=0;const j=document.querySelector('[data-readmd-cinema]'),top=j.getBoundingClientRect().top+scrollY,range=j.offsetHeight-innerHeight+56; + const frame=t=>{if(last)samples.push(t-last);last=t;scrollTo({top:top-56+range*(.1+n/140),behavior:'instant'});if(++n<90)requestAnimationFrame(frame);else{samples.sort((a,b)=>a-b);resolve({frames:samples.length,p95ms:samples[Math.floor(samples.length*.95)],maxMs:samples.at(-1)});}};requestAnimationFrame(frame); + })); + assert.ok(report.frameTiming.p95ms<150,'Scroll animation must remain responsive under repeated seeking'); + } + const reduced=await browser.newPage({viewport:{width:390,height:740},reducedMotion:'reduce'}); + let videosRequested=0;reduced.on('request',r=>{if(/\.(mp4|webm)$/.test(r.url()))videosRequested++;}); + await reduced.goto(local.url+'/zh-cn/');await reduced.waitForTimeout(300); + assert.equal(await reduced.locator('html.motion-ready').count(),0);assert.equal(videosRequested,0); + assert.equal(await reduced.locator('.journey-caption[inert]').count(),0);report.cases.push({engine:engineName,reducedMotion:true}); + const fallback=await browser.newPage({viewport:{width:390,height:740}}); + await fallback.route(/\.(mp4|webm)$/,route=>route.abort()); + await fallback.goto(local.url+'/zh-cn/');await fallback.evaluate(()=>document.querySelector('[data-readmd-cinema]').scrollIntoView());await fallback.waitForTimeout(700); + assert.equal(await fallback.locator('.journey-stage video.cinema-decoded').count(),0); + assert.ok(await fallback.locator('.journey-stage>img').evaluate(img=>img.complete&&img.naturalWidth>0)); + assert.ok(await fallback.locator('.journey-caption.is-active a').isVisible());report.cases.push({engine:engineName,mediaFailureFallback:true});await fallback.close(); + const plain=await browser.newPage({viewport:{width:390,height:740},javaScriptEnabled:false}); + await plain.goto(local.url+'/zh-cn/');assert.equal(await plain.locator('.journey-caption[inert]').count(),0); + for(const caption of await plain.locator('.journey-caption').all())assert.ok(await caption.isVisible()); + assert.ok(await plain.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); + report.cases.push({engine:engineName,noJavaScript:true});await plain.close(); + if(process.env.READMD_MOTION_TEST_MP4==='1'&&engineName==='chromium'){ + const mp4=await browser.newPage({viewport:{width:1440,height:900}});await mp4.route(/\.webm$/,r=>r.abort());await mp4.goto(local.url+'/zh-cn/'); + await settle(mp4,.04);assert.ok(await mp4.locator('video.cinema-visible').evaluate(v=>v.currentSrc.endsWith('.mp4'))); + report.cases.push({engine:engineName,mp4SourceFallback:true});await mp4.close(); + } + await reduced.close();await page.close(); + }finally{await browser.close();} + } + assert.deepEqual(report.pageErrors,[]);report.passed=true; + if(output){fs.mkdirSync(path.dirname(output),{recursive:true});fs.writeFileSync(output,JSON.stringify(report,null,2));} + console.log(JSON.stringify({passed:true,cases:report.cases.length,frameTiming:report.frameTiming,pageErrors:report.pageErrors})); + }finally{local.server.close();} +})().catch(e=>{console.error(e.stack);process.exitCode=1;}); diff --git a/website/tools/sync-version.mjs b/website/tools/sync-version.mjs index 02fc551e..a1ddf0fb 100644 --- a/website/tools/sync-version.mjs +++ b/website/tools/sync-version.mjs @@ -1,76 +1,9 @@ -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = resolve(__dirname, "../../"); -const websiteRoot = resolve(__dirname, "../"); -const distDir = resolve(websiteRoot, "dist"); - -async function extractVersion() { - // 1. Check website/.env or root/.env - const envPaths = [ - resolve(websiteRoot, ".env"), - resolve(projectRoot, ".env"), - resolve(websiteRoot, ".env.local"), - resolve(projectRoot, ".env.local") - ]; - - for (const p of envPaths) { - if (existsSync(p)) { - try { - const content = await readFile(p, "utf8"); - const match = content.match(/(?:READMD_VERSION|VITE_APP_VERSION|APP_VERSION|VERSION)\s*=\s*["']?([^"'\r\n]+)["']?/); - if (match && match[1]?.trim()) { - const v = match[1].trim(); - console.log("[sync-version] Detected version from environment configuration"); - return v; - } - } catch (err) { - console.warn("[sync-version] Could not read environment configuration:", err.message); - } - } - } - - // 2. Check root/VERSION file - const versionFilePath = resolve(projectRoot, "VERSION"); - if (existsSync(versionFilePath)) { - try { - const v = (await readFile(versionFilePath, "utf8")).trim(); - if (v) { - console.log("[sync-version] Detected version from VERSION file:", v); - return v; - } - } catch (err) { - console.warn("[sync-version] Could not read VERSION file:", err.message); - } - } - - // 3. Fallback - return "2.3.8-preview.1"; -} - -async function main() { - const version = await extractVersion(); - const releaseTag = version.startsWith("v") ? version : `v${version}`; - const pureVersion = version.replace(/^v/, ""); - - const versionData = { - version: pureVersion, - releaseTag: releaseTag, - updatedAt: new Date().toISOString(), - assetsBaseUrl: `https://github.com/Natsummerance/rust-ReadMD/releases/download/${releaseTag}/`, - checksumUrl: `https://github.com/Natsummerance/rust-ReadMD/releases/download/${releaseTag}/SHA256SUMS.txt` - }; - - await mkdir(distDir, { recursive: true }); - const distVersionPath = resolve(distDir, "version.json"); - await writeFile(distVersionPath, JSON.stringify(versionData, null, 2), "utf8"); - console.log("[sync-version] Wrote dist/version.json successfully."); -} - -main().catch((err) => { - console.error("[sync-version] Error:", err); - process.exit(1); -}); +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +const release = JSON.parse(await readFile(new URL('../release.json', import.meta.url), 'utf8')); +if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(release.stable) || release.repository !== 'Natsummerance/rust-ReadMD') throw Error('Invalid published release manifest'); +const releaseTag = 'V' + release.stable; +const assetsBaseUrl = 'https://github.com/' + release.repository + '/releases/download/' + releaseTag + '/'; +const output = new URL('../dist/', import.meta.url); +await mkdir(output, { recursive: true }); +await writeFile(new URL('version.json', output), JSON.stringify({ version: release.stable, releaseTag, candidate: release.candidate, updatedAt: release.published, assetsBaseUrl, checksumUrl: assetsBaseUrl + 'SHA256SUMS.txt' }, null, 2) + '\n'); +console.log('[sync-version] Public downloads pinned to published ' + releaseTag); diff --git a/website/tools/validate-website.mjs b/website/tools/validate-website.mjs index dccf3ba9..630499e6 100644 --- a/website/tools/validate-website.mjs +++ b/website/tools/validate-website.mjs @@ -29,6 +29,7 @@ const pageSet = slug => Object.fromEntries(Object.entries(LANG_PREFIX).map(([lan }])); const INTENT_PAGES = pageSet('workflows'); const DOWNLOAD_PAGES = pageSet('download'); +const INTEGRATION_PAGES = pageSet('integrations'); const ANSWER_TOPICS = [ ['large-files', 'large-markdown-files'], ['slides', 'markdown-to-slides'], ['conversion', 'convert-to-markdown'], ['pdf', 'pdf-to-markdown'], ['tables', 'markdown-tables'], ['release-notes', 'release-notes'], @@ -39,11 +40,13 @@ for (const [key, slug] of ANSWER_TOPICS) { for (const [lang, c] of Object.entries(pageSet(slug))) ANSWER_PAGES[`${lang}-${key}`] = c; } -const VERSION = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(); +const RELEASE_INFO = JSON.parse(fs.readFileSync(path.join(SITE, 'release.json'), 'utf8')); +const VERSION = RELEASE_INFO.stable; const RELEASE_ASSETS = new Set([ 'ReadMDSetup-windows-x64.exe', 'ReadMD-windows-x64.zip', 'ReadMD-macos-arm64.zip', 'ReadMD-macos-x64.zip', 'ReadMD-macos-arm64.dmg', 'ReadMD-macos-x64.dmg', 'ReadMD-linux-x86_64.tar.gz', 'ReadMD-linux-x86_64.deb', 'SHA256SUMS.txt', + `readmd-vscode-${VERSION}.vsix`, `readmd-mcp-server-${VERSION}.zip`, ]); const AI_CRAWLERS = ['GPTBot', 'OAI-SearchBot', 'ClaudeBot', 'PerplexityBot']; const FAQ_QUESTION_COUNTS = {}; @@ -162,7 +165,9 @@ function auditPage(p, canonical) { const hreflang = new Set(audit.links.filter(i => i.rel === 'alternate' && i.hreflang).map(i => i.hreflang)); if (!setEq(hreflang, new Set(['en', 'zh-CN', 'zh-TW', 'ja', 'x-default']))) errors.push(`${s}: incomplete hreflang set: ${pyList(sorted([...hreflang].filter(Boolean)))}`); if (audit.headings.filter(([t, x]) => t === 'h1' && x).length !== 1) errors.push(`${s}: page must contain exactly one non-empty h1`); - for (const img of audit.images) { + const decorativeBrand = img => img.src === '/assets/icon-256.png' && img.alt === '' && img['aria-hidden'] === 'true'; + const productImages = audit.images.filter(img => !decorativeBrand(img)); + for (const img of productImages) { if ((img.alt || '').trim().length < 10) errors.push(`${s}: image lacks meaningful alt text: ${img.src || ''}`); if (img.loading === 'eager' && img.fetchpriority !== 'high') errors.push(`${s}: eager hero image must declare fetchpriority=high`); } @@ -171,8 +176,8 @@ function auditPage(p, canonical) { for (const r of ['icon', 'apple-touch-icon', 'manifest', 'license']) if (!rels.has(r)) errors.push(`${s}: missing ${r} link`); if (!audit.links.some(i => i.type === 'application/atom+xml' && (i.href || '').endsWith('releases.atom'))) errors.push(`${s}: release Atom feed link is missing`); if (!audit.links.some(i => i.type === 'application/atom+xml' && i.href === '/feed.xml')) errors.push(`${s}: full-site Atom feed link is missing`); - const genuineHome=/class="[^\"]*\breadmd-home\b/.test(content)&&audit.images.every(img=>(img.src||'').endsWith('.webp')); - if (!genuineHome && content.split('').length - 1 !== audit.images.length) errors.push(`${s}: every product image must have a WebP picture fallback`); + const genuineHome=/class="[^\"]*\breadmd-home\b/.test(content)&&productImages.every(img=>(img.src||'').endsWith('.webp')); + if (!genuineHome && content.split('').length - 1 !== productImages.length) errors.push(`${s}: every product image must have a WebP picture fallback`); if (audit.images.length && !content.includes('.webp')) errors.push(`${s}: optimized WebP source is missing`); if (!/https:\/\/github\.com\/Natsummerance\/(?:rust-)?readMD\/stargazers/i.test(content)) errors.push(`${s}: star call to action is missing`); const blocks = jsonBlocks(content); @@ -247,7 +252,7 @@ function validateRobotsAndSitemap() { if (!robots.includes('Sitemap: https://rust.readmd.asia/sitemap.xml')) errors.push('robots.txt omits canonical sitemap'); const sitemap = read(P('sitemap.xml')); if (!sitemap.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"')) errors.push('sitemap omits XHTML hreflang namespace'); - const expected = new Set([LANGUAGES, INTENT_PAGES, DOWNLOAD_PAGES, ANSWER_PAGES].flatMap(g => Object.values(g).map(c => c.canonical))); + const expected = new Set([LANGUAGES, INTENT_PAGES, DOWNLOAD_PAGES, ANSWER_PAGES, INTEGRATION_PAGES].flatMap(g => Object.values(g).map(c => c.canonical))); expected.add('https://rust.readmd.asia/showcase/'); const actual = new Set([...sitemap.matchAll(/(.*?)<\/loc>/g)].map(m => m[1])); if (!setEq(actual, expected)) errors.push(`sitemap mismatch: missing=${pySet(minus(expected, actual))}, extra=${pySet(minus(actual, expected))}`); @@ -467,7 +472,7 @@ function validateSecurityTxt() { const p = P('.well-known', 'security.txt'); if (!isFile(p)) return ['security.txt is missing']; const t = read(p); - const req = ['Contact: https://github.com/Natsummerance/readMD/security/advisories/new', 'Expires: 2027-08-26T00:00:00Z', 'Preferred-Languages: en, zh-CN, zh-TW, ja', 'Canonical: https://rust.readmd.asia/.well-known/security.txt']; + const req = ['Contact: https://github.com/Natsummerance/rust-ReadMD/security/advisories/new', 'Expires: 2027-08-26T00:00:00Z', 'Preferred-Languages: en, zh-CN, zh-TW, ja', 'Canonical: https://rust.readmd.asia/.well-known/security.txt']; return req.every(r => t.includes(r)) ? [] : ['security.txt omits required trust fields']; } @@ -481,6 +486,21 @@ function validate404() { function main() { const errors = []; + for (const c of Object.values(LANGUAGES)) { + const html=read(c.path); + for (const asset of ['motion.css','motion.js']) if(!html.includes('/assets/'+asset)||!isFile(P('assets',asset))) errors.push('Missing shared motion asset: '+asset); + const videos=[...html.matchAll(/data-video="([^"]+)"/g)].map(m=>m[1]); + if(videos.length!==6||new Set(videos).size!==6)errors.push('Homepage must contain six distinct scroll chapters: '+c.path); + for(const src of videos)if(!/^\/showcase\/videos\/F\d{3}\.mp4$/.test(src)||!isFile(P(src.slice(1))))errors.push('Scroll chapter recording missing: '+src); + if(/data-webm=/.test(html))errors.push('Derived WebM belongs to ignored dist only: '+c.path); + if(/class="journey-caption[^>]*\sinert\b/.test(html))errors.push('Static chapters must remain accessible without JavaScript: '+c.path); + } + for (const c of Object.values(INTEGRATION_PAGES)) { + if (!isFile(c.path)) { errors.push('Missing integration page: '+c.canonical); continue; } + const html=read(c.path); + if (!html.includes('rel="canonical" href="'+c.canonical+'"')) errors.push('Integration canonical mismatch: '+c.canonical); + if (!html.includes('V'+RELEASE_INFO.candidate) || !html.includes('--mcp')) errors.push('Integration capabilities missing: '+c.canonical); + } const groups = [[LANGUAGES, 'index'], [INTENT_PAGES, 'workflow page'], [DOWNLOAD_PAGES, 'download page'], [ANSWER_PAGES, 'answer page']]; for (const [group, label] of groups) { for (const [name, c] of Object.entries(group)) { @@ -506,7 +526,7 @@ function main() { answer_pages: Object.keys(ANSWER_PAGES), showcase_features: JSON.parse(read(P('showcase/catalog.json'))).features.length, broad_seo: { - canonical_pages: 1 + [LANGUAGES, INTENT_PAGES, DOWNLOAD_PAGES, ANSWER_PAGES].reduce((n, g) => n + Object.keys(g).length, 0), + canonical_pages: 1 + [LANGUAGES, INTENT_PAGES, DOWNLOAD_PAGES, ANSWER_PAGES, INTEGRATION_PAGES].reduce((n, g) => n + Object.keys(g).length, 0), atom_feed: true, entity_graph: true, security_txt: true, quality_404: true, genuine_operation_gallery: true, }, }));