diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3cf2f..a6e62fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.10.0] - 2026-09-15 + ### Added - Added local Windows application icons to the recording-source selector, with a keyboard-accessible two-line target list and a neutral fallback when an application does not expose an icon. +- Added local PDF export for completed AI document versions, preserving Nota's + Markdown typography, tables, code blocks, and syntax highlighting without + contacting a network service, with an optional success-toast action that + reveals the exported file in Windows Explorer. The first Markdown H1 supplies + both the suggested filename and the PDF document title. ### Changed +- Simplified the AI document toolbar with bounded document/version selectors, + icon-only revision and copy actions, generation details in the overflow menu, + and one generation dialog for both new templates and new versions of an + existing template. - Reorganized recording details into a full-height reading workspace with a compact contextual toolbar, document/version selectors, bottom audio player, a collapsible recording list controlled from the left of the detail title, @@ -384,7 +395,8 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht - Added GitHub Actions workflows for continuous integration and tagged Windows releases. - Added English and Simplified Chinese README documentation. -[Unreleased]: https://github.com/kwp-lab/nota/compare/v0.9.0...HEAD +[Unreleased]: https://github.com/kwp-lab/nota/compare/v0.10.0...HEAD +[0.10.0]: https://github.com/kwp-lab/nota/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/kwp-lab/nota/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/kwp-lab/nota/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/kwp-lab/nota/compare/069d067d016282ae7a9ca24a360fcc64a7b4155b...v0.7.0 diff --git a/README.md b/README.md index fc301fa..d253d06 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@

Platform: Windows 11 x64 - Version: 0.9.0 + Version: 0.10.0 Status: early preview Privacy: local first Telemetry: none diff --git a/README.zh-CN.md b/README.zh-CN.md index 26a1ad4..a2d450d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,7 +19,7 @@

平台:Windows 11 x64 - 版本:0.9.0 + 版本:0.10.0 状态:早期预览 隐私:本地优先 遥测:无 diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 5b68af6..93b2f59 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -464,8 +464,11 @@ details for reciprocal licenses are in `THIRD_PARTY_SOURCES.md`. | estree-util-is-identifier-name | 3.0.0 | MIT | | extend | 3.0.2 | MIT | | get-nonce | 1.0.1 | MIT | +| hast-util-is-element | 3.0.0 | MIT | | hast-util-to-jsx-runtime | 2.3.6 | MIT | +| hast-util-to-text | 4.0.2 | MIT | | hast-util-whitespace | 3.0.0 | MIT | +| highlight.js | 11.11.2 | BSD-3-Clause | | html-url-attributes | 3.0.1 | MIT | | inline-style-parser | 0.2.7 | MIT | | is-alphabetical | 2.0.1 | MIT | @@ -474,6 +477,7 @@ details for reciprocal licenses are in `THIRD_PARTY_SOURCES.md`. | is-hexadecimal | 2.0.1 | MIT | | is-plain-obj | 4.1.0 | MIT | | longest-streak | 3.1.0 | MIT | +| lowlight | 3.3.0 | MIT | | lucide-react | 0.468.0 | ISC | | markdown-table | 3.0.4 | MIT | | mdast-util-find-and-replace | 3.0.2 | MIT | @@ -529,6 +533,7 @@ details for reciprocal licenses are in `THIRD_PARTY_SOURCES.md`. | react-remove-scroll | 2.7.2 | MIT | | react-style-singleton | 2.2.3 | MIT | | react | 19.2.8 | MIT | +| rehype-highlight | 7.0.2 | MIT | | remark-gfm | 4.0.1 | MIT | | remark-parse | 11.0.0 | MIT | | remark-rehype | 11.1.2 | MIT | @@ -543,6 +548,7 @@ details for reciprocal licenses are in `THIRD_PARTY_SOURCES.md`. | trough | 2.2.0 | MIT | | tslib | 2.8.1 | 0BSD | | unified | 11.0.5 | MIT | +| unist-util-find-after | 5.0.0 | MIT | | unist-util-is | 6.0.1 | MIT | | unist-util-position | 5.0.0 | MIT | | unist-util-stringify-position | 4.0.0 | MIT | diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index 1f5a5f8..b129cac 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -4242,7 +4242,7 @@ developed by Chen Jiaju, licensed under the MIT License and the Apache License 2 ------------------------------------------------------------------------------- MIT License (MIT) Used by: -- nota 0.9.0 +- nota 0.10.0 - brotli-decompressor 5.0.3 - cargo_toml 0.22.3 - dpi 0.1.2 @@ -7185,6 +7185,7 @@ Used by: - character-entities@2.0.2 (MIT) - character-reference-invalid@2.0.1 (MIT) - mdast-util-to-string@4.0.0 (MIT) +- unist-util-find-after@5.0.0 (MIT) - unist-util-position@5.0.0 (MIT) - unist-util-visit@5.1.0 (MIT) @@ -7214,6 +7215,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------- Used by: - comma-separated-tokens@2.0.3 (MIT) +- hast-util-is-element@3.0.0 (MIT) - hast-util-whitespace@3.0.0 (MIT) - is-alphabetical@2.0.1 (MIT) - is-alphanumerical@2.0.1 (MIT) @@ -7300,6 +7302,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Used by: - decode-named-character-reference@1.3.0 (MIT) - hast-util-to-jsx-runtime@2.3.6 (MIT) +- lowlight@3.3.0 (MIT) - markdown-table@3.0.4 (MIT) - mdast-util-find-and-replace@3.0.2 (MIT) - mdast-util-from-markdown@2.0.3 (MIT) @@ -7328,6 +7331,7 @@ Used by: - micromark-util-symbol@2.0.1 (MIT) - micromark-util-types@2.0.2 (MIT) - micromark@4.0.2 (MIT) +- rehype-highlight@7.0.2 (MIT) - remark-gfm@4.0.1 (MIT) - remark-rehype@11.1.2 (MIT) - vfile-message@4.0.3 (MIT) @@ -7541,6 +7545,67 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------- +Used by: +- hast-util-to-text@4.0.2 (MIT) + +(The MIT License) + +Copyright (c) 2019 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +------------------------------------------------------------------------------- +Used by: +- highlight.js@11.11.2 (BSD-3-Clause) + +BSD 3-Clause License + +Copyright (c) 2006, Ivan Sagalaev. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ------------------------------------------------------------------------------- Used by: - html-url-attributes@3.0.1 (MIT) diff --git a/bom.cyclonedx.json b/bom.cyclonedx.json index 5ac4f89..691cdcc 100644 --- a/bom.cyclonedx.json +++ b/bom.cyclonedx.json @@ -1,14 +1,14 @@ { "bomFormat": "CycloneDX", "specVersion": "1.6", - "serialNumber": "urn:uuid:54ad63fe-3c3a-4af4-ac2f-91d239fb104a", + "serialNumber": "urn:uuid:2e51a386-9432-4a50-a3c3-0e7d998d7106", "version": 1, "metadata": { "component": { "type": "application", "bom-ref": "pkg:github/kwp-lab/nota", "name": "Nota", - "version": "0.9.0", + "version": "0.10.0", "licenses": [ { "license": { @@ -8169,6 +8169,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/hast-util-is-element@3.0.0", + "name": "hast-util-is-element", + "version": "3.0.0", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/hast-util-is-element@3.0.0", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/hast-util-is-element/v/3.0.0" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/hast-util-to-jsx-runtime@2.3.6", @@ -8187,6 +8205,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/hast-util-to-text@4.0.2", + "name": "hast-util-to-text", + "version": "4.0.2", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/hast-util-to-text@4.0.2", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/hast-util-to-text/v/4.0.2" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/hast-util-whitespace@3.0.0", @@ -8205,6 +8241,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/highlight.js@11.11.2", + "name": "highlight.js", + "version": "11.11.2", + "licenses": [ + { + "expression": "BSD-3-Clause" + } + ], + "purl": "pkg:npm/highlight.js@11.11.2", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/highlight.js/v/11.11.2" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/html-url-attributes@3.0.1", @@ -8349,6 +8403,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/lowlight@3.3.0", + "name": "lowlight", + "version": "3.3.0", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/lowlight@3.3.0", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/lowlight/v/3.3.0" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/lucide-react@0.468.0", @@ -9339,6 +9411,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/rehype-highlight@7.0.2", + "name": "rehype-highlight", + "version": "7.0.2", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/rehype-highlight@7.0.2", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/rehype-highlight/v/7.0.2" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/remark-gfm@4.0.1", @@ -9591,6 +9681,24 @@ } ] }, + { + "type": "library", + "bom-ref": "pkg:npm/unist-util-find-after@5.0.0", + "name": "unist-util-find-after", + "version": "5.0.0", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:npm/unist-util-find-after@5.0.0", + "externalReferences": [ + { + "type": "website", + "url": "https://www.npmjs.com/package/unist-util-find-after/v/5.0.0" + } + ] + }, { "type": "library", "bom-ref": "pkg:npm/unist-util-is@6.0.1", diff --git a/docs/ai-documents.md b/docs/ai-documents.md index f88b2d3..ee918d1 100644 --- a/docs/ai-documents.md +++ b/docs/ai-documents.md @@ -1,10 +1,11 @@ # AI Meeting Documents - Status: Accepted -- Last updated: 2026-08-22 +- Last updated: 2026-09-15 - Owners: Nota desktop maintainers - Related code: `src-tauri/src/ai.rs`, `src-tauri/src/storage.rs`, - `src/components/AiDocumentsPanel.tsx`, `src/components/AiSettingsSection.tsx` + `src-tauri/src/pdf_export.rs`, `src/components/AiDocumentsPanel.tsx`, + `src/components/AiDocumentReader.tsx`, `src/components/AiSettingsSection.tsx` - Related decision: [`0005-markdown-first-ai-meeting-documents.md`](decisions/0005-markdown-first-ai-meeting-documents.md), [`0011-versioned-transcription-generations.md`](decisions/0011-versioned-transcription-generations.md) @@ -37,9 +38,14 @@ Every generation attempt appends a version ledger row. Every successful attempt creates a new `.md` file with a monotonically increasing version number; Nota must never overwrite a previous generated file. The UI opens the newest completed version by default and lets the user select any earlier version. -Regenerate and revise controls explicitly describe that they create a new -version, and the generation dialog repeats that existing versions and files are -never overwritten. +The toolbar uses one generation entry point for both new documents and new +versions. Its dialog lists every applicable template: selecting an unused +template creates a document, while selecting a template already used by the +meeting regenerates that document as a new version. The current document's +template is selected by default. The dialog explicitly describes which outcome +will occur and repeats that existing versions and files are never overwritten. +Revision remains a separate action because it includes the selected version's +content and a required revision request. Generation modes have distinct semantics: @@ -53,6 +59,64 @@ If a selected Markdown file was edited outside Nota, preview and `revise` use the current on-disk content. A hash mismatch is displayed as `modified`; it is not treated as corruption and the file is never rewritten. +## PDF Export + +A completed, readable AI document version can be exported to a user-selected +`.pdf` path. PDF is a derivative for sharing: the versioned Markdown file stays +authoritative, no new document/version row is created, and export never changes +the selected version. + +React renders the Markdown once with GFM and syntax highlighting. At export it +clones that already-sanitized rendered body into a temporary print-only root, +so the PDF cannot inherit clipped application scroll containers. The print +stylesheet uses A4 portrait pages, Nota's existing typography and semantic +colors, bounded reading width, wrapped code, repeating-safe table geometry, and +visible backgrounds. Remote Markdown images remain text placeholders and are +not fetched for either preview or export. + +The first non-empty rendered H1 is the export title. Nota uses its visible text +for the native save dialog's suggested filename after Windows filename +sanitization, and temporarily assigns the same text to the WebView document +title so WebView2 writes meaningful PDF title metadata. If no H1 exists, the +stored AI document title is used. The application document title is restored +after both successful and failed exports. + +Rust validates that the requested version is still readable and that the +destination is an absolute PDF path in an existing directory, then asks the +current local WebView2 instance to print the temporary document. Native browser +headers and footers are disabled. Export adds no network capability and must +not log document content, titles, or destination paths. + +After a successful export, Nota shows a transient success toast with an +`Open folder` action. The action revalidates that the selected path is an +existing PDF before asking Windows Explorer to reveal the file. Nota does not +open Explorer automatically, so repeated exports do not interrupt the user's +document workflow. + +```mermaid +sequenceDiagram + participant User + participant UI as React Markdown reader + participant Rust as Rust PDF command + participant WV as Local WebView2 + participant FS as User-selected PDF + + User->>UI: choose Export PDF and destination + UI->>UI: derive title from first H1 and clone rendered body + UI->>UI: temporarily apply H1 as WebView title + UI->>Rust: export version ID and destination path + Rust->>Rust: validate version and destination + Rust->>WV: PrintToPdf with A4 settings + WV->>FS: write PDF locally + WV-->>Rust: completion status + Rust-->>UI: success or actionable failure + UI->>UI: restore app title and remove temporary print root + UI-->>User: show success toast with Open folder action + User->>Rust: optionally reveal exported PDF + Rust->>FS: validate existing PDF + Rust-->>User: select file in Windows Explorer +``` + ## Context Layers The generation dialog separates context by lifetime: diff --git a/docs/architecture.md b/docs/architecture.md index 34bb3a0..07934bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,12 +1,12 @@ # Nota Client Architecture - Status: Accepted -- Last updated: 2026-08-27 +- Last updated: 2026-09-15 - Owners: Nota desktop maintainers - Related code: `src/`, `src-tauri/src/controller.rs`, `src-tauri/src/audio/`, `src-tauri/src/storage.rs`, `src-tauri/src/asr.rs`, `src-tauri/src/importer.rs`, `src-tauri/src/voiceprints.rs`, - `src-tauri/src/ai.rs` + `src-tauri/src/ai.rs`, `src-tauri/src/pdf_export.rs` - Related decisions: [`0001-whole-meeting-funasr-jobs.md`](decisions/0001-whole-meeting-funasr-jobs.md), [`0010-direct-dashscope-file-transcription.md`](decisions/0010-direct-dashscope-file-transcription.md), @@ -73,6 +73,7 @@ flowchart LR | ASR provider adapter | Provider HTTP, upload validation, task/result parsing, and normalization into Nota transcript types | Queue ownership, React state, or SQLite credentials outside the active request | | Voiceprint manager | Timestamp candidate planning, bounded Ogg sampling, clean-range response mapping, local matching, and confirmation sessions | Participant-name disclosure to the ASR Server or raw-vector disclosure to React | | AI document manager | Prompt assembly, LLM requests, cancellation, version lifecycle, atomic Markdown creation, and relinking | Automatic generation, transcript mutation, or in-place overwrite of generated files | +| PDF exporter | Destination/version validation and local WebView2 print completion | Markdown parsing, persistent document state, network access, or content logging | | Configured ASR service | Model inference and server-side processing | Local recording ownership | | Configured LLM provider | Explicitly requested text generation | Local Markdown, SQLite, or recording ownership | @@ -81,9 +82,11 @@ flowchart LR `RecordingsWorkspace` owns selected-record presentation, native audio playback, transcription actions, focus mode, and the existing speaker/transcription dialogs. `AiDocumentsPanel` remains the sole owner of AI workspace reads, -generation drafts, request estimates, version events, file actions, and all -three generation-dialog modes. Its toolbar, Markdown reader, and generation -details drawer are presentation components, not additional IPC owners. +generation drafts, request estimates, version events, file/export actions, and +all three generation-dialog modes. Its toolbar, Markdown reader, and generation +details drawer are presentation components, not additional IPC owners. For PDF +export, the reader owns sanitized HTML and print composition; the Rust command +owns version/path validation and the native WebView2 completion boundary. The AI panel mounts on first visit and remains mounted while its tab is hidden. The audio element, record list, and transcript scroller also retain their DOM @@ -103,11 +106,14 @@ flowchart LR G --> H[Apply only if request and version are current] D --> I[Existing create / regenerate / revise dialog] I --> J[Existing preview, validation and explicit generation IPC] + E --> K[Temporary print-only DOM clone] + K --> L[Rust validates version and PDF destination] + L --> M[Local WebView2 writes PDF] ``` -Opening tabs, reading documents, and inspecting persisted details do not invoke -LLM generation. No new backend commands, schemas, credentials, or network paths -are introduced by the workspace layout. +Opening tabs, reading documents, inspecting persisted details, and exporting a +PDF do not invoke LLM generation. PDF export adds one local backend command but +no schema, credential, or network path. ### Settings Ownership and Persistence diff --git a/docs/design-system.md b/docs/design-system.md index 64a8df1..48f21ca 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -227,11 +227,23 @@ audio player follow DOM order. The list and reading region scroll independently; the outer detail pane must not scroll. Playback remains mounted across tabs. AI documents use shared select styling for document/version selection instead -of a permanent nested document sidebar. AI revision and copying stay directly -available; low-frequency file and generation actions use a keyboard-accessible -disclosure. Generation details open in a right-hand native dialog drawer with -focus containment and restoration. Generation configuration dialogs retain -their original fields and controls. Their rounded outer shell clips overflow; +of a permanent nested document sidebar. The document selector may grow to a +bounded readable width; the version selector stays narrower, and both truncate +before displacing the trailing action group. The unified new-document/new-version +action leads that group, followed by AI revision and Markdown copying; all stay +directly available as icon-only controls with accessible names and custom +tooltips. PDF export follows Markdown copying and uses a download-document icon; +its in-progress state replaces that icon with the shared spinner and disables +repeat submission. Successful export uses the existing transient success-toast +pattern with a clearly labelled “打开文件夹” action; it does not open Explorer +without a second user action. Generation details and low-frequency file actions +use the keyboard-accessible disclosure; the disclosure remains the final trailing +control. Generation details open in +a right-hand native dialog drawer with focus containment and restoration. The +generation dialog lists both unused templates and templates already used by the +meeting, clearly distinguishing “new document” from “new version” without +changing the one-document-per-template model. Generation configuration dialogs +retain their original fields and controls. Their rounded outer shell clips overflow; only the active form/request panel scrolls, leaving the title, tabs and footer visible. Scrollbars stay inside the shell padding, clear of its rounded corners. Close controls use Lucide `X`; JSON tree disclosure slots use the bundled Lucide @@ -240,6 +252,17 @@ Both generation overlays dismiss on primary clicks that start and end on their backdrop. Inside clicks and text-selection drags must not dismiss them; generation submission disables close, cancel and backdrop dismissal together. +AI document printing is a content surface, not a screenshot of application +chrome. Print output uses A4 portrait pages, the platform UI/display/monospace +font stacks, existing type and reading-line-height tokens, and a centered body +no wider than 80 characters. Headings keep clear hierarchy, code and tables wrap +inside the page, semantic syntax colors and subtle backgrounds are preserved, +and page breaks avoid separating a heading from its following content. Remote +images retain the same safe placeholder used in the preview. The sidebar, +toolbar, player, dialogs, and other application UI never appear in the PDF. +The first visible Markdown H1 supplies both the sanitized suggested filename +and PDF document title; documents without an H1 fall back to their stored title. + At 1280×800 and 980×640, normal completed-document content targets at least 70% and 60% of workspace height respectively. Necessary warnings may reduce that area. Controls must not wrap into multiple toolbar rows, and long titles, diff --git a/docs/testing.md b/docs/testing.md index 044cd24..65fcd60 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -275,8 +275,21 @@ Automated coverage for AI documents must include: overwrite of an existing or concurrently created path; - `ready`, `modified`, and `missing` file states plus identity-checked relink; - safe Markdown preview with raw HTML disabled and remote images not loaded; +- syntax-highlighted fenced code in preview and local PDF export of the same + sanitized rendered body, with the first H1 as the safe default filename and + PDF metadata title, selected-version/path IPC, and restoration of the app + title plus print-root cleanup after both success and failure; +- PDF destination validation for empty, relative, non-PDF, directory, and + missing-parent paths without creating output during validation, plus + revalidation of the exported file before revealing it in Explorer; - document-first and version-second UI selection, with newest successful version selected by default; +- the unified generation entry point defaulting to the current template, + submitting `regenerate` for a used template and `create` for an unused one, + while the separate regenerate action stays absent; +- the compact AI document toolbar keeping revision, Markdown copy, generation, + and overflow actions visible while generation details remain keyboard + accessible inside overflow; - three context lifetimes and per-version prompt/provider/template snapshots; - credential-free request JSON matching the submitted body, successful raw response JSON and normalized usage persistence, nullable-column migration @@ -423,6 +436,17 @@ include the recording or transcript in ordinary logs or committed artifacts. - Regress generation progress/cancel, missing/externally modified Markdown, file relinking, stale responses, and on-demand request/response inspection. Retain all existing playback, speaker, export and transcription tests. +- Export a synthetic long AI document to PDF on Windows 11 at 100%, 125%, and + 150% scaling. Inspect every page for A4 margins, Chinese/Latin typography, + heading-page breaks, long lists, links, quotes, tables, inline/fenced code, + syntax colors, wrapped long lines, and absence of application chrome. Confirm + the first H1 becomes both the suggested filename and the document title shown + by Chrome's PDF viewer, while no-H1 documents use the stored title. Confirm + remote images stay placeholders, cancellation creates no file, overwrite uses + the native save confirmation, completion reports success with an optional + “打开文件夹” toast action that selects the PDF in Explorer, and invalid or + unwritable destinations produce an actionable error without changing the + Markdown version. - Verify generation dialogs and the details drawer close on backdrop clicks, but not inside clicks, cancelled pointers, secondary buttons, or text-selection drags ending outside. Submission blocks close/cancel/backdrop dismissal. Check diff --git a/package-lock.json b/package-lock.json index 703ad5d..0370a83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nota", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nota", - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", "dependencies": { "@radix-ui/react-select": "^2.3.7", @@ -18,6 +18,7 @@ "react-dom": "^19.2.0", "react-json-view-lite": "^2.5.0", "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tokenx": "^1.6.0" }, @@ -33,7 +34,7 @@ "license-checker-rseidelsohn": "5.0.1", "typescript": "~5.9.3", "vite": "^8.1.0", - "vitest": "^4.1.0" + "vitest": "^4.1.11" }, "engines": { "node": ">=24.18.0 <25", @@ -2992,16 +2993,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3010,13 +3011,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3037,9 +3038,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3050,13 +3051,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3064,14 +3065,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3080,9 +3081,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3090,13 +3091,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3928,6 +3929,19 @@ "node": ">=8" } }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -3955,6 +3969,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -3968,6 +3998,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "11.11.2", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz", + "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hosted-git-info": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", @@ -4625,6 +4664,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6433,6 +6487,23 @@ "node": ">=8" } }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -6940,9 +7011,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -7105,6 +7176,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -7371,19 +7456,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7411,12 +7496,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 7fd3c36..fcef732 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nota", "private": true, - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", "repository": { "type": "git", @@ -39,6 +39,7 @@ "react-dom": "^19.2.0", "react-json-view-lite": "^2.5.0", "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tokenx": "^1.6.0" }, @@ -54,6 +55,6 @@ "license-checker-rseidelsohn": "5.0.1", "typescript": "~5.9.3", "vite": "^8.1.0", - "vitest": "^4.1.0" + "vitest": "^4.1.11" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 75d3551..74bea21 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2299,7 +2299,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nota" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "arboard", @@ -2330,7 +2330,9 @@ dependencies = [ "thiserror 2.0.19", "trash", "uuid", + "webview2-com", "windows 0.62.2", + "windows-core 0.61.2", "windows-core 0.62.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 990680f..ec3990d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nota" -version = "0.9.0" +version = "0.10.0" description = "A private, local-first Windows meeting recorder" authors = ["Nota Contributors"] license = "MIT" @@ -68,6 +68,10 @@ windows = { version = "0.62.2", features = [ ] } windows-core = "0.62.2" +[target.'cfg(windows)'.dependencies] +webview2-com = "=0.38.2" +webview2-windows-core = { package = "windows-core", version = "=0.61.2" } + [profile.release] lto = true codegen-units = 1 diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index 1d55533..2c15862 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -19,6 +19,7 @@ use crate::importer::AudioImportManager; use crate::logging::{self, Field, FieldKey}; use crate::models::*; use crate::paths::AppPaths; +use crate::pdf_export::{export_current_webview_pdf, validate_exported_pdf}; use crate::process_icons::attach_capture_target_icons; use crate::state_machine::{RecordingEvent, transition}; use crate::storage::Storage; @@ -36,7 +37,7 @@ use tauri::image::Image; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{ - AppHandle, Emitter, LogicalSize, Manager, PhysicalPosition, State, WebviewUrl, + AppHandle, Emitter, LogicalSize, Manager, PhysicalPosition, State, Webview, WebviewUrl, WebviewWindowBuilder, }; use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; @@ -2227,7 +2228,7 @@ fn reveal_path_in_explorer(path: &Path) -> Result<()> { } let result = unsafe { SHOpenFolderAndSelectItems(pidl, None, 0) }; unsafe { ILFree(Some(pidl)) }; - result.context("无法在资源管理器中显示 Markdown 文件") + result.context("无法在资源管理器中显示文件") } #[tauri::command] @@ -2291,6 +2292,25 @@ fn copy_ai_document_path( })()) } +#[tauri::command] +async fn export_ai_document_pdf( + webview: Webview, + state: State<'_, AppState>, + version_id: String, + path: String, +) -> std::result::Result<(), String> { + command_result(read_document_content(&state.storage, &version_id).map(|_| ()))?; + command_result(export_current_webview_pdf(webview, &path).await) +} + +#[tauri::command] +fn reveal_ai_document_pdf(path: String) -> std::result::Result<(), String> { + command_result((|| { + let path = validate_exported_pdf(&path)?; + reveal_path_in_explorer(&path) + })()) +} + #[tauri::command] fn start_transcription( app: AppHandle, @@ -3140,6 +3160,8 @@ pub fn run_app() { reveal_ai_document_version, copy_ai_document_version, copy_ai_document_path, + export_ai_document_pdf, + reveal_ai_document_pdf, start_transcription, cancel_transcription, resume_transcription, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d620d03..d64e94a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod importer; mod logging; mod models; mod paths; +mod pdf_export; mod process_icons; mod state_machine; mod storage; diff --git a/src-tauri/src/pdf_export.rs b/src-tauri/src/pdf_export.rs new file mode 100644 index 0000000..e17178d --- /dev/null +++ b/src-tauri/src/pdf_export.rs @@ -0,0 +1,204 @@ +use anyhow::{Context, Result, bail}; +use parking_lot::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, mpsc}; +use std::time::Duration; +use tauri::Webview; + +const PDF_EXPORT_TIMEOUT: Duration = Duration::from_secs(60); + +fn validate_pdf_destination(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + bail!("请选择 PDF 导出位置"); + } + let destination = PathBuf::from(value); + if !destination.is_absolute() { + bail!("PDF 导出路径必须是绝对路径"); + } + if !destination + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("pdf")) + { + bail!("PDF 导出文件必须使用 .pdf 扩展名"); + } + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .context("PDF 导出路径缺少父目录")?; + if !parent.is_dir() { + bail!("PDF 导出目录不存在"); + } + if destination.is_dir() { + bail!("PDF 导出位置不能是目录"); + } + Ok(destination) +} + +pub(crate) fn validate_exported_pdf(value: &str) -> Result { + let destination = validate_pdf_destination(value)?; + if !destination.is_file() { + bail!("找不到已导出的 PDF 文件"); + } + Ok(destination) +} + +type PdfCompletion = Arc>>>>; + +fn finish(completion: &PdfCompletion, result: Result<()>) { + if let Some(sender) = completion.lock().take() { + let _ = sender.send(result); + } +} + +#[cfg(windows)] +fn begin_windows_pdf_export( + webview: Webview, + destination: &Path, + completion: PdfCompletion, +) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use webview2_com::Microsoft::Web::WebView2::Win32::{ + COREWEBVIEW2_PRINT_ORIENTATION_PORTRAIT, ICoreWebView2_7, ICoreWebView2Environment6, + }; + use webview2_com::PrintToPdfCompletedHandler; + use webview2_windows_core::{Interface, PCWSTR}; + + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let setup_completion = Arc::clone(&completion); + webview + .with_webview(move |platform_webview| { + let result = (|| unsafe { + let core_webview = platform_webview + .controller() + .CoreWebView2() + .context("无法读取当前 WebView2 页面")?; + let printable_webview = core_webview + .cast::() + .context("当前 WebView2 Runtime 不支持 PDF 导出")?; + let print_environment = platform_webview + .environment() + .cast::() + .context("当前 WebView2 Runtime 不支持打印设置")?; + let settings = print_environment + .CreatePrintSettings() + .context("无法创建 PDF 打印设置")?; + + settings.SetOrientation(COREWEBVIEW2_PRINT_ORIENTATION_PORTRAIT)?; + settings.SetPageWidth(8.27)?; + settings.SetPageHeight(11.69)?; + settings.SetMarginTop(0.0)?; + settings.SetMarginBottom(0.0)?; + settings.SetMarginLeft(0.0)?; + settings.SetMarginRight(0.0)?; + settings.SetScaleFactor(1.0)?; + settings.SetShouldPrintBackgrounds(true)?; + settings.SetShouldPrintHeaderAndFooter(false)?; + + let callback_completion = Arc::clone(&completion); + let callback = + PrintToPdfCompletedHandler::create(Box::new(move |status, succeeded| { + let result = match status { + Ok(()) if succeeded => Ok(()), + Ok(()) => Err(anyhow::anyhow!("WebView2 未能完成 PDF 导出")), + Err(error) => { + Err(anyhow::Error::new(error).context("WebView2 PDF 导出失败")) + } + }; + finish(&callback_completion, result); + Ok(()) + })); + printable_webview + .PrintToPdf(PCWSTR(destination.as_ptr()), &settings, &callback) + .context("无法启动 WebView2 PDF 导出")?; + Ok(()) + })(); + if let Err(error) = result { + finish(&setup_completion, Err(error)); + } + }) + .context("无法访问当前 Nota WebView")?; + Ok(()) +} + +pub async fn export_current_webview_pdf(webview: Webview, path: &str) -> Result<()> { + let destination = validate_pdf_destination(path)?; + let (sender, receiver) = mpsc::channel(); + let completion = Arc::new(Mutex::new(Some(sender))); + + #[cfg(windows)] + begin_windows_pdf_export(webview, &destination, completion)?; + + #[cfg(not(windows))] + { + let _ = webview; + let _ = destination; + let _ = completion; + bail!("PDF 导出目前仅支持 Windows"); + } + + let received = + tauri::async_runtime::spawn_blocking(move || receiver.recv_timeout(PDF_EXPORT_TIMEOUT)) + .await + .context("等待 PDF 导出任务失败")?; + match received { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => bail!("PDF 导出超时,请重试"), + Err(mpsc::RecvTimeoutError::Disconnected) => bail!("PDF 导出意外中断"), + } +} + +#[cfg(test)] +mod tests { + use super::{validate_exported_pdf, validate_pdf_destination}; + use std::fs; + + #[test] + fn pdf_destination_requires_an_absolute_pdf_path() { + assert!(validate_pdf_destination("").is_err()); + assert!(validate_pdf_destination("report.pdf").is_err()); + assert!(validate_pdf_destination("C:\\Exports\\report.txt").is_err()); + } + + #[test] + fn pdf_destination_accepts_an_existing_parent_without_creating_output() { + let directory = + std::env::temp_dir().join(format!("nota-pdf-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&directory).unwrap(); + let destination = directory.join("Meeting.PDF"); + + assert_eq!( + validate_pdf_destination(destination.to_string_lossy().as_ref()).unwrap(), + destination + ); + assert!(!destination.exists()); + + fs::remove_dir(directory).unwrap(); + } + + #[test] + fn exported_pdf_requires_an_existing_pdf_file() { + let directory = + std::env::temp_dir().join(format!("nota-pdf-reveal-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&directory).unwrap(); + let destination = directory.join("Meeting.PDF"); + + assert!(validate_exported_pdf(destination.to_string_lossy().as_ref()).is_err()); + fs::write(&destination, b"%PDF-test").unwrap(); + assert_eq!( + validate_exported_pdf(destination.to_string_lossy().as_ref()).unwrap(), + destination + ); + assert!( + validate_exported_pdf(directory.join("Meeting.txt").to_string_lossy().as_ref()) + .is_err() + ); + + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4c1842c..df2ab66 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Nota", - "version": "0.9.0", + "version": "0.10.0", "identifier": "com.local.meetingnote", "build": { "beforeDevCommand": "npm run dev:web", diff --git a/src/App.tsx b/src/App.tsx index 9b436a3..9be3676 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,7 @@ import { enqueueToast, ToastRegion, type AppToast, + type ToastOptions, type ToastTone, } from "./components/ToastRegion"; import type { @@ -173,11 +174,7 @@ export default function App() { ( tone: ToastTone, message: string, - options?: { - durationMs?: number | null; - dedupeKey?: string; - action?: AppToast["action"]; - }, + options?: ToastOptions, ) => { const durationMs = options?.durationMs === undefined @@ -206,7 +203,8 @@ export default function App() { ); const handleAiMessage = useCallback( - (type: "success" | "error", message: string) => showToast(type, message), + (type: "success" | "error", message: string, options?: ToastOptions) => + showToast(type, message, options), [showToast], ); diff --git a/src/ai-document-pdf.test.ts b/src/ai-document-pdf.test.ts new file mode 100644 index 0000000..06449be --- /dev/null +++ b/src/ai-document-pdf.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { + aiDocumentExportTitle, + pdfDefaultFileName, + withAiDocumentPrintRoot, +} from "./ai-document-pdf"; + +describe("AI document PDF helpers", () => { + it("creates a safe PDF filename without duplicating the extension", () => { + expect(pdfDefaultFileName(" Weekly: review? ")).toBe("Weekly- review-.pdf"); + expect(pdfDefaultFileName("Notes.PDF")).toBe("Notes.PDF"); + expect(pdfDefaultFileName("<>. ")).toBe("--.pdf"); + expect(pdfDefaultFileName(" ")).toBe("AI 文档.pdf"); + }); + + it("avoids Windows reserved device names", () => { + expect(pdfDefaultFileName("CON")).toBe("_CON.pdf"); + expect(pdfDefaultFileName("LPT9.pdf")).toBe("_LPT9.pdf"); + }); + + it("uses the first Markdown H1 as the export title", () => { + const source = document.createElement("div"); + source.innerHTML = "

Intro

Weekly product review

Ignored

"; + expect(aiDocumentExportTitle(source, "Stored title")).toBe("Weekly product review"); + source.querySelectorAll("h1").forEach((heading) => heading.remove()); + expect(aiDocumentExportTitle(source, " Stored title ")).toBe("Stored title"); + }); + + it("removes the cloned print root even when export fails", async () => { + const source = document.createElement("div"); + source.innerHTML = "

Printable

Body

"; + const exportPdf = vi.fn(async () => { + expect(document.querySelector(".ai-pdf-document h1")).toHaveTextContent("Printable"); + throw new Error("synthetic export failure"); + }); + + const originalTitle = document.title; + await expect(withAiDocumentPrintRoot(source, "Printable title", exportPdf)).rejects.toThrow("synthetic export failure"); + expect(exportPdf).toHaveBeenCalledOnce(); + expect(document.querySelector(".ai-pdf-document")).toBeNull(); + expect(document.title).toBe(originalTitle); + }); +}); diff --git a/src/ai-document-pdf.ts b/src/ai-document-pdf.ts new file mode 100644 index 0000000..e83631f --- /dev/null +++ b/src/ai-document-pdf.ts @@ -0,0 +1,56 @@ +const PDF_EXTENSION = ".pdf"; +const WINDOWS_RESERVED_FILE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; + +export function pdfDefaultFileName(title: string) { + const sanitized = title + .trim() + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-") + .replace(/[. ]+$/g, "") + .slice(0, 120); + const base = sanitized || "AI 文档"; + const safeBase = WINDOWS_RESERVED_FILE_NAME.test(base) ? `_${base}` : base; + return safeBase.toLocaleLowerCase().endsWith(PDF_EXTENSION) ? safeBase : `${safeBase}${PDF_EXTENSION}`; +} + +export function aiDocumentExportTitle(source: HTMLElement, fallbackTitle: string) { + const heading = Array.from(source.querySelectorAll("h1"), (element) => + element.textContent?.replace(/\s+/g, " ").trim() ?? "").find(Boolean); + return heading || fallbackTitle.trim() || "AI 文档"; +} + +async function waitForPrintLayout() { + await document.fonts?.ready; + await new Promise((resolve) => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => resolve()); + } else { + setTimeout(resolve, 0); + } + }); +} + +export async function withAiDocumentPrintRoot( + source: HTMLElement, + title: string, + exportPdf: () => Promise, +) { + const previousTitle = document.title; + const printRoot = document.createElement("article"); + printRoot.className = "ai-pdf-document"; + printRoot.setAttribute("aria-hidden", "true"); + + const content = document.createElement("div"); + content.className = "ai-pdf-content"; + content.append(...Array.from(source.childNodes, (node) => node.cloneNode(true))); + printRoot.append(content); + document.body.append(printRoot); + document.title = title; + + try { + await waitForPrintLayout(); + await exportPdf(); + } finally { + document.title = previousTitle; + printRoot.remove(); + } +} diff --git a/src/api.ts b/src/api.ts index c35c84d..7e7ed0b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -171,6 +171,10 @@ export const api = { invoke("copy_ai_document_version", { versionId }), copyAiDocumentPath: (versionId: string) => invoke("copy_ai_document_path", { versionId }), + exportAiDocumentPdf: (versionId: string, path: string) => + invoke("export_ai_document_pdf", { versionId, path }), + revealAiDocumentPdf: (path: string) => + invoke("reveal_ai_document_pdf", { path }), startTranscription: ( recordingId: string, providerId?: string | null, diff --git a/src/components/AiDocumentReader.tsx b/src/components/AiDocumentReader.tsx index 99b0d69..bff8b1d 100644 --- a/src/components/AiDocumentReader.tsx +++ b/src/components/AiDocumentReader.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useRef } from "react"; import { FileText, LoaderCircle } from "lucide-react"; import ReactMarkdown from "react-markdown"; +import rehypeHighlight from "rehype-highlight"; import remarkGfm from "remark-gfm"; import type { AiDocumentContent } from "../types"; @@ -26,7 +27,7 @@ export function AiDocumentReader(props: {
读取 Markdown…
) : props.content ? (
- [图片未自动加载:{alt || "无标题"}], }}>{props.content.markdown}
diff --git a/src/components/AiDocumentToolbar.tsx b/src/components/AiDocumentToolbar.tsx index 39b0634..154d3ad 100644 --- a/src/components/AiDocumentToolbar.tsx +++ b/src/components/AiDocumentToolbar.tsx @@ -1,4 +1,4 @@ -import { Clipboard, FileText, FolderOpen, Info, Link2, Plus, RefreshCw, RotateCcw, WandSparkles } from "lucide-react"; +import { Clipboard, FileDown, FileText, FolderOpen, Info, Link2, LoaderCircle, Plus, RefreshCw, WandSparkles } from "lucide-react"; import { AppTooltip } from "./AppTooltip"; import { DetailActionPopover } from "./DetailActionPopover"; import { DetailSelect } from "./DetailSelect"; @@ -8,22 +8,23 @@ interface ToolbarModel { versions: { id: string; label: string }[]; documentId: string | null; versionId: string | null; - canCreate: boolean; - createHint: string; - canRegenerate: boolean; + canGenerate: boolean; + generationHint: string; canRevise: boolean; canRead: boolean; + canExport: boolean; + exporting: boolean; } interface ToolbarActions { selectDocument: (id: string) => void; selectVersion: (id: string) => void; - create: () => void; - regenerate: () => void; + generate: () => void; revise: () => void; details: () => void; refresh: () => void; copy: () => void; + exportPdf: () => void; copyPath: () => void; open: () => void; reveal: () => void; @@ -32,38 +33,83 @@ interface ToolbarActions { export function AiDocumentToolbar({ model, actions }: { model: ToolbarModel; actions: ToolbarActions }) { return (
- actions.selectDocument(event.target.value)}> - {!model.documents.length && } - {model.documents.map((document) => )} - - - - - {model.documentId && <> - actions.selectVersion(event.target.value)}> - {model.versions.map((version) => )} - - - +
+
+ actions.selectDocument(event.target.value)} + > + {!model.documents.length && } + {model.documents.map((document) => ( + + ))} + +
+ {model.documentId && ( +
+ actions.selectVersion(event.target.value)} + > + {model.versions.map((version) => ( + + ))} + +
+ )} +
+
+ + - {model.canRead && } - - - - - - {model.canRead && <> - - - - - } - - } + {model.documentId && ( + <> + + + + {model.canRead && ( + + + + )} + + + + + )} + {model.documentId && ( + + + {model.canRead && ( + <> + + + + + + )} + + )} +
); } diff --git a/src/components/AiDocumentsPanel.test.tsx b/src/components/AiDocumentsPanel.test.tsx index 475cb77..56aa9ee 100644 --- a/src/components/AiDocumentsPanel.test.tsx +++ b/src/components/AiDocumentsPanel.test.tsx @@ -1,5 +1,6 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { save } from "@tauri-apps/plugin-dialog"; import { api } from "../api"; import { AiDocumentsPanel } from "./AiDocumentsPanel"; import type { @@ -20,7 +21,10 @@ const testState = vi.hoisted(() => ({ details: new Map(), readDocument: null as null | ((id: string) => Promise), })); -vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(async () => null) })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(async () => null), + save: vi.fn(async () => null), +})); vi.mock("../api", () => ({ api: { @@ -51,6 +55,8 @@ vi.mock("../api", () => ({ relinkAiDocumentVersion: vi.fn(), findAiDocumentVersion: vi.fn(), copyAiDocumentVersion: vi.fn(), + exportAiDocumentPdf: vi.fn(), + revealAiDocumentPdf: vi.fn(async () => undefined), copyAiDocumentPath: vi.fn(), openAiDocumentVersion: vi.fn(), revealAiDocumentVersion: vi.fn(), @@ -166,9 +172,17 @@ describe("AI documents panel", () => { const secondProvider = { ...provider, id: "provider-2", name: "Second provider", modelId: "second-model" }; render(); await screen.findByRole("heading", { name: "Existing summary" }); - if (mode === "regenerate") fireEvent.click(screen.getByLabelText("更多文档操作")); - fireEvent.click(screen.getByRole("button", { name: mode === "create" ? "生成新文档" : mode === "revise" ? "AI修改" : "重新生成" })); - let dialog = screen.getByRole("dialog"); + const openModeDialog = async () => { + fireEvent.click(screen.getByRole("button", { + name: mode === "revise" ? "AI 修改" : "生成 AI 文档或新版本", + })); + const nextDialog = await screen.findByRole("dialog"); + if (mode === "create") { + fireEvent.change(within(nextDialog).getByLabelText("场景模板"), { target: { value: "extra" } }); + } + return nextDialog; + }; + let dialog = await openModeDialog(); const backdrop = dialog.parentElement!; const close = within(dialog).getByRole("button", { name: "关闭生成窗口" }); expect(close.querySelector("svg.lucide-x")).not.toBeNull(); @@ -185,12 +199,20 @@ describe("AI documents panel", () => { fireEvent.click(backdrop); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(api.generateAiDocument).not.toHaveBeenCalled(); - if (mode === "regenerate") fireEvent.click(screen.getByLabelText("更多文档操作")); - fireEvent.click(screen.getByRole("button", { name: mode === "create" ? "生成新文档" : mode === "revise" ? "AI修改" : "重新生成" })); - dialog = screen.getByRole("dialog"); + dialog = await openModeDialog(); expect(within(dialog).getByRole("tab", { name: "生成设置" })).toBeInTheDocument(); expect(within(dialog).getByRole("tab", { name: "请求预览" })).toBeInTheDocument(); - expect(within(dialog).queryByLabelText("场景模板") !== null).toBe(mode === "create"); + expect(within(dialog).queryByLabelText("场景模板") !== null).toBe(mode !== "revise"); + if (mode === "regenerate") { + expect(within(dialog).getByLabelText("场景模板")).toHaveValue("summary"); + expect(within(dialog).getByRole("option", { name: "会议总结(创建新版本)" })).toBeInTheDocument(); + expect(within(dialog).getByText("该模板已有文档,本次将创建新版本,不会覆盖现有版本。")).toBeInTheDocument(); + } + if (mode === "create") { + expect(within(dialog).getByLabelText("场景模板")).toHaveValue("extra"); + expect(within(dialog).getByRole("option", { name: "会议总结(新建文档)" })).toBeInTheDocument(); + expect(within(dialog).queryByText("该模板已有文档,本次将创建新版本,不会覆盖现有版本。")).toBeNull(); + } expect(within(dialog).getByLabelText(/会议级上下文/)).toHaveValue("existing meeting context"); expect(within(dialog).getByLabelText(/文档要求/)).toHaveValue(mode === "create" ? "" : "existing document requirements"); if (mode === "revise") { @@ -224,7 +246,7 @@ describe("AI documents panel", () => { let finish!: (value: AiDocumentVersion) => void; vi.mocked(api.generateAiDocument).mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); render(); - fireEvent.click(await screen.findByRole("button", { name: "生成新文档" })); + fireEvent.click(await screen.findByRole("button", { name: "生成 AI 文档或新版本" })); await waitFor(() => expect(screen.getByRole("button", { name: "生成新版本" })).toBeEnabled()); fireEvent.click(screen.getByRole("button", { name: "生成新版本" })); const dialog = screen.getByRole("dialog"); @@ -240,7 +262,7 @@ describe("AI documents panel", () => { it("keeps token budget and preview failures blocking generation without losing the draft", async () => { testState.workspace!.templates = [template("summary", "meeting_summary")]; render(); - fireEvent.click(await screen.findByRole("button", { name: "生成新文档" })); + fireEvent.click(await screen.findByRole("button", { name: "生成 AI 文档或新版本" })); await waitFor(() => expect(document.querySelector(".ai-token-estimate.over")).not.toBeNull()); expect(screen.getByRole("button", { name: "生成新版本" })).toBeDisabled(); vi.mocked(api.previewAiGenerationRequest).mockRejectedValueOnce(new Error("Preview unavailable")); @@ -315,15 +337,91 @@ describe("AI documents panel", () => { expect(await screen.findByRole("heading", { name: "Current summary" })).toBeInTheDocument(); expect(screen.getByText("[图片未自动加载:chart]")).toBeInTheDocument(); expect(document.querySelector("img")).toBeNull(); + expect(screen.getByRole("button", { name: "AI 修改" })).toHaveClass("icon-button"); + expect(screen.getByRole("button", { name: "AI 修改" })).not.toHaveTextContent("AI修改"); + expect(screen.getByRole("button", { name: "生成 AI 文档或新版本" })).toHaveClass("icon-button"); + const toolbarActions = document.querySelector(".ai-document-actions"); + expect(toolbarActions).not.toBeNull(); + expect(within(toolbarActions as HTMLElement).getAllByRole("button")[0]).toHaveAccessibleName("生成 AI 文档或新版本"); fireEvent.click(screen.getByLabelText("更多文档操作")); - expect(screen.getByRole("button", { name: "重新生成" })).toHaveClass("compact"); - expect(screen.getByRole("button", { name: "重新生成" })).not.toHaveAttribute("title"); - expect(screen.getByRole("button", { name: "AI修改" })).toHaveClass("compact"); - expect(screen.getByRole("button", { name: "AI修改" })).not.toHaveAttribute("title"); - fireEvent.click(screen.getByRole("button", { name: "生成详情" })); + expect(screen.queryByRole("button", { name: "重新生成" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "查看生成详情" })); expect(await screen.findByText("该版本生成时尚未记录原始请求 JSON。")).toBeInTheDocument(); }); + it("exports the rendered Markdown with syntax highlighting and removes the temporary print root", async () => { + const summaryTemplate = template("summary", "meeting_summary"); + const completedVersion = version("v1", 1, "completed"); + testState.workspace = { + ...testState.workspace!, + templates: [summaryTemplate], + documents: [{ + id: "document-1", + recordingId: recording.id, + templateId: summaryTemplate.id, + title: "Weekly: meeting?", + requirements: "", + templateName: summaryTemplate.name, + templateBuiltinKey: summaryTemplate.builtinKey, + latestVersion: completedVersion, + createdAt: "2026-08-09T00:00:00Z", + updatedAt: "2026-08-09T00:03:00Z", + }], + }; + testState.versions = [completedVersion]; + testState.contents.set("v1", { + version: completedVersion, + markdown: "# Current summary\n\n```js\nconst ready = true;\n```", + }); + vi.mocked(save).mockResolvedValueOnce("C:\\Exports\\Weekly meeting.pdf"); + vi.mocked(api.exportAiDocumentPdf).mockImplementationOnce(async () => { + const printRoot = document.querySelector(".ai-pdf-document"); + expect(printRoot).not.toBeNull(); + expect(printRoot).toHaveTextContent("Current summary"); + expect(printRoot?.querySelector(".hljs-keyword")).toHaveTextContent("const"); + expect(document.title).toBe("Current summary"); + }); + const onMessage = vi.fn(); + const originalTitle = document.title; + + render( + , + ); + + await screen.findByRole("heading", { name: "Current summary" }); + fireEvent.click(screen.getByRole("button", { name: "导出 PDF" })); + + await waitFor(() => expect(api.exportAiDocumentPdf).toHaveBeenCalledWith( + "v1", + "C:\\Exports\\Weekly meeting.pdf", + )); + expect(save).toHaveBeenCalledWith({ + defaultPath: "Current summary.pdf", + filters: [{ name: "PDF", extensions: ["pdf"] }], + }); + expect(document.querySelector(".ai-pdf-document")).toBeNull(); + expect(document.title).toBe(originalTitle); + expect(onMessage).toHaveBeenCalledWith( + "success", + "AI 文档已导出为 PDF", + expect.objectContaining({ + durationMs: 8_000, + action: expect.objectContaining({ label: "打开文件夹" }), + }), + ); + const successOptions = onMessage.mock.calls.find(([tone]) => tone === "success")?.[2]; + successOptions?.action.onClick(); + await waitFor(() => expect(api.revealAiDocumentPdf).toHaveBeenCalledWith( + "C:\\Exports\\Weekly meeting.pdf", + )); + }); + it("shows persisted request and response JSON with normalized token usage", async () => { const summaryTemplate = template("summary", "meeting_summary"); const completedVersion = version("v1", 1, "completed"); @@ -371,8 +469,10 @@ describe("AI documents panel", () => { />, ); - const detailsButton = await screen.findByRole("button", { name: "生成详情" }); + const moreButton = await screen.findByLabelText("更多文档操作"); expect(api.readAiGenerationDetails).not.toHaveBeenCalled(); + fireEvent.click(moreButton); + const detailsButton = screen.getByRole("button", { name: "查看生成详情" }); detailsButton.focus(); fireEvent.click(detailsButton); expect(screen.getByRole("dialog", { name: "生成详情" })).toBeInTheDocument(); @@ -394,7 +494,7 @@ describe("AI documents panel", () => { )); fireEvent.keyDown(screen.getByRole("button", { name: "关闭生成详情" }), { key: "Escape" }); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(detailsButton).toHaveFocus(); + expect(moreButton).toHaveFocus(); }); it("skips an unavailable speaker template when opening a new document dialog", async () => { diff --git a/src/components/AiDocumentsPanel.tsx b/src/components/AiDocumentsPanel.tsx index a8c763d..5f741fb 100644 --- a/src/components/AiDocumentsPanel.tsx +++ b/src/components/AiDocumentsPanel.tsx @@ -1,4 +1,4 @@ -import { open } from "@tauri-apps/plugin-dialog"; +import { open, save } from "@tauri-apps/plugin-dialog"; import { AlertCircle, Clipboard, @@ -16,9 +16,15 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } import { AiDocumentToolbar } from "./AiDocumentToolbar"; import { AiDocumentReader } from "./AiDocumentReader"; import { api, type UnlistenFn } from "../api"; +import { + aiDocumentExportTitle, + pdfDefaultFileName, + withAiDocumentPrintRoot, +} from "../ai-document-pdf"; import { estimateAiRequestInputTokens } from "../ai-token-estimate"; import { llmProviderReady } from "../llm"; import { AiGenerationDetailsDrawer } from "./AiGenerationDetailsDrawer"; +import type { ToastOptions } from "./ToastRegion"; import { AppTooltip } from "./AppTooltip"; import { JsonTreeView } from "./JsonTreeView"; import { useBackdropDismiss } from "./useBackdropDismiss"; @@ -42,7 +48,7 @@ interface AiDocumentsPanelProps { transcript: TranscriptDocument | null; providers: LlmProvider[]; activeProviderId: string | null; - onMessage: (type: "success" | "error", message: string) => void; + onMessage: (type: "success" | "error", message: string, options?: ToastOptions) => void; } interface GenerationDialogState { @@ -113,6 +119,7 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { const [estimatedTokens, setEstimatedTokens] = useState(null); const [estimateError, setEstimateError] = useState(null); const [estimating, setEstimating] = useState(false); + const [pdfExporting, setPdfExporting] = useState(false); const recordingIdRef = useRef(props.recording.id); const selectedDocumentIdRef = useRef(selectedDocumentId); const contentRequestRef = useRef(0); @@ -299,23 +306,38 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { && selectedVersion.outputTokens !== null ? selectedVersion.inputTokens + selectedVersion.outputTokens : null; - const unusedTemplates = useMemo(() => { + const speakerLabelsAvailable = hasSpeakerLabels(props.transcript); + const generationTemplates = useMemo(() => { if (!workspace) return []; const used = new Set(workspace.documents.map((document) => document.templateId)); - return workspace.templates.filter((template) => !template.archived && !used.has(template.id)); + return workspace.templates.filter((template) => !template.archived || used.has(template.id)); }, [workspace]); + const eligibleGenerationTemplates = useMemo( + () => generationTemplates.filter((template) => !isSpeakerTemplate(template) || speakerLabelsAvailable), + [generationTemplates, speakerLabelsAvailable], + ); const availableProviders = useMemo( () => props.providers.filter(llmProviderReady), [props.providers], ); - const canCreateDocument = unusedTemplates.length > 0 && availableProviders.length > 0; + const canGenerateDocument = eligibleGenerationTemplates.length > 0 && availableProviders.length > 0; + const canReadVersion = Boolean( + selectedVersion?.status === "completed" && selectedVersion.fileState !== "missing", + ); const canReviseVersion = Boolean( availableProviders.length && selectedVersion && selectedVersion.status === "completed" && selectedVersion.fileState !== "missing", ); - const speakerLabelsAvailable = hasSpeakerLabels(props.transcript); + const canExportPdf = Boolean( + canReadVersion + && content + && selectedVersion + && content.version.id === selectedVersion.id + && !contentLoading + && !pdfExporting, + ); const provider = dialog ? availableProviders.find((item) => item.id === dialog.providerId) ?? null : null; @@ -393,27 +415,50 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { sourceVersion: AiDocumentVersion | null = null, ) => { setDialogTab("settings"); - const templateId = document?.templateId - ?? unusedTemplates.find((template) => !isSpeakerTemplate(template) || speakerLabelsAvailable)?.id - ?? unusedTemplates[0]?.id + const preferredTemplateId = document + && eligibleGenerationTemplates.some((template) => template.id === document.templateId) + ? document.templateId + : null; + const templateId = preferredTemplateId + ?? eligibleGenerationTemplates[0]?.id + ?? generationTemplates[0]?.id ?? ""; + const resolvedDocument = mode === "revise" + ? document + : workspace?.documents.find((item) => item.templateId === templateId) ?? null; + const resolvedMode = mode === "revise" ? mode : resolvedDocument ? "regenerate" : "create"; const defaultProvider = availableProviders.find((item) => item.id === props.activeProviderId) ?? availableProviders[0] ?? null; setDialog({ - mode, - document, + mode: resolvedMode, + document: resolvedDocument, sourceVersion, templateId, - title: document?.title ?? workspace?.templates.find((item) => item.id === templateId)?.name ?? "", + title: resolvedDocument?.title ?? workspace?.templates.find((item) => item.id === templateId)?.name ?? "", meetingContext: workspace?.profile.meetingContext ?? "", - documentRequirements: document?.requirements ?? "", + documentRequirements: resolvedDocument?.requirements ?? "", runRequest: "", providerId: defaultProvider?.id ?? "", modelId: defaultProvider?.modelId ?? "", }); }; + const selectGenerationTemplate = (templateId: string) => { + if (!dialog || dialog.mode === "revise" || !workspace) return; + const template = generationTemplates.find((item) => item.id === templateId); + const document = workspace.documents.find((item) => item.templateId === templateId) ?? null; + setDialog({ + ...dialog, + mode: document ? "regenerate" : "create", + document, + sourceVersion: null, + templateId, + title: document?.title ?? template?.name ?? dialog.title, + documentRequirements: document?.requirements ?? "", + }); + }; + const handleDialogTabKeyDown = (event: KeyboardEvent) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; event.preventDefault(); @@ -516,6 +561,37 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { } }; + const exportPdf = async () => { + if (!canExportPdf || !selectedDocument || !selectedVersion) return; + setPdfExporting(true); + try { + const renderedDocument = document.querySelector(".ai-markdown-content"); + if (!renderedDocument) throw new Error("当前 AI 文档尚未完成排版"); + const exportTitle = aiDocumentExportTitle(renderedDocument, selectedDocument.title); + const path = await save({ + defaultPath: pdfDefaultFileName(exportTitle), + filters: [{ name: "PDF", extensions: ["pdf"] }], + }); + if (!path) return; + await withAiDocumentPrintRoot(renderedDocument, exportTitle, () => + api.exportAiDocumentPdf(selectedVersion.id, path)); + props.onMessage("success", "AI 文档已导出为 PDF", { + durationMs: 8_000, + action: { + label: "打开文件夹", + onClick: () => { + void api.revealAiDocumentPdf(path).catch((error) => + props.onMessage("error", String(error))); + }, + }, + }); + } catch (error) { + props.onMessage("error", String(error)); + } finally { + setPdfExporting(false); + } + }; + if (loading) { return
读取 AI 文档…
; } @@ -536,18 +612,22 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { model={{ documents: workspace?.documents ?? [], versions: versions.map((version) => ({ id: version.id, label: formatVersionLabel(version) })), documentId: selectedDocumentId, versionId: selectedVersionId, - canCreate: canCreateDocument, canRegenerate: availableProviders.length > 0, canRevise: canReviseVersion, - canRead: selectedVersion?.status === "completed" && selectedVersion.fileState !== "missing", - createHint: !availableProviders.length ? "请先配置可用的 LLM Provider" : unusedTemplates.length ? "生成新文档" : "所有模板都已生成", + canGenerate: canGenerateDocument, canRevise: canReviseVersion, + canRead: canReadVersion, canExport: canExportPdf, exporting: pdfExporting, + generationHint: !availableProviders.length + ? "请先配置可用的 LLM Provider" + : eligibleGenerationTemplates.length + ? "生成 AI 文档或新版本" + : "没有适用于当前转写的场景模板", }} actions={{ selectDocument: setSelectedDocumentId, selectVersion: setSelectedVersionId, - create: () => openDialog("create", null), - regenerate: () => { if (selectedDocument) openDialog("regenerate", selectedDocument); }, + generate: () => openDialog(selectedDocument ? "regenerate" : "create", selectedDocument), revise: () => { if (selectedDocument) openDialog("revise", selectedDocument, selectedVersion); }, details: () => setPreviewTab("details"), refresh: () => setContentReloadKey((current) => current + 1), copy: () => { if (selectedVersion) void api.copyAiDocumentVersion(selectedVersion.id).then(() => props.onMessage("success", "已复制 Markdown")).catch((error) => props.onMessage("error", String(error))); }, + exportPdf: () => { void exportPdf(); }, copyPath: () => { if (selectedVersion) void api.copyAiDocumentPath(selectedVersion.id).then(() => props.onMessage("success", "已复制文件路径")).catch((error) => props.onMessage("error", String(error))); }, open: () => { if (selectedVersion) void api.openAiDocumentVersion(selectedVersion.id).catch((error) => props.onMessage("error", String(error))); }, reveal: () => { if (selectedVersion) void api.revealAiDocumentVersion(selectedVersion.id).catch((error) => props.onMessage("error", String(error))); }, @@ -558,7 +638,7 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { {!selectedDocument ? (

选择模板生成第一份 Markdown 文档。

- +
) : ( <> @@ -705,7 +785,7 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) {

AI MARKDOWN

-

{dialog.mode === "create" ? "生成 AI 文档" : dialog.mode === "revise" ? "基于所选版本创建新版" : "生成全新版本"}

+

{dialog.mode === "revise" ? "基于所选版本创建新版" : "生成 AI 文档"}

每次生成都会创建新的 Markdown 版本,不会覆盖已有版本或文件。 @@ -750,23 +830,34 @@ export function AiDocumentsPanel(props: AiDocumentsPanelProps) { role="tabpanel" aria-labelledby="ai-generation-settings-tab" > - {dialog.mode === "create" && ( + {dialog.mode !== "revise" && ( )} diff --git a/src/components/RecordingsWorkspace.test.tsx b/src/components/RecordingsWorkspace.test.tsx index 6bdab86..d83c304 100644 --- a/src/components/RecordingsWorkspace.test.tsx +++ b/src/components/RecordingsWorkspace.test.tsx @@ -239,7 +239,7 @@ describe("RecordingsWorkspace", () => { fireEvent.click(screen.getByRole("button", { name: "折叠录音列表" })); expect(document.querySelector(".history-pane")).toHaveAttribute("hidden"); fireEvent.click(screen.getByRole("tab", { name: "AI 文档" })); - await screen.findByRole("button", { name: "生成新文档" }); + await screen.findByRole("button", { name: "生成 AI 文档或新版本" }); fireEvent.click(screen.getByRole("tab", { name: "文字转写" })); expect(document.querySelector("audio")).toBe(audio); expect(audio.currentTime).toBe(12); diff --git a/src/components/RecordingsWorkspace.tsx b/src/components/RecordingsWorkspace.tsx index 4c3f729..bb8f302 100644 --- a/src/components/RecordingsWorkspace.tsx +++ b/src/components/RecordingsWorkspace.tsx @@ -41,6 +41,7 @@ import { DetailActionPopover } from "./DetailActionPopover"; import { DetailSelect } from "./DetailSelect"; import { AiDocumentsPanel } from "./AiDocumentsPanel"; import { AppTooltip } from "./AppTooltip"; +import type { ToastOptions } from "./ToastRegion"; import { SpeakerIdentificationModal, type SpeakerAnalysisStatus, @@ -100,7 +101,7 @@ interface RecordingsWorkspaceProps { onDiscardRecovery: (id: string) => void; onRename: (id: string, currentTitle: string) => void; onPermanentDelete: (id: string) => void; - onAiMessage: (type: "success" | "error", message: string) => void; + onAiMessage: (type: "success" | "error", message: string, options?: ToastOptions) => void; } interface RecordingActionMenu { diff --git a/src/components/ToastRegion.tsx b/src/components/ToastRegion.tsx index 45d3100..dc345ed 100644 --- a/src/components/ToastRegion.tsx +++ b/src/components/ToastRegion.tsx @@ -22,6 +22,8 @@ export interface AppToast { }; } +export type ToastOptions = Partial>; + interface ToastRegionProps { toasts: AppToast[]; onDismiss: (id: number) => void; diff --git a/src/recording-detail.css b/src/recording-detail.css index 78c0808..f7b338f 100644 --- a/src/recording-detail.css +++ b/src/recording-detail.css @@ -36,16 +36,29 @@ .ai-documents-layout { min-height: 0; min-width: 0; display: flex; flex-direction: column; margin: 0; border: 0; border-radius: 0; overflow: visible; } .ai-document-preview { flex: 1 1 0; } .ai-document-toolbar { flex: 0 0 auto; display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-5); border-block: 1px solid var(--color-border-subtle); background: var(--color-surface-primary); } -.ai-document-toolbar > .detail-select { flex: 1 1 150px; min-width: 0; } -.ai-document-toolbar > button { flex-shrink: 0; } -.ai-document-toolbar > button[aria-label="复制 Markdown"] { display: grid; place-items: center; width: var(--control-height-default); height: var(--control-height-default); border: 0; border-radius: var(--radius-medium); background: transparent; color: var(--color-icon-primary); cursor: pointer; } -.ai-document-toolbar > button[aria-label="复制 Markdown"]:hover { background: var(--color-surface-hover); } +.ai-document-context { min-width: 0; flex: 1 1 auto; display: flex; align-items: center; gap: var(--space-2); } +.ai-document-selector { min-width: 0; flex: 1 1 200px; max-width: 240px; } +.ai-version-selector { min-width: 160px; flex: 0 1 200px; max-width: 200px; } +.ai-document-actions { flex: 0 0 auto; display: flex; align-items: center; gap: var(--space-1); margin-inline-start: auto; } .detail-select { min-width: 0; width: 100%; } .detail-select select { width: 100%; min-width: 0; height: var(--control-height-compact-text); padding: var(--space-1-5) var(--space-8) var(--space-1-5) var(--space-2-5); font-size: var(--font-size-body); line-height: var(--line-height-body); text-overflow: ellipsis; } .ai-markdown-body { overflow-wrap: anywhere; padding: var(--space-5) var(--space-6); } .ai-markdown-content { max-width: 80ch; margin-inline: auto; } .ai-markdown-body pre { white-space: pre-wrap; } .ai-markdown-body table { table-layout: fixed; } +.ai-pdf-document { display: none; } +.ai-markdown-body :is(.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link), +.ai-pdf-document :is(.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link) { color: var(--color-syntax-key); } +.ai-markdown-body :is(.hljs-string, .hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition), +.ai-pdf-document :is(.hljs-string, .hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition) { color: var(--color-syntax-string); } +.ai-markdown-body :is(.hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name), +.ai-pdf-document :is(.hljs-number, .hljs-meta, .hljs-built_in, .hljs-builtin-name) { color: var(--color-syntax-number); } +.ai-markdown-body :is(.hljs-boolean, .hljs-deletion), +.ai-pdf-document :is(.hljs-boolean, .hljs-deletion) { color: var(--color-syntax-boolean); } +.ai-markdown-body :is(.hljs-comment, .hljs-quote), +.ai-pdf-document :is(.hljs-comment, .hljs-quote) { color: var(--color-syntax-null); } +.ai-markdown-body :is(.hljs-punctuation, .hljs-operator, .hljs-variable, .hljs-template-variable), +.ai-pdf-document :is(.hljs-punctuation, .hljs-operator, .hljs-variable, .hljs-template-variable) { color: var(--color-syntax-punctuation); } .ai-documents-layout > .ai-inline-warning { margin: var(--space-2) var(--space-4); } .detail-action-popover { position: relative; flex-shrink: 0; } .detail-action-popover summary { list-style: none; } @@ -71,3 +84,134 @@ .ai-markdown-body { padding-inline: var(--space-4); } .library-workspace .history-header { flex-wrap: wrap; } } + +@media print { + @page { + size: A4 portrait; + margin: 18mm 16mm 20mm; + } + + :root, + html, + body { + height: auto; + overflow: visible; + background: var(--color-surface-raised); + } + + body { + color: var(--color-text-primary); + font-family: var(--font-family-ui); + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + #root { display: none !important; } + .ai-pdf-document { + display: block; + font-size: var(--font-size-body); + line-height: var(--line-height-reading); + } + + .ai-pdf-content { + width: 100%; + max-width: 80ch; + margin-inline: auto; + overflow-wrap: anywhere; + } + + .ai-pdf-document h1 { + margin: 0 0 var(--space-5); + font-family: var(--font-family-display); + font-size: var(--font-size-title); + line-height: var(--line-height-title); + } + + .ai-pdf-document h2 { + margin: var(--space-6) 0 var(--space-2-5); + padding-bottom: var(--space-1); + border-bottom: 1px solid var(--color-border-subtle); + font-family: var(--font-family-display); + font-size: var(--font-size-subtitle); + line-height: var(--line-height-subtitle); + } + + .ai-pdf-document h3 { + margin: var(--space-5) 0 var(--space-2); + font-size: var(--font-size-body-large); + line-height: var(--line-height-body-large); + } + + .ai-pdf-document h4, + .ai-pdf-document h5, + .ai-pdf-document h6 { + margin: var(--space-4) 0 var(--space-2); + font-size: var(--font-size-body); + line-height: var(--line-height-body); + } + + .ai-pdf-document :is(h1, h2, h3, h4, h5, h6) { + break-after: avoid-page; + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); + } + + .ai-pdf-document p { margin: var(--space-2) 0; } + .ai-pdf-document :is(ul, ol) { padding-inline-start: var(--space-6); } + .ai-pdf-document li + li { margin-top: var(--space-1); } + .ai-pdf-document a { color: var(--color-text-brand); text-decoration-thickness: 1px; text-underline-offset: var(--space-0-5); } + .ai-pdf-document blockquote { + margin: var(--space-4) 0; + padding: var(--space-2) var(--space-4); + border-inline-start: var(--space-1) solid var(--color-border-strong); + background: var(--color-surface-subtle); + color: var(--color-text-secondary); + break-inside: avoid-page; + } + + .ai-pdf-document table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + } + + .ai-pdf-document :is(th, td) { + padding: var(--space-2); + border: 1px solid var(--color-border-default); + text-align: start; + vertical-align: top; + overflow-wrap: anywhere; + } + + .ai-pdf-document th { background: var(--color-surface-subtle); font-weight: var(--font-weight-semibold); } + .ai-pdf-document tr { break-inside: avoid-page; } + .ai-pdf-document code { font-family: var(--font-family-mono); } + .ai-pdf-document :not(pre) > code { + padding: var(--space-0-5) var(--space-1); + border-radius: var(--radius-small); + background: var(--color-surface-subtle); + } + + .ai-pdf-document pre { + margin: var(--space-4) 0; + padding: var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-medium); + background: var(--color-surface-subtle); + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .ai-pdf-document hr { margin: var(--space-6) 0; border: 0; border-top: 1px solid var(--color-border-subtle); } + .ai-pdf-document img { max-width: 100%; height: auto; } + .ai-pdf-document .ai-remote-image { + display: inline-block; + padding: var(--space-1-5) var(--space-2); + background: var(--color-surface-subtle); + color: var(--color-text-muted); + font-size: var(--font-size-caption); + line-height: var(--line-height-caption); + } +}