diff --git a/.gitignore b/.gitignore index d5e79b0..edc2222 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ src-tauri/gen/ # local-only reference files .hermes_guide.md + +# Excalidraw fonts, copied in by scripts/copy-excalidraw-fonts.mjs +/static/excalidraw/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 19de0f3..64138ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,78 @@ All notable changes to InstantNotes are recorded here. The section for each release becomes the GitHub release notes and the "What's new" text shown by the -in-app updater, so write it for users. Newest first. +in-app updater, written for users. Newest first. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the app uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.9.0] - 2026-09-30 + +### Added +- License and EULA: on first launch they open as two notes in a License Space; + agree to each to start. A new version of either asks again. +- Sticky notes (beta): pop any note or whiteboard out of the library into its own + small window and put it anywhere. Or Pop Out as Sticky (⌘⇧O). +- Import Apple Stickies (Mac): Settings > Import. Colors, formatting, images, + and dates come along; importing again brings in only new ones. +- Connect agents (Claude Code, Codex, Cursor) over MCP: Settings > Agents. Off + until you turn it on; read only or read and write. +- See an agent at work: what it reads or edits lights up as it happens. If you + are typing when it edits, your typing wins and you can restore theirs. +- Change Settings front page to settings dashboard + release notes reference +- Configure image settings: Modify how images behave. +- Added importing images. +- Configure UI settings: Show exact save time +- Contexting can rewrite images to their absolute path (new default) +- Send feedback from app (Github Issue) (beta) +- Vault: InstantNotes mirrors your notes as plain files in a folder you choose, + organized by Space. Open them in any editor, on any device, even with the app + closed. Edits sync within seconds; attachments are included and trashed notes + go to the vault's trash folder. Use a synced folder (iCloud, Dropbox) to reach + them elsewhere. "Check vault" verifies every file matches its note. + Note: syncing is one-way for now, so keep editing in the app. + "Export a copy" is still available for one-off backups. +- Whiteboards (beta): freeform canvases for boxes, arrows, frames, and sketches. + Create one from the chevron next to + in the note list, with Cmd+Shift+N, or + from the File menu. The command palette can convert an existing note; its text + moves onto the board. Board text is searchable and #tags still apply. Boards + save as standard .excalidraw files, in the vault and on export. +- Graph: a new sidebar view of how your notes, tags, and Spaces connect. It opens + centered on the current note. Hover to highlight connections; click to open a + note, filter by tag, or enter a Space. +- Changed update dialog into custom notification: a special "Update" Space. + +### Fixed +- Images no longer pile up forever: deleting a note for good also deletes the + images only it used. Settings > Images shows how many images no note uses any + more and can remove them. Images used by notes in the Trash or the Archive + are always kept. +- A stray caret no longer lingers in notes you switch between: each note now + loads with its own clean editing state, and undo no longer reaches back into + the previously open note. +- Links set to open with Cmd/Ctrl+Click now show the pointer cursor while the + modifier is held, so it reads as clickable. +- Opening a notes library written by a newer version of InstantNotes no longer + crashes the app on launch. It now shows a message telling you to update + instead. +- A library touched by a pre-release build with the whiteboard now opens + instead of being refused as written by a newer version, and its boards open + as whiteboards again. +- Linux: fixed a blank or corrupted window on many systems, caused by the + WebKit compositor. The README now lists Linux prerequisites and Arch/CachyOS + build notes. +- The release script guards its Windows path check so cutting a release + no longer crashes there. +- The update dialog's "Remind me later" options are gone: an update is now a + place you can simply ignore rather than a reminder you have to silence. +- Note titles skip bold/italic markers and leading images. + +### Changed +- A new app icon, New menu bar icon. +- Under the hood: error codes and event names each live in one place per side. + ## [0.8.0] - 2026-07-11 ### Added diff --git a/README.md b/README.md index 2839da0..aa1ff2e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Instantly externalize your writing. Capture and locate your thoughts. - **Quick Capture:** Global hotkey (`Opt` / `Ctrl+Shift+Space`) with drafts. - **Command Palette:** `Cmd/Ctrl+P` for actions, search, and themes. -- **Flexible Organization:** Group notes with Workspaces and `#inline` tags instead of strict folders. +- **Flexible Organization:** Group notes with Spaces and `#inline` tags instead of strict folders. +- **Plain Markdown Vault:** Keep a live copy of every note as a Markdown file in a folder you choose, readable in any editor. - **100% Local & Private:** Everything lives in a local SQLite database. Zero telemetry. ## Installation @@ -36,11 +37,31 @@ To build from source instead ### Prerequisites -- macOS, Windows +- macOS, Windows, Linux - [Rust](https://rustup.rs/) via rustup (the version is pinned by `rust-toolchain.toml`) - Node.js 22+ - Windows: the Visual Studio Build Tools with the C++ workload +
+Arch / CachyOS Linux + +Install system dependencies: +```sh +paru -S --needed webkit2gtk-4.1 base-devel openssl libayatana-appindicator librsvg squashfs-tools +``` + +If using system `rust` instead of `rustup`, configure `~/.cargo/config.toml`: +```toml +[target.x86_64-unknown-linux-gnu] +linker = "gcc" +``` + +To build local bundles (`npm run tauri build`): +```sh +NO_STRIP=1 npm run tauri build -- --config '{"bundle":{"createUpdaterArtifacts":false}}' +``` +
+ ### Run the app ```sh @@ -51,9 +72,9 @@ npm run tauri:dev ### Test ```sh -cargo test --manifest-path src-tauri/Cargo.toml # Rust core tests -npm run check # type-check frontend (runs svelte-kit sync) -npm test # frontend unit tests (Vitest) +cargo test --workspace --manifest-path src-tauri/Cargo.toml # Rust tests (app and core) +npm run check # type-check frontend (runs svelte-kit sync) +npm test # frontend unit tests (Vitest) ``` ### Build diff --git a/docs/API.md b/docs/API.md index dda6230..8375dcc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -15,6 +15,13 @@ and translate failures into an `ApiError`. Data mutations happen only through these commands; the frontend re-queries on `notes:changed`, `tags:changed`, and `workspaces:changed` events rather than mutating local state optimistically. +Neither side writes an event name or an error code as a literal. Both live in +one registry per language — `src-tauri/src/events.rs` and +`src-tauri/src/error.rs` on the Rust side, `src/lib/api/events.ts` and +`src/lib/api/error-codes.ts` on the frontend — and `src/lib/api/contract.test.ts` +holds the registries, this document, and the core's `AppError::code()` equal, +since no type can cross the IPC boundary. + ## 2. Invocation Each wrapper calls `invoke(name, args)` and returns a typed result. Arguments @@ -26,8 +33,11 @@ header (see section 8). Commands return a `Result`. On failure the core produces an `AppError`, which serializes to a plain object and is rethrown by the client as an `ApiError` -carrying the same `code`. `src/lib/errors.ts` maps each code to user-facing -copy; unknown codes fall back to a generic message. +carrying the same `code`, typed as `ErrorCode`. `src/lib/errors.ts` maps each +code to user-facing copy, exhaustively, so a new code does not compile until it +has copy. A code the frontend does not know can only come from a mismatched +backend; `client.ts` reports it as `STORAGE_ERROR` and keeps the original code +in the developer-facing message. ### 3.6 Error object shape @@ -37,7 +47,7 @@ A failed command rejects with: { code: string, message: string } ``` -`code` is one of the stable identifiers in section 11. `message` is a +`code` is one of the stable identifiers in section 14. `message` is a developer-facing description and is never shown to users verbatim. ## 4. Notes @@ -45,20 +55,32 @@ developer-facing description and is never shown to users verbatim. | Command | Purpose | | --- | --- | | `create_note` | Create a note; title is derived from the body (see DATA_MODEL.md section 6). | -| `get_note` | Fetch one note by id. | -| `update_note` | Patch title/body/flags; an empty patch is a no-op. | +| `get_note` | Fetch one note by id, including a whiteboard's `surfaceData`. | +| `update_note` | Patch title/body/flags, `contentKind`, and `surfaceData`; an empty patch is a no-op. The reply omits `surfaceData`: the caller already holds the canvas it saved. | | `soft_delete_note` | Move a note to trash (`is_deleted = 1`). | | `restore_note` | Restore a trashed note. | | `permanently_delete_note` | Destroy a note and its rows for good. | -| `list_notes` | List notes for a status/space/tag filter. | +| `set_notes_flags` / `soft_delete_notes` / `restore_notes` / `destroy_notes` | The same for a multi-selection (`ids`), each in one transaction. `set_notes_flags` takes optional `isPinned` and `isArchived`; `destroy_notes` refuses without `confirm: true`. | +| `list_notes` | List notes for a status/space/tag filter. Rows carry `contentKind` but not `surfaceData`. | | `search_notes` | Full-text search over title and body (section 7 of DATA_MODEL.md). | +| `library_graph` | Live notes, every tag and Space, and one link per note-to-tag or note-to-Space membership, for the Graph view. Derived on every call; nothing about the graph is stored. Trashed and archived notes are left out. | + +`contentKind` is `document` or `whiteboard`. `update_note` rejects turning a +whiteboard back into a document and `surfaceData` on a document, both with +`VALIDATION_ERROR`. A whiteboard's `body` is the text on its board, written +by the app with each canvas save (DATA_MODEL.md section 3.1). + +`update_note` takes an optional `expectedUpdatedAt`. When given, the patch +applies only if the note's `updatedAt` still equals it, checked inside the +write transaction; otherwise it fails with `CONFLICT` and changes nothing. The +editor's document saves send the version they were based on, so a write from +another process in between (an agent, section 15) is noticed, not overwritten. ## 5. Tags | Command | Purpose | | --- | --- | | `list_tags` | All tags with usage counts. | -| `get_or_create_tag` | Resolve a normalized tag name to a tag, creating it if new. | | `update_tag` | Rename or recolor a tag. | | `delete_tag` | Remove a tag and its note associations. | | `add_tag_to_note` / `remove_tag_from_note` | Attach or detach a tag. | @@ -91,34 +113,333 @@ to defaults when a key is absent or malformed. | --- | --- | | `save_attachment` | Store one pasted/dropped image; raw bytes in the body, extension in `x-attachment-ext`. Returns the generated filename. | | `get_attachments_dir` | Absolute path of the attachments directory. | +| `import_image_file` | Copy an image picked through a file dialog into the attachments directory (the "copy in" storage mode). Takes an absolute `path`; returns the generated filename. | +| `allow_image_file` | Allow one existing local image file to load through the asset protocol (the "link the original file" storage mode). Takes an absolute `path` to a file that must already exist; widens the asset scope to that single file only, never a directory. Idempotent. | +| `open_attachments_folder` | Reveal the attachments folder in the OS file manager. | +| `unused_attachments` | `{ count, bytes }` of stored images no note references, older than an hour. | +| `remove_unused_attachments` | Remove those images, and their unchanged copies in the live vault's `attachments/`. Returns what it removed as `{ count, bytes }`. | Images live as files under `/attachments` and notes reference them by relative `attachments/` markdown paths. The webview reads them back over the asset protocol, scoped to that directory in `tauri.conf.json`. +`import_image_file` and `save_attachment` both land copied images in the same +directory with the same `.` naming; the former is for the toolbar's +"Insert image..." action (an existing file on disk), the latter for paste and +drop (bytes already in memory). + +Link mode (`allow_image_file`) references the original file by its absolute +path instead of copying it, so the note body carries that path directly +(`![](/abs/path/to/file.png)`), not an `attachments/` reference. Both +`import_image_file` and `allow_image_file` reject a path whose extension is +outside `png`, `jpg`, `jpeg`, `gif`, `webp` with a `VALIDATION` error naming the +rejected extension; `allow_image_file` additionally requires the file to exist. +Cancelling the file picker never calls either command — the frontend checks the +dialog result before invoking. + +Cleanup. `permanently_delete_note` and `destroy_notes` also remove the copied +images only the destroyed notes referenced. An image stays while anything +references it as `attachments/`: any note in any state (the Trash and the +Archive included), a whiteboard's canvas, or the capture draft. The match +ignores case. The store stays locked from the reference check to the removal, +so no save can start using an image mid-cleanup, and a cleanup failure never +fails the delete. The unused-images commands cover what older versions left +behind; they skip images added in the last hour, since a fresh paste can +belong to an edit that has not saved yet. Linked originals are never touched. + +Link mode is a live-editing convenience only. It is not portable: a note that +uses it is not guaranteed to reproduce on a device without the original file at +that same path. The vault serializer (`export_vault`, §12) always materializes +a copy at export time regardless of storage mode; see +`feat-portable-vault-sync`'s `tasks.md` for the recorded decision. + ## 9. Windows and shell `hide_capture`, `open_library`, `set_window_vibrancy`, `set_window_theme`, `export_theme_file`, `import_theme_file`, `export_note_file`, `open_url`, `quit_app`. These drive native windows, theme file I/O, and external links; they -carry no note data beyond what the user explicitly exports. +carry no note data beyond what the user explicitly exports. `export_note_file` +writes `.md`, `.txt`, or `.excalidraw` (a whiteboard's canvas). +`get_shortcut_failure` returns why the global capture shortcut could not be +registered at launch, or `null`; the welcome screen shows it. + +The File menu announces itself to the library window with `menu:new-note`, +`menu:new-whiteboard`, `menu:export-note`, and `menu:toggle-sticky` (no +payload). Every event name the +shell emits is declared in `src-tauri/src/events.rs`. + +### 9.1 Stickies + +A sticky is a note popped out of the library into its own window +(`sticky-`, route `/sticky`). While it is open it is the note's only +editor; the library shows a placeholder and refuses body, title, and canvas +edits for that note, so the store never has two writers on one note. + +| Command | Purpose | +| --- | --- | +| `pop_out_note(id)` | Open the note as a sticky, or bring its window forward. Rejects a note in the Trash. The library writes the note's pending edit first. | +| `pop_in_note(id)` | Emit `sticky:close-requested` to that window, wait for `answer_pop_in`, then close it. Resolves once the edits are on disk; rejects, leaving the sticky open, when the sticky reports its save failed. A sticky that does not answer within 1.5s is closed anyway. | +| `answer_pop_in(saved)` | The sticky's answer to `sticky:close-requested`. | +| `list_stickies()` | Ids of the notes that are stickies. | +| `get_sticky_view()` | The calling sticky's `{ level, collapsed }`, for its header on load. | +| `set_sticky_level(level)` | The calling sticky's level: `float` (above every window, on every Space), `normal`, or `desktop` (below every window, on every Space). | +| `set_sticky_collapsed(collapsed)` | Roll the calling sticky up to its 30px header, or back down to its saved height. | +| `save_sticky_geometry()` | Remember the calling sticky's position and size, read from the window itself. | + +A sticky's header is its title bar: pressing and dragging it moves the window +(the `stickies` capability grants `core:window:allow-start-dragging` to +`sticky-*` windows only), and double-clicking it collapses or expands the +note, as Apple's Stickies does. The window accepts the first click, so an +unfocused sticky moves in one gesture. + +The level and geometry commands act on the window that calls them, never on an +id the webview names. `stickies:changed` (no payload) fires whenever a note +becomes a sticky or stops being one. Stickies reopen at launch; one whose note +is gone or in the Trash is dropped, and one saved on a display that is no longer +attached is centered. + +Quitting waits for every window that holds edits: the library and each sticky +flush on `app:quit-requested` and answer with `quit_app`, and the app exits on +the last answer (or after the 800ms fallback). ## 10. Capture `capture_input_ready` and `get_capture_latency` instrument the capture window's reveal-to-ready latency, surfaced in the About panel as a product number. -## 11. Error codes +## 11. Dashboard + +`library_stats` returns every number on the Settings front page in one call. The +result is flat, but it is assembled from two places. + +| Field | Meaning | +| --- | --- | +| `notesTotal` | Notes not in the Trash (active plus archived). | +| `notesActive` | Not trashed and not archived. | +| `notesPinned` | Not trashed and pinned. | +| `notesArchived` | Not trashed and archived. | +| `notesTrashed` | In the Trash. | +| `tags` | Rows in `tags`, including tags no live note uses. | +| `spaces` | Rows in `workspaces`. | +| `attachmentsCount` | Files directly inside `/attachments`. | +| `attachmentsBytes` | Their total size in bytes. | + +The seven counts are one `COUNT(*)` each in `core/src/store/stats.rs`, returned +as the core's `LibraryStats`. The two attachment numbers are not in the store at +all: the desktop layer reads them off the filesystem and flattens them onto the +same object, so what crosses the boundary is `LibraryStats` plus two fields. The +frontend calls that flat shape `DashboardStats` in `types.ts`. + +Attachment counting is best-effort on purpose. A missing or unreadable +attachments directory reports zero rather than failing the whole call, so the +rest of the dashboard still renders. Every regular file at the top level counts, +whether or not it is an image; subdirectories are neither traversed nor counted. + +The numbers are a snapshot taken when the page is opened. Nothing pushes an +update, and the dashboard is not on any hot path. + +## 12. Vault + +| Command | Purpose | +| --- | --- | +| `export_vault` | Write the whole library to `dest` (an absolute folder path) as a vault: one Markdown file per note plus `instantnotes.yaml`, per `feat-portable-vault-sync/design.md` §3. | +| `get_vault_status` | The live mirror's state: `{ path, pending, lastError, lastFlushedAt }`. `path` is null when mirroring is off. | +| `set_vault_folder` | Start mirroring into `path` (an absolute folder), move the mirror to a new folder, or stop it with `path: null`. Returns the new status. | +| `verify_vault` | Flush anything pending, then compare every file with the library: `{ checked, missing, diverged, orphans, pending, manifestOk }`. Read-only. | + +| Event | Payload | Meaning | +| --- | --- | --- | +| `vault:status` | none | A mirror flush finished; re-read `get_vault_status`. | + +SQLite stays authoritative for both the export and the mirror; nothing reads a +vault folder back yet. Folders are chosen by a native folder-picker dialog in +JS, the same trust boundary as `export_note_file`. + +### 12.1 Export (stage 1) + +Every note, active, archived, and trashed, is included. Active and archived +notes land at the vault root; trashed notes land under `dest/trash/`. +Filenames are the note's title (auto-derived when the user never set one +explicitly), sanitized for cross-platform filesystem safety. Names collide +case-insensitively, since macOS and Windows filesystems are: on a collision +the first six characters of the note's id are appended, or the whole id if +even that is taken. Tag colors and the full list of Spaces (including empty +ones) go into `instantnotes.yaml`; everything under `/attachments` +is copied into `dest/attachments`. + +Re-exporting to a folder that already holds a previous export does not remove +files for notes deleted or retitled since the last run; each call only writes +and overwrites, it never prunes. An export into the live mirror's folder (or +inside it) is refused with `STORAGE_ERROR`: the mirror already keeps it +current. + +The store-to-vault gather (`instantnotes_core::vault::collect_from_store`) +pages through `list_notes` rather than trusting its default 500-row limit, so +a library larger than that is not silently truncated. + +### 12.2 Live mirror (stage 2) + +`set_vault_folder` validates the folder (absolute, existing, and not inside or +around the app data folder, which holds the database) and returns at once. +Setting a new folder marks every note pending; a background writer then +flushes them, 50 notes per hold of the store lock, and emits `vault:status` +after each flush. From then on every note, tag, and Space write marks the +affected notes pending (in the database, by trigger) and pokes the writer, +which flushes once writes pause for 300 ms (at most 2 s later). Quitting +flushes one more chunk; anything left is flushed at the next launch. + +Filenames follow §12.1. A note moves when its title or trash state changes; a +permanent delete removes its file. The mirror only writes or removes files it +owns: a path it recorded, or a file whose frontmatter `id` is the note's. A +file with a different or missing `id` at a note's name is left alone, and the +note takes a suffixed name. A file edited outside the app is replaced the next +time its note changes, but is never removed by a move or delete. Attachments +missing from `/attachments` are copied in on setup, at launch, and +after each image is saved; existing names are never overwritten. + +A missing folder (an unmounted drive) pauses the mirror: `lastError` reads +`vault folder not found: `, notes stay pending, the folder is never +recreated, and the next flush after it returns catches up. Stopping the +mirror leaves the written files in place. + +In `verify_vault`, `missing` are notes whose file is gone, `diverged` are +files whose contents no longer parse to their note, and `orphans` are `.md` +files at the vault root or in `trash/` that the mirror did not write. Pending +notes are counted, not compared. With no folder set it fails with +`VALIDATION_ERROR`; with the folder missing, `STORAGE_ERROR`. + +## 13. Feedback + +| Command | Purpose | +| --- | --- | +| `submit_feedback` | Append one feedback submission to `/feedback.jsonl`. | +| `open_feedback_log` | Reveal `feedback.jsonl` in the OS file manager. | + +`submit_feedback` takes a `FeedbackInput`: + +| Field | Meaning | +| --- | --- | +| `category` | `"bug"`, `"idea"`, or `"other"`, from the Feedback page's kind selector. | +| `message` | The submission text. Rejected with `VALIDATION` when empty after trimming. | +| `appVersion` | Optional; the running app version. | +| `diagnostics` | Optional opt-in snapshot (`appVersion`, `platform`, `notes`, `attachments` counts), stored verbatim as shown to the user, or omitted when the user declined it. | + +Each call appends one JSON line with a millisecond timestamp; nothing reads +the file back or prunes it. `submit_feedback` never touches the network — the +GitHub hand-off (a prefilled `issues/new` URL) runs entirely on the frontend +through `open_url` after the local write succeeds, so feedback is durable even +offline or when no browser opens. No note content (title, body, tags, Spaces) +is ever included; only what `FeedbackDiagnostics` (`src/lib/feedback.ts`) +names. + +## 14. Error codes | Code | Meaning | | --- | --- | | `NOT_FOUND` | The requested record does not exist. | | `VALIDATION_ERROR` | Input failed a rule (empty name, bad type). | -| `CONFLICT` | A uniqueness constraint was violated (duplicate name). | +| `CONFLICT` | A uniqueness constraint was violated (duplicate name), or a note changed since the version an `update_note` named. | | `STORAGE_ERROR` | A persistence failure, including a corrupt database file. | | `MIGRATION_ERROR` | The schema could not be upgraded. | -These are the only codes callers may branch on; they are asserted in -`src-tauri/core/src/error.rs`. Database corruption is reported as +These are the only codes callers may branch on, and the only values +`ErrorCode` has on either side. The core's `AppError::code()` +(`src-tauri/core/src/error.rs`) is the authority for the strings; the shell +builds every error through `CmdError::storage`/`::validation` or +`From`, so no call site can name a code itself, and this table is +asserted against both registries by `src/lib/api/contract.test.ts`. Database corruption is reported as `STORAGE_ERROR` externally, while the core keeps it distinct internally so it can set aside a damaged file and start fresh. + +## 15. Agents + +Agents reach the library through the Model Context Protocol, served by the +app's own binary in a second mode (`src-tauri/agents`, crate +`instantnotes-agents`): + +``` +instantnotes mcp --db [--attachments ] +``` + +`main()` checks for `mcp` before Tauri starts, so this process never opens a +window or meets the single-instance plugin. It opens the library with +`Store::open` (never `open_or_recover`), speaks newline-delimited JSON-RPC 2.0 +on stdio, and writes nothing but protocol to stdout and no note content to +stderr. It works whether or not the app is running. + +It serves both eras of the specification. A request whose `_meta` names +`2026-07-28` is served statelessly: `server/discover` describes the server, +every result carries `resultType: "complete"` and the server's identity, and +an unknown version is refused with `-32022` and the supported list. Anything +else follows the `initialize` handshake of 2025-11-25 back to 2024-11-05, +including a JSON-RPC batch on one line. An unknown tool, or `arguments` that +are not an object, is a JSON-RPC error (`-32602`); everything a tool refuses +is a result with `isError`, which the model sees. Successful results carry +their JSON as `structuredContent` and again as text. Every response is +checked against the official MCP JSON Schema of its revision. + +| Tool | Access | Store call | +| --- | --- | --- | +| `search_notes`, `list_notes`, `get_note`, `list_tags`, `list_spaces` | read | `search_notes`, `list_notes`, `get_note(id, false)`, `list_tags`, `list_workspaces` | +| `create_note`, `update_note`, `append_to_note` | write | `create_note`, `update_note` with `expectedUpdatedAt` | +| `tag_note`, `untag_note`, `add_to_space`, `remove_from_space` | write | the tag and workspace membership calls | +| `trash_note`, `restore_note` | write | `soft_delete_note`, `restore_note` | + +Every tool declares a `title`, an input schema that rejects unknown +properties, and annotations: `readOnlyHint` for reads, `destructiveHint` for +the writes that remove or replace (`update_note`, `untag_note`, +`remove_from_space`, `trash_note`), `idempotentHint`, and `openWorldHint: +false`, since a tool only ever touches this library. + +`list_notes` with `status: "revisit"` is the Revisit view's filter. Reads never +set `lastOpenedAt`. There is no permanent delete, no settings, no vault, and +no whiteboard canvas at any access level; writing a whiteboard's text is +refused. + +Two settings keys belong to this surface: + +| Key | Written by | Meaning | +| --- | --- | --- | +| `agents.access` | Settings > Agents | `"off"` (default), `"read"`, or `"write"`; re-read on every call. | +| `agents.activity` | the MCP process | The last 30 successful calls, newest first: `at` (epoch ms), `client` (the client's name, from the handshake or the request), `tool`, `kind`, `noteIds`, `noteCount`, `titles` (first three), `space`, `tag`, `query`. | + +| Command | Purpose | +| --- | --- | +| `agent_connection` | The running executable, the live library path, and the attachments dir, for the connect commands on Settings > Agents. | + +The app notices another process's commits through SQLite's `data_version`, +read every 400 ms by `shell/agents.rs`. When it moves, the new +`agents.activity` entries go to the webview as `library:external-change` +(oldest first); if any was a write, or none describes the change, the three +change events fire and the vault mirror flushes, as after the app's own +writes. Write transactions begin `IMMEDIATE` so two processes queue on the +busy timeout instead of failing on a lock upgrade. + +## 16. Import + +Settings > Import, shown on macOS. Its one source is Apple Stickies +(`openspec/changes/feat-stickies-import/design.md`). + +| Command | Purpose | +| --- | --- | +| `stickies_location` | Where Stickies keeps its notes, for the folder picker to open at; `null` off macOS. | +| `scan_stickies` | Read the folder the user picked, or the Stickies folder inside it, and preview each sticky: `id`, `title` (the one the note will get), `text` (the start of its Markdown), `color` (`#rrggbb` or `null`), `createdAt`, `updatedAt`, `images`, and `imported`. When macOS refuses access this is `readable: false`, not an error. | +| `import_stickies` | Import the stickies `ids` from `folder` in one transaction, filed in the Space named `space` (none when blank or `null`). Returns `{ imported, skipped, workspaceId }`; `skipped` counts stickies imported before. | + +The folder comes from the webview, chosen with the native picker, which is +also what gives the app access to another app's data on macOS. These commands +only read it: `*.rtfd` packages (a flat `.rtfd` file, which is a sticky not +saved yet, or a symlink is skipped), `TXT.rtf` up to 16 MiB, the +`.SavedStickiesState` colors, and the images a sticky names. An image must be +a plain file name inside its own package, a regular file, at most 50 MiB, and +an image by its first bytes. PNG, JPEG, GIF, and WebP are copied into the +attachments folder as they are, anything else becomes PNG through `sips` on +macOS, and otherwise the note says `[Not imported: ]` where the image +was. Nothing is ever written under the chosen folder. + +Each note keeps the sticky's dates, gets its title from its first line and +tags from its `#words` as any note does, and is stamped as opened, so an +import does not fill Revisit. The settings key `import.stickies` maps each +imported sticky's UUID to its note, written in the same transaction. A sticky +whose note still exists, in the Trash or not, is not imported again; one whose +note was destroyed for good is. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 42c7d2c..5fa3c07 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -38,11 +38,35 @@ content rowid; `id` is the public UUID): | `seq` | Integer primary key; FTS `content_rowid`. | | `id` | Public UUID, unique. | | `title`, `title_is_auto` | Title and whether it was auto-derived (section 6). | -| `body` | Markdown body. | +| `body` | Markdown body. For a whiteboard, the text on the board (section 3.1). | | `created_at`, `updated_at`, `last_opened_at` | Lifecycle timestamps. | | `is_pinned`, `is_archived`, `is_deleted`, `deleted_at` | Status flags. | +| `content_kind`, `surface_data` | `document` (default) or `whiteboard`, and a whiteboard's canvas (section 3.1). | +| `vault_path`, `file_sha`, `vault_dirty`, `board_sha` | The live vault mirror (section 10). | -Indexes cover `updated_at`, `created_at`, and the status-flag triple. +Indexes cover `updated_at`, `created_at`, the status-flag triple, pending +vault writes, and `vault_path`. + +### 3.1 Whiteboards + +A note is a document or a whiteboard, and always a note: it lists, tags, +joins Spaces, trashes, and searches like any other. A whiteboard's canvas is +`surface_data`, an Excalidraw scene in a versioned envelope: + +```json +{ "v": 1, "engine": "excalidraw", "data": { "elements": [], "appState": {}, "files": {} } } +``` + +Its `body` is the text written on the board, in reading order (top to bottom, +then left to right), rewritten with every save. Search, inline `#tags`, list +previews, and the vault's Markdown file therefore describe what the board +shows. Because the body moves with the drawing, converting freezes an auto +title (`title_is_auto = 0`). + +`update_note` enforces the rest: converting is one-way (a whiteboard never +becomes a document again), and only a whiteboard holds `surface_data`. List +rows leave `surface_data` out, since a board can hold pasted images; opening +the note brings it. ## 4. Tags @@ -58,11 +82,14 @@ pair, cascading on note or workspace delete. The UI calls these "Spaces". ## 6. Title derivation -`derive_title` takes the first non-empty line of the body, strips leading -markdown markers (`#`, `-`, `*`, `>`) and inline `#` tag prefixes, collapses -whitespace, and truncates to 80 characters on a char boundary. An empty body -yields `Untitled`. A note keeps `title_is_auto = 1` until the user edits the -title directly. +`derive_title` takes the first line of the body with words on it (passing over +blank lines and lines that are only images), strips leading +markdown markers (`#`, `-`, `*`, `>`), the emphasis markers around each word +(`*`, `~`, `=`, and backticks, so `**Groceries**` titles the note +"Groceries"), and inline `#` tag prefixes, collapses whitespace, and truncates +to 80 characters on a char boundary. Markers inside a word stay (`C++`, +`a*b`). An empty body yields `Untitled`. A note keeps `title_is_auto = 1` +until the user edits the title directly. ## 7. Full-text search @@ -77,8 +104,68 @@ accepts plain user input without exposing FTS syntax errors. preferences. Reads are best-effort: a missing or malformed value falls back to the code default. +`stickies` holds which notes are popped out as sticky windows on this device, +as a map from note id to `{ x, y, width, height, level, collapsed }` in +logical pixels (`x`/`y` null means centered; `level` is `float`, `normal`, or +`desktop`; while `collapsed`, `height` keeps the expanded height). It +is window state, not note data: it never reaches the vault, and losing it only +means stickies reopen in the library. + +It also holds one record per import source, `import.`: an object +mapping each imported item's id in the app it came from to the note it +became. `import.stickies` is the only one (API.md §16). It is written in the +same transaction as the notes, and an entry whose note no longer exists is +ignored, so destroying an imported note for good lets it be imported again. + ## 9. Migrations `MIGRATIONS` is an ordered list of SQL scripts; `user_version` records how many have run, so a database upgrades forward exactly once per version. The list is public so tests can build fixtures at a historical schema version. + +| Version | Change | +| --- | --- | +| v1 | Notes, tags, settings, FTS. | +| v2 | Workspaces (Spaces). | +| v3 | Drop the unused sync scaffolding columns. | +| v4 | `content_kind`, `surface_data`: the whiteboard's surface mode. | +| v5 | The vault mirror: columns, `vault_tombstones`, and triggers (section 10). | +| v6 | `board_sha`, and triggers that carry whiteboards through the vault mirror (section 10). | + +v4 exists because pre-release builds that carried the whiteboard already +migrated some libraries to it before the whiteboard was lifted off the 0.9.0 +branch. A version number must mean one schema everywhere, so v4 ships as +those builds wrote it. + +Before migrating an existing library, `Store::open` snapshots it next to +itself as `.backup-v`. A library at a version newer than +the build knows is refused, never migrated. + +## 10. Vault mirror + +When a vault folder is set (the device-local setting `vault.path`), every +note is also written there as Markdown +(`openspec/changes/feat-portable-vault-sync/design.md`). SQLite stays +authoritative: the folder is written, never read back. + +- `vault_path`: where the note's file was last written, relative to the + vault folder (`trash/` for deleted notes). +- `file_sha`: sha256 of the bytes last written there. +- `vault_dirty`: 1 while the file is behind the database. +- `board_sha`: for a whiteboard, sha256 of its canvas file as last written. + +A whiteboard's note file carries `kind: whiteboard` in its frontmatter, and +its canvas is a standard `.excalidraw` file beside it with the same name +(`Plan.md`, `Plan.excalidraw`), openable in Excalidraw. The canvas file +follows its note through renames, the trash, and deletes, under the same +ownership rules as the note file. + +Triggers set `vault_dirty` in the same transaction as the write that changed +the note's file contents: the note's own columns (not `last_opened_at`, +which is device-local), its tag and Space edges (including the cascades from +deleting a tag or a Space), and a rename of a tag or Space it carries. A +permanent delete queues the file, and a whiteboard's canvas file, in +`vault_tombstones` (`vault_path`, `file_sha`). The flush writes pending notes, removes tombstoned files that +still hold the bytes it wrote, and clears the flags. A crash between the +commit and the file write leaves the flag set, and the next launch catches +up. diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md new file mode 100644 index 0000000..1cdb34d --- /dev/null +++ b/docs/TECH_DEBT.md @@ -0,0 +1,82 @@ +# Technical debt + +What an audit found and deliberately did not change, with the reason and +where each item goes in `openspec/SEQUENCE.md`. An item leaves this file +when a unit fixes it; nothing here is a TODO in the code. + +Last audit: 2026-09-30, on `feat/agent-access` (off `0.9.0-pre` at +`917dbd3`). Clean at that point: no TS/Svelte import cycles, no Rust +`pub fn` referenced nowhere, every IPC command both registered and called, +no TODO/FIXME markers, clippy and svelte-check clean. + +## Dependencies + +**D1. Runtime advisories, all through `@excalidraw/excalidraw`.** `npm audit +--omit=dev`: 2 high, 7 moderate, 1 low, none in a package the app imports +directly except excalidraw itself. + +| Advisory | Reachable? | +| --- | --- | +| `lodash-es` `_.template` code injection, array-path prototype pollution (high) | Only through excalidraw's Mermaid import (`@mermaid-js/parser` via `langium`/`chevrotain`), fed the user's own text. The app never calls lodash. | +| `nanoid` predictable output for non-integer sizes (high) | No: excalidraw asks for integer sizes. | +| `dompurify` `IN_PLACE` hook (low) | Only through Mermaid rendering. | + +Fix: `npm audit fix` (no `--force`) moves `lodash-es`, `chevrotain`, +`langium`, and `dompurify` within their ranges. `nanoid` waits for an +excalidraw release; the "fix" npm offers is a downgrade to 0.17.6. +Why not now: it rewrites the shared lockfile while two units are open, and +needs a whiteboard pass (Mermaid import, board save, reopen). +Slot: its own maintenance unit, any gap between units. + +**D2. Toolchain majors behind.** In range: CodeMirror, Tauri 2.11 to 2.12, +Svelte, SvelteKit, svelte-check. Majors: Vite 6 to 8, Vitest 3 to 5, +TypeScript 5.6 to 7, `vite-plugin-svelte` 5 to 7, React 18 to 19 (pinned by +excalidraw's peer range), jsdom 29 to 30. +Slot: with D1; the majors each need their own changelog read, Vite and +Vitest together. + +## Structure + +**S1. `src/lib/stores/library.svelte.ts` is the largest file (995 lines, 19 +commits).** It already hands saving to `stores/library/save-queue.svelte.ts`. +Next extractions, in order of independence: navigation (space, tag, revisit, +graph, status filters and `#resetForNavigation`), then multi-selection. Its +behavior is covered by `library.svelte.test.ts` and +`library.agents.svelte.test.ts`, so the split can be proven, not hoped. +Why not now: the sticky-notes unit is editing this file. +Slot: the first unit after sticky notes and agent access both land. + +**S2. Constants exported but used only in their own file.** +`SIDEBAR_MIN`/`MAX`/`DEFAULT` (`stores/sidebar.svelte.ts`), +`EXCALIDRAW_ENGINE` (`whiteboard/document.ts`), `EXCALIDRAW_ASSET_PATH` +(`whiteboard/excalidraw.ts`), `buildBodyFontCommands`/`buildThemeCommands` +(`commands.ts`), `assetMatcher` (`update/release-size.ts`). Live code, only +wider than it needs to be. Drop the `export` when the file is next touched. + +## Tests + +**T1. High-churn frontend files with no test of their own**, by commits: +`commands.ts` (10), `components/Sidebar.svelte` (9), +`components/NoteEditor.svelte` (8), `components/CommandPalette.svelte` (6, +522 lines), `stores/theme.svelte.ts` (5). Rule: the next change to one of +these adds its test in the same unit. `commands.ts` and `theme.svelte.ts` +are plain modules and cheap to cover first. + +## Agents + +**A1. The MCP mode is untested on Windows.** Release builds use the Windows +GUI subsystem (`main.rs`), so the binary gets no console of its own. MCP +clients spawn servers with redirected pipes, which a GUI-subsystem process +inherits, so it is expected to work, but nothing has run it there. CI builds +Windows and does not start the MCP mode. +Slot: before Windows leaves preview; one CI step that pipes +`server/discover` into the built binary settles it. + +**A2. A save that loses the version race three times shows "That name is +already in use."** `CONFLICT` is one code for two meanings (a duplicate name, +a stale version), and `src/lib/errors.ts` maps it to the first. The editor +already retries and then keeps the user's text with "Restore theirs", so +this copy only appears in the rare third loss. +Fix: copy chosen by the calling context, not a second error code (API.md §14 +keeps the code set closed). +Slot: with the next change to `errors.ts`. diff --git a/docs/superpowers/specs/2026-07-11-notes-polish-and-feedback-design.md b/docs/superpowers/specs/2026-07-11-notes-polish-and-feedback-design.md new file mode 100644 index 0000000..488dddc --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-notes-polish-and-feedback-design.md @@ -0,0 +1,154 @@ +# InstantNotes: post-0.8 polish + in-app feedback + +Date: 2026-07-11 +Status: approved (decisions locked with owner), building this session + +This spec covers the bug reports and feature asks that accumulated after 0.8.0 +shipped, plus the first cut of an in-app feedback ability. The theme is "under +the hood of a sports car": the app stays effortless to drive (capture, write), +while Settings becomes the one place where the machine is visible and tunable. + +## Owner decisions (locked) + +1. Images added to a note are **copied in by default**, with a Settings toggle + to instead **link the original file path**. Pasted screenshots always copy + (no source file exists to link). +2. When a note is copied as AI context, images are **rewritten to their + absolute path** by default (exposed as a Contexting setting; alternatives are + keep-reference and strip). +3. The phantom-caret fix uses the **clean-state-per-note** approach + (`view.setState`), which also isolates undo history per note. +4. The in-app feedback ability is **fully built** this session, not just spec'd. + +## Work items + +### 1. Phantom caret on note switch (bug) + +Root cause: `` is one persistent CodeMirror view reused across notes +(`+page.svelte`, unkeyed). Switching notes swaps the document with a replace-all +`changes` dispatch that sets no selection and never blurs (`Editor.svelte`). +CodeMirror maps the old cursor to position 0, and the forced `caret-color` on +`.cm-content` (`app.css`) lets WKWebView keep painting a caret in the unfocused +view. The same code path lets undo history bleed across notes. + +Fix: +- Build the extension list once, then load each note with + `view.setState(EditorState.create({ doc, selection: {anchor: 0}, extensions }))`. + State fields reset on `setState`, so re-seed the three dynamic ones + (preview mode, link prefs, attachments base) immediately after. This gives a + clean selection and a fresh, per-note undo history. +- Add `.cm-editor:not(.cm-focused) .cm-content { caret-color: transparent }` + so no caret is ever painted in an editor that does not have focus. This is the + direct cure for a caret appearing in notes the user is only viewing. + +### 2. Ctrl/Cmd-click opens a link but the cursor never becomes a pointer (bug) + +Root cause: `linkMarkClass` only adds `cm-link-clickable` (which carries +`cursor: pointer`) when `preview && openWith === "click"`. In `modclick` mode +the class is never applied, so the link opens on modifier-click but the cursor +gives no feedback. + +Fix: a CodeMirror view plugin tracks whether a Cmd/Ctrl modifier is currently +held (keydown/keyup, reset on blur) and toggles a `cm-mod-held` class on the +editor DOM. Theme rule: `.cm-mod-held .cm-link-target { cursor: pointer }`. +Because modifier-click always opens a link in either mode, this affordance is +always truthful. The Links settings sample mirrors it. Cross-platform: both +`metaKey` and `ctrlKey` are treated as the modifier everywhere (already the +convention); `modKey` from `platform.ts` supplies the correct label. + +### 3. Save timestamp to the minute + settings grouping + +`formatDate` shows time-only today, short-date otherwise, never a full stamp. + +Fix: +- Add `formatExact(iso)` for a full local date and time to the minute. +- The editor status bar and every note-list date get a `title` carrying + `formatExact`, so hovering always reveals the exact save time. +- A new **Editor** settings page (grouped, not a one-off module) hosts a + "Show exact save time" toggle that switches the status-bar stamp to the full + timestamp inline. It also hosts the existing "open formatting toolbar by + default" preference, so related editor settings live together. + +### 4. Images: storage policy, preview controls, and context handling + +Today every image is copied to `$APPDATA/attachments` and referenced as +`attachments/`. There is no user control and no context policy. + +Fix, a new **Images** settings page plus supporting plumbing: +- Storage mode: Copy into InstantNotes (default, portable) or Link the original + file. An "Insert image from file..." action (file dialog) honors the mode: + copy reads the file into attachments; link inserts the absolute path. +- Linked (absolute-path) images render inline through Tauri's asset protocol. + Security: each linked file is added to the asset scope at insert time and on + note open via a narrow Rust command that validates it is an existing image, + rather than broadening the static scope. +- Max preview height becomes a user setting (drives a CSS var the editor theme + reads; default 420px, matching today). +- Attachments location, file count, and total size are shown, with an + "Open attachments folder" button. +- Contexting image handling: `keep-reference | absolute-path | strip`, default + absolute-path. Applied to the note body before the copy template renders. + +### 5. Settings front page: the dashboard + +The Home is currently a three-card nav grid. It becomes a real dashboard: +- Live stats from a new `library_stats` command: notes (total, pinned, + archived, trashed), tags, spaces, attachments (count and size), and capture + readiness (reusing the existing latency summary). +- "What's new in vX": the current version's section parsed from the bundled + `CHANGELOG.md` (imported at build time; the webview CSP blocks external + fetches, so parsing the bundled file keeps it offline and always correct for + the installed build). +- The category nav (About, Editor, Images, Links, Contexting, Feedback) below. + +### 6. In-app feedback (full build) + +Entry points: a Feedback card on the Settings dashboard and a "Send feedback" +command in the palette. + +Form: category (Bug, Idea, Other), a message, and an "include diagnostics" +toggle that attaches app version, platform, and the current stats snapshot (with +a visible preview of exactly what is attached: nothing hidden). + +Delivery (works with no server, no auth): +- The submission is appended to `feedback.jsonl` in the app data directory via a + Rust command, so it is never lost and can be reviewed or exported. +- The user is then handed a prefilled GitHub issue (title and body composed from + the category, message, and opted-in diagnostics), opened through the existing + `open_url` command against the public repo. + +This is deliberately the seed of a larger system; see the extension notes below. + +## Architecture notes + +- New stores follow the existing runes-class + settings-KV pattern + (`imagePrefs`, `feedback`, extended `editorPrefs` and `contexting`). +- New settings pages follow the existing per-page component pattern under + `components/settings/`, routed from `SettingsView`. +- Pure logic (changelog parse, exact-date format, context image rewriting, + absolute-path classification) lives in unit-tested modules, no runes. +- Backend additions match the existing command shape (`locked(&state)`, typed + results, `CmdError` codes) and the shell file-IO validation style. + +## Feedback system: where this goes next (not built this session) + +The first cut delivers via GitHub issue + local log. Future extensions, each +additive and non-breaking: +- A direct submit path (a small ingest endpoint) so users never leave the app, + with the local log as the offline queue that flushes when online. +- Triage tie-in: attach the diagnostics snapshot and recent non-fatal errors + automatically, so a report is actionable without a back-and-forth. +- In-app changelog and "you asked, we shipped" surfacing on the dashboard, so + the feedback loop is visible to the user who reported it. +- These build on the same dashboard stats and the same local log; nothing here + forecloses them. + +## Verification + +- `npm run check` (svelte-check) and `npm test` (vitest) green, including new + unit tests for the pure modules. +- `cargo build` and `cargo test` green for the new commands and store method. +- Manual: switch rapidly between notes and confirm no stray caret; confirm the + pointer appears on links while the modifier is held in modclick mode; confirm + the dashboard stats and changelog render; confirm a feedback submission writes + the log and opens a prefilled issue. diff --git a/openspec/SEQUENCE.md b/openspec/SEQUENCE.md new file mode 100644 index 0000000..21938b1 --- /dev/null +++ b/openspec/SEQUENCE.md @@ -0,0 +1,341 @@ +# Work Sequence + +The order changes land in, and what "landed" means. Individual changes live in +`openspec/changes/`; this file says which one is open, what precedes it, and +what follows. + +No dates. Nothing here is scheduled. A unit is open until it is done, and the +next one does not start before that. + +## Working rule + +**One unit in flight. It lives in the working tree until it is complete.** + +While a unit is open it is uncommitted work. It is not split into partial +commits, not merged half-finished, and not set aside to start something else. +The branch therefore always holds either a releasable state or exactly one +unfinished thing, and never a scatter of half-built features. + +A unit is done when all of these are true: + +- [ ] The feature works end to end. No stubs, no dead branches, no code paths + only reachable by a flag nobody sets +- [ ] Every design decision it depends on is **decided**, not deferred: a + chosen library is chosen, not "currently wired up" +- [ ] `cargo test`, `npm test`, and `npm run check` pass +- [ ] `docs/DATA_MODEL.md` and `docs/API.md` match reality if the unit changed + storage or IPC +- [ ] The changelog carries a user-facing entry +- [ ] The unit's OpenSpec change has every task checked and is moved to + `openspec/changes/archive/` + +Only then does it commit and merge, and only then does the next unit start. + +**Code on a branch is not evidence of any of this.** Nothing in the ten streams +found on `0.9.0-pre` carried a TODO or a stub marker; every one of them read as +finished and none of them was. + +## Insertion rule + +Anything not listed below takes its position from one question: where does it +touch persistence? + +- **Changes the shape of persisted note state, or the API the store is written + against** -> before unit 7. The vault serializer encodes that shape; changing + it afterwards means writing the format twice. +- **Adds persisted state without changing note shape** -> after unit 9. + Units 7 through 9 rewrite the store's write path, and anything built against + the old one gets built again. +- **View only**: theming, editor behavior, palette, layout, list rendering, + settings pages on the existing key/value table -> any slot between units. It + takes a slot of its own; it does not run alongside an open unit. + +--- + +## 0. Branch hygiene (DONE) + +`0.9.0-pre` held ten entangled streams and stated one of them. + +- [x] Linux fix committed (`ffb7f90`). Does **not** make Linux supported; + `[0.7.0]` already ships an AppImage as early preview and CI already builds + `ubuntu-22.04` +- [x] `spec.md`: dropped the two references to the `version` column that + migration v3 removed +- [x] `0decff9` split. Its subject named only the bump-version guard, but it + also removed an editor block the whiteboard merge had duplicated. Only the + bump-version half survives, as `dff86b6` +- [x] **Whiteboard lifted off the branch.** Its six commits were local-only, so + no published history was rewritten and no force-push is needed. Preserved at + the time on `feat/note-whiteboard`, with the pre-lift state on + `backup/0.9.0-pre-with-whiteboard`; both refs are gone now, see + "Safety refs". This removed migration v4, the + `react`/`react-dom`/`@excalidraw` runtime deps, and four defects from the + release. See unit 12. Migration v4 alone came back on 2026-09-22 (see + unit 8): pre-release builds had already applied it to real libraries. + The whiteboard itself returned the same day, defects fixed (unit 12) +- [x] Dependabot deferred to unit 6 + +Verified after the lift: 298 tests, svelte-check 487 files / 0 errors, Rust green. + +## The four 0.9.0 units + +Seven of the ten streams are entangled in `44bea94`, which is **already pushed**. +Splitting it would rewrite published history for no product value, so they are +finished in place, grouped the way the repo already groups changes. + +## 1. `feat-settings-dashboard` + +Streams 2 and 7. **First**, because `SegmentedRow` and `ToggleRow` are consumed +by units 2 and 4; their API is frozen here or it is edited three times. + +## 2. `feat-image-handling` + +Streams 3 and 5. Carries a decision the vault depends on: **link mode versus +portability**. A linked image lives outside the vault by definition. Decided +here, because unit 7 encodes the answer and changing it later migrates user data. + +## 3. `feat-in-app-feedback` + +Stream 6. The only 0.9.0 feature that reaches outside the app. Carries the +question of where the local copy lives and what prunes it. + +## 4. `feat-editor-settings-and-fixes` + +Streams 4 and 8. Smallest. Exists as its own unit mainly because the caret/undo +isolation fix has **no regression test**: the `undo` coverage is workspace +undo, the `caret` coverage is fold behavior. + +Streams 9 (Linux) and 10 (bump-version guard) are finished by every measure +checked; they need a changelog line at release, not a unit. + +## 5. Cut 0.9.0 + +Mechanical once units 1-4 are done. + +- [x] `npm run bump 0.9.0`: all five manifests bumped +- [x] Changelog: date `[0.9.0]`, and correct every entry to what shipped +- [ ] Merge to `main`, tag `v0.9.0` + +**By decision (2026-09-19), unit 7 (vault serializer and export, stage 1 of +`feat-portable-vault-sync`) landed on `0.9.0-pre` ahead of this cut**, out of +the order this file states below. The insertion rule's reasoning for unit +7's position (§"Insertion rule": persisted-shape changes go before it) still +holds; what changed is that it now ships as part of 0.9.0 rather than +waiting for units 5-6 to close first. See `feat-portable-vault-sync/tasks.md` +for what stage 1 actually covers and the drift it records against this file +(migration numbering, the Excalidraw sidecar being inapplicable until unit +12). Units 5-6 below still gate on 1-4, not on unit 7. + +**By decision (2026-09-22), unit 8 (dual-write, stage 2) also landed on +`0.9.0-pre`**, on top of unit 7 and before this cut. See unit 8. + +## 6. Dependency baseline + +Dependabot's three grouped PRs were closed on 2026-09-25 rather than merged, so +the groups regenerate against `main` after `v0.9.0`. Each was addressed on its +own terms: + +- **cargo (#52): superseded.** Both manifest bumps in the group, rusqlite + 0.32->0.40 and window-vibrancy 0.6->0.8, shipped in 0.9.0 as part of + `6197b97`, leaving only `Cargo.lock` churn against a base that had moved. +- **npm (#53): deferred, and stale.** It predates the whiteboard and updater + work and proposed `@tauri-apps/plugin-updater` ^2.11.0 where 0.9.0 already + carries 2.12.0. The majors are the real unit: vite 6->8, vitest 3->4, + typescript 5.6->7.0, vite-plugin-svelte 5->7, jsdom 29->30, jest-dom 6->7, + `@types/node` 25->26. +- **github-actions (#43): deferred, and it gates the release.** Not "CI only", + as this file previously said: `tauri-apps/tauri-action` v0->v1 is a major bump + of the action that builds and publishes the release artifacts, and + `dtolnay/rust-toolchain` 1.91.1->1.100.0 moves the compiler. Also + `actions/checkout` and `actions/setup-node` v4->v7. + +rusqlite 0.32->0.40 was why this unit sat before units 7 through 9: it changes +the API `store.rs` is written against. That bump is in 0.9.0 now, so what is +left here is the npm majors and the workflow actions. Take them as separate +units. + +**Done when** the suite passes on the new toolchain and a release bundle builds +on every supported platform, which is also the only thing that can prove +tauri-action v1 and the newer compiler. + +## 7. Vault serializer and export (DONE, shipped in 0.9.0; see §5) + +`feat-portable-vault-sync` stage 1. Pure addition; SQLite stays authoritative. + +**Done when** exporting the full library and re-parsing it reproduces every +field of every note, and the exported folder reads correctly in a plain Markdown +editor. + +## 8. Dual-write (BUILT, shipped in 0.9.0; see §5) + +Stage 2. The last point at which the vault can be wrong for free. The +verification command is the unit's real output. + +**Done when** it reports zero divergence across a real library after sustained +ordinary use. Not yet met: "Check vault" reports zero divergence on a copy of +the maintainer's library right after a full mirror, which proves the format and +the flush but not sustained use. Close this once the mirror has run on a real +library for a while and Check vault still comes back clean. + +Building it surfaced a schema collision. Pre-release builds carrying the +whiteboard had migrated real libraries to v4 (`content_kind`, `surface_data`), +and `0.9.0-pre`, with the whiteboard lifted off, refused those libraries as +too new. The whiteboard's v4 now ships in 0.9.0 exactly as it was written, +columns only, and the vault is v5, as `design.md` §5 always said. See +`feat-portable-vault-sync/tasks.md`. + +## 9. Filesystem authoritative + +Stage 3. Carries the data-loss risk, which is why units 7 and 8 precede it. + +**Cuts 0.10.0.** Portability with no sync: notes readable without the app, +backup by folder copy, folder-sync tools work by construction. + +## 10. Git transport + +Stage 4. **Cuts 0.11.0.** Split from unit 9 so that if the vault runs long, +portability has already shipped and only sync slips. + +## 11. Attachment garbage collection (DONE, shipped in 0.9.0) + +Landed on `0.9.0-pre` on 2026-09-22, ahead of unit 9, by decision: every +reference already lives in SQLite (note bodies, whiteboard canvases, the +capture draft), so the defect did not need the vault's view to be fixed. +Destroying notes removes the images only they used, and Settings > Images +clears what older versions left behind. See +`changes/archive/feat-attachment-cleanup/tasks.md`. + +## 12. Whiteboard surface (DONE, shipped in 0.9.0) + +Returned from `feat/note-whiteboard` onto `0.9.0-pre` on 2026-09-22, after vault +stages 1 and 2, the order this unit asked for: the canvas file is additive to +a vault format that already existed. The engine is Excalidraw, chosen for its +open file format. The four defects found on the branch (silent canvas loss on +unmount and quit, a dead engine abstraction, a hardcoded dark theme, and +searchable text hidden by convert) are each fixed or designed away; see +`changes/archive/feat-note-whiteboard/tasks.md`. Migration v6 carries boards +through the vault mirror. `feat/note-whiteboard` was deleted on +2026-09-25, after confirming 0.9.0 carries the better implementation: 27 +frontend tests and 4 Rust tests (round trip, the v4 migration, v6 vault +tracking, canvas images), Excalidraw fonts bundled at `predev`/`prebuild`, and +`docs/DATA_MODEL.md` section 3.1 documenting the envelope. The branch's +`ARCHITECTURE.md` described files 0.9.0 deliberately dropped, and its +`registry.ts` was the dead engine abstraction this unit designed away. + +## 13. Graph (DONE, shipped in 0.9.0) + +Consumes note state and adds none: `library_graph` derives notes, tags, Spaces, +and their memberships from the existing tables on every read, and the layout +is computed in the view from positions seeded by id. Nothing is stored, +positions included, so the insertion rule does not move it. Landed on +`0.9.0-pre` on 2026-09-22. Edges are tags and Spaces only, by decision; links +between notes would be a body syntax (and a vault concern), and are not part +of this unit. See `changes/archive/feat-graph-view/tasks.md`. + +## 13a. Update notification (DONE, shipped in 0.9.0) + +`feat-update-notification`. View only: it renders the updater's state and stores +nothing, so the insertion rule puts it in any slot between units; it took its +own. An available update is a synthetic Space rather than a modal - two notes +carrying the version pair, the size delta read from the GitHub release assets, +the install button, and the release notes. Nothing is written to SQLite or the +vault, so the notification cannot be searched, tagged, filed, exported, or left +behind, and it disappears with the update. "Remind me later" is gone by +decision: the notification carries no timer and is answered by updating or +ignored by doing nothing. Landed on `0.9.0-pre` on 2026-09-24. See +`changes/archive/feat-update-notification/tasks.md`. + +## 13b. `chore-boundary-registries` (DONE, shipped in 0.9.0) + +Error codes and event names each get one registry per language, and one test +that holds the two sides equal (`src/lib/api/contract.test.ts`). Adds no +persisted state and does not touch the store API, so the insertion rule puts it +in any slot; it took its own. From `docs/CODE_SMELL_AUDIT.md` (2026-09-25), +phases 0-4 and 6; branded id types (phase 5) and collapsing the two-file IPC +command surface are deliberately not part of it. Landed on `0.9.0-pre` on +2026-09-25. See `changes/archive/chore-boundary-registries/tasks.md`. + +## 13c. Sticky notes (BUILT, open until checked in the app) + +`feat-sticky-notes`. A note pops out of the library into its own window that +floats, sits like any window, or lies on the desktop, and pops back in. Which +notes are stickies and where they sit is per-device window state in the +existing settings table (`stickies`), the same kind of row as a UI preference: +no note shape changes, nothing reaches the vault, and no write path moves, so +it is placed like a settings page on the key/value table, in any slot; it took +its own. A sticky is its note's only editor in the app while it is out; an +agent's write (13d) reaches it the same way it reaches the library's open +note. Built on `0.9.0-pre` on 2026-09-30 with its +tests green; it stays open until the in-app checks in +`changes/feat-sticky-notes/tasks.md` pass, then archives. + +## 13d. `feat-agent-access` (BUILT, open until checked in the app) + +Agents connect over MCP through the app's own binary (`instantnotes mcp`). +The insertion rule places it anywhere: no migration, no change to note shape, +and it reaches the library only through `Store`'s public API, so unit 9's +write-path rewrite sits beneath it rather than under it. The one store API +change is additive (`update_note`'s optional version check). Built in its own +worktree while the sticky-notes unit was in flight, then rebased onto it and +landed on `0.9.0-pre` on 2026-09-30. It precedes unit 14 on purpose: local AI +adds one tool to this surface instead of inventing its own. See +`changes/feat-agent-access/`. + +## 13e. `feat-stickies-import` (BUILT, open until checked in the app) + +Settings > Import, with Apple Stickies as its one source (macOS). The +insertion rule puts new persisted state after unit 9; this adds one settings +key (`import.stickies`), no migration, and no change to note shape, and lands +now because the maintainer asked for it. What unit 9 inherits is kept small on +purpose: `Store::import_notes` writes through `insert_note`, the one insert +path it shares with `create_note`, so moving the write path moves both. Built +in its own worktree while sticky notes (13c) and agent access (13d) were in +flight, then rebased onto both and landed on `0.9.0-pre` on 2026-09-30. See +`changes/feat-stickies-import/`. + +## 13f. `feat-license-gate` (BUILT, open until checked in the app) + +The source license and the EULA as a License Space that every window waits +on until both are agreed. It renders the installers agreement gate +(`agreements.svelte.ts`, generated) instead of the generic `AgreementGate` +view, so the rules and the texts stay owned by `installers` and only the form +is InstantNotes'. No store, vault, or Rust change: agreement is stored per +document version on the device, as the gate's contract says. It follows the +merge of `main` (PR #58) into `0.9.0-pre`, which brought the license itself. +See `changes/feat-license-gate/`. + +## 14. Local AI + +Needs embedding storage, so it cannot precede unit 10. Embeddings are a +rebuildable cache artifact and belong beside `instantnotes.db`, never in the +vault, a distinction the vault establishes. Graph precedes it because a graph +gives local AI somewhere to surface results. + +--- + +## Branch model + +A `-pre` branch collects a release, `feat/*` branches merge into it, the +release merges to `main` and is tagged. One unit per branch, in general: the +vault units past stage 1 should still get their own `-pre` branch rather than +sharing one with unrelated feature work, since that separation is what makes +the working rule enforceable rather than aspirational. Units 7 and 8 (stages 1 +and 2) are the recorded exceptions, landed on `0.9.0-pre` by decision (§5). +Stage 1 is pure addition. Stage 2 adds a second write path, but SQLite stays +authoritative and nothing reads the vault back, so the worst a stage 2 bug can +do is write a wrong file, which Check vault reports. Stages 3 and 4 flip +authority to the filesystem and must get their own `-pre` branch. + +## Safety refs + +Both were deleted on 2026-09-25, once 0.9.0 was confirmed to carry every piece +of work they held: + +- `feat/note-whiteboard`: the pre-release whiteboard work, superseded by unit 12, + which returned it with its four defects fixed. +- `backup/0.9.0-pre-with-whiteboard`: `0.9.0-pre` as it stood before the lift. + Its nine commits are all represented on `0.9.0-pre`: the five whiteboard + commits by unit 12, the Linux fix by `ffb7f90`, the bump-version guard by + `dff86b6`, and the sequence and vault design docs by the files themselves. + +`v0.8.0`, and the `v0.9.0` tag when it is cut, are the rollback points now. diff --git a/openspec/changes/archive/chore-boundary-registries/proposal.md b/openspec/changes/archive/chore-boundary-registries/proposal.md new file mode 100644 index 0000000..cef6b44 --- /dev/null +++ b/openspec/changes/archive/chore-boundary-registries/proposal.md @@ -0,0 +1,44 @@ +# Change: One registry per boundary fact + +## Why + +`docs/CODE_SMELL_AUDIT.md` (2026-09-25) found five smells with one root cause: +several values that cross the Rust↔TypeScript boundary had no authoritative +definition. Error codes were written in five places across three languages and +had already drifted — eight Rust sites hand-rolled `"VALIDATION"`, which is not +a code the app has ever recognised. Twelve event names existed only as bare +literals on both sides, so renaming either side would have dropped a listener +in silence, with nothing failing. + +The audit's own measurement of that drift was that it was not caused by +carelessness: there were two ways to build a command error and only one of them +was correct. The fix is therefore to leave one way. + +## What Changes + +- Rust: `src-tauri/src/error.rs` owns `ErrorCode` (an enum, serialized as its + API string) and `CmdError`, whose `code` field is private. A command error is + built through `From`, `CmdError::storage`, or + `CmdError::validation` — the 40 literal `code: "…"` sites are gone, and the + eight `"VALIDATION"` among them cannot be written again. +- Rust: `src-tauri/src/events.rs` declares every event name the shell emits. +- Frontend: `src/lib/api/error-codes.ts` and `src/lib/api/events.ts` are the + mirrors. `ApiError.code`, `AppErrorPayload.code`, and `friendlyMessage` take + `ErrorCode`; `MESSAGES` is an exhaustive `Record`, so a new + code does not compile until it has user-facing copy. +- `src/lib/api/contract.test.ts` holds the four registries, `docs/API.md` §14, + and the core's `AppError::code()` equal, and fails if any code or event name + is written as a literal again. +- `FeedbackInput.category` is the `FeedbackCategory` union that already existed. + +## Non-goals + +- Branded `NoteId`/`TagId`/`WorkspaceId` types (the audit's phase 5). Largest + churn in the codebase, no live defect behind it; deliberately deferred. +- The two-file IPC command surface (`client.ts` wrappers + `lib.rs` registration). + Collapsing it needs codegen (`specta`/`ts-rs`) or a macro, which is a + dependency decision, not a cleanup. +- Any behaviour change. Every error keeps the code and message it had, except + the eight that said `"VALIDATION"` and now say `VALIDATION_ERROR`. +- `Tag.color` typing, and the settings keys (already consts; the literals are + in tests, which legitimately pin the wire key). diff --git a/openspec/changes/archive/chore-boundary-registries/tasks.md b/openspec/changes/archive/chore-boundary-registries/tasks.md new file mode 100644 index 0000000..02c5ecb --- /dev/null +++ b/openspec/changes/archive/chore-boundary-registries/tasks.md @@ -0,0 +1,59 @@ +# Tasks: One registry per boundary fact + +From `docs/CODE_SMELL_AUDIT.md`, phases 0–4 and 6. Landed on `0.9.0-pre` on +2026-09-25. View-only by the insertion rule: no persisted state, no store API +change, so it takes a slot of its own between units. + +## Decide first + +- [x] **Where the authority sits.** The core's `AppError::code()` keeps the + strings; the shell's `ErrorCode` and the frontend's `ERROR_CODES` are + mirrors, asserted equal. The core cannot depend on the shell crate, so a + single Rust declaration was not available. +- [x] **How parity is enforced.** One vitest test reading the Rust source, not + a generated JSON artifact: no build step, and it cannot pass against a + stale file. Codegen (`specta`/`ts-rs`) was considered and is not + warranted at this scale. +- [x] **What "unwritable" means for `CmdError`.** `code` is private and + `From` matches the variants exhaustively, so a new `AppError` + variant fails to compile until the shell decides its code. + +## Build + +- [x] `src-tauri/src/error.rs`: `ErrorCode` (+ `Serialize` as its API string), + `CmdError` with a private code and `storage`/`validation` constructors, + `CmdResult`, and the `From` exhaustive match. Tests: every core + code is a known code and agrees with the match; a validation error + serializes as `VALIDATION_ERROR`; every code round-trips. +- [x] 40 hand-rolled `code: "…"` sites replaced across `lib.rs`, `shell/files.rs`, + `shell/capture.rs`, `shell/mirror.rs`, `commands/feedback.rs`, and + `commands/vault.rs` (whose local `storage_error` helper is now redundant). + Eight of them said `"VALIDATION"`. +- [x] `src-tauri/src/events.rs`: all 12 event names; every `emit` call uses one. +- [x] `src/lib/api/error-codes.ts`, `src/lib/api/events.ts` (with + `LIBRARY_CHANGED_EVENTS`, which the store and the graph both listen to). +- [x] `client.ts`: one `asApiError` for both catch sites; an unrecognised code + becomes `STORAGE_ERROR` with the original code kept in the message. +- [x] `errors.ts`: `Record` and `GENERIC_MESSAGE` for the + four callers that asked for generic copy by passing `""`. +- [x] Every `listen` call and the store test's listener assertions use `EVENTS`. +- [x] `FeedbackInput.category: FeedbackCategory`; `AppErrorPayload.code: ErrorCode`. +- [x] `src/lib/api/contract.test.ts`: codes match the core, the Rust enum, and + API.md §14; event names match the Rust list exactly; no `code:` or + `emit(` literal in `src-tauri/src`, no `listen(` literal in `src`. + Verified by mutation — renaming one side's `vault:status` fails it. +- [x] Docs: `API.md` §1, §3, §9, §14 name the registries and the parity test; + `CHANGELOG.md` under 0.9.0. + +## Found along the way + +- [x] The audit called `"VALIDATION"` a live bug on the grounds that + `friendlyMessage` would miss it. It is a latent one: `friendlyMessage` is + only called by the library store and the sidebar, whose errors all come + through `From` with correct codes. The attachment, image, and + feedback paths that said `"VALIDATION"` surface `e.message` directly, so + users saw the same sentence before and after. Recorded in + `docs/CODE_SMELL_AUDIT.md`. +- [x] `GraphView.test.ts` asserted the empty-note line without its sentence + break, so it failed against the shipped copy. Fixed with the test's own + regex, not by changing the copy. diff --git a/openspec/changes/archive/feat-attachment-cleanup/proposal.md b/openspec/changes/archive/feat-attachment-cleanup/proposal.md new file mode 100644 index 0000000..fe60766 --- /dev/null +++ b/openspec/changes/archive/feat-attachment-cleanup/proposal.md @@ -0,0 +1,27 @@ +# Change: Image Cleanup + +## Why + +Pasted, dropped, and copied-in images are files under `/attachments`, +referenced from notes as `attachments/`. Nothing ever removed one: a note +deleted for good left its images behind, and the folder only grew. The live +vault copies that folder, so the leftovers showed up there too +(`feat-portable-vault-sync/design.md` §14, open question 4). + +## What Changes + +- Deleting notes for good also removes the copied-in images only they + referenced, and the vault's copy of each when it is unchanged. +- Settings > Images reports how many stored images no note uses, and removes + them on request, for what older versions left behind. +- An image is kept while anything references it: any note in any state, + including the Trash and the Archive, a whiteboard's canvas, or the capture + draft. + +## Non-goals + +- Linked originals (the "Link original" storage mode). They are the user's + files, not the app's, and are never touched. +- Removing images the moment a reference is deleted from a note's text. Undo + in the editor can bring the reference back, so only a permanent delete, or + the explicit Settings cleanup, removes a file. diff --git a/openspec/changes/archive/feat-attachment-cleanup/tasks.md b/openspec/changes/archive/feat-attachment-cleanup/tasks.md new file mode 100644 index 0000000..2c1d461 --- /dev/null +++ b/openspec/changes/archive/feat-attachment-cleanup/tasks.md @@ -0,0 +1,49 @@ +# Tasks: Image Cleanup + +SEQUENCE.md unit 11. Landed on `0.9.0-pre` on 2026-09-22. + +## Decide first + +- [x] When to remove. **At permanent delete, plus an explicit sweep**, never + on a timer or at launch. A permanent delete is the moment the user has + said "gone for good", and it has no undo. A background sweep would race + the editor's own undo, which can restore a reference the user just + deleted. The sweep for older leftovers is a button in Settings > Images + that confirms first. +- [x] Before unit 9, not after. The sequence placed this after the vault flips + authority "because every reference and every file is visible in one + place". Every reference is already in SQLite: note bodies, whiteboard + canvases, and the capture draft setting. Nothing about the fix needed + the vault. +- [x] What counts as a reference. Any `attachments/` in a note's body or + a whiteboard's canvas, or in the capture draft; matched without regard + to case, because the disks attachments live on usually ignore it. Wider + than image syntax on purpose: a false match keeps a file, a missed one + would lose it. + +## Safety + +- [x] The store stays locked from the reference check to the file removal, so + no save can start using an image mid-cleanup + (`Store::remove_unreferenced_attachments` re-checks every candidate + itself rather than trusting the caller) +- [x] Only plain file names are ever removed: no separators, no `.` or `..` +- [x] The vault's copy goes only while it holds the same bytes; a copy changed + outside the app stays +- [x] The sweep skips images added in the last hour: a fresh paste can belong to + an edit that has not saved yet +- [x] A cleanup failure never fails the delete that triggered it + +## Build + +- [x] Core: `attachments.rs` (finding references, removing one file, listing + the folder) and `store/attachments.rs` (what is still referenced; + checked removal). `core/tests/attachments_test.rs`, 15 tests, real + SQLite and real folders +- [x] Shell: `permanently_delete_note` and `destroy_notes` clean up under the + store lock; `unused_attachments` and `remove_unused_attachments` back + the sweep +- [x] Settings > Images: an "Unused" line in the Stored images card and a + "Remove unused…" button that confirms first + (`SettingsImages.test.ts`) +- [x] Docs: `API.md` §8, `CHANGELOG.md`, `spec.md` (Image Cleanup) diff --git a/openspec/changes/archive/feat-editor-settings-and-fixes/proposal.md b/openspec/changes/archive/feat-editor-settings-and-fixes/proposal.md new file mode 100644 index 0000000..764d5b8 --- /dev/null +++ b/openspec/changes/archive/feat-editor-settings-and-fixes/proposal.md @@ -0,0 +1,45 @@ +# Change: Editor Settings and Editor State Fixes + +## Why + +Two small things that belong together because they touch the same files: a +settings page for editor preferences, and a fix for editor state leaking between +notes. + +The code is written and works. This change is the remaining distance to done. + +## What Changes + +Already built on `0.9.0-pre` (`44bea94`): + +- An Editor settings page with "Show exact save time"; the exact time is always + available on hover over any note's date. `format.test.ts` covers the + formatting (9 tests) +- Each note now loads with its own clean editing state, so a stray caret no + longer lingers when switching notes and undo no longer reaches back into the + previously open note +- Links set to open with Cmd/Ctrl+Click show the pointer cursor while the + modifier is held. `links.test.ts` (16 tests) + +## Remaining to done + +- **The caret/undo isolation fix has no regression test.** The `undo` coverage + in `library.svelte.test.ts` is workspace-delete undo, and the `caret` coverage + in `kernel.test.ts` is fold behavior. Neither exercises per-note editor state +- `SettingsEditor.svelte` (57 lines) is untested, though it is one toggle + +## Non-goals + +- No further editor preferences beyond the shipped toggle +- No changes to the fold/preview kernel + +## Sequencing + +Smallest of the four 0.9.0 units. Depends on the settings primitives frozen in +`feat-settings-dashboard`. + +## Impact + +The state-isolation fix protects the "Trust is release" promise directly: undo +reaching into a previous note is a way to lose writing that the user believed +was parked. diff --git a/openspec/changes/archive/feat-editor-settings-and-fixes/tasks.md b/openspec/changes/archive/feat-editor-settings-and-fixes/tasks.md new file mode 100644 index 0000000..38907a6 --- /dev/null +++ b/openspec/changes/archive/feat-editor-settings-and-fixes/tasks.md @@ -0,0 +1,67 @@ +# Tasks: Editor Settings and Editor State Fixes + +Streams 4 and 8 of the ten identified on `0.9.0-pre`. + +## The missing regression test + +The reason this unit exists rather than being folded into another. + +- [x] Add a test proving editor state does not leak between notes: open note A, + type, switch to note B, undo, and assert B's content is untouched and A's + text is not reachable. `Editor.test.ts`. Mounts the real component with a + real `EditorView` in jsdom, recovers the view with + `EditorView.findFromDOM` (the component deliberately exposes no view of + its own), and switches notes via `rerender` on the *same* instance — a + fresh `render()` per note would mount a new view and trivially pass + regardless of whether the fix works. Verified the test actually catches + the regression: temporarily reverted `loadDoc` to reuse state across + notes (`view.dispatch` instead of `view.setState`) and confirmed this + test fails against it, then restored the real fix +- [x] Add a test proving the caret does not carry over from the previously open + note. Same file, second test + +## Editor settings page + +- [x] Test `SettingsEditor.svelte`: the toggle persists and re-reads. + `SettingsEditor.test.ts` (4 tests) covers the page; `editorPrefs`'s + `init()`/persistence logic is covered separately in + `stores/editor.svelte.test.ts` (6 tests), matching the split used for + `imagePrefs` in `feat-image-handling` (mounting a component can't drive + a `#loaded`-guarded singleton's read path without breaking Svelte's + effect context under `resetModules`). Writing that store test surfaced a + real bug: a setter called while `init()`'s settings read was still in + flight got silently overwritten when the read resolved afterward. Fixed + with a per-field `#touched` guard in `editor.svelte.ts`, applied to all + three setters (`setShowExactTime`, `setZoom`, `toggleToolbar`) since + they share the identical hazard. The same unguarded pattern exists in + `imagePrefs` and `linkPrefs`, both outside this unit's scope; not + touched here since those units are already archived +- [x] Confirm the hover-for-exact-time path works regardless of the toggle — + the toggle controls the always-visible form only. Confirmed by reading + `NoteEditor.svelte:177-179`: the `title` attribute (native hover + tooltip) always calls `formatExact()` unconditionally; only the inline + visible text branches on `editorPrefs.showExactTime`. No gap found + +## Links + +- [x] Confirm the modifier-held pointer cursor across the three link open + modes. Mode 1 (preview + "click") was already covered by + `linkMarkClass`'s existing tests (`cm-link-clickable` applied + unconditionally). Modes 2 (preview + "modclick") and 3 (edit mode, any + `openWith`) both fall back to `cm-mod-held` while the modifier is held — + also already provably correct via the existing `linkMarkClass` tests + (neither mode gets `cm-link-clickable`) plus the CSS pairing in + `editor/theme.ts`, but `ModKeyCursor`, the class itself, had no test. + Added four to `links.test.ts`: toggles on Cmd or Ctrl and clears on + release, clears on window blur, and clears on `destroy()` so a stale + modifier state can't leak into the next note + +## Contract and record + +- [x] `docs/API.md`: no new commands expected — confirmed: this unit touched + no Rust files, `src-tauri/src/lib.rs`'s command list is unchanged +- [x] `CHANGELOG.md`: entries for the Editor page and both fixes — already + present under `[0.9.0]` from `44bea94` +- [x] `cargo test`, `npm test`, `npm run check` pass — 92 Rust tests, + 366 frontend tests (up from 350), 0 type errors across 496 files +- [x] Move this change to `openspec/changes/archive/` diff --git a/openspec/changes/archive/feat-graph-view/proposal.md b/openspec/changes/archive/feat-graph-view/proposal.md new file mode 100644 index 0000000..3c8c4a5 --- /dev/null +++ b/openspec/changes/archive/feat-graph-view/proposal.md @@ -0,0 +1,27 @@ +# Change: Graph View + +## Why + +The staged plan runs notes, then graph and structure, then local AI (SEQUENCE.md +units 13 and 14). With the notes foundation solid, the library should be +visible as a whole: which notes share which tags and Spaces, and where the +clusters are. A graph is also where local AI results will have somewhere to +surface later. + +## What Changes + +- A Graph place in the sidebar, next to All Notes and Revisit, that fills the + list and editor area. +- Nodes are live notes, tags, and Spaces; each note links to the tags and + Spaces it carries. Hovering or focusing a node lights its neighborhood. +- Choosing a note opens it, a tag filters by it, and a Space opens it. +- It opens framed around the open note's neighborhood; "Show all" fits the + whole library. Pan by dragging, zoom with the wheel or a pinch. + +## Non-goals + +- Links between notes. That is a body syntax, with its own editor, rename, + and vault questions, and was decided out of this change (2026-09-22). +- Storing anything: no edge table, no saved positions. +- Trashed and archived notes, which stay out of view here as everywhere else by + default. diff --git a/openspec/changes/archive/feat-graph-view/tasks.md b/openspec/changes/archive/feat-graph-view/tasks.md new file mode 100644 index 0000000..649856d --- /dev/null +++ b/openspec/changes/archive/feat-graph-view/tasks.md @@ -0,0 +1,35 @@ +# Tasks: Graph View + +SEQUENCE.md unit 13. Landed on `0.9.0-pre` on 2026-09-22. + +## Decide first + +- [x] Edges. **Tags and Spaces only** (decided 2026-09-22). Notes cannot link + to each other yet; adding `[[links]]` was offered and not chosen. +- [x] Place. **A Graph entry in the sidebar** that takes the list and editor + columns, not a panel beside the note (decided 2026-09-22). +- [x] Derived, not stored. `Store::library_graph` reads the existing tables + each time; the layout lives in the view. The sequence's condition for + unit 13 staying where it is. + +## Build + +- [x] Core: `library_graph` (`store/graph.rs`), live notes only, every tag + and Space, one link per membership (`core/tests/graph_test.rs`) +- [x] Layout: `src/lib/graph/layout.ts`. A d3-force layout seeded by node id + with a seeded jitter source, so the same library draws the same way + every visit. A refresh starts from the previous positions and runs cool, + so adding a note barely moves the rest (under 4 px on average, tested). +- [x] Settling in frames. Measured: 300 ticks take about 1 s at 1,000 notes + and 3.6 s at 3,000, so the view runs the layout in 12 ms slices across + animation frames instead of blocking on it +- [x] View: `GraphView.svelte`. Nodes are buttons (keyboard reachable, named + by kind and label); hubs are sized by how many notes they gather and + always labeled; note titles show when zoomed in or lit. Boards draw as + squares. Live refresh on note, tag, and Space changes, debounced +- [x] Store: `graphMode`, entered with `selectGraph` and left by every other + place and by opening a note; the list's arrow keys stand down while + the list is hidden +- [x] Entry points: sidebar Graph, palette "Show graph" +- [x] Docs: `API.md`, `CHANGELOG.md`, `spec.md` (Graph View, and the sidebar + in Library Organization) diff --git a/openspec/changes/archive/feat-image-handling/proposal.md b/openspec/changes/archive/feat-image-handling/proposal.md new file mode 100644 index 0000000..4037e1f --- /dev/null +++ b/openspec/changes/archive/feat-image-handling/proposal.md @@ -0,0 +1,57 @@ +# Change: Image Handling + +## Why + +0.8.0 let a user paste or drop an image into a note. That left three things +unanswered: where the file lives, how big it renders, and what happens to it +when the note is copied out as context. This change answers all three. + +The code is written and works. This change is the remaining distance to done. + +## What Changes + +Already built on `0.9.0-pre` (`44bea94`): + +- An Images settings page: copy-into-app vs link-in-place, preview height, and + a button revealing the attachments folder +- Insert an image from a file via the editor toolbar, alongside paste and drop +- Four IPC commands: `import_image_file`, `allow_image_file`, + `open_attachments_folder`, plus `attachments_stats` feeding the dashboard +- Contexting rewrites images to absolute paths (new default), leaves them as + written, or drops them, when copying a note as context +- `editor/images.test.ts` (15 tests) and `contexting-format.test.ts` (12 tests) + +## Remaining to done + +- `SettingsImages.svelte` is 210 lines with no test +- `import_image_file`, `allow_image_file`, and `open_attachments_folder` are + undocumented in `docs/API.md` +- **One open decision, below** + +## The open decision: "link" mode versus a portable vault + +Link mode leaves the image at its original path and references it from the note. +`feat-portable-vault-sync` guarantees the vault carries the complete saved state +to any device. A linked image is outside the vault by definition, so a note that +uses one is not portable, and on a second device the reference is broken. + +Three ways out, to be chosen in this unit: + +1. **Drop link mode.** Always copy. Simplest, and the guarantee holds +2. **Copy on vault export.** Link mode stays a local convenience; the vault + always materializes the bytes. The note body then differs from the vault copy +3. **Accept the hole.** Link mode is documented as opting a note out of + portability + +Chosen here rather than during the vault work, because the vault serializer +encodes the answer and changing it afterwards migrates user data. + +## Non-goals + +- No image editing, cropping, or compression +- No attachment garbage collection — that is a separate unit after the vault + +## Impact + +Settles how images are stored before the vault serializes them. Whichever way +the link decision goes, it is made once and recorded. diff --git a/openspec/changes/archive/feat-image-handling/tasks.md b/openspec/changes/archive/feat-image-handling/tasks.md new file mode 100644 index 0000000..f7ed387 --- /dev/null +++ b/openspec/changes/archive/feat-image-handling/tasks.md @@ -0,0 +1,94 @@ +# Tasks: Image Handling + +Streams 3 and 5 of the ten identified on `0.9.0-pre`. + +## Decide first + +- [x] Resolve link mode versus vault portability (proposal, three options). + Record the choice and the reasoning in this file — `feat-portable-vault-sync` + reads it +- [x] If link mode survives, decide what a note does on a device where the + linked path does not exist + +### The decision: option 2, copy on vault flush + +Option 3 is rejected outright: it breaks the vault's own requirement that the +complete saved state reproduce exactly on any device +(`feat-portable-vault-sync/design.md:9-19`). Option 1 is rejected because it +removes a shipped feature — `allow_image_file` exists precisely so a note can +reference an existing external image library without InstantNotes duplicating +it into app data, and nothing about the vault requires giving that up. + +Link mode survives as a live-editing convenience. What changes is the vault +boundary, not the setting: `allow_image_file` keeps referencing the original +absolute path for as long as a note is edited on the device that linked it — +today's behavior, unaffected. `flush_vault()` (`design.md:225-236`) is the +choke point that resolves the conflict: when it serializes a note whose body +references a path outside the vault, it copies the bytes into +`/attachments/`, rewrites that one reference to `attachments/` in +the file it writes, and leaves the live SQLite body untouched. The vault copy +and the live copy diverge by design (`design.md:34` already anticipates this: +"the note body then differs from the vault copy"), and only the vault copy +ever leaves the device. + +This answers the second question: a note with a linked image never reaches a +second device with a link at all, because the vault file synced to that device +already carries the materialized `attachments/` reference the flush step +wrote. There is no broken-path case to design for on the receiving device. The +only place a stale link can appear is the *authoring* device, if the user +relinks or deletes the source file between edits — the existing behavior for +that (the asset protocol simply fails to load it) is unchanged and is a +pre-existing editor concern, not a vault one. + +Recorded for `feat-portable-vault-sync`: the serializer (design.md §3.2, §6) +needs one additional rule — on flush, detect body image references that are +absolute paths outside the vault, copy those bytes into `attachments/` +alongside the pasted/copied-in ones, and rewrite the reference in the +serialized output only. No schema change; this is serializer logic, not a new +column. + +## Images settings page + +- [x] Test `SettingsImages.svelte` (210 lines, currently untested): storage + mode switch, preview-height bounds (120-900, default 420), and the + attachments folder button. `settings/SettingsImages.test.ts` (7 tests) + covers the page; the `init()`/persistence logic it renders on top of is + covered separately in `stores/images.svelte.test.ts` (9 tests), since + that path can't be driven through a mounted component without breaking + Svelte's effect context (see that file's header comment) +- [x] Confirm behavior when the attachments directory is missing or unreadable. + `attachments_stats` (`shell/files.rs:241`) already returns `(0, 0)` on a + missing or unreadable directory rather than failing; no gap found +- [x] Confirm an unsupported file type is rejected with a legible message. + `image_ext` (`shell/files.rs:143`) returns a `VALIDATION` error naming the + rejected extension, shared by both `import_image_file` and + `allow_image_file`; no gap found + +## Insert from file + +- [x] Confirm the toolbar path produces the same `attachments/` reference + as paste and drop. It called a hand-written `` `![](attachments/${name})` `` + that happened to match; `NoteEditor.svelte` now calls the same + `attachmentMarkdown()` helper `editor/images.ts` uses for paste and drop, + so the two paths are one path rather than two matching ones +- [x] Confirm cancelling the file dialog is a no-op. `insertImage()` + (`NoteEditor.svelte`) returns before calling either command when the + dialog does not resolve to a string path; no gap found + +## Contexting + +- [x] Confirm all three image modes against a note with a linked image, a copied + image, and both. Added to `contexting-format.test.ts`: a linked image + (an absolute path, not an `attachments/` reference) passes through + unchanged in `keep` and `absolute`, is removed in `strip` same as a + copied one, and a note carrying both resolves each independently + +## Contract and record + +- [x] `docs/API.md`: document `import_image_file`, `allow_image_file`, + `open_attachments_folder` +- [x] `CHANGELOG.md`: entries for the Images page, insert-from-file, and the + contexting modes — already present under `[0.9.0]` from `44bea94` +- [x] `cargo test`, `npm test`, `npm run check` pass — 92 Rust tests, + 339 frontend tests (up from 318), 0 type errors across 492 files +- [x] Move this change to `openspec/changes/archive/` diff --git a/openspec/changes/archive/feat-in-app-feedback/proposal.md b/openspec/changes/archive/feat-in-app-feedback/proposal.md new file mode 100644 index 0000000..6279afe --- /dev/null +++ b/openspec/changes/archive/feat-in-app-feedback/proposal.md @@ -0,0 +1,43 @@ +# Change: In-App Feedback + +## Why + +Reporting a bug meant leaving the app and writing an issue by hand. The Feedback +page files a bug or an idea from inside InstantNotes, keeps a copy on the user's +machine, and opens a prefilled GitHub issue. + +The code is written and works. This change is the remaining distance to done. + +## What Changes + +Already built on `0.9.0-pre` (`44bea94`): + +- `SettingsFeedback.svelte` — category (bug or idea), body, submit +- `submit_feedback` IPC over `commands/feedback.rs`, appending a local copy +- The GitHub hand-off runs on the frontend through `open_url`, so the Rust side + never talks to the network +- `feedback.test.ts` (4 tests) + +## Remaining to done + +- `SettingsFeedback.svelte` is 185 lines with no test +- `submit_feedback` is undocumented in `docs/API.md` +- Two open questions, below + +## Open questions + +1. **Where the local copy lives, and for how long.** Nothing prunes it today. + It grows without bound and nothing surfaces it to the user +2. **Whether note content can reach it.** `openspec/project.md` constrains note + content from logs and diagnostics. A user pasting note text into a feedback + body is their choice; the app must not add any itself + +## Non-goals + +- No telemetry, no automatic diagnostics, no crash reporting +- No in-app issue browsing or status tracking + +## Impact + +The only feature in 0.9.0 that reaches outside the app. It stays a user-initiated +hand-off to the browser, never a background transmission. diff --git a/openspec/changes/archive/feat-in-app-feedback/tasks.md b/openspec/changes/archive/feat-in-app-feedback/tasks.md new file mode 100644 index 0000000..1231a63 --- /dev/null +++ b/openspec/changes/archive/feat-in-app-feedback/tasks.md @@ -0,0 +1,69 @@ +# Tasks: In-App Feedback + +Stream 6 of the ten identified on `0.9.0-pre`. + +## Decide first + +- [x] Where the local feedback copy lives, and whether anything prunes it +- [x] Confirm no note content is added automatically — the Privacy By Default + requirement in `openspec/specs/instantnotes/spec.md` applies + +### The decision: `/feedback.jsonl`, appended forever, revealed not pruned + +Location is unchanged: `/feedback.jsonl`, one JSON object per line, +append-only. That part was already right. + +No pruning. Each line is a category, a trimmed message, and four short +diagnostics fields — bytes, not megabytes. A thousand submissions is still +under a few hundred KB, and nothing about this app's usage pattern produces +thousands of feedback submissions. Pruning would be solving a problem this +file cannot reach at any realistic scale. (Attachments needed exactly the +opposite answer in `feat-image-handling` because images are not bytes, they +are megabytes; the two aren't analogous.) + +The real gap was the other half of "grows without bound and nothing surfaces +it to the user" — not the growth, the invisibility. `SettingsFeedback.svelte` +had no way to see what had accumulated, unlike the Images page's "Open +attachments folder". Added `open_feedback_log` (`commands/feedback.rs`), +mirroring `open_attachments_folder`: reveals the file in the OS file manager +rather than opening it, since it's meant to be located, not edited, and +creates an empty file first if none exists yet so there is always something to +reveal. A "Reveal saved feedback" button and a line stating the file is never +pruned automatically now sit next to Send. + +Privacy: confirmed by reading `FeedbackDiagnostics` +(`src/lib/feedback.ts`) — exactly `appVersion`, `platform`, `notes` (a count), +`attachments` (a count). No note title, body, tag, or Space name is in reach +of this code path. What a user pastes into the message box by hand is their +own choice, per the proposal; the app adds nothing on top of it. No gap found. + +## The page + +- [x] Test `SettingsFeedback.svelte` (185 lines, currently untested): category + switch, empty-body rejection, submit success, submit failure. + `SettingsFeedback.test.ts` (8 tests) +- [x] Confirm submitting works when the GitHub hand-off fails or no browser + opens — the local copy must still be written. It was already written + first, but a GitHub-only failure surfaced as "Couldn't send feedback", + which is wrong: the feedback *was* sent, only the browser hand-off + failed. `send()` now treats the two failures separately — a local-save + failure keeps the draft and reports the error; a hand-off-only failure + still clears the draft and reports "Saved locally, but couldn't open + GitHub automatically." Covered by two tests in `SettingsFeedback.test.ts` +- [x] Confirm a very long body does not break the prefilled URL. It wasn't + confirmed, it was false: only the title was capped (`.slice(0, 60)`), + the body had no limit, and `open_url` hands the result to the OS shell, + whose length tolerance is tighter than a browser address bar in some + environments. `githubIssueUrl` now truncates the message past 1500 + characters with a note that the full text is saved locally; the + diagnostics block is never truncated, since it's always short. Covered + by three tests in `feedback.test.ts` + +## Contract and record + +- [x] `docs/API.md`: document `submit_feedback` and its `FeedbackInput` shape, + and `open_feedback_log` +- [x] `CHANGELOG.md`: entry for the Feedback page +- [x] `cargo test`, `npm test`, `npm run check` pass — 92 Rust tests, + 350 frontend tests (up from 339), 0 type errors across 493 files +- [x] Move this change to `openspec/changes/archive/` diff --git a/openspec/changes/archive/feat-note-whiteboard/proposal.md b/openspec/changes/archive/feat-note-whiteboard/proposal.md new file mode 100644 index 0000000..77b9b40 --- /dev/null +++ b/openspec/changes/archive/feat-note-whiteboard/proposal.md @@ -0,0 +1,45 @@ +# Change: Whiteboard Notes + +## Why + +Planning and systems thinking often need a spatial canvas: boxes, arrows, +frames, freehand. InstantNotes should support that without becoming a separate +diagrams app or adding a new top-level destination. The unit stays a note: +a note can become a whiteboard, so capture, the library, tags, Spaces, search, +and the vault all keep working on it. + +Pre-release builds carried a first version on `feat/note-whiteboard`, and it +was lifted off `0.9.0-pre` with four recorded defects (SEQUENCE.md unit 12). +This change brings it back with those defects fixed and the canvas made +portable through the vault. + +## What Changes + +- Notes carry a `contentKind` of `document` (default) or `whiteboard`; a + board's canvas lives in `surfaceData` as a versioned Excalidraw envelope. +- The canvas engine is Excalidraw, loaded on first use only. +- A board's `body` is the text written on it, in reading order, rewritten on + every save. Search, inline `#tags`, list previews, and the vault's Markdown + file all see what the board shows. +- Converting a document is one-way and confirms first. Its text moves onto the + board as a text block, so nothing written disappears. "New whiteboard" + creates one directly (palette, File menu, `Cmd+Shift+N`). +- Board edits go through the same save queue as body edits: debounced, + retried once, flushed on note switch, trash, and quit. +- The vault writes each board's canvas as a standard `.excalidraw` file + beside its note file, under the mirror's ownership rules. Export Note writes + a board as `.excalidraw`. + +## Non-goals + +- Converting a whiteboard back into a document. +- Multiplayer, or reading edited `.excalidraw` files back from the vault + (that is vault stage 3). +- Excalidraw's own file menu, image export, and online features: a board is a + note, and saves and exports as one. + +## Impact + +Notes stay the canonical unit. Schema v4 (the pre-release columns) is carried +unchanged; v6 adds the canvas hash the vault mirror needs. No existing note +changes shape. diff --git a/openspec/changes/archive/feat-note-whiteboard/tasks.md b/openspec/changes/archive/feat-note-whiteboard/tasks.md new file mode 100644 index 0000000..1bfa5cd --- /dev/null +++ b/openspec/changes/archive/feat-note-whiteboard/tasks.md @@ -0,0 +1,80 @@ +# Tasks: Whiteboard Notes + +SEQUENCE.md unit 12. Landed on `0.9.0-pre` by decision (2026-09-22), after +vault stages 1 and 2, which is the order unit 12 asked for: the canvas file is +additive to a vault format that already exists. + +## Decide first + +- [x] The engine: **Excalidraw** (`@excalidraw/excalidraw` 0.18, React 18). The + pre-release branch wired Svelte Flow, then swapped to Excalidraw without + choosing. Excalidraw wins on the thing that matters most here: its file + format is an open, documented standard that other tools read, so the + vault's canvas file is useful outside InstantNotes. Svelte Flow has no + file format of its own; its nodes would be ours to define and nobody + else's to open. React 18, not 19: Excalidraw's Radix UI dependencies + declare React 18 at most. +- [x] What a board's `body` is. **The text on the board**, in reading order + (`boardText`, `src/lib/whiteboard/document.ts`), written with every save. + Search, inline tags, previews, and the vault's `.md` then describe what + the board shows. This designs away defect 4 below instead of patching + the dialog. +- [x] Whether a board's title follows its body. **No**: converting freezes an + auto title (`update_note`, `store/notes.rs`), since the body is rewritten + whenever a shape moves. + +## The four defects from the pre-release branch + +- [x] **1. Silent canvas data loss.** The old canvas cleared its save timer on + unmount without firing it, and quit never asked it for its edit. Board + edits now go through the library's save queue like body edits (the queue + holds `{ body }` or `{ surfaceData, body }`), and the canvas registers an + `onBeforeFlush` hook that the library runs before every flush, trash, and + note switch. Unmounting hands the waiting edit over too. Pinned by + `WhiteboardCanvas.test.ts` ("hands a waiting edit over when the board + closes", mutation-checked) and the store's whiteboard tests. +- [x] **2. Dead engine abstraction.** `registry.ts` and the adapter interfaces + are not carried over. One engine, used directly. +- [x] **3. Theme ignored.** The board follows the app's effective light or dark + variant, live, without remounting (`NoteEditor.svelte`, + `effectiveVariant`). +- [x] **4. Convert hid searchable text.** Converting now lays the note's text + onto the board as a text element (`boardFromText`), and the body is the + board's text from then on, so every search hit is visible on the board. + The dialog says so. + +## Build + +- [x] Core: `content_kind` and `surface_data` on `Note` and `UpdateNotePatch`; + one-way convert and "only a board holds a canvas" enforced in + `update_note`; list rows leave the canvas out, and the `update_note` IPC + reply drops it, since a board can hold pasted images + (`core/tests/whiteboard_test.rs`) +- [x] Vault: `kind: whiteboard` in frontmatter, the canvas as a standard + `.excalidraw` file beside the note file, moved, trashed, and deleted with + it under the mirror's ownership rules, and checked by Check vault + (`vault/board.rs`, migration v6's `board_sha`, + `core/tests/vault_board_test.rs`) +- [x] Canvas: `WhiteboardCanvas.svelte`, loaded on first use; saves only on a + changed scene fingerprint (scrolling and selecting are not edits); drops + deleted elements and unused images from what it saves; opens web links + in the browser +- [x] Keys: a board owns arrows, `Cmd+A`, `Cmd+=`, and Backspace; the library + window's shortcuts stand aside for them (`whiteboard/keys.ts`), so an + arrow press cannot switch notes and `Cmd+Backspace` cannot trash the + board +- [x] Entry points: "New whiteboard" (palette, File menu, `Cmd+Shift+N`) and + "Turn into whiteboard…" (palette, confirms); a board cue in the list +- [x] Export Note writes a board as `.excalidraw` +- [x] Offline fonts: `scripts/copy-excalidraw-fonts.mjs` copies Excalidraw's + fonts into `static/excalidraw/` before dev and build; without them + Excalidraw would reach for a CDN, which the CSP blocks. The CJK face + (Xiaolai, 12 MB) is left out; CJK text renders in the system font +- [x] Docs: `DATA_MODEL.md` (v6, the canvas file), `API.md` (fields, reply, + menu event, export), `CHANGELOG.md`, `spec.md` (Whiteboard Notes) + +## Left for a real run + +- Pasting an image onto a board. Excalidraw resizes images in a `blob:` + worker, which the CSP (no `worker-src`) blocks; it is expected to fall back + to resizing on the main thread. Confirm before widening the CSP. diff --git a/openspec/changes/archive/feat-settings-dashboard/proposal.md b/openspec/changes/archive/feat-settings-dashboard/proposal.md new file mode 100644 index 0000000..40efed6 --- /dev/null +++ b/openspec/changes/archive/feat-settings-dashboard/proposal.md @@ -0,0 +1,51 @@ +# Change: Settings Dashboard + +## Why + +The Settings front page was a bare list of category cards. It became a dashboard +showing live library counts, capture readiness, and the release notes for the +running version, so Settings answers "what is in here and what changed" without +leaving the app. + +The code is written and works. This change is the remaining distance to done. + +## What Changes + +Already built on `0.9.0-pre` (`44bea94`): + +- `library_stats` IPC command over `core/src/store/stats.rs` — one query per + number, unit-tested across all seven counts +- `changelog.ts` — a pure parser for the bundled `CHANGELOG.md`, so "what's new" + needs no network call (the webview CSP blocks external fetches). 5 tests +- `SettingsView.svelte` dashboard layout. 4 tests +- Shared settings primitives `SegmentedRow.svelte` and `ToggleRow.svelte` + +Added while finishing the unit: + +- `PrefRow.svelte`, the row shell the two primitives were each carrying their + own copy of, along with `SettingsLinks.svelte` and `SettingsImages.svelte` +- The primitives' frozen API, their keyboard behavior, and `rows.test.ts`. + `SettingsLinks.svelte` hand-rolled both controls and is now migrated onto them + +## Remaining to done + +- `library_stats` is undocumented in `docs/API.md` +- No changelog entry yet describes this accurately + +## Non-goals + +- No new stats. The seven counts plus attachments are the set +- No redesign of the category grid below the dashboard + +## Sequencing + +**First of the four 0.9.0 units**, because `SegmentedRow` and `ToggleRow` are +consumed by `feat-image-handling`, `feat-in-app-feedback`, and +`feat-editor-settings-and-fixes`. Their API is frozen here; changing it later +edits three other units. + +## Impact + +Settings gains a front page worth opening. `library_stats` is the first IPC +command that aggregates across the whole store, and the shape it settles on is +what a future graph or dashboard reads. diff --git a/openspec/changes/archive/feat-settings-dashboard/tasks.md b/openspec/changes/archive/feat-settings-dashboard/tasks.md new file mode 100644 index 0000000..27682b4 --- /dev/null +++ b/openspec/changes/archive/feat-settings-dashboard/tasks.md @@ -0,0 +1,107 @@ +# Tasks: Settings Dashboard + +Streams 2 and 7 of the ten identified on `0.9.0-pre`. The code exists; these are +the gaps between it and the definition of done in `openspec/SEQUENCE.md`. + +## Freeze the primitives first + +Everything below and three other units depend on these. + +- [x] Review `SegmentedRow.svelte` (81 lines) and `ToggleRow.svelte` (79 lines) + props: settle names, required vs optional, disabled and error states +- [x] Decide whether presentational-only components need tests here. If yes, add + them; if no, record the reasoning so the next unit does not relitigate it +- [x] Confirm keyboard and screen-reader behavior on both (they replace native + controls in every settings page) + +### The frozen API + +Both rows: `label` (required), `sub` (optional), `disabled` (optional, default +false), `onchange` (required). `SegmentedRow` adds `options` and `value`; +`ToggleRow` adds `checked`. + +`SegmentedRow` is generic over its value type, so `options`, `value` and +`onchange` agree at compile time and no caller casts. The three `as` casts at +the call sites are gone. + +**No `error` state.** Nothing in settings validates anything, and an optional +prop is additive to add later but a breaking change to remove. A frozen +primitive should not carry API that no consumer uses. Add it with the first +row that actually needs to report an invalid value. + +`sub` is wired to the control through `aria-describedby`, so the explanation a +sighted user reads is the one a screen reader announces. + +### The row shell + +The row layout, the label, the sub line and the disabled dimming were copied in +four files. They now live once in `PrefRow.svelte`, which `SegmentedRow` and +`ToggleRow` render through, and which the Images preview-height slider uses +directly. A new kind of row gets the layout by using it; no page restates it. + +### Keyboard + +`SegmentedRow` declared `role="radiogroup"` and did not behave like one: every +segment was a tab stop and the arrow keys did nothing. It is now a real radio +group: roving tabindex so the selected segment is the group's single tab stop, +arrows move the selection and wrap, Home and End jump to the ends, and focus +follows the selection. The keys are handled on the segments, not on the group, +because the segment is what holds focus. + +Both rows gained a `:focus-visible` ring. Neither had one, and both replace a +native control that would have had one. + +`disabled` is enforced in the handlers as well as through the attribute, so +"onchange never fires while disabled" is a property of the component rather +than of the host that dispatches the event. + +### Tests + +`src/lib/components/settings/rows.test.ts`, 14 tests. They stopped being +presentational the moment they carried keyboard logic, so they are tested +directly rather than through the pages. `SettingsView.test.ts` additionally +holds the migrated Links page to the shared rows and proves a change persists. + +## Dashboard + +- [x] Verify every dashboard number against a library with archived, trashed, + and pinned notes present at once. **Confirmed by jam in the running app**: + the seven counts are unit-tested in `core/src/store/stats.rs`, but + agreement between those counts and what the sidebar shows is only + observable live. Checked against `dev-0.9.0.db`, a real (not synthetic) + v3-schema library, after the app's own pre-migration backup mechanism + surfaced it as the way to test this build against real data without + touching the primary library (which had drifted to a v4 schema from an + unmerged whiteboard branch) +- [x] Confirm the attachments count and size come from the filesystem, not the + store, and behave when the attachments directory is missing +- [x] Confirm capture readiness renders when no measurement has been recorded + +`shell/files.rs:241-258` reads the directory, not the store, and a missing or +unreadable one returns zeros so the rest of the dashboard still renders. Two +things worth knowing, now in `docs/API.md`: every regular top-level file counts, +image or not, and subdirectories are neither traversed nor counted. + +`notes_active` is returned by the command and displayed nowhere; the Notes tile +shows `notes_total` with pinned and archived beneath it. Left in the contract as +an aggregate, not removed. + +## Changelog view + +- [x] Confirm `changelog.ts` handles a version heading with no date. 0.9.0 is + currently undated and will be until release +- [x] Confirm behavior when the running version has no matching section + +Both were already handled and are now held there by tests. `SettingsView:131` +drops the whole "What's new" block when no section matches, rather than showing +an empty one, and `:135` renders the date only when there is one. A further test +parses the real bundled `CHANGELOG.md` for the version in `package.json`, so +cutting a release with no changelog entry fails the suite instead of silently +emptying the dashboard. + +## Contract and record + +- [x] `docs/API.md`: document `library_stats` and its `LibraryStats` shape +- [x] `CHANGELOG.md`: entry describing the dashboard as it actually shipped +- [x] `cargo test`, `npm test`, `npm run check` pass +- [x] Move this change to `openspec/changes/archive/` diff --git a/openspec/changes/archive/feat-update-notification/proposal.md b/openspec/changes/archive/feat-update-notification/proposal.md new file mode 100644 index 0000000..90ef925 --- /dev/null +++ b/openspec/changes/archive/feat-update-notification/proposal.md @@ -0,0 +1,44 @@ +# Change: An Update Is a Notification, Not a Modal + +## Why + +The update flow was a modal reached from a pill on the empty library state. +A modal interrupts and reads as a system event; an available update is not an +emergency, it is an invitation. The library already has a place whose whole +vocabulary means "a thing you go and deal with": Spaces. An update can live +there without inventing any new surface, and without teaching the user a second +pattern for the same idea. + +## What Changes + +- An available update appears as the first row under Spaces, named `Update` with + a green `*` on the end of the label. It looks and behaves like every other + Space row - same size, same count, same hover - except it cannot be renamed, + deleted, or right-clicked. +- Opening it lands in a Space of two notes: + - `update -> `: the special note. Opening it shows the two + versions, how much larger or smaller the download is, and the Update + button. It is not editable and it is where the install lives: pressing + Update shows progress in place, and when the install finishes the note + offers `Ok`, which dismisses the notification. + - `What's new in `: the release notes from the changelog, as an ordinary + note. It opens in the ordinary editor and can be typed in; nothing is saved, + because it is not user data. +- The update Space is synthetic. Nothing is written to SQLite or the vault. It + is derived from the updater's state, so it cannot be searched, tagged, filed, + exported, backed up, or left behind, and it disappears the moment the update + is gone. +- The size delta comes from the published release assets on GitHub: the running + version's updater artifact against the offered one. It is best-effort; when + either size is unknown the line is simply absent. +- A manual "Check for Updates…" reports "up to date" and failures with a toast, + since there is no modal left to carry them. +- The welcome screen's update pill stays, as a shortcut into the update Space. + +## Non-goals + +- Turning an update into a real note or a real Space. That would leak into All + Notes, search, the dashboard, the graph, and above all the vault mirror. +- A "remind me later" cadence. The notification is not a nag and carries no + timer; it is ignored by doing nothing and answered by updating. +- Links between notes, or anything else the Space system does not already do. diff --git a/openspec/changes/archive/feat-update-notification/tasks.md b/openspec/changes/archive/feat-update-notification/tasks.md new file mode 100644 index 0000000..5705322 --- /dev/null +++ b/openspec/changes/archive/feat-update-notification/tasks.md @@ -0,0 +1,44 @@ +# Tasks: An Update Is a Notification, Not a Modal + +View-only: it renders the updater's state and stores nothing, so the SEQUENCE +insertion rule places it in any slot between units. Landed on `0.9.0-pre` on +2026-09-24. + +## Decide first + +- [x] Delta source. **True delta from the GitHub release assets** (decided + 2026-09-24). The updater plugin only reports a size once a download + starts, so the running and offered updater artifacts are read from + `api.github.com` and compared. Any failure shows no delta. +- [x] Manual-check outcome. **A toast** (decided 2026-09-24): "up to date" and + failures surface without the modal. +- [x] Release notes. **The second note in the Space** (decided 2026-09-24), + opened in the ordinary editor. Not a bespoke read-only panel. +- [x] The Space is **synthetic** (decided 2026-09-24): it never becomes user + data, so it cannot reach the vault and needs no cleanup. +- [x] `Ok` **only dismisses** (decided 2026-09-24); the installed update applies + on the next launch. + +## Build + +- [x] `src/lib/update/space.ts`: ids, the two note titles, the notes builder, + and the delta line, all pure +- [x] `src/lib/update/release-size.ts`: pick the platform's updater artifact by + name, read both releases' sizes, and diff them; every failure is null +- [x] Updater store: `date`, `sizeDelta`/`deltaState` with a best-effort load, + `pendingUpdate`, `acknowledge`, and the manual-check toasts +- [x] Remove the modal (`UpdatePanel.svelte`) and the now-unused snooze path + (`updater-snooze.ts`) +- [x] Library store: the update Space is not queried, `selectVirtual` opens a + synthetic note, and a synthetic body edit never queues a write +- [x] `SidebarEntityRow`: an optional trailing `suffix` snippet and a `readonly` + variant (no rename, no context menu) +- [x] Sidebar: the `Update *` row, first under Spaces +- [x] NoteList: the Space's two rows, routed through `selectVirtual` +- [x] `UpdateNote.svelte`: versions, delta, Update -> progress -> Ok, reusing the + old modal's primary button, not a new one +- [x] NoteEditor: a synthetic note opens read-only outside the body +- [x] `+page.svelte`: delete the panel wiring, open the Space from the tray and + the pill, and leave the Space when it goes away +- [x] Tests: the pure helpers, the store transitions, and the note component +- [x] Docs: `CHANGELOG.md`, `spec.md`, and this change archived diff --git a/openspec/changes/feat-agent-access/proposal.md b/openspec/changes/feat-agent-access/proposal.md new file mode 100644 index 0000000..2445bd7 --- /dev/null +++ b/openspec/changes/feat-agent-access/proposal.md @@ -0,0 +1,46 @@ +# Change: Agent access over MCP + +## Why + +The AI stage should not be AI built into the app. The first step is letting +any agent the user already runs (Claude Code, Codex, Cursor, Hermes) read and +write the library through a standard, so everything after it can be built on +top rather than inside. The user asked for exactly this in the library itself +(note "0.6.2"): an MCP server built in, following the protocol standard, not +overengineered, separable so the app behaves the same without it, with agents +searching through the same search the user uses. And a second note rules out +the obvious UI for it: no agents tab, which clutters; show it near All Notes. + +## What Changes + +- `instantnotes mcp --db `: the app's own binary serves MCP over stdio. + Fourteen tools map onto `Store`'s public API. Access is off by default, then + read or read-and-write, re-read on every call. No permanent delete, settings, + vault, or canvas at any level. +- Presence: every call is recorded in `agents.activity`; the app notices other + processes' commits through SQLite's `data_version` and lights up what an + agent touches (note rows, Spaces, tags, the open note), with one live line + under All Notes naming who is doing what. +- Two writers, one note: `update_note` gains an optional version check. Agents + must use it; the editor uses it too. An agent's edit to a clean open note + lands as a CodeMirror change (caret kept, undoable, highlighted). A save that + meets an agent's edit keeps the user's typing and offers the agent's back. +- Settings > Agents: access, connect commands, recent activity. + +## Non-goals + +- Transports other than stdio (HTTP, remote). stdio needs no port or token and + every client supports it. +- MCP resources and prompts; tools cover every read and write. +- Embeddings and semantic search (SEQUENCE unit 14; one more tool later). +- A terminal or agent host inside the app ("0.6.2" floated it; connecting the + agents the user already runs comes first). +- Windows: release builds use the GUI subsystem, which has no console stdio. + macOS and Linux only until that is addressed. + +## Impact + +A new workspace crate (`src-tauri/agents`, depends only on core). Core: an +`IMMEDIATE` transaction default, `data_version`, `schema_is_current`, and the +optional version check. Shell: one watcher file and a `main()` dispatch. No +migration, no new dependency beyond `chrono` (already in the tree via core). diff --git a/openspec/changes/feat-agent-access/tasks.md b/openspec/changes/feat-agent-access/tasks.md new file mode 100644 index 0000000..336aa09 --- /dev/null +++ b/openspec/changes/feat-agent-access/tasks.md @@ -0,0 +1,80 @@ +# Tasks: Agent access + +Built on `feat/agent-access` (its own worktree), off `0.9.0-pre` at `917dbd3`, +kept apart from the sticky-notes unit in flight on `0.9.0-pre`. + +## Decide first + +- [x] Standard: MCP, stdio, tools only +- [x] Transport: the app binary in a second mode, not an HTTP server in the + running app (no port, no token, works with the app closed) +- [x] SDK: none. `rmcp` 3.5 adds 125 crates and a tokio runtime for four + JSON-RPC methods; the loop is `protocol.rs` on `serde_json` +- [x] Conflict rule: `updatedAt` is the version; agents must send it; the + user's typing wins and the user is told, with the agent's version + offered back +- [x] Presence without a tab: the things touched light up; one line under + All Notes (the placement the user proposed in their own note) + +## Build + +- [x] Core: `IMMEDIATE` transactions, `data_version`, `schema_is_current`, + `expected_updated_at` (`core/tests/concurrency_test.rs`, two stores on + one file) +- [x] Crate `instantnotes-agents`: protocol, tools, access gate, activity log + (`agents/tests/mcp_test.rs`, 10 tests over in-memory pipes) +- [x] Shell: `main()` dispatch, `shell/agents.rs` watcher and + `agent_connection`, `library:external-change` in both event registries +- [x] Frontend: `agent-activity.ts`, `stores/agents.svelte.ts`, + `external-edit.ts`, save-queue version check, library adoption, + presence hooks, `app.css` presence block, Settings > Agents +- [x] Tests: 511 frontend, 244 Rust; `npm run check`, clippy, fmt clean +- [x] Proven over real stdio against a read-only snapshot of the + maintainer's library: handshake, all tools listed, Space listing, full + reads of the "InstantNotes Bugs" Space, read-only refusal, a write, + the activity log, and `vault_dirty` set by the existing triggers + +## Standards and audit pass + +Checked against the specification text, not memory: 2025-11-25 (tools, +schema) and 2026-07-28, which made MCP stateless. + +- [x] Dual-era: `server/discover`, per-request `_meta` version, `resultType`, + server identity in `_meta`, `ttlMs`/`cacheScope`, `-32022` naming the + supported versions; `initialize` still serves 2025-11-25 to 2024-11-05 +- [x] 2025-03-26 batches accepted (that revision says servers MUST) +- [x] Unknown tool and non-object `arguments` are `-32602`, not `isError` +- [x] Tools: `title`; `destructiveHint` true only where a write removes or + replaces; `idempotentHint`; `openWorldHint: false`; schemas declare + `additionalProperties: false` (the server already rejected extras) and + describe every shared parameter; `structuredContent` on success +- [x] JSON-RPC: `jsonrpc` checked, ids must be strings or numbers, responses + are never answered, a non-UTF-8 line is a parse error instead of the + end of the server +- [x] Conformance: the real binary, driven over stdio against a copy of the + maintainer's library, returns replies that validate against the + official `schema.json` of each revision (46 checks; the validator + rejects the old shapes, so it is not vacuous) +- [x] `tools.rs` split: `access.rs` (level and gate), `activity.rs` (the + trace the app reads); `tools.rs` is only the tool surface +- [x] `shell/agents.rs`: the decision behind each `data_version` move is a + pure function with its own tests +- [x] Found while auditing, fixed here: `get_or_create_tag` had no caller + since the first UI commit (`0ff7598`), so its command, registration, + client wrapper, and API.md row are gone (`Store::get_or_create_tag` + stays, a tested core primitive); API.md now documents + `set_notes_flags`, `soft_delete_notes`, `restore_notes`, + `destroy_notes`, and `get_shortcut_failure`; §15 moved after §14 +- [x] Found and left, with reasons and slots: `docs/TECH_DEBT.md` + +## Before archiving + +- [ ] The maintainer runs it: connect Claude Code from Settings > Agents, + watch rows and the live line while it reads, type in a note while it + appends +- [ ] Merge against the sticky-notes unit (shared files: `library.svelte.ts`, + `NoteList.svelte`, `NoteEditor.svelte`, `events.ts`, `events.rs`, + `lib.rs`, `+page.svelte`, `client.ts`, `docs/API.md`, `SEQUENCE.md`, + `CHANGELOG.md`, and `library.svelte.test.ts`). A sticky window edits in the same process, + so its writes do not move `data_version`; the library already stops + editing a stickied note, which is what keeps them from colliding diff --git a/openspec/changes/feat-license-gate/proposal.md b/openspec/changes/feat-license-gate/proposal.md new file mode 100644 index 0000000..d677565 --- /dev/null +++ b/openspec/changes/feat-license-gate/proposal.md @@ -0,0 +1,45 @@ +# Change: License gate as a Space + +## Why + +InstantNotes is PolyForm Strict with the Jam-Sw EULA (PR #58). The installer +asks Windows users to agree, but the DMG and the AppImage have no license +step, and the maintainer wants every user to agree to both the source license +and the EULA before using the app. The generic `EulaGate` from `installers` +asked for the EULA only, in a modal that looked like no part of the app. + +## What Changes + +- On first launch, and whenever the license or the EULA gets a new version, + the library opens into a License Space: the two documents as notes, each + read in full and agreed on its own. Decline quits. +- Until both are agreed, everything else in the library is shown but out of + reach (`inert`), every shortcut and menu command stands down, and the + capture panel and stickies show one line and a way to the library. +- Once both are agreed the Space is gone, the same way the Update Space goes + when there is nothing to install. + +## Decisions + +- **Render the shared gate, do not fork it.** `installers` now owns the + agreement gate's contract: `agreements.json` (both texts, their versions, + the gate's words), the rules, and `agreements.conformance.json` (the rules + as cases). InstantNotes renders the generated `agreements.svelte.ts` as a + Space instead of using the default `AgreementGate` view. The texts are never + edited here; `apply.mjs` writes them. +- **Each window gates itself.** The root layout no longer wraps every window + in one modal, which put a full EULA inside the capture panel and stickies. +- **Stored on the device, per document version**, under `jam-sw.agreements`, + as the contract says. Nothing reaches the library or the vault. + +## Impact + +- Generated (from `installers`, `feat/agreement-gate`): `src/lib/agreements.svelte.ts`, + `src/lib/AgreementGate.svelte`, `src/lib/agreements.test.ts`, + `src-tauri/installer/agreements.json`, `agreements.conformance.json`; + `src/lib/EulaGate.svelte` removed. +- New: `stores/license-space.svelte.ts`, `components/LicenseNote.svelte`, + `components/LicenseLocked.svelte`. +- Changed: `routes/+layout.svelte`, `routes/+page.svelte`, + `routes/capture/+page.svelte`, `routes/sticky/+page.svelte`, + `components/Sidebar.svelte`, `components/NoteList.svelte`. diff --git a/openspec/changes/feat-license-gate/tasks.md b/openspec/changes/feat-license-gate/tasks.md new file mode 100644 index 0000000..591cfb7 --- /dev/null +++ b/openspec/changes/feat-license-gate/tasks.md @@ -0,0 +1,34 @@ +# Tasks: License gate as a Space + +See `SEQUENCE.md` §13f. + +## Decide first + +- [x] Form. **A License Space with the two documents as notes, app locked** + (decided 2026-09-30), over one full-window page. +- [x] Ownership. **The gate is shared across Jam-Sw apps** (decided + 2026-09-30): `installers` owns the data and the rules, each toolkit has + one thin gate, and InstantNotes renders the Svelte one its own way. + +## Build + +- [x] Merge `main` (PR #58: license, EULA, installer art) into `0.9.0-pre` +- [x] Re-run `installers/scripts/apply.mjs` from `feat/agreement-gate` +- [x] License Space store, document page, sidebar row, note list +- [x] Lock: sidebar `inert`, shortcuts, menu events, Settings, graph +- [x] Capture panel and stickies stand down until agreed +- [x] Docs: changelog, SEQUENCE §13f + +## Verify + +- [x] `npm test` (shared conformance cases; License Space lock and order) +- [x] `npm run check` +- [x] `cargo test --workspace`, clippy, fmt after the merge +- [ ] In the app, with storage cleared: the library opens into the License + Space, nothing else responds, each document agrees on its own, and the + app opens after the second +- [ ] Capture shortcut while locked shows the message, and its button opens + the library +- [ ] Decline quits +- [ ] `installers` PR (`feat/agreement-gate`, stacked on #2) merged before + 0.9.0 ships, then `apply.mjs` re-run on the merged `main` diff --git a/openspec/changes/feat-portable-vault-sync/design.md b/openspec/changes/feat-portable-vault-sync/design.md new file mode 100644 index 0000000..a673f00 --- /dev/null +++ b/openspec/changes/feat-portable-vault-sync/design.md @@ -0,0 +1,411 @@ +# Design: Portable Vault and Cross-Device Sync + +Technical design for `proposal.md`. Supersedes the earlier +`docs/PORTABLE_VAULT_AND_SYNC_SPEC.md`, which is deleted by this change; its +rejected positions are recorded with reasons in §13. + +## 1. Requirements + +The complete saved state must live in an open, app-independent format that any +device or transport can carry and reproduce exactly: + +- note body text +- whiteboard canvases +- tags and spaces +- attachments +- timestamps and status flags + +The format must be readable and editable with InstantNotes absent. Sync must +cost nothing upfront or ongoing, and must reach Linux, macOS, and eventually iOS. + +## 2. Decisions + +**D1. The filesystem is authoritative. SQLite is a rebuildable index.** +Writes go to disk first, then to the cache. Deleting the cache loses nothing. +This is what makes the vault portable and what makes any transport work. + +**D2. No sync server is written.** +Ordering and replication are the transport's job. Git and Syncthing already do +this correctly. Writing a delta-sync daemon means reimplementing them worse, +and a home server is not zero ongoing cost. + +**D3. Git is the built-in transport.** +It is the only free transport that reaches all three platforms, needs no server, +and requires no Apple entitlements. It also supplies version history and +undelete. Folder-sync tools work by construction on the same layout. + +**D4. The cache database stays outside the vault.** +`Store::open` hard-fails when `PRAGMA journal_mode = WAL` does not stick +(`store.rs:250`), and cloud and network mounts break WAL routinely. A live +SQLite file inside a synced folder is also the classic way to corrupt one. The +cache stays at `/instantnotes.db`, where it is today. + +## 3. Vault layout + +``` +/ +├── Distributed Systems Notes.md +├── Q3 Planning Board.md # kind: whiteboard +├── Q3 Planning Board.excalidraw # sidecar, renamed in lockstep +├── attachments/ +│ └── a1b2c3d4-e5f6-7890.png +├── trash/ +│ └── Old Draft.md +└── instantnotes.yaml +``` + +Notes sit at the vault root, not in a `notes/` subdirectory. This is deliberate: +bodies already reference images as `attachments/` (`shell/files.rs:82`), +and that relative path resolves correctly only when the note and the +`attachments/` directory are siblings. Root placement means the migration +rewrites zero note bodies. + +Known limitation: a note in `trash/` resolves `attachments/…` one level too deep, +so images in trashed notes do not render in external viewers. Trash is transient +and restoring fixes it. Accepted rather than rewriting bodies on trash. + +### 3.1 Filenames + +`.md`, slug derived from the note title. On collision, append the +first six characters of the note `id`. + +The filename is a label, not an identity. Identity is the `id` in frontmatter, +which is why an external rename, a git rename, or a Syncthing delete-plus-create +all reconcile to the same note. + +Renames are applied at flush time (§6), not per keystroke, so a note with an +auto-derived title settles within a save cycle instead of churning. + +### 3.2 Note file format + +````markdown +--- +id: 018f6c3a-8b29-7c10-9824-3a2e1d0f5b6a +created: 2026-09-03T19:00:00.000000Z +updated: 2026-09-03T19:15:00.000000Z +pinned: true +tags: [distributed-systems, consensus] +spaces: [Engineering] +--- +# Consensus Protocols + +Notes on Paxos and Raft. #consensus +```` + +**Every field is omitted when it equals its default.** A plain note carries only +`id`, `created`, and `updated`. Frontmatter noise defeats the purpose of the +format being human-readable. + +`title` is the one non-obvious rule: it is written **only when the user set it +explicitly**. `title_is_auto` is then redundant — its value is exactly +`frontmatter.title.is_none()`, because an auto title is by definition +`derive_title(body)` (`notes.rs:12`, `notes.rs:89`). One field, no redundancy, +fully lossless. + +Spaces use the product term `spaces`, not the storage term `workspaces`. The +vault is user-facing, and the glossary in `openspec/project.md` puts the +UI/storage boundary at exactly this kind of surface. + +### 3.3 Whiteboard sidecars + +`surface_data` currently holds the app's own envelope, +`{v, engine, data:{elements, appState, files}}` (`whiteboard/document.ts`). +The sidecar writes the **standard Excalidraw file format** instead: + +```json +{ "type": "excalidraw", "version": 2, "source": "instantnotes", + "elements": [], "appState": {}, "files": {} } +``` + +so the file opens on excalidraw.com and in the desktop app. The `v`/`engine` +envelope is reconstructed on ingest. Embedded images in `files` are extracted to +`attachments/` and referenced by id rather than inlined as data URLs. + +### 3.4 `instantnotes.yaml` + +Holds what has no note to live on: + +```yaml +tags: + consensus: { color: "#7aa2f7", created: 2026-07-02T09:14:00Z } +spaces: + - { name: Engineering, created: 2026-07-02T09:10:00Z } +``` + +Spaces must be listed explicitly because an empty space has no note referencing +it, and empty spaces are legitimate and always visible +(`docs/superpowers/specs/2026-07-10-spaces-design.md`). Without this file they +would vanish on rebuild. + +Tag and space **identity is the name**, which is already unique in both tables. +Their row `id`s are cache-local and may be regenerated on rebuild. Nothing +outside the cache persists a tag or space id — settings hold only UI +preferences. + +## 4. Field mapping + +Every column has a defined home. Nothing is dropped. + +| `notes` column | Vault location | Note | +| --- | --- | --- | +| `seq` | not persisted | cache-only; FTS `content_rowid` | +| `id` | frontmatter `id` | stable identity | +| `title` | frontmatter `title` | present only when user-set | +| `title_is_auto` | implied | `= title absent` (§3.2) | +| `body` | Markdown body | after the frontmatter block | +| `content_kind` | frontmatter `kind` | omitted when `document` | +| `surface_data` | `.excalidraw` | §3.3 | +| `created_at` | frontmatter `created` | | +| `updated_at` | frontmatter `updated` | authoritative; mtime is not | +| `last_opened_at` | not persisted | device-local, §7 | +| `is_pinned` | frontmatter `pinned` | omitted when false | +| `is_archived` | frontmatter `archived` | omitted when false | +| `is_deleted` | file location | the note lives in `trash/` | +| `deleted_at` | frontmatter `deleted` | present only in `trash/` | + +| Other state | Vault location | +| --- | --- | +| `note_tags` | frontmatter `tags` | +| `tags.color`, `tags.created_at` | `instantnotes.yaml` | +| `note_workspaces` | frontmatter `spaces` | +| `workspaces` | `instantnotes.yaml` | +| `settings` | not persisted — device-local UI preferences | +| `notes_fts` | not persisted — derived | +| attachments | `attachments/` (moved from ``) | + +`note_tags.source` is reconstructed, not stored: a tag is `inline` when its +token appears in the body, `manual` otherwise. This reproduces current behavior +exactly, since `update_note` already deletes and re-derives inline edges on +every body change (`notes.rs:120-137`). + +## 5. Schema migration v5 + +Additive only. Migration v3 dropped the unused sync scaffolding with the note +that "a real sync feature will design its own schema when it lands" +(`store.rs`); this is that schema. v4 is the whiteboard's `content_kind` and +`surface_data`, which pre-release builds had already written into real +libraries, so it ships ahead of this one unchanged. + +```sql +ALTER TABLE notes ADD COLUMN vault_path TEXT; +ALTER TABLE notes ADD COLUMN file_sha TEXT; +ALTER TABLE notes ADD COLUMN vault_dirty INTEGER NOT NULL DEFAULT 0; +CREATE TABLE vault_tombstones (vault_path TEXT PRIMARY KEY, file_sha TEXT); +-- plus the dirty triggers (§6) and indexes on vault_dirty and vault_path +``` + +- `vault_path`: path relative to the vault root; detects moves and renames +- `file_sha`: sha256 of the bytes we last wrote; drives echo suppression (§7), + and in stage 2 decides whether a file may be removed (it may only while it + still holds our bytes) +- `vault_dirty`: survives a crash between the DB commit and the file write +- `vault_tombstones`: a permanently deleted note leaves no row to flag, so its + file is queued here for the flush to remove + +## 6. Store changes + +```rust +pub struct Store { + conn: Connection, + vault: Option, // None = no mirror configured +} +``` + +Dirty tracking lives in SQL, not in `Store` methods. Triggers set +`vault_dirty` in the same transaction as the write that changes a note's file: + +- `notes` insert, and updates of every column the file carries (not + `last_opened_at`, §7.2) +- `note_tags` and `note_workspaces` inserts and deletes, which also covers the + cascades from deleting a tag or a space +- a rename of a tag or space, for every note carrying it (a color change + touches only `instantnotes.yaml`) +- a `notes` delete with a `vault_path` queues a tombstone + +The first draft of this design put one `self.dirty.insert(id)` line in each of +the 13 note-writing functions plus an in-memory `HashSet`. Triggers do the same +job with no second source of truth, cannot be forgotten by a future writer, +and are crash-safe by construction. No public signature changes, so +`store_test.rs` keeps passing unmodified, and the 10 read functions keep the +same SQL, the same FTS5 triggers, and the same `NoteFilter`. + +`flush_vault(max)` writes up to `max` pending notes: + +``` +remove each tombstoned file that still holds its recorded sha +for id in pending (oldest first, up to max): + note = vault_note(id) + path = first free of .md, -.md, -.md + (under trash/ when deleted); a path is usable when it is the + note's own, unclaimed and absent, or holds a file whose id is ours + atomic_write(path, serialize(note)) # tmp in same dir, fsync, rename + if path moved: remove the old file only if it still holds our bytes + UPDATE notes SET vault_path=?, file_sha=sha256(bytes), vault_dirty=0 +rewrite instantnotes.yaml only if its bytes changed +``` + +Collisions compare case-insensitively, and a case-only rename renames the file +first, because on macOS `notes.md` and `Notes.md` are one file. The vault root +is never created: a missing root fails the flush and every note stays pending. + +In the desktop shell, every write command already calls one of the +`emit_*_changed` helpers, which also poke a background writer. It flushes after +300 ms of quiet (at most 2 s), 50 notes per hold of the store lock, so typing +an auto-titled first line settles into one rename and a large first mirror +never stalls an autosave. Startup flushes whatever a crash left pending; quit +flushes one more chunk. + +Renaming a tag or a space rewrites the frontmatter of every note carrying it. +That is bounded and it is the price of names rather than opaque ids in the file. + +## 7. Two writers + +This is the only genuinely hard part of the change. Today SQLite is the sole +writer. Afterwards an external editor, a sync tool, or a git checkout can also +write, and the app must not fight its own writes: write file, watcher fires, +re-ingest, write again, forever. + +Suppression is one comparison, no leases and no timers: + +``` +on_fs_event(path): + bytes = read(path) + if sha256(bytes) == file_sha of the row whose vault_path == path: + return # our own write; drop it + note = parse(bytes) + upsert by frontmatter id # new id => new note; known id => edit or move + emit notes:changed +``` + +Other cases: + +- **File deleted externally** → move the note to `trash/` state. +- **Unknown `id`, or no frontmatter** → new note; generate an `id` and write it + back on next flush. This is how a note dropped into the folder by hand joins. +- **Known `id` at a new path** → a move; update `vault_path` only. +- **Debounce** filesystem events 150 ms; batch reconciliation in one transaction. + +### 7.1 Editor buffer safety + +If an external edit lands on the note currently open with unsaved changes, the +local buffer is never overwritten. The incoming version is written as a conflict +copy (§9) and the user keeps typing. + +### 7.2 `last_opened_at` stays device-local + +Writing it to frontmatter means *opening* a note dirties its file and triggers a +sync round-trip on every read. It also has per-device meaning — the Revisit view +is about what *this* device has seen. Keeping it in the cache leaves +`get_note(touch: true)` a pure cache write that never touches disk. + +This is the one field deliberately not portable. If it must sync later, it moves +to a separate append-only file, not to note frontmatter. + +## 8. Rebuild from disk + +Triggered on cache loss, corruption, schema mismatch, or explicitly from +Settings. + +``` +seen = {} +for each *.md at vault root and under trash/: + parse; upsert by id; seen += id +DELETE FROM notes WHERE id NOT IN seen +reconcile tags/spaces from instantnotes.yaml plus frontmatter +``` + +Upsert rather than truncate, so device-local `last_opened_at` survives a rebuild. +FTS5 repopulates through the existing triggers; no separate index pass. + +Target: under 500 ms for 5,000 notes. Parsing is parallel; the insert is one +transaction. + +## 9. Conflicts + +No automatic body merge. Conflicts are made visible and non-destructive. + +| Artifact | Resolution | +| --- | --- | +| `*.md` | Keep local. Write the remote side as ` (conflict from <device> <date>).md` with a fresh `id`, so it appears as a normal note to reconcile by hand. | +| `*.excalidraw` | Element-level merge: match on element `id`, take `MAX(version, versionNonce)`, honor `isDeleted` tombstones. Deterministic, no user involvement. | +| `instantnotes.yaml` | Per-key last-write-wins. | +| `attachments/*` | Content-addressed filenames; cannot conflict. | + +The Excalidraw merge is the one piece worth building, because no transport can +merge a JSON scene and a text-merged `.excalidraw` is a corrupt `.excalidraw`. + +## 10. Transport: git + +Built into `instantnotes-core` using `git2` (libgit2). `gix` is the pure-Rust +alternative if OpenSSL cross-compilation becomes a problem for iOS later. + +- **Pull** on launch, on window focus, on `online`, and before every push. +- **Commit** 30 s after the last vault flush, on window blur, and on quit. + Message: `notes: <title>` for one note, `notes: <n> changes` otherwise. +- **Push** after commit. On non-fast-forward: `pull --rebase`, then push. On + rebase conflict: abort, apply §9, commit the resolution, push. +- **Auth**: HTTPS with a personal access token in the OS keychain (`keyring` + crate). No SSH key management. +- **Remote**: any free private repo host. A text vault stays far inside free + tier limits. + +The vault gets a `.gitignore` for `.DS_Store` and editor droppings. The cache is +outside the vault, so nothing else needs ignoring — which is a direct benefit +of D4. + +## 11. Transport: folder sync + +Works with no additional code, because the vault is an ordinary directory. The +user points InstantNotes at a Syncthing, Nextcloud, or Dropbox folder. The +watcher in §7 already handles incoming writes; those tools generate their own +conflict files, which surface as ordinary notes. + +This covers Linux ↔ macOS well. It does not cover iOS, which is why git is the +built-in transport rather than the optional one. + +## 12. Risks + +| # | Risk | Mitigation | +| --- | --- | --- | +| R1 | Two-writer echo loops or lost writes | §7 sha comparison; dual-write stage proves round-tripping before authority flips | +| R2 | Filename churn from auto-derived titles | Rename only at flush, only when the slug actually changes | +| R3 | Tag or space rename rewrites many files | Bounded and accepted; it is what keeps names in the files | +| R4 | Data loss during migration | Stage 1 is export-only. `backup_before_migration` (`store.rs`) snapshots the DB before v5 runs | +| R5 | Vault on a WAL-hostile mount | Cache stays in app data (D4), so `store.rs:250` is never exercised against the vault | +| R6 | Private repo means the host can read notes | Out of scope. Recorded in §14 | + +## 13. Rejected alternatives + +Recorded so the reasoning is not relitigated. All of these appeared in the +superseded `docs/PORTABLE_VAULT_AND_SYNC_SPEC.md`. + +- **Self-hosted Axum delta-sync server behind a Cloudflare Tunnel.** A second + product — server, auth, protocol, deployment, operations — duplicating what + git and Syncthing already do. A home server also has real ongoing cost in + hardware, power, and maintenance, which contradicts the requirement. +- **Hybrid Logical Clocks.** Only needed to order events for a custom delta + protocol. With no such protocol, the transport orders events. +- **diff3 automatic body merge.** Real complexity and a real corruption risk for + a single-user app where conflicts are rare. Conflict copies are honest and + cost nothing to build. Revisit if conflicts prove common in practice. +- **UUID filenames (`018f6c3a-….md`).** Defeats the requirement. A folder of + UUIDs is not readable without the app. +- **`.instantnotes/cache.db` inside the vault.** See D4. +- **iCloud Drive as the primary transport.** No Linux client, and the iCloud + container entitlement is unavailable under free Apple provisioning, so it is + gated behind a paid developer account. +- **Syncthing as the only transport.** No free, reliable iOS client. +- **Dropping SQLite and parsing Markdown at query time.** Would make the sidebar, + tag counts, and search scale with library size. The cache exists precisely so + the format can be dumb. + +## 14. Open questions + +1. Should `last_opened_at` eventually sync? §7.2 says no for now; the Revisit + view's intended semantics decide it. +2. Multiple vaults, or one per install? Assume one until there is a reason. +3. Encryption at the remote. A private repo is probably enough; `age` or + `git-crypt` on the remote only would keep the local vault plaintext. Deferred. +4. Attachment garbage collection. Resolved in 0.9.0 as its own change + (`changes/archive/feat-attachment-cleanup`): destroying the last note that + references an image removes it, and the vault's unchanged copy with it. diff --git a/openspec/changes/feat-portable-vault-sync/proposal.md b/openspec/changes/feat-portable-vault-sync/proposal.md new file mode 100644 index 0000000..cb33280 --- /dev/null +++ b/openspec/changes/feat-portable-vault-sync/proposal.md @@ -0,0 +1,73 @@ +# Change: Portable Vault and Cross-Device Sync + +## Why + +Everything a user owns lives in one SQLite file at `<app data>/instantnotes.db`, +plus loose images under `<app data>/attachments/`. That file is the only copy. +Two consequences follow: + +1. **No sync is possible.** Any sync mechanism would have to move a live SQLite + database between machines. That is unsafe (WAL, partial writes, `store.rs:250` + refuses non-WAL mounts outright) and produces conflicts nothing can resolve. +2. **No portability.** Without InstantNotes installed, the notes are unreadable. + +Sync is not the first problem. Storage format is. Once the complete saved state +is a folder of plain files, sync stops being something we build and becomes +something the user configures — git, Syncthing, or any folder that already +replicates. This change does the storage half and ships one transport. + +This also serves the Product Thesis directly. "Trust is release" does not hold +while a single unreadable file is the only copy of everything the user parked. + +## What Changes + +- The filesystem becomes the source of truth: a vault of Markdown files with + YAML frontmatter, Excalidraw sidecars, and an `attachments/` directory +- SQLite is re-roled as a rebuildable cache and search index; every read path, + FTS5 trigger, and query stays exactly as it is today +- Migration v5 adds `vault_path`, `file_sha`, `vault_dirty` to `notes` +- The 13 note-writing store functions mark notes dirty; a flush serializes them + to disk after each command +- A `notify` watcher ingests external edits (sync tools, other editors), with + echo suppression by content hash +- Git transport built into `instantnotes-core`: pull on launch/foreground/online, + debounced commit and push on change +- Tag colors and space identity move to a vault-level `instantnotes.yaml`; + UI settings stay device-local in the cache + +## Non-goals (this change) + +- No sync server. No hosted service, no self-hosted daemon, no delta protocol +- No automatic three-way merge of note bodies; conflicts become conflict copies +- No real-time or multi-user editing +- No encryption of the vault or the remote +- No iOS client. The vault and transport land in `instantnotes-core`, which has + no Tauri dependency, so a future iOS client can link them; that is separate work +- No change to capture, library, tags, spaces, or any IPC command signature + +## Sequencing + +Units 7-10 in `openspec/SEQUENCE.md`, worked one at a time and in that order. +Unit 9 cuts 0.10.0 (the vault, no sync); unit 10 cuts 0.11.0 (git transport). + +Nothing that adds a migration, a persisted field, or a write path runs alongside +these units, because they rewrite the store's write path. + +Two decisions land before unit 7 and are encoded permanently by it: + +- **Image link mode** (`feat-image-handling`). A linked image lives outside the + vault by definition, so it is either dropped, materialized on export, or + documented as opting a note out of portability +- **Whiteboards are deliberately sequenced after the vault** (SEQUENCE.md unit + 12), so the sidecar is a new file type in a format with no legacy data rather + than a migration of user data + +## Impact + +Notes become readable and editable without InstantNotes, on any device, in any +editor. Backup becomes copying a folder. Sync becomes a transport choice rather +than an architecture. The cost is that the store gains a second writer — the +filesystem — which §7 of the design addresses and which the staged rollout in +`tasks.md` proves out before authority flips. + +Full technical design in `design.md`. diff --git a/openspec/changes/feat-portable-vault-sync/tasks.md b/openspec/changes/feat-portable-vault-sync/tasks.md new file mode 100644 index 0000000..8f2c629 --- /dev/null +++ b/openspec/changes/feat-portable-vault-sync/tasks.md @@ -0,0 +1,137 @@ +# Tasks: Portable Vault and Cross-Device Sync + +Four stages. Each is shippable on its own and reversible. Authority does not +flip to the filesystem until stage 3, after stage 2 has proven round-tripping +against a real library. + +## Stage 1: Serializer and one-way export (DONE, 0.9.0) + +No behavior change. SQLite stays authoritative. + +- [x] `core/src/vault/write.rs`: vault root resolution via the caller's `dest`, atomic write (tmp + fsync + rename) +- [x] `core/src/vault/serialize.rs`: `VaultNote` → Markdown + YAML frontmatter, defaults omitted +- [x] `core/src/vault/parse.rs`: Markdown + frontmatter → `VaultNote`, tolerant of missing/extra keys +- [x] Excalidraw sidecar: `surface_data` envelope to a standard `.excalidraw` file beside the note file, plus `kind: whiteboard` in frontmatter (`vault/board.rs`). Landed with the whiteboard's return (SEQUENCE.md unit 12, `changes/archive/feat-note-whiteboard`). Writing only: reading an edited `.excalidraw` back is stage 3. +- [x] `instantnotes.yaml` reader/writer (tag colors, space list): `core/src/vault/manifest.rs` +- [x] Round-trip property tests: `parse(serialize(n)) == n` for every field combination. `vault::round_trip_tests` (36 combinations plus YAML-hostile/unicode/empty-body edge cases), and a real-SQLite round trip in `core/tests/store_test.rs::vault_export` +- [x] `export_vault` command + Settings action: write the whole library to a chosen folder. `commands::vault::export_vault`, Settings → Vault page +- [x] Copy `<app data>/attachments/` into `<vault>/attachments/` on export: `vault::copy_dir_recursive` + +Used `serde_norway` for YAML (`serde_yaml` is unmaintained upstream; `serde_norway` +is the maintained hard-fork with an API-compatible surface). The store's +`title_is_auto` column was not previously exposed publicly; added +`Store::title_is_auto(id)` rather than widening the IPC-facing `Note` type, +since the vault is the only caller. + +Known limitation, accepted for a one-way snapshot rather than blocking it: +re-exporting to a folder that already holds a previous export does not prune +files for notes deleted or retitled since the last run. Stage 2's dual-write +flush is what actually needs to solve this (it already tracks `vault_path` +per note); stage 1 just overwrites and never deletes. + +Fixed before stage 1 was committed: filename collisions are now compared +case-insensitively (`vault::collision_key`). The default macOS (APFS) and +Windows filesystems treat `Notes.md` and `notes.md` as one file, so two notes +titled that way silently overwrote each other in an export. A suffixed name +that is itself taken now falls back to the full id. Also fixed: +`list_notes` had no unique tiebreaker in its `ORDER BY`, so rows tied on the +sort column could be skipped or repeated across the `LIMIT`/`OFFSET` pages +`collect_from_store` walks; `id` is now the final sort column. + +## Stage 2: Dual-write (DONE, 0.9.0) + +SQLite still authoritative. The vault is written but not read. + +- [x] Migration v5, as design.md §5 says: `vault_path`, `file_sha`, + `vault_dirty` on `notes`, plus `vault_tombstones` and the dirty triggers. + An earlier draft of this list renumbered it to v4 because the whiteboard's + v4 had been lifted off the branch. That was wrong: pre-release whiteboard + builds had already migrated real libraries to v4 (the maintainer's own + library among them), which the committed 0.9.0-pre then refused to open + as too new. So the whiteboard's v4 now ships on 0.9.0 exactly as + `feat/note-whiteboard` wrote it (columns only, unread until unit 12 + returns), and the vault is v5 +- [x] `Store` gains `vault: Option<VaultState>` (`store/vault.rs`). **Drift:** + no in-memory `dirty: HashSet`; see the next item +- [x] Dirty marking. **Drift:** SQL triggers instead of a line in each of the 13 + note-writing functions. They set `vault_dirty` in the same transaction as + the write, so a crash can't lose it and a future writer can't forget it, + and they cover edges, tag and Space renames, and the delete cascades. + `last_opened_at` is not watched (§7.2). `store_test.rs` is untouched +- [x] Flush after writes. **Drift:** not inline in each command. The + `emit_*_changed` helpers (which every write command calls) poke a + background writer (`shell/mirror.rs`) that flushes after 300 ms of quiet + (at most 2 s), in 50-note chunks so the store lock is never held long. + Measured on a real 91-note library: 5 ms per note, 6 ms per single-note + flush +- [x] Flush pending `vault_dirty` rows on startup (crash recovery), and one + chunk on quit +- [x] Filename slug, case-insensitive collision suffix, rename-at-flush. + Case-only renames rename the file rather than delete it on macOS. A + board's `.excalidraw` file moves, trashes, and deletes with its note + under the same rules, tracked by `board_sha` (migration v6) +- [x] Trash: move file to `trash/` on soft delete, back on restore, unlink on + destroy (via tombstones, only while the file still holds our bytes) +- [x] Only files the mirror owns are touched: a foreign file at a note's name is + never overwritten, and an earlier export of the same ids is adopted in + place. The vault root is never created, so a missing drive pauses the + mirror instead of writing to the boot disk +- [x] Attachments copied in on setup, at launch, and after each image save +- [x] Settings: vault folder picker, change, stop, status (pending / paused) +- [x] Verification command: `verify_vault` reports missing, diverged, and orphan + files and the manifest. Proven clean on a copy of a real library + (`vault_mirror_test.rs::a_real_library_copy_mirrors_and_verifies_clean`) +- Moved to stage 3: "Rebuild cache from vault". While SQLite is authoritative + it would overwrite the real library from an unchecked mirror; it belongs + with `rebuild_from_disk()` once the vault is the source of truth + +## Stage 3: Filesystem authoritative + +**Gate before starting (met 2026-09-22):** real libraries hold whiteboard +notes, and a rebuild from a vault that carried only their text would drop +their canvases. The whiteboard returned first (unit 12): the vault now writes +`kind: whiteboard` and each board's `.excalidraw` file. What stage 3 still +owes boards is the other direction: ingest must read the canvas file back +into `surface_data`, and treat a board without one as an empty scene. + +- [ ] `notify` watcher with 150 ms debounce, wired into the Tauri shell layer +- [ ] Echo suppression by `file_sha` comparison per `vault_path` +- [ ] Ingest: upsert by frontmatter `id`; handle new / edited / moved / deleted +- [ ] Unknown or absent `id`: adopt the file, assign an id, write back on flush +- [ ] Reconstruct `note_tags.source` from body token presence +- [ ] Dirty-buffer safety: never overwrite the open editor; write a conflict copy +- [ ] `rebuild_from_disk()`: upsert-not-truncate so `last_opened_at` survives +- [ ] Settings: "Rebuild cache from vault" (moved here from stage 2) +- [ ] Attachments move into the vault; bodies unchanged (paths already relative) +- [ ] Benchmark rebuild at 5,000 notes; target < 500 ms +- [ ] One-time migration for existing installs, gated behind explicit confirmation + +## Stage 4: Git transport + +- [ ] `git2` in `instantnotes-core`; init or clone a vault repo +- [ ] Pull on launch, focus, `online`, and before push +- [ ] Debounced commit (30 s idle, blur, quit); push after commit +- [ ] Non-fast-forward: `pull --rebase`, retry push +- [ ] Markdown conflict → `<Title> (conflict from <device> <date>).md` with fresh id +- [ ] `instantnotes.yaml` conflict → per-key last-write-wins +- [ ] Excalidraw element merge: match on element id, `MAX(version, versionNonce)`, honor `isDeleted` +- [ ] PAT storage in the OS keychain (`keyring`) +- [ ] Vault `.gitignore` +- [ ] Sync status in the UI: last sync, pending changes, error state + +## Docs + +Bulk of this update lands when stage 3 lands, since `docs/DATA_MODEL.md` and +`README.md` describe SQLite-is-authoritative behavior that stays accurate +through stage 2. `docs/API.md` is the exception: every 0.9.0 unit ships its +own command docs as it lands, so `export_vault` was documented in stage 1 +rather than held back. + +- [x] `docs/API.md`: `export_vault` (stage 1's command), §12 +- [x] `docs/API.md`: `get_vault_status`, `set_vault_folder`, `verify_vault`, + `vault:status` (stage 2), §12 +- [ ] `docs/API.md`: sync commands, once stage 4 adds them +- [x] `docs/DATA_MODEL.md`: migrations v4 and v5 and the mirror columns (stage 2) +- [ ] `docs/DATA_MODEL.md`: vault layout, field mapping, cache role (stage 3) +- [ ] `README.md`: "Everything lives in a local SQLite database" → vault + cache +- [x] `openspec/project.md`: Tech Stack (the vault format and its crates, stage 2) diff --git a/openspec/changes/feat-stickies-import/design.md b/openspec/changes/feat-stickies-import/design.md new file mode 100644 index 0000000..247a10f --- /dev/null +++ b/openspec/changes/feat-stickies-import/design.md @@ -0,0 +1,189 @@ +# Design: Import from Apple Stickies + +## 1. What Stickies keeps on disk + +Verified, not assumed. Sources: the field notes of an open-source Stickies +exporter tested on a real Mac on 2026-08-30 (`RedBearAK/Stickies-to-Markdown`, +`dev_notes/MAC_FINDINGS.md`), and RTF produced on this machine by `textutil`, +which writes through the same Cocoa `NSAttributedString` RTF writer Stickies +saves with (`\cocoartf`). + +``` +~/Library/Containers/com.apple.Stickies/Data/Library/Stickies/ + <UUID>.rtfd/ one package per sticky + TXT.rtf the text, Cocoa RTF, \ansicpg1252 + <image files> attachments, under their original names + .SavedStickiesState binary plist: a list of dicts, one per sticky +``` + +- State entries carry `UUID` and `StickyColor` (`{Red, Green, Blue, Alpha}`, + floats 0..1), plus window geometry the importer does not need. +- A brand-new sticky exists for a few seconds as a flat `.rtfd` file before + its first save turns it into a package. Only packages are stickies. +- Every save replaces `TXT.rtf` (new inode). Moving or recoloring a sticky + also saves it, so the file's modified time is "last touched", not "last + typed". It is still the closest thing to a modified date that exists. +- The package directory persists across saves, so its birth time is the + sticky's creation time. +- `TXT.rtf` is bytes, not UTF-8: cp1252 escapes (`\'e9`), `\uN` for anything + else (an emoji is a UTF-16 surrogate pair, two `\uN` in a row), and a raw + `0xAC` byte after each attachment group. + +## 2. Access + +macOS 27 denies another developer's app container by default, with no prompt +("Accessing files in other developer teams' app data containers ... no +longer prompts the user for authorization; such accesses are denied by +default and can be managed by the user in Privacy & Security settings"). On +earlier macOS it prompts once, and for an ad-hoc signed app like this one the +grant lasts only the session. + +So the importer never reaches into Stickies' folder on its own. The user +picks the folder in the system picker, which opens already there +(`stickies_location`), and that choice grants the running app access to it. + +| Outcome of reading the chosen folder | What the page shows | +| --- | --- | +| Readable, stickies found | The preview | +| Readable, none found | "No stickies here", and where Stickies keeps them | +| Permission denied | Why, a button to Privacy & Security, and Choose again | +| Not a folder, or another I/O error | The error | + +Picking the Stickies container itself, or `Data`, still works: the reader +descends to `Data/Library/Stickies` when that is where the packages are. + +## 3. Pipeline + +``` +Choose folder ──> scan_stickies(folder) ──> preview ──> import_stickies(folder, ids, space) + read + convert, no writes copy images, one store transaction +``` + +The scan converts every sticky (it is cheap) so the preview shows the real +text. Import converts again from disk rather than trusting the preview, so +what lands is the file as it is at that moment. + +`core::import::stickies::read_folder(dir) -> Vec<Sticky>`: + +| Field | From | +| --- | --- | +| `id` | Package name without `.rtfd` (the sticky's UUID) | +| `rtf` | `TXT.rtf` bytes | +| `color` | `StickyColor` as `#rrggbb`, when the state file has the sticky | +| `created_at`, `updated_at` | Package birth time (modified time where the platform has none), `TXT.rtf` modified time; `created_at` is never after `updated_at` | + +A package without `TXT.rtf`, or one whose `TXT.rtf` is over 16 MiB, is left +out. An unreadable state file only costs the colors. + +## 4. RTF to Markdown + +`core::import::rtf::to_markdown(rtf: &[u8], image: impl FnMut(&str) -> Option<String>) -> String`. +A byte-level reader for the RTF Cocoa writes. `image` is asked for each +attachment by its file name and returns the Markdown for it, or `None` for +one it could not bring in; the converter itself never touches the disk. + +| RTF | Markdown | +| --- | --- | +| `\par`, `\` + newline | a new line | +| `\line`, U+2028 | a new line | +| `\b` / `\b0`, `\i` / `\i0` | `**bold**`, `*italic*`, `***both***` | +| `\strike`, `\striked1` / `\strike0`, `\striked0` | `~~struck~~` | +| `\plain` | all of the above off | +| `\ls` + `\ilvl` + `{\listtext 1.}` | `- item` or `1. item`, two spaces per level; the number is the one Stickies drew | +| `{\field{\*\fldinst HYPERLINK "u"}{\fldrslt t}}` | `[t](u)`, or bare `u` when `t` is `u` | +| `{{\NeXTGraphic name \width…}` + `0xAC` `}` | whatever `image(name)` returns; the placeholder byte is dropped | +| `\'hh` | cp1252 | +| `\uN` (negative N wraps; surrogate pairs combine) | the character, skipping `\ucN` fallback characters | +| `\{`, `\}`, `\\`, `\~`, `\tab`, `\emdash`, `\endash`, `\bullet`, the quote words | the character | +| `\fonttbl`, `\colortbl`, `\stylesheet`, `\info`, `\pict`, any `{\*…}` except `\fldinst` | skipped | +| font, size, color, underline, alignment, spacing | dropped | + +Emphasis markers never touch whitespace: `**bold **` is not Markdown, so +leading and trailing spaces of a styled run are written outside its markers. +Emphasis never spans lines; each line opens and closes its own. Plain text is +not escaped: the note reads as if it had been typed into InstantNotes, where +the same characters mean the same things. + +Trailing blank lines are trimmed. Nothing else about spacing is changed. + +## 5. Images + +For each attachment name, `import_stickies` (shell): + +1. Accepts only a plain file name that resolves inside the package, and only + a regular file (a symlink is refused); at most 50 MiB. +2. PNG, JPEG, GIF, or WebP, confirmed by the file's first bytes: copied in as + is. +3. Anything else on macOS: converted to PNG by `/usr/bin/sips` (TIFF, the + classic "Pasted Graphic.tiff", HEIC, BMP), from a copy in a temporary + folder, so `sips` never reads Stickies' folder itself. +4. Otherwise, the note says so where the image was: `[Not imported: name]`. + +Copied images get new `<uuid>.<ext>` names in the attachments folder and are +referenced as `![](attachments/<name>)`, exactly like a pasted image, so the +vault mirror, cleanup, and export treat them the same. + +## 6. Store + +`Store::import_notes(source, items, space) -> ImportOutcome`, one +transaction (the store's usual kind, so it becomes `IMMEDIATE` with the rest +when agent access lands): + +- skips any item whose source id maps to a note that still exists (Trash + included) in the `import.<source>` settings map; +- inserts the rest with their `created_at` and `updated_at`, `last_opened_at` + set to now, the title derived from the body, and inline tags attached; +- files each into the Space, creating it if new (none when `space` is empty); +- writes the updated map. + +A note destroyed for good frees its sticky to be imported again; one in the +Trash does not (restore it instead). The vault triggers mark every inserted +note dirty, so the mirror writes them on its next flush. + +## 7. Settings > Import + +Shown on macOS only (`isMac`); on Windows and Linux there is nothing to +import yet, so there is no empty page. + +- **Start:** what it does in one sentence; **Choose Stickies Folder…**; fine + print: Stickies keeps its notes, formatting Markdown cannot hold stays + behind. +- **Preview:** a grid of miniatures, each in the sticky's color with dark + ink whatever the theme (it is a picture of the sticky), the first line + bold, a few lines under it, the date, and an image count. Each is a toggle + button (`aria-pressed`). Already imported ones are dimmed, labeled, and + cannot be picked. Select all / none. "File them in" Space field. + **Import N Stickies**. +- **Done:** "Imported N stickies into Apple Stickies." **Show Them** opens the + Space and closes Settings. +- **Denied / empty / error:** as in section 2. + +## 8. Trust boundary + +The folder path comes from the webview, which chose it through the picker. +The commands read only: the folder listing, `*.rtfd/TXT.rtf`, the state +file, and attachments named by an RTF file, each checked as in section 5. +Nothing is ever written under the chosen folder. RTF is parsed with bounded +input (16 MiB) and no recursion, so a crafted file can cost time linear in its +size and nothing else. + +## 9. Tests + +- Converter: fixtures produced by `textutil` from HTML (formatting, nested + and numbered lists, links, cp1252, emoji, Cyrillic, escapes, an RTFD with + an image), checked in with a README saying how they were made, plus + focused cases (whitespace at emphasis edges, skipped destinations, + negative `\u`, `\ucN` fallback, unbalanced braces). +- Folder reader: packages vs flat files, missing `TXT.rtf`, the state file + (a real binary plist written by `plutil`), descending from the container. +- Store: dates kept, titles, inline tags, Space filing, skip on re-import, + re-import after destroy, one transaction. +- Shell: attachment name and symlink refusal, magic bytes. +- Frontend: the page's states, selection, and what it sends. +- End to end, by the maintainer: his own stickies. + +## 10. Not in this change + +- Watching Stickies and syncing changes back or forth. +- Recreating sticky windows (color, position) as InstantNotes stickies. +- Other sources. The page is where they go; none is built speculatively. diff --git a/openspec/changes/feat-stickies-import/proposal.md b/openspec/changes/feat-stickies-import/proposal.md new file mode 100644 index 0000000..7610f26 --- /dev/null +++ b/openspec/changes/feat-stickies-import/proposal.md @@ -0,0 +1,76 @@ +# Change: Import from Apple Stickies + +## Why + +People who move to InstantNotes on a Mac usually have a desktop of Apple +Stickies: phone numbers, lists, half-finished thoughts, some of them years +old. Retyping them is the reason they stay where they are. Stickies keeps +them as RTF files, so bringing them in is a file conversion. It needs no AI, +and doing it deterministically means the result is exactly what was on the +sticky. + +The maintainer's own note (2026-09-30) asked for this and named the +constraint: InstantNotes also ships for Windows and Linux, so a Mac-only +feature has to be built without making the app Mac-shaped. + +## What Changes + +- Settings gains an **Import** page (macOS only). Its first and only source + is Apple Stickies. +- **Choose Stickies Folder…** opens the system folder picker already at + Stickies' folder. The user clicks Open. That click is the permission: on + macOS 27 another app's data is denied without a prompt, and a folder the + user picks is the access the platform intends. +- The page shows every sticky as a miniature in its own color, with its + first lines and date. The user picks which to bring in (all, by default) + and which Space to file them in (`Apple Stickies`, editable, or none). +- Import copies each sticky into a note: bold, italic, strikethrough, links, + bulleted and numbered lists (nested), line breaks, and images. Original + created and modified dates are kept. `#words` become tags, as anywhere in + the app. Nothing inside Stickies is changed. +- Importing again brings in only the stickies not imported yet. +- A derived title no longer carries emphasis markers (a first line of + `**Groceries**` titles the note "Groceries", not "Groceries**") or an + image path (a note opening with a screenshot takes its first line of words), + for imported and typed notes alike. + +## Decisions + +- **Deterministic.** No model sees the stickies. Tagging an import with + existing tags is what a connected agent can do afterwards, through the + tools it already has; it is not part of importing. +- **Neutral core, Mac edges.** The RTF converter and the Stickies folder + reader are plain Rust in `instantnotes-core`, built and tested on all + three CI platforms. Only three things are macOS: where Stickies lives, + converting TIFF/HEIC images with the system's `sips`, and the Settings + entry. That is the answer to the maintainer's question: keep platform + facts at the edges and everything that decides behavior testable + everywhere. +- **Copy, never move.** The importer only reads Stickies' folder. +- **No migration.** Which stickies became which notes is one settings key, + `import.stickies`, written in the same transaction as the notes. Two + other units are open on `0.9.0-pre`, and a schema bump here would make a + library opened by this build unreadable by theirs. +- **Imported notes are not Revisit captures.** They are stamped as opened at + import time. Revisit means "things I captured and never came back to", and + forty stickies arriving at once would bury that list; the import's Space + is where to go through them. +- **One transaction.** All chosen stickies land, or none do. Images are + copied first; if the transaction fails, they are unreferenced and the + existing attachment cleanup removes them after its grace period. + +## Impact + +- Core: `src-tauri/core/src/import/` (new: `rtf.rs`, `stickies.rs`), + `store/import.rs` (new), `store/notes.rs` (`insert_note`, the one insert + path, now shared with `create_note`), `store.rs` and `store/settings.rs` + (helpers usable inside a transaction), `domain.rs` (`derive_title`), + `Cargo.toml` (`plist`, already in the app's dependency graph through Tauri). +- Shell: `src-tauri/src/commands/import.rs` (new), `shell/files.rs` (one + way to write an attachment, plus `store_image`), `lib.rs`. +- Frontend: `components/settings/SettingsImport.svelte`, `stickies-import.ts`, + `folder-picker.ts` (new; `vault-mirror.ts` uses it too), + `SettingsView.svelte`, `routes/+page.svelte`, `api/client.ts`, + `api/types.ts`. +- Docs: `docs/API.md` §16, `docs/DATA_MODEL.md` (the settings key), + `CHANGELOG.md`, `openspec/SEQUENCE.md`. diff --git a/openspec/changes/feat-stickies-import/tasks.md b/openspec/changes/feat-stickies-import/tasks.md new file mode 100644 index 0000000..1d58bd6 --- /dev/null +++ b/openspec/changes/feat-stickies-import/tasks.md @@ -0,0 +1,67 @@ +# Tasks: Import from Apple Stickies + +Built on `feat/stickies-import` (its own worktree), off `0.9.0-pre` at +`917dbd3`, apart from sticky notes (13c) and agent access (13d). + +## Decide first + +- [x] Deterministic conversion, no model (the maintainer: "we actually dont + need AI to do this") +- [x] Access through the folder picker: macOS 27 denies another developer's + container with no prompt, and a picked folder is the platform's own way in +- [x] RTF parsed in portable Rust, not by AppKit: the input is Cocoa's own + writer, the converter runs and is tested on all three CI platforms, and + there is no `unsafe` or main-thread rule to respect +- [x] Record of what was imported in a settings key, not a table: no + migration while two other units are open +- [x] Imported notes are stamped opened, so they do not flood Revisit +- [x] Images in web formats copied as they are, the rest converted by `sips` +- [x] Import page only where Stickies exists (macOS) + +## Build + +- [x] Core: `import/rtf.rs` (converter), `import/stickies.rs` (folder + reader), `store/import.rs` (`import_notes`, `imported_ids`), + `insert_note` shared with `create_note`, `workspace_get_or_create` and + `setting_put` shared with their methods, `store::iso` public, + `derive_title` drops emphasis markers +- [x] Shell: `commands/import.rs` (`stickies_location`, `scan_stickies`, + `import_stickies`); `shell/files.rs` gains `store_image` (sniffed by + first bytes, `sips` for the rest) and one `write_attachment` for the + three places that store an image +- [x] Frontend: `SettingsImport.svelte`, `stickies-import.ts`, + `folder-picker.ts` (the picker `vault-mirror.ts` now uses too), client + and types, `SettingsView` entry on macOS, `+page.svelte` opens the Space +- [x] Docs: API.md §16, DATA_MODEL.md §6 and §8, CHANGELOG, SEQUENCE 13e + +## Proof + +- [x] Converter against RTF from Cocoa's own writer (`textutil`), checked in + with its provenance: formatting, nested and numbered lists, a link, + cp1252, an emoji pair, Cyrillic, escapes, an RTFD image with its raw + placeholder byte, CRLF; plus the edges (emphasis and whitespace, + skipped destinations, negative `\u`, `\uc` fallback, broken input) +- [x] Folder reader: packages only, flat files and symlinks skipped, colors + from a real binary plist, an unreadable state file, newest first, + picking the container +- [x] Store: dates, titles, tags, the Space, once per library, the Trash, + destroyed notes, no empty Space, one pass end to end over the fixtures +- [x] Shell: attachment names that leave the package, links, non-images, and + a real TIFF made by `sips` stored as a PNG +- [x] Frontend: every state of the page, what it sends, the picker's start + folder, no Import page off macOS +- [x] 504 frontend tests (14 new), 252 Rust (32 new), clippy, fmt, + svelte-check + +## Before archiving + +- [ ] The maintainer imports his own stickies: Settings > Import, Choose + Stickies Folder…, Open, the board, Import, Show Them. This is also the + first run of the picker grant on macOS 27, which nothing here can + exercise without a person at the dialog +- [ ] Merge with 13c and 13d. Shared files: `SettingsView.svelte` (each adds + a page), `+page.svelte`, `client.ts`, `types.ts`, `lib.rs` (commands), + `docs/API.md` (§15 comes from 13d; this is §16), `CHANGELOG.md`, + `SEQUENCE.md`, `core/src/store.rs`, `core/src/store/notes.rs` (13d adds + `expected_updated_at` to `update_note` and `IMMEDIATE` transactions, + which `import_notes` then gets for free) diff --git a/openspec/changes/feat-sticky-notes/proposal.md b/openspec/changes/feat-sticky-notes/proposal.md new file mode 100644 index 0000000..d834b8f --- /dev/null +++ b/openspec/changes/feat-sticky-notes/proposal.md @@ -0,0 +1,55 @@ +# Change: Sticky Notes + +## Why + +A note that matters right now (a phone number mid-call, a checklist for the +next hour, the paragraph being drafted) is buried in the library the moment +another window takes focus. Parking it somewhere visible is the "trusted +parking" promise stated as a window: the loop stays in view until it closes, +and goes back into the library when it does. + +## What Changes + +- Any note or whiteboard can pop out of the library into its own small, + frameless window, and pop back in. +- A sticky sits at one of three levels, switchable from its header: above every + window on every Space (`float`, the default), as an ordinary window + (`normal`), or below every window on every Space like a desktop widget + (`desktop`). +- Entry points: the Sticky button in the editor toolbar, right-click on a note + row, the command palette, and File > Pop Out as Sticky (⌘⇧O), which toggles. +- Stickies reopen at launch where they were left. One whose note is gone or in + the Trash is dropped; one saved on a display that is no longer attached is + centered. + +## Decisions + +- **A sticky is a handoff, not a second view.** While a note is a sticky, its + window is the note's only editor and the library shows a placeholder with + Show Sticky and Bring Back. `update_note` is last-write-wins, so two live + editors on one note would silently overwrite each other; one writer per note + removes the problem instead of reconciling it. The one writer outside the + app, an agent over MCP (`feat-agent-access`), reaches a sticky exactly as + it reaches the library's open note: taken in place when nothing is unsaved, + and offered back by name when typing replaced it. +- **Popping out writes first.** The library flushes every pending edit and + refuses to pop out a note whose edit did not land, since the sticky loads + from disk. +- **Popping in waits for the sticky.** The shell asks the sticky to flush and + waits for its answer. A sticky whose save failed stays open with its text; a + sticky that never answers (a hung webview) is closed after 1.5s. +- **Trash and destroy pop in first**, and do not go ahead if a sticky cannot + save. +- **Quit waits for every window that holds edits**, not only the library. +- **Per-device window state, not note data.** One `stickies` row in the + settings table. No migration, no vault change. + +## Impact + +- Shell: `src-tauri/src/shell/stickies.rs` (new), `shell/quit.rs`, + `events.rs`, `lib.rs`, `capabilities/default.json`. +- Frontend: `src/routes/sticky/+page.svelte` and + `src/lib/stores/sticky.svelte.ts` (new), `library.svelte.ts`, + `NoteEditor.svelte`, `NoteList.svelte`, `commands.ts`, `+page.svelte`, and + the API client, types, and events. +- Docs: `docs/API.md` §9.1, `docs/DATA_MODEL.md` §8, `CHANGELOG.md`. diff --git a/openspec/changes/feat-sticky-notes/tasks.md b/openspec/changes/feat-sticky-notes/tasks.md new file mode 100644 index 0000000..e7645e5 --- /dev/null +++ b/openspec/changes/feat-sticky-notes/tasks.md @@ -0,0 +1,49 @@ +# Tasks: Sticky Notes + +Per-device window state on the existing settings table; no note shape change +and no vault change. See `SEQUENCE.md` §13c for where it sits. + +## Decide first + +- [x] Ownership. **A sticky is the note's only editor** (decided 2026-09-30); + the library shows a placeholder, never a second live editor. +- [x] Storage. **One `stickies` settings row** (decided 2026-09-30), window + state rather than note data. +- [x] Levels. **`float`, `normal`, `desktop`** (decided 2026-09-30). `desktop` + is Tauri's always-on-bottom, which on macOS is the below-normal window + level: under every app window, above the desktop icons. + +## Build + +- [x] `shell/stickies.rs`: window build, levels, pop out, pop in with the + flush answer, restore at launch, geometry, reachability check +- [x] Quit handshake waits for the library and every sticky +- [x] `stickies:changed`, `sticky:close-requested`, `menu:toggle-sticky` on + both sides of the event registry +- [x] Sticky window route and store (reusing `SaveQueue`) +- [x] Library: sticky set, pop out after a confirmed flush, reopen on pop in, + edit guards, pop in before trash and destroy +- [x] Entry points: toolbar button, row context menu, palette, File menu +- [x] Docs: API.md §9.1, DATA_MODEL.md §8, changelog +- [x] Header behaves as a title bar (found 2026-09-30: stickies did not move). + `core:default` does not grant `start_dragging`, so the drag region was + denied; a `stickies` capability grants it to `sticky-*` only. The window + accepts the first click, so an unfocused sticky drags in one gesture. + Double-click collapses to the header, as Stickies does, instead of the + drag region's zoom to full screen; collapsed state persists + +## Verify + +- [x] `cargo test --workspace` (reachability, labels, saved map, quit answers) +- [x] `npm test` (sticky store; library pop out, guards, pop in, trash) +- [x] `npm run check` +- [ ] In the app, against a copy of a real library: pop out, type, bring back, + and the text is in the library +- [ ] Type in a sticky and quit at once; the text is there on relaunch +- [ ] Relaunch restores each sticky's position, size, and level +- [ ] Each level behaves as named, including across Spaces +- [ ] Trash a sticky's note from the library; the sticky closes first +- [ ] A whiteboard as a sticky keeps its last stroke on bring back +- [ ] Drag a sticky by its header, including while another app is in front; + double-click the header to collapse and expand; relaunch keeps it + collapsed diff --git a/openspec/project.md b/openspec/project.md index 5d49812..d875677 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -18,6 +18,8 @@ Anti-goal: InstantNotes is not a task manager. Dates, checkboxes, and notificati - Tauri 2 desktop shell - Rust core for persistence, search, and command handling - SQLite with FTS5 for local storage and full-text search +- Markdown files with YAML frontmatter (`serde_norway`) for the vault export + and live mirror, with sha256 (`sha2`) to know which files the mirror wrote - Svelte 5 and TypeScript for the webview UI - CodeMirror 6 for the library editor - Vitest, svelte-check, and cargo test for verification @@ -37,6 +39,8 @@ Core behavior is covered with Rust tests against SQLite. Frontend utilities are Each public branch should contain one clear product step. Commit subjects stay short and concrete; bodies explain what changed in one or two natural sentences. ### Change Proposals +`openspec/SEQUENCE.md` holds the order changes land in, the definition of done that gates each one, and the rule that places anything new. One unit is in flight at a time and lives as uncommitted work until it is complete. + Active OpenSpec change proposals live in `openspec/changes/`. Once a change has shipped, its folder moves to `openspec/changes/archive/` so the active list only shows work in flight. The canonical IPC and storage contracts are documented in `docs/API.md` and `docs/DATA_MODEL.md`. ## Domain Context diff --git a/openspec/specs/instantnotes/spec.md b/openspec/specs/instantnotes/spec.md index ee68bfb..7f6af0f 100644 --- a/openspec/specs/instantnotes/spec.md +++ b/openspec/specs/instantnotes/spec.md @@ -22,15 +22,15 @@ The app SHALL store notes, tags, settings, and search index data locally in SQLi #### Scenario: Create a note - **WHEN** the user creates a note without a title - **THEN** the app derives the title from the first meaningful line of the body -- **AND** stores the note with timestamps and version metadata +- **AND** stores the note with created and updated timestamps #### Scenario: Update a note - **WHEN** the user edits note content -- **THEN** the app updates the note body, updated timestamp, version, and search index +- **THEN** the app updates the note body, updated timestamp, and search index - **AND** keeps a manually entered title unless the title is edited again ### Requirement: Library Organization -The app SHALL provide a library window for browsing, editing, tagging, archiving, and deleting notes, with a sidebar offering exactly two sections: All Notes and Workspaces. +The app SHALL provide a library window for browsing, editing, tagging, archiving, and deleting notes, with a sidebar offering All Notes, Graph, and Workspaces (shown as Spaces), plus Revisit while there are captures to revisit. #### Scenario: Browse all notes - **WHEN** the user chooses All Notes @@ -51,6 +51,18 @@ The app SHALL provide a library window for browsing, editing, tagging, archiving - **THEN** the sidebar tag counts update - **AND** selecting a tag filters the note list to matching notes +### Requirement: Graph View +The app SHALL draw the library as a graph of notes, tags, and Spaces, linked by the tags and Spaces each note carries, derived from the notes at read time with nothing about the graph stored. + +#### Scenario: See how notes connect +- **WHEN** the user opens Graph +- **THEN** every live note with a tag or a Space appears, joined to those tags and Spaces +- **AND** the app says how many notes have neither and are left out + +#### Scenario: Go from the graph +- **WHEN** the user chooses a note, a tag, or a Space on the graph +- **THEN** the app opens that note, filters by that tag, or opens that Space + ### Requirement: Workspaces The app SHALL let the user create named workspaces that collect notes and help organize them. @@ -82,6 +94,39 @@ The app SHALL provide full-text search over note title and body. - **THEN** the app sanitizes the query - **AND** does not expose a search syntax error to the user +### Requirement: Image Cleanup +The app SHALL remove a copied-in image once no note, in any state, and no capture draft references it any more, and SHALL never remove an image something still references. + +#### Scenario: Delete the last note using an image +- **WHEN** the user deletes a note for good and no other note references its images +- **THEN** those images are removed from the attachments folder +- **AND** from the vault's attachments folder when its copy is unchanged + +#### Scenario: Keep images a trashed note uses +- **WHEN** an image is referenced only by a note in the Trash or the Archive +- **THEN** the image is kept + +### Requirement: Whiteboard Notes +The app SHALL let a note be a whiteboard, a freeform canvas that stays a note: listed, tagged, filed in Spaces, trashed, and searched like any other. + +#### Scenario: Turn a note into a whiteboard +- **WHEN** the user turns a document into a whiteboard and confirms +- **THEN** the note's text appears on the board as a text block +- **AND** the note cannot be turned back into a document + +#### Scenario: Search a whiteboard +- **WHEN** the user searches for words written on a board +- **THEN** the board appears in the results +- **AND** every result is text visible on that board + +#### Scenario: Never lose the last stroke +- **WHEN** the user draws on a board and immediately switches notes, trashes the board, or quits +- **THEN** the drawing is saved + +#### Scenario: A board in the vault +- **WHEN** a vault folder is set and a board is saved +- **THEN** the vault holds the note file and a standard `.excalidraw` file beside it with the same name + ### Requirement: Privacy By Default The app SHALL avoid writing note content to logs or diagnostics. @@ -101,3 +146,22 @@ The app SHALL expose a macOS-oriented desktop shell with a tray entry, library w - **WHEN** the user closes the library window - **THEN** the app hides the window - **AND** keeps the desktop app running from the tray + +### Requirement: Software Updates +The app SHALL offer an available update as a notification - a synthetic Space in the sidebar, not a modal dialog - and SHALL store nothing about it. + +#### Scenario: An update is offered +- **WHEN** the app finds a newer release +- **THEN** an Update Space marked with a green asterisk appears first in the sidebar +- **AND** it holds the version jump, the size difference against the running version when known, and a button to install +- **AND** its release-notes note can be read in the ordinary editor +- **AND** neither note is written to the database or the vault + +#### Scenario: Answering an update +- **WHEN** the user answers a finished install with Ok +- **THEN** the Update Space disappears +- **AND** the installed update applies the next time the app opens + +#### Scenario: A manual check finds nothing +- **WHEN** the user asks to check for updates and none is available +- **THEN** the app says it is up to date, without a dialog diff --git a/package-lock.json b/package-lock.json index baeb15d..e974cad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instantnotes", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instantnotes", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@codemirror/commands": "^6", @@ -15,12 +15,16 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/state": "^6", "@codemirror/view": "^6", + "@excalidraw/excalidraw": "^0.18.1", "@lezer/highlight": "^1.2.3", "@lezer/markdown": "^1.7.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-updater": "^2.10.1" + "@tauri-apps/plugin-updater": "^2.12.0", + "d3-force": "^3.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", @@ -29,7 +33,10 @@ "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.4.2", + "@types/d3-force": "^3.0.10", "@types/node": "^25.9.3", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", "jsdom": "^29.1.1", "svelte": "^5.0.0", "svelte-check": "^4.0.0", @@ -48,6 +55,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@antfu/install-pkg": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", + "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.1" + }, + "funding": { + "url": "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/sponsors/antfu" + } + }, + "node_modules/@antfu/install-pkg/node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -135,12 +164,17 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", + "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -154,6 +188,45 @@ "specificity": "bin/cli.js" } }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.3", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", @@ -1126,6 +1199,379 @@ "node": ">=18" } }, + "node_modules/@excalidraw/excalidraw": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@excalidraw/excalidraw/-/excalidraw-0.18.1.tgz", + "integrity": "sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "6.0.2", + "@excalidraw/laser-pointer": "1.3.1", + "@excalidraw/mermaid-to-excalidraw": "2.2.2", + "@excalidraw/random-username": "1.1.0", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-tabs": "1.0.2", + "browser-fs-access": "0.29.1", + "canvas-roundrect-polyfill": "0.0.1", + "clsx": "1.1.1", + "cross-env": "7.0.3", + "es6-promise-pool": "2.5.0", + "fractional-indexing": "3.2.0", + "fuzzy": "0.1.3", + "image-blob-reduce": "3.0.1", + "jotai": "2.11.0", + "jotai-scope": "0.7.2", + "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", + "nanoid": "3.3.3", + "open-color": "1.9.1", + "pako": "2.0.3", + "perfect-freehand": "1.2.0", + "pica": "7.1.1", + "png-chunk-text": "1.0.0", + "png-chunks-encode": "1.0.0", + "png-chunks-extract": "1.0.0", + "points-on-curve": "1.0.1", + "pwacompat": "2.0.17", + "roughjs": "4.6.4", + "sass": "1.51.0", + "tunnel-rat": "0.1.2" + }, + "peerDependencies": { + "react": "^17.0.2 || ^18.2.0 || ^19.0.0", + "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz", + "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-roving-focus": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.0.tgz", + "integrity": "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz", + "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz", + "integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-collection": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-use-callback-ref": "1.0.0", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz", + "integrity": "sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/@excalidraw/laser-pointer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", + "integrity": "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==", + "license": "MIT" + }, + "node_modules/@excalidraw/markdown-to-text": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz", + "integrity": "sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==", + "license": "MIT" + }, + "node_modules/@excalidraw/mermaid-to-excalidraw": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz", + "integrity": "sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==", + "license": "MIT", + "dependencies": { + "@excalidraw/markdown-to-text": "0.1.2", + "@mermaid-js/parser": "^0.6.3", + "mermaid": "^11.12.1", + "nanoid": "4.0.2" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "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/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@excalidraw/random-username": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@excalidraw/random-username/-/random-username-1.1.0.tgz", + "integrity": "sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -1144,6 +1590,61 @@ } } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1377,6 +1878,15 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@mermaid-js/parser": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", + "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1384,31 +1894,441 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", - "cpu": [ - "arm" - ], - "dev": true, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", "license": "MIT", - "optional": true, - "os": [ + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ "android" ] }, @@ -2099,12 +3019,12 @@ } }, "node_modules/@tauri-apps/plugin-updater": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", - "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.12.0.tgz", + "integrity": "sha512-74+g7rSIHXQje3ttR6tYap3FJP/UtZqqYxGVF9EE0Kpz0YADxHX2Ynw/eIlNPMy9D1aG+F8oBosT+6nphSa8Og==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.11.0" } }, "node_modules/@testing-library/dom": { @@ -2229,41 +3149,338 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } }, - "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", - "dev": true, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "@types/d3-selection": "*" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", "license": "MIT" }, - "node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2411,6 +3628,43 @@ "url": "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/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "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/sponsors/jonschlinkert" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -2451,6 +3705,36 @@ "require-from-string": "^2.0.2" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "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/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-fs-access": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.29.1.tgz", + "integrity": "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==", + "license": "Apache-2.0" + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2461,6 +3745,12 @@ "node": ">=8" } }, + "node_modules/canvas-roundrect-polyfill": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz", + "integrity": "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==", + "license": "MIT" + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2488,6 +3778,32 @@ "node": ">= 16" } }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2514,6 +3830,15 @@ "node": ">=6" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -2524,32 +3849,588 @@ "node": ">= 0.6" } }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/crc-32": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "node": ">=12" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } }, "node_modules/data-urls": { "version": "7.0.0", @@ -2565,6 +4446,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2610,6 +4497,15 @@ "node": ">=0.10.0" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2620,6 +4516,12 @@ "node": ">=6" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devalue": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", @@ -2634,6 +4536,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2654,6 +4565,27 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/es6-promise-pool": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", + "integrity": "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2741,6 +4673,15 @@ "node": ">=12.0.0" } }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2759,11 +4700,31 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fractional-indexing": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", + "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", + "license": "CC0-1.0", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2774,6 +4735,47 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fuzzy": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz", + "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glur": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz", + "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==", + "license": "MIT" + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2787,6 +4789,43 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/image-blob-reduce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz", + "integrity": "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==", + "license": "MIT", + "dependencies": { + "pica": "^7.1.0" + } + }, + "node_modules/immutable": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", + "license": "MIT" + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "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/sponsors/wooorm" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2797,6 +4836,63 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2814,6 +4910,43 @@ "@types/estree": "^1.0.6" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jotai": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", + "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/jotai-scope": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/jotai-scope/-/jotai-scope-0.7.2.tgz", + "integrity": "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==", + "license": "MIT", + "peerDependencies": { + "jotai": ">=2.9.2", + "react": ">=17.0.0" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -2862,6 +4995,36 @@ } } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "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/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2872,6 +5035,28 @@ "node": ">=6" } }, + "node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -2879,6 +5064,42 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2916,6 +5137,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -2923,6 +5156,75 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mermaid": { + "version": "11.17.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "fastdom": "1.0.12", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/mermaid/node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/mermaid/node_modules/@mermaid-js/parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/mermaid/node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/mermaid/node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -2960,6 +5262,16 @@ "dev": true, "license": "MIT" }, + "node_modules/multimath": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz", + "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "object-assign": "^4.1.1" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -2979,6 +5291,42 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/open-color": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz", + "integrity": "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==", + "license": "MIT" + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/pako": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2992,6 +5340,21 @@ "url": "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/inikulin/parse5?sponsor=1" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3005,8 +5368,27 @@ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 14.16" + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-freehand": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz", + "integrity": "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==", + "license": "MIT" + }, + "node_modules/pica": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz", + "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "inherits": "^2.0.3", + "multimath": "^2.0.0", + "object-assign": "^4.1.1", + "webworkify": "^1.5.0" } }, "node_modules/picocolors": { @@ -3029,6 +5411,53 @@ "url": "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/sponsors/jonschlinkert" } }, + "node_modules/png-chunk-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz", + "integrity": "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==", + "license": "MIT" + }, + "node_modules/png-chunks-encode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz", + "integrity": "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0", + "sliced": "^1.0.1" + } + }, + "node_modules/png-chunks-extract": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", + "integrity": "sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0" + } + }, + "node_modules/points-on-curve": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz", + "integrity": "sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/points-on-path/node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -3083,6 +5512,37 @@ "node": ">=6" } }, + "node_modules/pwacompat": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz", + "integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==", + "license": "Apache-2.0" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -3090,6 +5550,75 @@ "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3128,6 +5657,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", @@ -3173,6 +5708,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/roughjs": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.4.tgz", + "integrity": "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/roughjs/node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -3186,6 +5745,77 @@ "node": ">=6" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", + "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", + "license": "MIT", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/sass/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "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/sponsors/jonschlinkert" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3199,6 +5829,15 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -3206,6 +5845,27 @@ "dev": true, "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3228,11 +5888,17 @@ "node": ">=18" } }, + "node_modules/sliced": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", + "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", + "deprecated": "Unsupported", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3252,6 +5918,12 @@ "dev": true, "license": "MIT" }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "license": "MIT" + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3284,6 +5956,12 @@ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/svelte": { "version": "5.56.3", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", @@ -3425,6 +6103,18 @@ "dev": true, "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -3461,6 +6151,30 @@ "node": ">=20" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -3492,6 +6206,71 @@ "dev": true, "license": "MIT" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "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/sponsors/broofa", + "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/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -3683,6 +6462,55 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", + "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -3712,6 +6540,12 @@ "node": ">=20" } }, + "node_modules/webworkify": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", + "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==", + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -3737,6 +6571,21 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3777,6 +6626,34 @@ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true, "license": "MIT" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 40ce85b..afdbc9f 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "name": "instantnotes", - "version": "0.8.0", + "version": "0.9.0", "description": "InstantNotes — instant capture, organized knowledge", "type": "module", "engines": { "node": ">=22" }, "scripts": { + "predev": "node scripts/copy-excalidraw-fonts.mjs", "dev": "vite dev", + "prebuild": "node scripts/copy-excalidraw-fonts.mjs", "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -25,12 +27,16 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/state": "^6", "@codemirror/view": "^6", + "@excalidraw/excalidraw": "^0.18.1", "@lezer/highlight": "^1.2.3", "@lezer/markdown": "^1.7.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-updater": "^2.10.1" + "@tauri-apps/plugin-updater": "^2.12.0", + "d3-force": "^3.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", @@ -39,7 +45,10 @@ "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.4.2", + "@types/d3-force": "^3.0.10", "@types/node": "^25.9.3", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", "jsdom": "^29.1.1", "svelte": "^5.0.0", "svelte-check": "^4.0.0", diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 69cf9ba..3d87f73 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -2,3 +2,4 @@ # and on all three CI runners, so builds cannot drift between machines. [toolchain] channel = "1.91.1" +components = ["rust-analyzer"] diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 6541683..ac8b81a 100644 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -104,6 +104,12 @@ function main(argv) { console.log(` 3. git tag v${next} && git push origin v${next} # CI builds, signs, publishes`); } -if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { +const argv1 = process.argv[1] ?? ""; +const isMain = + argv1 && + typeof pathToFileURL === "function" && + import.meta.url === pathToFileURL(argv1).href; + +if (isMain) { main(process.argv); } diff --git a/scripts/copy-excalidraw-fonts.mjs b/scripts/copy-excalidraw-fonts.mjs new file mode 100644 index 0000000..c17fcb6 --- /dev/null +++ b/scripts/copy-excalidraw-fonts.mjs @@ -0,0 +1,33 @@ +// Copy Excalidraw's fonts into static/excalidraw/ so whiteboards draw with +// them offline. Excalidraw otherwise fetches its fonts from a CDN, which the +// app's content security policy blocks by design: InstantNotes makes no +// network requests. The app points Excalidraw here through +// EXCALIDRAW_ASSET_PATH (src/lib/whiteboard/excalidraw.ts). +// +// Xiaolai, the hand-drawn CJK face, is left out: it is 12 MB of the 12.5, +// and would roughly double the app for one script's styling. CJK text on a +// board still renders, in the system's own CJK font. +// +// Runs before `npm run dev` and `npm run build` (the predev/prebuild +// scripts), so dev, CI, and release builds all carry the fonts. The copy is +// generated, not committed (.gitignore). + +import { cpSync, existsSync, readdirSync, rmSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const source = join(root, "node_modules", "@excalidraw", "excalidraw", "dist", "prod", "fonts"); +const target = join(root, "static", "excalidraw", "fonts"); + +if (!existsSync(source)) { + console.error(`Excalidraw fonts not found at ${source}. Run npm install.`); + process.exit(1); +} +rmSync(target, { recursive: true, force: true }); +cpSync(source, target, { + recursive: true, + filter: (path) => basename(path) !== "Xiaolai", +}); +const count = readdirSync(target, { recursive: true }).length; +console.log(`copied Excalidraw fonts (${count} entries) to static/excalidraw/fonts`); diff --git a/scripts/openspec-ledger.test.ts b/scripts/openspec-ledger.test.ts new file mode 100644 index 0000000..3dc6420 --- /dev/null +++ b/scripts/openspec-ledger.test.ts @@ -0,0 +1,38 @@ +// Guards openspec/SEQUENCE.md against the omission that actually happened: on +// 2026-09-24 `feat-update-notification` was archived with every task checked +// and shipped in 0.9.0, yet no unit was ever added to the ledger, so the file +// that says what a release contains silently under-reported it. Nothing failed, +// because nothing was checking. +// +// The invariant is self-describing rather than a baseline list. A change whose +// tasks.md records "Landed on <branch>" is by its own account a unit that +// landed, and every such unit must be named in SEQUENCE.md. Changes archived +// before SEQUENCE.md existed carry no such line and are not implicated, so this +// needs no allowlist to stay quiet about them. + +import { describe, it, expect } from "vitest"; +import sequenceRaw from "../openspec/SEQUENCE.md?raw"; + +const tasks = import.meta.glob("../openspec/changes/archive/*/tasks.md", { + query: "?raw", + import: "default", + eager: true, +}) as Record<string, string>; + +/** `../openspec/changes/archive/feat-graph-view/tasks.md` -> `feat-graph-view` */ +const changeName = (path: string) => path.split("/").at(-2)!; + +describe("the SEQUENCE ledger", () => { + it("names every archived change that records landing on a release branch", () => { + const landed = Object.entries(tasks) + .filter(([, md]) => md.includes("Landed on")) + .map(([path]) => changeName(path)) + .sort(); + + // Also catches the glob matching nothing, which would make this vacuous. + expect(landed.length).toBeGreaterThan(0); + + const missing = landed.filter((name) => !sequenceRaw.includes(name)); + expect(missing).toEqual([]); + }); +}); diff --git a/scripts/tauri-dev.mjs b/scripts/tauri-dev.mjs index 5876b0f..ab94ef5 100644 --- a/scripts/tauri-dev.mjs +++ b/scripts/tauri-dev.mjs @@ -19,6 +19,11 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +// This project's Vite dev port. Keep in sync with vite.config.js and +// src-tauri/tauri.conf.json (build.devUrl). Each Tauri project uses its own +// port so several can run side by side. +const DEV_PORT = 1422; + function sh(cmd) { try { return execSync(cmd, { encoding: "utf8" }); @@ -53,9 +58,11 @@ if (process.platform !== "win32") { } } - // 2) Free the Vite dev port so a fresh server is used (not a stale one). - const onPort = sh("lsof -ti tcp:1420").trim(); - if (onPort) for (const pid of onPort.split("\n")) killPid(pid, "stale dev server on :1420"); + // 2) Free this project's Vite dev port so a fresh server is used (not a + // stale one). Only InstantNotes' own port is touched, so dev servers of + // other Tauri projects - each on their own port - keep running. + const onPort = sh(`lsof -ti tcp:${DEV_PORT}`).trim(); + if (onPort) for (const pid of onPort.split("\n")) killPid(pid, `stale dev server on :${DEV_PORT}`); } // 3) Launch fresh, pointed at the real notes DB (the installed app's @@ -77,7 +84,13 @@ const child = spawn( ["dev", "--config", "src-tauri/tauri.dev.conf.json"], { stdio: "inherit", - env: { ...process.env, INSTANTNOTES_DB_PATH: dbPath }, + env: { + ...process.env, + INSTANTNOTES_DB_PATH: dbPath, + ...(process.platform === "linux" && !process.env.WEBKIT_DISABLE_DMABUF_RENDERER + ? { WEBKIT_DISABLE_DMABUF_RENDERER: "1" } + : {}), + }, // .cmd shims only execute through a shell. shell: process.platform === "win32", }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dc8b651..67c0e66 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,23 +8,11 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -37,27 +25,27 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -161,7 +149,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -190,13 +178,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -246,6 +234,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -269,9 +263,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -296,9 +290,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -309,9 +303,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -320,9 +314,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -345,9 +339,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -363,9 +357,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -376,7 +370,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cairo-sys-rs", "glib", "libc", @@ -397,9 +391,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -424,7 +418,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -439,9 +433,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "shlex", @@ -476,9 +470,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "chrono" @@ -494,9 +488,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -513,14 +507,24 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "time", "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -543,8 +547,8 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.13.0", - "core-foundation", + "bitflags 2.13.2", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -556,8 +560,8 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.13.0", - "core-foundation", + "bitflags 2.13.2", + "core-foundation 0.10.1", "libc", ] @@ -572,27 +576,27 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-common" @@ -624,7 +628,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -645,9 +649,9 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -655,46 +659,76 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", "windows-sys 0.61.2", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -706,7 +740,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -727,7 +761,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -767,7 +801,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -775,13 +809,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -804,7 +838,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -815,7 +849,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash 0.2.0", + "foldhash", "html5ever", "precomputed-hash", "selectors", @@ -875,14 +909,14 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "embed-resource" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "vswhom", "winreg", ] @@ -917,7 +951,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -949,11 +983,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -982,9 +1015,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fdeflate" @@ -1017,18 +1050,19 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -1037,12 +1071,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1061,13 +1089,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1087,24 +1115,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1113,9 +1141,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1132,32 +1160,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -1313,15 +1341,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] @@ -1362,7 +1388,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "futures-channel", "futures-core", "futures-executor", @@ -1390,7 +1416,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1405,9 +1431,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "global-hotkey" @@ -1421,7 +1447,7 @@ dependencies = [ "objc2-app-kit", "once_cell", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.59.0", "x11rb", "xkeysym", @@ -1487,7 +1513,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1498,20 +1524,11 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -1519,14 +1536,17 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.17.1", ] [[package]] @@ -1543,9 +1563,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -1565,9 +1585,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1575,9 +1595,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1585,9 +1605,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1610,9 +1630,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -1630,9 +1650,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1661,9 +1681,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1702,9 +1724,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1716,9 +1738,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1729,9 +1751,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1743,16 +1765,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1763,15 +1786,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1782,12 +1805,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1841,9 +1858,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1862,8 +1879,9 @@ dependencies = [ [[package]] name = "instantnotes" -version = "0.8.0" +version = "0.9.0" dependencies = [ + "instantnotes-agents", "instantnotes-core", "serde", "serde_json", @@ -1876,7 +1894,17 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "uuid", - "window-vibrancy", + "window-vibrancy 0.8.1", +] + +[[package]] +name = "instantnotes-agents" +version = "0.9.0" +dependencies = [ + "chrono", + "instantnotes-core", + "serde", + "serde_json", ] [[package]] @@ -1884,19 +1912,22 @@ name = "instantnotes-core" version = "0.8.0" dependencies = [ "chrono", + "plist", "rusqlite", "serde", "serde_json", + "serde_norway", + "sha2", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "uuid", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-docker" @@ -1946,6 +1977,60 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -1974,7 +2059,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -1989,7 +2074,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2017,14 +2102,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -2059,17 +2144,11 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "serde", "unicode-segmentation", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libappindicator" version = "0.9.0" @@ -2096,9 +2175,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -2121,18 +2200,18 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -2147,9 +2226,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2162,9 +2241,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "markup5ever" @@ -2179,9 +2258,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -2214,11 +2293,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -2237,9 +2326,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -2252,7 +2341,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -2262,7 +2351,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2320,7 +2409,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2339,11 +2428,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "objc2", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2352,7 +2442,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -2373,7 +2463,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -2384,7 +2474,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "dispatch2", "objc2", "objc2-core-foundation", @@ -2417,7 +2507,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -2444,7 +2534,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -2457,7 +2547,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", ] @@ -2468,7 +2558,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-app-kit", "objc2-foundation", @@ -2480,7 +2570,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -2492,7 +2582,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "objc2", "objc2-cloud-kit", @@ -2523,7 +2613,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "objc2", "objc2-app-kit", @@ -2539,14 +2629,13 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.3.5" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] @@ -2582,7 +2671,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -2639,12 +2728,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2692,7 +2775,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2723,18 +2806,18 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", + "base64 0.23.1", + "indexmap 2.14.2", "quick-xml", "serde", "time", @@ -2750,7 +2833,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -2759,11 +2842,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -2780,11 +2863,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2801,16 +2899,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2837,7 +2925,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -2866,33 +2954,33 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2921,45 +3009,44 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2969,9 +3056,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2986,11 +3073,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-core", "futures-util", @@ -3061,25 +3148,36 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3092,11 +3190,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -3105,9 +3203,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -3131,9 +3229,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] @@ -3144,7 +3242,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -3167,9 +3265,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -3178,9 +3276,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3229,9 +3333,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -3248,7 +3352,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3263,8 +3367,8 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", - "core-foundation", + "bitflags 2.13.2", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3286,7 +3390,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cssparser", "derive_more", "log", @@ -3311,9 +3415,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3333,22 +3437,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -3359,14 +3463,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3375,15 +3479,28 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_norway" +version = "0.9.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "ryu", + "serde", + "unsafe-libyaml-norway", +] + [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -3406,18 +3523,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -3426,14 +3544,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -3455,7 +3573,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3496,15 +3614,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -3530,15 +3648,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3592,6 +3710,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3636,9 +3766,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" dependencies = [ "base64 0.21.7", "serde", @@ -3657,9 +3787,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3677,13 +3818,34 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -3705,9 +3867,9 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3741,13 +3903,13 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3769,9 +3931,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.2" +version = "2.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4" dependencies = [ "anyhow", "bytes", @@ -3810,21 +3972,21 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tray-icon", "url", "webkit2gtk", "webview2-com", - "window-vibrancy", + "window-vibrancy 0.6.0", "windows", ] [[package]] name = "tauri-build" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -3843,9 +4005,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -3859,9 +4021,9 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.117", + "syn 2.0.119", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "uuid", @@ -3870,23 +4032,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -3900,9 +4062,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.7.1" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" dependencies = [ "log", "raw-window-handle", @@ -3912,15 +4074,15 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.5.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" dependencies = [ "anyhow", "dunce", @@ -3935,8 +4097,8 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", "url", ] @@ -3952,14 +4114,14 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "tauri-plugin-opener" -version = "2.5.4" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" dependencies = [ "dunce", "glob", @@ -3971,7 +4133,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "windows", "zbus", @@ -3989,14 +4151,15 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.2" +version = "2.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0" dependencies = [ "serde", "serde_json", "tauri", - "thiserror 2.0.18", + "thiserror 2.0.20", + "tokio", "tracing", "windows-sys 0.60.2", "zbus", @@ -4004,9 +4167,9 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" -version = "2.10.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" dependencies = [ "base64 0.22.1", "dirs", @@ -4027,7 +4190,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "url", @@ -4037,9 +4200,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.2" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -4053,7 +4216,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -4062,9 +4225,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.2" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -4088,9 +4251,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", @@ -4116,8 +4279,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.6+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4132,7 +4295,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", ] [[package]] @@ -4142,7 +4305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4150,12 +4313,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ "new_debug_unreachable", - "utf-8", ] [[package]] @@ -4169,11 +4331,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -4184,28 +4346,27 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -4215,15 +4376,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -4231,9 +4392,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -4241,24 +4402,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -4270,9 +4422,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -4280,9 +4432,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -4309,7 +4461,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -4320,17 +4472,17 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4366,7 +4518,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -4377,7 +4529,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -4386,30 +4538,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -4432,7 +4584,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -4475,7 +4627,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4489,9 +4641,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.23.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs", @@ -4505,7 +4657,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -4581,9 +4733,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -4592,10 +4744,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unsafe-libyaml-norway" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" [[package]] name = "untrusted" @@ -4628,12 +4780,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4642,11 +4788,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.3" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -4717,27 +4863,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -4748,9 +4885,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.73" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -4758,9 +4895,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4768,48 +4905,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -4823,23 +4938,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.100" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -4847,9 +4950,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ "phf", "phf_codegen", @@ -4903,9 +5006,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -4932,7 +5035,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4941,7 +5044,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "windows", "windows-core 0.61.2", ] @@ -4992,6 +5095,22 @@ dependencies = [ "windows-version", ] +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + [[package]] name = "windows" version = "0.61.3" @@ -5059,7 +5178,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5070,7 +5189,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5095,6 +5214,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5397,9 +5527,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5414,105 +5544,17 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" @@ -5547,7 +5589,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", @@ -5625,21 +5667,21 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "synstructure", ] [[package]] name = "zbus" -version = "5.16.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -5664,7 +5706,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.3", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -5672,14 +5714,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.16.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "zbus_names", "zvariant", "zvariant_utils", @@ -5687,33 +5729,22 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow 1.0.3", + "winnow 1.0.4", "zvariant", ] [[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" +name = "zcheapstr" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "serde", ] [[package]] @@ -5727,13 +5758,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "synstructure", ] @@ -5745,9 +5776,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -5756,9 +5787,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -5767,13 +5798,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -5784,52 +5815,59 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.3", + "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.4.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", - "winnow 1.0.3", + "syn 3.0.6", + "winnow 1.0.4", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9c1b8bf..f70a3cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,11 +1,11 @@ [workspace] -members = ["core"] +members = ["core", "agents"] resolver = "2" [package] license = "PolyForm-Strict-1.0.0" name = "instantnotes" -version = "0.8.0" +version = "0.9.0" description = "InstantNotes — instant capture, organized knowledge" edition = "2021" @@ -24,7 +24,8 @@ tauri-plugin-process = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" instantnotes-core = { path = "core" } -serde = { version = "1", features = ["derive"] } +instantnotes-agents = { path = "agents" } +serde ={ version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } tauri-plugin-dialog = "2" @@ -32,7 +33,7 @@ tauri-plugin-dialog = "2" # Native macOS window materials (Liquid Glass / vibrancy). macOS-only; the app # ships for Apple Silicon, but the cfg keeps non-macOS builds honest. [target."cfg(target_os = \"macos\")".dependencies] -window-vibrancy = "0.6" +window-vibrancy = "0.8" # Smaller, faster release binaries (Tauri's recommended profile). Lives in the # workspace root manifest so it applies to the whole workspace. diff --git a/src-tauri/agents/Cargo.toml b/src-tauri/agents/Cargo.toml new file mode 100644 index 0000000..20ae09a --- /dev/null +++ b/src-tauri/agents/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "instantnotes-agents" +version = "0.9.0" +description = "InstantNotes agent access: an MCP server over stdio. No UI, no Tauri." +edition = "2021" + +[dependencies] +instantnotes-core = { path = "../core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } diff --git a/src-tauri/agents/src/access.rs b/src-tauri/agents/src/access.rs new file mode 100644 index 0000000..0b08147 --- /dev/null +++ b/src-tauri/agents/src/access.rs @@ -0,0 +1,47 @@ +//! Whether agents may act, and how far: the level the user set in +//! Settings > Agents, re-read on every call so a change applies at once. + +use crate::fail; +use instantnotes_core::Store; +use serde_json::Value; + +/// Settings key holding the access level; written by Settings > Agents. +pub const ACCESS_KEY: &str = "agents.access"; + +/// What agents may do. Off until the user turns it on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Access { + Off, + Read, + Write, +} + +impl Access { + /// The current level, read from settings. Anything unreadable is `Off`. + pub fn of(store: &Store) -> Access { + match store.get_setting(ACCESS_KEY) { + Ok(Some(Value::String(s))) if s == "read" => Access::Read, + Ok(Some(Value::String(s))) if s == "write" => Access::Write, + _ => Access::Off, + } + } + + /// Let through a call that needs `needed`, or say why not in words the + /// agent can pass on to the user. + pub(crate) fn check(store: &Store, needed: Access) -> Result<(), String> { + let granted = Access::of(store); + if granted < needed { + return Err(match granted { + Access::Off => "Agent access is off. The user can turn it on in InstantNotes, Settings > Agents.", + _ => "Agent access is read only. The user can allow writing in InstantNotes, Settings > Agents.", + } + .into()); + } + // A newer app that migrated the file since this process opened it + // owns the schema now; this build's writes could be wrong for it. + if needed == Access::Write && !store.schema_is_current().map_err(fail)? { + return Err("InstantNotes was updated since this connection started. Restart the connection to keep writing.".into()); + } + Ok(()) + } +} diff --git a/src-tauri/agents/src/activity.rs b/src-tauri/agents/src/activity.rs new file mode 100644 index 0000000..eb99bec --- /dev/null +++ b/src-tauri/agents/src/activity.rs @@ -0,0 +1,90 @@ +//! The trace of agent calls, kept in the library where the app reads it to +//! show, on the notes themselves, what an agent is looking at or changing. +//! Writing it is also what tells the app anything happened: a read changes +//! no note, but this row moves SQLite's `data_version`. + +use instantnotes_core::Store; +use serde_json::{json, Value}; + +/// Settings key holding the most recent agent calls, newest first. +pub const ACTIVITY_KEY: &str = "agents.activity"; +const KEEP: usize = 30; +/// Ids recorded per call; enough to light up a full page of results. +const NOTE_IDS: usize = 50; +const TITLES: usize = 3; + +/// Where a call looked, from its arguments. +pub(crate) struct Scope { + space: Option<String>, + tag: Option<String>, + query: Option<String>, +} + +impl Scope { + pub(crate) fn of(args: &Value) -> Scope { + let field = |k: &str| args.get(k).and_then(Value::as_str).map(str::to_string); + Scope { + space: field("space"), + tag: field("tag"), + query: field("query"), + } + } +} + +/// Record one successful call. Best effort: an agent's call never fails +/// because its trace could not be written. +pub(crate) fn record( + store: &mut Store, + client: &str, + tool: &str, + wrote: bool, + scope: &Scope, + result: &Value, +) { + let notes = touched_notes(result); + let entry = json!({ + "at": now_ms(), + "client": client, + "tool": tool, + "kind": if wrote { "write" } else { "read" }, + "noteIds": notes.iter().take(NOTE_IDS).map(|(id, _)| id).collect::<Vec<_>>(), + "noteCount": notes.len(), + "titles": notes.iter().take(TITLES).map(|(_, t)| t).collect::<Vec<_>>(), + "space": scope.space, + "tag": scope.tag, + "query": scope.query, + }); + let mut log = match store.get_setting(ACTIVITY_KEY) { + Ok(Some(Value::Array(log))) => log, + _ => Vec::new(), + }; + log.insert(0, entry); + log.truncate(KEEP); + let _ = store.set_setting(ACTIVITY_KEY, Value::Array(log)); +} + +/// The notes a result is about, as (id, title): the note itself, or every +/// note in a list or search result. +fn touched_notes(result: &Value) -> Vec<(String, String)> { + let pair = |v: &Value| { + let id = v.get("id").and_then(Value::as_str)?; + let title = v.get("title").and_then(Value::as_str).unwrap_or_default(); + Some((id.to_string(), title.to_string())) + }; + if let Some(one) = pair(result) { + return vec![one]; + } + ["notes", "results"] + .iter() + .filter_map(|k| result.get(*k).and_then(Value::as_array)) + .flatten() + .filter_map(pair) + .collect() +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default() +} diff --git a/src-tauri/agents/src/lib.rs b/src-tauri/agents/src/lib.rs new file mode 100644 index 0000000..801d293 --- /dev/null +++ b/src-tauri/agents/src/lib.rs @@ -0,0 +1,93 @@ +//! Agent access: InstantNotes as a Model Context Protocol server. +//! +//! Any agent or coding CLI (Claude Code, Codex, Cursor, Claude Desktop) +//! reads and writes the library by launching the app's own binary in a +//! second mode: +//! +//! ```text +//! instantnotes mcp --db <library.db> [--attachments <dir>] +//! ``` +//! +//! That process opens the same SQLite file through `instantnotes-core`, so +//! every business rule (titles, inline tags, trash) is the app's own, and +//! speaks MCP on stdin/stdout. It does not need the app to be running; when +//! the app is running, it notices the writes through SQLite's `data_version` +//! (see `src-tauri/src/shell/agents.rs`). +//! +//! Layout: `protocol` is the JSON-RPC loop, `tools` is the tool surface, +//! `access` is the permission gate in front of it, `activity` is the trace +//! the app reads back, and this file is the command-line entry point. The +//! crate never touches Tauri, so it is tested without a window. +//! +//! Invariants: +//! - stdout carries protocol messages only; stderr carries nothing that +//! includes note content (openspec/project.md: content never in logs). +//! - Access is off until the user turns it on in Settings > Agents, and is +//! re-read on every call, so turning it off applies immediately. +//! - The store is opened with `Store::open`, never `open_or_recover`: an agent +//! process must never be the one to set a library aside. + +mod access; +mod activity; +mod protocol; +mod tools; + +pub use access::{Access, ACCESS_KEY}; +pub use activity::ACTIVITY_KEY; +pub use protocol::serve; + +use instantnotes_core::{AppError, Store}; +use std::ffi::OsString; +use std::io::{stdin, stdout}; +use std::path::PathBuf; + +/// The subcommand that selects this mode. +pub const SUBCOMMAND: &str = "mcp"; + +/// Entry point for `main()`: returns `None` when the arguments do not ask +/// for the MCP server, so the app starts as usual, and the exit code when +/// they do. +pub fn run_from_args(mut args: impl Iterator<Item = OsString>) -> Option<i32> { + if args.next().as_deref() != Some(SUBCOMMAND.as_ref()) { + return None; + } + let mut db: Option<PathBuf> = None; + let mut attachments: Option<PathBuf> = None; + while let Some(flag) = args.next() { + let value = args.next().map(PathBuf::from); + match (flag.to_str(), value) { + (Some("--db"), Some(v)) => db = Some(v), + (Some("--attachments"), Some(v)) => attachments = Some(v), + _ => return Some(usage()), + } + } + let Some(db) = db else { + return Some(usage()); + }; + let mut store = match Store::open(&db) { + Ok(store) => store, + Err(e) => { + // The error names the file, never a note. + eprintln!("instantnotes mcp: cannot open the library: {e}"); + return Some(1); + } + }; + match serve(&mut store, attachments, stdin().lock(), stdout().lock()) { + Ok(()) => Some(0), + Err(e) => { + eprintln!("instantnotes mcp: {e}"); + Some(1) + } + } +} + +fn usage() -> i32 { + eprintln!("usage: instantnotes mcp --db <library.db> [--attachments <dir>]"); + 2 +} + +/// A store error as the agent sees it: the stable code first, so a model +/// can branch on `CONFLICT` or `NOT_FOUND`. +fn fail(e: AppError) -> String { + format!("{}: {e}", e.code()) +} diff --git a/src-tauri/agents/src/protocol.rs b/src-tauri/agents/src/protocol.rs new file mode 100644 index 0000000..22552f6 --- /dev/null +++ b/src-tauri/agents/src/protocol.rs @@ -0,0 +1,257 @@ +//! The MCP stdio transport: newline-delimited JSON-RPC 2.0, served in both +//! eras of the specification (a "dual-era" server, 2026-07-28 versioning.md): +//! +//! - Modern (2026-07-28): stateless. Every request names its protocol version +//! in `params._meta`, `server/discover` describes the server, and every +//! result carries `resultType`. +//! - Legacy (2025-11-25 back to 2024-11-05): an `initialize` handshake picks +//! the version for the rest of the process. 2025-03-26 also lets a client +//! send a batch (a JSON array of messages) on one line. +//! +//! A request is modern exactly when its `_meta` names a modern version. The +//! methods are few and stable, so this is written against `serde_json` +//! directly rather than pulling in an async SDK and its runtime. + +use crate::tools::Tools; +use instantnotes_core::Store; +use serde_json::{json, Map, Value}; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +/// Stateless revisions: the version travels on every request. +const MODERN_VERSIONS: &[&str] = &["2026-07-28"]; +/// Handshake revisions, newest first. `initialize` asking for one of them +/// gets it back; any other gets the newest. +const LEGACY_VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]; + +const VERSION_META: &str = "io.modelcontextprotocol/protocolVersion"; +const CLIENT_META: &str = "io.modelcontextprotocol/clientInfo"; +const SERVER_META: &str = "io.modelcontextprotocol/serverInfo"; + +/// How long a client may keep `tools/list` and `server/discover`: both are +/// fixed for the life of this binary. +const CACHE_TTL_MS: u64 = 60 * 60 * 1000; + +// JSON-RPC error codes, and the one MCP adds. +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; +const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022; + +const INSTRUCTIONS: &str = "You are connected to InstantNotes, the user's \ +personal notes app: a place to park thoughts fast and trust they come back. \ +The user sees what you read and change as you do it, highlighted in the app. + +Library: notes, tags, and Spaces. A Space is a named collection for one \ +effort; a note can be in several. A note's title is its first line unless set. + +Formatting, as the editor renders it: Markdown with GitHub extensions. \ +# headings, **bold**, *italic*, ~~strike~~, ==highlight==, `code`, fenced code \ +blocks, > quotes, - lists (indent to nest), - [ ] tasks, [links](url), and \ +images as ![](attachments/<file>). A #word in the text is a tag. + +Working with notes: search or list before creating, so you add to an existing \ +note instead of duplicating it. Prefer append_to_note to add to a note. To \ +rewrite one, read it with get_note and pass its updatedAt to update_note; a \ +CONFLICT means the user changed it since, so read it again. Nothing you do \ +deletes for good: trash_note is undoable by the user. + +Open loops: list_notes with status \"revisit\" gives captures the user has \ +not come back to, oldest first. That is the list to help close, in a Space or \ +across the library. InstantNotes is not a task manager: do not add dates, \ +reminders, or checklists the user did not ask for."; + +/// Serve one client until stdin closes. +pub fn serve( + store: &mut Store, + attachments_dir: Option<PathBuf>, + mut input: impl BufRead, + mut output: impl Write, +) -> io::Result<()> { + let mut tools = Tools::new(store, attachments_dir); + let mut line = Vec::new(); + loop { + line.clear(); + if input.read_until(b'\n', &mut line)? == 0 { + return Ok(()); + } + // A line that is not UTF-8 is one bad message, not a dead server. + let reply = match std::str::from_utf8(&line) { + Ok(text) if text.trim().is_empty() => continue, + Ok(text) => handle_line(&mut tools, text), + Err(_) => Some(error(Value::Null, PARSE_ERROR, "parse error")), + }; + if let Some(reply) = reply { + serde_json::to_writer(&mut output, &reply)?; + output.write_all(b"\n")?; + output.flush()?; + } + } +} + +/// One line: a message or a batch of them, to at most one line back. +fn handle_line(tools: &mut Tools, line: &str) -> Option<Value> { + match serde_json::from_str(line) { + Err(_) => Some(error(Value::Null, PARSE_ERROR, "parse error")), + Ok(Value::Array(batch)) if batch.is_empty() => { + Some(error(Value::Null, INVALID_REQUEST, "empty batch")) + } + Ok(Value::Array(batch)) => { + let replies: Vec<Value> = batch.into_iter().filter_map(|m| handle(tools, m)).collect(); + (!replies.is_empty()).then_some(Value::Array(replies)) + } + Ok(message) => handle(tools, message), + } +} + +/// One message to at most one reply. Notifications, and responses (this +/// server sends no requests, so any response answers nothing), get none. +fn handle(tools: &mut Tools, message: Value) -> Option<Value> { + let Value::Object(msg) = message else { + return Some(error(Value::Null, INVALID_REQUEST, "invalid request")); + }; + let method = msg.get("method").and_then(Value::as_str); + if method.is_none() && (msg.contains_key("result") || msg.contains_key("error")) { + return None; + } + let id = match msg.get("id") { + None => None, + Some(id @ (Value::String(_) | Value::Number(_))) => Some(id.clone()), + // MCP request ids are strings or integers, never null. + Some(_) => return Some(error(Value::Null, INVALID_REQUEST, "invalid request id")), + }; + let well_formed = msg.get("jsonrpc").and_then(Value::as_str) == Some("2.0"); + let (Some(method), true) = (method, well_formed) else { + return id.map(|id| error(id, INVALID_REQUEST, "invalid request")); + }; + let id = id?; + let params = msg.get("params").cloned().unwrap_or(Value::Null); + Some(match request(tools, method, ¶ms) { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err(err) => json!({ "jsonrpc": "2.0", "id": id, "error": err }), + }) +} + +/// A request's result, or its JSON-RPC error object. +fn request(tools: &mut Tools, method: &str, params: &Value) -> Result<Value, Value> { + let modern = match params.get("_meta").and_then(|m| m.get(VERSION_META)) { + None => false, + Some(Value::String(v)) if MODERN_VERSIONS.contains(&v.as_str()) => true, + // A legacy client that names its version anyway. + Some(Value::String(v)) if LEGACY_VERSIONS.contains(&v.as_str()) => false, + Some(requested) => { + return Err(json!({ + "code": UNSUPPORTED_PROTOCOL_VERSION, + "message": "Unsupported protocol version", + "data": { "supported": supported_versions(), "requested": requested }, + })) + } + }; + if modern { + let client = params.pointer(&format!("/_meta/{}/name", CLIENT_META.replace('/', "~1"))); + if let Some(name) = client.and_then(Value::as_str) { + tools.set_client(name); + } + } + let mut result = match method { + // The probe a dual-era client sends first, so it answers in any era. + "server/discover" => { + return Ok(complete(json!({ + "supportedVersions": supported_versions(), + "capabilities": capabilities(), + "instructions": INSTRUCTIONS, + "ttlMs": CACHE_TTL_MS, + "cacheScope": "private", + }))) + } + "initialize" => { + let client = params.pointer("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/clientInfo/name").and_then(Value::as_str); + tools.set_client(client.unwrap_or_default()); + initialize(params) + } + "ping" => json!({}), + "tools/list" => json!({ "tools": tools.list() }), + "tools/call" => call(tools, params)?, + _ => return Err(error_object(METHOD_NOT_FOUND, "method not found")), + }; + if modern { + if method == "tools/list" { + result["ttlMs"] = json!(CACHE_TTL_MS); + result["cacheScope"] = json!("private"); + } + result = complete(result); + } + Ok(result) +} + +/// `tools/call`. A request that fails the call's own schema, or names a tool +/// that does not exist, is a protocol error; anything the tool itself +/// refuses is a result with `isError`, which the model sees and can fix. +fn call(tools: &mut Tools, params: &Value) -> Result<Value, Value> { + let Some(name) = params.get("name").and_then(Value::as_str) else { + return Err(error_object(INVALID_PARAMS, "tools/call needs a name")); + }; + if !tools.knows(name) { + return Err(error_object( + INVALID_PARAMS, + &format!("Unknown tool: {name}"), + )); + } + let args = match params.get("arguments") { + None | Some(Value::Null) => json!({}), + Some(args @ Value::Object(_)) => args.clone(), + Some(_) => return Err(error_object(INVALID_PARAMS, "arguments must be an object")), + }; + Ok(tools.call(name, args)) +} + +fn initialize(params: &Value) -> Value { + let requested = params.get("protocolVersion").and_then(Value::as_str); + let version = requested + .filter(|v| LEGACY_VERSIONS.contains(v)) + .unwrap_or(LEGACY_VERSIONS[0]); + json!({ + "protocolVersion": version, + "capabilities": capabilities(), + "serverInfo": server_info(), + "instructions": INSTRUCTIONS, + }) +} + +/// A modern result: `resultType`, and who answered. +fn complete(mut result: Value) -> Value { + let Value::Object(fields) = &mut result else { + return result; + }; + fields.insert("resultType".into(), json!("complete")); + let meta = fields + .entry("_meta") + .or_insert_with(|| Value::Object(Map::new())); + meta[SERVER_META] = server_info(); + result +} + +fn supported_versions() -> Vec<&'static str> { + MODERN_VERSIONS + .iter() + .chain(LEGACY_VERSIONS) + .copied() + .collect() +} + +fn capabilities() -> Value { + json!({ "tools": {} }) +} + +fn server_info() -> Value { + json!({ "name": "instantnotes", "title": "InstantNotes", "version": env!("CARGO_PKG_VERSION") }) +} + +fn error_object(code: i64, message: &str) -> Value { + json!({ "code": code, "message": message }) +} + +fn error(id: Value, code: i64, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": error_object(code, message) }) +} diff --git a/src-tauri/agents/src/tools.rs b/src-tauri/agents/src/tools.rs new file mode 100644 index 0000000..52bd269 --- /dev/null +++ b/src-tauri/agents/src/tools.rs @@ -0,0 +1,644 @@ +//! The tools an agent can call: what each one is, as `tools/list` describes +//! it, and what it does. +//! +//! Every tool maps onto a public `Store` method, so an agent's write goes +//! through the same rules as a keystroke in the app. The surface says +//! "space" (the product term); the core underneath says "workspace". +//! +//! Deliberately absent, at every access level: permanent delete, settings, +//! the vault, and whiteboard canvases. + +use crate::access::Access; +use crate::activity::{self, Scope}; +use crate::fail; +use instantnotes_core::domain::{normalize_tag_name, normalize_workspace_name}; +use instantnotes_core::types::{ + CreateNoteInput, Note, NoteFilter, UpdateNotePatch, CONTENT_KIND_WHITEBOARD, +}; +use instantnotes_core::{AppError, Store}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::PathBuf; + +const SNIPPET_CHARS: usize = 160; +const DEFAULT_LIMIT: i64 = 50; +const MAX_LIMIT: i64 = 200; +/// append_to_note re-reads and retries when the user saves in between. +const APPEND_ATTEMPTS: usize = 3; +/// A capture is an open loop once it has gone unopened this long. Matches +/// `REVISIT_AFTER_MS` in src/lib/stores/library.svelte.ts. +const REVISIT_AFTER_MS: i64 = 3 * 24 * 60 * 60 * 1000; + +type ToolResult = Result<Value, String>; + +pub(crate) struct Tools<'a> { + store: &'a mut Store, + attachments_dir: Option<PathBuf>, + /// `clientInfo.name` from `initialize`, shown to the user as who is + /// acting ("claude-code", "cursor"). + client: String, +} + +struct ToolDef { + name: &'static str, + /// Shown to the user by clients that display tools. + title: &'static str, + level: Access, + /// MCP `destructiveHint`, for writes: the tool can remove or replace + /// something, rather than only add. Undoable still counts. + destructive: bool, + /// MCP `idempotentHint`, for writes: repeating the call with the same + /// arguments changes nothing more. + idempotent: bool, + description: &'static str, + schema: fn() -> Value, +} + +const TOOLS: &[ToolDef] = &[ + ToolDef { + name: "search_notes", + title: "Search notes", + level: Access::Read, + destructive: false, + idempotent: true, + description: "Full-text search over note titles and bodies. Returns ids, titles, and a matching excerpt.", + schema: || object(json!({ + "query": { "type": "string", "description": "Words to search for." }, + "limit": limit_param() + }), &["query"]), + }, + ToolDef { + name: "list_notes", + title: "List notes", + level: Access::Read, + destructive: false, + idempotent: true, + description: "List notes, most recently updated first, optionally within a space or tag. Returns summaries, not full bodies.", + schema: || object(json!({ + "space": space_param(), + "tag": tag_param(), + "status": { + "type": "string", + "enum": ["active", "pinned", "archived", "trash", "revisit"], + "default": "active", + "description": "revisit: open loops, captures never opened in the app and older than three days, oldest first." + }, + "limit": limit_param(), + "offset": { "type": "integer", "minimum": 0, "default": 0 } + }), &[]), + }, + ToolDef { + name: "get_note", + title: "Read a note", + level: Access::Read, + destructive: false, + idempotent: true, + description: "Read one note in full: body, tags, spaces, and updatedAt (pass it to update_note). Reading does not mark the note as opened.", + schema: || object(json!({ "id": id_param() }), &["id"]), + }, + ToolDef { + name: "list_tags", + title: "List tags", + level: Access::Read, + destructive: false, + idempotent: true, + description: "Every tag with how many notes use it.", + schema: || object(json!({}), &[]), + }, + ToolDef { + name: "list_spaces", + title: "List Spaces", + level: Access::Read, + destructive: false, + idempotent: true, + description: "Every space with how many notes it holds.", + schema: || object(json!({}), &[]), + }, + ToolDef { + name: "create_note", + title: "Create a note", + level: Access::Write, + destructive: false, + idempotent: false, + description: "Create a note. The title is taken from the first line unless given. #words in the body become tags.", + schema: || object(json!({ + "body": { "type": "string", "description": "Markdown." }, + "title": { "type": "string", "description": "Only to override the first line as the title." }, + "tags": { "type": "array", "items": tag_param() }, + "space": { "type": "string", "description": "Space to file it in; created if new." } + }), &["body"]), + }, + ToolDef { + name: "update_note", + title: "Rewrite a note", + level: Access::Write, + destructive: true, + idempotent: true, + description: "Replace a note's title and/or body. expectedUpdatedAt must be the updatedAt from get_note; a CONFLICT means the note changed since, so read it again.", + schema: || object(json!({ + "id": id_param(), + "expectedUpdatedAt": { "type": "string", "description": "The note's updatedAt, exactly as get_note returned it." }, + "title": { "type": "string" }, + "body": { "type": "string", "description": "Markdown; replaces the whole body." } + }), &["id", "expectedUpdatedAt"]), + }, + ToolDef { + name: "append_to_note", + title: "Add to a note", + level: Access::Write, + destructive: false, + idempotent: false, + description: "Add text to the end of a note on a new line, without replacing what is there.", + schema: || object(json!({ + "id": id_param(), + "text": { "type": "string", "description": "Markdown." } + }), &["id", "text"]), + }, + ToolDef { + name: "tag_note", + title: "Tag a note", + level: Access::Write, + destructive: false, + idempotent: true, + description: "Add a tag to a note.", + schema: || object(json!({ "id": id_param(), "tag": tag_param() }), &["id", "tag"]), + }, + ToolDef { + name: "untag_note", + title: "Untag a note", + level: Access::Write, + destructive: true, + idempotent: true, + description: "Remove a tag from a note. A #tag still written in the body comes back on the next edit.", + schema: || object(json!({ "id": id_param(), "tag": tag_param() }), &["id", "tag"]), + }, + ToolDef { + name: "add_to_space", + title: "Add a note to a Space", + level: Access::Write, + destructive: false, + idempotent: true, + description: "Add a note to a space, creating the space if it is new.", + schema: || object(json!({ "id": id_param(), "space": space_param() }), &["id", "space"]), + }, + ToolDef { + name: "remove_from_space", + title: "Take a note out of a Space", + level: Access::Write, + destructive: true, + idempotent: true, + description: "Take a note out of a space. The note itself is kept.", + schema: || object(json!({ "id": id_param(), "space": space_param() }), &["id", "space"]), + }, + ToolDef { + name: "trash_note", + title: "Move a note to the Trash", + level: Access::Write, + destructive: true, + idempotent: true, + description: "Move a note to the Trash. The user can restore it; nothing is deleted for good.", + schema: || object(json!({ "id": id_param() }), &["id"]), + }, + ToolDef { + name: "restore_note", + title: "Restore a note from the Trash", + level: Access::Write, + destructive: false, + idempotent: true, + description: "Bring a note back from the Trash.", + schema: || object(json!({ "id": id_param() }), &["id"]), + }, +]; + +// Parameter schemas several tools share. +fn id_param() -> Value { + json!({ "type": "string", "description": "A note id, as search_notes, list_notes, or get_note return it." }) +} + +fn tag_param() -> Value { + json!({ "type": "string", "description": "Tag name, with or without #." }) +} + +fn space_param() -> Value { + json!({ "type": "string", "description": "Space name; case does not matter." }) +} + +fn limit_param() -> Value { + json!({ "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "default": DEFAULT_LIMIT }) +} + +/// An input schema as MCP wants it: an object that accepts exactly these +/// properties, since the server rejects any other. +fn object(properties: Value, required: &[&str]) -> Value { + let mut schema = + json!({ "type": "object", "properties": properties, "additionalProperties": false }); + if !required.is_empty() { + schema["required"] = json!(required); + } + schema +} + +impl<'a> Tools<'a> { + pub(crate) fn new(store: &'a mut Store, attachments_dir: Option<PathBuf>) -> Self { + Tools { + store, + attachments_dir, + client: "agent".into(), + } + } + + pub(crate) fn set_client(&mut self, name: &str) { + if !name.trim().is_empty() { + self.client = name.trim().to_string(); + } + } + + /// Every tool, in a fixed order so a client's cache and prompt stay + /// stable. The title is given twice: top level for 2025-06-18 and later, + /// in the annotations for 2025-03-26. + pub(crate) fn list(&self) -> Vec<Value> { + TOOLS + .iter() + .map(|t| { + json!({ + "name": t.name, + "title": t.title, + "description": t.description, + "inputSchema": (t.schema)(), + "annotations": { + "title": t.title, + "readOnlyHint": t.level == Access::Read, + "destructiveHint": t.destructive, + "idempotentHint": t.idempotent, + // Only this library, never the wider world. + "openWorldHint": false, + }, + }) + }) + .collect() + } + + pub(crate) fn knows(&self, name: &str) -> bool { + TOOLS.iter().any(|t| t.name == name) + } + + /// Run a tool that exists (see `knows`). Failures are tool results with + /// `isError`, which the model sees and can act on. A success carries its + /// JSON twice, as MCP asks: structured, and as text for older clients. + pub(crate) fn call(&mut self, name: &str, args: Value) -> Value { + let outcome = match TOOLS.iter().find(|t| t.name == name) { + None => Err(format!("unknown tool: {name}")), + Some(def) => Access::check(self.store, def.level).and_then(|()| { + let scope = Scope::of(&args); + let value = self.dispatch(name, args)?; + let wrote = def.level == Access::Write; + activity::record(self.store, &self.client, def.name, wrote, &scope, &value); + Ok(value) + }), + }; + match outcome { + Ok(value) => json!({ + "content": [{ "type": "text", "text": pretty(&value) }], + "structuredContent": value, + "isError": false, + }), + Err(message) => json!({ + "content": [{ "type": "text", "text": message }], + "isError": true, + }), + } + } + + fn dispatch(&mut self, name: &str, args: Value) -> ToolResult { + match name { + "search_notes" => self.search_notes(parse(args)?), + "list_notes" => self.list_notes(parse(args)?), + "get_note" => { + let a: IdArgs = parse(args)?; + self.note_view(&a.id) + } + "list_tags" => { + let tags = self.store.list_tags().map_err(fail)?; + Ok(json!({ "tags": tags.iter().map(|t| json!({ + "name": t.tag.name, "notes": t.usage_count + })).collect::<Vec<_>>() })) + } + "list_spaces" => { + let spaces = self.store.list_workspaces().map_err(fail)?; + Ok(json!({ "spaces": spaces.iter().map(|w| json!({ + "name": w.workspace.name, "notes": w.note_count + })).collect::<Vec<_>>() })) + } + "create_note" => self.create_note(parse(args)?), + "update_note" => self.update_note(parse(args)?), + "append_to_note" => self.append_to_note(parse(args)?), + "tag_note" => { + let a: TagArgs = parse(args)?; + self.store.add_tag_to_note(&a.id, &a.tag).map_err(fail)?; + self.note_view(&a.id) + } + "untag_note" => { + let a: TagArgs = parse(args)?; + let tag_id = self.tag_id(&a.tag)?; + self.store + .remove_tag_from_note(&a.id, &tag_id) + .map_err(fail)?; + self.note_view(&a.id) + } + "add_to_space" => { + let a: SpaceArgs = parse(args)?; + self.store.get_note(&a.id, false).map_err(fail)?; + let ws = self.store.get_or_create_workspace(&a.space).map_err(fail)?; + self.store + .add_note_to_workspace(&a.id, &ws.id) + .map_err(fail)?; + self.note_view(&a.id) + } + "remove_from_space" => { + let a: SpaceArgs = parse(args)?; + let space_id = self.space_id(&a.space)?; + self.store + .remove_note_from_workspace(&a.id, &space_id) + .map_err(fail)?; + self.note_view(&a.id) + } + "trash_note" => { + let a: IdArgs = parse(args)?; + self.store.soft_delete_note(&a.id).map_err(fail)?; + self.note_view(&a.id) + } + "restore_note" => { + let a: IdArgs = parse(args)?; + self.store.restore_note(&a.id).map_err(fail)?; + self.note_view(&a.id) + } + _ => Err(format!("unknown tool: {name}")), + } + } + + fn search_notes(&mut self, a: SearchArgs) -> ToolResult { + let hits = self + .store + .search_notes(&a.query, clamp_limit(a.limit)) + .map_err(fail)?; + Ok(json!({ "results": hits.iter().map(|h| json!({ + "id": h.note_id, + "title": unmark(&h.title), + "excerpt": unmark(&h.excerpt), + "updatedAt": h.updated_at, + })).collect::<Vec<_>>() })) + } + + fn list_notes(&mut self, a: ListArgs) -> ToolResult { + let mut filter = NoteFilter { + limit: Some(clamp_limit(a.limit)), + offset: a.offset.map(|o| o.max(0)), + ..Default::default() + }; + match a.status.as_deref().unwrap_or("active") { + "active" => {} + "pinned" => filter.is_pinned = Some(true), + "archived" => filter.is_archived = Some(true), + "trash" => filter.is_deleted = Some(true), + // The app's Revisit view, same filter (library.svelte.ts). + "revisit" => { + let cutoff = chrono::Utc::now() - chrono::Duration::milliseconds(REVISIT_AFTER_MS); + filter.never_opened = Some(true); + // Same form as stored timestamps, which compare as strings. + filter.created_before = + Some(cutoff.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)); + filter.sort_by = Some("createdAt".into()); + filter.sort_order = Some("asc".into()); + } + other => return Err(format!("unknown status: {other}")), + } + if let Some(space) = &a.space { + filter.workspace_id = Some(self.space_id(space)?); + } + if let Some(tag) = &a.tag { + filter.tag_ids = vec![self.tag_id(tag)?]; + } + let notes = self.store.list_notes(filter).map_err(fail)?; + Ok(json!({ "notes": notes.iter().map(summary).collect::<Vec<_>>() })) + } + + fn create_note(&mut self, a: CreateArgs) -> ToolResult { + let note = self + .store + .create_note(CreateNoteInput { + title: a.title, + body: Some(a.body), + tags: a.tags, + }) + .map_err(fail)?; + if let Some(space) = &a.space { + let ws = self.store.get_or_create_workspace(space).map_err(fail)?; + self.store + .add_note_to_workspace(¬e.id, &ws.id) + .map_err(fail)?; + } + self.note_view(¬e.id) + } + + fn update_note(&mut self, a: UpdateArgs) -> ToolResult { + if a.title.is_none() && a.body.is_none() { + return Err("give a title, a body, or both".into()); + } + let current = self.store.get_note(&a.id, false).map_err(fail)?; + if a.body.is_some() { + refuse_whiteboard(¤t)?; + } + self.store + .update_note( + &a.id, + UpdateNotePatch { + title: a.title, + body: a.body, + expected_updated_at: Some(a.expected_updated_at), + ..Default::default() + }, + ) + .map_err(fail)?; + self.note_view(&a.id) + } + + fn append_to_note(&mut self, a: AppendArgs) -> ToolResult { + for _ in 0..APPEND_ATTEMPTS { + let current = self.store.get_note(&a.id, false).map_err(fail)?; + refuse_whiteboard(¤t)?; + let body = appended(¤t.body, &a.text); + let patch = UpdateNotePatch { + body: Some(body), + expected_updated_at: Some(current.updated_at), + ..Default::default() + }; + match self.store.update_note(&a.id, patch) { + Ok(_) => return self.note_view(&a.id), + Err(AppError::Conflict(_)) => continue, + Err(e) => return Err(fail(e)), + } + } + Err("CONFLICT: the note kept changing while appending; try again".into()) + } + + /// A note in full, as every read and write tool returns it. + fn note_view(&mut self, id: &str) -> ToolResult { + let note = self.store.get_note(id, false).map_err(fail)?; + let tags = self.store.tags_for_note(id).map_err(fail)?; + let spaces = self.store.workspaces_for_note(id).map_err(fail)?; + let mut view = json!({ + "id": note.id, + "title": note.title, + "kind": note.content_kind, + "body": note.body, + "tags": tags.iter().map(|t| &t.name).collect::<Vec<_>>(), + "spaces": spaces.iter().map(|w| &w.name).collect::<Vec<_>>(), + "createdAt": note.created_at, + "updatedAt": note.updated_at, + "isPinned": note.is_pinned, + "isArchived": note.is_archived, + "isDeleted": note.is_deleted, + }); + // Bodies reference images as `attachments/<file>`; this is where + // those files are, for an agent that can read them. + if let Some(dir) = &self.attachments_dir { + view["attachmentsDir"] = json!(dir); + } + Ok(view) + } + + fn tag_id(&self, raw: &str) -> Result<String, String> { + let name = normalize_tag_name(raw).ok_or("tag name must not be empty")?; + let tags = self.store.list_tags().map_err(fail)?; + tags.into_iter() + .find(|t| t.tag.name == name) + .map(|t| t.tag.id) + .ok_or_else(|| format!("NOT_FOUND: no tag named {name}")) + } + + fn space_id(&self, raw: &str) -> Result<String, String> { + let name = normalize_workspace_name(raw).ok_or("space name must not be empty")?; + let spaces = self.store.list_workspaces().map_err(fail)?; + spaces + .into_iter() + .find(|w| w.workspace.name.eq_ignore_ascii_case(&name)) + .map(|w| w.workspace.id) + .ok_or_else(|| format!("NOT_FOUND: no space named {name}")) + } +} + +fn summary(note: &Note) -> Value { + let collapsed = note.body.split_whitespace().collect::<Vec<_>>().join(" "); + let snippet: String = collapsed.chars().take(SNIPPET_CHARS).collect(); + json!({ + "id": note.id, + "title": note.title, + "kind": note.content_kind, + "snippet": snippet, + "createdAt": note.created_at, + "updatedAt": note.updated_at, + "isPinned": note.is_pinned, + "isArchived": note.is_archived, + "isDeleted": note.is_deleted, + }) +} + +/// A whiteboard's body is the text on its canvas, rewritten by every canvas +/// save, so writing it would be silently undone. +fn refuse_whiteboard(note: &Note) -> Result<(), String> { + if note.content_kind == CONTENT_KIND_WHITEBOARD { + return Err("this note is a whiteboard; its text can only be edited in the app".into()); + } + Ok(()) +} + +fn appended(body: &str, text: &str) -> String { + if body.is_empty() { + text.to_string() + } else if body.ends_with('\n') { + format!("{body}{text}") + } else { + format!("{body}\n{text}") + } +} + +fn clamp_limit(limit: Option<i64>) -> i64 { + limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) +} + +/// Search marks matches with \u{1} and \u{2} for the app to highlight. +fn unmark(s: &str) -> String { + s.replace(['\u{1}', '\u{2}'], "") +} + +fn pretty(value: &Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_default() +} + +fn parse<T: DeserializeOwned>(args: Value) -> Result<T, String> { + serde_json::from_value(args).map_err(|e| format!("invalid arguments: {e}")) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct IdArgs { + id: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SearchArgs { + query: String, + limit: Option<i64>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ListArgs { + space: Option<String>, + tag: Option<String>, + status: Option<String>, + limit: Option<i64>, + offset: Option<i64>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CreateArgs { + body: String, + title: Option<String>, + #[serde(default)] + tags: Vec<String>, + space: Option<String>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct UpdateArgs { + id: String, + expected_updated_at: String, + title: Option<String>, + body: Option<String>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AppendArgs { + id: String, + text: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TagArgs { + id: String, + tag: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SpaceArgs { + id: String, + space: String, +} diff --git a/src-tauri/agents/tests/mcp_test.rs b/src-tauri/agents/tests/mcp_test.rs new file mode 100644 index 0000000..9539dda --- /dev/null +++ b/src-tauri/agents/tests/mcp_test.rs @@ -0,0 +1,508 @@ +//! The MCP server driven the way a client drives it: JSON-RPC lines in, +//! JSON-RPC lines out, against a real store. + +use instantnotes_agents::{serve, ACCESS_KEY, ACTIVITY_KEY}; +use instantnotes_core::types::CreateNoteInput; +use instantnotes_core::Store; +use serde_json::{json, Value}; +use std::io::Cursor; + +/// Send one session's worth of messages; return every reply. +fn session(store: &mut Store, messages: &[Value]) -> Vec<Value> { + let input: String = messages.iter().map(|m| format!("{m}\n")).collect(); + raw_session(store, input.as_bytes()) +} + +/// The same over raw bytes, for input that is not valid JSON or UTF-8. +fn raw_session(store: &mut Store, input: &[u8]) -> Vec<Value> { + let mut output = Vec::new(); + serve(store, None, Cursor::new(input.to_vec()), &mut output).unwrap(); + String::from_utf8(output) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() +} + +/// A modern (2026-07-28) request: no handshake, the version on the request. +fn modern(id: i64, method: &str, mut params: Value) -> Value { + params["_meta"] = json!({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { "name": "codex", "version": "1" }, + "io.modelcontextprotocol/clientCapabilities": {} + }); + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) +} + +fn init() -> Value { + json!({ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "claude-code", "version": "1" } + }}) +} + +fn call(id: i64, name: &str, arguments: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": { "name": name, "arguments": arguments } }) +} + +/// A tool call's result: (is_error, text, parsed JSON when it is JSON). +fn result_of(reply: &Value) -> (bool, String, Value) { + let result = &reply["result"]; + let text = result["content"][0]["text"].as_str().unwrap().to_string(); + let parsed = serde_json::from_str(&text).unwrap_or(Value::Null); + (result["isError"].as_bool().unwrap(), text, parsed) +} + +fn store_with(access: &str) -> Store { + let mut store = Store::open_in_memory().unwrap(); + store.set_setting(ACCESS_KEY, json!(access)).unwrap(); + store +} + +#[test] +fn initialize_negotiates_and_notifications_get_no_reply() { + let mut store = store_with("off"); + let replies = session( + &mut store, + &[ + init(), + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }), + ], + ); + assert_eq!(replies.len(), 2, "the notification must not be answered"); + assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18"); + assert_eq!(replies[0]["result"]["serverInfo"]["name"], "instantnotes"); + assert!(replies[0]["result"]["capabilities"]["tools"].is_object()); + assert_eq!(replies[1]["result"], json!({})); +} + +#[test] +fn unknown_version_gets_the_newest_and_unknown_method_an_error() { + let mut store = store_with("off"); + let replies = session( + &mut store, + &[ + json!({ "jsonrpc": "2.0", "id": 0, "method": "initialize", + "params": { "protocolVersion": "1999-01-01" } }), + json!({ "jsonrpc": "2.0", "id": 1, "method": "resources/list" }), + json!("not an object"), + ], + ); + assert_eq!(replies[0]["result"]["protocolVersion"], "2025-11-25"); + assert_eq!(replies[1]["error"]["code"], -32601); + assert_eq!(replies[2]["error"]["code"], -32600); +} + +#[test] +fn tools_list_describes_every_tool_with_a_schema() { + let mut store = store_with("off"); + let replies = session( + &mut store, + &[ + init(), + json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }), + ], + ); + let tools = replies[1]["result"]["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for expected in [ + "search_notes", + "get_note", + "create_note", + "update_note", + "append_to_note", + ] { + assert!(names.contains(&expected), "missing {expected}"); + } + assert!( + !names.iter().any(|n| n.contains("delete")), + "no permanent delete, ever" + ); + for tool in tools { + let name = tool["name"].as_str().unwrap(); + let schema = &tool["inputSchema"]; + assert_eq!(schema["type"], "object", "{name}"); + // The server rejects unknown arguments, so the schema must say so. + assert_eq!(schema["additionalProperties"], false, "{name}"); + assert_ne!(schema["required"], json!([]), "{name}: omit, not empty"); + assert!( + tool["title"].as_str().is_some_and(|t| !t.is_empty()), + "{name}" + ); + assert_eq!(tool["annotations"]["title"], tool["title"], "{name}"); + assert_eq!(tool["annotations"]["openWorldHint"], false, "{name}"); + // Destructive means it can remove or replace: never the additive ones. + let destructive = matches!( + name, + "update_note" | "untag_note" | "remove_from_space" | "trash_note" + ); + assert_eq!( + tool["annotations"]["destructiveHint"], destructive, + "{name}" + ); + } +} + +#[test] +fn modern_clients_need_no_handshake() { + let mut store = store_with("read"); + let note = store + .create_note(CreateNoteInput { + body: Some("Roadmap".into()), + ..Default::default() + }) + .unwrap(); + let replies = session( + &mut store, + &[ + modern(1, "server/discover", json!({})), + modern(2, "tools/list", json!({})), + modern( + 3, + "tools/call", + json!({ "name": "get_note", "arguments": { "id": note.id } }), + ), + ], + ); + let discover = &replies[0]["result"]; + assert_eq!(discover["resultType"], "complete"); + let versions = discover["supportedVersions"].as_array().unwrap(); + assert_eq!(versions[0], "2026-07-28"); + assert!( + versions.contains(&json!("2025-11-25")), + "still serves legacy" + ); + assert!(discover["capabilities"]["tools"].is_object()); + assert_eq!( + discover["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "instantnotes" + ); + + let list = &replies[1]["result"]; + assert_eq!(list["resultType"], "complete"); + assert!(list["ttlMs"].as_u64().is_some()); + assert_eq!(list["cacheScope"], "private"); + + let (is_error, _, read) = result_of(&replies[2]); + assert!(!is_error); + assert_eq!(read["title"], "Roadmap"); + assert_eq!(replies[2]["result"]["resultType"], "complete"); + // Who is acting comes from the request itself, not a handshake. + let log = store.get_setting(ACTIVITY_KEY).unwrap().unwrap(); + assert_eq!(log[0]["client"], "codex"); +} + +#[test] +fn an_unsupported_version_names_the_supported_ones() { + let mut store = store_with("read"); + let mut request = modern(1, "tools/list", json!({})); + request["params"]["_meta"]["io.modelcontextprotocol/protocolVersion"] = json!("2099-01-01"); + let replies = session(&mut store, &[request]); + let error = &replies[0]["error"]; + assert_eq!(error["code"], -32022); + assert_eq!(error["data"]["requested"], "2099-01-01"); + assert_eq!(error["data"]["supported"][0], "2026-07-28"); +} + +#[test] +fn legacy_results_stay_legacy_shaped() { + let mut store = store_with("read"); + let replies = session( + &mut store, + &[ + init(), + json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }), + ], + ); + assert_eq!(replies[0]["result"]["serverInfo"]["title"], "InstantNotes"); + assert!(replies[1]["result"].get("resultType").is_none()); + assert!(replies[1]["result"].get("ttlMs").is_none()); +} + +#[test] +fn a_batch_gets_one_array_back_without_the_notifications() { + let mut store = store_with("read"); + let batch = json!([ + init(), + { "jsonrpc": "2.0", "method": "notifications/initialized" }, + { "jsonrpc": "2.0", "id": 1, "method": "tools/list" } + ]); + let replies = session(&mut store, &[batch, json!([])]); + let answers = replies[0] + .as_array() + .expect("a batch is answered with an array"); + assert_eq!(answers.len(), 2); + assert_eq!(answers[0]["id"], 0); + assert_eq!(answers[1]["id"], 1); + assert_eq!( + replies[1]["error"]["code"], -32600, + "an empty batch is invalid" + ); +} + +#[test] +fn unknown_tools_and_malformed_calls_are_protocol_errors() { + let mut store = store_with("write"); + let replies = session( + &mut store, + &[ + init(), + call(1, "drop_database", json!({})), + json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "get_note", "arguments": "id=1" } }), + // A bad argument is the tool's error, which the model can fix. + call(3, "get_note", json!({ "note": "x" })), + ], + ); + assert_eq!(replies[1]["error"]["code"], -32602); + assert_eq!( + replies[1]["error"]["message"], + "Unknown tool: drop_database" + ); + assert_eq!(replies[2]["error"]["code"], -32602); + let (is_error, text, _) = result_of(&replies[3]); + assert!(is_error); + assert!(text.contains("invalid arguments"), "{text}"); +} + +#[test] +fn malformed_messages_are_refused_and_the_server_keeps_going() { + let mut store = store_with("read"); + let mut input = Vec::new(); + // No jsonrpc member. + input.extend_from_slice(b"{\"id\":1,\"method\":\"ping\"}\n"); + // A null id, which MCP forbids. + input.extend_from_slice(b"{\"jsonrpc\":\"2.0\",\"id\":null,\"method\":\"ping\"}\n"); + // A response to nothing: never answered. + input.extend_from_slice(b"{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{}}\n"); + // Bytes that are not UTF-8. + input.extend_from_slice(b"\xff\xfe\n"); + input.extend_from_slice(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}\n"); + let replies = raw_session(&mut store, &input); + assert_eq!(replies.len(), 4, "{replies:?}"); + assert_eq!(replies[0]["error"]["code"], -32600); + assert_eq!(replies[0]["id"], 1); + assert_eq!(replies[1]["error"]["code"], -32600); + assert_eq!(replies[1]["id"], Value::Null); + assert_eq!(replies[2]["error"]["code"], -32700); + assert_eq!(replies[3]["result"], json!({}), "still serving"); +} + +#[test] +fn a_result_carries_its_json_structured_and_as_text() { + let mut store = store_with("read"); + let replies = session(&mut store, &[init(), call(1, "list_tags", json!({}))]); + let (is_error, _, parsed) = result_of(&replies[1]); + assert!(!is_error); + assert_eq!(replies[1]["result"]["structuredContent"], parsed); +} + +#[test] +fn access_off_refuses_everything_and_leaves_no_trace() { + let mut store = store_with("off"); + let replies = session(&mut store, &[init(), call(1, "list_notes", json!({}))]); + let (is_error, text, _) = result_of(&replies[1]); + assert!(is_error); + assert!(text.contains("Settings > Agents")); + assert_eq!(store.get_setting(ACTIVITY_KEY).unwrap(), None); +} + +#[test] +fn read_only_reads_but_refuses_writes() { + let mut store = store_with("read"); + let note = store + .create_note(CreateNoteInput { + body: Some("Fix the #sync bug".into()), + ..Default::default() + }) + .unwrap(); + let replies = session( + &mut store, + &[ + init(), + call(1, "get_note", json!({ "id": note.id })), + call(2, "append_to_note", json!({ "id": note.id, "text": "no" })), + ], + ); + let (is_error, _, read) = result_of(&replies[1]); + assert!(!is_error); + assert_eq!(read["body"], "Fix the #sync bug"); + assert_eq!(read["tags"], json!(["sync"])); + let (is_error, text, _) = result_of(&replies[2]); + assert!(is_error); + assert!(text.contains("read only")); + + // Reading never marks the note as opened: it stays in Revisit. + let after = store.get_note(¬e.id, false).unwrap(); + assert_eq!(after.last_opened_at, None); + assert_eq!(after.body, "Fix the #sync bug"); +} + +#[test] +fn write_creates_files_and_appends_through_the_core_rules() { + let mut store = store_with("write"); + let replies = session( + &mut store, + &[ + init(), + call( + 1, + "create_note", + json!({ + "body": "Agent idea\nwith a #plan", "space": "Ideas", "tags": ["later"] + }), + ), + ], + ); + let (is_error, text, created) = result_of(&replies[1]); + assert!(!is_error, "{text}"); + assert_eq!(created["title"], "Agent idea"); + assert_eq!(created["spaces"], json!(["Ideas"])); + let mut tags: Vec<String> = serde_json::from_value(created["tags"].clone()).unwrap(); + tags.sort(); + assert_eq!(tags, ["later", "plan"]); + + let id = created["id"].as_str().unwrap().to_string(); + let replies = session( + &mut store, + &[ + init(), + call( + 1, + "append_to_note", + json!({ "id": id, "text": "- step one" }), + ), + call(2, "list_notes", json!({ "space": "ideas" })), + call(3, "search_notes", json!({ "query": "step" })), + ], + ); + let (_, _, appended) = result_of(&replies[1]); + assert_eq!(appended["body"], "Agent idea\nwith a #plan\n- step one"); + let (_, _, listed) = result_of(&replies[2]); + assert_eq!(listed["notes"][0]["id"], json!(id)); + let (_, _, found) = result_of(&replies[3]); + assert_eq!(found["results"][0]["id"], json!(id)); + assert!(!found["results"][0]["excerpt"] + .as_str() + .unwrap() + .contains('\u{1}')); +} + +#[test] +fn update_with_a_stale_version_is_a_conflict() { + let mut store = store_with("write"); + let note = store + .create_note(CreateNoteInput { + body: Some("v1".into()), + ..Default::default() + }) + .unwrap(); + let replies = session( + &mut store, + &[ + init(), + call( + 1, + "update_note", + json!({ + "id": note.id, "expectedUpdatedAt": note.updated_at, "body": "v2" + }), + ), + // The version it read is now stale. + call( + 2, + "update_note", + json!({ + "id": note.id, "expectedUpdatedAt": note.updated_at, "body": "v3" + }), + ), + ], + ); + assert!(!result_of(&replies[1]).0); + let (is_error, text, _) = result_of(&replies[2]); + assert!(is_error); + assert!(text.starts_with("CONFLICT"), "{text}"); + assert_eq!(store.get_note(¬e.id, false).unwrap().body, "v2"); +} + +#[test] +fn whiteboard_text_is_refused() { + let mut store = store_with("write"); + let note = store.create_note(CreateNoteInput::default()).unwrap(); + store + .update_note( + ¬e.id, + instantnotes_core::types::UpdateNotePatch { + content_kind: Some("whiteboard".into()), + ..Default::default() + }, + ) + .unwrap(); + let replies = session( + &mut store, + &[ + init(), + call(1, "append_to_note", json!({ "id": note.id, "text": "x" })), + ], + ); + let (is_error, text, _) = result_of(&replies[1]); + assert!(is_error); + assert!(text.contains("whiteboard")); +} + +#[test] +fn revisit_lists_only_fresh_captures_once_they_are_old_enough() { + let mut store = store_with("read"); + let fresh = store + .create_note(CreateNoteInput { + body: Some("just captured".into()), + ..Default::default() + }) + .unwrap(); + let replies = session( + &mut store, + &[ + init(), + call(1, "list_notes", json!({ "status": "revisit" })), + call(2, "list_notes", json!({})), + ], + ); + // A capture from moments ago is not yet an open loop; it is a live note. + let (is_error, text, open) = result_of(&replies[1]); + assert!(!is_error, "{text}"); + assert_eq!(open["notes"], json!([])); + let (_, _, all) = result_of(&replies[2]); + assert_eq!(all["notes"][0]["id"], json!(fresh.id)); +} + +#[test] +fn every_successful_call_is_recorded_for_the_app_newest_first() { + let mut store = store_with("read"); + let note = store + .create_note(CreateNoteInput { + body: Some("Roadmap".into()), + ..Default::default() + }) + .unwrap(); + session( + &mut store, + &[ + init(), + call(1, "list_notes", json!({})), + call(2, "get_note", json!({ "id": note.id })), + call(3, "get_note", json!({ "id": "missing" })), + ], + ); + let log = store.get_setting(ACTIVITY_KEY).unwrap().unwrap(); + let log = log.as_array().unwrap(); + assert_eq!(log.len(), 2, "the failed call is not recorded"); + assert_eq!(log[0]["tool"], "get_note"); + assert_eq!(log[0]["client"], "claude-code"); + assert_eq!(log[0]["kind"], "read"); + assert_eq!(log[0]["noteIds"], json!([note.id])); + assert_eq!(log[0]["titles"], json!(["Roadmap"])); + assert_eq!(log[1]["tool"], "list_notes"); +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2da12de..d494b74 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Library and capture windows: app IPC + events only. No network, no shell, no opener.", - "windows": ["library", "capture"], + "description": "Library, capture, and sticky windows: app IPC + events only. No network, no shell, no opener.", + "windows": ["library", "capture", "sticky-*"], "permissions": [ "core:default" ] diff --git a/src-tauri/capabilities/stickies.json b/src-tauri/capabilities/stickies.json new file mode 100644 index 0000000..6799489 --- /dev/null +++ b/src-tauri/capabilities/stickies.json @@ -0,0 +1,9 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "stickies", + "description": "Sticky windows only: move the frameless window by its header, as a title bar would.", + "windows": ["sticky-*"], + "permissions": [ + "core:window:allow-start-dragging" + ] +} diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml index dfec658..99838a7 100644 --- a/src-tauri/core/Cargo.toml +++ b/src-tauri/core/Cargo.toml @@ -5,12 +5,16 @@ description = "InstantNotes core: domain logic, persistence, search. No UI, no T edition = "2021" [dependencies] -rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_norway = "0.9.42" +sha2 = "0.10" +# Apple Stickies' state file; already in the app through Tauri. +plist = "1" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/core/src/attachments.rs b/src-tauri/core/src/attachments.rs new file mode 100644 index 0000000..1d86a70 --- /dev/null +++ b/src-tauri/core/src/attachments.rs @@ -0,0 +1,120 @@ +//! Attachment cleanup (SEQUENCE.md unit 11). Pasted and copied-in images live +//! as files under `<app data>/attachments`, and notes reference them as +//! `attachments/<name>`. Destroying the last note that references a file +//! should remove it, so the folder stops growing forever. +//! +//! This module is the careful half: finding references in text, and removing +//! one named file (plus the live vault's copy of it, only while that copy is +//! still identical). Which names are still referenced is a store question +//! (`store/attachments.rs`); when to ask it is the shell's. + +use std::collections::BTreeSet; +use std::fs; +use std::io; +use std::path::Path; +use std::time::SystemTime; + +const PREFIX: &str = "attachments/"; + +fn is_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') +} + +/// Every attachment name referenced in `text`, as `attachments/<name>`. Wider +/// than the Markdown image syntax on purpose (HTML `src`, a link, a mention +/// in prose): a false match only keeps a file, a missed one would lose it. +pub fn referenced_names(text: &str) -> BTreeSet<String> { + let mut found = BTreeSet::new(); + let mut rest = text; + while let Some(at) = rest.find(PREFIX) { + rest = &rest[at + PREFIX.len()..]; + let len = rest.find(|c: char| !is_name_char(c)).unwrap_or(rest.len()); + let name = &rest[..len]; + if !name.is_empty() && name.chars().any(|c| c != '.') { + found.insert(name.to_string()); + } + } + found +} + +/// A bare file name: never a path, never `.` or `..`. +fn is_plain_name(name: &str) -> bool { + !name.is_empty() && name.chars().all(is_name_char) && name.chars().any(|c| c != '.') +} + +/// Remove `dir/name`, and `vault_dir/name` too when it holds the same bytes +/// (a copy that differs was changed outside the app and is left alone). +/// Returns the bytes freed in `dir`; a file already gone frees nothing. +pub fn remove_attachment(dir: &Path, vault_dir: Option<&Path>, name: &str) -> io::Result<u64> { + if !is_plain_name(name) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("not an attachment name: {name:?}"), + )); + } + let path = dir.join(name); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(e), + }; + if let Some(vault_dir) = vault_dir { + let copy = vault_dir.join(name); + if fs::read(©).is_ok_and(|theirs| theirs == bytes) { + fs::remove_file(©)?; + } + } + fs::remove_file(&path)?; + Ok(bytes.len() as u64) +} + +/// The files directly in `dir` as `(name, size)`, skipping folders and +/// hidden files. With `modified_before`, only files last changed before +/// then: a fresh paste may belong to an edit that has not saved yet. +pub fn list_attachments( + dir: &Path, + modified_before: Option<SystemTime>, +) -> io::Result<Vec<(String, u64)>> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + let mut out = Vec::new(); + for entry in entries { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + let meta = entry.metadata()?; + if name.starts_with('.') || !meta.is_file() || !is_plain_name(&name) { + continue; + } + if let Some(cutoff) = modified_before { + if meta.modified()? >= cutoff { + continue; + } + } + out.push((name, meta.len())); + } + out.sort(); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_name_stops_at_the_first_character_a_name_cannot_hold() { + let found = + referenced_names("(attachments/a.png) \"attachments/b.gif\" attachments/c.jpg?x"); + assert_eq!( + found.into_iter().collect::<Vec<_>>(), + vec!["a.png", "b.gif", "c.jpg"] + ); + } + + #[test] + fn dots_alone_are_not_a_name() { + assert!(referenced_names("attachments/.. attachments/.").is_empty()); + } +} diff --git a/src-tauri/core/src/domain.rs b/src-tauri/core/src/domain.rs index 1e5ace7..3d81b6f 100644 --- a/src-tauri/core/src/domain.rs +++ b/src-tauri/core/src/domain.rs @@ -66,14 +66,19 @@ pub fn extract_inline_tags(body: &str) -> Vec<String> { out } -/// Derive a note title from the first non-empty line of the body (see -/// DATA_MODEL.md section 6): strip leading markdown markers (`#`, `-`, `*`, -/// `>`) and inline `#` tag -/// prefixes, collapse whitespace, truncate to 80 chars (char boundary). -/// Empty body yields "Untitled". +/// Derive a note title from the first line of the body with words on it +/// (see DATA_MODEL.md section 6), passing over blank lines and lines that +/// are only images: strip leading markdown markers (`#`, `-`, `*`, `>`), the +/// emphasis markers around words (`**`, `*`, `~~`, `==`, `` ` ``), and inline +/// `#` tag prefixes, collapse whitespace, truncate to 80 chars (char +/// boundary). A body with no such line yields "Untitled". pub fn derive_title(body: &str) -> String { const UNTITLED: &str = "Untitled"; - let Some(line) = body.lines().find(|l| !l.trim().is_empty()) else { + const EMPHASIS: [char; 4] = ['*', '~', '=', '`']; + let Some(line) = body + .lines() + .find(|l| !l.trim().is_empty() && !is_image_line(l)) + else { return UNTITLED.to_string(); }; let line = line @@ -81,7 +86,7 @@ pub fn derive_title(body: &str) -> String { .trim_start_matches(['#', '-', '*', '>', ' ', '\t']); let words: Vec<&str> = line .split_whitespace() - .map(|w| w.trim_start_matches('#')) + .map(|w| w.trim_matches(EMPHASIS).trim_start_matches('#')) .filter(|w| !w.is_empty()) .collect(); let joined = words.join(" "); @@ -91,6 +96,27 @@ pub fn derive_title(body: &str) -> String { joined.chars().take(80).collect() } +/// A line holding nothing but Markdown images: `![alt](path)`, one or more. +fn is_image_line(line: &str) -> bool { + let mut rest = line.trim(); + if rest.is_empty() { + return false; + } + while !rest.is_empty() { + let Some(after) = rest.strip_prefix("![") else { + return false; + }; + let Some(close) = after.find("](") else { + return false; + }; + let Some(end) = after[close + 2..].find(')') else { + return false; + }; + rest = after[close + 2 + end + 1..].trim_start(); + } + true +} + #[cfg(test)] mod tests { use super::*; @@ -177,6 +203,36 @@ mod tests { assert_eq!(derive_title("> quoted thought"), "quoted thought"); } + #[test] + fn title_passes_over_lines_that_are_only_images() { + assert_eq!( + derive_title("![](attachments/a.png)\nWhiteboard photo"), + "Whiteboard photo" + ); + assert_eq!( + derive_title("![a](x.png) ![b](y.png)\n\nTwo shots"), + "Two shots" + ); + assert_eq!(derive_title("![](attachments/a.png)"), "Untitled"); + // An image inside a line of words is part of the title line. + assert_eq!(derive_title("See ![](a.png) here"), "See ![](a.png) here"); + } + + #[test] + fn title_drops_emphasis_markers_around_words() { + assert_eq!( + derive_title("**Groceries** for the week"), + "Groceries for the week" + ); + assert_eq!(derive_title("***Call the bank***"), "Call the bank"); + assert_eq!( + derive_title("~~old plan~~ ==new== `plan`"), + "old plan new plan" + ); + // Inside a word they are the word. + assert_eq!(derive_title("C++ and a*b"), "C++ and a*b"); + } + #[test] fn title_strips_tag_hashes_keeping_words() { assert_eq!( diff --git a/src-tauri/core/src/error.rs b/src-tauri/core/src/error.rs index ae36909..f658e67 100644 --- a/src-tauri/core/src/error.rs +++ b/src-tauri/core/src/error.rs @@ -18,6 +18,13 @@ pub enum AppError { /// API.md §11 (it is a storage failure to callers). #[error("{0}")] Corruption(String), + /// The file's `user_version` is past the last migration this build knows. + /// Kept distinct from `Migration` so callers can tell "written by a newer + /// version, do not touch" apart from "this build's own migration failed", + /// and never route it into `open_or_recover`'s move-aside-and-start-fresh + /// path the way `is_corruption` would. + #[error("database schema v{found} was created by a newer version of the app (this build knows up to v{known})")] + SchemaTooNew { found: i64, known: usize }, } impl AppError { @@ -30,6 +37,7 @@ impl AppError { AppError::Storage(_) => "STORAGE_ERROR", AppError::Migration(_) => "MIGRATION_ERROR", AppError::Corruption(_) => "STORAGE_ERROR", + AppError::SchemaTooNew { .. } => "MIGRATION_ERROR", } } @@ -38,6 +46,13 @@ impl AppError { pub fn is_corruption(&self) -> bool { matches!(self, AppError::Corruption(_)) } + + /// True when the file is intact but was written by a build newer than + /// this one. Nothing to recover from and nothing safe to touch; the + /// caller's only move is to tell the user to update. + pub fn is_schema_too_new(&self) -> bool { + matches!(self, AppError::SchemaTooNew { .. }) + } } impl From<rusqlite::Error> for AppError { @@ -73,11 +88,22 @@ mod tests { assert_eq!(AppError::Storage("x".into()).code(), "STORAGE_ERROR"); assert_eq!(AppError::Migration("x".into()).code(), "MIGRATION_ERROR"); assert_eq!(AppError::Corruption("x".into()).code(), "STORAGE_ERROR"); + assert_eq!( + AppError::SchemaTooNew { found: 4, known: 3 }.code(), + "MIGRATION_ERROR" + ); } #[test] fn only_corruption_reports_corruption() { assert!(AppError::Corruption("x".into()).is_corruption()); assert!(!AppError::Storage("x".into()).is_corruption()); + assert!(!AppError::SchemaTooNew { found: 4, known: 3 }.is_corruption()); + } + + #[test] + fn only_schema_too_new_reports_schema_too_new() { + assert!(AppError::SchemaTooNew { found: 4, known: 3 }.is_schema_too_new()); + assert!(!AppError::Migration("x".into()).is_schema_too_new()); } } diff --git a/src-tauri/core/src/import/mod.rs b/src-tauri/core/src/import/mod.rs new file mode 100644 index 0000000..8675b12 --- /dev/null +++ b/src-tauri/core/src/import/mod.rs @@ -0,0 +1,6 @@ +//! Bringing notes in from other apps (Settings > Import). Reading and +//! converting only: the library is written by `Store::import_notes`, and +//! images are copied in by the shell, which owns the attachments folder. + +pub mod rtf; +pub mod stickies; diff --git a/src-tauri/core/src/import/rtf.rs b/src-tauri/core/src/import/rtf.rs new file mode 100644 index 0000000..e19ebe4 --- /dev/null +++ b/src-tauri/core/src/import/rtf.rs @@ -0,0 +1,481 @@ +//! RTF, as the Cocoa text system writes it (Apple Stickies, TextEdit), to the +//! Markdown the editor renders. The mapping is in the feat-stickies-import +//! design, section 4. +//! +//! The input is bytes, not text: RTF is 7-bit with `\'hh` escapes in the +//! document's code page, and Cocoa also writes one raw byte (0xAC) after +//! each attachment. The reader is one loop over a stack of group states, with +//! no recursion, so a file costs time in proportion to its size and nothing +//! more. + +use std::mem::take; + +/// Everything past this is left out of the conversion. +pub const MAX_RTF_BYTES: usize = 16 * 1024 * 1024; + +/// Convert `rtf` to Markdown. `image` is asked for each attachment by its +/// file name and returns the Markdown that stands for it, or `None` when it +/// could not be brought in, which the note then says in its place. +pub fn to_markdown(rtf: &[u8], image: impl FnMut(&str) -> Option<String>) -> String { + let mut reader = Reader::new(image); + reader.read(&rtf[..rtf.len().min(MAX_RTF_BYTES)]); + reader.finish() +} + +/// How a run of text looks, as far as Markdown can say it. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +struct Style { + bold: bool, + italic: bool, + strike: bool, +} + +/// What a group's text is for. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum Dest { + #[default] + Text, + /// Not rendered: font and color tables, list definitions, metadata. + Skip, + /// `{\*\fldinst ...}`: a field's instruction, `HYPERLINK "url"`. + FieldInst, + /// `{\listtext ...}`: the list marker Cocoa drew for this paragraph. + ListText, + /// `{\NeXTGraphic name \width...}`: an attachment's file name. + Graphic, +} + +#[derive(Clone, Default)] +struct Group { + style: Style, + dest: Dest, + /// The link every character of this group belongs to. + link: Option<String>, + /// `\ucN`: fallback characters that follow each `\uN`. + uc: usize, +} + +enum Piece { + Text { + text: String, + style: Style, + link: Option<String>, + }, + /// Markdown written as is (an image). + Raw(String), + /// A line break inside a paragraph. + Break, +} + +/// Groups whose first control word says they are not text. +const SKIPPED: &[&str] = &[ + "fonttbl", + "colortbl", + "stylesheet", + "info", + "pict", + "header", + "headerl", + "headerr", + "footer", + "footerl", + "footerr", + "footnote", + "listtable", + "listoverridetable", +]; + +struct Reader<F> { + image: F, + group: Group, + stack: Vec<Group>, + /// The next token is the first in its group. + group_start: bool, + /// The group began with `\*`: skip it unless it is one we read. + starred: bool, + lines: Vec<String>, + para: Vec<Piece>, + list_level: usize, + marker: String, + inst: String, + graphic: String, + graphic_named: bool, + /// Right after an attachment, Cocoa writes a placeholder character. + after_graphic: bool, + high_surrogate: Option<u32>, + /// `\uc` fallback characters still to skip. + fallback: usize, +} + +impl<F: FnMut(&str) -> Option<String>> Reader<F> { + fn new(image: F) -> Self { + Reader { + image, + group: Group { + uc: 1, + ..Group::default() + }, + stack: Vec::new(), + group_start: false, + starred: false, + lines: Vec::new(), + para: Vec::new(), + list_level: 0, + marker: String::new(), + inst: String::new(), + graphic: String::new(), + graphic_named: false, + after_graphic: false, + high_surrogate: None, + fallback: 0, + } + } + + fn read(&mut self, input: &[u8]) { + let mut i = 0; + while i < input.len() { + let b = input[i]; + i += 1; + match b { + b'{' => self.open_group(), + b'}' => self.close_group(), + b'\\' => i = self.control(input, i), + b'\r' | b'\n' => {} + _ => { + self.group_start = false; + self.char(cp1252(b)); + } + } + } + } + + fn open_group(&mut self) { + // A group starts as its parent is, so the children of a skipped or + // collecting group stay skipped or collecting. + let inner = self.group.clone(); + self.stack.push(take(&mut self.group)); + self.group = inner; + self.group_start = true; + self.starred = false; + self.fallback = 0; + } + + fn close_group(&mut self) { + let Some(outer) = self.stack.pop() else { + return; // an unbalanced brace: nothing to close + }; + let closed = std::mem::replace(&mut self.group, outer); + self.fallback = 0; + self.group_start = false; + if closed.dest == Dest::Graphic && self.group.dest != Dest::Graphic { + let name = take(&mut self.graphic); + let name = name.trim(); + if !name.is_empty() { + let md = (self.image)(name).unwrap_or_else(|| format!("[Not imported: {name}]")); + self.para.push(Piece::Raw(md)); + self.after_graphic = true; + } + } + } + + /// A backslash at `input[i - 1]`; returns where reading resumes. + fn control(&mut self, input: &[u8], mut i: usize) -> usize { + let Some(&next) = input.get(i) else { + return i; + }; + if !next.is_ascii_alphabetic() { + i += 1; + let first = take(&mut self.group_start); + match next { + b'\\' | b'{' | b'}' => self.char(next as char), + b'\'' => { + let hex = input + .get(i..i + 2) + .and_then(|h| std::str::from_utf8(h).ok()); + if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + self.char(cp1252(byte)); + i += 2; + } + } + b'\n' | b'\r' if self.group.dest == Dest::Text => self.paragraph(), + b'~' => self.char(' '), + b'_' => self.char('-'), + b'*' if first => self.starred = true, + _ => {} // \- optional hyphen, \: index subentry, \| formula + } + return i; + } + let start = i; + while i < input.len() && input[i].is_ascii_alphabetic() { + i += 1; + } + let word = std::str::from_utf8(&input[start..i]).unwrap_or_default(); + let num_start = i; + if i < input.len() && input[i] == b'-' { + i += 1; + } + while i < input.len() && input[i].is_ascii_digit() { + i += 1; + } + let param = std::str::from_utf8(&input[num_start..i]) + .ok() + .and_then(|n| n.parse::<i64>().ok()); + if i < input.len() && input[i] == b' ' { + i += 1; // the delimiter belongs to the control word + } + self.word(word, param); + i + } + + fn word(&mut self, word: &str, param: Option<i64>) { + let first = take(&mut self.group_start); + let starred = take(&mut self.starred); + if first || starred { + match word { + "fldinst" => { + self.group.dest = Dest::FieldInst; + self.inst.clear(); + return; + } + "listtext" => { + self.group.dest = Dest::ListText; + self.marker.clear(); + return; + } + "NeXTGraphic" => { + self.group.dest = Dest::Graphic; + self.graphic.clear(); + self.graphic_named = false; + return; + } + w if starred || SKIPPED.contains(&w) => { + self.group.dest = Dest::Skip; + return; + } + _ => {} + } + } + if self.group.dest == Dest::Graphic { + // The name ends where the attachment's properties begin. + self.graphic_named = true; + return; + } + let on = param.is_none_or(|p| p != 0); + match word { + "b" => self.group.style.bold = on, + "i" => self.group.style.italic = on, + "strike" | "striked" => self.group.style.strike = on, + "plain" => self.group.style = Style::default(), + "uc" => self.group.uc = param.unwrap_or(1).max(0) as usize, + "u" => { + if let Some(n) = param { + self.unicode(if n < 0 { n + 65536 } else { n } as u32); + } + } + "fldrslt" => self.group.link = hyperlink(&take(&mut self.inst)), + _ if self.group.dest != Dest::Text => {} + "par" | "sect" | "page" => self.paragraph(), + "line" => self.para.push(Piece::Break), + "pard" => self.list_level = 0, + "ilvl" => self.list_level = param.unwrap_or(0).clamp(0, 8) as usize, + "tab" => self.char('\t'), + "emdash" => self.char('\u{2014}'), + "endash" => self.char('\u{2013}'), + "bullet" => self.char('\u{2022}'), + "lquote" => self.char('\u{2018}'), + "rquote" => self.char('\u{2019}'), + "ldblquote" => self.char('\u{201C}'), + "rdblquote" => self.char('\u{201D}'), + "emspace" | "enspace" | "qmspace" => self.char(' '), + _ => {} + } + } + + /// `\uN`, joining UTF-16 surrogate pairs, then the fallback to skip. + fn unicode(&mut self, unit: u32) { + let c = match (self.high_surrogate.take(), unit) { + (None, 0xD800..=0xDBFF) => { + self.high_surrogate = Some(unit); + None + } + (Some(high), 0xDC00..=0xDFFF) => { + char::from_u32(0x10000 + ((high - 0xD800) << 10) + (unit - 0xDC00)) + } + (_, 0xD800..=0xDFFF) => None, // an unpaired half + (_, unit) => char::from_u32(unit), + }; + if let Some(c) = c { + self.put(c); + } + self.fallback = self.group.uc; + } + + /// A character read from the text itself (not `\uN`). + fn char(&mut self, c: char) { + self.group_start = false; + if self.fallback > 0 { + self.fallback -= 1; + return; + } + self.put(c); + } + + fn put(&mut self, c: char) { + if take(&mut self.after_graphic) && (c == '\u{AC}' || c == '\u{FFFC}') { + return; + } + match self.group.dest { + Dest::Skip => {} + Dest::FieldInst => self.inst.push(c), + Dest::ListText => self.marker.push(c), + Dest::Graphic if !self.graphic_named => self.graphic.push(c), + Dest::Graphic => {} + Dest::Text => match c { + '\u{2028}' => self.para.push(Piece::Break), + '\u{FFFC}' => {} + '\t' => self.text('\t'), + c if c.is_control() => {} + c => self.text(c), + }, + } + } + + fn text(&mut self, c: char) { + let (style, link) = (self.group.style, &self.group.link); + if let Some(Piece::Text { + text, + style: s, + link: l, + }) = self.para.last_mut() + { + if *s == style && l == link { + text.push(c); + return; + } + } + self.para.push(Piece::Text { + text: c.to_string(), + style, + link: link.clone(), + }); + } + + fn paragraph(&mut self) { + let pieces = take(&mut self.para); + let marker = list_marker(&take(&mut self.marker)); + let body = render(&pieces); + let line = match marker { + Some(marker) => { + let indent = " ".repeat(self.list_level); + // A line break inside an item stays inside the item. + let hang = " ".repeat(indent.len() + marker.len() + 1); + format!( + "{indent}{marker} {}", + body.replace('\n', &format!("\n{hang}")) + ) + } + None => body, + }; + self.lines.push(line); + } + + fn finish(mut self) -> String { + if !self.para.is_empty() || !self.marker.is_empty() { + self.paragraph(); + } + let lines = &self.lines; + let first = lines.iter().position(|l| !l.trim().is_empty()); + let last = lines.iter().rposition(|l| !l.trim().is_empty()); + match (first, last) { + (Some(first), Some(last)) => lines[first..=last].join("\n"), + _ => String::new(), + } + } +} + +/// The Markdown marker for what Cocoa drew as a list marker: the number of +/// a numbered item, a dash for any bullet. `None` when the paragraph is not +/// a list item. +fn list_marker(drawn: &str) -> Option<String> { + let drawn = drawn.trim(); + if drawn.is_empty() { + return None; + } + let digits: String = drawn.chars().filter(char::is_ascii_digit).collect(); + Some(if digits.is_empty() { + "-".into() + } else { + format!("{digits}.") + }) +} + +/// The URL of a `HYPERLINK "url"` field instruction. +fn hyperlink(inst: &str) -> Option<String> { + let rest = inst.trim().strip_prefix("HYPERLINK")?.trim(); + let url = match rest.strip_prefix('"') { + Some(quoted) => quoted.split('"').next()?, + None => rest.split_whitespace().next()?, + }; + (!url.is_empty()).then(|| url.to_string()) +} + +fn render(pieces: &[Piece]) -> String { + let mut out = String::new(); + for piece in pieces { + match piece { + Piece::Text { text, style, link } => match link { + Some(url) if text.trim() == url => out.push_str(text), + Some(url) => { + let label = styled(&text.replace('[', "\\[").replace(']', "\\]"), *style); + let target = if url.contains([' ', '(', ')']) { + format!("<{url}>") + } else { + url.clone() + }; + out.push_str(&format!("[{label}]({target})")); + } + None => out.push_str(&styled(text, *style)), + }, + Piece::Raw(md) => out.push_str(md), + Piece::Break => out.push('\n'), + } + } + out +} + +/// A run with its emphasis. Markers never touch whitespace (`**bold **` is +/// not Markdown), so a run's leading and trailing spaces go outside them. +fn styled(text: &str, style: Style) -> String { + let core = text.trim(); + if style == Style::default() || core.is_empty() { + return text.to_string(); + } + let lead = &text[..text.len() - text.trim_start().len()]; + let trail = &text[text.trim_end().len()..]; + let mut s = match (style.bold, style.italic) { + (true, true) => format!("***{core}***"), + (true, false) => format!("**{core}**"), + (false, true) => format!("*{core}*"), + (false, false) => core.to_string(), + }; + if style.strike { + s = format!("~~{s}~~"); + } + format!("{lead}{s}{trail}") +} + +/// Windows-1252, the code page Cocoa writes (`\ansicpg1252`). Its 0xA0-0xFF +/// half is Latin-1; only 0x80-0x9F differs. +fn cp1252(b: u8) -> char { + const HIGH: [char; 32] = [ + '\u{20AC}', '\u{81}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', + '\u{2021}', '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{8D}', + '\u{017D}', '\u{8F}', '\u{90}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', + '\u{2013}', '\u{2014}', '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', '\u{0153}', + '\u{9D}', '\u{017E}', '\u{0178}', + ]; + match b { + 0x80..=0x9F => HIGH[usize::from(b - 0x80)], + _ => char::from(b), + } +} diff --git a/src-tauri/core/src/import/stickies.rs b/src-tauri/core/src/import/stickies.rs new file mode 100644 index 0000000..a5a1b92 --- /dev/null +++ b/src-tauri/core/src/import/stickies.rs @@ -0,0 +1,121 @@ +//! Reading an Apple Stickies folder: one `<UUID>.rtfd` package per sticky, +//! and `.SavedStickiesState` for their colors. Nothing here writes; see the +//! feat-stickies-import design, sections 1 and 3. + +use super::rtf::MAX_RTF_BYTES; +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// Where Stickies keeps its notes, under the user's home folder (macOS). +pub const STICKIES_DIR: &str = "Library/Containers/com.apple.Stickies/Data/Library/Stickies"; +const STATE_FILE: &str = ".SavedStickiesState"; +const TEXT_FILE: &str = "TXT.rtf"; + +pub struct Sticky { + /// The package name without `.rtfd`, upper-cased: the sticky's UUID. + pub id: String, + /// The package folder, which also holds its attachments. + pub package: PathBuf, + pub rtf: Vec<u8>, + /// `#rrggbb`, when the state file knows the sticky. + pub color: Option<String>, + pub created_at: SystemTime, + pub updated_at: SystemTime, +} + +/// The folder that holds the packages. Picking Stickies' container, or a +/// folder on the way down to its notes, finds them. +pub fn resolve_folder(dir: &Path) -> PathBuf { + ["Data/Library/Stickies", "Library/Stickies", "Stickies"] + .iter() + .map(|tail| dir.join(tail)) + .find(|candidate| candidate.is_dir()) + .unwrap_or_else(|| dir.to_path_buf()) +} + +/// Every sticky in `dir`, most recently changed first. Fails only when `dir` +/// itself cannot be listed (`PermissionDenied` is how macOS refuses another +/// app's data); a package that cannot be read is left out. +pub fn read_folder(dir: &Path) -> io::Result<Vec<Sticky>> { + let colors = read_colors(&dir.join(STATE_FILE)); + let mut stickies: Vec<Sticky> = fs::read_dir(dir)? + .filter_map(|entry| read_package(&entry.ok()?.path(), &colors)) + .collect(); + stickies.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(stickies) +} + +fn read_package(package: &Path, colors: &HashMap<String, String>) -> Option<Sticky> { + let name = package.file_name()?.to_str()?; + let id = name.strip_suffix(".rtfd")?.to_uppercase(); + if id.is_empty() || id.starts_with('.') { + return None; + } + // A flat `.rtfd` file is a sticky Stickies has not saved yet, and a + // symlink is not one of its packages. + let meta = fs::symlink_metadata(package).ok()?; + if !meta.is_dir() { + return None; + } + let text = package.join(TEXT_FILE); + let text_meta = fs::symlink_metadata(&text).ok()?; + if !text_meta.is_file() || text_meta.len() > MAX_RTF_BYTES as u64 { + return None; + } + let rtf = fs::read(&text).ok()?; + let updated_at = text_meta.modified().ok()?; + // Every save replaces TXT.rtf; the package lives as long as the sticky. + let created_at = meta + .created() + .or_else(|_| meta.modified()) + .unwrap_or(updated_at) + .min(updated_at); + Some(Sticky { + color: colors.get(&id).cloned(), + id, + package: package.to_path_buf(), + rtf, + created_at, + updated_at, + }) +} + +/// UUID to `#rrggbb`, from the state file. Anything unreadable costs only +/// the colors. +fn read_colors(path: &Path) -> HashMap<String, String> { + let entries = match plist::Value::from_file(path) { + Ok(plist::Value::Array(entries)) => entries, + // A future wrapper around the list: take the longest list inside. + Ok(plist::Value::Dictionary(d)) => d + .into_iter() + .filter_map(|(_, v)| v.into_array()) + .max_by_key(Vec::len) + .unwrap_or_default(), + _ => return HashMap::new(), + }; + entries + .iter() + .filter_map(|entry| { + let entry = entry.as_dictionary()?; + let id = entry.get("UUID")?.as_string()?.to_uppercase(); + let color = entry.get("StickyColor")?.as_dictionary()?; + let channel = |key: &str| { + let v = color.get(key)?; + let x = v + .as_real() + .or_else(|| v.as_signed_integer().map(|i| i as f64))?; + Some((x.clamp(0.0, 1.0) * 255.0).round() as u8) + }; + let hex = format!( + "#{:02x}{:02x}{:02x}", + channel("Red")?, + channel("Green")?, + channel("Blue")? + ); + Some((id, hex)) + }) + .collect() +} diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index 133eb7f..b400196 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -1,8 +1,11 @@ +pub mod attachments; pub mod domain; pub mod error; +pub mod import; pub mod sensitive; pub mod store; pub mod types; +pub mod vault; pub use error::AppError; pub use store::Store; diff --git a/src-tauri/core/src/store.rs b/src-tauri/core/src/store.rs index 30506d6..f0193b8 100644 --- a/src-tauri/core/src/store.rs +++ b/src-tauri/core/src/store.rs @@ -104,18 +104,133 @@ CREATE INDEX idx_note_workspaces_ws ON note_workspaces(workspace_id); ALTER TABLE notes DROP COLUMN sync_state; ALTER TABLE notes DROP COLUMN version; ALTER TABLE notes DROP COLUMN last_synced_at; +"#, + // v4: note surface mode, document (markdown) or whiteboard (canvas + // host). Carried here byte-for-byte from feat/note-whiteboard, which + // shipped it in pre-release builds before being lifted off this branch: + // libraries those builds touched are already at v4 with these columns, + // so v4 must mean this everywhere. Nothing reads the columns until the + // whiteboard returns (SEQUENCE.md unit 12); that branch drops its own + // copy of this migration when it rebases. + r#" +ALTER TABLE notes ADD COLUMN content_kind TEXT NOT NULL DEFAULT 'document'; +ALTER TABLE notes ADD COLUMN surface_data TEXT; +"#, + // v5: the vault mirror (feat-portable-vault-sync stage 2, design.md §5). + // vault_path is where the note's file was last written, relative to the + // vault root; file_sha is the sha256 of those bytes. vault_dirty is set + // by the triggers below in the same transaction as any write that + // changes what the note's file holds, so a crash between the commit and + // the file write still leaves the note marked for the next flush. + // last_opened_at is deliberately absent from the watched columns: it is + // device-local and never written to the vault (design.md §7.2). + r#" +ALTER TABLE notes ADD COLUMN vault_path TEXT; +ALTER TABLE notes ADD COLUMN file_sha TEXT; +ALTER TABLE notes ADD COLUMN vault_dirty INTEGER NOT NULL DEFAULT 0; +CREATE INDEX idx_notes_vault_dirty ON notes(vault_dirty) WHERE vault_dirty = 1; +CREATE INDEX idx_notes_vault_path ON notes(vault_path COLLATE NOCASE) + WHERE vault_path IS NOT NULL; + +-- A hard-deleted note leaves no row to flag, so its file is queued here. +CREATE TABLE vault_tombstones ( + vault_path TEXT PRIMARY KEY, + file_sha TEXT +); + +CREATE TRIGGER notes_vault_ai AFTER INSERT ON notes BEGIN + UPDATE notes SET vault_dirty = 1 WHERE seq = new.seq; +END; +CREATE TRIGGER notes_vault_au AFTER UPDATE OF + title, title_is_auto, body, created_at, updated_at, + is_pinned, is_archived, is_deleted, deleted_at ON notes BEGIN + UPDATE notes SET vault_dirty = 1 WHERE seq = new.seq; +END; +CREATE TRIGGER notes_vault_ad AFTER DELETE ON notes + WHEN old.vault_path IS NOT NULL BEGIN + INSERT OR REPLACE INTO vault_tombstones (vault_path, file_sha) + VALUES (old.vault_path, old.file_sha); +END; + +-- Edge changes, including the cascades from deleting a tag or a space. +CREATE TRIGGER note_tags_vault_ai AFTER INSERT ON note_tags BEGIN + UPDATE notes SET vault_dirty = 1 WHERE id = new.note_id; +END; +CREATE TRIGGER note_tags_vault_ad AFTER DELETE ON note_tags BEGIN + UPDATE notes SET vault_dirty = 1 WHERE id = old.note_id; +END; +CREATE TRIGGER note_workspaces_vault_ai AFTER INSERT ON note_workspaces BEGIN + UPDATE notes SET vault_dirty = 1 WHERE id = new.note_id; +END; +CREATE TRIGGER note_workspaces_vault_ad AFTER DELETE ON note_workspaces BEGIN + UPDATE notes SET vault_dirty = 1 WHERE id = old.note_id; +END; + +-- Names are what the frontmatter carries; a color change touches only +-- instantnotes.yaml, which the flush compares on its own. +CREATE TRIGGER tags_vault_au AFTER UPDATE OF name ON tags + WHEN old.name IS NOT new.name BEGIN + UPDATE notes SET vault_dirty = 1 + WHERE id IN (SELECT note_id FROM note_tags WHERE tag_id = new.id); +END; +CREATE TRIGGER workspaces_vault_au AFTER UPDATE OF name ON workspaces + WHEN old.name IS NOT new.name BEGIN + UPDATE notes SET vault_dirty = 1 + WHERE id IN (SELECT note_id FROM note_workspaces WHERE workspace_id = new.id); +END; +"#, + // v6: whiteboards in the vault. A board's canvas is a `.excalidraw` file + // beside its note file (vault/board.rs); board_sha is the sha256 of the + // canvas bytes last written, so the canvas gets the same "only remove + // what still holds our bytes" rule as the note file. The update trigger + // now also watches the whiteboard columns, and a hard delete queues the + // canvas for removal alongside the note file. + r#" +ALTER TABLE notes ADD COLUMN board_sha TEXT; + +DROP TRIGGER notes_vault_au; +CREATE TRIGGER notes_vault_au AFTER UPDATE OF + title, title_is_auto, body, created_at, updated_at, + is_pinned, is_archived, is_deleted, deleted_at, + content_kind, surface_data ON notes BEGIN + UPDATE notes SET vault_dirty = 1 WHERE seq = new.seq; +END; + +DROP TRIGGER notes_vault_ad; +CREATE TRIGGER notes_vault_ad AFTER DELETE ON notes + WHEN old.vault_path IS NOT NULL BEGIN + INSERT OR REPLACE INTO vault_tombstones (vault_path, file_sha) + VALUES (old.vault_path, old.file_sha); + INSERT OR REPLACE INTO vault_tombstones (vault_path, file_sha) + SELECT substr(old.vault_path, 1, length(old.vault_path) - 3) || '.excalidraw', + old.board_sha + WHERE old.board_sha IS NOT NULL; +END; "#, ]; const NOTE_COLUMNS: &str = "id, title, body, created_at, updated_at, last_opened_at, \ - is_pinned, is_archived, is_deleted, deleted_at"; + is_pinned, is_archived, is_deleted, deleted_at, content_kind, surface_data"; + +/// `NOTE_COLUMNS` for list queries: the whiteboard canvas stays out of list +/// rows (see `Note::surface_data`). +const LIST_COLUMNS: &str = "id, title, body, created_at, updated_at, last_opened_at, \ + is_pinned, is_archived, is_deleted, deleted_at, content_kind, NULL"; pub struct Store { conn: Connection, + /// The live vault mirror (stage 2), when one is configured. + vault: Option<vault::VaultState>, } fn now_iso() -> String { - Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true) + iso(std::time::SystemTime::now()) +} + +/// The one timestamp format the store writes (and callers show), which also +/// sorts as text. +pub fn iso(t: std::time::SystemTime) -> String { + chrono::DateTime::<Utc>::from(t).to_rfc3339_opts(SecondsFormat::Micros, true) } fn new_id() -> String { @@ -134,6 +249,8 @@ fn row_to_note(row: &rusqlite::Row<'_>) -> rusqlite::Result<Note> { is_archived: row.get::<_, i64>(7)? != 0, is_deleted: row.get::<_, i64>(8)? != 0, deleted_at: row.get(9)?, + content_kind: row.get(10)?, + surface_data: row.get(11)?, }) } @@ -187,6 +304,34 @@ fn tag_get_or_create(conn: &Connection, raw_name: &str) -> Result<Tag> { }) } +/// Get-or-create a workspace inside an existing transaction/connection. +fn workspace_get_or_create(conn: &Connection, raw_name: &str) -> Result<Workspace> { + let name = domain::normalize_workspace_name(raw_name) + .ok_or_else(|| AppError::Validation("workspace name must not be empty".into()))?; + if let Some(ws) = conn + .query_row( + &format!("SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE name = ?1"), + params![name], + row_to_workspace, + ) + .optional()? + { + return Ok(ws); + } + let now = now_iso(); + let id = new_id(); + conn.execute( + "INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", + params![id, name, now], + )?; + Ok(Workspace { + id, + name, + created_at: now.clone(), + updated_at: now, + }) +} + /// Attach a tag to a note (idempotent). fn attach_tag(conn: &Connection, note_id: &str, tag_id: &str, source: &str) -> Result<()> { conn.execute( @@ -280,15 +425,21 @@ impl Store { Self::init(conn) } - fn init(conn: Connection) -> Result<Self> { + fn init(mut conn: Connection) -> Result<Self> { conn.pragma_update(None, "foreign_keys", "ON")?; + // Take the write lock when a transaction begins, not at its first + // write. The library is also opened by agent processes + // (`instantnotes mcp`); a deferred transaction that reads and then + // writes fails at once with SQLITE_BUSY on the upgrade instead of + // waiting out the busy timeout. + conn.set_transaction_behavior(rusqlite::TransactionBehavior::Immediate); let check: String = conn.query_row("PRAGMA quick_check", [], |r| r.get(0))?; if check != "ok" { return Err(AppError::Corruption(format!( "database integrity check failed: {check}" ))); } - let mut store = Store { conn }; + let mut store = Store { conn, vault: None }; store.migrate()?; Ok(store) } @@ -301,11 +452,10 @@ impl Store { // written by a newer build; its schema is unknown to us, so refuse // rather than run queries that assume the older shape. if current > MIGRATIONS.len() as i64 { - return Err(AppError::Migration(format!( - "database schema v{current} was created by a newer version of \ - the app (this build knows up to v{})", - MIGRATIONS.len() - ))); + return Err(AppError::SchemaTooNew { + found: current, + known: MIGRATIONS.len(), + }); } for (idx, sql) in MIGRATIONS.iter().enumerate() { let target = (idx + 1) as i64; @@ -390,6 +540,26 @@ impl Store { Ok(()) } + /// SQLite's `data_version`: moves only when another connection (an agent + /// process, say) commits to this file. This connection's own writes never + /// move it, so a change means someone else wrote. + pub fn data_version(&self) -> Result<i64> { + Ok(self + .conn + .query_row("PRAGMA data_version", [], |r| r.get(0))?) + } + + /// Whether the file is still at the schema this build migrated it to. A + /// long-lived second process (an agent server) checks this before each + /// write, so a newer app that migrated the file meanwhile is never + /// written to through older code. + pub fn schema_is_current(&self) -> Result<bool> { + let version: i64 = self + .conn + .query_row("PRAGMA user_version", [], |r| r.get(0))?; + Ok(version == MIGRATIONS.len() as i64) + } + fn fetch_note(&self, id: &str) -> Result<Note> { self.conn .query_row( @@ -402,9 +572,14 @@ impl Store { } } +mod attachments; +mod graph; +mod import; mod notes; mod settings; +mod stats; mod tags; +mod vault; mod workspaces; #[cfg(test)] @@ -496,4 +671,125 @@ mod migration_tests { let all = store.list_notes(Default::default()).unwrap(); assert!(all.iter().any(|n| n.id == "n1")); } + + /// A library written by a pre-release build that carried the whiteboard + /// (schema v4: content_kind and surface_data) must open, not be refused + /// as too new, and gain the vault columns on top. + #[test] + fn a_whiteboard_v4_library_migrates_to_the_vault_schema() { + let dir = tempdir().unwrap(); + let path = dir.path().join("whiteboard.db"); + { + let conn = Connection::open(&path).unwrap(); + for sql in &MIGRATIONS[..3] { + conn.execute_batch(sql).unwrap(); + } + conn.execute_batch( + "ALTER TABLE notes ADD COLUMN content_kind TEXT NOT NULL DEFAULT 'document'; + ALTER TABLE notes ADD COLUMN surface_data TEXT;", + ) + .unwrap(); + conn.pragma_update(None, "user_version", 4i64).unwrap(); + conn.execute( + "INSERT INTO notes (id, title, body, created_at, updated_at, content_kind) \ + VALUES ('n1', 'Board', 'the body', 't', 't', 'whiteboard')", + [], + ) + .unwrap(); + } + + let mut store = Store::open(&path).unwrap(); + + let cols = note_columns(&store.conn); + for col in ["content_kind", "surface_data", "vault_path", "vault_dirty"] { + assert!(cols.contains(&col.to_string()), "missing {col}"); + } + let kind: String = store + .conn + .query_row("SELECT content_kind FROM notes WHERE id = 'n1'", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(kind, "whiteboard"); + assert_eq!(store.get_note("n1", false).unwrap().body, "the body"); + } + + /// A library at v5 (the vault mirror without whiteboards) gains the + /// canvas hash column, and a board's canvas is tombstoned with its note. + #[test] + fn v6_tracks_whiteboard_canvases_in_the_vault() { + let dir = tempdir().unwrap(); + let path = dir.path().join("v5.db"); + { + let conn = Connection::open(&path).unwrap(); + for sql in &MIGRATIONS[..5] { + conn.execute_batch(sql).unwrap(); + } + conn.pragma_update(None, "user_version", 5i64).unwrap(); + } + + let store = Store::open(&path).unwrap(); + assert!(note_columns(&store.conn).contains(&"board_sha".to_string())); + store + .conn + .execute_batch( + "INSERT INTO notes (id, title, body, created_at, updated_at, vault_path, \ + file_sha, board_sha) VALUES ('n1', 'B', '', 't', 't', 'B.md', 'a', 'b'); + DELETE FROM notes WHERE id = 'n1';", + ) + .unwrap(); + let mut stmt = store + .conn + .prepare("SELECT vault_path, file_sha FROM vault_tombstones ORDER BY vault_path") + .unwrap(); + let rows: Vec<(String, String)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert_eq!( + rows, + vec![ + ("B.excalidraw".to_string(), "b".to_string()), + ("B.md".to_string(), "a".to_string()), + ] + ); + } + + /// A 0.8/0.9-era library (schema v3) gains the vault mirror columns on + /// open. Existing notes survive and start clean: nothing is pending + /// until a vault is configured, which marks every note itself. + #[test] + fn v3_library_gains_vault_columns_and_preserves_notes() { + let dir = tempdir().unwrap(); + let path = dir.path().join("v3.db"); + { + let conn = Connection::open(&path).unwrap(); + for sql in &MIGRATIONS[..3] { + conn.execute_batch(sql).unwrap(); + } + conn.pragma_update(None, "user_version", 3i64).unwrap(); + conn.execute( + "INSERT INTO notes (id, title, body, created_at, updated_at) \ + VALUES ('n1', 'Kept', 'the body', 't', 't')", + [], + ) + .unwrap(); + } + + let mut store = Store::open(&path).unwrap(); + + let cols = note_columns(&store.conn); + for col in ["vault_path", "file_sha", "vault_dirty"] { + assert!(cols.contains(&col.to_string()), "missing {col}"); + } + let dirty: i64 = store + .conn + .query_row("SELECT vault_dirty FROM notes WHERE id = 'n1'", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(dirty, 0); + assert_eq!(store.get_note("n1", false).unwrap().body, "the body"); + } } diff --git a/src-tauri/core/src/store/attachments.rs b/src-tauri/core/src/store/attachments.rs new file mode 100644 index 0000000..fe5a968 --- /dev/null +++ b/src-tauri/core/src/store/attachments.rs @@ -0,0 +1,90 @@ +//! Which attachment files are still referenced (`crate::attachments`). +//! Every note counts, in any state: trashed and archived notes keep their +//! images, since both can come back. So does the capture draft, which holds +//! text that is not a note yet. + +use super::*; +use crate::attachments::{referenced_names, remove_attachment}; +use std::collections::BTreeSet; + +/// The setting the capture window keeps its unsent draft in. +const CAPTURE_DRAFT_SETTING: &str = "capture.draft"; + +impl Store { + /// Attachment names the notes `ids` reference, in their text or, for a + /// whiteboard, its canvas. + pub fn attachment_names_of(&self, ids: &[String]) -> Result<BTreeSet<String>> { + let mut names = BTreeSet::new(); + let mut stmt = self + .conn + .prepare("SELECT body, surface_data FROM notes WHERE id = ?1")?; + for id in ids { + let row: Option<(String, Option<String>)> = stmt + .query_row(params![id], |r| Ok((r.get(0)?, r.get(1)?))) + .optional()?; + if let Some((body, surface)) = row { + names.extend(referenced_names(&body)); + names.extend(referenced_names(surface.as_deref().unwrap_or(""))); + } + } + Ok(names) + } + + /// Of `names`, the ones nothing references any more: no note in any + /// state, and not the capture draft. Sorted. + pub fn unreferenced_attachments( + &self, + names: impl IntoIterator<Item = String>, + ) -> Result<Vec<String>> { + let draft = self + .get_setting(CAPTURE_DRAFT_SETTING)? + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_default(); + let in_draft: BTreeSet<String> = referenced_names(&draft) + .into_iter() + .map(|n| n.to_ascii_lowercase()) + .collect(); + // Case-insensitive, like the disks attachments usually live on: a + // reference spelled `A.PNG` still shows `a.png` there. + let mut stmt = self.conn.prepare( + "SELECT 1 FROM notes WHERE instr(lower(body), ?1) > 0 \ + OR instr(lower(COALESCE(surface_data, '')), ?1) > 0 LIMIT 1", + )?; + let mut out = Vec::new(); + for name in names { + let lowered = name.to_ascii_lowercase(); + if in_draft.contains(&lowered) { + continue; + } + let reference = format!("attachments/{lowered}"); + if !stmt.exists(params![reference])? { + out.push(name); + } + } + out.sort(); + Ok(out) + } + + /// Remove, from the attachments folder `dir`, each of `names` that + /// nothing references, re-checked here rather than trusted, along with + /// the live vault's unchanged copy. Callers hold the store for the whole + /// call, so no save can add a reference between the check and the + /// removal. A file that cannot be removed is skipped, not fatal. + pub fn remove_unreferenced_attachments( + &self, + dir: &Path, + names: impl IntoIterator<Item = String>, + ) -> Result<AttachmentCleanup> { + let vault_dir = self.vault_root().map(|root| root.join("attachments")); + let mut done = AttachmentCleanup::default(); + for name in self.unreferenced_attachments(names)? { + if let Ok(bytes) = remove_attachment(dir, vault_dir.as_deref(), &name) { + if bytes > 0 { + done.count += 1; + done.bytes += bytes; + } + } + } + Ok(done) + } +} diff --git a/src-tauri/core/src/store/graph.rs b/src-tauri/core/src/store/graph.rs new file mode 100644 index 0000000..a884f8c --- /dev/null +++ b/src-tauri/core/src/store/graph.rs @@ -0,0 +1,70 @@ +//! The library graph (`types::LibraryGraph`). Live notes only: the trash and +//! the archive are out of view everywhere else by default, so they are here +//! too. Every tag and Space is listed; the view decides what to draw. + +use super::*; + +impl Store { + pub fn library_graph(&self) -> Result<LibraryGraph> { + const LIVE: &str = "n.is_deleted = 0 AND n.is_archived = 0"; + let notes = self.query_rows( + &format!( + "SELECT n.id, n.title, n.content_kind, n.is_pinned FROM notes n \ + WHERE {LIVE} ORDER BY n.updated_at DESC, n.id" + ), + |r| { + Ok(GraphNote { + id: r.get(0)?, + title: r.get(1)?, + content_kind: r.get(2)?, + is_pinned: r.get::<_, i64>(3)? != 0, + }) + }, + )?; + let tags = self.query_rows("SELECT id, name, color FROM tags ORDER BY name", |r| { + Ok(GraphTag { + id: r.get(0)?, + name: r.get(1)?, + color: r.get(2)?, + }) + })?; + let spaces = self.query_rows("SELECT id, name FROM workspaces ORDER BY name", |r| { + Ok(GraphSpace { + id: r.get(0)?, + name: r.get(1)?, + }) + })?; + let links = self.query_rows( + &format!( + "SELECT e.note_id, e.tag_id, 'tag' FROM note_tags e \ + JOIN notes n ON n.id = e.note_id WHERE {LIVE} \ + UNION ALL \ + SELECT e.note_id, e.workspace_id, 'space' FROM note_workspaces e \ + JOIN notes n ON n.id = e.note_id WHERE {LIVE}" + ), + |r| { + Ok(GraphLink { + note_id: r.get(0)?, + target_id: r.get(1)?, + kind: r.get(2)?, + }) + }, + )?; + Ok(LibraryGraph { + notes, + tags, + spaces, + links, + }) + } + + fn query_rows<T>( + &self, + sql: &str, + map: impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>, + ) -> Result<Vec<T>> { + let mut stmt = self.conn.prepare(sql)?; + let rows = stmt.query_map([], map)?; + Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) + } +} diff --git a/src-tauri/core/src/store/import.rs b/src-tauri/core/src/store/import.rs new file mode 100644 index 0000000..58a95b2 --- /dev/null +++ b/src-tauri/core/src/store/import.rs @@ -0,0 +1,105 @@ +//! Notes brought in from other apps (Settings > Import): all of a batch in +//! one transaction, each source note at most once per library. + +use super::notes::{insert_note, NewNote}; +use super::settings::setting_put; +use super::*; +use std::collections::{BTreeMap, HashSet}; + +impl Store { + /// The source ids already imported from `source` whose note still + /// exists, in the Trash or not. + pub fn imported_ids(&self, source: &str) -> Result<HashSet<String>> { + Ok(imported(&self.conn, source)?.into_keys().collect()) + } + + /// Insert `items` as notes, skipping any imported before, filed in the + /// Space named `space` (created if new, and only if a note lands; none + /// when blank). Each keeps its dates; its title comes from its body, and + /// its `#words` become tags, as for any note. They are stamped as opened + /// now: an import is not a capture waiting in Revisit. + pub fn import_notes( + &mut self, + source: &str, + items: Vec<ImportItem>, + space: Option<&str>, + ) -> Result<ImportOutcome> { + let tx = self.conn.transaction()?; + let mut done = imported(&tx, source)?; + let space = space.map(str::trim).filter(|s| !s.is_empty()); + let mut workspace: Option<Workspace> = None; + let mut outcome = ImportOutcome { + imported: 0, + skipped: 0, + workspace_id: None, + }; + let now = now_iso(); + for item in items { + if done.contains_key(&item.source_id) { + outcome.skipped += 1; + continue; + } + let id = insert_note( + &tx, + NewNote { + body: &item.body, + title: None, + created_at: &iso(item.created_at), + updated_at: &iso(item.updated_at.max(item.created_at)), + last_opened_at: Some(&now), + }, + )?; + if let Some(name) = space { + if workspace.is_none() { + workspace = Some(workspace_get_or_create(&tx, name)?); + } + if let Some(ws) = &workspace { + tx.execute( + "INSERT OR IGNORE INTO note_workspaces (note_id, workspace_id, created_at) \ + VALUES (?1, ?2, ?3)", + params![id, ws.id, now], + )?; + } + } + done.insert(item.source_id, id); + outcome.imported += 1; + } + if outcome.imported > 0 { + let map = serde_json::to_value(&done) + .map_err(|e| AppError::Storage(format!("import record: {e}")))?; + setting_put(&tx, &record_key(source), &map)?; + } + tx.commit()?; + outcome.workspace_id = workspace.map(|w| w.id); + Ok(outcome) + } +} + +/// Settings key holding, for one source, which note each source id became. +fn record_key(source: &str) -> String { + format!("import.{source}") +} + +/// The record for `source`, keeping only entries whose note still exists: a +/// note destroyed for good frees its source note to be imported again. +fn imported(conn: &Connection, source: &str) -> Result<BTreeMap<String, String>> { + let raw: Option<String> = conn + .query_row( + "SELECT value FROM settings WHERE key = ?1", + params![record_key(source)], + |r| r.get(0), + ) + .optional()?; + // A record that cannot be parsed is rebuilt by the next import. + let record: BTreeMap<String, String> = raw + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let mut exists = conn.prepare("SELECT 1 FROM notes WHERE id = ?1")?; + let mut live = BTreeMap::new(); + for (source_id, note_id) in record { + if exists.exists(params![note_id])? { + live.insert(source_id, note_id); + } + } + Ok(live) +} diff --git a/src-tauri/core/src/store/notes.rs b/src-tauri/core/src/store/notes.rs index 9ad2a15..dd457c5 100644 --- a/src-tauri/core/src/store/notes.rs +++ b/src-tauri/core/src/store/notes.rs @@ -2,29 +2,62 @@ use super::*; +/// A note about to be written by `insert_note`. +pub(super) struct NewNote<'a> { + pub body: &'a str, + /// Set explicitly; otherwise derived from the body. + pub title: Option<String>, + pub created_at: &'a str, + pub updated_at: &'a str, + pub last_opened_at: Option<&'a str>, +} + +/// The one place a note row is inserted, inside the caller's transaction: +/// its title (explicit, or derived from the body) and its inline `#tags`. +/// Returns the new note's id. +pub(super) fn insert_note(conn: &Connection, note: NewNote<'_>) -> Result<String> { + let explicit_title = note + .title + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()); + let title_is_auto = explicit_title.is_none(); + let title = explicit_title.unwrap_or_else(|| domain::derive_title(note.body)); + let id = new_id(); + conn.execute( + "INSERT INTO notes (id, title, title_is_auto, body, created_at, updated_at, \ + last_opened_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + id, + title, + i64::from(title_is_auto), + note.body, + note.created_at, + note.updated_at, + note.last_opened_at + ], + )?; + for name in domain::extract_inline_tags(note.body) { + let tag = tag_get_or_create(conn, &name)?; + attach_tag(conn, &id, &tag.id, "inline")?; + } + Ok(id) +} + impl Store { pub fn create_note(&mut self, input: CreateNoteInput) -> Result<Note> { let body = input.body.unwrap_or_default(); - let explicit_title = input - .title - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()); - let title_is_auto = explicit_title.is_none(); - let title = explicit_title.unwrap_or_else(|| domain::derive_title(&body)); - let inline_tags = domain::extract_inline_tags(&body); let now = now_iso(); - let id = new_id(); - let tx = self.conn.transaction()?; - tx.execute( - "INSERT INTO notes (id, title, title_is_auto, body, created_at, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?5)", - params![id, title, i64::from(title_is_auto), body, now], + let id = insert_note( + &tx, + NewNote { + body: &body, + title: input.title, + created_at: &now, + updated_at: &now, + last_opened_at: None, + }, )?; - for name in &inline_tags { - let tag = tag_get_or_create(&tx, name)?; - attach_tag(&tx, &id, &tag.id, "inline")?; - } for name in &input.tags { let tag = tag_get_or_create(&tx, name)?; attach_tag(&tx, &id, &tag.id, "manual")?; @@ -33,6 +66,22 @@ impl Store { self.fetch_note(&id) } + /// Whether the note's title is still auto-derived from its body rather + /// than set explicitly. Not on `Note` itself: IPC callers never need + /// it; the vault serializer does (design.md §3.2: frontmatter omits + /// `title` exactly when this is true). + pub fn title_is_auto(&self, id: &str) -> Result<bool> { + self.conn + .query_row( + "SELECT title_is_auto FROM notes WHERE id = ?1", + params![id], + |r| r.get::<_, i64>(0), + ) + .optional()? + .map(|v| v != 0) + .ok_or_else(|| AppError::NotFound(format!("note {id} not found"))) + } + /// Fetch a note. When `touch` is true, updates `last_opened_at`. pub fn get_note(&mut self, id: &str, touch: bool) -> Result<Note> { if touch { @@ -53,9 +102,34 @@ impl Store { && patch.body.is_none() && patch.is_pinned.is_none() && patch.is_archived.is_none() + && patch.content_kind.is_none() + && patch.surface_data.is_none() { return Ok(existing); } + + // Converting to a whiteboard is one-way, and only a whiteboard has a + // canvas to hold. + let kind = match patch.content_kind.as_deref() { + None => existing.content_kind.as_str(), + Some(k @ (CONTENT_KIND_DOCUMENT | CONTENT_KIND_WHITEBOARD)) => k, + Some(other) => { + return Err(AppError::Validation(format!( + "content kind must be {CONTENT_KIND_DOCUMENT} or {CONTENT_KIND_WHITEBOARD}, got {other}" + ))) + } + }; + if existing.content_kind == CONTENT_KIND_WHITEBOARD && kind != CONTENT_KIND_WHITEBOARD { + return Err(AppError::Validation( + "a whiteboard cannot be turned back into a document".into(), + )); + } + let is_board = kind == CONTENT_KIND_WHITEBOARD; + if patch.surface_data.is_some() && !is_board { + return Err(AppError::Validation( + "only a whiteboard can hold a canvas".into(), + )); + } let title_is_auto: bool = self .conn .query_row( @@ -70,14 +144,33 @@ impl Store { .title .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()); + // A whiteboard's body is the text on its canvas, rewritten by every + // save, so its title stops following the body the moment it converts. let (new_title, new_title_is_auto) = match (&explicit_title, &patch.body) { (Some(t), _) => (Some(t.clone()), Some(false)), - (None, Some(body)) if title_is_auto => (Some(domain::derive_title(body)), None), + (None, Some(body)) if title_is_auto && !is_board => { + (Some(domain::derive_title(body)), None) + } + (None, _) if title_is_auto && is_board => (None, Some(false)), _ => (None, None), }; let now = now_iso(); let tx = self.conn.transaction()?; + // Checked inside the (immediate) transaction, so no other writer can + // land between the check and the update. + if let Some(expected) = &patch.expected_updated_at { + let current: String = tx.query_row( + "SELECT updated_at FROM notes WHERE id = ?1", + params![id], + |r| r.get(0), + )?; + if ¤t != expected { + return Err(AppError::Conflict(format!( + "note {id} changed since it was read" + ))); + } + } tx.execute( "UPDATE notes SET \ title = COALESCE(?1, title), \ @@ -85,14 +178,18 @@ impl Store { body = COALESCE(?3, body), \ is_pinned = COALESCE(?4, is_pinned), \ is_archived = COALESCE(?5, is_archived), \ - updated_at = ?6 \ - WHERE id = ?7", + content_kind = ?6, \ + surface_data = COALESCE(?7, surface_data), \ + updated_at = ?8 \ + WHERE id = ?9", params![ new_title, new_title_is_auto.map(i64::from), patch.body.as_deref(), patch.is_pinned.map(i64::from), patch.is_archived.map(i64::from), + kind, + patch.surface_data.as_deref(), now, id ], @@ -239,11 +336,13 @@ impl Store { let offset = filter.offset.unwrap_or(0).max(0); // Pinned notes float to the top of every live list; trash keeps - // plain recency order. + // plain recency order. The id is a final tiebreaker so rows equal on + // the sort column keep one fixed order, which LIMIT/OFFSET paging + // needs to never skip or repeat a row across a page boundary. let pinned_first = if deleted { "" } else { "is_pinned DESC, " }; let sql = format!( - "SELECT {NOTE_COLUMNS} FROM notes WHERE {} \ - ORDER BY {pinned_first}{order_column} {order_dir} \ + "SELECT {LIST_COLUMNS} FROM notes WHERE {} \ + ORDER BY {pinned_first}{order_column} {order_dir}, id ASC \ LIMIT {limit} OFFSET {offset}", conditions.join(" AND ") ); diff --git a/src-tauri/core/src/store/settings.rs b/src-tauri/core/src/store/settings.rs index 943c67d..83d4046 100644 --- a/src-tauri/core/src/store/settings.rs +++ b/src-tauri/core/src/store/settings.rs @@ -21,15 +21,7 @@ impl Store { } pub fn set_setting(&mut self, key: &str, value: serde_json::Value) -> Result<()> { - let serialized = serde_json::to_string(&value) - .map_err(|e| AppError::Validation(format!("unserializable setting value: {e}")))?; - self.conn.execute( - "INSERT INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3) \ - ON CONFLICT(key) DO UPDATE SET value = excluded.value, \ - updated_at = excluded.updated_at", - params![key, serialized, now_iso()], - )?; - Ok(()) + setting_put(&self.conn, key, &value) } pub fn delete_setting(&mut self, key: &str) -> Result<()> { @@ -38,3 +30,16 @@ impl Store { Ok(()) } } + +/// Write a setting inside an existing transaction/connection. +pub(super) fn setting_put(conn: &Connection, key: &str, value: &serde_json::Value) -> Result<()> { + let serialized = serde_json::to_string(value) + .map_err(|e| AppError::Validation(format!("unserializable setting value: {e}")))?; + conn.execute( + "INSERT INTO settings (key, value, updated_at) VALUES (?1, ?2, ?3) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value, \ + updated_at = excluded.updated_at", + params![key, serialized, now_iso()], + )?; + Ok(()) +} diff --git a/src-tauri/core/src/store/stats.rs b/src-tauri/core/src/store/stats.rs new file mode 100644 index 0000000..c790de3 --- /dev/null +++ b/src-tauri/core/src/store/stats.rs @@ -0,0 +1,62 @@ +//! Aggregate counts for the Settings dashboard. One cheap query per number; +//! the dashboard is opened rarely and never on a hot path. + +use super::*; + +impl Store { + pub fn library_stats(&self) -> Result<LibraryStats> { + let count = |sql: &str| -> Result<i64> { Ok(self.conn.query_row(sql, [], |r| r.get(0))?) }; + Ok(LibraryStats { + notes_total: count("SELECT COUNT(*) FROM notes WHERE is_deleted = 0")?, + notes_active: count( + "SELECT COUNT(*) FROM notes WHERE is_deleted = 0 AND is_archived = 0", + )?, + notes_pinned: count( + "SELECT COUNT(*) FROM notes WHERE is_deleted = 0 AND is_pinned = 1", + )?, + notes_archived: count( + "SELECT COUNT(*) FROM notes WHERE is_deleted = 0 AND is_archived = 1", + )?, + notes_trashed: count("SELECT COUNT(*) FROM notes WHERE is_deleted = 1")?, + tags: count("SELECT COUNT(*) FROM tags")?, + spaces: count("SELECT COUNT(*) FROM workspaces")?, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::store::Store; + use crate::types::CreateNoteInput; + + #[test] + fn counts_notes_by_status_tags_and_spaces() { + let mut store = Store::open_in_memory().unwrap(); + + let a = store + .create_note(CreateNoteInput { + body: Some("one #work".into()), + ..Default::default() + }) + .unwrap(); + let b = store + .create_note(CreateNoteInput { + body: Some("two".into()), + ..Default::default() + }) + .unwrap(); + store + .set_notes_flags(std::slice::from_ref(&a.id), Some(true), None) + .unwrap(); + store.soft_delete_note(&b.id).unwrap(); + store.get_or_create_workspace("Ideas").unwrap(); + + let s = store.library_stats().unwrap(); + assert_eq!(s.notes_total, 1); // b is trashed, a remains + assert_eq!(s.notes_active, 1); + assert_eq!(s.notes_pinned, 1); + assert_eq!(s.notes_trashed, 1); + assert_eq!(s.tags, 1); // #work, auto-extracted from a's body + assert_eq!(s.spaces, 1); + } +} diff --git a/src-tauri/core/src/store/vault.rs b/src-tauri/core/src/store/vault.rs new file mode 100644 index 0000000..f17b22e --- /dev/null +++ b/src-tauri/core/src/store/vault.rs @@ -0,0 +1,555 @@ +//! Stage 2 of the portable vault: the live mirror (dual-write, design.md §6). +//! SQLite stays authoritative. Migration v5's triggers set `vault_dirty` in +//! the same transaction as every write that changes a note's file; +//! `flush_vault` writes those notes out and clears the flag. Nothing here +//! reads the vault back into the store. +//! +//! Safety rules, each pinned by `core/tests/vault_mirror_test.rs`: +//! - Only files the mirror owns are written or removed: a path recorded in +//! `vault_path` or a tombstone, or a file whose frontmatter `id` is the +//! note's own. Anything else at a target name is left alone and the note +//! takes a suffixed name instead. +//! - A removal happens only while the file still holds the bytes the mirror +//! wrote (`file_sha`); a file edited outside the app is left in place. +//! - The vault root is never created. A missing root (an unmounted drive) +//! fails the flush and leaves every note dirty until it returns. +//! - A whiteboard's canvas is a `.excalidraw` file named after its note file +//! (`vault/board.rs`). It moves, trashes, and deletes with the note under +//! the same rules, tracked by its own hash in `board_sha`. + +use super::*; +use crate::vault::{ + atomic_write, candidate_filenames, canvas_file, canvas_rel, note_rel_of_canvas, parse_note, + same_canvas, serialize_manifest, serialize_note, FlushOutcome, Manifest, ManifestSpace, + ManifestTag, VaultNote, VaultReport, VaultStatus, CANVAS_EXT, +}; +use sha2::{Digest, Sha256}; +use std::fs; + +/// Device-local, like every other setting: another device's vault lives +/// wherever that device keeps it. +const VAULT_PATH_SETTING: &str = "vault.path"; +const MANIFEST_FILE: &str = "instantnotes.yaml"; +const TRASH_DIR: &str = "trash"; + +pub(crate) struct VaultState { + root: PathBuf, + last_error: Option<String>, + last_flushed_at: Option<String>, +} + +impl VaultState { + fn new(root: PathBuf) -> Self { + VaultState { + root, + last_error: None, + last_flushed_at: None, + } + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn io_error(context: &str, path: &Path, e: std::io::Error) -> AppError { + AppError::Storage(format!("{context} {}: {e}", path.display())) +} + +fn root_missing(root: &Path) -> AppError { + AppError::Storage(format!("vault folder not found: {}", root.display())) +} + +/// Whether two paths name one file. On a case-insensitive filesystem +/// `notes.md` and `Notes.md` do, which is what makes a case-only rename +/// dangerous: removing the "old" path would delete the new file. +fn same_file(a: &Path, b: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match (fs::metadata(a), fs::metadata(b)) { + (Ok(x), Ok(y)) => x.dev() == y.dev() && x.ino() == y.ino(), + _ => false, + } + } + #[cfg(not(unix))] + { + a.exists() && a.to_string_lossy().to_lowercase() == b.to_string_lossy().to_lowercase() + } +} + +/// Whether the file at `path` still holds exactly the bytes we wrote. +fn holds_our_bytes(path: &Path, sha: Option<&str>) -> bool { + match (fs::read(path), sha) { + (Ok(bytes), Some(sha)) => sha256_hex(&bytes) == sha, + _ => false, + } +} + +/// Where a note's files were last written, and the hashes of what was +/// written there. +struct WrittenFiles { + rel: Option<String>, + sha: Option<String>, + board_sha: Option<String>, +} + +impl Store { + /// Point the mirror at `root`, or stop mirroring with `None`. A new root + /// starts from scratch: every note is marked for writing and the old + /// folder is left exactly as it was. Stopping leaves the files in place. + pub fn configure_vault(&mut self, root: Option<&Path>) -> Result<()> { + let Some(root) = root else { + self.delete_setting(VAULT_PATH_SETTING)?; + self.forget_vault_paths()?; + self.vault = None; + return Ok(()); + }; + if !root.is_dir() { + return Err(AppError::Validation(format!( + "vault folder not found: {}", + root.display() + ))); + } + let root_str = root.to_string_lossy().into_owned(); + if self.saved_vault_root()?.as_deref() != Some(root_str.as_str()) { + self.forget_vault_paths()?; + self.conn.execute("UPDATE notes SET vault_dirty = 1", [])?; + } + self.set_setting(VAULT_PATH_SETTING, serde_json::Value::String(root_str))?; + self.vault = Some(VaultState::new(root.to_path_buf())); + Ok(()) + } + + /// Resume the mirror saved by `configure_vault`, at startup. The folder + /// need not exist right now (an unmounted drive): flushes report it + /// missing and keep notes pending until it is back. + pub fn attach_saved_vault(&mut self) -> Result<()> { + self.vault = self.saved_vault_root()?.map(|p| VaultState::new(p.into())); + Ok(()) + } + + /// The configured vault root, if any. + pub fn vault_root(&self) -> Option<&Path> { + self.vault.as_ref().map(|v| v.root.as_path()) + } + + pub fn vault_status(&self) -> Result<VaultStatus> { + let Some(state) = &self.vault else { + return Ok(VaultStatus { + path: None, + pending: 0, + last_error: None, + last_flushed_at: None, + }); + }; + Ok(VaultStatus { + path: Some(state.root.to_string_lossy().into_owned()), + pending: self.pending_vault_writes()?, + last_error: state.last_error.clone(), + last_flushed_at: state.last_flushed_at.clone(), + }) + } + + /// One note in the shape its vault file carries. + pub fn vault_note(&self, id: &str) -> Result<VaultNote> { + let note = self.fetch_note(id)?; + let title_is_auto = self.title_is_auto(id)?; + let tags = self + .tags_for_note(id)? + .into_iter() + .map(|t| t.name) + .collect(); + let spaces = self + .workspaces_for_note(id)? + .into_iter() + .map(|w| w.name) + .collect(); + let canvas = (note.content_kind == CONTENT_KIND_WHITEBOARD) + .then(|| canvas_file(note.surface_data.as_deref())); + Ok(VaultNote { + id: note.id, + title: if title_is_auto { + None + } else { + Some(note.title) + }, + body: note.body, + created_at: note.created_at, + updated_at: note.updated_at, + is_pinned: note.is_pinned, + is_archived: note.is_archived, + deleted_at: note.deleted_at, + tags, + spaces, + kind: note.content_kind, + canvas, + }) + } + + /// What `instantnotes.yaml` holds: tag colors and every space, including + /// empty ones (design.md §3.4). + pub fn vault_manifest(&self) -> Result<Manifest> { + let mut manifest = Manifest::default(); + for t in self.list_tags()? { + manifest.tags.insert( + t.tag.name, + ManifestTag { + color: t.tag.color, + created: t.tag.created_at, + }, + ); + } + for w in self.list_workspaces()? { + manifest.spaces.push(ManifestSpace { + name: w.workspace.name, + created: w.workspace.created_at, + }); + } + Ok(manifest) + } + + /// Write up to `max` pending notes, process queued removals, and refresh + /// `instantnotes.yaml` when it changed. A failure on one note is + /// recorded in the outcome and the note stays pending; only a missing + /// root fails the whole flush. With no vault configured this is a no-op. + pub fn flush_vault(&mut self, max: usize) -> Result<FlushOutcome> { + let Some(root) = self.vault_root().map(Path::to_path_buf) else { + return Ok(FlushOutcome::default()); + }; + let result = self.flush_into(&root, max); + if let Some(state) = self.vault.as_mut() { + match &result { + Ok(out) if out.errors.is_empty() => { + state.last_error = None; + state.last_flushed_at = Some(now_iso()); + } + Ok(out) => { + state.last_error = Some(match out.errors.len() { + 1 => out.errors[0].clone(), + n => format!("{} (and {} more)", out.errors[0], n - 1), + }); + } + Err(e) => state.last_error = Some(e.to_string()), + } + } + result + } + + /// Compare the vault against the store without changing either. Notes + /// still pending are counted, not compared: their files are stale by + /// definition until the next flush. + pub fn verify_vault(&self) -> Result<VaultReport> { + let root = self + .vault_root() + .ok_or_else(|| AppError::Validation("no vault folder is set".into()))? + .to_path_buf(); + if !root.is_dir() { + return Err(root_missing(&root)); + } + let mut report = VaultReport { + pending: self.pending_vault_writes()?, + ..Default::default() + }; + + let rows: Vec<(String, String)> = { + let mut stmt = self.conn.prepare( + "SELECT id, vault_path FROM notes \ + WHERE vault_path IS NOT NULL AND vault_dirty = 0 ORDER BY vault_path", + )?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect::<rusqlite::Result<_>>()? + }; + for (id, rel) in rows { + report.checked += 1; + let expected = self.vault_note(&id)?; + match fs::read_to_string(root.join(&rel)) { + Err(_) => report.missing.push(rel.clone()), + Ok(text) => { + let as_parsed = VaultNote { + canvas: None, + ..expected.clone() + }; + if parse_note(&text).ok() != Some(as_parsed) { + report.diverged.push(rel.clone()); + } + } + } + if let Some(canvas) = &expected.canvas { + let crel = canvas_rel(&rel); + match fs::read_to_string(root.join(&crel)) { + Err(_) => report.missing.push(crel), + Ok(text) if !same_canvas(&text, canvas) => report.diverged.push(crel), + Ok(_) => {} + } + } + } + + for (prefix, dir) in [("", root.clone()), ("trash/", root.join(TRASH_DIR))] { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let is_vault_file = + (name.ends_with(".md") || name.ends_with(CANVAS_EXT)) && !name.starts_with('.'); + if !is_vault_file || !entry.file_type().is_ok_and(|t| t.is_file()) { + continue; + } + let rel = format!("{prefix}{name}"); + let claimed = match note_rel_of_canvas(&rel) { + Some(note_rel) => self.board_claims(¬e_rel)?, + None => self.vault_path_claimed(&rel, None)?, + }; + if !claimed { + report.orphans.push(rel); + } + } + } + report.orphans.sort(); + + let manifest = serialize_manifest(&self.vault_manifest()?); + report.manifest_ok = + fs::read_to_string(root.join(MANIFEST_FILE)).ok().as_deref() == Some(&manifest); + Ok(report) + } + + // ---- internals ---- + + fn saved_vault_root(&self) -> Result<Option<String>> { + Ok(self + .get_setting(VAULT_PATH_SETTING)? + .and_then(|v| v.as_str().map(str::to_string))) + } + + /// Drop every record of files in the current vault. Used when the root + /// changes or mirroring stops: those files are no longer ours to manage. + fn forget_vault_paths(&mut self) -> Result<()> { + let tx = self.conn.transaction()?; + tx.execute( + "UPDATE notes SET vault_path = NULL, file_sha = NULL, board_sha = NULL", + [], + )?; + tx.execute("DELETE FROM vault_tombstones", [])?; + tx.commit()?; + Ok(()) + } + + fn pending_vault_writes(&self) -> Result<i64> { + Ok(self.conn.query_row( + "SELECT COUNT(*) FROM notes WHERE vault_dirty = 1", + [], + |r| r.get(0), + )?) + } + + /// Whether a note other than `except_id` has its file at `rel`. + /// Case-insensitive, like the filesystems the vault usually lives on. + fn vault_path_claimed(&self, rel: &str, except_id: Option<&str>) -> Result<bool> { + Ok(self + .conn + .query_row( + "SELECT 1 FROM notes WHERE vault_path = ?1 COLLATE NOCASE \ + AND (?2 IS NULL OR id <> ?2) LIMIT 1", + params![rel, except_id], + |_| Ok(()), + ) + .optional()? + .is_some()) + } + + /// Whether a whiteboard has its note file at `note_rel`, which makes the + /// canvas file beside it that board's. + fn board_claims(&self, note_rel: &str) -> Result<bool> { + Ok(self + .conn + .query_row( + "SELECT 1 FROM notes WHERE vault_path = ?1 COLLATE NOCASE \ + AND content_kind = ?2 LIMIT 1", + params![note_rel, CONTENT_KIND_WHITEBOARD], + |_| Ok(()), + ) + .optional()? + .is_some()) + } + + fn flush_into(&mut self, root: &Path, max: usize) -> Result<FlushOutcome> { + if !root.is_dir() { + return Err(root_missing(root)); + } + let trash = root.join(TRASH_DIR); + fs::create_dir_all(&trash).map_err(|e| io_error("cannot create", &trash, e))?; + let mut out = FlushOutcome::default(); + + // Removals first, so a name freed by a deleted note is free for any + // note written below. + let tombstones: Vec<(String, Option<String>)> = { + let mut stmt = self + .conn + .prepare("SELECT vault_path, file_sha FROM vault_tombstones")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect::<rusqlite::Result<_>>()? + }; + for (rel, sha) in tombstones { + let path = root.join(&rel); + // A canvas is claimed through the note file it sits beside. + let claimed = match note_rel_of_canvas(&rel) { + Some(note_rel) => self.vault_path_claimed(¬e_rel, None)?, + None => self.vault_path_claimed(&rel, None)?, + }; + if !claimed && holds_our_bytes(&path, sha.as_deref()) { + if let Err(e) = fs::remove_file(&path) { + out.errors + .push(io_error("cannot remove", &path, e).to_string()); + continue; + } + out.removed += 1; + } + self.conn.execute( + "DELETE FROM vault_tombstones WHERE vault_path = ?1", + params![rel], + )?; + } + + let dirty: Vec<(String, WrittenFiles)> = { + let mut stmt = self.conn.prepare( + "SELECT id, vault_path, file_sha, board_sha FROM notes WHERE vault_dirty = 1 \ + ORDER BY seq LIMIT ?1", + )?; + let rows = stmt.query_map(params![max as i64], |r| { + Ok(( + r.get(0)?, + WrittenFiles { + rel: r.get(1)?, + sha: r.get(2)?, + board_sha: r.get(3)?, + }, + )) + })?; + rows.collect::<rusqlite::Result<_>>()? + }; + for (id, old) in dirty { + match self.write_note_file(root, &id, &old) { + Ok(()) => out.written += 1, + Err(e) => out.errors.push(format!("note {id}: {e}")), + } + } + + let manifest = serialize_manifest(&self.vault_manifest()?); + let manifest_path = root.join(MANIFEST_FILE); + if fs::read(&manifest_path).ok().as_deref() != Some(manifest.as_bytes()) { + if let Err(e) = atomic_write(&manifest_path, manifest.as_bytes()) { + out.errors + .push(io_error("cannot write", &manifest_path, e).to_string()); + } + } + + out.remaining = self.pending_vault_writes()?; + Ok(out) + } + + /// Write one note's file (and a whiteboard's canvas beside it), moving + /// them when the name or trash state changed, then record where they + /// went and mark the note clean. + fn write_note_file(&mut self, root: &Path, id: &str, old: &WrittenFiles) -> Result<()> { + let note = self.vault_note(id)?; + let bytes = serialize_note(¬e); + let canvas = note.canvas.as_deref(); + let title = note + .title + .clone() + .unwrap_or_else(|| domain::derive_title(¬e.body)); + let old_rel = old.rel.as_deref(); + let old_path = old_rel.map(|r| root.join(r)); + let old_canvas = old_rel.map(|r| root.join(canvas_rel(r))); + + let mut chosen = None; + for name in candidate_filenames(&title, id) { + let rel = if note.deleted_at.is_some() { + format!("{TRASH_DIR}/{name}") + } else { + name + }; + if old_rel == Some(rel.as_str()) { + chosen = Some(rel); + break; + } + if self.vault_path_claimed(&rel, Some(id))? { + continue; + } + let path = root.join(&rel); + let adopted = fs::read_to_string(&path) + .ok() + .and_then(|text| parse_note(&text).ok()) + .is_some_and(|existing| existing.id == id); + let ours = |path: &Path, old: Option<&Path>| { + fs::symlink_metadata(path).is_err() + || old.is_some_and(|old| same_file(old, path)) + || adopted + }; + // A canvas takes its note file's name, so a board needs both + // names free or already its own. A canvas beside a note file + // that is this note's own is its own too. + let canvas_ok = + canvas.is_none() || ours(&root.join(canvas_rel(&rel)), old_canvas.as_deref()); + if ours(&path, old_path.as_deref()) && canvas_ok { + chosen = Some(rel); + break; + } + } + let rel = + chosen.ok_or_else(|| AppError::Conflict(format!("no free filename for note {id}")))?; + let path = root.join(&rel); + let canvas_path = root.join(canvas_rel(&rel)); + let moved = old_rel.is_some_and(|r| r != rel); + + // A case-only rename of our own file: rename first, or on a + // case-insensitive filesystem the write below keeps the old spelling + // and the removal below would take the only copy. + if moved { + for (old, new) in [ + (old_path.as_deref(), &path), + (old_canvas.as_deref(), &canvas_path), + ] { + if let Some(old) = old.filter(|old| same_file(old, new)) { + fs::rename(old, new).map_err(|e| io_error("cannot rename", old, e))?; + } + } + } + atomic_write(&path, bytes.as_bytes()).map_err(|e| io_error("cannot write", &path, e))?; + if let Some(canvas) = canvas { + atomic_write(&canvas_path, canvas.as_bytes()) + .map_err(|e| io_error("cannot write", &canvas_path, e))?; + } + if moved { + // Left behind when edited outside the app: that edit is not ours + // to discard, and verify reports the file as an orphan. + for (old, new, sha) in [ + (old_path.as_deref(), &path, old.sha.as_deref()), + ( + old_canvas.as_deref(), + &canvas_path, + old.board_sha.as_deref(), + ), + ] { + if let Some(old) = + old.filter(|old| !same_file(old, new) && holds_our_bytes(old, sha)) + { + fs::remove_file(old).map_err(|e| io_error("cannot remove", old, e))?; + } + } + } + + self.conn.execute( + "UPDATE notes SET vault_path = ?1, file_sha = ?2, board_sha = ?3, vault_dirty = 0 \ + WHERE id = ?4", + params![ + rel, + sha256_hex(bytes.as_bytes()), + canvas.map(|c| sha256_hex(c.as_bytes())), + id + ], + )?; + Ok(()) + } +} diff --git a/src-tauri/core/src/store/workspaces.rs b/src-tauri/core/src/store/workspaces.rs index 9477f5c..f37c2cf 100644 --- a/src-tauri/core/src/store/workspaces.rs +++ b/src-tauri/core/src/store/workspaces.rs @@ -15,31 +15,7 @@ impl Store { } pub fn get_or_create_workspace(&mut self, raw_name: &str) -> Result<Workspace> { - let name = domain::normalize_workspace_name(raw_name) - .ok_or_else(|| AppError::Validation("workspace name must not be empty".into()))?; - if let Some(ws) = self - .conn - .query_row( - &format!("SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE name = ?1"), - params![name], - row_to_workspace, - ) - .optional()? - { - return Ok(ws); - } - let now = now_iso(); - let id = new_id(); - self.conn.execute( - "INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", - params![id, name, now], - )?; - Ok(Workspace { - id, - name, - created_at: now.clone(), - updated_at: now, - }) + workspace_get_or_create(&self.conn, raw_name) } pub fn list_workspaces(&self) -> Result<Vec<WorkspaceWithCount>> { diff --git a/src-tauri/core/src/types.rs b/src-tauri/core/src/types.rs index 6821359..11b2218 100644 --- a/src-tauri/core/src/types.rs +++ b/src-tauri/core/src/types.rs @@ -1,5 +1,10 @@ use serde::{Deserialize, Serialize}; +/// How a note is edited: a Markdown document, or a whiteboard canvas whose +/// text is kept in `body` for search and tags. Strings on the wire. +pub const CONTENT_KIND_DOCUMENT: &str = "document"; +pub const CONTENT_KIND_WHITEBOARD: &str = "whiteboard"; + /// Canonical note shape used by persistence and the desktop IPC layer. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -14,6 +19,11 @@ pub struct Note { pub is_archived: bool, pub is_deleted: bool, pub deleted_at: Option<String>, + /// `"document"` or `"whiteboard"`. + pub content_kind: String, + /// A whiteboard's canvas as JSON. Only `get_note` carries it: list rows + /// leave it out, since a board can hold pasted images. + pub surface_data: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -62,6 +72,26 @@ pub struct CreateNoteInput { pub tags: Vec<String>, } +/// One note to bring in from another app (`Store::import_notes`). +#[derive(Debug, Clone)] +pub struct ImportItem { + /// Its id in the app it came from. Each is imported once per library. + pub source_id: String, + pub body: String, + pub created_at: std::time::SystemTime, + pub updated_at: std::time::SystemTime, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ImportOutcome { + pub imported: usize, + /// Imported before, and their note still exists (the Trash counts). + pub skipped: usize, + /// The Space they were filed in, when one was named and any landed. + pub workspace_id: Option<String>, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct UpdateNotePatch { @@ -69,6 +99,15 @@ pub struct UpdateNotePatch { pub body: Option<String>, pub is_pinned: Option<bool>, pub is_archived: Option<bool>, + /// Only ever `"whiteboard"` in practice: converting is one-way. + pub content_kind: Option<String>, + /// A whiteboard's canvas; rejected on a document. + pub surface_data: Option<String>, + /// Optimistic concurrency: when set, the update applies only if the + /// note's `updated_at` still equals it, and fails with `Conflict` + /// otherwise. Lets a writer that read the note refuse to overwrite a + /// change it has not seen. + pub expected_updated_at: Option<String>, } #[derive(Debug, Clone, Default, Deserialize)] @@ -91,6 +130,22 @@ pub struct NoteFilter { pub offset: Option<i64>, } +/// Aggregate library counts for the Settings dashboard. Attachment counts are +/// added by the desktop layer (they live on the filesystem, not in the store). +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LibraryStats { + /// Notes not in the Trash (active plus archived). + pub notes_total: i64, + /// Notes not in the Trash and not archived. + pub notes_active: i64, + pub notes_pinned: i64, + pub notes_archived: i64, + pub notes_trashed: i64, + pub tags: i64, + pub spaces: i64, +} + /// Search result for library queries. Tag search goes through the note_tags /// join, not FTS. #[derive(Debug, Clone, Serialize, PartialEq)] @@ -102,3 +157,57 @@ pub struct SearchResult { pub score: f64, pub updated_at: String, } + +/// What an attachment cleanup removed (or, for a preview, would remove). +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentCleanup { + pub count: usize, + pub bytes: u64, +} + +/// The library as a graph (SEQUENCE.md unit 13): live notes, the tags and +/// Spaces they carry, and one link per note-to-tag or note-to-Space edge. +/// Derived from the existing tables on every read; nothing is stored. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryGraph { + pub notes: Vec<GraphNote>, + pub tags: Vec<GraphTag>, + pub spaces: Vec<GraphSpace>, + pub links: Vec<GraphLink>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphNote { + pub id: String, + pub title: String, + pub content_kind: String, + pub is_pinned: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphTag { + pub id: String, + pub name: String, + pub color: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphSpace { + pub id: String, + pub name: String, +} + +/// A note's membership: `kind` is `tag` or `space`, and `target_id` is that +/// tag's or Space's id. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphLink { + pub note_id: String, + pub target_id: String, + pub kind: String, +} diff --git a/src-tauri/core/src/vault/board.rs b/src-tauri/core/src/vault/board.rs new file mode 100644 index 0000000..e2a33bd --- /dev/null +++ b/src-tauri/core/src/vault/board.rs @@ -0,0 +1,106 @@ +//! A whiteboard's canvas in the vault: a standard `.excalidraw` file beside +//! the note's `.md`, sharing its name, so the board opens in Excalidraw or +//! any tool that reads its format. The note file holds the frontmatter and +//! the text on the board; this file holds the drawing. + +use serde_json::{json, Map, Value}; + +pub const CANVAS_EXT: &str = ".excalidraw"; + +/// The canvas file that sits beside the note file `note_rel` (`x.md`). +pub fn canvas_rel(note_rel: &str) -> String { + format!( + "{}{CANVAS_EXT}", + note_rel.strip_suffix(".md").unwrap_or(note_rel) + ) +} + +/// The note file a canvas file belongs to: the inverse of `canvas_rel`. +pub fn note_rel_of_canvas(canvas_rel: &str) -> Option<String> { + canvas_rel + .strip_suffix(CANVAS_EXT) + .map(|stem| format!("{stem}.md")) +} + +/// The `.excalidraw` file for a whiteboard's stored canvas (`surface_data`: +/// `{"v":1,"engine":"excalidraw","data":{elements,appState,files}}`). A +/// missing, unreadable, or other-engine canvas becomes an empty scene, the +/// same thing the app shows for it. +pub fn canvas_file(surface_data: Option<&str>) -> String { + let envelope: Value = surface_data + .and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or(Value::Null); + let data = if envelope["engine"] == "excalidraw" { + &envelope["data"] + } else { + &Value::Null + }; + let object = |v: &Value| match v { + Value::Object(_) => v.clone(), + _ => Value::Object(Map::new()), + }; + let file = json!({ + "type": "excalidraw", + "version": 2, + "source": "InstantNotes", + "elements": match &data["elements"] { + Value::Array(_) => data["elements"].clone(), + _ => json!([]), + }, + "appState": object(&data["appState"]), + "files": object(&data["files"]), + }); + let mut text = + serde_json::to_string_pretty(&file).expect("a JSON value always serializes to a string"); + text.push('\n'); + text +} + +/// Whether two canvas files hold the same drawing, ignoring formatting. +pub fn same_canvas(a: &str, b: &str) -> bool { + match ( + serde_json::from_str::<Value>(a), + serde_json::from_str::<Value>(b), + ) { + (Ok(x), Ok(y)) => x == y, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_canvas_sits_beside_its_note() { + assert_eq!(canvas_rel("Plan.md"), "Plan.excalidraw"); + assert_eq!(canvas_rel("trash/Plan.md"), "trash/Plan.excalidraw"); + assert_eq!( + note_rel_of_canvas("trash/Plan.excalidraw").as_deref(), + Some("trash/Plan.md") + ); + assert_eq!(note_rel_of_canvas("Plan.md"), None); + } + + #[test] + fn a_legacy_engine_canvas_is_an_empty_scene() { + let file = canvas_file(Some( + r#"{"v":1,"engine":"svelte-flow","data":{"nodes":[1]}}"#, + )); + let v: Value = serde_json::from_str(&file).unwrap(); + assert_eq!(v["elements"], json!([])); + assert_eq!(v["appState"], json!({})); + } + + #[test] + fn formatting_does_not_change_the_drawing() { + let file = canvas_file(Some( + r#"{"v":1,"engine":"excalidraw","data":{"elements":[{"id":"a"}],"appState":{},"files":{}}}"#, + )); + let compact = + serde_json::to_string(&serde_json::from_str::<Value>(&file).unwrap()).unwrap(); + assert!(same_canvas(&file, &compact)); + assert!(!same_canvas(&file, "{}")); + assert!(!same_canvas(&file, "not json")); + } +} diff --git a/src-tauri/core/src/vault/export.rs b/src-tauri/core/src/vault/export.rs new file mode 100644 index 0000000..dcc3c3e --- /dev/null +++ b/src-tauri/core/src/vault/export.rs @@ -0,0 +1,239 @@ +//! Stage 1 export: write the whole library to a chosen folder as a vault. +//! One-way, no behavior change: SQLite stays authoritative (design.md, +//! SEQUENCE.md unit 7). Attachment copying lives in the desktop shell layer, +//! which is the one that knows where `<app data>/attachments` is. + +use super::{ + atomic_write, canvas_rel, collision_key, note_filename, serialize_manifest, serialize_note, + Manifest, VaultNote, +}; +use crate::domain; +use crate::error::Result; +use crate::store::Store; +use crate::types::{Note, NoteFilter}; +use std::collections::HashSet; +use std::fs; +use std::io; +use std::path::Path; + +// list_notes pages at up to 5000 rows per call (store/notes.rs); page +// through every state rather than trusting one call to return everything, +// or a library past that size silently loses notes off the end. +const LIST_PAGE: i64 = 5000; + +fn list_all(store: &Store, filter: NoteFilter) -> Result<Vec<Note>> { + let mut out = Vec::new(); + let mut offset = 0i64; + loop { + let page = store.list_notes(NoteFilter { + limit: Some(LIST_PAGE), + offset: Some(offset), + ..filter.clone() + })?; + let got = page.len(); + out.extend(page); + if (got as i64) < LIST_PAGE { + break; + } + offset += LIST_PAGE; + } + Ok(out) +} + +/// Gather every note (active, archived, and trashed) as `VaultNote`s, plus +/// the tag/space manifest, from a live store. SEQUENCE.md unit 7's done +/// condition: exporting this and re-parsing it must reproduce every field +/// of every note. +pub fn collect_from_store(store: &Store) -> Result<(Vec<VaultNote>, Manifest)> { + let mut notes: Vec<Note> = Vec::new(); + for filter in [ + NoteFilter { + is_deleted: Some(false), + is_archived: Some(false), + ..Default::default() + }, + NoteFilter { + is_deleted: Some(false), + is_archived: Some(true), + ..Default::default() + }, + NoteFilter { + is_deleted: Some(true), + ..Default::default() + }, + ] { + notes.extend(list_all(store, filter)?); + } + + let vault_notes = notes + .iter() + .map(|note| store.vault_note(¬e.id)) + .collect::<Result<Vec<_>>>()?; + let manifest = store.vault_manifest()?; + Ok((vault_notes, manifest)) +} + +/// Write every note plus `instantnotes.yaml` into `dest`, which is created +/// if missing. Deleted notes land under `dest/trash/`; everything else at +/// the vault root. Filenames are assigned in `notes` order, so collisions +/// resolve deterministically for a given input order. +pub fn export_vault(notes: &[VaultNote], manifest: &Manifest, dest: &Path) -> io::Result<()> { + let trash_dir = dest.join("trash"); + fs::create_dir_all(&trash_dir)?; + + let mut taken_root: HashSet<String> = HashSet::new(); + let mut taken_trash: HashSet<String> = HashSet::new(); + + for note in notes { + let display_title = note + .title + .clone() + .unwrap_or_else(|| domain::derive_title(¬e.body)); + let is_trashed = note.deleted_at.is_some(); + let taken = if is_trashed { + &mut taken_trash + } else { + &mut taken_root + }; + let filename = note_filename(&display_title, ¬e.id, taken); + taken.insert(collision_key(&filename)); + + let dir = if is_trashed { &trash_dir } else { dest }; + atomic_write(&dir.join(&filename), serialize_note(note).as_bytes())?; + if let Some(canvas) = ¬e.canvas { + atomic_write(&dir.join(canvas_rel(&filename)), canvas.as_bytes())?; + } + } + + atomic_write( + &dest.join("instantnotes.yaml"), + serialize_manifest(manifest).as_bytes(), + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::{ManifestSpace, ManifestTag}; + + fn note(id: &str, title: Option<&str>, body: &str, deleted: bool) -> VaultNote { + VaultNote { + id: id.to_string(), + title: title.map(str::to_string), + body: body.to_string(), + created_at: "2026-01-01T00:00:00.000000Z".to_string(), + updated_at: "2026-01-01T00:00:00.000000Z".to_string(), + is_pinned: false, + is_archived: false, + deleted_at: deleted.then(|| "2026-01-02T00:00:00.000000Z".to_string()), + tags: Vec::new(), + spaces: Vec::new(), + kind: crate::types::CONTENT_KIND_DOCUMENT.to_string(), + canvas: None, + } + } + + #[test] + fn writes_a_note_file_named_after_its_title() { + let dir = tempfile::tempdir().unwrap(); + let notes = vec![note("id1", Some("My Note"), "body", false)]; + export_vault(¬es, &Manifest::default(), dir.path()).unwrap(); + let content = fs::read_to_string(dir.path().join("My Note.md")).unwrap(); + assert!(content.contains("id: id1")); + assert!(content.ends_with("body")); + } + + #[test] + fn falls_back_to_the_derived_title_when_none_is_set() { + let dir = tempfile::tempdir().unwrap(); + let notes = vec![note("id1", None, "Buy milk\nand eggs", false)]; + export_vault(¬es, &Manifest::default(), dir.path()).unwrap(); + assert!(dir.path().join("Buy milk.md").exists()); + } + + #[test] + fn deleted_notes_land_in_trash() { + let dir = tempfile::tempdir().unwrap(); + let notes = vec![note("id1", Some("Old Draft"), "body", true)]; + export_vault(¬es, &Manifest::default(), dir.path()).unwrap(); + assert!(!dir.path().join("Old Draft.md").exists()); + assert!(dir.path().join("trash").join("Old Draft.md").exists()); + } + + #[test] + fn two_notes_with_the_same_title_both_survive_with_distinct_names() { + let dir = tempfile::tempdir().unwrap(); + let notes = vec![ + note("aaaaaa-1", Some("Standup"), "one", false), + note("bbbbbb-2", Some("Standup"), "two", false), + ]; + export_vault(¬es, &Manifest::default(), dir.path()).unwrap(); + assert!(dir.path().join("Standup.md").exists()); + assert!(dir.path().join("Standup-bbbbbb.md").exists()); + } + + #[test] + fn titles_differing_only_in_case_both_survive() { + let dir = tempfile::tempdir().unwrap(); + let notes = vec![ + note("aaaaaa-1", Some("Notes"), "upper", false), + note("bbbbbb-2", Some("notes"), "lower", false), + ]; + export_vault(¬es, &Manifest::default(), dir.path()).unwrap(); + let mut bodies: Vec<String> = fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|x| x == "md")) + .map(|p| fs::read_to_string(p).unwrap()) + .collect(); + bodies.sort(); + assert_eq!(bodies.len(), 2, "one note overwrote the other"); + // Sorted by full text, so by frontmatter id: aaaaaa-1 first. + assert!(bodies[0].ends_with("upper") && bodies[1].ends_with("lower")); + } + + #[test] + fn root_and_trash_collisions_are_tracked_independently() { + let dir = tempfile::tempdir().unwrap(); + let notes = vec![ + note("aaaaaa-1", Some("Notes"), "active", false), + note("bbbbbb-2", Some("Notes"), "trashed", true), + ]; + export_vault(¬es, &Manifest::default(), dir.path()).unwrap(); + // Same plain name in each location is fine; they don't collide with + // each other, only within their own directory. + assert!(dir.path().join("Notes.md").exists()); + assert!(dir.path().join("trash").join("Notes.md").exists()); + } + + #[test] + fn writes_the_manifest_alongside_the_notes() { + let dir = tempfile::tempdir().unwrap(); + let mut m = Manifest::default(); + m.tags.insert( + "idea".to_string(), + ManifestTag { + color: Some("#fff".to_string()), + created: "2026-01-01T00:00:00.000000Z".to_string(), + }, + ); + m.spaces.push(ManifestSpace { + name: "Engineering".to_string(), + created: "2026-01-01T00:00:00.000000Z".to_string(), + }); + export_vault(&[], &m, dir.path()).unwrap(); + let text = fs::read_to_string(dir.path().join("instantnotes.yaml")).unwrap(); + assert!(text.contains("idea")); + assert!(text.contains("Engineering")); + } + + #[test] + fn creates_the_destination_directory_when_missing() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("fresh-vault"); + export_vault(&[], &Manifest::default(), &dest).unwrap(); + assert!(dest.join("instantnotes.yaml").exists()); + } +} diff --git a/src-tauri/core/src/vault/manifest.rs b/src-tauri/core/src/vault/manifest.rs new file mode 100644 index 0000000..1c0a095 --- /dev/null +++ b/src-tauri/core/src/vault/manifest.rs @@ -0,0 +1,107 @@ +//! `instantnotes.yaml`: what has no note to live on (design.md §3.4): tag +//! colors and the list of spaces, including empty ones, which no note +//! frontmatter would otherwise mention. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ManifestTag { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub color: Option<String>, + pub created: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ManifestSpace { + pub name: String, + pub created: String, +} + +/// Tags keyed by name in a `BTreeMap` (not the `tags` table's opaque id, per +/// design.md §3.4: "identity is the name") so the file sorts deterministically +/// and diffs cleanly under git. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct Manifest { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tags: BTreeMap<String, ManifestTag>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub spaces: Vec<ManifestSpace>, +} + +pub fn serialize_manifest(m: &Manifest) -> String { + serde_norway::to_string(m).expect("Manifest has no maps with non-string keys") +} + +pub fn parse_manifest(text: &str) -> Result<Manifest, serde_norway::Error> { + serde_norway::from_str(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_manifest_round_trips() { + let m = Manifest::default(); + let text = serialize_manifest(&m); + assert_eq!(parse_manifest(&text).unwrap(), m); + } + + #[test] + fn round_trips_tags_and_spaces() { + let mut m = Manifest::default(); + m.tags.insert( + "consensus".to_string(), + ManifestTag { + color: Some("#7aa2f7".to_string()), + created: "2026-07-02T09:14:00.000000Z".to_string(), + }, + ); + m.tags.insert( + "no-color".to_string(), + ManifestTag { + color: None, + created: "2026-07-02T09:14:00.000000Z".to_string(), + }, + ); + m.spaces.push(ManifestSpace { + name: "Engineering".to_string(), + created: "2026-07-02T09:10:00.000000Z".to_string(), + }); + let text = serialize_manifest(&m); + assert_eq!(parse_manifest(&text).unwrap(), m); + } + + #[test] + fn tags_serialize_sorted_by_name_for_stable_diffs() { + let mut m = Manifest::default(); + for name in ["zebra", "alpha", "mid"] { + m.tags.insert( + name.to_string(), + ManifestTag { + color: None, + created: "2026-01-01T00:00:00.000000Z".to_string(), + }, + ); + } + let text = serialize_manifest(&m); + let alpha = text.find("alpha").unwrap(); + let mid = text.find("mid").unwrap(); + let zebra = text.find("zebra").unwrap(); + assert!(alpha < mid && mid < zebra, "tags not sorted:\n{text}"); + } + + #[test] + fn an_empty_space_survives_the_round_trip() { + // The whole reason spaces are listed explicitly: an empty space has + // no note referencing it and would otherwise vanish on rebuild. + let mut m = Manifest::default(); + m.spaces.push(ManifestSpace { + name: "Someday".to_string(), + created: "2026-01-01T00:00:00.000000Z".to_string(), + }); + let text = serialize_manifest(&m); + assert_eq!(parse_manifest(&text).unwrap().spaces.len(), 1); + } +} diff --git a/src-tauri/core/src/vault/mirror.rs b/src-tauri/core/src/vault/mirror.rs new file mode 100644 index 0000000..a2150ae --- /dev/null +++ b/src-tauri/core/src/vault/mirror.rs @@ -0,0 +1,127 @@ +//! Stage 2 mirror types. The flush itself lives on `Store` +//! (`store/vault.rs`), which owns the connection. + +use serde::Serialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Default)] +pub struct FlushOutcome { + pub written: usize, + pub removed: usize, + pub remaining: i64, + pub errors: Vec<String>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultStatus { + pub path: Option<String>, + pub pending: i64, + pub last_error: Option<String>, + pub last_flushed_at: Option<String>, +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultReport { + pub checked: usize, + pub missing: Vec<String>, + pub diverged: Vec<String>, + pub orphans: Vec<String>, + pub pending: i64, + pub manifest_ok: bool, +} + +/// Whether `path` is `dir` itself or somewhere inside it. Both are resolved +/// first (symlinks, `..`, and on macOS `/var` vs `/private/var`), so two +/// spellings of one folder compare equal. A path that does not exist yet is +/// resolved through its nearest existing ancestor. +pub fn is_within(path: &Path, dir: &Path) -> bool { + // Path::starts_with compares whole components, so `notes2` is not + // inside `notes`. + resolve(path).starts_with(resolve(dir)) +} + +fn resolve(path: &Path) -> PathBuf { + let mut rest = Vec::new(); + let mut current = path; + loop { + if let Ok(real) = current.canonicalize() { + return rest.iter().rev().fold(real, |acc, part| acc.join(part)); + } + match (current.parent(), current.file_name()) { + (Some(parent), Some(name)) => { + rest.push(name.to_os_string()); + current = parent; + } + _ => return path.to_path_buf(), + } + } +} + +/// A vault folder must not overlap the app data directory in either +/// direction: inside it, the mirror would write next to (or over) the +/// database and its attachments; around it, the live SQLite file would sit +/// inside a synced folder, which design.md D4 exists to prevent. +pub fn check_vault_location(root: &Path, app_data: &Path) -> Result<(), String> { + if is_within(root, app_data) { + return Err("choose a folder outside the app's own data folder".into()); + } + if is_within(app_data, root) { + return Err("choose a folder that does not contain the app's own data folder".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn is_within_matches_the_folder_itself_and_its_descendants() { + let dir = tempfile::tempdir().unwrap(); + let inner = dir.path().join("a").join("b"); + fs::create_dir_all(&inner).unwrap(); + assert!(is_within(dir.path(), dir.path())); + assert!(is_within(&inner, dir.path())); + assert!(!is_within(dir.path(), &inner)); + } + + #[test] + fn is_within_sees_through_dot_dot_and_symlinks() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("real"); + fs::create_dir_all(real.join("sub")).unwrap(); + assert!(is_within(&real.join("sub").join(".."), &real)); + #[cfg(unix)] + { + let link = dir.path().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + assert!(is_within(&link.join("sub"), &real)); + } + } + + #[test] + fn is_within_does_not_confuse_a_shared_name_prefix() { + let dir = tempfile::tempdir().unwrap(); + let notes = dir.path().join("notes"); + let notes2 = dir.path().join("notes2"); + fs::create_dir_all(¬es).unwrap(); + fs::create_dir_all(¬es2).unwrap(); + assert!(!is_within(¬es2, ¬es)); + } + + #[test] + fn a_vault_may_not_overlap_the_app_data_directory() { + let dir = tempfile::tempdir().unwrap(); + let data = dir.path().join("Library").join("InstantNotes"); + let elsewhere = dir.path().join("Documents").join("Vault"); + fs::create_dir_all(&data).unwrap(); + fs::create_dir_all(&elsewhere).unwrap(); + assert!(check_vault_location(&elsewhere, &data).is_ok()); + assert!(check_vault_location(&data, &data).is_err()); + assert!(check_vault_location(&data.join("attachments"), &data).is_err()); + assert!(check_vault_location(dir.path(), &data).is_err()); + } +} diff --git a/src-tauri/core/src/vault/mod.rs b/src-tauri/core/src/vault/mod.rs new file mode 100644 index 0000000..794f4d8 --- /dev/null +++ b/src-tauri/core/src/vault/mod.rs @@ -0,0 +1,221 @@ +//! The vault format: a note as Markdown + YAML frontmatter, and back. +//! This file holds the pure format; `write` and `export` do the filesystem +//! I/O, and the stage 2 flush lives on `Store` (`store/vault.rs`). Design: openspec/changes/feat-portable-vault-sync/design.md §3.2. + +pub mod board; +pub mod export; +pub mod manifest; +pub mod mirror; +pub mod naming; +pub mod parse; +pub mod serialize; +pub mod write; + +pub use board::{canvas_file, canvas_rel, note_rel_of_canvas, same_canvas, CANVAS_EXT}; +pub use export::{collect_from_store, export_vault}; +pub use manifest::{parse_manifest, serialize_manifest, Manifest, ManifestSpace, ManifestTag}; +pub use mirror::{check_vault_location, is_within, FlushOutcome, VaultReport, VaultStatus}; +pub use naming::{candidate_filenames, collision_key, note_filename}; +pub use parse::{parse_note, ParseError}; +pub use serialize::serialize_note; +pub use write::{atomic_write, copy_dir_recursive, copy_missing_files}; + +use serde::{Deserialize, Serialize}; + +/// The YAML frontmatter shape, shared by the serializer and the parser so +/// the two can never drift out of step with each other. +#[derive(Serialize, Deserialize)] +struct Frontmatter { + id: String, + created: String, + updated: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + title: Option<String>, + /// `whiteboard` for a board; omitted for a document, the default. + #[serde(skip_serializing_if = "Option::is_none", default)] + kind: Option<String>, + #[serde(skip_serializing_if = "is_false", default)] + pinned: bool, + #[serde(skip_serializing_if = "is_false", default)] + archived: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + deleted: Option<String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + tags: Vec<String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + spaces: Vec<String>, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +/// A note in the shape the vault format carries. Deliberately narrower than +/// `types::Note`: `last_opened_at` is device-local and never written to the +/// vault (design.md §7.2), and `is_deleted` is implied by `deleted_at` (the +/// store keeps them in lockstep) plus, from stage 2 on, by the note's +/// location under `trash/` rather than by a frontmatter field. +/// +/// `title: None` means the title is auto-derived from the body (design.md +/// §3.2). This replaces a separate `title_is_auto` bool so the type can't +/// represent the contradictory state of a bool and a string disagreeing. +#[derive(Debug, Clone, PartialEq)] +pub struct VaultNote { + pub id: String, + pub title: Option<String>, + pub body: String, + pub created_at: String, + pub updated_at: String, + pub is_pinned: bool, + pub is_archived: bool, + pub deleted_at: Option<String>, + pub tags: Vec<String>, + pub spaces: Vec<String>, + /// `document` or `whiteboard` (`types::CONTENT_KIND_*`). + pub kind: String, + /// A whiteboard's `.excalidraw` file, written beside the note file and + /// never inside it, so parsing a note file always yields `None`. + pub canvas: Option<String>, +} + +#[cfg(test)] +mod round_trip_tests { + use super::*; + use crate::types::{CONTENT_KIND_DOCUMENT, CONTENT_KIND_WHITEBOARD}; + + fn base(id: &str) -> VaultNote { + VaultNote { + id: id.to_string(), + title: None, + body: "hello world".to_string(), + created_at: "2026-09-03T19:00:00.000000Z".to_string(), + updated_at: "2026-09-03T19:15:00.000000Z".to_string(), + is_pinned: false, + is_archived: false, + deleted_at: None, + tags: Vec::new(), + spaces: Vec::new(), + kind: CONTENT_KIND_DOCUMENT.to_string(), + canvas: None, + } + } + + /// Every combination of the boolean/optional fields, plus edge-case + /// strings, round-trips exactly through serialize -> parse. + #[test] + fn round_trips_every_flag_combination() { + for pinned in [false, true] { + for archived in [false, true] { + for title in [None, Some("Consensus Protocols".to_string())] { + for deleted_at in [None, Some("2026-09-04T00:00:00.000000Z".to_string())] { + for tags in [ + Vec::<String>::new(), + vec!["distributed-systems".to_string(), "consensus".to_string()], + ] { + for spaces in [Vec::<String>::new(), vec!["Engineering".to_string()]] { + let mut n = base("018f6c3a-8b29-7c10-9824-3a2e1d0f5b6a"); + n.is_pinned = pinned; + n.is_archived = archived; + n.title = title.clone(); + n.deleted_at = deleted_at.clone(); + n.tags = tags.clone(); + n.spaces = spaces.clone(); + + let text = serialize_note(&n); + let parsed = parse_note(&text).unwrap_or_else(|e| { + panic!("failed to parse own output: {e}\n---\n{text}") + }); + assert_eq!(parsed, n, "round trip mismatch for:\n{text}"); + } + } + } + } + } + } + } + + #[test] + fn a_whiteboard_round_trips_its_kind() { + let mut n = base("id-wb"); + n.kind = CONTENT_KIND_WHITEBOARD.to_string(); + let text = serialize_note(&n); + assert!(text.contains("\nkind: whiteboard\n"), "{text}"); + assert_eq!(parse_note(&text).unwrap(), n); + } + + #[test] + fn round_trips_multiline_body_containing_a_literal_thematic_break() { + let mut n = base("id-1"); + n.body = "# Title\n\nabove\n\n---\n\nbelow the horizontal rule".to_string(); + let text = serialize_note(&n); + let parsed = parse_note(&text).unwrap(); + assert_eq!(parsed, n); + } + + #[test] + fn round_trips_yaml_hostile_characters_in_title_and_tags() { + let mut n = base("id-2"); + n.title = Some("Meeting: 3pm - \"quoted\", #hash, [brackets]".to_string()); + n.tags = vec!["c++".to_string(), "a:b".to_string()]; + n.spaces = vec!["R&D".to_string()]; + let text = serialize_note(&n); + let parsed = parse_note(&text).unwrap(); + assert_eq!(parsed, n); + } + + #[test] + fn round_trips_empty_body() { + let mut n = base("id-3"); + n.body = String::new(); + let text = serialize_note(&n); + let parsed = parse_note(&text).unwrap(); + assert_eq!(parsed, n); + } + + #[test] + fn round_trips_unicode_title_and_body() { + let mut n = base("id-4"); + n.title = Some("Café ☕ notes — 日本語".to_string()); + n.body = "Body with emoji 🎉 and accents: café, naïve".to_string(); + let text = serialize_note(&n); + let parsed = parse_note(&text).unwrap(); + assert_eq!(parsed, n); + } + + #[test] + fn a_plain_note_serializes_with_only_id_created_updated() { + let n = base("018f6c3a-8b29-7c10-9824-3a2e1d0f5b6a"); + let text = serialize_note(&n); + assert_eq!( + text, + "---\nid: 018f6c3a-8b29-7c10-9824-3a2e1d0f5b6a\ncreated: 2026-09-03T19:00:00.000000Z\nupdated: 2026-09-03T19:15:00.000000Z\n---\nhello world" + ); + } + + #[test] + fn missing_frontmatter_delimiter_is_a_clear_error() { + let err = parse_note("just a plain markdown file\nwith no frontmatter").unwrap_err(); + assert!(matches!(err, ParseError::MissingFrontmatter)); + } + + #[test] + fn unterminated_frontmatter_is_a_clear_error() { + let err = parse_note("---\nid: x\ncreated: y\nupdated: z\nbody with no closing delimiter") + .unwrap_err(); + assert!(matches!(err, ParseError::UnterminatedFrontmatter)); + } + + #[test] + fn unknown_frontmatter_keys_are_ignored() { + let text = "---\nid: id-5\ncreated: c\nupdated: u\nfuture_field: something\n---\nbody"; + let parsed = parse_note(text).unwrap(); + assert_eq!(parsed.id, "id-5"); + } + + #[test] + fn missing_required_key_is_a_clear_error() { + let text = "---\ncreated: c\nupdated: u\n---\nbody"; + let err = parse_note(text).unwrap_err(); + assert!(matches!(err, ParseError::Yaml(_))); + } +} diff --git a/src-tauri/core/src/vault/naming.rs b/src-tauri/core/src/vault/naming.rs new file mode 100644 index 0000000..49ef638 --- /dev/null +++ b/src-tauri/core/src/vault/naming.rs @@ -0,0 +1,157 @@ +//! Vault filenames (design.md §3.1). The filename is a label, not identity: +//! identity is the frontmatter `id`, so this only has to be readable and +//! collision-free, not stable across renames. + +use std::collections::HashSet; + +const INVALID: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|']; + +/// Bytes of title kept in a filename. Names are capped at 255 bytes; the +/// longest candidate adds `-<36-char id>.md` (40 bytes) and the atomic +/// write's temp name adds `.` and `.tmp-<pid>-<seq>` (up to 37), which leaves +/// 178. The rest is margin. +const MAX_BASE_BYTES: usize = 150; + +/// Strip characters that are illegal in a filename on any of Windows/macOS/ +/// Linux, cut it to `MAX_BASE_BYTES` on a character boundary, collapse the +/// result to something non-empty, and trim trailing dots/spaces (Windows +/// rejects trailing dots). +fn sanitize(title: &str) -> String { + let cleaned: String = title + .chars() + .map(|c| { + if INVALID.contains(&c) || c.is_control() { + ' ' + } else { + c + } + }) + .collect(); + let mut end = cleaned.len().min(MAX_BASE_BYTES); + while !cleaned.is_char_boundary(end) { + end -= 1; + } + let trimmed = cleaned[..end].trim().trim_end_matches(['.', ' ']).trim(); + if trimmed.is_empty() { + "Untitled".to_string() + } else { + trimmed.to_string() + } +} + +/// The key a filename occupies in a `taken` set. Case-folded because the +/// default filesystems on macOS (APFS) and Windows are case-insensitive: +/// "Notes.md" and "notes.md" are one file there, so they must collide here. +pub fn collision_key(name: &str) -> String { + name.to_lowercase() +} + +/// A note's filenames in order of preference: the title, then the title +/// with the first six characters of the id, then with the whole id, which is +/// unique by construction. +pub fn candidate_filenames(title: &str, id: &str) -> [String; 3] { + let base = sanitize(title); + let short: String = id.chars().take(6).collect(); + [ + format!("{base}.md"), + format!("{base}-{short}.md"), + format!("{base}-{id}.md"), + ] +} + +/// The `.md` filename for a note, unique against `taken` (the +/// `collision_key`s of names already assigned earlier in the same export +/// pass): the first of `candidate_filenames` not already taken. +pub fn note_filename(title: &str, id: &str, taken: &HashSet<String>) -> String { + let [plain, short, full] = candidate_filenames(title, id); + [plain, short] + .into_iter() + .find(|name| !taken.contains(&collision_key(name))) + .unwrap_or(full) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_spaces_and_case_as_written() { + let taken = HashSet::new(); + assert_eq!( + note_filename("Distributed Systems Notes", "id1", &taken), + "Distributed Systems Notes.md" + ); + } + + #[test] + fn strips_path_separators_and_other_illegal_characters() { + let taken = HashSet::new(); + assert_eq!( + note_filename("Q1/Q2: Plan?", "id1", &taken), + "Q1 Q2 Plan.md" + ); + } + + #[test] + fn empty_or_illegal_only_title_falls_back_to_untitled() { + let taken = HashSet::new(); + assert_eq!(note_filename("", "id1", &taken), "Untitled.md"); + assert_eq!(note_filename("///", "id1", &taken), "Untitled.md"); + } + + #[test] + fn trims_trailing_dots_and_spaces() { + let taken = HashSet::new(); + assert_eq!(note_filename("Notes. ", "id1", &taken), "Notes.md"); + } + + /// Filenames are capped at 255 bytes on every supported filesystem, and + /// the atomic write's temp name adds a prefix and suffix on top. An + /// explicit title has no length limit, so the name must be budgeted or a + /// long title fails every write of its note. + #[test] + fn a_very_long_title_still_fits_a_filename_with_its_temp_suffix() { + let title = "é".repeat(300); + let id = "018f6c3a-8b29-7c10-9824-3a2e1d0f5b6a"; + for name in candidate_filenames(&title, id) { + let temp_name = format!(".{name}.tmp-4294967295-18446744073709551615"); + assert!(temp_name.len() <= 255, "{} bytes: {name}", temp_name.len()); + assert!(name.starts_with("éé")); + } + } + + #[test] + fn appends_id_prefix_on_collision() { + let mut taken = HashSet::new(); + taken.insert(collision_key("Meeting Notes.md")); + assert_eq!( + note_filename("Meeting Notes", "018f6c3a-8b29", &taken), + "Meeting Notes-018f6c.md" + ); + } + + /// macOS (APFS) and Windows filesystems are case-insensitive by default, + /// so "Notes.md" and "notes.md" are the same file there: the second + /// write would silently replace the first. + #[test] + fn a_case_variant_title_counts_as_a_collision() { + let mut taken = HashSet::new(); + taken.insert(collision_key("Notes.md")); + assert_eq!( + note_filename("notes", "abc123-rest", &taken), + "notes-abc123.md" + ); + } + + #[test] + fn a_taken_suffixed_name_falls_back_to_the_full_id() { + let mut taken = HashSet::new(); + taken.insert(collision_key("Notes.md")); + // A note literally titled "Notes-abc123" already holds this name. + taken.insert(collision_key("Notes-abc123.md")); + assert_eq!( + note_filename("Notes", "abc123-rest-of-uuid", &taken), + "Notes-abc123-rest-of-uuid.md" + ); + } +} diff --git a/src-tauri/core/src/vault/parse.rs b/src-tauri/core/src/vault/parse.rs new file mode 100644 index 0000000..10e6c7d --- /dev/null +++ b/src-tauri/core/src/vault/parse.rs @@ -0,0 +1,72 @@ +//! Markdown + YAML frontmatter -> `VaultNote`. Tolerant of missing optional +//! keys (they default, per `Frontmatter`'s `#[serde(default)]`) and of +//! unrecognized keys (serde ignores them unless `deny_unknown_fields` is +//! set, which this type deliberately does not set). + +use super::{Frontmatter, VaultNote}; +use crate::types::CONTENT_KIND_DOCUMENT; +use std::fmt; + +#[derive(Debug)] +pub enum ParseError { + /// The file does not open with a `---\n` frontmatter delimiter. + MissingFrontmatter, + /// A `---\n` opened but no closing `---` was found. + UnterminatedFrontmatter, + /// The frontmatter block is not valid YAML, or is missing a required + /// key (`id`, `created`, or `updated`). + Yaml(serde_norway::Error), +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParseError::MissingFrontmatter => { + write!(f, "file does not start with a `---` frontmatter block") + } + ParseError::UnterminatedFrontmatter => { + write!(f, "frontmatter block has no closing `---`") + } + ParseError::Yaml(e) => write!(f, "invalid frontmatter: {e}"), + } + } +} + +impl std::error::Error for ParseError {} + +pub fn parse_note(text: &str) -> Result<VaultNote, ParseError> { + let after_open = text + .strip_prefix("---\n") + .ok_or(ParseError::MissingFrontmatter)?; + + // The first `\n---\n` after the opening delimiter closes the + // frontmatter block. A body can safely contain its own `---` lines + // (e.g. a Markdown thematic break) further down; only the first match + // is ever consulted, so those never get misread as the closing marker. + let (yaml, body) = if let Some(idx) = after_open.find("\n---\n") { + (&after_open[..idx], &after_open[idx + 5..]) + } else if let Some(yaml) = after_open.strip_suffix("\n---") { + (yaml, "") + } else { + return Err(ParseError::UnterminatedFrontmatter); + }; + + let frontmatter: Frontmatter = serde_norway::from_str(yaml).map_err(ParseError::Yaml)?; + + Ok(VaultNote { + id: frontmatter.id, + title: frontmatter.title, + body: body.to_string(), + created_at: frontmatter.created, + updated_at: frontmatter.updated, + is_pinned: frontmatter.pinned, + is_archived: frontmatter.archived, + deleted_at: frontmatter.deleted, + tags: frontmatter.tags, + spaces: frontmatter.spaces, + kind: frontmatter + .kind + .unwrap_or_else(|| CONTENT_KIND_DOCUMENT.to_string()), + canvas: None, + }) +} diff --git a/src-tauri/core/src/vault/serialize.rs b/src-tauri/core/src/vault/serialize.rs new file mode 100644 index 0000000..6f76dd6 --- /dev/null +++ b/src-tauri/core/src/vault/serialize.rs @@ -0,0 +1,24 @@ +//! `VaultNote` -> Markdown + YAML frontmatter. Every field is omitted when +//! it equals its default, per design.md §3.2: frontmatter noise defeats the +//! point of the format being human-readable. + +use super::{Frontmatter, VaultNote}; +use crate::types::CONTENT_KIND_DOCUMENT; + +pub fn serialize_note(note: &VaultNote) -> String { + let frontmatter = Frontmatter { + id: note.id.clone(), + created: note.created_at.clone(), + updated: note.updated_at.clone(), + title: note.title.clone(), + kind: (note.kind != CONTENT_KIND_DOCUMENT).then(|| note.kind.clone()), + pinned: note.is_pinned, + archived: note.is_archived, + deleted: note.deleted_at.clone(), + tags: note.tags.clone(), + spaces: note.spaces.clone(), + }; + let yaml = serde_norway::to_string(&frontmatter) + .expect("Frontmatter has no maps with non-string keys and cannot fail to serialize"); + format!("---\n{yaml}---\n{}", note.body) +} diff --git a/src-tauri/core/src/vault/write.rs b/src-tauri/core/src/vault/write.rs new file mode 100644 index 0000000..a4a8b35 --- /dev/null +++ b/src-tauri/core/src/vault/write.rs @@ -0,0 +1,166 @@ +//! Filesystem writes: one note file, and the vault-level `instantnotes.yaml`. +//! Both go through `atomic_write` (design.md §6): write to a temp file in +//! the same directory, fsync, then rename, so a crash mid-write never +//! leaves a half-written note on disk, and a concurrent reader (the future +//! `notify` watcher, or an external editor) never observes a partial file. + +use std::fs::{self, File}; +use std::io; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Distinguishes temp files within one process, so two writers to the same +/// target (an export into a folder the mirror is flushing) never share one. +static TMP_SEQ: AtomicU64 = AtomicU64::new(0); + +/// Write `bytes` to `path` atomically. `path`'s parent directory must +/// already exist. +pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> { + let dir = path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory") + })?; + let tmp_path = dir.join(format!( + ".{}.tmp-{}-{}", + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("vault-write"), + std::process::id(), + TMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + + let mut tmp = File::create(&tmp_path)?; + io::Write::write_all(&mut tmp, bytes)?; + tmp.sync_all()?; + drop(tmp); + + fs::rename(&tmp_path, path)?; + Ok(()) +} + +/// Copy every file under `src` into `dst`, creating `dst` and any +/// subdirectories as needed. Used to bring `<app data>/attachments` into the +/// vault on export (design.md's vault layout, §3): a plain recursive copy, +/// not a move, since stage 1 leaves the app data directory authoritative. +pub fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let dst_path = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&entry.path(), &dst_path)?; + } else if file_type.is_file() { + fs::copy(entry.path(), dst_path)?; + } + } + Ok(()) +} + +/// Copy each file directly under `src` that `dst` does not already have, +/// creating `dst` if needed. Returns how many were copied. +pub fn copy_missing_files(src: &Path, dst: &Path) -> io::Result<usize> { + fs::create_dir_all(dst)?; + let mut copied = 0; + for entry in fs::read_dir(src)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let target = dst.join(entry.file_name()); + if fs::symlink_metadata(&target).is_err() { + fs::copy(entry.path(), target)?; + copied += 1; + } + } + Ok(copied) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn writes_the_bytes_to_the_target_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("note.md"); + atomic_write(&path, b"hello").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "hello"); + } + + #[test] + fn overwrites_an_existing_file_completely() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("note.md"); + atomic_write(&path, b"a much longer first version").unwrap(); + atomic_write(&path, b"short").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "short"); + } + + #[test] + fn leaves_no_temp_file_behind_on_success() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("note.md"); + atomic_write(&path, b"hello").unwrap(); + let entries: Vec<_> = fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(entries, vec![std::ffi::OsString::from("note.md")]); + } + + #[test] + fn errors_when_the_parent_directory_does_not_exist() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("missing-subdir").join("note.md"); + assert!(atomic_write(&path, b"hello").is_err()); + } + + #[test] + fn copy_dir_recursive_copies_nested_files_and_creates_the_destination() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + fs::create_dir_all(src.join("nested")).unwrap(); + fs::write(src.join("a.png"), b"a-bytes").unwrap(); + fs::write(src.join("nested").join("b.png"), b"b-bytes").unwrap(); + + let dst = dir.path().join("dst"); + copy_dir_recursive(&src, &dst).unwrap(); + + assert_eq!(fs::read(dst.join("a.png")).unwrap(), b"a-bytes"); + assert_eq!( + fs::read(dst.join("nested").join("b.png")).unwrap(), + b"b-bytes" + ); + } + + /// The live mirror (stage 2) re-runs this at every launch: attachment + /// names are uuids, so an existing name is already the same image and + /// must never be rewritten. + #[test] + fn copy_missing_files_copies_only_what_the_destination_lacks() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + let dst = dir.path().join("dst"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + fs::write(src.join("new.png"), b"new").unwrap(); + fs::write(src.join("have.png"), b"source").unwrap(); + fs::write(dst.join("have.png"), b"already there").unwrap(); + + assert_eq!(copy_missing_files(&src, &dst).unwrap(), 1); + assert_eq!(fs::read(dst.join("new.png")).unwrap(), b"new"); + assert_eq!(fs::read(dst.join("have.png")).unwrap(), b"already there"); + assert_eq!(copy_missing_files(&src, &dst).unwrap(), 0); + } + + #[test] + fn copy_dir_recursive_on_an_empty_source_just_creates_the_destination() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + fs::create_dir_all(&src).unwrap(); + let dst = dir.path().join("dst"); + copy_dir_recursive(&src, &dst).unwrap(); + assert!(dst.is_dir()); + } +} diff --git a/src-tauri/core/tests/attachments_test.rs b/src-tauri/core/tests/attachments_test.rs new file mode 100644 index 0000000..6284725 --- /dev/null +++ b/src-tauri/core/tests/attachments_test.rs @@ -0,0 +1,254 @@ +//! Attachment cleanup (SEQUENCE.md unit 11). An image pasted into a note is a +//! file under `<app data>/attachments`, referenced from the body as +//! `attachments/<name>`. Destroying the last note that references it removes +//! the file; nothing a note, the trash, or the capture draft still refers to +//! is ever removed. Real SQLite and real tempdirs throughout. + +use instantnotes_core::attachments::{list_attachments, referenced_names, remove_attachment}; +use instantnotes_core::types::*; +use instantnotes_core::Store; +use std::fs; +use std::time::{Duration, SystemTime}; + +fn store() -> Store { + Store::open_in_memory().expect("open in-memory store") +} + +fn create(s: &mut Store, body: &str) -> Note { + s.create_note(CreateNoteInput { + body: Some(body.to_string()), + ..Default::default() + }) + .unwrap() +} + +fn names(list: &[&str]) -> Vec<String> { + list.iter().map(|s| s.to_string()).collect() +} + +// ---- finding references ---- + +#[test] +fn finds_every_attachment_a_body_references() { + let body = "intro ![](attachments/a1.png) and ![alt](attachments/b-2.JPG)\n\ + <img src=\"attachments/c_3.webp\"> twice ![](attachments/a1.png)"; + let found: Vec<String> = referenced_names(body).into_iter().collect(); + assert_eq!(found, names(&["a1.png", "b-2.JPG", "c_3.webp"])); +} + +#[test] +fn ignores_a_bare_folder_mention_and_linked_originals() { + assert!(referenced_names("see the attachments/ folder").is_empty()); + assert!(referenced_names("![](/Users/me/Pictures/cat.png)").is_empty()); +} + +// ---- which files are still referenced ---- + +#[test] +fn a_destroyed_notes_images_become_unreferenced() { + let mut s = store(); + let n = create( + &mut s, + "![](attachments/only.png) ![](attachments/shared.png)", + ); + create(&mut s, "also uses ![](attachments/shared.png)"); + + let referenced = s.attachment_names_of(std::slice::from_ref(&n.id)).unwrap(); + assert_eq!( + referenced.iter().cloned().collect::<Vec<_>>(), + names(&["only.png", "shared.png"]) + ); + s.soft_delete_note(&n.id).unwrap(); + s.destroy_notes(std::slice::from_ref(&n.id), true).unwrap(); + + assert_eq!( + s.unreferenced_attachments(referenced).unwrap(), + names(&["only.png"]) + ); +} + +#[test] +fn a_trashed_or_archived_note_still_holds_its_images() { + let mut s = store(); + let trashed = create(&mut s, "![](attachments/t.png)"); + s.soft_delete_note(&trashed.id).unwrap(); + let archived = create(&mut s, "![](attachments/a.png)"); + s.update_note( + &archived.id, + UpdateNotePatch { + is_archived: Some(true), + ..Default::default() + }, + ) + .unwrap(); + let left = s + .unreferenced_attachments(names(&["t.png", "a.png", "gone.png"])) + .unwrap(); + assert_eq!(left, names(&["gone.png"])); +} + +#[test] +fn a_reference_in_another_case_still_holds_the_image() { + let mut s = store(); + create(&mut s, "![](attachments/Shot.PNG)"); + assert!(s + .unreferenced_attachments(names(&["shot.png"])) + .unwrap() + .is_empty()); +} + +#[test] +fn the_capture_draft_holds_its_images() { + let mut s = store(); + s.set_setting( + "capture.draft", + serde_json::Value::String("half written ![](attachments/draft.png)".into()), + ) + .unwrap(); + assert!(s + .unreferenced_attachments(names(&["draft.png"])) + .unwrap() + .is_empty()); +} + +#[test] +fn a_whiteboard_canvas_holds_its_images() { + let mut s = store(); + let n = create(&mut s, ""); + s.update_note( + &n.id, + UpdateNotePatch { + content_kind: Some(CONTENT_KIND_WHITEBOARD.into()), + surface_data: Some(r#"{"v":1,"note":"attachments/board.png"}"#.into()), + ..Default::default() + }, + ) + .unwrap(); + assert!(s + .unreferenced_attachments(names(&["board.png"])) + .unwrap() + .is_empty()); +} + +// ---- removing files ---- + +#[test] +fn removing_takes_the_file_and_an_identical_vault_copy() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("x.png"), b"pixels").unwrap(); + fs::write(vault.path().join("x.png"), b"pixels").unwrap(); + + let freed = remove_attachment(dir.path(), Some(vault.path()), "x.png").unwrap(); + + assert_eq!(freed, 6); + assert!(!dir.path().join("x.png").exists()); + assert!(!vault.path().join("x.png").exists()); +} + +/// A vault copy that differs was changed outside the app: not ours to remove. +#[test] +fn removing_keeps_a_vault_copy_that_was_changed() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("x.png"), b"pixels").unwrap(); + fs::write(vault.path().join("x.png"), b"edited").unwrap(); + + remove_attachment(dir.path(), Some(vault.path()), "x.png").unwrap(); + + assert!(!dir.path().join("x.png").exists()); + assert_eq!(fs::read(vault.path().join("x.png")).unwrap(), b"edited"); +} + +#[test] +fn removing_refuses_anything_but_a_plain_file_name() { + let dir = tempfile::tempdir().unwrap(); + let outside = dir.path().join("keep.png"); + fs::write(&outside, b"x").unwrap(); + let inner = dir.path().join("attachments"); + fs::create_dir(&inner).unwrap(); + for bad in ["../keep.png", "a/b.png", "", ".", ".."] { + assert!(remove_attachment(&inner, None, bad).is_err(), "{bad:?}"); + } + assert!(outside.exists()); +} + +#[test] +fn removing_a_missing_file_frees_nothing() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(remove_attachment(dir.path(), None, "nope.png").unwrap(), 0); +} + +#[test] +fn listing_skips_folders_hidden_files_and_anything_too_recent() { + let dir = tempfile::tempdir().unwrap(); + let old = SystemTime::now() - Duration::from_secs(3 * 3600); + for (name, bytes) in [("old.png", 3usize), ("new.png", 5)] { + fs::write(dir.path().join(name), vec![0u8; bytes]).unwrap(); + } + fs::File::options() + .write(true) + .open(dir.path().join("old.png")) + .unwrap() + .set_modified(old) + .unwrap(); + fs::write(dir.path().join(".DS_Store"), b"x").unwrap(); + fs::create_dir(dir.path().join("sub")).unwrap(); + + let cutoff = SystemTime::now() - Duration::from_secs(3600); + assert_eq!( + list_attachments(dir.path(), Some(cutoff)).unwrap(), + vec![("old.png".to_string(), 3)] + ); + let mut all = list_attachments(dir.path(), None).unwrap(); + all.sort(); + assert_eq!( + all, + vec![("new.png".to_string(), 5), ("old.png".to_string(), 3)] + ); +} + +#[test] +fn listing_a_missing_folder_is_empty() { + let dir = tempfile::tempdir().unwrap(); + assert!(list_attachments(&dir.path().join("none"), None) + .unwrap() + .is_empty()); +} + +// ---- the store's removal: re-checked, and carried into the vault ---- + +#[test] +fn the_store_removes_only_what_nothing_references() { + let mut s = store(); + let dir = tempfile::tempdir().unwrap(); + for name in ["kept.png", "loose.png"] { + fs::write(dir.path().join(name), b"img").unwrap(); + } + create(&mut s, "![](attachments/kept.png)"); + + // Callers pass candidates; the store checks them again itself. + let done = s + .remove_unreferenced_attachments(dir.path(), names(&["kept.png", "loose.png"])) + .unwrap(); + + assert_eq!((done.count, done.bytes), (1, 3)); + assert!(dir.path().join("kept.png").exists()); + assert!(!dir.path().join("loose.png").exists()); +} + +#[test] +fn the_store_also_removes_the_live_vault_copy() { + let mut s = store(); + let vault = tempfile::tempdir().unwrap(); + s.configure_vault(Some(vault.path())).unwrap(); + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(vault.path().join("attachments")).unwrap(); + fs::write(dir.path().join("x.png"), b"img").unwrap(); + fs::write(vault.path().join("attachments/x.png"), b"img").unwrap(); + + s.remove_unreferenced_attachments(dir.path(), names(&["x.png"])) + .unwrap(); + + assert!(!vault.path().join("attachments/x.png").exists()); +} diff --git a/src-tauri/core/tests/concurrency_test.rs b/src-tauri/core/tests/concurrency_test.rs new file mode 100644 index 0000000..a1fad12 --- /dev/null +++ b/src-tauri/core/tests/concurrency_test.rs @@ -0,0 +1,78 @@ +//! Two writers on one library: the app and an agent process (`instantnotes +//! mcp`) each open their own `Store` on the same file. Covers the version +//! check that keeps one from overwriting what it has not seen, and the +//! `data_version` signal the app watches to notice the other's writes. + +use instantnotes_core::types::*; +use instantnotes_core::{AppError, Store}; + +fn create(store: &mut Store, body: &str) -> Note { + store + .create_note(CreateNoteInput { + body: Some(body.to_string()), + ..Default::default() + }) + .expect("create note") +} + +fn body_patch(body: &str, expected: Option<&str>) -> UpdateNotePatch { + UpdateNotePatch { + body: Some(body.to_string()), + expected_updated_at: expected.map(str::to_string), + ..Default::default() + } +} + +#[test] +fn update_with_current_version_applies() { + let mut s = Store::open_in_memory().unwrap(); + let n = create(&mut s, "first"); + let updated = s + .update_note(&n.id, body_patch("second", Some(&n.updated_at))) + .unwrap(); + assert_eq!(updated.body, "second"); +} + +#[test] +fn update_with_stale_version_conflicts_and_changes_nothing() { + let mut s = Store::open_in_memory().unwrap(); + let n = create(&mut s, "first"); + s.update_note(&n.id, body_patch("someone else", None)) + .unwrap(); + + let err = s + .update_note(&n.id, body_patch("mine", Some(&n.updated_at))) + .unwrap_err(); + assert!(matches!(err, AppError::Conflict(_)), "got {err:?}"); + assert_eq!(err.code(), "CONFLICT"); + assert_eq!(s.get_note(&n.id, false).unwrap().body, "someone else"); +} + +#[test] +fn two_stores_on_one_file_see_each_other_and_data_version_moves() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("instantnotes.db"); + let mut app = Store::open(&path).unwrap(); + let mut agent = Store::open(&path).unwrap(); + + let before = app.data_version().unwrap(); + // The app's own write does not move its own data_version. + let n = create(&mut app, "from the app"); + assert_eq!(app.data_version().unwrap(), before); + + // The agent sees the app's note and writes to it with the version it read. + let seen = agent.get_note(&n.id, false).unwrap(); + agent + .update_note(&n.id, body_patch("agent edit", Some(&seen.updated_at))) + .unwrap(); + + // The app notices, and reads the agent's text. + assert_ne!(app.data_version().unwrap(), before); + assert_eq!(app.get_note(&n.id, false).unwrap().body, "agent edit"); + + // And the app's stale version now conflicts instead of overwriting. + let err = app + .update_note(&n.id, body_patch("stale", Some(&n.updated_at))) + .unwrap_err(); + assert_eq!(err.code(), "CONFLICT"); +} diff --git a/src-tauri/core/tests/fixtures/stickies/.SavedStickiesState b/src-tauri/core/tests/fixtures/stickies/.SavedStickiesState new file mode 100644 index 0000000..ada89e7 Binary files /dev/null and b/src-tauri/core/tests/fixtures/stickies/.SavedStickiesState differ diff --git a/src-tauri/core/tests/fixtures/stickies/0B7D6A52-5A1F-4C3E-9D2B-3F1E8A6C4D10.rtfd/TXT.rtf b/src-tauri/core/tests/fixtures/stickies/0B7D6A52-5A1F-4C3E-9D2B-3F1E8A6C4D10.rtfd/TXT.rtf new file mode 100644 index 0000000..8906218 --- /dev/null +++ b/src-tauri/core/tests/fixtures/stickies/0B7D6A52-5A1F-4C3E-9D2B-3F1E8A6C4D10.rtfd/TXT.rtf @@ -0,0 +1,45 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2907 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\froman\fcharset0 Times-Roman;\f1\froman\fcharset0 Times-Bold;\f2\froman\fcharset0 Times-Italic; +\f3\froman\fcharset0 Times-BoldItalic;} +{\colortbl;\red255\green255\blue255;\red0\green0\blue0;\red0\green0\blue233;} +{\*\expandedcolortbl;;\cssrgb\c0\c0\c0;\cssrgb\c0\c0\c93333;} +{\*\listtable{\list\listtemplateid1\listhybrid{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat0\levelspace360\levelindent0{\*\levelmarker \{disc\}}{\leveltext\leveltemplateid1\'01\uc0\u8226 ;}{\levelnumbers;}\fi-360\li720\lin720 }{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat0\levelspace360\levelindent0{\*\levelmarker \{circle\}}{\leveltext\leveltemplateid2\'01\uc0\u9702 ;}{\levelnumbers;}\fi-360\li1440\lin1440 }{\listname ;}\listid1} +{\list\listtemplateid2\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid101\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listname ;}\listid2}} +{\*\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}{\listoverride\listid2\listoverridecount0\ls2}} +\deftab720 +\pard\pardeftab720\sa240\partightenfactor0 + +\f0\fs24 \cf0 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 Groceries for the week\ +Plain, +\f1\b bold +\f0\b0 , +\f2\i italic +\f0\i0 , +\f3\i\b both +\f0\i0\b0 , \strike \strikec0 struck\strike0\striked0 , \ul under\ulnone . Caf\'e9 \'93quoted\'94 \'97 don\'92t \uc0\u55357 \u56960 done.\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls1\ilvl0\cf0 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 milk\ +\ls1\ilvl0\kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 eggs\ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls1\ilvl1\cf0 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 free range\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls1\ilvl0\cf0 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 bread\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls2\ilvl0\cf0 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 first\ +\ls2\ilvl0\kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 second\ +\pard\pardeftab720\sa240\partightenfactor0 +\cf0 See {\field{\*\fldinst{HYPERLINK "https://example.com/path?a=1&b=2"}}{\fldrslt \cf3 \ul \ulc3 \strokec3 the site}} and #errands\ +line one\uc0\u8232 line two tabbed\ +braces \{x\} and back\\slash\ +\pard\pardeftab720\sa240\partightenfactor0 +\cf0 \pard\pardeftab720\sa240\partightenfactor0 +\cf0 \ +after image\ +} \ No newline at end of file diff --git a/src-tauri/core/tests/fixtures/stickies/5A6B7C8D-9E0F-4A1B-8C2D-3E4F5A6B7C8D.rtfd/README b/src-tauri/core/tests/fixtures/stickies/5A6B7C8D-9E0F-4A1B-8C2D-3E4F5A6B7C8D.rtfd/README new file mode 100644 index 0000000..349f907 --- /dev/null +++ b/src-tauri/core/tests/fixtures/stickies/5A6B7C8D-9E0F-4A1B-8C2D-3E4F5A6B7C8D.rtfd/README @@ -0,0 +1 @@ +a package with no TXT.rtf is not a sticky diff --git a/src-tauri/core/tests/fixtures/stickies/6E2F9C31-8B4A-4D7E-A1C5-2D9E7F3B8A64.rtfd/Attachment.png b/src-tauri/core/tests/fixtures/stickies/6E2F9C31-8B4A-4D7E-A1C5-2D9E7F3B8A64.rtfd/Attachment.png new file mode 100644 index 0000000..928d16f Binary files /dev/null and b/src-tauri/core/tests/fixtures/stickies/6E2F9C31-8B4A-4D7E-A1C5-2D9E7F3B8A64.rtfd/Attachment.png differ diff --git a/src-tauri/core/tests/fixtures/stickies/6E2F9C31-8B4A-4D7E-A1C5-2D9E7F3B8A64.rtfd/TXT.rtf b/src-tauri/core/tests/fixtures/stickies/6E2F9C31-8B4A-4D7E-A1C5-2D9E7F3B8A64.rtfd/TXT.rtf new file mode 100644 index 0000000..8270335 --- /dev/null +++ b/src-tauri/core/tests/fixtures/stickies/6E2F9C31-8B4A-4D7E-A1C5-2D9E7F3B8A64.rtfd/TXT.rtf @@ -0,0 +1,46 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2907 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\froman\fcharset0 Times-Roman;\f1\froman\fcharset0 Times-Bold;\f2\froman\fcharset0 Times-Italic; +\f3\froman\fcharset0 Times-BoldItalic;} +{\colortbl;\red255\green255\blue255;\red0\green0\blue0;\red0\green0\blue233;} +{\*\expandedcolortbl;;\cssrgb\c0\c0\c0;\cssrgb\c0\c0\c93333;} +{\*\listtable{\list\listtemplateid1\listhybrid{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat0\levelspace360\levelindent0{\*\levelmarker \{disc\}}{\leveltext\leveltemplateid1\'01\uc0\u8226 ;}{\levelnumbers;}\fi-360\li720\lin720 }{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat0\levelspace360\levelindent0{\*\levelmarker \{circle\}}{\leveltext\leveltemplateid2\'01\uc0\u9702 ;}{\levelnumbers;}\fi-360\li1440\lin1440 }{\listname ;}\listid1} +{\list\listtemplateid2\listhybrid{\listlevel\levelnfc0\levelnfcn0\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{decimal\}}{\leveltext\leveltemplateid101\'01\'00;}{\levelnumbers\'01;}\fi-360\li720\lin720 }{\listname ;}\listid2}} +{\*\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}{\listoverride\listid2\listoverridecount0\ls2}} +\deftab720 +\pard\pardeftab720\sa240\partightenfactor0 + +\f0\fs24 \cf0 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 Groceries for the week\ +Plain, +\f1\b bold +\f0\b0 , +\f2\i italic +\f0\i0 , +\f3\i\b both +\f0\i0\b0 , \strike \strikec0 struck\strike0\striked0 , \ul under\ulnone . Caf\'e9 \'93quoted\'94 \'97 don\'92t \uc0\u55357 \u56960 done.\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls1\ilvl0\cf0 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 milk\ +\ls1\ilvl0\kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 eggs\ +\pard\tx940\tx1440\pardeftab720\li1440\fi-1440\partightenfactor0 +\ls1\ilvl1\cf0 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u9702 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 free range\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls1\ilvl0\cf0 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext \uc0\u8226 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 bread\ +\pard\tx220\tx720\pardeftab720\li720\fi-720\partightenfactor0 +\ls2\ilvl0\cf0 \kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 1 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 first\ +\ls2\ilvl0\kerning1\expnd0\expndtw0 \outl0\strokewidth0 {\listtext 2 }\expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 second\ +\pard\pardeftab720\sa240\partightenfactor0 +\cf0 See {\field{\*\fldinst{HYPERLINK "https://example.com/path?a=1&b=2"}}{\fldrslt \cf3 \ul \ulc3 \strokec3 the site}} and #errands\ +line one\uc0\u8232 line two tabbed\ +braces \{x\} and back\\slash\ +\pard\pardeftab720\sa240\partightenfactor0 +\cf0 {{\NeXTGraphic Attachment.png \width320 \height320 \appleattachmentpadding0 \appleembedtype0 \appleaqc +}�}\pard\pardeftab720\sa240\partightenfactor0 +\cf0 \ +after image\ +} \ No newline at end of file diff --git a/src-tauri/core/tests/fixtures/stickies/9F3E5D1C-2A7B-4C8E-B6D0-1E4F8A2C7B55.rtfd b/src-tauri/core/tests/fixtures/stickies/9F3E5D1C-2A7B-4C8E-B6D0-1E4F8A2C7B55.rtfd new file mode 100644 index 0000000..d50aef0 --- /dev/null +++ b/src-tauri/core/tests/fixtures/stickies/9F3E5D1C-2A7B-4C8E-B6D0-1E4F8A2C7B55.rtfd @@ -0,0 +1 @@ +a sticky Stickies has not saved yet is a flat file, not a package diff --git a/src-tauri/core/tests/fixtures/stickies/C4A81E07-3D6B-45F2-9E8C-7B1A0D5F2E93.rtfd/TXT.rtf b/src-tauri/core/tests/fixtures/stickies/C4A81E07-3D6B-45F2-9E8C-7B1A0D5F2E93.rtfd/TXT.rtf new file mode 100644 index 0000000..24c6f65 --- /dev/null +++ b/src-tauri/core/tests/fixtures/stickies/C4A81E07-3D6B-45F2-9E8C-7B1A0D5F2E93.rtfd/TXT.rtf @@ -0,0 +1,10 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2907 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\froman\fcharset0 Times-Roman;} +{\colortbl;\red255\green255\blue255;\red0\green0\blue0;} +{\*\expandedcolortbl;;\cssrgb\c0\c0\c0;} +\deftab720 +\pard\pardeftab720\sa240\partightenfactor0 + +\f0\fs24 \cf0 \expnd0\expndtw0\kerning0 +\outl0\strokewidth0 \strokec2 rocket \uc0\u55357 \u56960 and caf\'e9 and \'93q\'94 and \u1055 \u1088 \u1080 and \u10003 \ +} \ No newline at end of file diff --git a/src-tauri/core/tests/fixtures/stickies/README.md b/src-tauri/core/tests/fixtures/stickies/README.md new file mode 100644 index 0000000..bba96b2 --- /dev/null +++ b/src-tauri/core/tests/fixtures/stickies/README.md @@ -0,0 +1,23 @@ +# A Stickies folder, for `import_test.rs` + +Laid out the way Apple Stickies keeps its notes: one `<UUID>.rtfd` package per +sticky, and `.SavedStickiesState`. + +The RTF is not hand-written. `textutil`, which saves through the same Cocoa +writer Stickies uses (`\cocoartf`), made it from HTML on macOS 27: + +| Package | Made with | +| --- | --- | +| `0B7D6A52….rtfd/TXT.rtf` | `textutil -convert rtf` of a page with bold, italic, strikethrough, underline, nested and numbered lists, a link, cp1252 punctuation, an emoji, escapes, and an image (which plain RTF drops) | +| `6E2F9C31….rtfd/` | `textutil -convert rtfd` of the same page: `TXT.rtf` refers to `Attachment.png` with `\NeXTGraphic` and a raw `0xAC` byte | +| `C4A81E07….rtfd/TXT.rtf` | `textutil -convert rtf` of an emoji, Cyrillic, and a check mark | + +`.SavedStickiesState` is a binary plist written by `plutil -convert binary1` +with the keys Stickies uses (`UUID`, `StickyColor` as float `Red`/`Green`/`Blue`/ +`Alpha`, `Frame`, `Floating`, `Translucent`): yellow for the first package, +blue for the second, the third missing, and one entry for a sticky that is +not here. + +Two entries are not stickies and must be skipped: `9F3E5D1C….rtfd` is a flat +file (a sticky Stickies has not saved yet), and `5A6B7C8D….rtfd` has no +`TXT.rtf`. diff --git a/src-tauri/core/tests/graph_test.rs b/src-tauri/core/tests/graph_test.rs new file mode 100644 index 0000000..05ac278 --- /dev/null +++ b/src-tauri/core/tests/graph_test.rs @@ -0,0 +1,111 @@ +//! The library graph (SEQUENCE.md unit 13): notes, tags, and Spaces, and the +//! links between them, derived from the existing tables on every read. +//! Nothing about the graph is stored. + +use instantnotes_core::types::*; +use instantnotes_core::Store; + +fn store() -> Store { + Store::open_in_memory().expect("open in-memory store") +} + +fn create(s: &mut Store, body: &str) -> Note { + s.create_note(CreateNoteInput { + body: Some(body.to_string()), + ..Default::default() + }) + .unwrap() +} + +fn links(g: &LibraryGraph) -> Vec<(String, String, String)> { + let mut out: Vec<_> = g + .links + .iter() + .map(|l| (l.note_id.clone(), l.target_id.clone(), l.kind.clone())) + .collect(); + out.sort(); + out +} + +#[test] +fn notes_link_to_their_tags_and_spaces() { + let mut s = store(); + let a = create(&mut s, "alpha #ideas"); + let b = create(&mut s, "beta #ideas #later"); + let ws = s.get_or_create_workspace("Research").unwrap(); + s.add_note_to_workspace(&a.id, &ws.id).unwrap(); + + let g = s.library_graph().unwrap(); + + let tag = |name: &str| g.tags.iter().find(|t| t.name == name).unwrap().id.clone(); + let mut expected = vec![ + (a.id.clone(), tag("ideas"), "tag".to_string()), + (b.id.clone(), tag("ideas"), "tag".to_string()), + (b.id.clone(), tag("later"), "tag".to_string()), + (a.id.clone(), ws.id.clone(), "space".to_string()), + ]; + expected.sort(); + assert_eq!(links(&g), expected); + assert_eq!(g.spaces.len(), 1); + assert_eq!(g.spaces[0].name, "Research"); +} + +#[test] +fn trashed_and_archived_notes_are_left_out_with_their_links() { + let mut s = store(); + let live = create(&mut s, "live #kept"); + let trashed = create(&mut s, "trashed #kept"); + s.soft_delete_note(&trashed.id).unwrap(); + let archived = create(&mut s, "archived #kept"); + s.update_note( + &archived.id, + UpdateNotePatch { + is_archived: Some(true), + ..Default::default() + }, + ) + .unwrap(); + + let g = s.library_graph().unwrap(); + + assert_eq!( + g.notes.iter().map(|n| n.id.clone()).collect::<Vec<_>>(), + vec![live.id.clone()] + ); + assert!(g.links.iter().all(|l| l.note_id == live.id)); +} + +#[test] +fn a_note_with_no_tags_or_spaces_is_listed_without_links() { + let mut s = store(); + let n = create(&mut s, "loose thought"); + let g = s.library_graph().unwrap(); + assert_eq!(g.notes.len(), 1); + assert_eq!(g.notes[0].id, n.id); + assert_eq!(g.notes[0].title, "loose thought"); + assert!(g.links.is_empty()); +} + +#[test] +fn nodes_carry_what_the_graph_draws() { + let mut s = store(); + let n = create(&mut s, "board #plans"); + s.update_note( + &n.id, + UpdateNotePatch { + is_pinned: Some(true), + content_kind: Some(CONTENT_KIND_WHITEBOARD.into()), + surface_data: Some("{}".into()), + ..Default::default() + }, + ) + .unwrap(); + let tag = s.tags_for_note(&n.id).unwrap().remove(0); + s.update_tag(&tag.id, None, Some("#ff8800".into())).unwrap(); + + let g = s.library_graph().unwrap(); + + assert!(g.notes[0].is_pinned); + assert_eq!(g.notes[0].content_kind, CONTENT_KIND_WHITEBOARD); + assert_eq!(g.tags[0].color.as_deref(), Some("#ff8800")); +} diff --git a/src-tauri/core/tests/import_test.rs b/src-tauri/core/tests/import_test.rs new file mode 100644 index 0000000..d5f499d --- /dev/null +++ b/src-tauri/core/tests/import_test.rs @@ -0,0 +1,407 @@ +//! Import from Apple Stickies: the RTF converter against RTF written by +//! Cocoa itself (fixtures/stickies/README.md), the folder reader, and the +//! store's all-or-nothing import. + +use instantnotes_core::import::rtf::to_markdown; +use instantnotes_core::import::stickies::{read_folder, resolve_folder}; +use instantnotes_core::types::{ImportItem, NoteFilter}; +use instantnotes_core::Store; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +const FORMATTED: &str = "0B7D6A52-5A1F-4C3E-9D2B-3F1E8A6C4D10"; +const WITH_IMAGE: &str = "6E2F9C31-8B4A-4D7E-A1C5-2D9E7F3B8A64"; +const SCRIPTS: &str = "C4A81E07-3D6B-45F2-9E8C-7B1A0D5F2E93"; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/stickies") +} + +fn rtf(id: &str) -> Vec<u8> { + fs::read(fixtures().join(format!("{id}.rtfd/TXT.rtf"))).unwrap() +} + +fn convert(rtf: &[u8]) -> String { + to_markdown(rtf, |name| Some(format!("![](attachments/{name})"))) +} + +/// The fixture folder, copied somewhere the test may change it. +fn folder() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + copy_dir(&fixtures(), dir.path()); + dir +} + +fn copy_dir(from: &Path, to: &Path) { + for entry in fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + let target = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + fs::create_dir_all(&target).unwrap(); + copy_dir(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).unwrap(); + } + } +} + +// ---- the converter, on Cocoa's own RTF ---- + +#[test] +fn formatting_lists_links_and_escapes_become_markdown() { + assert_eq!( + convert(&rtf(FORMATTED)), + "Groceries for the week\n\ + Plain, **bold**, *italic*, ***both***, ~~struck~~, under. \ + Caf\u{e9} \u{201c}quoted\u{201d} \u{2014} don\u{2019}t \u{1f680} done.\n\ + - milk\n\ + - eggs\n \ + - free range\n\ + - bread\n\ + 1. first\n\ + 2. second\n\ + See [the site](https://example.com/path?a=1&b=2) and #errands\n\ + line one\n\ + line two tabbed\n\ + braces {x} and back\\slash\n\ + \n\ + after image" + ); +} + +#[test] +fn an_attachment_is_asked_for_by_name_and_its_placeholder_dropped() { + let mut asked = Vec::new(); + let md = to_markdown(&rtf(WITH_IMAGE), |name| { + asked.push(name.to_string()); + Some("![](attachments/new.png)".into()) + }); + assert_eq!(asked, ["Attachment.png"]); + assert!( + md.ends_with("braces {x} and back\\slash\n![](attachments/new.png)\nafter image"), + "{md}" + ); + assert!(!md.contains('\u{ac}'), "the 0xAC placeholder is not text"); +} + +#[test] +fn an_attachment_that_could_not_come_in_says_so_in_its_place() { + let md = to_markdown(&rtf(WITH_IMAGE), |_| None); + assert!(md.contains("\n[Not imported: Attachment.png]\n"), "{md}"); +} + +#[test] +fn surrogate_pairs_and_other_scripts_decode() { + assert_eq!( + convert(&rtf(SCRIPTS)), + "rocket \u{1f680} and caf\u{e9} and \u{201c}q\u{201d} and \u{41f}\u{440}\u{438} and \u{2713}" + ); +} + +#[test] +fn a_windows_checkout_with_crlf_reads_the_same() { + let lf = rtf(FORMATTED); + let crlf: Vec<u8> = lf + .iter() + .flat_map(|&b| { + if b == b'\n' { + vec![b'\r', b'\n'] + } else { + vec![b] + } + }) + .collect(); + assert_eq!(convert(&crlf), convert(&lf)); +} + +// ---- the converter, on the edges ---- + +#[test] +fn emphasis_markers_never_touch_whitespace() { + assert_eq!(convert(br"{\rtf1 a\b bold \b0 c}"), "a **bold** c"); + assert_eq!( + convert(br"{\rtf1 \i \i0 x}"), + " x", + "a styled space stays a space" + ); +} + +#[test] +fn emphasis_closes_at_each_line() { + assert_eq!(convert(b"{\\rtf1 \\b one\\\ntwo\\b0 }"), "**one**\n**two**"); +} + +#[test] +fn tables_metadata_and_starred_groups_are_not_text() { + let rtf = br"{\rtf1{\fonttbl{\f0 Helvetica;}}{\colortbl;\red0\green0\blue0;}{\info{\title T}}{\*\generator Foo;}Hello}"; + assert_eq!(convert(rtf), "Hello"); +} + +#[test] +fn unicode_escapes_follow_the_rtf_rules() { + // Negative values wrap to 16 bits; this pair is U+1F680. + assert_eq!(convert(br"{\rtf1\uc0 \u-10179 \u-8576 }"), "\u{1f680}"); + // \uc1: one fallback character follows and is skipped. + assert_eq!(convert(br"{\rtf1\uc1 caf\u233 e!}"), "caf\u{e9}!"); + // A lone half of a pair is dropped, not turned into garbage. + assert_eq!(convert(br"{\rtf1\uc0 a\u-10179 b}"), "ab"); +} + +#[test] +fn a_link_whose_text_is_its_address_stays_bare() { + let rtf = br#"{\rtf1 {\field{\*\fldinst{HYPERLINK "https://a.io"}}{\fldrslt https://a.io}}}"#; + assert_eq!(convert(rtf), "https://a.io"); +} + +#[test] +fn a_line_break_inside_a_list_item_stays_in_the_item() { + let rtf = b"{\\rtf1 \\ls1\\ilvl1{\\listtext\t\\uc0\\u8226 \t}one\\line two\\\n}"; + assert_eq!(convert(rtf), " - one\n two"); +} + +#[test] +fn broken_input_ends_without_a_panic() { + assert_eq!(convert(br"}}}{\b x"), "**x**"); + // A broken hex escape is dropped; the text after it is kept, not lost. + assert_eq!(convert(br"{\rtf1 \'zz\u"), "zz"); + assert_eq!(convert(b""), ""); +} + +// ---- the folder reader ---- + +#[test] +fn only_packages_with_text_are_stickies_and_colors_come_from_the_state_file() { + let dir = folder(); + let stickies = read_folder(dir.path()).unwrap(); + let mut ids: Vec<&str> = stickies.iter().map(|s| s.id.as_str()).collect(); + ids.sort(); + assert_eq!(ids, [FORMATTED, WITH_IMAGE, SCRIPTS]); + let color = |id: &str| stickies.iter().find(|s| s.id == id).unwrap().color.clone(); + assert_eq!(color(FORMATTED).as_deref(), Some("#fef49c")); + assert_eq!(color(WITH_IMAGE).as_deref(), Some("#adf4ff")); + assert_eq!(color(SCRIPTS), None); + for sticky in &stickies { + assert!(sticky.created_at <= sticky.updated_at, "{}", sticky.id); + } +} + +#[test] +fn newest_first_by_when_stickies_last_saved_it() { + let dir = folder(); + let touch = |id: &str, secs_ago: u64| { + let file = fs::File::options() + .write(true) + .open(dir.path().join(format!("{id}.rtfd/TXT.rtf"))) + .unwrap(); + file.set_modified(SystemTime::now() - Duration::from_secs(secs_ago)) + .unwrap(); + }; + touch(FORMATTED, 300); + touch(WITH_IMAGE, 10); + touch(SCRIPTS, 3000); + let ids: Vec<String> = read_folder(dir.path()) + .unwrap() + .into_iter() + .map(|s| s.id) + .collect(); + assert_eq!(ids, [WITH_IMAGE, FORMATTED, SCRIPTS]); +} + +#[test] +fn picking_the_container_finds_the_notes_inside() { + let dir = tempfile::tempdir().unwrap(); + let notes = dir.path().join("Data/Library/Stickies"); + fs::create_dir_all(¬es).unwrap(); + assert_eq!(resolve_folder(dir.path()), notes); + assert_eq!(resolve_folder(¬es), notes, "the right folder stays"); +} + +#[cfg(unix)] +#[test] +fn a_symlinked_package_is_not_followed() { + let dir = folder(); + let elsewhere = tempfile::tempdir().unwrap(); + let outside = elsewhere.path().join("secret.rtfd"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("TXT.rtf"), br"{\rtf1 not a sticky}").unwrap(); + std::os::unix::fs::symlink(&outside, dir.path().join("AAAAAAAA.rtfd")).unwrap(); + let stickies = read_folder(dir.path()).unwrap(); + assert!(stickies.iter().all(|s| s.id != "AAAAAAAA")); +} + +#[test] +fn an_unreadable_state_file_only_costs_the_colors() { + let dir = folder(); + fs::write(dir.path().join(".SavedStickiesState"), b"not a plist").unwrap(); + let stickies = read_folder(dir.path()).unwrap(); + assert_eq!(stickies.len(), 3); + assert!(stickies.iter().all(|s| s.color.is_none())); +} + +// ---- the store ---- + +fn item(id: &str, body: &str, created: u64, updated: u64) -> ImportItem { + let at = |secs: u64| SystemTime::UNIX_EPOCH + Duration::from_secs(secs); + ImportItem { + source_id: id.into(), + body: body.into(), + created_at: at(created), + updated_at: at(updated), + } +} + +// 2024-01-01T00:00:00Z and 2024-02-01T00:00:00Z. +const JAN: u64 = 1_704_067_200; +const FEB: u64 = 1_706_745_600; + +#[test] +fn imported_notes_keep_their_dates_titles_and_tags_and_land_in_the_space() { + let mut store = Store::open_in_memory().unwrap(); + let outcome = store + .import_notes( + "stickies", + vec![ + item("A", "**Groceries**\n- milk #errands", JAN, FEB), + item("B", "Call the bank", JAN, JAN), + ], + Some("Apple Stickies"), + ) + .unwrap(); + assert_eq!(outcome.imported, 2); + assert_eq!(outcome.skipped, 0); + let space = outcome.workspace_id.expect("filed in a Space"); + + let notes = store + .list_notes(NoteFilter { + workspace_id: Some(space), + ..Default::default() + }) + .unwrap(); + let groceries = notes.iter().find(|n| n.title == "Groceries").unwrap(); + assert_eq!(groceries.created_at, "2024-01-01T00:00:00.000000Z"); + assert_eq!(groceries.updated_at, "2024-02-01T00:00:00.000000Z"); + assert!(groceries.last_opened_at.is_some(), "not a Revisit capture"); + let tags = store.tags_for_note(&groceries.id).unwrap(); + assert_eq!( + tags.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(), + ["errands"] + ); + assert!(notes.iter().any(|n| n.title == "Call the bank")); +} + +#[test] +fn a_sticky_is_imported_once_until_its_note_is_destroyed() { + let mut store = Store::open_in_memory().unwrap(); + store + .import_notes("stickies", vec![item("A", "one", JAN, JAN)], None) + .unwrap(); + let again = store + .import_notes( + "stickies", + vec![item("A", "one", JAN, JAN), item("B", "two", JAN, JAN)], + None, + ) + .unwrap(); + assert_eq!((again.imported, again.skipped), (1, 1)); + assert_eq!( + store.imported_ids("stickies").unwrap(), + ["A".to_string(), "B".to_string()].into() + ); + + // In the Trash it still counts; destroyed for good, it can come back. + let a = store + .list_notes(Default::default()) + .unwrap() + .into_iter() + .find(|n| n.body == "one") + .unwrap(); + store.soft_delete_note(&a.id).unwrap(); + assert!(store.imported_ids("stickies").unwrap().contains("A")); + store.permanently_delete_note(&a.id, true).unwrap(); + assert!(!store.imported_ids("stickies").unwrap().contains("A")); + let back = store + .import_notes("stickies", vec![item("A", "one", JAN, JAN)], None) + .unwrap(); + assert_eq!(back.imported, 1); +} + +#[test] +fn no_space_is_made_when_nothing_lands_or_none_is_named() { + let mut store = Store::open_in_memory().unwrap(); + let none = store + .import_notes("stickies", vec![item("A", "x", JAN, JAN)], Some(" ")) + .unwrap(); + assert_eq!(none.workspace_id, None); + let empty = store + .import_notes( + "stickies", + vec![item("A", "x", JAN, JAN)], + Some("Apple Stickies"), + ) + .unwrap(); + assert_eq!((empty.imported, empty.workspace_id), (0, None)); + assert!(store.list_workspaces().unwrap().is_empty()); +} + +/// The whole path `import_stickies` takes, less the app: a Stickies folder +/// read, converted, and imported, twice. +#[test] +fn a_stickies_folder_lands_as_notes_once() { + let dir = folder(); + let mut store = Store::open_in_memory().unwrap(); + let items = |store: &Store| -> Vec<ImportItem> { + let done = store.imported_ids("stickies").unwrap(); + read_folder(dir.path()) + .unwrap() + .into_iter() + .filter(|s| !done.contains(&s.id)) + .map(|s| ImportItem { + body: to_markdown(&s.rtf, |name| { + Some(format!("![](attachments/copy-of-{name})")) + }), + source_id: s.id, + created_at: s.created_at, + updated_at: s.updated_at, + }) + .collect() + }; + let first = items(&store); + let outcome = store + .import_notes("stickies", first, Some("Apple Stickies")) + .unwrap(); + assert_eq!(outcome.imported, 3); + + let notes = store.list_notes(Default::default()).unwrap(); + let mut titles: Vec<&str> = notes.iter().map(|n| n.title.as_str()).collect(); + titles.sort(); + assert_eq!( + titles, + [ + "Groceries for the week", + "Groceries for the week", + "rocket 🚀 and café and “q” and При and ✓" + ] + ); + assert!(notes + .iter() + .any(|n| n.body.contains("![](attachments/copy-of-Attachment.png)"))); + assert!(notes.iter().all(|n| n.last_opened_at.is_some())); + let errands = store.list_tags().unwrap(); + assert!( + errands.iter().any(|t| t.tag.name == "errands"), + "#errands became a tag" + ); + + assert!(items(&store).is_empty(), "a second pass finds nothing new"); +} + +#[test] +fn a_modified_date_before_the_created_one_is_raised_to_it() { + let mut store = Store::open_in_memory().unwrap(); + store + .import_notes("stickies", vec![item("A", "x", FEB, JAN)], None) + .unwrap(); + let note = &store.list_notes(Default::default()).unwrap()[0]; + assert_eq!(note.created_at, note.updated_at); +} diff --git a/src-tauri/core/tests/store_test.rs b/src-tauri/core/tests/store_test.rs index e5dd47e..c167b5d 100644 --- a/src-tauri/core/tests/store_test.rs +++ b/src-tauri/core/tests/store_test.rs @@ -443,6 +443,37 @@ fn list_supports_limit_offset_and_title_sort() { assert_eq!(titles, vec!["banana", "cherry"]); } +/// Rows tied on the sort column must still come back in one fixed order, or +/// LIMIT/OFFSET paging (the vault export pages through every note) can skip +/// or repeat a row that lands on a page boundary. The id breaks the tie. +#[test] +fn list_breaks_sort_ties_by_id_so_paging_is_stable() { + let mut s = store(); + let mut ids: Vec<String> = (0..20) + .map(|i| create(&mut s, &format!("note {i}")).id) + .collect(); + // One bulk statement stamps every note with the same updated_at. + s.set_notes_flags(&ids, Some(false), None).unwrap(); + ids.sort(); + + let all = s.list_notes(NoteFilter::default()).unwrap(); + let listed: Vec<String> = all.into_iter().map(|n| n.id).collect(); + assert_eq!(listed, ids, "tied rows are not ordered by id"); + + let mut paged = Vec::new(); + for offset in (0..20).step_by(2) { + let page = s + .list_notes(NoteFilter { + limit: Some(2), + offset: Some(offset), + ..Default::default() + }) + .unwrap(); + paged.extend(page.into_iter().map(|n| n.id)); + } + assert_eq!(paged, ids); +} + // ---- search ---- #[test] @@ -724,6 +755,32 @@ fn migrate_refuses_user_version_above_known_migrations() { Err(e) => e, }; assert_eq!(err.code(), "MIGRATION_ERROR"); + assert!(err.is_schema_too_new()); +} + +#[test] +fn open_or_recover_does_not_treat_a_future_schema_as_corruption() { + // A future-schema file is intact, just unreadable by this build. Moving + // it aside and starting fresh, the way open_or_recover does for a + // genuinely corrupt file, would silently sideline a real library. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("future.db"); + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.pragma_update(None, "user_version", (MIGRATIONS.len() + 1) as i64) + .unwrap(); + } + let err = match Store::open_or_recover(&path) { + Ok(_) => panic!("open_or_recover should refuse a future schema version, not recover it"), + Err(e) => e, + }; + assert!(err.is_schema_too_new()); + // Untouched at its original path, not moved aside the way a corrupt file + // would be. + assert!(path.exists()); + let mut moved_aside = path.as_os_str().to_os_string(); + moved_aside.push(".corrupt-1"); + assert!(!std::path::Path::new(&moved_aside).exists()); } #[test] @@ -1089,3 +1146,173 @@ fn bulk_destroy_requires_confirm() { s.destroy_notes(&ids, true).unwrap(); assert!(s.get_note(&a.id, false).is_err()); } + +// ---- title_is_auto (read by the vault serializer, design.md §3.2) ---- + +#[test] +fn title_is_auto_true_for_a_derived_title() { + let mut s = store(); + let n = create(&mut s, "Buy milk and coffee"); + assert!(s.title_is_auto(&n.id).unwrap()); +} + +#[test] +fn title_is_auto_false_once_a_title_is_set_explicitly() { + let mut s = store(); + let n = s + .create_note(CreateNoteInput { + title: Some("Groceries".into()), + body: Some("Buy milk".into()), + ..Default::default() + }) + .unwrap(); + assert!(!s.title_is_auto(&n.id).unwrap()); +} + +#[test] +fn title_is_auto_flips_false_after_an_explicit_title_update() { + let mut s = store(); + let n = create(&mut s, "Buy milk"); + assert!(s.title_is_auto(&n.id).unwrap()); + s.update_note( + &n.id, + UpdateNotePatch { + title: Some("Renamed".into()), + ..Default::default() + }, + ) + .unwrap(); + assert!(!s.title_is_auto(&n.id).unwrap()); +} + +#[test] +fn title_is_auto_missing_note_is_not_found() { + let s = store(); + assert!(matches!( + s.title_is_auto("missing"), + Err(AppError::NotFound(_)) + )); +} + +// ---- vault export against a real store (SEQUENCE.md unit 7's done +// condition: exporting the full library and re-parsing it reproduces every +// field of every note) ---- + +mod vault_export { + use super::*; + use instantnotes_core::vault::{collect_from_store, export_vault, parse_note, VaultNote}; + use std::collections::HashSet; + use std::fs; + + fn read_all_md(dir: &std::path::Path) -> Vec<String> { + let mut out = Vec::new(); + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + out.extend(read_all_md(&path)); + continue; + } + if path.extension().and_then(|e| e.to_str()) == Some("md") { + out.push(fs::read_to_string(&path).unwrap()); + } + } + out + } + + #[test] + fn round_trips_a_real_library_through_export() { + let mut s = store(); + + let explicit = s + .create_note(CreateNoteInput { + title: Some("Groceries".into()), + body: Some("Buy milk and #errands".into()), + ..Default::default() + }) + .unwrap(); + let engineering = s.get_or_create_workspace("Engineering").unwrap(); + s.add_note_to_workspace(&explicit.id, &engineering.id) + .unwrap(); + + let derived = create(&mut s, "Buy eggs\nand bacon"); + s.update_note( + &derived.id, + UpdateNotePatch { + is_pinned: Some(true), + ..Default::default() + }, + ) + .unwrap(); + + let archived = create(&mut s, "Archived thought"); + s.update_note( + &archived.id, + UpdateNotePatch { + is_archived: Some(true), + ..Default::default() + }, + ) + .unwrap(); + + let trashed = create(&mut s, "Old draft"); + s.soft_delete_note(&trashed.id).unwrap(); + + let (notes, manifest) = collect_from_store(&s).unwrap(); + assert_eq!(notes.len(), 4); + // The explicit-title note carries its inline tag and its Space + // membership: the one path this whole test exists to prove, since + // both live outside the `notes` table and are easy to leave out of + // `collect_from_store` without any other assertion noticing. + let explicit_collected = notes.iter().find(|n| n.id == explicit.id).unwrap(); + assert_eq!(explicit_collected.tags, vec!["errands".to_string()]); + assert_eq!(explicit_collected.spaces, vec!["Engineering".to_string()]); + + let dir = tempfile::tempdir().unwrap(); + export_vault(¬es, &manifest, dir.path()).unwrap(); + + let texts = read_all_md(dir.path()); + assert_eq!(texts.len(), 4); + + // SEQUENCE.md unit 7's done condition, verbatim: re-parsing the + // export reproduces every field of every note, not just the ones a + // spot-check happens to look at. + let collected_by_id: std::collections::HashMap<String, VaultNote> = + notes.into_iter().map(|n| (n.id.clone(), n)).collect(); + let mut seen_ids: HashSet<String> = HashSet::new(); + for text in &texts { + let parsed = parse_note(text).unwrap(); + let expected = collected_by_id.get(&parsed.id).unwrap_or_else(|| { + panic!( + "exported a file for an id not in collect_from_store's output: {}", + parsed.id + ) + }); + assert_eq!(&parsed, expected, "field mismatch for note {}", parsed.id); + seen_ids.insert(parsed.id); + } + assert_eq!( + seen_ids, + HashSet::from([ + explicit.id.clone(), + derived.id.clone(), + archived.id.clone(), + trashed.id.clone(), + ]) + ); + + assert!(manifest.spaces.iter().any(|sp| sp.name == "Engineering")); + } + + #[test] + fn collects_more_notes_than_a_single_list_notes_page() { + // list_notes defaults to a 500-row page; collect_from_store must + // page through, not silently truncate a library bigger than that. + let mut s = store(); + for i in 0..520 { + create(&mut s, &format!("note {i}")); + } + let (notes, _manifest) = collect_from_store(&s).unwrap(); + assert_eq!(notes.len(), 520); + } +} diff --git a/src-tauri/core/tests/vault_board_test.rs b/src-tauri/core/tests/vault_board_test.rs new file mode 100644 index 0000000..0cec1b0 --- /dev/null +++ b/src-tauri/core/tests/vault_board_test.rs @@ -0,0 +1,314 @@ +//! Whiteboards in the vault. A board's note file carries `kind: whiteboard` +//! and the text on the canvas; the canvas itself is a standard `.excalidraw` +//! file beside it with the same name, openable in Excalidraw. The sidecar +//! follows its note through renames, the trash, and deletes, under the same +//! ownership rules as the note files. + +use instantnotes_core::types::*; +use instantnotes_core::vault::{collect_from_store, export_vault, parse_note}; +use instantnotes_core::Store; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; +use tempfile::TempDir; + +fn mirrored() -> (Store, TempDir) { + let mut s = Store::open_in_memory().expect("open in-memory store"); + let dir = tempfile::tempdir().unwrap(); + s.configure_vault(Some(dir.path())).unwrap(); + (s, dir) +} + +fn flush(s: &mut Store) { + let out = s.flush_vault(10_000).expect("flush"); + assert!(out.errors.is_empty(), "flush errors: {:?}", out.errors); + assert_eq!(out.remaining, 0); +} + +fn envelope(elements: Value) -> String { + json!({ + "v": 1, + "engine": "excalidraw", + "data": { + "elements": elements, + "appState": { "viewBackgroundColor": "transparent" }, + "files": {} + } + }) + .to_string() +} + +fn rect(id: &str) -> Value { + json!({ "id": id, "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10 }) +} + +/// A whiteboard titled `title` holding one rectangle. +fn board(s: &mut Store, title: &str) -> Note { + let n = s + .create_note(CreateNoteInput { + title: Some(title.to_string()), + body: Some(String::new()), + ..Default::default() + }) + .unwrap(); + s.update_note( + &n.id, + UpdateNotePatch { + content_kind: Some(CONTENT_KIND_WHITEBOARD.into()), + surface_data: Some(envelope(json!([rect("r1")]))), + body: Some("words on the board".into()), + ..Default::default() + }, + ) + .unwrap() +} + +fn save_board(s: &mut Store, id: &str, elements: Value) { + s.update_note( + id, + UpdateNotePatch { + surface_data: Some(envelope(elements)), + ..Default::default() + }, + ) + .unwrap(); +} + +/// Visible filenames directly inside `dir`, sorted, skipping folders. +fn files(dir: &Path) -> Vec<String> { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut names: Vec<String> = entries + .map(|e| e.unwrap()) + .filter(|e| e.file_type().unwrap().is_file()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.starts_with('.') && n != "instantnotes.yaml") + .collect(); + names.sort(); + names +} + +fn read_json(path: &Path) -> Value { + serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap() +} + +#[test] +fn a_whiteboard_writes_its_canvas_beside_its_note() { + let (mut s, dir) = mirrored(); + let n = board(&mut s, "Plan"); + flush(&mut s); + + assert_eq!(files(dir.path()), vec!["Plan.excalidraw", "Plan.md"]); + let note = parse_note(&fs::read_to_string(dir.path().join("Plan.md")).unwrap()).unwrap(); + assert_eq!(note.id, n.id); + assert_eq!(note.kind, CONTENT_KIND_WHITEBOARD); + assert_eq!(note.body, "words on the board"); + + let canvas = read_json(&dir.path().join("Plan.excalidraw")); + assert_eq!(canvas["type"], "excalidraw"); + assert_eq!(canvas["version"], 2); + assert_eq!(canvas["elements"], json!([rect("r1")])); + assert_eq!(canvas["appState"]["viewBackgroundColor"], "transparent"); + assert_eq!(canvas["files"], json!({})); +} + +#[test] +fn a_document_gets_no_canvas_and_no_kind_line() { + let (mut s, dir) = mirrored(); + s.create_note(CreateNoteInput { + body: Some("Plain".into()), + ..Default::default() + }) + .unwrap(); + flush(&mut s); + assert_eq!(files(dir.path()), vec!["Plain.md"]); + let text = fs::read_to_string(dir.path().join("Plain.md")).unwrap(); + assert!(!text.contains("kind:"), "{text}"); +} + +#[test] +fn a_canvas_edit_rewrites_the_canvas_file() { + let (mut s, dir) = mirrored(); + let n = board(&mut s, "Plan"); + flush(&mut s); + save_board(&mut s, &n.id, json!([rect("r1"), rect("r2")])); + flush(&mut s); + let canvas = read_json(&dir.path().join("Plan.excalidraw")); + assert_eq!(canvas["elements"].as_array().unwrap().len(), 2); +} + +#[test] +fn renaming_a_board_moves_its_canvas_too() { + let (mut s, dir) = mirrored(); + let n = board(&mut s, "Plan"); + flush(&mut s); + s.update_note( + &n.id, + UpdateNotePatch { + title: Some("Roadmap".into()), + ..Default::default() + }, + ) + .unwrap(); + flush(&mut s); + assert_eq!(files(dir.path()), vec!["Roadmap.excalidraw", "Roadmap.md"]); +} + +#[test] +fn a_case_only_rename_keeps_one_canvas_under_the_new_name() { + let (mut s, dir) = mirrored(); + let n = board(&mut s, "plan"); + flush(&mut s); + s.update_note( + &n.id, + UpdateNotePatch { + title: Some("Plan".into()), + ..Default::default() + }, + ) + .unwrap(); + flush(&mut s); + assert_eq!(files(dir.path()), vec!["Plan.excalidraw", "Plan.md"]); +} + +#[test] +fn trash_restore_and_destroy_carry_the_canvas() { + let (mut s, dir) = mirrored(); + let n = board(&mut s, "Plan"); + flush(&mut s); + + s.soft_delete_note(&n.id).unwrap(); + flush(&mut s); + assert!(files(dir.path()).is_empty()); + assert_eq!( + files(&dir.path().join("trash")), + vec!["Plan.excalidraw", "Plan.md"] + ); + + s.restore_note(&n.id).unwrap(); + flush(&mut s); + assert_eq!(files(dir.path()), vec!["Plan.excalidraw", "Plan.md"]); + assert!(files(&dir.path().join("trash")).is_empty()); + + s.soft_delete_note(&n.id).unwrap(); + s.destroy_notes(std::slice::from_ref(&n.id), true).unwrap(); + flush(&mut s); + assert!(files(dir.path()).is_empty()); + assert!(files(&dir.path().join("trash")).is_empty()); +} + +#[test] +fn a_canvas_edited_outside_the_app_survives_a_delete() { + let (mut s, dir) = mirrored(); + let n = board(&mut s, "Plan"); + flush(&mut s); + fs::write(dir.path().join("Plan.excalidraw"), "{\"mine\": true}").unwrap(); + + s.soft_delete_note(&n.id).unwrap(); + s.destroy_notes(std::slice::from_ref(&n.id), true).unwrap(); + flush(&mut s); + + assert_eq!(files(dir.path()), vec!["Plan.excalidraw"]); + assert_eq!( + fs::read_to_string(dir.path().join("Plan.excalidraw")).unwrap(), + "{\"mine\": true}" + ); +} + +#[test] +fn a_foreign_canvas_at_the_name_is_never_overwritten() { + let (mut s, dir) = mirrored(); + fs::write(dir.path().join("Plan.excalidraw"), "someone else's drawing").unwrap(); + let n = board(&mut s, "Plan"); + flush(&mut s); + + assert_eq!( + fs::read_to_string(dir.path().join("Plan.excalidraw")).unwrap(), + "someone else's drawing" + ); + let id6 = &n.id[..6]; + let names = files(dir.path()); + assert!(names.contains(&format!("Plan-{id6}.md")), "{names:?}"); + assert!( + names.contains(&format!("Plan-{id6}.excalidraw")), + "{names:?}" + ); + assert!(!names.contains(&"Plan.md".to_string()), "{names:?}"); +} + +#[test] +fn stopping_and_restarting_on_the_same_folder_adopts_the_canvas() { + let (mut s, dir) = mirrored(); + board(&mut s, "Plan"); + flush(&mut s); + s.configure_vault(None).unwrap(); + s.configure_vault(Some(dir.path())).unwrap(); + flush(&mut s); + assert_eq!(files(dir.path()), vec!["Plan.excalidraw", "Plan.md"]); +} + +#[test] +fn verify_checks_the_canvas_too() { + let (mut s, dir) = mirrored(); + board(&mut s, "Plan"); + board(&mut s, "Map"); + flush(&mut s); + + let clean = s.verify_vault().unwrap(); + assert!(clean.missing.is_empty() && clean.diverged.is_empty() && clean.orphans.is_empty()); + assert_eq!(clean.checked, 2); + + fs::write(dir.path().join("Plan.excalidraw"), "{}").unwrap(); + fs::remove_file(dir.path().join("Map.excalidraw")).unwrap(); + fs::write(dir.path().join("Loose.excalidraw"), "{}").unwrap(); + let report = s.verify_vault().unwrap(); + assert_eq!(report.diverged, vec!["Plan.excalidraw"]); + assert_eq!(report.missing, vec!["Map.excalidraw"]); + assert_eq!(report.orphans, vec!["Loose.excalidraw"]); +} + +/// Formatting is not content: a canvas file reindented by another tool +/// still matches. +#[test] +fn verify_compares_the_canvas_by_content_not_bytes() { + let (mut s, dir) = mirrored(); + board(&mut s, "Plan"); + flush(&mut s); + let path = dir.path().join("Plan.excalidraw"); + let compact = serde_json::to_string(&read_json(&path)).unwrap(); + fs::write(&path, compact).unwrap(); + assert!(s.verify_vault().unwrap().diverged.is_empty()); +} + +#[test] +fn an_export_writes_the_canvas_too() { + let mut s = Store::open_in_memory().unwrap(); + board(&mut s, "Plan"); + let dest = tempfile::tempdir().unwrap(); + let (notes, manifest) = collect_from_store(&s).unwrap(); + export_vault(¬es, &manifest, dest.path()).unwrap(); + assert_eq!(files(dest.path()), vec!["Plan.excalidraw", "Plan.md"]); + let canvas = read_json(&dest.path().join("Plan.excalidraw")); + assert_eq!(canvas["elements"], json!([rect("r1")])); +} + +/// A board saved before the store ever wrote a canvas (or with an envelope +/// it does not recognize) still mirrors: its canvas file is an empty scene. +#[test] +fn an_unreadable_canvas_mirrors_as_an_empty_scene() { + let (mut s, dir) = mirrored(); + let n = board(&mut s, "Plan"); + s.update_note( + &n.id, + UpdateNotePatch { + surface_data: Some("not json".into()), + ..Default::default() + }, + ) + .unwrap(); + flush(&mut s); + let canvas = read_json(&dir.path().join("Plan.excalidraw")); + assert_eq!(canvas["type"], "excalidraw"); + assert_eq!(canvas["elements"], json!([])); +} diff --git a/src-tauri/core/tests/vault_mirror_test.rs b/src-tauri/core/tests/vault_mirror_test.rs new file mode 100644 index 0000000..ebdc4a5 --- /dev/null +++ b/src-tauri/core/tests/vault_mirror_test.rs @@ -0,0 +1,572 @@ +//! Stage 2 of the portable vault: the live mirror (dual-write). SQLite stays +//! authoritative; every note write is mirrored into a vault folder by +//! `flush_vault`. Real SQLite (in-memory or tempfile) and a real tempdir +//! vault throughout. + +use instantnotes_core::types::*; +use instantnotes_core::vault::{ + collect_from_store, export_vault, parse_manifest, parse_note, VaultNote, +}; +use instantnotes_core::Store; +use std::fs; +use std::path::Path; +use tempfile::TempDir; + +fn mirrored() -> (Store, TempDir) { + let mut s = Store::open_in_memory().expect("open in-memory store"); + let dir = tempfile::tempdir().unwrap(); + s.configure_vault(Some(dir.path())).unwrap(); + (s, dir) +} + +fn flush(s: &mut Store) { + let out = s.flush_vault(10_000).expect("flush"); + assert!(out.errors.is_empty(), "flush errors: {:?}", out.errors); + assert_eq!(out.remaining, 0); +} + +fn create(s: &mut Store, body: &str) -> Note { + s.create_note(CreateNoteInput { + body: Some(body.to_string()), + ..Default::default() + }) + .unwrap() +} + +fn create_titled(s: &mut Store, title: &str, body: &str) -> Note { + s.create_note(CreateNoteInput { + title: Some(title.to_string()), + body: Some(body.to_string()), + ..Default::default() + }) + .unwrap() +} + +fn retitle(s: &mut Store, id: &str, title: &str) { + s.update_note( + id, + UpdateNotePatch { + title: Some(title.to_string()), + ..Default::default() + }, + ) + .unwrap(); +} + +/// Visible `.md` filenames directly inside `dir`, sorted. +fn md_files(dir: &Path) -> Vec<String> { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut names: Vec<String> = entries + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".md") && !n.starts_with('.')) + .collect(); + names.sort(); + names +} + +fn read_note(path: &Path) -> VaultNote { + parse_note(&fs::read_to_string(path).unwrap()).unwrap() +} + +fn pending(s: &Store) -> i64 { + s.vault_status().unwrap().pending +} + +// ---- writing and dirty tracking ---- + +#[test] +fn flush_writes_a_created_note_that_parses_back_to_the_note() { + let (mut s, dir) = mirrored(); + let n = create_titled(&mut s, "Plan", "hello #idea"); + flush(&mut s); + assert_eq!(md_files(dir.path()), vec!["Plan.md"]); + assert_eq!( + read_note(&dir.path().join("Plan.md")), + s.vault_note(&n.id).unwrap() + ); +} + +#[test] +fn every_note_write_leaves_the_note_pending_until_flushed() { + let (mut s, _dir) = mirrored(); + let n = create(&mut s, "first"); + assert_eq!(pending(&s), 1); + flush(&mut s); + assert_eq!(pending(&s), 0); + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("second".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(pending(&s), 1); +} + +/// design.md §7.2: `last_opened_at` is device-local and never written to the +/// vault, so opening a note must not dirty its file. +#[test] +fn opening_a_note_does_not_dirty_the_vault() { + let (mut s, _dir) = mirrored(); + let n = create(&mut s, "read me"); + flush(&mut s); + s.get_note(&n.id, true).unwrap(); + assert_eq!(pending(&s), 0); +} + +#[test] +fn flush_in_chunks_reports_what_remains() { + let (mut s, _dir) = mirrored(); + for i in 0..5 { + create(&mut s, &format!("note {i}")); + } + let out = s.flush_vault(2).unwrap(); + assert_eq!(out.written, 2); + assert_eq!(out.remaining, 3); + flush(&mut s); +} + +// ---- filenames and moves ---- + +#[test] +fn an_explicit_retitle_moves_the_file() { + let (mut s, dir) = mirrored(); + let n = create_titled(&mut s, "Old", "body"); + flush(&mut s); + retitle(&mut s, &n.id, "New"); + flush(&mut s); + assert_eq!(md_files(dir.path()), vec!["New.md"]); + assert_eq!(read_note(&dir.path().join("New.md")).id, n.id); +} + +/// On a case-insensitive filesystem (macOS default) `notes.md` and +/// `Notes.md` are one file: removing the "old" path after writing the new +/// one would delete the note's only file. +#[test] +fn a_case_only_retitle_leaves_one_file_under_the_new_name() { + let (mut s, dir) = mirrored(); + let n = create_titled(&mut s, "notes", "body"); + flush(&mut s); + retitle(&mut s, &n.id, "Notes"); + flush(&mut s); + assert_eq!(md_files(dir.path()), vec!["Notes.md"]); + assert_eq!( + read_note(&dir.path().join("Notes.md")).title.unwrap(), + "Notes" + ); +} + +#[test] +fn titles_differing_only_in_case_get_separate_files() { + let (mut s, dir) = mirrored(); + create_titled(&mut s, "Notes", "upper"); + create_titled(&mut s, "notes", "lower"); + flush(&mut s); + let files = md_files(dir.path()); + assert_eq!(files.len(), 2, "one note overwrote the other: {files:?}"); +} + +#[test] +fn same_titles_get_separate_files() { + let (mut s, dir) = mirrored(); + create_titled(&mut s, "Standup", "one"); + create_titled(&mut s, "Standup", "two"); + flush(&mut s); + assert_eq!(md_files(dir.path()).len(), 2); +} + +#[test] +fn soft_delete_moves_the_file_to_trash_and_restore_moves_it_back() { + let (mut s, dir) = mirrored(); + let n = create_titled(&mut s, "Draft", "body"); + flush(&mut s); + + s.soft_delete_note(&n.id).unwrap(); + flush(&mut s); + assert!(md_files(dir.path()).is_empty()); + assert_eq!(md_files(&dir.path().join("trash")), vec!["Draft.md"]); + assert!(read_note(&dir.path().join("trash/Draft.md")) + .deleted_at + .is_some()); + + s.restore_note(&n.id).unwrap(); + flush(&mut s); + assert_eq!(md_files(dir.path()), vec!["Draft.md"]); + assert!(md_files(&dir.path().join("trash")).is_empty()); +} + +#[test] +fn bulk_trash_and_restore_move_every_file() { + let (mut s, dir) = mirrored(); + let ids: Vec<String> = ["A", "B"] + .iter() + .map(|t| create_titled(&mut s, t, "x").id) + .collect(); + flush(&mut s); + s.soft_delete_notes(&ids).unwrap(); + flush(&mut s); + assert_eq!(md_files(&dir.path().join("trash")), vec!["A.md", "B.md"]); + s.restore_notes(&ids).unwrap(); + flush(&mut s); + assert_eq!(md_files(dir.path()), vec!["A.md", "B.md"]); +} + +#[test] +fn permanent_deletes_remove_the_files() { + let (mut s, dir) = mirrored(); + let a = create_titled(&mut s, "A", "x"); + let b = create_titled(&mut s, "B", "x"); + let c = create_titled(&mut s, "C", "x"); + flush(&mut s); + s.permanently_delete_note(&a.id, true).unwrap(); + s.destroy_notes(&[b.id.clone(), c.id.clone()], true) + .unwrap(); + let out = s.flush_vault(10_000).unwrap(); + assert_eq!(out.removed, 3); + assert!(md_files(dir.path()).is_empty()); +} + +/// A file the user edited after we wrote it is theirs now: destroying the +/// note must not take their edit with it. +#[test] +fn a_permanent_delete_leaves_a_file_edited_outside_the_app() { + let (mut s, dir) = mirrored(); + let a = create_titled(&mut s, "A", "x"); + flush(&mut s); + fs::write(dir.path().join("A.md"), "edited by hand").unwrap(); + s.permanently_delete_note(&a.id, true).unwrap(); + flush(&mut s); + assert_eq!( + fs::read_to_string(dir.path().join("A.md")).unwrap(), + "edited by hand" + ); +} + +// ---- tags and spaces ---- + +#[test] +fn tag_changes_rewrite_the_frontmatter_of_every_carrying_note() { + let (mut s, dir) = mirrored(); + let n = create_titled(&mut s, "Plan", "body"); + let file = dir.path().join("Plan.md"); + + let tag = s.add_tag_to_note(&n.id, "work").unwrap(); + flush(&mut s); + assert_eq!(read_note(&file).tags, vec!["work"]); + + s.update_tag(&tag.id, Some("job".into()), None).unwrap(); + flush(&mut s); + assert_eq!(read_note(&file).tags, vec!["job"]); + + s.remove_tag_from_note(&n.id, &tag.id).unwrap(); + flush(&mut s); + assert!(read_note(&file).tags.is_empty()); + + // Deleting a tag reaches its notes only through the note_tags cascade. + let tag = s.add_tag_to_note(&n.id, "gone").unwrap(); + flush(&mut s); + s.delete_tag(&tag.id).unwrap(); + assert_eq!(pending(&s), 1); + flush(&mut s); + assert!(read_note(&file).tags.is_empty()); +} + +#[test] +fn a_tag_color_change_touches_only_the_manifest() { + let (mut s, dir) = mirrored(); + let n = create_titled(&mut s, "Plan", "body"); + let tag = s.add_tag_to_note(&n.id, "work").unwrap(); + flush(&mut s); + s.update_tag(&tag.id, None, Some("#ff0000".into())).unwrap(); + assert_eq!(pending(&s), 0, "a color change must not dirty notes"); + flush(&mut s); + let manifest = + parse_manifest(&fs::read_to_string(dir.path().join("instantnotes.yaml")).unwrap()).unwrap(); + assert_eq!(manifest.tags["work"].color.as_deref(), Some("#ff0000")); +} + +#[test] +fn space_changes_rewrite_the_frontmatter_of_every_member_note() { + let (mut s, dir) = mirrored(); + let n = create_titled(&mut s, "Plan", "body"); + let file = dir.path().join("Plan.md"); + let ws = s.get_or_create_workspace("Engineering").unwrap(); + + s.add_note_to_workspace(&n.id, &ws.id).unwrap(); + flush(&mut s); + assert_eq!(read_note(&file).spaces, vec!["Engineering"]); + + s.rename_workspace(&ws.id, "Platform").unwrap(); + flush(&mut s); + assert_eq!(read_note(&file).spaces, vec!["Platform"]); + + s.remove_note_from_workspace(&n.id, &ws.id).unwrap(); + flush(&mut s); + assert!(read_note(&file).spaces.is_empty()); + + s.add_note_to_workspace(&n.id, &ws.id).unwrap(); + flush(&mut s); + s.delete_workspace(&ws.id).unwrap(); + assert_eq!(pending(&s), 1); + flush(&mut s); + assert!(read_note(&file).spaces.is_empty()); +} + +// ---- files we don't own ---- + +#[test] +fn a_foreign_file_at_the_target_name_is_never_overwritten() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Plan.md"), "the user's own file").unwrap(); + let mut s = Store::open_in_memory().unwrap(); + s.configure_vault(Some(dir.path())).unwrap(); + + let n = create_titled(&mut s, "Plan", "body"); + flush(&mut s); + assert_eq!( + fs::read_to_string(dir.path().join("Plan.md")).unwrap(), + "the user's own file" + ); + let short: String = n.id.chars().take(6).collect(); + assert_eq!( + read_note(&dir.path().join(format!("Plan-{short}.md"))).id, + n.id + ); +} + +/// A folder that already holds a stage 1 export of the same library: the +/// files carry the same ids, so they are ours and get adopted in place +/// rather than duplicated under suffixed names. +#[test] +fn an_earlier_export_of_the_same_library_is_adopted_in_place() { + let dir = tempfile::tempdir().unwrap(); + let mut s = Store::open_in_memory().unwrap(); + create_titled(&mut s, "Plan", "body"); + let t = create_titled(&mut s, "Old", "x"); + s.soft_delete_note(&t.id).unwrap(); + let (notes, manifest) = collect_from_store(&s).unwrap(); + export_vault(¬es, &manifest, dir.path()).unwrap(); + + s.configure_vault(Some(dir.path())).unwrap(); + flush(&mut s); + assert_eq!(md_files(dir.path()), vec!["Plan.md"]); + assert_eq!(md_files(&dir.path().join("trash")), vec!["Old.md"]); +} + +// ---- lifecycle ---- + +#[test] +fn pending_writes_survive_a_restart() { + let db_dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let db = db_dir.path().join("notes.db"); + { + let mut s = Store::open(&db).unwrap(); + s.configure_vault(Some(vault.path())).unwrap(); + create_titled(&mut s, "Unflushed", "typed right before a crash"); + } + let mut s = Store::open(&db).unwrap(); + s.attach_saved_vault().unwrap(); + assert_eq!(pending(&s), 1); + flush(&mut s); + assert_eq!(md_files(vault.path()), vec!["Unflushed.md"]); +} + +/// An unmounted drive must not be recreated as an empty folder on the boot +/// disk, and nothing may be lost while it is away. +#[test] +fn a_missing_vault_folder_pauses_the_mirror_without_losing_work() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("vault"); + fs::create_dir(&root).unwrap(); + let mut s = Store::open_in_memory().unwrap(); + s.configure_vault(Some(&root)).unwrap(); + fs::remove_dir(&root).unwrap(); + + create_titled(&mut s, "Plan", "body"); + assert!(s.flush_vault(10_000).is_err()); + assert!(!root.exists(), "the vault root was recreated"); + let status = s.vault_status().unwrap(); + assert_eq!(status.pending, 1); + assert!(status.last_error.is_some()); + + fs::create_dir(&root).unwrap(); + flush(&mut s); + assert_eq!(md_files(&root), vec!["Plan.md"]); + assert!(s.vault_status().unwrap().last_error.is_none()); +} + +#[test] +fn configure_rejects_a_folder_that_does_not_exist() { + let parent = tempfile::tempdir().unwrap(); + let mut s = Store::open_in_memory().unwrap(); + assert!(s + .configure_vault(Some(&parent.path().join("missing"))) + .is_err()); + assert!(s.vault_status().unwrap().path.is_none()); +} + +#[test] +fn switching_folders_writes_everything_into_the_new_one() { + let (mut s, first) = mirrored(); + create_titled(&mut s, "A", "x"); + create_titled(&mut s, "B", "x"); + flush(&mut s); + + let second = tempfile::tempdir().unwrap(); + s.configure_vault(Some(second.path())).unwrap(); + flush(&mut s); + assert_eq!(md_files(second.path()), vec!["A.md", "B.md"]); + // The old folder is left exactly as it was. + assert_eq!(md_files(first.path()), vec!["A.md", "B.md"]); +} + +#[test] +fn stopping_the_mirror_leaves_the_files_and_writes_nothing_more() { + let (mut s, dir) = mirrored(); + let n = create_titled(&mut s, "A", "x"); + flush(&mut s); + s.configure_vault(None).unwrap(); + retitle(&mut s, &n.id, "B"); + s.flush_vault(10_000).unwrap(); + assert_eq!(md_files(dir.path()), vec!["A.md"]); + assert!(s.vault_status().unwrap().path.is_none()); +} + +// ---- verify ---- + +/// The stage 2 proof: a library covering every field mirrors to a vault +/// whose every file parses back to exactly its note. +#[test] +fn a_whole_library_mirrors_and_verifies_clean() { + let (mut s, dir) = mirrored(); + let ws = s.get_or_create_workspace("Engineering").unwrap(); + let a = create_titled(&mut s, "Consensus", "Paxos and Raft #consensus"); + s.add_note_to_workspace(&a.id, &ws.id).unwrap(); + s.add_tag_to_note(&a.id, "reading").unwrap(); + let b = create(&mut s, "auto titled\nsecond line"); + s.set_notes_flags(std::slice::from_ref(&b.id), Some(true), None) + .unwrap(); + let c = create_titled(&mut s, "Consensus", "same title, other note"); + s.set_notes_flags(std::slice::from_ref(&c.id), None, Some(true)) + .unwrap(); + let d = create_titled(&mut s, "Q1/Q2: plan?", "illegal filename characters"); + s.soft_delete_note(&d.id).unwrap(); + s.get_or_create_workspace("Empty space").unwrap(); + flush(&mut s); + + let report = s.verify_vault().unwrap(); + assert_eq!(report.checked, 4); + assert!(report.missing.is_empty(), "{report:?}"); + assert!(report.diverged.is_empty(), "{report:?}"); + assert!(report.orphans.is_empty(), "{report:?}"); + assert_eq!(report.pending, 0); + assert!(report.manifest_ok); + + let (expected, _) = collect_from_store(&s).unwrap(); + let mut found = Vec::new(); + for sub in ["", "trash"] { + for name in md_files(&dir.path().join(sub)) { + found.push(read_note(&dir.path().join(sub).join(name))); + } + } + found.sort_by(|x, y| x.id.cmp(&y.id)); + let mut expected = expected; + expected.sort_by(|x, y| x.id.cmp(&y.id)); + assert_eq!(found, expected); +} + +#[test] +fn verify_reports_missing_diverged_and_orphan_files() { + let (mut s, dir) = mirrored(); + create_titled(&mut s, "Kept", "x"); + create_titled(&mut s, "Deleted by hand", "x"); + create_titled(&mut s, "Edited by hand", "x"); + flush(&mut s); + + fs::remove_file(dir.path().join("Deleted by hand.md")).unwrap(); + let edited = dir.path().join("Edited by hand.md"); + let text = fs::read_to_string(&edited).unwrap(); + fs::write(&edited, text.replace("\nx", "\nchanged")).unwrap(); + fs::write(dir.path().join("Stray.md"), "not a note").unwrap(); + + let report = s.verify_vault().unwrap(); + assert_eq!(report.missing, vec!["Deleted by hand.md"]); + assert_eq!(report.diverged, vec!["Edited by hand.md"]); + assert_eq!(report.orphans, vec!["Stray.md"]); +} + +/// The stage 2 proof on a real library. Run by hand against a COPY (opening +/// migrates the file, so never point this at a live library): +/// +/// ```sh +/// sqlite3 -readonly <library.db> ".backup /tmp/copy.db" +/// INSTANTNOTES_VAULT_TEST_DB=/tmp/copy.db \ +/// cargo test -p instantnotes-core --test vault_mirror_test -- --ignored --nocapture +/// ``` +#[test] +#[ignore = "needs INSTANTNOTES_VAULT_TEST_DB pointing at a copy of a real library"] +fn a_real_library_copy_mirrors_and_verifies_clean() { + use std::time::Instant; + + let db = std::env::var_os("INSTANTNOTES_VAULT_TEST_DB") + .expect("set INSTANTNOTES_VAULT_TEST_DB to a copy of a library"); + let mut s = Store::open(Path::new(&db)).unwrap(); + let vault = tempfile::tempdir().unwrap(); + s.configure_vault(Some(vault.path())).unwrap(); + let total = pending(&s); + + let started = Instant::now(); + loop { + let out = s.flush_vault(200).unwrap(); + assert!(out.errors.is_empty(), "flush errors: {:?}", out.errors); + if out.remaining == 0 { + break; + } + } + let first_mirror = started.elapsed(); + + let report = s.verify_vault().unwrap(); + assert_eq!(report.checked as i64, total); + assert!(report.missing.is_empty(), "{report:?}"); + assert!(report.diverged.is_empty(), "{report:?}"); + assert!(report.orphans.is_empty(), "{report:?}"); + assert!(report.manifest_ok); + + let (mut expected, _) = collect_from_store(&s).unwrap(); + let mut found = Vec::new(); + for sub in ["", "trash"] { + for name in md_files(&vault.path().join(sub)) { + found.push(read_note(&vault.path().join(sub).join(name))); + } + } + found.sort_by(|x, y| x.id.cmp(&y.id)); + expected.sort_by(|x, y| x.id.cmp(&y.id)); + assert_eq!(found, expected); + + // One note edited and flushed, the autosave path. + let id = expected[0].id.clone(); + let mut single = Vec::new(); + for i in 0..20 { + s.update_note( + &id, + UpdateNotePatch { + body: Some(format!("{}\n{i}", expected[0].body)), + ..Default::default() + }, + ) + .unwrap(); + let t = Instant::now(); + flush(&mut s); + single.push(t.elapsed()); + } + single.sort(); + eprintln!( + "{total} notes: first mirror {first_mirror:?}; single-note flush median {:?}, max {:?}", + single[single.len() / 2], + single[single.len() - 1] + ); +} diff --git a/src-tauri/core/tests/whiteboard_test.rs b/src-tauri/core/tests/whiteboard_test.rs new file mode 100644 index 0000000..df01cac --- /dev/null +++ b/src-tauri/core/tests/whiteboard_test.rs @@ -0,0 +1,190 @@ +//! Whiteboard notes in the store: a note whose `content_kind` is +//! `whiteboard` keeps its canvas in `surface_data` and the text written on +//! the canvas in `body`, so search and inline tags keep working. + +use instantnotes_core::types::*; +use instantnotes_core::Store; + +const BOARD: &str = + r#"{"v":1,"engine":"excalidraw","data":{"elements":[],"appState":{},"files":{}}}"#; + +fn store() -> Store { + Store::open_in_memory().expect("open in-memory store") +} + +fn create(store: &mut Store, body: &str) -> Note { + store + .create_note(CreateNoteInput { + body: Some(body.to_string()), + ..Default::default() + }) + .expect("create note") +} + +fn convert(store: &mut Store, id: &str) -> Note { + store + .update_note( + id, + UpdateNotePatch { + content_kind: Some(CONTENT_KIND_WHITEBOARD.into()), + surface_data: Some(BOARD.into()), + ..Default::default() + }, + ) + .expect("convert to whiteboard") +} + +#[test] +fn new_notes_are_documents_without_a_board() { + let mut s = store(); + let n = create(&mut s, "plain"); + assert_eq!(n.content_kind, CONTENT_KIND_DOCUMENT); + assert!(n.surface_data.is_none()); +} + +#[test] +fn converting_sets_the_kind_and_board_and_keeps_the_body() { + let mut s = store(); + let n = create(&mut s, "planning notes"); + let wb = convert(&mut s, &n.id); + assert_eq!(wb.content_kind, CONTENT_KIND_WHITEBOARD); + assert_eq!(wb.surface_data.as_deref(), Some(BOARD)); + assert_eq!(wb.body, "planning notes"); +} + +/// A whiteboard's body is the text on its canvas, rewritten on every save; +/// a title that followed it would jump around as shapes move. +#[test] +fn converting_freezes_an_auto_title() { + let mut s = store(); + let n = create(&mut s, "Roadmap\nsecond line"); + convert(&mut s, &n.id); + let after = s + .update_note( + &n.id, + UpdateNotePatch { + body: Some("Some sticky note\nRoadmap".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(after.title, "Roadmap"); + assert!(!s.title_is_auto(&n.id).unwrap()); +} + +#[test] +fn a_board_save_updates_body_and_board_together() { + let mut s = store(); + let n = create(&mut s, "x"); + convert(&mut s, &n.id); + let next = BOARD.replace("\"elements\":[]", "\"elements\":[{\"id\":\"a\"}]"); + let saved = s + .update_note( + &n.id, + UpdateNotePatch { + body: Some("text on the board #ideas".into()), + surface_data: Some(next.clone()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(saved.surface_data.as_deref(), Some(next.as_str())); + assert_eq!(saved.body, "text on the board #ideas"); + let tags: Vec<String> = s + .tags_for_note(&n.id) + .unwrap() + .into_iter() + .map(|t| t.name) + .collect(); + assert_eq!(tags, vec!["ideas"]); +} + +#[test] +fn a_whiteboard_cannot_become_a_document_again() { + let mut s = store(); + let n = create(&mut s, "x"); + convert(&mut s, &n.id); + let err = s + .update_note( + &n.id, + UpdateNotePatch { + content_kind: Some(CONTENT_KIND_DOCUMENT.into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(err.code(), "VALIDATION_ERROR"); +} + +#[test] +fn a_document_cannot_hold_a_board() { + let mut s = store(); + let n = create(&mut s, "x"); + let err = s + .update_note( + &n.id, + UpdateNotePatch { + surface_data: Some(BOARD.into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(err.code(), "VALIDATION_ERROR"); + assert!(s.get_note(&n.id, false).unwrap().surface_data.is_none()); +} + +#[test] +fn an_unknown_kind_is_rejected() { + let mut s = store(); + let n = create(&mut s, "x"); + let err = s + .update_note( + &n.id, + UpdateNotePatch { + content_kind: Some("canvas".into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(err.code(), "VALIDATION_ERROR"); +} + +/// Boards can carry pasted images, so list rows, which every refresh ships +/// to the UI, leave the board out; opening the note brings it. +#[test] +fn list_rows_carry_the_kind_but_not_the_board() { + let mut s = store(); + let n = create(&mut s, "x"); + convert(&mut s, &n.id); + let row = s + .list_notes(Default::default()) + .unwrap() + .into_iter() + .find(|r| r.id == n.id) + .unwrap(); + assert_eq!(row.content_kind, CONTENT_KIND_WHITEBOARD); + assert!(row.surface_data.is_none()); + assert_eq!( + s.get_note(&n.id, false).unwrap().surface_data.as_deref(), + Some(BOARD) + ); +} + +#[test] +fn text_on_a_board_is_searchable() { + let mut s = store(); + let n = create(&mut s, "x"); + convert(&mut s, &n.id); + s.update_note( + &n.id, + UpdateNotePatch { + body: Some("quarterly flywheel".into()), + surface_data: Some(BOARD.into()), + ..Default::default() + }, + ) + .unwrap(); + let hits = s.search_notes("flywheel", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].note_id, n.id); +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index f3e59b2..9832af1 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 152d36d..9bca915 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 19d00dc..4511819 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index a77655d..b77c7c9 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 231032d..abdced8 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index dfa3d54..3fd194b 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index a347c9a..e67142b 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index e748d66..f61cb62 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 4795851..72dcd72 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 50c0d6e..7f83880 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index d4a84a8..b4ebb40 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 1f4c6ad..e84f491 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 70b7a31..b458ed0 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 0650a66..8309665 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 652f966..3fb30a0 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 06f151d..2a54652 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg index c390e10..b2779e1 100644 --- a/src-tauri/icons/icon.svg +++ b/src-tauri/icons/icon.svg @@ -1,58 +1,33 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="InstantNotes app icon"> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="InstantNotes icon"> <title>InstantNotes - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + - \ No newline at end of file + diff --git a/src-tauri/icons/tray.png b/src-tauri/icons/tray.png index 1bb2faf..add37e8 100644 Binary files a/src-tauri/icons/tray.png and b/src-tauri/icons/tray.png differ diff --git a/src-tauri/installer/agreements.conformance.json b/src-tauri/installer/agreements.conformance.json new file mode 100644 index 0000000..5237f03 --- /dev/null +++ b/src-tauri/installer/agreements.conformance.json @@ -0,0 +1,60 @@ +{ + "about": "The agreement gate's rules as cases (README, \"The agreement gate\"). Every platform's gate runs these against its own implementation: stored is what the device holds under the manifest's storageKey, accept is agreed to in order, and pending and storedAfter are what must follow.", + "documents": [ + { "id": "license", "version": "PolyForm-Strict-1.0.0" }, + { "id": "eula", "version": "2026-09-30" } + ], + "cases": [ + { + "name": "a first launch asks for every document, in manifest order", + "stored": {}, + "pending": ["license", "eula"] + }, + { + "name": "each document needs its own agreement", + "stored": { "license": "PolyForm-Strict-1.0.0" }, + "pending": ["eula"] + }, + { + "name": "agreeing to both opens the app", + "stored": { "license": "PolyForm-Strict-1.0.0", "eula": "2026-09-30" }, + "pending": [] + }, + { + "name": "a new version asks again for that document only", + "stored": { "license": "PolyForm-Strict-1.0.0", "eula": "2026-01-01" }, + "pending": ["eula"] + }, + { + "name": "unreadable storage counts as nothing agreed", + "stored": "accepted", + "pending": ["license", "eula"] + }, + { + "name": "a stored value that is not a version string does not count", + "stored": { "license": true, "eula": "2026-09-30" }, + "pending": ["license"] + }, + { + "name": "agreeing records the version that was shown", + "stored": {}, + "accept": ["eula"], + "pending": ["license"], + "storedAfter": { "eula": "2026-09-30" } + }, + { + "name": "agreeing to a document the manifest does not list changes nothing", + "stored": { "license": "PolyForm-Strict-1.0.0" }, + "accept": ["privacy"], + "pending": ["eula"], + "storedAfter": { "license": "PolyForm-Strict-1.0.0" } + }, + { + "name": "agreements to documents no longer listed are dropped on the next write", + "stored": { "terms": "2025-01-01" }, + "accept": ["license", "eula"], + "pending": [], + "storedAfter": { "license": "PolyForm-Strict-1.0.0", "eula": "2026-09-30" } + } + ] +} diff --git a/src-tauri/installer/agreements.json b/src-tauri/installer/agreements.json new file mode 100644 index 0000000..53ffeb4 --- /dev/null +++ b/src-tauri/installer/agreements.json @@ -0,0 +1,25 @@ +{ + "app": "InstantNotes", + "storageKey": "jam-sw.agreements", + "documents": [ + { + "id": "license", + "title": "PolyForm Strict License 1.0.0", + "version": "PolyForm-Strict-1.0.0", + "text": "Required Notice: Copyright Jam-Sw (https://github.com/Jam-Sw)\n\nRequired Notice: Uses not permitted below need a commercial license from\nJam-Sw. Contact jam.sw.org@gmail.com.\n\n# PolyForm Strict License 1.0.0\n\n\n\n## Acceptance\n\nIn order to get any license under these terms, you must agree\nto them as both strict obligations and conditions to all\nyour licenses.\n\n## Copyright License\n\nThe licensor grants you a copyright license for the software\nto do everything you might do with the software that would\notherwise infringe the licensor's copyright in it for any\npermitted purpose, other than distributing the software or\nmaking changes or new works based on the software.\n\n## Patent License\n\nThe licensor grants you a patent license for the software that\ncovers patent claims the licensor can license, or becomes able\nto license, that you would infringe by using the software.\n\n## Noncommercial Purposes\n\nAny noncommercial purpose is a permitted purpose.\n\n## Personal Uses\n\nPersonal use for research, experiment, and testing for\nthe benefit of public knowledge, personal study, private\nentertainment, hobby projects, amateur pursuits, or religious\nobservance, without any anticipated commercial application,\nis use for a permitted purpose.\n\n## Noncommercial Organizations\n\nUse by any charitable organization, educational institution,\npublic research organization, public safety or health\norganization, environmental protection organization,\nor government institution is use for a permitted purpose\nregardless of the source of funding or obligations resulting\nfrom the funding.\n\n## Fair Use\n\nYou may have \"fair use\" rights for the software under the\nlaw. These terms do not limit them.\n\n## No Other Rights\n\nThese terms do not allow you to sublicense or transfer any of\nyour licenses to anyone else, or prevent the licensor from\ngranting licenses to anyone else. These terms do not imply\nany other licenses.\n\n## Patent Defense\n\nIf you make any written claim that the software infringes or\ncontributes to infringement of any patent, your patent license\nfor the software granted under these terms ends immediately. If\nyour company makes such a claim, your patent license ends\nimmediately for work on behalf of your company.\n\n## Violations\n\nThe first time you are notified in writing that you have\nviolated any of these terms, or done anything with the software\nnot covered by your licenses, your licenses can nonetheless\ncontinue if you come into full compliance with these terms,\nand take practical steps to correct past violations, within\n32 days of receiving notice. Otherwise, all your licenses\nend immediately.\n\n## No Liability\n\n***As far as the law allows, the software comes as is, without\nany warranty or condition, and the licensor will not be liable\nto you for any damages arising out of these terms or the use\nor nature of the software, under any kind of legal claim.***\n\n## Definitions\n\nThe **licensor** is the individual or entity offering these\nterms, and the **software** is the software the licensor makes\navailable under these terms.\n\n**You** refers to the individual or entity agreeing to these\nterms.\n\n**Your company** is any legal entity, sole proprietorship,\nor other kind of organization that you work for, plus all\norganizations that have control over, are under the control of,\nor are under common control with that organization. **Control**\nmeans ownership of substantially all the assets of an entity,\nor the power to direct its management and policies by vote,\ncontract, or otherwise. Control can be direct or indirect.\n\n**Your licenses** are all the licenses granted to you for the\nsoftware under these terms.\n\n**Use** means anything you do with the software requiring one\nof your licenses.\n" + }, + { + "id": "eula", + "title": "InstantNotes End User License Agreement", + "version": "2026-09-30", + "text": "InstantNotes END USER LICENSE AGREEMENT\nJam-Sw. EULA version 2026-09-30.\n\nRead this agreement before installing or using InstantNotes (the \"Software\").\nBy installing, copying, or using the Software you agree to these terms. If you\ndo not agree, do not install or use the Software.\n\n1. WHO WE ARE\n The Software is developed and distributed by Jam-Sw (\"we\", \"us\").\n Contact: jam.sw.org@gmail.com\n\n2. FREE LICENSE\n We grant you a non-exclusive, non-transferable license to install and use\n the Software on devices you own or control, at no charge, for these uses:\n a. personal, noncommercial use; and\n b. use by charities, schools, and public research or government bodies.\n Use by or for a business, for paid work, or to make money is not free.\n\n3. COMMERCIAL LICENSE\n Any other use needs a commercial license. You get one by buying it from\n Jam-Sw or by written agreement with us. A commercial license covers the\n number of users or devices stated at purchase and is otherwise subject to\n this agreement. If you have been using the Software without the license\n your use needs, you have 30 days after we notify you to buy one or stop.\n\n4. WHAT YOU MAY NOT DO\n Unless a written agreement with Jam-Sw says otherwise, you may not:\n a. sell, rent, lease, or sublicense the Software;\n b. redistribute the Software, or a modified version, for commercial\n purposes;\n c. remove or alter any copyright, trademark, or Required Notice;\n d. use the Jam-Sw name, logos, or the Software's name to endorse or\n promote a product or service.\n\n5. SOURCE CODE\n The source code is published under the PolyForm Strict License 1.0.0:\n https://polyformproject.org/licenses/strict/1.0.0\n That license governs the source; this agreement governs the installed\n Software. Nothing here narrows the rights that license grants you.\n\n6. YOUR DATA\n The Software stores your data on your device. We do not collect it. The\n Software contacts our servers only to check for updates, and the only\n information sent is what an update check needs (app version and platform).\n Third-party components named in the Software's notices may send anonymous\n usage counts to their makers; they never send your content.\n\n7. OWNERSHIP\n The Software is licensed, not sold. Jam-Sw keeps all rights, title, and\n interest in it, including all intellectual property rights.\n\n8. UPDATES\n The Software may check for and install updates. Updates are covered by this\n agreement unless they come with their own terms.\n\n9. THIRD-PARTY COMPONENTS\n The Software includes open source components under their own licenses.\n Their notices ship with the Software, and those licenses govern those\n components.\n\n10. NO WARRANTY\n THE SOFTWARE IS PROVIDED \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR\n PURPOSE, AND NON-INFRINGEMENT. YOU USE IT AT YOUR OWN RISK.\n\n11. LIMITATION OF LIABILITY\n TO THE FULLEST EXTENT PERMITTED BY LAW, JAM-SW WILL NOT BE LIABLE FOR ANY\n INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY\n LOSS OF DATA, PROFITS, OR BUSINESS, ARISING FROM THE SOFTWARE OR THIS\n AGREEMENT. IF WE ARE LIABLE FOR ANYTHING, OUR TOTAL LIABILITY IS LIMITED\n TO THE AMOUNT YOU PAID US FOR THE SOFTWARE IN THE 12 MONTHS BEFORE THE\n CLAIM.\n\n12. TERMINATION\n This license ends automatically if you breach it. When it ends you must\n stop using the Software and delete all copies. Sections 7, 10, 11, and 13\n survive termination.\n\n13. GENERAL\n This agreement, plus any commercial license you buy, is the entire\n agreement between you and Jam-Sw about the Software. If any part is\n unenforceable, the rest stays in effect. Not enforcing a term is not a\n waiver of it. This agreement is governed by the laws of the Province of Ontario, Canada,\n without regard to conflict of law rules. Consumer protection laws that\n apply to you still apply.\n\nCopyright Jam-Sw. All rights reserved except as expressly granted above.\n" + } + ], + "copy": { + "heading": "Before you start", + "lead": "Read each document and agree to it to use InstantNotes.", + "agree": "I agree", + "agreed": "Agreed", + "decline": "Decline" + } +} diff --git a/src-tauri/installer/dmg-background.png b/src-tauri/installer/dmg-background.png index 5a09204..8b45013 100644 Binary files a/src-tauri/installer/dmg-background.png and b/src-tauri/installer/dmg-background.png differ diff --git a/src-tauri/installer/nsis-header.bmp b/src-tauri/installer/nsis-header.bmp index 56875fc..b594dfd 100644 Binary files a/src-tauri/installer/nsis-header.bmp and b/src-tauri/installer/nsis-header.bmp differ diff --git a/src-tauri/installer/nsis-sidebar.bmp b/src-tauri/installer/nsis-sidebar.bmp index bcbc1c6..f899807 100644 Binary files a/src-tauri/installer/nsis-sidebar.bmp and b/src-tauri/installer/nsis-sidebar.bmp differ diff --git a/src-tauri/installer/nsis-uninstaller-header.bmp b/src-tauri/installer/nsis-uninstaller-header.bmp index 56875fc..b594dfd 100644 Binary files a/src-tauri/installer/nsis-uninstaller-header.bmp and b/src-tauri/installer/nsis-uninstaller-header.bmp differ diff --git a/src-tauri/src/commands/feedback.rs b/src-tauri/src/commands/feedback.rs new file mode 100644 index 0000000..b55aee9 --- /dev/null +++ b/src-tauri/src/commands/feedback.rs @@ -0,0 +1,79 @@ +//! In-app feedback: append one submission to `feedback.jsonl` in the app data +//! directory. This is the durable local record; delivery to the project (a +//! prefilled GitHub issue) happens on the frontend through `open_url`. + +use crate::*; +use std::io::Write; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedbackInput { + category: String, + message: String, + #[serde(default)] + app_version: Option, + /// Opt-in diagnostics snapshot (version, platform, stats). Whatever the + /// user agreed to attach, stored verbatim so the record matches what was + /// shown to them. + #[serde(default)] + diagnostics: Option, +} + +fn feedback_log_path(app: &AppHandle) -> CmdResult { + let dir = app + .path() + .app_data_dir() + .map_err(|e| CmdError::storage(format!("no app data dir: {e}")))?; + std::fs::create_dir_all(&dir) + .map_err(|e| CmdError::storage(format!("could not create data dir: {e}")))?; + Ok(dir.join("feedback.jsonl")) +} + +#[tauri::command(async)] +pub fn submit_feedback(app: AppHandle, input: FeedbackInput) -> CmdResult<()> { + let message = input.message.trim(); + if message.is_empty() { + return Err(CmdError::validation("feedback message is empty")); + } + let at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let entry = serde_json::json!({ + "at": at, + "category": input.category, + "message": message, + "appVersion": input.app_version, + "diagnostics": input.diagnostics, + }); + let line = serde_json::to_string(&entry) + .map_err(|e| CmdError::storage(format!("could not encode feedback: {e}")))?; + + let path = feedback_log_path(&app)?; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|e| CmdError::storage(format!("could not open feedback log: {e}")))?; + writeln!(file, "{line}") + .map_err(|e| CmdError::storage(format!("could not write feedback: {e}")))?; + Ok(()) +} + +/// Reveal `feedback.jsonl` in the OS file manager, from the Feedback settings +/// page — the only way a user can see what has accumulated there, since +/// nothing else surfaces or prunes it (recorded in +/// `openspec/changes/archive/feat-in-app-feedback/tasks.md`). Reveals rather +/// than opens: the file is meant to be located, not edited. +#[tauri::command(async)] +pub fn open_feedback_log(app: AppHandle) -> CmdResult<()> { + let path = feedback_log_path(&app)?; + if !path.exists() { + std::fs::write(&path, "") + .map_err(|e| CmdError::storage(format!("could not create feedback log: {e}")))?; + } + app.opener() + .reveal_item_in_dir(&path) + .map_err(|e| CmdError::storage(format!("could not reveal feedback log: {e}")))?; + Ok(()) +} diff --git a/src-tauri/src/commands/import.rs b/src-tauri/src/commands/import.rs new file mode 100644 index 0000000..5e2e874 --- /dev/null +++ b/src-tauri/src/commands/import.rs @@ -0,0 +1,270 @@ +//! Settings > Import: Apple Stickies into the library. Reading a Stickies +//! folder and converting its RTF live in `instantnotes_core::import`; this +//! module copies in the images a sticky refers to and hands the notes to the +//! store. Nothing here ever writes under the chosen folder. + +use crate::*; +use instantnotes_core::domain::derive_title; +use instantnotes_core::import::{rtf, stickies}; +use instantnotes_core::store::iso; +use std::collections::HashSet; +use std::io; +use std::path::{Component, Path, PathBuf}; + +/// The name the store records these imports under (`import.stickies`). +const SOURCE: &str = "stickies"; +/// A sticky's image larger than this is left out, and the note says so. +const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; +/// Enough of a sticky's text to fill its miniature. +const PREVIEW_CHARS: usize = 400; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StickiesScan { + /// Where the stickies were found: the chosen folder, or the Stickies + /// folder inside it. `import_stickies` takes this back. + folder: String, + /// False when macOS refused to let the app read the folder. + readable: bool, + stickies: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StickyPreview { + id: String, + /// The title the note will get. + title: String, + /// The start of the note's Markdown. + text: String, + /// `#rrggbb`, the sticky's paper. + color: Option, + created_at: String, + updated_at: String, + images: usize, + /// Imported before, and its note still exists. + imported: bool, +} + +/// Where Stickies keeps its notes, for the folder picker to open at. `None` +/// off macOS, where there is no Stickies. +#[tauri::command(async)] +pub fn stickies_location(app: AppHandle) -> Option { + if !cfg!(target_os = "macos") { + return None; + } + let home = app.path().home_dir().ok()?; + Some( + home.join(stickies::STICKIES_DIR) + .to_string_lossy() + .into_owned(), + ) +} + +/// Every sticky in the folder the user picked, converted, for the preview. +#[tauri::command(async)] +pub fn scan_stickies(state: State<'_, AppState>, folder: String) -> CmdResult { + let dir = chosen_folder(&folder)?; + let found = match stickies::read_folder(&dir) { + Ok(found) => found, + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + return Ok(StickiesScan { + folder: dir.to_string_lossy().into_owned(), + readable: false, + stickies: Vec::new(), + }) + } + Err(e) => return Err(unreadable(e)), + }; + let imported = locked(&state)?.imported_ids(SOURCE)?; + let stickies = found + .into_iter() + .map(|sticky| { + let mut images = 0; + let text = rtf::to_markdown(&sticky.rtf, |_| { + images += 1; + Some(String::new()) + }); + StickyPreview { + title: derive_title(&text), + text: text.chars().take(PREVIEW_CHARS).collect(), + color: sticky.color, + created_at: iso(sticky.created_at), + updated_at: iso(sticky.updated_at), + images, + imported: imported.contains(&sticky.id), + id: sticky.id, + } + }) + .collect(); + Ok(StickiesScan { + folder: dir.to_string_lossy().into_owned(), + readable: true, + stickies, + }) +} + +/// Import the stickies `ids` from `folder` as notes, filed in the Space named +/// `space` (none when blank). Read again from disk, not from the preview, so +/// what lands is each sticky as it is now. +#[tauri::command(async)] +pub fn import_stickies( + state: State<'_, AppState>, + app: AppHandle, + folder: String, + ids: Vec, + space: Option, +) -> CmdResult { + let dir = chosen_folder(&folder)?; + let wanted: HashSet = ids.iter().map(|id| id.to_uppercase()).collect(); + // Leave out what is already in before copying any image for it; the + // store checks again inside its transaction. + let done = locked(&state)?.imported_ids(SOURCE)?; + let found = stickies::read_folder(&dir).map_err(unreadable)?; + let attachments = attachments_dir(&app)?; + let mut already = 0; + let items: Vec = found + .into_iter() + .filter(|sticky| wanted.contains(&sticky.id)) + .filter(|sticky| { + let fresh = !done.contains(&sticky.id); + already += usize::from(!fresh); + fresh + }) + .map(|sticky| ImportItem { + body: rtf::to_markdown(&sticky.rtf, |name| { + let stored = copy_image(&attachments, &sticky.package, name)?; + Some(format!("![](attachments/{stored})")) + }), + source_id: sticky.id, + created_at: sticky.created_at, + updated_at: sticky.updated_at, + }) + .collect(); + let mut outcome = locked(&state)?.import_notes(SOURCE, items, space.as_deref())?; + outcome.skipped += already; + // Best effort, as after a paste: the next launch mirrors what this misses. + let _ = mirror_attachments(&app); + if outcome.imported > 0 { + emit_notes_changed(&app); + emit_tags_changed(&app); + emit_workspaces_changed(&app); + } + Ok(outcome) +} + +/// The folder the picker returned, where its stickies are. +fn chosen_folder(folder: &str) -> CmdResult { + let path = Path::new(folder); + if !path.is_absolute() { + return Err(CmdError::validation("the folder must be an absolute path")); + } + Ok(stickies::resolve_folder(path)) +} + +fn unreadable(e: io::Error) -> CmdError { + match e.kind() { + io::ErrorKind::NotFound => CmdError::validation("that folder is no longer there"), + io::ErrorKind::NotADirectory => CmdError::validation("that is a file, not a folder"), + _ => CmdError::storage(format!("could not read the folder: {e}")), + } +} + +/// Copy the image a sticky names into the attachments folder, returning its +/// stored name. The name comes from a file, so it must be a plain file name +/// inside the sticky's own package, a regular file (a link is not +/// followed), no larger than `MAX_IMAGE_BYTES`, and an image. +fn copy_image(attachments: &Path, package: &Path, name: &str) -> Option { + if !is_plain_name(name) { + return None; + } + let path = package.join(name); + let meta = std::fs::symlink_metadata(&path).ok()?; + if !meta.is_file() || meta.len() > MAX_IMAGE_BYTES { + return None; + } + let bytes = std::fs::read(&path).ok()?; + store_image(attachments, &bytes).ok() +} + +/// One ordinary path component: no separators, no `.` or `..`, no root. +fn is_plain_name(name: &str) -> bool { + let mut parts = Path::new(name).components(); + matches!( + (parts.next(), parts.next()), + (Some(Component::Normal(_)), None) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder under the system temp dir, removed on drop. + struct Scratch(PathBuf); + impl Scratch { + fn new() -> Self { + let dir = + std::env::temp_dir().join(format!("instantnotes-import-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + } + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + const PNG: &[u8] = b"\x89PNG\r\n\x1a\n not really the rest of a png"; + + #[test] + fn only_a_plain_file_name_is_an_attachment_name() { + for ok in ["Attachment.png", "Pasted Graphic 2.tiff", "a..b.png"] { + assert!(is_plain_name(ok), "{ok}"); + } + for bad in ["", ".", "..", "../x.png", "a/b.png", "/etc/passwd"] { + assert!(!is_plain_name(bad), "{bad}"); + } + } + + #[test] + fn an_image_in_the_package_is_copied_in_under_a_new_name() { + let (package, attachments) = (Scratch::new(), Scratch::new()); + std::fs::write(package.0.join("Attachment.png"), PNG).unwrap(); + let stored = copy_image(&attachments.0, &package.0, "Attachment.png").unwrap(); + assert!(stored.ends_with(".png") && stored != "Attachment.png"); + assert_eq!(std::fs::read(attachments.0.join(&stored)).unwrap(), PNG); + } + + #[test] + fn a_name_that_leaves_the_package_is_refused() { + let (root, attachments) = (Scratch::new(), Scratch::new()); + let package = root.0.join("A.rtfd"); + std::fs::create_dir(&package).unwrap(); + std::fs::write(root.0.join("outside.png"), PNG).unwrap(); + assert_eq!(copy_image(&attachments.0, &package, "../outside.png"), None); + assert_eq!(std::fs::read_dir(&attachments.0).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn a_link_inside_the_package_is_not_followed() { + let (package, elsewhere, attachments) = (Scratch::new(), Scratch::new(), Scratch::new()); + std::fs::write(elsewhere.0.join("secret.png"), PNG).unwrap(); + std::os::unix::fs::symlink(elsewhere.0.join("secret.png"), package.0.join("a.png")) + .unwrap(); + assert_eq!(copy_image(&attachments.0, &package.0, "a.png"), None); + } + + #[test] + fn a_file_that_is_no_image_is_not_brought_in() { + let (package, attachments) = (Scratch::new(), Scratch::new()); + std::fs::write( + package.0.join("notes.png"), + b"plain text, whatever the name says", + ) + .unwrap(); + assert_eq!(copy_image(&attachments.0, &package.0, "notes.png"), None); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 977c3e5..237ea83 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,8 @@ +pub mod feedback; +pub mod import; pub mod notes; pub mod settings; +pub mod stats; pub mod tags; +pub mod vault; pub mod workspaces; diff --git a/src-tauri/src/commands/notes.rs b/src-tauri/src/commands/notes.rs index 264f2cd..b7a0211 100644 --- a/src-tauri/src/commands/notes.rs +++ b/src-tauri/src/commands/notes.rs @@ -26,9 +26,12 @@ pub fn update_note( id: String, patch: UpdateNotePatch, ) -> CmdResult { - let note = locked(&state)?.update_note(&id, patch)?; + let mut note = locked(&state)?.update_note(&id, patch)?; emit_notes_changed(&app); emit_tags_changed(&app); + // The caller already holds the canvas it just saved; echoing it back + // would ship the whole board, pasted images included, on every save. + note.surface_data = None; Ok(note) } @@ -53,7 +56,9 @@ pub fn permanently_delete_note( id: String, confirm: bool, ) -> CmdResult<()> { - locked(&state)?.permanently_delete_note(&id, confirm)?; + destroy_with_attachments(&state, &app, std::slice::from_ref(&id), |store| { + store.permanently_delete_note(&id, confirm) + })?; emit_notes_changed(&app); emit_tags_changed(&app); Ok(()) @@ -120,8 +125,38 @@ pub fn destroy_notes( ids: Vec, confirm: bool, ) -> CmdResult<()> { - locked(&state)?.destroy_notes(&ids, confirm)?; + destroy_with_attachments(&state, &app, &ids, |store| { + store.destroy_notes(&ids, confirm) + })?; emit_notes_changed(&app); emit_tags_changed(&app); Ok(()) } + +/// The library as a graph of notes, tags, and Spaces, for the Graph view. +#[tauri::command(async)] +pub fn library_graph(state: State<'_, AppState>) -> CmdResult { + Ok(locked(&state)?.library_graph()?) +} + +/// Destroy notes for good, and with them the images only they used. The +/// store stays locked from reading their references to removing the files, +/// so no save can start referencing an image in between. A cleanup failure +/// never fails the delete: the note is gone either way, and a stray file is +/// what Settings > Images offers to clean up. +fn destroy_with_attachments( + state: &State<'_, AppState>, + app: &AppHandle, + ids: &[String], + destroy: impl FnOnce(&mut Store) -> instantnotes_core::error::Result<()>, +) -> CmdResult<()> { + let mut store = locked(state)?; + let names = store.attachment_names_of(ids)?; + destroy(&mut store)?; + if !names.is_empty() { + if let Ok(dir) = attachments_dir(app) { + let _ = store.remove_unreferenced_attachments(&dir, names); + } + } + Ok(()) +} diff --git a/src-tauri/src/commands/stats.rs b/src-tauri/src/commands/stats.rs new file mode 100644 index 0000000..f4fb867 --- /dev/null +++ b/src-tauri/src/commands/stats.rs @@ -0,0 +1,26 @@ +//! Dashboard stats: library counts from the store, joined with attachment +//! counts from the filesystem, for the Settings front page. + +use crate::*; + +/// The store's counts plus the on-disk attachment totals. Flattened so the +/// frontend sees one flat object of camelCase numbers. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardStats { + #[serde(flatten)] + library: LibraryStats, + attachments_count: i64, + attachments_bytes: i64, +} + +#[tauri::command(async)] +pub fn library_stats(state: State<'_, AppState>, app: AppHandle) -> CmdResult { + let library = locked(&state)?.library_stats()?; + let (attachments_count, attachments_bytes) = attachments_stats(&app); + Ok(DashboardStats { + library, + attachments_count, + attachments_bytes, + }) +} diff --git a/src-tauri/src/commands/tags.rs b/src-tauri/src/commands/tags.rs index 358e2a4..d8b9ff1 100644 --- a/src-tauri/src/commands/tags.rs +++ b/src-tauri/src/commands/tags.rs @@ -7,17 +7,6 @@ pub fn list_tags(state: State<'_, AppState>) -> CmdResult> { Ok(locked(&state)?.list_tags()?) } -#[tauri::command(async)] -pub fn get_or_create_tag( - state: State<'_, AppState>, - app: AppHandle, - name: String, -) -> CmdResult { - let tag = locked(&state)?.get_or_create_tag(&name)?; - emit_tags_changed(&app); - Ok(tag) -} - #[tauri::command(async)] pub fn update_tag( state: State<'_, AppState>, diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs new file mode 100644 index 0000000..85dc57d --- /dev/null +++ b/src-tauri/src/commands/vault.rs @@ -0,0 +1,98 @@ +//! Vault commands for `feat-portable-vault-sync`. Stage 1: a one-way export +//! of the whole library to a folder the user picks. Stage 2: the live +//! mirror, configured here and written by `shell/mirror.rs`. SQLite stays +//! authoritative throughout; nothing here reads a vault back. +//! +//! The store->VaultNote gather lives in `instantnotes-core::vault` +//! (`collect_from_store`), not here: this crate is IPC plumbing only +//! (lib.rs's stated dependency rule), and that gather is exactly the +//! business logic the rule keeps out of it. It's also exercised by a real +//! SQLite store in `core/tests/store_test.rs::vault_export`, which this +//! command's own layer has no equivalent way to test without a Tauri +//! runtime. + +use crate::*; +use instantnotes_core::vault::{self, VaultReport, VaultStatus}; + +/// The destination is chosen by the user through a native folder-picker +/// dialog in JS, same trust boundary as `export_note_file`. +fn validate_vault_dest(path: &str) -> CmdResult<()> { + if !std::path::Path::new(path).is_absolute() { + return Err(CmdError::storage("vault folder path must be absolute")); + } + Ok(()) +} + +#[tauri::command(async)] +pub fn export_vault(state: State<'_, AppState>, app: AppHandle, dest: String) -> CmdResult<()> { + validate_vault_dest(&dest)?; + let dest_path = std::path::Path::new(&dest); + + let (notes, manifest) = { + let store = locked(&state)?; + // An export into the live mirror would write a second copy of every + // note beside the mirror's own files. + if store + .vault_root() + .is_some_and(|root| vault::is_within(dest_path, root)) + { + return Err(CmdError::storage( + "that folder is your live vault, which is already up to date; \ + choose another folder for the copy", + )); + } + vault::collect_from_store(&store)? + }; + + vault::export_vault(¬es, &manifest, dest_path) + .map_err(|e| CmdError::storage(format!("could not write vault: {e}")))?; + + let attachments_src = attachments_dir(&app)?; + if attachments_src.is_dir() { + vault::copy_dir_recursive(&attachments_src, &dest_path.join("attachments")) + .map_err(|e| CmdError::storage(format!("could not copy attachments: {e}")))?; + } + Ok(()) +} + +#[tauri::command(async)] +pub fn get_vault_status(state: State<'_, AppState>) -> CmdResult { + Ok(locked(&state)?.vault_status()?) +} + +/// Start mirroring into `path`, move the mirror there, or stop it (`None`). +/// The folder is chosen through the native picker in JS. Returns right away: +/// the first write of a large library happens in the background, and the +/// status reports how many notes are still pending. +#[tauri::command(async)] +pub fn set_vault_folder( + state: State<'_, AppState>, + app: AppHandle, + path: Option, +) -> CmdResult { + if let Some(path) = &path { + validate_vault_dest(path)?; + let app_data = app + .path() + .app_data_dir() + .map_err(|e| CmdError::storage(format!("no app data dir: {e}")))?; + vault::check_vault_location(std::path::Path::new(path), &app_data) + .map_err(CmdError::storage)?; + } + locked(&state)?.configure_vault(path.as_deref().map(std::path::Path::new))?; + if path.is_some() { + // The notes follow through the flusher; a failure here shows up as + // missing images in the vault, not as a failed setup. + let _ = mirror_attachments(&app); + request_vault_flush(&app); + } + Ok(locked(&state)?.vault_status()?) +} + +/// Flush anything pending, then compare every file in the vault with the +/// library. Read-only: it reports differences and changes nothing. +#[tauri::command(async)] +pub fn verify_vault(state: State<'_, AppState>, app: AppHandle) -> CmdResult { + flush_vault_now(&app, None); + Ok(locked(&state)?.verify_vault()?) +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..4ce66d9 --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,154 @@ +//! The one place a command error is built. +//! +//! A `CmdError` carries an `ErrorCode`, never a string, so a wrong code is +//! unwritable: the codes in API.md §14 are the only values that exist. Before +//! this, two dozen sites hand-rolled `code:` as a literal and eight of them +//! wrote `"VALIDATION"` instead of `"VALIDATION_ERROR"`, which silently broke +//! the friendly copy in `src/lib/errors.ts` for every validation failure. +//! +//! `From` stays the path a core error takes; the constructors here +//! are for the shell's own failures, which have no `AppError` to map. + +use instantnotes_core::AppError; +use serde::{Serialize, Serializer}; + +/// A stable error code per API.md §14 — the only codes callers may branch on. +/// The frontend mirror is `src/lib/api/error-codes.ts` and the core's +/// `AppError::code()` is the authority for the strings; both equalities are +/// asserted (in `src/lib/api/contract.test.ts` and in this module's tests). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorCode { + NotFound, + Validation, + Conflict, + Storage, + Migration, +} + +impl ErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::NotFound => "NOT_FOUND", + Self::Validation => "VALIDATION_ERROR", + Self::Conflict => "CONFLICT", + Self::Storage => "STORAGE_ERROR", + Self::Migration => "MIGRATION_ERROR", + } + } + + #[cfg(test)] + pub const ALL: &'static [ErrorCode] = &[ + Self::NotFound, + Self::Validation, + Self::Conflict, + Self::Storage, + Self::Migration, + ]; + + /// The inverse of `as_str`, used to prove the core's `AppError::code()` + /// strings and this enum cannot drift apart. + #[cfg(test)] + pub fn parse(s: &str) -> Option { + Self::ALL.iter().copied().find(|c| c.as_str() == s) + } +} + +impl Serialize for ErrorCode { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +/// Serializable error per API.md §3.6 / §14. +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CmdError { + code: ErrorCode, + message: String, +} + +impl CmdError { + /// A persistence or platform failure: the operation could not be carried + /// out. Also the code for a path the shell refuses to touch. + pub fn storage(message: impl Into) -> Self { + CmdError { + code: ErrorCode::Storage, + message: message.into(), + } + } + + /// Input from the webview failed a rule. + pub fn validation(message: impl Into) -> Self { + CmdError { + code: ErrorCode::Validation, + message: message.into(), + } + } + + /// Read back for assertions; production code only ever serializes it. + #[cfg(test)] + pub fn code(&self) -> ErrorCode { + self.code + } +} + +impl From for CmdError { + fn from(e: AppError) -> Self { + // Matched rather than read off `e.code()` so a new `AppError` variant + // cannot reach the frontend without a decision here; the test below + // holds this mapping and `AppError::code()` in agreement. + let code = match &e { + AppError::NotFound(_) => ErrorCode::NotFound, + AppError::Validation(_) => ErrorCode::Validation, + AppError::Conflict(_) => ErrorCode::Conflict, + // A damaged file is a storage failure to callers (API.md §14). + AppError::Storage(_) | AppError::Corruption(_) => ErrorCode::Storage, + AppError::Migration(_) | AppError::SchemaTooNew { .. } => ErrorCode::Migration, + }; + CmdError { + code, + message: e.to_string(), + } + } +} + +pub type CmdResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_core_error_code_is_a_known_code() { + for e in [ + AppError::NotFound("x".into()), + AppError::Validation("x".into()), + AppError::Conflict("x".into()), + AppError::Storage("x".into()), + AppError::Migration("x".into()), + AppError::Corruption("x".into()), + AppError::SchemaTooNew { found: 9, known: 6 }, + ] { + let code = e.code(); + assert!( + ErrorCode::parse(code).is_some(), + "core emits {code}, which is not an ErrorCode" + ); + assert_eq!(CmdError::from(e).code().as_str(), code); + } + } + + #[test] + fn codes_serialize_as_their_api_strings() { + let json = serde_json::to_string(&CmdError::validation("nope")).expect("serialize"); + assert_eq!(json, r#"{"code":"VALIDATION_ERROR","message":"nope"}"#); + assert_eq!(CmdError::storage("nope").code(), ErrorCode::Storage); + } + + #[test] + fn every_code_round_trips_through_its_string() { + for code in ErrorCode::ALL { + assert_eq!(ErrorCode::parse(code.as_str()), Some(*code)); + } + } +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs new file mode 100644 index 0000000..beb0f76 --- /dev/null +++ b/src-tauri/src/events.rs @@ -0,0 +1,39 @@ +//! Every event name the shell emits, in one place. +//! +//! An event name is a contract with the webview that no type can reach across: +//! the frontend mirror is `src/lib/api/events.ts`, and +//! `src/lib/api/contract.test.ts` holds the two lists equal, so renaming one +//! side alone fails a test instead of silently dropping a listener. + +/// Notes were added, changed, or removed; the frontend re-queries. +pub const NOTES_CHANGED: &str = "notes:changed"; +/// Tags changed. +pub const TAGS_CHANGED: &str = "tags:changed"; +/// Spaces changed (storage and IPC say "workspace"; see openspec/project.md). +pub const WORKSPACES_CHANGED: &str = "workspaces:changed"; +/// Open the Settings window (app menu). +pub const SETTINGS_OPEN: &str = "settings:open"; +/// File menu: new note, new whiteboard, export the open note. +pub const MENU_NEW_NOTE: &str = "menu:new-note"; +pub const MENU_NEW_WHITEBOARD: &str = "menu:new-whiteboard"; +pub const MENU_EXPORT_NOTE: &str = "menu:export-note"; +/// Check for an update now (app menu). +pub const UPDATER_CHECK: &str = "updater:check"; +/// Global-shortcut registration failed; payload is the shortcut label. +pub const SHORTCUT_FAILED: &str = "shortcut:failed"; +/// A vault mirror flush finished; re-read `get_vault_status`. +pub const VAULT_STATUS: &str = "vault:status"; +/// The user asked to quit; windows save before the app exits. +pub const APP_QUIT_REQUESTED: &str = "app:quit-requested"; +/// The capture panel became visible (emitted to that window only). +pub const CAPTURE_SHOWN: &str = "capture:shown"; +/// File menu: pop the open note out as a sticky, or back in. +pub const MENU_TOGGLE_STICKY: &str = "menu:toggle-sticky"; +/// A note became a sticky or stopped being one; re-read `list_stickies`. +pub const STICKIES_CHANGED: &str = "stickies:changed"; +/// Pop in: the sticky flushes, then answers `answer_pop_in` (that window only). +pub const STICKY_CLOSE_REQUESTED: &str = "sticky:close-requested"; +/// Another process wrote to the library (an agent, via `instantnotes mcp`); +/// payload is the new agent activity entries, oldest first. See +/// `shell/agents.rs`. +pub const LIBRARY_EXTERNAL_CHANGE: &str = "library:external-change"; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 999c4bb..7bf78b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ //! No business logic lives here — that's instantnotes-core's job. use instantnotes_core::types::*; -use instantnotes_core::{AppError, Store}; +use instantnotes_core::Store; use serde::Serialize; use std::sync::atomic::Ordering; use std::sync::Mutex; @@ -17,44 +17,31 @@ struct AppState { store: Mutex, } -/// Serializable error per API.md §3.6 / §11. -#[derive(Serialize, Debug)] -#[serde(rename_all = "camelCase")] -struct CmdError { - code: String, - message: String, -} - -impl From for CmdError { - fn from(e: AppError) -> Self { - CmdError { - code: e.code().to_string(), - message: e.to_string(), - } - } -} - -type CmdResult = Result; - fn locked<'a>( state: &'a State<'_, AppState>, ) -> Result, CmdError> { - state.store.lock().map_err(|_| CmdError { - code: "STORAGE_ERROR".into(), - message: "internal state lock poisoned".into(), - }) + state + .store + .lock() + .map_err(|_| CmdError::storage("internal state lock poisoned")) } +// Every write command announces itself through one of these, and no read +// does, so they double as the vault mirror's flush trigger (shell/mirror.rs). + fn emit_notes_changed(app: &AppHandle) { - let _ = app.emit("notes:changed", ()); + let _ = app.emit(events::NOTES_CHANGED, ()); + request_vault_flush(app); } fn emit_tags_changed(app: &AppHandle) { - let _ = app.emit("tags:changed", ()); + let _ = app.emit(events::TAGS_CHANGED, ()); + request_vault_flush(app); } fn emit_workspaces_changed(app: &AppHandle) { - let _ = app.emit("workspaces:changed", ()); + let _ = app.emit(events::WORKSPACES_CHANGED, ()); + request_vault_flush(app); } // ---- shortcut status ---- @@ -68,9 +55,14 @@ pub(crate) struct ShortcutStatus { } mod commands; +mod error; +mod events; mod shell; -use commands::{notes::*, settings::*, tags::*, workspaces::*}; -use shell::{capture::*, files::*, quit::*, windows::*}; +use commands::{ + feedback::*, import::*, notes::*, settings::*, stats::*, tags::*, vault::*, workspaces::*, +}; +use error::{CmdError, CmdResult}; +use shell::{agents::*, capture::*, files::*, mirror::*, quit::*, stickies::*, windows::*}; // ---- app shell ---- @@ -120,12 +112,47 @@ pub fn run() { if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent)?; } - let (store, recovered) = - Store::open_or_recover(&db_path).map_err(|e| format!("cannot open store: {e}"))?; + let (mut store, recovered) = match Store::open_or_recover(&db_path) { + Ok(ok) => ok, + // Intact file, unknown future schema: nothing here is safe to + // migrate, recover, or overwrite. Tell the user and stop, + // rather than let the error propagate out to build().expect() + // (a panic there aborts the process before the dialog plugin + // ever gets to run its event loop). + Err(e) if e.is_schema_too_new() => { + let handle = app.handle().clone(); + app.dialog() + .message( + "This notes library was created by a newer version of \ + InstantNotes. Update the app to open it.", + ) + .title("Library too new") + .kind(MessageDialogKind::Error) + .show(move |_| handle.exit(1)); + return Ok(()); + } + Err(e) => return Err(format!("cannot open store: {e}").into()), + }; + // Resume the live vault mirror, if one is set. A setting that + // cannot be read leaves mirroring off rather than failing launch. + if store.attach_saved_vault().is_err() { + eprintln!("vault mirror setting unreadable; mirroring stays off"); + } app.manage(AppState { store: Mutex::new(store), }); app.manage(CaptureMetrics::default()); + app.manage(start_vault_flusher(app.handle())); + app.manage(start_agent_watcher(app.handle(), db_path.clone())); + // Catch up anything a crash or a missing drive left pending, and + // any attachment added while mirroring was paused. + { + let handle = app.handle().clone(); + std::thread::spawn(move || { + let _ = mirror_attachments(&handle); + request_vault_flush(&handle); + }); + } if recovered { // Non-blocking on purpose: setup must finish (single-instance // handshake, window creation) whether or not the user has @@ -172,12 +199,28 @@ pub fn run() { .build()?; let new_note_item = MenuItem::with_id(app, "new_note", "New Note", true, Some("CmdOrCtrl+N"))?; + let new_board_item = MenuItem::with_id( + app, + "new_whiteboard", + "New Whiteboard", + true, + Some("CmdOrCtrl+Shift+N"), + )?; let export_item = MenuItem::with_id(app, "export_note", "Export Note As…", true, None::<&str>)?; + let sticky_item = MenuItem::with_id( + app, + "toggle_sticky", + "Pop Out as Sticky", + true, + Some("CmdOrCtrl+Shift+O"), + )?; let file_submenu = { let builder = SubmenuBuilder::new(app, "File") .item(&new_note_item) + .item(&new_board_item) .separator() + .item(&sticky_item) .item(&export_item); #[cfg(not(target_os = "macos"))] let builder = builder @@ -208,15 +251,23 @@ pub fn run() { app.on_menu_event(|app, event| match event.id().as_ref() { "settings" => { show_library_window(app); - let _ = app.emit("settings:open", ()); + let _ = app.emit(events::SETTINGS_OPEN, ()); } "new_note" => { show_library_window(app); - let _ = app.emit("menu:new-note", ()); + let _ = app.emit(events::MENU_NEW_NOTE, ()); + } + "new_whiteboard" => { + show_library_window(app); + let _ = app.emit(events::MENU_NEW_WHITEBOARD, ()); } "export_note" => { show_library_window(app); - let _ = app.emit("menu:export-note", ()); + let _ = app.emit(events::MENU_EXPORT_NOTE, ()); + } + // Library only: the note it acts on is the library's open one. + "toggle_sticky" => { + let _ = app.emit_to("library", events::MENU_TOGGLE_STICKY, ()); } "quit" => request_quit(app), _ => {} @@ -288,7 +339,7 @@ pub fn run() { "open_library" => show_library_window(app), "check_updates" => { show_library_window(app); - let _ = app.emit("updater:check", ()); + let _ = app.emit(events::UPDATER_CHECK, ()); } "open_repo" => { let _ = app.opener().open_url(REPO_URL, None::<&str>); @@ -341,7 +392,7 @@ pub fn run() { shortcut_label.to_string() }); if let Some(label) = &shortcut_failure { - let _ = app.emit("shortcut:failed", label.clone()); + let _ = app.emit(events::SHORTCUT_FAILED, label.clone()); } app.manage(ShortcutStatus { failed: shortcut_failure, @@ -372,6 +423,10 @@ pub fn run() { let _ = library.set_focus(); } } + + // Last: the store is managed and the library exists, so a sticky + // restored now is never the app's first window. + restore_stickies(app.handle()); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -388,7 +443,6 @@ pub fn run() { restore_notes, destroy_notes, list_tags, - get_or_create_tag, update_tag, delete_tag, add_tag_to_note, @@ -414,11 +468,36 @@ pub fn run() { export_note_file, save_attachment, get_attachments_dir, + import_image_file, + allow_image_file, + open_attachments_folder, + library_graph, + unused_attachments, + remove_unused_attachments, + library_stats, + export_vault, + get_vault_status, + set_vault_folder, + verify_vault, + stickies_location, + scan_stickies, + import_stickies, + submit_feedback, + open_feedback_log, open_url, quit_app, capture_input_ready, get_capture_latency, - get_shortcut_failure + get_shortcut_failure, + pop_out_note, + pop_in_note, + answer_pop_in, + list_stickies, + get_sticky_view, + set_sticky_level, + set_sticky_collapsed, + save_sticky_geometry, + agent_connection ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 28f0291..2683190 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,18 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // `instantnotes mcp ...` serves agents over stdio and never starts the + // app (see src-tauri/agents). Checked before anything else, the + // single-instance plugin included, which would hand this process's + // arguments to the running app and exit. + if let Some(code) = instantnotes_agents::run_from_args(std::env::args_os().skip(1)) { + std::process::exit(code); + } + + #[cfg(target_os = "linux")] + if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + instantnotes_lib::run() } diff --git a/src-tauri/src/shell/agents.rs b/src-tauri/src/shell/agents.rs new file mode 100644 index 0000000..0ef2c5f --- /dev/null +++ b/src-tauri/src/shell/agents.rs @@ -0,0 +1,175 @@ +//! The app's side of agent access (`instantnotes-agents`). An agent writes +//! through its own process and its own connection to the library, so the +//! app learns of it the way SQLite reports it: `data_version` moves only +//! when another connection commits. This thread watches it, and when it +//! moves: +//! +//! - the library re-queries, and the vault mirror flushes what changed, as +//! after any write of the app's own; +//! - the new entries of the agent activity log go to the webview as +//! `library:external-change`, which draws them on the notes themselves. +//! +//! An agent's read changes no note, but the server records every call in +//! the activity log, and that write is what makes a read visible here too. + +use crate::*; +use instantnotes_agents::ACTIVITY_KEY; +use serde_json::Value; +use std::path::PathBuf; +use std::time::Duration; + +/// How often `data_version` is read: a lock and one pragma, well under a +/// millisecond, and quick enough for a highlight to feel live. +const POLL: Duration = Duration::from_millis(400); + +/// What Settings > Agents needs to print a working connect command. +pub(crate) struct AgentBridge { + db_path: PathBuf, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentConnection { + /// This app's executable, which is also the MCP server. + exe: String, + db: String, + attachments: Option, +} + +/// Start the watcher; manage the returned bridge. +pub(crate) fn start_agent_watcher(app: &AppHandle, db_path: PathBuf) -> AgentBridge { + let handle = app.clone(); + std::thread::spawn(move || { + let mut last = Seen::default(); + let mut first = true; + loop { + if let Some(now) = read_state(&handle) { + if first { + // History from before launch is not news. + first = false; + } else if now.version != last.version { + announce(&handle, &now, last.newest_at); + } + last = now; + } + std::thread::sleep(POLL); + } + }); + AgentBridge { db_path } +} + +#[derive(Default)] +struct Seen { + version: i64, + /// `at` of the newest activity entry seen, so each is announced once. + newest_at: u64, + log: Vec, +} + +fn read_state(app: &AppHandle) -> Option { + let state = app.try_state::()?; + let store = state.store.lock().ok()?; + let version = store.data_version().ok()?; + let log = match store.get_setting(ACTIVITY_KEY) { + Ok(Some(Value::Array(log))) => log, + _ => Vec::new(), + }; + let newest_at = log.first().map(entry_at).unwrap_or_default(); + Some(Seen { + version, + newest_at, + log, + }) +} + +fn entry_at(entry: &Value) -> u64 { + entry.get("at").and_then(Value::as_u64).unwrap_or_default() +} + +fn announce(app: &AppHandle, now: &Seen, seen_at: u64) { + let (fresh, wrote) = news(&now.log, seen_at); + if wrote { + emit_notes_changed(app); + emit_tags_changed(app); + emit_workspaces_changed(app); + } + let _ = app.emit(events::LIBRARY_EXTERNAL_CHANGE, fresh); +} + +/// What a move of `data_version` means, given the activity log (newest +/// first) and the newest entry already announced: the entries not yet +/// announced, oldest first so the webview plays them in order, and whether +/// the library itself changed. Only reads since the last look change no +/// note, so nothing needs re-querying; a move with no new entry at all is a +/// write from elsewhere (a second copy of the app), and does. +fn news(log: &[Value], seen_at: u64) -> (Vec<&Value>, bool) { + let mut fresh: Vec<&Value> = log.iter().take_while(|e| entry_at(e) > seen_at).collect(); + fresh.reverse(); + let wrote = fresh.is_empty() + || fresh + .iter() + .any(|e| e.get("kind").and_then(Value::as_str) == Some("write")); + (fresh, wrote) +} + +/// The executable and library an agent should be pointed at. +#[tauri::command(async)] +pub fn agent_connection( + app: AppHandle, + bridge: State<'_, AgentBridge>, +) -> CmdResult { + let exe = std::env::current_exe() + .map_err(|e| CmdError::storage(format!("cannot locate the app: {e}")))?; + Ok(AgentConnection { + exe: exe.to_string_lossy().into_owned(), + db: bridge.db_path.to_string_lossy().into_owned(), + attachments: attachments_dir(&app) + .ok() + .map(|d| d.to_string_lossy().into_owned()), + }) +} + +#[cfg(test)] +mod tests { + use super::news; + use serde_json::{json, Value}; + + fn entry(at: u64, kind: &str) -> Value { + json!({ "at": at, "kind": kind }) + } + + fn ats(entries: &[&Value]) -> Vec { + entries.iter().map(|e| e["at"].as_u64().unwrap()).collect() + } + + #[test] + fn new_entries_come_oldest_first_and_each_only_once() { + let log = [entry(30, "read"), entry(20, "read"), entry(10, "read")]; + let (fresh, _) = news(&log, 10); + assert_eq!(ats(&fresh), [20, 30]); + let (fresh, _) = news(&log, 30); + assert!(fresh.is_empty(), "already announced"); + } + + #[test] + fn reads_alone_do_not_requery_the_library() { + let log = [entry(2, "read"), entry(1, "read")]; + assert!(!news(&log, 0).1); + } + + #[test] + fn any_write_requeries_the_library() { + let log = [entry(3, "read"), entry(2, "write"), entry(1, "read")]; + assert!(news(&log, 1).1); + // The write was already announced; the new read alone changes nothing. + assert!(!news(&log, 2).1); + } + + #[test] + fn a_change_no_agent_logged_is_a_write_from_elsewhere() { + let log = [entry(5, "read")]; + let (fresh, wrote) = news(&log, 5); + assert!(fresh.is_empty()); + assert!(wrote, "a second copy of the app wrote; re-query"); + } +} diff --git a/src-tauri/src/shell/capture.rs b/src-tauri/src/shell/capture.rs index 5d528d5..473dd22 100644 --- a/src-tauri/src/shell/capture.rs +++ b/src-tauri/src/shell/capture.rs @@ -59,10 +59,10 @@ fn median_ms(samples: &[u64]) -> Option { /// never double-record; returns the measured reveal-to-ready milliseconds. #[tauri::command] pub fn capture_input_ready(metrics: State<'_, CaptureMetrics>) -> CmdResult> { - let mut inner = metrics.inner.lock().map_err(|_| CmdError { - code: "STORAGE_ERROR".into(), - message: "internal state lock poisoned".into(), - })?; + let mut inner = metrics + .inner + .lock() + .map_err(|_| CmdError::storage("internal state lock poisoned"))?; let Some(shown) = inner.shown_at.take() else { return Ok(None); }; @@ -73,10 +73,10 @@ pub fn capture_input_ready(metrics: State<'_, CaptureMetrics>) -> CmdResult) -> CmdResult { - let inner = metrics.inner.lock().map_err(|_| CmdError { - code: "STORAGE_ERROR".into(), - message: "internal state lock poisoned".into(), - })?; + let inner = metrics + .inner + .lock() + .map_err(|_| CmdError::storage("internal state lock poisoned"))?; Ok(CaptureLatencySummary { last_ms: inner.samples_ms.last().copied(), median_ms: median_ms(&inner.samples_ms), diff --git a/src-tauri/src/shell/files.rs b/src-tauri/src/shell/files.rs index 9a02c7f..9c06dfb 100644 --- a/src-tauri/src/shell/files.rs +++ b/src-tauri/src/shell/files.rs @@ -12,20 +12,14 @@ use crate::*; fn validate_theme_path(path: &str) -> CmdResult<()> { let p = std::path::Path::new(path); if !p.is_absolute() { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "theme path must be absolute".into(), - }); + return Err(CmdError::storage("theme path must be absolute")); } let is_json = p .extension() .and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case("json")); if !is_json { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "theme file must have a .json extension".into(), - }); + return Err(CmdError::storage("theme file must have a .json extension")); } Ok(()) } @@ -33,38 +27,30 @@ fn validate_theme_path(path: &str) -> CmdResult<()> { #[tauri::command(async)] pub fn export_theme_file(path: String, contents: String) -> CmdResult<()> { validate_theme_path(&path)?; - std::fs::write(&path, contents).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not write theme file: {e}"), - }) + std::fs::write(&path, contents) + .map_err(|e| CmdError::storage(format!("could not write theme file: {e}"))) } #[tauri::command(async)] pub fn import_theme_file(path: String) -> CmdResult { validate_theme_path(&path)?; - std::fs::read_to_string(&path).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not read theme file: {e}"), - }) + std::fs::read_to_string(&path) + .map_err(|e| CmdError::storage(format!("could not read theme file: {e}"))) } fn validate_export_path(path: &str) -> CmdResult<()> { let p = std::path::Path::new(path); if !p.is_absolute() { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "export path must be absolute".into(), - }); + return Err(CmdError::storage("export path must be absolute")); } let is_allowed = p .extension() .and_then(|e| e.to_str()) - .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "md" | "txt")); + .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "md" | "txt" | "excalidraw")); if !is_allowed { - return Err(CmdError { - code: "STORAGE_ERROR".into(), - message: "export file must have a .md or .txt extension".into(), - }); + return Err(CmdError::storage( + "export file must have a .md, .txt, or .excalidraw extension", + )); } Ok(()) } @@ -72,10 +58,8 @@ fn validate_export_path(path: &str) -> CmdResult<()> { #[tauri::command(async)] pub fn export_note_file(path: String, contents: String) -> CmdResult<()> { validate_export_path(&path)?; - std::fs::write(&path, contents).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not write export file: {e}"), - }) + std::fs::write(&path, contents) + .map_err(|e| CmdError::storage(format!("could not write export file: {e}"))) } // Pasted/dropped images live as files under /attachments and notes @@ -85,19 +69,14 @@ pub fn export_note_file(path: String, contents: String) -> CmdResult<()> { const ATTACHMENT_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; -fn attachments_dir(app: &AppHandle) -> CmdResult { +pub fn attachments_dir(app: &AppHandle) -> CmdResult { let dir = app .path() .app_data_dir() - .map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("no app data dir: {e}"), - })? + .map_err(|e| CmdError::storage(format!("no app data dir: {e}")))? .join("attachments"); - std::fs::create_dir_all(&dir).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not create attachments dir: {e}"), - })?; + std::fs::create_dir_all(&dir) + .map_err(|e| CmdError::storage(format!("could not create attachments dir: {e}")))?; Ok(dir) } @@ -118,35 +97,311 @@ pub fn save_attachment(app: AppHandle, request: tauri::ipc::Request<'_>) -> CmdR .map(str::to_ascii_lowercase) .unwrap_or_default(); if !ATTACHMENT_EXTS.contains(&ext.as_str()) { - return Err(CmdError { - code: "VALIDATION".into(), - message: format!("unsupported attachment type: {ext:?}"), - }); + return Err(CmdError::validation(format!( + "unsupported attachment type: {ext:?}" + ))); } let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { - return Err(CmdError { - code: "VALIDATION".into(), - message: "attachment body must be raw bytes".into(), - }); + return Err(CmdError::validation("attachment body must be raw bytes")); }; if bytes.is_empty() { - return Err(CmdError { - code: "VALIDATION".into(), - message: "attachment is empty".into(), - }); + return Err(CmdError::validation("attachment is empty")); } + let name = write_attachment(&attachments_dir(&app)?, bytes, &ext)?; + // Best effort: a vault that can't take it now gets it at next launch. + let _ = mirror_attachments(&app); + Ok(name) +} + +/// Write one image into the attachments folder under a new name. The caller +/// builds `attachments/` and mirrors the folder into the vault. +fn write_attachment(dir: &std::path::Path, bytes: &[u8], ext: &str) -> CmdResult { let name = format!("{}.{ext}", uuid::Uuid::new_v4()); - let path = attachments_dir(&app)?.join(&name); - std::fs::write(&path, bytes).map_err(|e| CmdError { - code: "STORAGE_ERROR".into(), - message: format!("could not write attachment: {e}"), - })?; + std::fs::write(dir.join(&name), bytes) + .map_err(|e| CmdError::storage(format!("could not write attachment: {e}")))?; + Ok(name) +} + +/// Store image bytes brought in from elsewhere (an imported sticky): PNG, +/// JPEG, GIF, and WebP as they are, known by their first bytes rather than a +/// file name; anything else converted to PNG where the system can. +pub(crate) fn store_image(dir: &std::path::Path, bytes: &[u8]) -> CmdResult { + if let Some(ext) = sniff_image(bytes) { + return write_attachment(dir, bytes, ext); + } + let png = + to_png(bytes).ok_or_else(|| CmdError::validation("not an image this app can show"))?; + write_attachment(dir, &png, "png") +} + +/// The attachment extension for bytes in a format every webview shows. +fn sniff_image(b: &[u8]) -> Option<&'static str> { + if b.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("png") + } else if b.starts_with(&[0xFF, 0xD8, 0xFF]) { + Some("jpg") + } else if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") { + Some("gif") + } else if b.len() >= 12 && &b[..4] == b"RIFF" && &b[8..12] == b"WEBP" { + Some("webp") + } else { + None + } +} + +/// TIFF (a pasted "Pasted Graphic.tiff"), HEIC, BMP, and the rest ImageIO +/// reads, as PNG, by the system's own `sips`. It works on a copy in a fresh +/// temporary folder, so it never reads the folder the image came from. +#[cfg(target_os = "macos")] +fn to_png(bytes: &[u8]) -> Option> { + use std::process::{Command, Stdio}; + let tmp = std::env::temp_dir().join(format!("instantnotes-image-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&tmp).ok()?; + let (src, out) = (tmp.join("source"), tmp.join("converted.png")); + let png = std::fs::write(&src, bytes).ok().and_then(|()| { + let converted = Command::new("/usr/bin/sips") + .args(["-s", "format", "png"]) + .arg(&src) + .arg("--out") + .arg(&out) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok()? + .success(); + converted.then(|| std::fs::read(&out).ok()).flatten() + }); + let _ = std::fs::remove_dir_all(&tmp); + png.filter(|png| sniff_image(png) == Some("png")) +} + +#[cfg(not(target_os = "macos"))] +fn to_png(_bytes: &[u8]) -> Option> { + None +} + +fn image_ext(path: &std::path::Path) -> CmdResult { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !ATTACHMENT_EXTS.contains(&ext.as_str()) { + return Err(CmdError::validation(format!( + "unsupported image type: {ext:?}" + ))); + } + Ok(ext) +} + +/// Copy an image the user picked through a file dialog into the attachments +/// directory (the "copy in" storage mode). Returns the stored filename; the +/// caller builds `attachments/`, exactly like a pasted image. +#[tauri::command(async)] +pub fn import_image_file(app: AppHandle, path: String) -> CmdResult { + let src = std::path::Path::new(&path); + if !src.is_absolute() { + return Err(CmdError::validation("image path must be absolute")); + } + let ext = image_ext(src)?; + let bytes = + std::fs::read(src).map_err(|e| CmdError::storage(format!("could not read image: {e}")))?; + if bytes.is_empty() { + return Err(CmdError::validation("image is empty")); + } + let name = write_attachment(&attachments_dir(&app)?, &bytes, &ext)?; + let _ = mirror_attachments(&app); Ok(name) } +/// Allow one existing local image to load through the asset protocol (the +/// "link the original file" storage mode). Only an existing image file is +/// permitted, and only that specific file, so the scope is never widened to a +/// whole directory. Idempotent: re-allowing on every note open is fine. +#[tauri::command(async)] +pub fn allow_image_file(app: AppHandle, path: String) -> CmdResult<()> { + let p = std::path::Path::new(&path); + if !p.is_absolute() { + return Err(CmdError::validation("image path must be absolute")); + } + image_ext(p)?; + if !p.is_file() { + return Err(CmdError::storage("image file not found")); + } + app.asset_protocol_scope() + .allow_file(p) + .map_err(|e| CmdError::storage(format!("could not allow image: {e}")))?; + Ok(()) +} + +/// Reveal the attachments folder in the OS file manager, from the Images +/// settings page. Uses the opener from Rust (like `open_url`), so it needs no +/// frontend opener capability. +#[tauri::command(async)] +pub fn open_attachments_folder(app: AppHandle) -> CmdResult<()> { + let dir = attachments_dir(&app)?; + app.opener() + .open_path(dir.to_string_lossy(), None::<&str>) + .map_err(|e| CmdError::storage(format!("could not open attachments folder: {e}")))?; + Ok(()) +} + +/// How long the Settings cleanup leaves a new image alone: a fresh paste can +/// belong to an edit that has not saved yet. +const CLEANUP_GRACE: std::time::Duration = std::time::Duration::from_secs(3600); + +/// Stored images nothing references, older than `CLEANUP_GRACE`, with their +/// total size. +fn unused_attachment_files( + store: &Store, + dir: &std::path::Path, +) -> CmdResult<(Vec, AttachmentCleanup)> { + let cutoff = std::time::SystemTime::now() - CLEANUP_GRACE; + let files = instantnotes_core::attachments::list_attachments(dir, Some(cutoff)) + .map_err(|e| CmdError::storage(format!("could not list attachments: {e}")))?; + let unused = store.unreferenced_attachments(files.iter().map(|(n, _)| n.clone()))?; + let bytes = files + .iter() + .filter(|(n, _)| unused.contains(n)) + .map(|(_, size)| size) + .sum(); + let summary = AttachmentCleanup { + count: unused.len(), + bytes, + }; + Ok((unused, summary)) +} + +/// How many stored images no note uses any more, for Settings > Images. +#[tauri::command(async)] +pub fn unused_attachments( + state: State<'_, AppState>, + app: AppHandle, +) -> CmdResult { + let dir = attachments_dir(&app)?; + let store = locked(&state)?; + Ok(unused_attachment_files(&store, &dir)?.1) +} + +/// Remove the stored images no note uses (and their unchanged copies in the +/// live vault). The store stays locked throughout, so nothing can start +/// referencing one of them mid-cleanup. +#[tauri::command(async)] +pub fn remove_unused_attachments( + state: State<'_, AppState>, + app: AppHandle, +) -> CmdResult { + let dir = attachments_dir(&app)?; + let store = locked(&state)?; + let (unused, _) = unused_attachment_files(&store, &dir)?; + Ok(store.remove_unreferenced_attachments(&dir, unused)?) +} + +/// Best-effort count and total byte size of stored attachments, for the +/// dashboard. A missing or unreadable directory reports zero rather than +/// failing the whole stats call. +pub fn attachments_stats(app: &AppHandle) -> (i64, i64) { + let Ok(dir) = attachments_dir(app) else { + return (0, 0); + }; + let Ok(entries) = std::fs::read_dir(&dir) else { + return (0, 0); + }; + let (mut count, mut bytes) = (0i64, 0i64); + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + count += 1; + bytes += meta.len() as i64; + } + } + } + (count, bytes) +} + #[cfg(test)] mod tests { - use super::{export_theme_file, import_theme_file}; + use super::{ + export_theme_file, import_theme_file, sniff_image, store_image, validate_export_path, + }; + + /// A real PNG, the one `textutil` put in the Stickies fixture. + const PNG: &[u8] = include_bytes!( + "../../core/tests/fixtures/stickies/6E2F9C31-8B4A-4D7E-A1C5-2D9E7F3B8A64.rtfd/Attachment.png" + ); + + #[test] + fn image_formats_are_known_by_their_first_bytes() { + assert_eq!(sniff_image(PNG), Some("png")); + assert_eq!(sniff_image(b"\xFF\xD8\xFF\xE0rest"), Some("jpg")); + assert_eq!(sniff_image(b"GIF89a..."), Some("gif")); + assert_eq!(sniff_image(b"RIFF\0\0\0\0WEBPVP8 "), Some("webp")); + assert_eq!(sniff_image(b"II*\0 a tiff"), None); + assert_eq!(sniff_image(b""), None); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_tiff_is_stored_as_a_png() { + let dir = std::env::temp_dir().join(format!("instantnotes-tiff-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + // A genuine TIFF, as a pasted "Pasted Graphic.tiff" would be. + let (png, tiff) = (dir.join("in.png"), dir.join("in.tiff")); + std::fs::write(&png, PNG).unwrap(); + let made = std::process::Command::new("/usr/bin/sips") + .args(["-s", "format", "tiff"]) + .arg(&png) + .arg("--out") + .arg(&tiff) + .output() + .unwrap(); + assert!(made.status.success()); + let tiff_bytes = std::fs::read(&tiff).unwrap(); + assert_eq!(sniff_image(&tiff_bytes), None, "not a web format as it is"); + + let attachments = dir.join("attachments"); + std::fs::create_dir(&attachments).unwrap(); + let stored = store_image(&attachments, &tiff_bytes).unwrap(); + assert!(stored.ends_with(".png"), "{stored}"); + let written = std::fs::read(attachments.join(&stored)).unwrap(); + assert_eq!(sniff_image(&written), Some("png")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn bytes_that_are_no_image_are_refused() { + let dir = std::env::temp_dir().join(format!("instantnotes-noimg-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(store_image(&dir, b"just some text").is_err()); + assert_eq!( + std::fs::read_dir(&dir).unwrap().count(), + 0, + "nothing written" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn note_export_accepts_markdown_text_and_excalidraw_only() { + // What counts as absolute is platform specific: on Windows a leading + // separator is not enough without a drive prefix, so "/tmp/a.md" is a + // relative path there and would fail the absolute check before the + // extension is ever looked at. Build the fixtures from temp_dir(), + // which is absolute everywhere. + let dir = std::env::temp_dir(); + let path = |name: &str| dir.join(name).to_string_lossy().into_owned(); + + for name in ["a.md", "a.TXT", "board.excalidraw"] { + let p = path(name); + assert!(validate_export_path(&p).is_ok(), "{p}"); + } + for name in ["a.json", "a"] { + let p = path(name); + assert!(validate_export_path(&p).is_err(), "{p}"); + } + // A relative path is refused whatever its extension. + assert!(validate_export_path("relative/a.md").is_err()); + } #[test] fn theme_file_round_trip() { @@ -169,7 +424,7 @@ mod tests { fn import_missing_file_errors() { let res = import_theme_file("/nonexistent/path/theme.intheme.json".into()); assert!(res.is_err()); - assert_eq!(res.unwrap_err().code, "STORAGE_ERROR"); + assert_eq!(res.unwrap_err().code(), crate::error::ErrorCode::Storage); } #[test] diff --git a/src-tauri/src/shell/mirror.rs b/src-tauri/src/shell/mirror.rs new file mode 100644 index 0000000..dd14e6f --- /dev/null +++ b/src-tauri/src/shell/mirror.rs @@ -0,0 +1,98 @@ +//! The live vault mirror's background writer (stage 2 of +//! `feat-portable-vault-sync`). Every write command already announces itself +//! through one of the `emit_*_changed` helpers; those also poke this thread, +//! which waits for the writes to go quiet and then flushes the pending notes +//! to the vault folder. The write that marked a note pending never waits on +//! the disk, and a burst (typing an auto-titled first line) becomes one +//! write and one rename instead of dozens. +//! +//! Nothing can be lost here: pending notes are recorded in the database by +//! migration v5's triggers, so a missed poke, a failed write, or a crash +//! only delays the file until the next flush or the next launch. + +use crate::*; +use instantnotes_core::vault; +use std::sync::mpsc::{channel, Sender}; +use std::time::{Duration, Instant}; + +/// How long writes must pause before a flush. +const QUIET: Duration = Duration::from_millis(300); +/// A flush happens at least this often while writes keep arriving. +const MAX_DELAY: Duration = Duration::from_secs(2); +/// Notes written per lock hold, so a first mirror of a large library never +/// holds up an autosave for longer than one chunk. Each note costs about +/// 5 ms, almost all of it the fsync (measured on a real library), so a +/// chunk holds the store for roughly a quarter of a second. +const CHUNK: usize = 50; + +pub(crate) struct VaultFlusher { + poke: Sender<()>, +} + +pub(crate) fn start_vault_flusher(app: &AppHandle) -> VaultFlusher { + let (poke, pokes) = channel::<()>(); + let handle = app.clone(); + std::thread::spawn(move || { + while pokes.recv().is_ok() { + let first = Instant::now(); + while first.elapsed() < MAX_DELAY && pokes.recv_timeout(QUIET).is_ok() {} + flush_vault_now(&handle, None); + } + }); + VaultFlusher { poke } +} + +/// Ask the background writer for a flush. A no-op before setup has +/// created it. +pub(crate) fn request_vault_flush(app: &AppHandle) { + if let Some(flusher) = app.try_state::() { + let _ = flusher.poke.send(()); + } +} + +/// Flush pending notes on this thread, chunk by chunk, releasing the store +/// between chunks. `max_chunks` bounds the work where waiting is visible +/// (quit); whatever is left stays pending for the next launch. Errors land +/// in the store's vault status for the Settings page, never in a log: +/// they carry note filenames, which are note titles (SEC-001). +pub(crate) fn flush_vault_now(app: &AppHandle, max_chunks: Option) { + let Some(state) = app.try_state::() else { + return; + }; + let mut chunks = 0; + loop { + let outcome = { + let Ok(mut store) = state.store.lock() else { + return; + }; + if store.vault_root().is_none() { + return; + } + store.flush_vault(CHUNK) + }; + chunks += 1; + let more = matches!(&outcome, Ok(out) + if out.errors.is_empty() && out.written > 0 && out.remaining > 0); + if !more || max_chunks.is_some_and(|max| chunks >= max) { + break; + } + } + let _ = app.emit(events::VAULT_STATUS, ()); +} + +/// Copy attachments the vault does not have yet into `/attachments`. +/// Names are uuids, so an existing name is already the same image. Skipped +/// while the vault folder is missing, which must never be recreated. +pub(crate) fn mirror_attachments(app: &AppHandle) -> CmdResult<()> { + let root = { + let state = app.state::(); + let store = locked(&state)?; + store.vault_root().map(std::path::Path::to_path_buf) + }; + let Some(root) = root.filter(|r| r.is_dir()) else { + return Ok(()); + }; + vault::copy_missing_files(&attachments_dir(app)?, &root.join("attachments")) + .map(|_| ()) + .map_err(|e| CmdError::storage(format!("could not copy attachments into the vault: {e}"))) +} diff --git a/src-tauri/src/shell/mod.rs b/src-tauri/src/shell/mod.rs index 0a0544d..956eaa6 100644 --- a/src-tauri/src/shell/mod.rs +++ b/src-tauri/src/shell/mod.rs @@ -1,8 +1,12 @@ //! The desktop shell behind run(): capture-latency metrics, window management, -//! file I/O commands, and the quit handshake. run() keeps only the native menu, -//! tray, and setup wiring that composes these. +//! file I/O commands, the vault mirror's background writer, and the quit +//! handshake, and the watcher that notices agent writes. run() keeps only +//! the native menu, tray, and setup wiring that composes these. +pub(crate) mod agents; pub(crate) mod capture; pub(crate) mod files; +pub(crate) mod mirror; pub(crate) mod quit; +pub(crate) mod stickies; pub(crate) mod windows; diff --git a/src-tauri/src/shell/quit.rs b/src-tauri/src/shell/quit.rs index 1815fd8..3ba457d 100644 --- a/src-tauri/src/shell/quit.rs +++ b/src-tauri/src/shell/quit.rs @@ -1,11 +1,40 @@ //! The quit handshake. Body edits are debounced in the webview, so exiting the //! process directly would drop the tail of whatever was just typed. Every quit -//! path (menu, tray, Dock) emits "app:quit-requested"; the library window -//! flushes its pending edits and answers with quit_app, which really exits. +//! path (menu, tray, Dock) emits "app:quit-requested"; every window that holds +//! edits (the library and each sticky) flushes and answers with quit_app, and +//! the last answer really exits. use crate::*; +use std::collections::HashSet; use std::sync::atomic::{AtomicBool, Ordering}; +/// Labels of the windows whose flush the current quit still waits for. +static AWAITING: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Mutex::new(HashSet::new())); + +/// Windows that answer the quit handshake: the library and every sticky. The +/// capture panel persists its draft on the event and never answers. +fn answering_windows(app: &AppHandle) -> HashSet { + app.webview_windows() + .into_keys() + .filter(|label| label == "library" || label.starts_with("sticky-")) + .collect() +} + +/// Record one window's answer; true once no window is left to wait for. +fn record_answer(awaiting: &mut HashSet, label: &str) -> bool { + awaiting.remove(label); + awaiting.is_empty() +} + +/// Exit once, whichever of the last answer and the fallback gets here first. +fn finish_quit(app: &AppHandle) { + if !QUIT_READY.swap(true, Ordering::AcqRel) { + flush_vault_now(app, Some(QUIT_VAULT_CHUNKS)); + app.exit(0); + } +} + /// True once the frontend flushed and called quit_app, or once the fallback /// gave up waiting. ExitRequested lets the exit proceed only when this is set, /// so the flush handshake runs at most once per quit. @@ -14,30 +43,58 @@ pub(crate) static QUIT_READY: AtomicBool = AtomicBool::new(false); /// How long a quit waits for the webview flush before exiting anyway. const QUIT_FLUSH_GRACE_MS: u64 = 800; +/// The vault mirror gets one chunk on the way out: enough for anything just +/// typed, without making quit wait on a large first mirror, which the next +/// launch resumes from the pending rows. +const QUIT_VAULT_CHUNKS: usize = 1; + /// Ask the webviews to flush, then exit. The fallback timer exists because /// quit must not block forever on a dead webview: if the frontend never /// answers with quit_app, exit anyway after the grace period. pub(crate) fn request_quit(app: &AppHandle) { - let _ = app.emit("app:quit-requested", ()); + if let Ok(mut awaiting) = AWAITING.lock() { + *awaiting = answering_windows(app); + } + let _ = app.emit(events::APP_QUIT_REQUESTED, ()); let handle = app.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(QUIT_FLUSH_GRACE_MS)); - // swap keeps the fallback and quit_app from racing: whichever runs - // first marks the handshake done and the other becomes a no-op. - if !QUIT_READY.swap(true, Ordering::AcqRel) { - handle.exit(0); - } + finish_quit(&handle); }); } -/// Final leg of the handshake: the library webview has flushed pending edits. +/// A window's leg of the handshake: it has flushed its pending edits. The +/// app exits when the last window it waits for has answered. #[tauri::command] -pub fn quit_app(app: AppHandle) { - QUIT_READY.store(true, Ordering::Release); - app.exit(0); +pub fn quit_app(app: AppHandle, window: tauri::WebviewWindow) { + let done = AWAITING + .lock() + .map(|mut awaiting| record_answer(&mut awaiting, window.label())) + .unwrap_or(true); + if done { + finish_quit(&app); + } } #[tauri::command] pub fn get_shortcut_failure(state: State<'_, ShortcutStatus>) -> Option { state.failed.clone() } + +#[cfg(test)] +mod tests { + use super::record_answer; + use std::collections::HashSet; + + #[test] + fn quit_waits_for_every_window_that_holds_edits() { + let mut awaiting: HashSet = ["library", "sticky-a"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert!(!record_answer(&mut awaiting, "library")); + // A repeat answer from the same window does not count twice. + assert!(!record_answer(&mut awaiting, "library")); + assert!(record_answer(&mut awaiting, "sticky-a")); + } +} diff --git a/src-tauri/src/shell/stickies.rs b/src-tauri/src/shell/stickies.rs new file mode 100644 index 0000000..9f3e9ad --- /dev/null +++ b/src-tauri/src/shell/stickies.rs @@ -0,0 +1,508 @@ +//! Sticky notes: a note popped out of the library into its own small window. +//! +//! A sticky is a handoff, not a second view. While a note is a sticky, its +//! window is the note's only editor and the library shows a placeholder, so +//! the store never sees two writers on one note. Popping back in asks the +//! sticky to flush and waits for its answer before the window goes away. +//! +//! Which notes are stickies, and where their windows sit, is per-device window +//! state rather than note data: one JSON map in the settings table under +//! `stickies`, never in the vault. + +use crate::*; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::mpsc; +use std::time::Duration; +use tauri::{LogicalPosition, LogicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +const SETTING_KEY: &str = "stickies"; +const LABEL_PREFIX: &str = "sticky-"; +const DEFAULT_SIZE: (f64, f64) = (320.0, 320.0); +const MIN_SIZE: (f64, f64) = (220.0, 160.0); +/// A collapsed sticky is only its header strip, like a Stickies note after +/// a double-click on its title bar. Matches the header height in +/// `src/routes/sticky/+page.svelte`. +pub(crate) const STRIP_HEIGHT: f64 = 30.0; +/// How long popping in waits for the sticky to flush before closing it +/// anyway. Only a hung webview reaches the limit, and its unsaved edit is +/// unreachable either way; the same reasoning as the quit handshake. +const POP_IN_GRACE: Duration = Duration::from_millis(1500); +/// How much of a sticky must overlap a monitor to count as on screen. +const VISIBLE_MARGIN: f64 = 40.0; + +/// Where the sticky sits relative to other windows. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StickyLevel { + /// Above every window, on every Space. + #[default] + Float, + /// An ordinary window. + Normal, + /// Below every window, on every Space: a desktop widget. + Desktop, +} + +/// One sticky's window, in logical pixels. No position means centered. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StickyState { + pub x: Option, + pub y: Option, + pub width: f64, + pub height: f64, + #[serde(default)] + pub level: StickyLevel, + /// Rolled up to its header. `height` keeps the expanded height. + #[serde(default)] + pub collapsed: bool, +} + +impl Default for StickyState { + fn default() -> Self { + StickyState { + x: None, + y: None, + width: DEFAULT_SIZE.0, + height: DEFAULT_SIZE.1, + level: StickyLevel::default(), + collapsed: false, + } + } +} + +type StickyMap = BTreeMap; + +pub(crate) fn label_for(note_id: &str) -> String { + format!("{LABEL_PREFIX}{note_id}") +} + +fn note_id_of(label: &str) -> Option<&str> { + label.strip_prefix(LABEL_PREFIX) +} + +/// The saved map. An unreadable value is treated as empty: losing where +/// stickies sat is harmless, and it must never block launch. +fn load_map(store: &Store) -> StickyMap { + store + .get_setting(SETTING_KEY) + .ok() + .flatten() + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default() +} + +fn save_map(store: &mut Store, map: &StickyMap) -> CmdResult<()> { + let value = + serde_json::to_value(map).map_err(|e| CmdError::storage(format!("stickies: {e}")))?; + Ok(store.set_setting(SETTING_KEY, value)?) +} + +fn with_map( + state: &State<'_, AppState>, + edit: impl FnOnce(&mut StickyMap) -> T, +) -> CmdResult { + let mut store = locked(state)?; + let mut map = load_map(&store); + let out = edit(&mut map); + save_map(&mut store, &map)?; + Ok(out) +} + +fn apply_level(win: &WebviewWindow, level: StickyLevel) { + let _ = win.set_always_on_top(level == StickyLevel::Float); + let _ = win.set_always_on_bottom(level == StickyLevel::Desktop); + let _ = win.set_visible_on_all_workspaces(level != StickyLevel::Normal); +} + +/// Roll the window up to its header strip, or back down to `height`. A +/// collapsed sticky can still be widened but not heightened. +fn apply_collapsed(win: &WebviewWindow, sticky: &StickyState) { + let width = win + .inner_size() + .ok() + .zip(win.scale_factor().ok()) + .map(|(size, scale)| size.to_logical::(scale).width) + .unwrap_or(sticky.width); + if sticky.collapsed { + let _ = win.set_min_size(Some(LogicalSize::new(MIN_SIZE.0, STRIP_HEIGHT))); + let _ = win.set_max_size(Some(LogicalSize::new(16_384.0, STRIP_HEIGHT))); + let _ = win.set_size(LogicalSize::new(width, STRIP_HEIGHT)); + } else { + let _ = win.set_max_size(None::>); + let _ = win.set_min_size(Some(LogicalSize::new(MIN_SIZE.0, MIN_SIZE.1))); + let _ = win.set_size(LogicalSize::new(width, sticky.height)); + } +} + +fn build_window(app: &AppHandle, note_id: &str, sticky: &StickyState) -> tauri::Result<()> { + let mut builder = + WebviewWindowBuilder::new(app, label_for(note_id), WebviewUrl::App("sticky".into())) + .title("InstantNotes Sticky") + .inner_size(sticky.width, sticky.height) + .min_inner_size(MIN_SIZE.0, MIN_SIZE.1) + .decorations(false) + .transparent(true) + .skip_taskbar(true) + // One gesture moves a sticky that is not in front, as a title bar + // does; without this the first click only activates the window. + .accept_first_mouse(true) + .focused(true); + builder = match (sticky.x, sticky.y) { + (Some(x), Some(y)) => builder.position(x, y), + _ => builder.center(), + }; + let win = builder.build()?; + apply_level(&win, sticky.level); + if sticky.collapsed { + apply_collapsed(&win, sticky); + } + // Closing a sticky by any route (⌘W, the window menu) is popping it in, + // which has to flush first; the flush answers and then really closes. + let handle = app.clone(); + let id = note_id.to_string(); + win.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let handle = handle.clone(); + let id = id.clone(); + std::thread::spawn(move || { + let _ = pop_in(&handle, &id); + }); + } + }); + Ok(()) +} + +/// A monitor's area in logical pixels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Area { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// Whether a window at this rect shows enough of itself on some monitor to be +/// grabbed. A sticky saved on a display that is gone fails this and is +/// re-centered rather than restored off screen. +pub(crate) fn is_reachable(rect: Area, monitors: &[Area]) -> bool { + monitors.iter().any(|m| { + let overlap_w = (rect.x + rect.width).min(m.x + m.width) - rect.x.max(m.x); + let overlap_h = (rect.y + rect.height).min(m.y + m.height) - rect.y.max(m.y); + overlap_w >= VISIBLE_MARGIN && overlap_h >= VISIBLE_MARGIN + }) +} + +fn monitor_areas(app: &AppHandle) -> Vec { + app.available_monitors() + .unwrap_or_default() + .iter() + .map(|m| { + let scale = m.scale_factor(); + let pos = m.position().to_logical::(scale); + let size = m.size().to_logical::(scale); + Area { + x: pos.x, + y: pos.y, + width: size.width, + height: size.height, + } + }) + .collect() +} + +/// Reopen every sticky from the last session. Entries whose note is gone or +/// in the Trash are dropped; a window that fails to build is dropped too, so +/// one bad entry cannot strand a note in the "popped out" state. +pub(crate) fn restore_stickies(app: &AppHandle) { + let state = app.state::(); + let Ok(mut store) = state.store.lock() else { + return; + }; + let saved = load_map(&store); + if saved.is_empty() { + return; + } + let monitors = monitor_areas(app); + let mut kept = StickyMap::new(); + for (id, mut sticky) in saved { + let live = store.get_note(&id, false).is_ok_and(|n| !n.is_deleted); + if !live { + continue; + } + if let (Some(x), Some(y)) = (sticky.x, sticky.y) { + let rect = Area { + x, + y, + width: sticky.width, + height: sticky.height, + }; + if !monitors.is_empty() && !is_reachable(rect, &monitors) { + sticky.x = None; + sticky.y = None; + } + } + if build_window(app, &id, &sticky).is_ok() { + kept.insert(id, sticky); + } + } + let _ = save_map(&mut store, &kept); +} + +// ---- pop in ---- + +/// Senders waiting for a sticky's flush answer, by window label. +static POP_IN_WAITERS: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Ask the sticky to flush, wait for its answer, then close it and forget it. +/// A sticky that reports its save failed stays open with its edit, rather +/// than closing on text that never reached disk. +fn pop_in(app: &AppHandle, note_id: &str) -> CmdResult<()> { + let label = label_for(note_id); + if let Some(win) = app.get_webview_window(&label) { + let (tx, rx) = mpsc::channel(); + if let Ok(mut waiters) = POP_IN_WAITERS.lock() { + waiters.insert(label.clone(), tx); + } + let _ = win.emit_to(&label, events::STICKY_CLOSE_REQUESTED, ()); + let answer = rx.recv_timeout(POP_IN_GRACE); + if let Ok(mut waiters) = POP_IN_WAITERS.lock() { + waiters.remove(&label); + } + if answer == Ok(false) { + return Err(CmdError::storage( + "The sticky couldn't save, so it stays open with your text.", + )); + } + let _ = win.destroy(); + } + let state = app.state::(); + with_map(&state, |map| { + map.remove(note_id); + })?; + let _ = app.emit(events::STICKIES_CHANGED, ()); + Ok(()) +} + +/// The sticky's answer to "sticky:close-requested": its edits are on disk +/// (`saved`), or they are not and it stays open. +#[tauri::command] +pub fn answer_pop_in(window: WebviewWindow, saved: bool) { + if let Ok(waiters) = POP_IN_WAITERS.lock() { + if let Some(tx) = waiters.get(window.label()) { + let _ = tx.send(saved); + } + } +} + +// ---- commands ---- + +/// Turn a note into a sticky, or bring its sticky forward if it is one. The +/// library flushes the note's pending edit before calling this. +#[tauri::command(async)] +pub fn pop_out_note(state: State<'_, AppState>, app: AppHandle, id: String) -> CmdResult<()> { + if let Some(win) = app.get_webview_window(&label_for(&id)) { + let _ = win.show(); + let _ = win.set_focus(); + return Ok(()); + } + let note = locked(&state)?.get_note(&id, false)?; + if note.is_deleted { + return Err(CmdError::validation( + "a note in the Trash cannot be a sticky", + )); + } + let sticky = with_map(&state, |map| map.entry(id.clone()).or_default().clone())?; + if let Err(e) = build_window(&app, &id, &sticky) { + let _ = with_map(&state, |map| { + map.remove(&id); + }); + return Err(CmdError::storage(format!("couldn't open the sticky: {e}"))); + } + let _ = app.emit(events::STICKIES_CHANGED, ()); + Ok(()) +} + +/// Turn a sticky back into a normal note. Resolves once the sticky's edits +/// are on disk and its window is gone, so the caller can reload the note. +#[tauri::command(async)] +pub fn pop_in_note(app: AppHandle, id: String) -> CmdResult<()> { + pop_in(&app, &id) +} + +/// Ids of the notes that are stickies right now. +#[tauri::command(async)] +pub fn list_stickies(state: State<'_, AppState>) -> CmdResult> { + Ok(load_map(&*locked(&state)?).into_keys().collect()) +} + +/// The calling sticky's level. Applied now and remembered for next launch. +#[tauri::command(async)] +pub fn set_sticky_level( + state: State<'_, AppState>, + window: WebviewWindow, + level: StickyLevel, +) -> CmdResult<()> { + let Some(id) = note_id_of(window.label()) else { + return Err(CmdError::validation("not a sticky window")); + }; + apply_level(&window, level); + with_map(&state, |map| { + if let Some(sticky) = map.get_mut(id) { + sticky.level = level; + } + }) +} + +/// Roll the calling sticky up to its header, or back down. Remembered for +/// next launch, like its level. +#[tauri::command(async)] +pub fn set_sticky_collapsed( + state: State<'_, AppState>, + window: WebviewWindow, + collapsed: bool, +) -> CmdResult<()> { + let Some(id) = note_id_of(window.label()) else { + return Err(CmdError::validation("not a sticky window")); + }; + let sticky = with_map(&state, |map| { + map.get_mut(id).map(|sticky| { + sticky.collapsed = collapsed; + sticky.clone() + }) + })?; + if let Some(sticky) = sticky { + apply_collapsed(&window, &sticky); + } + Ok(()) +} + +/// The calling sticky's header state on load. +#[derive(Debug, Clone, Copy, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StickyView { + pub level: StickyLevel, + pub collapsed: bool, +} + +#[tauri::command(async)] +pub fn get_sticky_view(state: State<'_, AppState>, window: WebviewWindow) -> CmdResult { + let id = note_id_of(window.label()).unwrap_or_default(); + Ok(load_map(&*locked(&state)?) + .get(id) + .map(|s| StickyView { + level: s.level, + collapsed: s.collapsed, + }) + .unwrap_or_default()) +} + +/// Remember where the calling sticky sits. Read from the window itself, so +/// the webview only says "now", never a position it could get wrong. +#[tauri::command(async)] +pub fn save_sticky_geometry(state: State<'_, AppState>, window: WebviewWindow) -> CmdResult<()> { + let Some(id) = note_id_of(window.label()) else { + return Err(CmdError::validation("not a sticky window")); + }; + let scale = window.scale_factor().unwrap_or(1.0); + let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) else { + return Ok(()); + }; + let pos: LogicalPosition = pos.to_logical(scale); + let size: LogicalSize = size.to_logical(scale); + with_map(&state, |map| { + if let Some(sticky) = map.get_mut(id) { + sticky.x = Some(pos.x); + sticky.y = Some(pos.y); + sticky.width = size.width; + // Collapsed, the window is only the strip; the height to restore + // is the one saved before it rolled up. + if !sticky.collapsed { + sticky.height = size.height; + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAIN: Area = Area { + x: 0.0, + y: 0.0, + width: 1440.0, + height: 900.0, + }; + + fn at(x: f64, y: f64) -> Area { + Area { + x, + y, + width: 320.0, + height: 320.0, + } + } + + #[test] + fn a_sticky_on_a_monitor_is_reachable() { + assert!(is_reachable(at(100.0, 100.0), &[MAIN])); + // Mostly off the right edge, but a grabbable strip remains. + assert!(is_reachable(at(1440.0 - 60.0, 100.0), &[MAIN])); + } + + #[test] + fn a_sticky_on_a_missing_display_is_not() { + // Saved on a second display to the right that is now unplugged. + assert!(!is_reachable(at(1800.0, 200.0), &[MAIN])); + // Only a sliver shows: not enough to grab. + assert!(!is_reachable(at(1440.0 - 10.0, 100.0), &[MAIN])); + } + + #[test] + fn a_second_display_counts() { + let left = Area { + x: -1920.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + assert!(is_reachable(at(-600.0, 300.0), &[MAIN, left])); + } + + #[test] + fn labels_round_trip_note_ids() { + let id = "0b9c7a52-5f1e-4c1f-9b7a-3f2d1e0c9a8b"; + assert_eq!(note_id_of(&label_for(id)), Some(id)); + assert_eq!(note_id_of("library"), None); + } + + #[test] + fn the_saved_map_round_trips_and_tolerates_older_entries() { + let mut map = StickyMap::new(); + map.insert( + "a".into(), + StickyState { + x: Some(10.0), + y: Some(20.0), + width: 300.0, + height: 240.0, + level: StickyLevel::Desktop, + collapsed: true, + }, + ); + let json = serde_json::to_value(&map).unwrap(); + assert_eq!(json["a"]["level"], "desktop"); + assert_eq!(serde_json::from_value::(json).unwrap(), map); + // An entry without a level floats, the default. + let bare: StickyMap = + serde_json::from_value(serde_json::json!({"b": {"width": 300.0, "height": 200.0}})) + .unwrap(); + assert_eq!(bare["b"].level, StickyLevel::Float); + assert_eq!(bare["b"].x, None); + assert!(!bare["b"].collapsed); + } +} diff --git a/src-tauri/src/shell/windows.rs b/src-tauri/src/shell/windows.rs index 816c102..81d1acb 100644 --- a/src-tauri/src/shell/windows.rs +++ b/src-tauri/src/shell/windows.rs @@ -86,7 +86,7 @@ pub(crate) fn show_capture_window(app: &AppHandle) { let _ = w.show(); let _ = w.set_focus(); // Frontend focuses the textarea and restores any preserved draft. - let _ = w.emit("capture:shown", ()); + let _ = w.emit(events::CAPTURE_SHOWN, ()); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 40b3461..2443a6e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,11 +1,11 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "InstantNotes", - "version": "0.8.0", + "version": "0.9.0", "identifier": "com.instantnotes.app", "build": { "beforeDevCommand": "npm run dev", - "devUrl": "http://localhost:1420", + "devUrl": "http://localhost:1422", "beforeBuildCommand": "npm run build", "frontendDist": "../build" }, diff --git a/src-tauri/tauri.dev.conf.json b/src-tauri/tauri.dev.conf.json index 90e18f7..aaa8fe9 100644 --- a/src-tauri/tauri.dev.conf.json +++ b/src-tauri/tauri.dev.conf.json @@ -4,7 +4,7 @@ "identifier": "com.instantnotes.dev", "app": { "security": { - "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420 ws://localhost:1421" + "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost http://localhost:1422 ws://localhost:1422 ws://localhost:1423" }, "windows": [ { diff --git a/src/lib/AgreementGate.svelte b/src/lib/AgreementGate.svelte new file mode 100644 index 0000000..50749b4 --- /dev/null +++ b/src/lib/AgreementGate.svelte @@ -0,0 +1,157 @@ + + +{#if agreements.done} + {@render children?.()} +{:else} + +{/if} + + diff --git a/src/lib/EulaGate.svelte b/src/lib/EulaGate.svelte deleted file mode 100644 index 39d98b8..0000000 --- a/src/lib/EulaGate.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -{#if accepted} - {@render children?.()} -{:else} - -{/if} - - diff --git a/src/lib/agent-activity.test.ts b/src/lib/agent-activity.test.ts new file mode 100644 index 0000000..359ccdc --- /dev/null +++ b/src/lib/agent-activity.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + claudeCodeCommand, + clientLabel, + describeActivity, + mcpServersJson, + parseAccess, + parseActivityLog, + timeAgo, + type AgentActivity, +} from "./agent-activity"; + +const entry = (over: Partial): AgentActivity => ({ + at: 0, + client: "claude-code", + tool: "get_note", + kind: "read", + noteIds: ["n1"], + noteCount: 1, + titles: ["Groceries"], + space: null, + tag: null, + query: null, + ...over, +}); + +describe("agent activity", () => { + it("names known clients and tidies unknown ones", () => { + expect(clientLabel("claude-code")).toBe("Claude Code"); + expect(clientLabel("Cursor")).toBe("Cursor"); + expect(clientLabel("my-local_bot")).toBe("My Local Bot"); + expect(clientLabel("")).toBe("An agent"); + expect(clientLabel("agent")).toBe("An agent"); + }); + + it("describes calls in plain words", () => { + expect(describeActivity(entry({}))).toBe("Reading “Groceries”"); + expect(describeActivity(entry({ tool: "list_notes", space: "Ideas" }))).toBe( + "Looking through Ideas", + ); + expect(describeActivity(entry({ tool: "search_notes", query: "sync" }))).toBe( + "Searching for “sync”", + ); + expect(describeActivity(entry({ tool: "append_to_note", kind: "write" }))).toBe( + "Adding to “Groceries”", + ); + expect(describeActivity(entry({ tool: "create_note", titles: [""] }))).toBe( + "Writing a new note, “Untitled”", + ); + }); + + it("reads access defensively, defaulting to off", () => { + expect(parseAccess("write")).toBe("write"); + expect(parseAccess("read")).toBe("read"); + expect(parseAccess("everything")).toBe("off"); + expect(parseAccess(null)).toBe("off"); + }); + + it("drops malformed log entries", () => { + const good = entry({}); + expect(parseActivityLog([good, { at: "x" }, null, 3])).toEqual([good]); + expect(parseActivityLog("nope")).toEqual([]); + }); + + it("says how long ago", () => { + expect(timeAgo(0, 10_000)).toBe("just now"); + expect(timeAgo(0, 4 * 60_000)).toBe("4 min ago"); + expect(timeAgo(0, 3 * 3_600_000)).toBe("3 h ago"); + expect(timeAgo(0, 3 * 86_400_000)).toBe("3 d ago"); + }); + + it("builds connect commands, quoting paths with spaces", () => { + const c = { + exe: "/Applications/InstantNotes.app/Contents/MacOS/instantnotes", + db: "/Users/me/Library/Application Support/com.instantnotes.app/instantnotes.db", + attachments: null, + }; + expect(claudeCodeCommand(c)).toBe( + "claude mcp add instantnotes -- /Applications/InstantNotes.app/Contents/MacOS/instantnotes mcp --db '/Users/me/Library/Application Support/com.instantnotes.app/instantnotes.db'", + ); + const json = JSON.parse(mcpServersJson({ ...c, attachments: "/a b" })); + expect(json.mcpServers.instantnotes.args).toEqual([ + "mcp", + "--db", + c.db, + "--attachments", + "/a b", + ]); + }); +}); diff --git a/src/lib/agent-activity.ts b/src/lib/agent-activity.ts new file mode 100644 index 0000000..8072619 --- /dev/null +++ b/src/lib/agent-activity.ts @@ -0,0 +1,166 @@ +// Agent access, the parts with no runes: the activity record the MCP server +// writes (src-tauri/agents/src/tools.rs), how it reads in plain words, and +// the commands that connect an agent. Shared by the agents store, the +// sidebar's live line, and Settings > Agents. + +/** Settings keys, owned by the MCP server's side (tools.rs). */ +export const AGENT_ACCESS_KEY = "agents.access"; +export const AGENT_ACTIVITY_KEY = "agents.activity"; + +export type AgentAccess = "off" | "read" | "write"; + +/** One agent call, as the server records it. */ +export interface AgentActivity { + /** Epoch milliseconds. */ + at: number; + /** The client's own name from the MCP handshake, e.g. "claude-code". */ + client: string; + tool: string; + kind: "read" | "write"; + noteIds: string[]; + noteCount: number; + /** The first few touched notes' titles. */ + titles: string[]; + space: string | null; + tag: string | null; + query: string | null; +} + +export function parseAccess(value: unknown): AgentAccess { + return value === "read" || value === "write" ? value : "off"; +} + +/** Keep only well-formed entries: the log is data another process wrote. */ +export function parseActivityLog(value: unknown): AgentActivity[] { + if (!Array.isArray(value)) return []; + return value.filter( + (e): e is AgentActivity => + !!e && + typeof e === "object" && + typeof e.at === "number" && + typeof e.tool === "string" && + (e.kind === "read" || e.kind === "write") && + Array.isArray(e.noteIds), + ); +} + +/** Whether a batch of outside writes may have changed this note. No entries + * means a write the log does not describe (a second copy of the app), which + * may have touched anything. */ +export function mayHaveWritten(entries: AgentActivity[], noteId: string): boolean { + return ( + entries.length === 0 || + entries.some((e) => e.kind === "write" && e.noteIds.includes(noteId)) + ); +} + +const KNOWN_CLIENTS: Record = { + "claude-code": "Claude Code", + "claude-ai": "Claude", + "claude-desktop": "Claude", + codex: "Codex", + "codex-mcp-client": "Codex", + cursor: "Cursor", + "cursor-vscode": "Cursor", + "gemini-cli": "Gemini CLI", + hermes: "Hermes", + vscode: "VS Code", + zed: "Zed", +}; + +/** A client's handshake name as a person would say it. */ +export function clientLabel(raw: string): string { + const name = raw.trim(); + const known = KNOWN_CLIENTS[name.toLowerCase()]; + if (known) return known; + if (!name || name === "agent") return "An agent"; + return name + .split(/[-_\s]+/) + .filter(Boolean) + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(" "); +} + +const quote = (s: string) => `“${s || "Untitled"}”`; + +/** What the call did, in words: "Reading “Groceries”". */ +export function describeActivity(e: AgentActivity): string { + const title = quote(e.titles[0] ?? ""); + const more = e.noteCount > 1 ? ` and ${e.noteCount - 1} more` : ""; + switch (e.tool) { + case "search_notes": + return `Searching for ${quote(e.query ?? "")}`; + case "list_notes": + if (e.space) return `Looking through ${e.space}`; + if (e.tag) return `Looking through #${e.tag}`; + return "Looking through your notes"; + case "get_note": + return `Reading ${title}${more}`; + case "list_tags": + return "Looking at your tags"; + case "list_spaces": + return "Looking at your Spaces"; + case "create_note": + return `Writing a new note, ${title}`; + case "update_note": + return `Editing ${title}`; + case "append_to_note": + return `Adding to ${title}`; + case "tag_note": + return `Tagging ${title} #${e.tag ?? ""}`; + case "untag_note": + return `Removing #${e.tag ?? ""} from ${title}`; + case "add_to_space": + return `Filing ${title} in ${e.space ?? "a Space"}`; + case "remove_from_space": + return `Taking ${title} out of ${e.space ?? "a Space"}`; + case "trash_note": + return `Moving ${title} to Trash`; + case "restore_note": + return `Restoring ${title}`; + default: + return e.kind === "write" ? "Changing your notes" : "Reading your notes"; + } +} + +/** "just now", "4 min ago", "2 h ago", "3 d ago". */ +export function timeAgo(at: number, now: number): string { + const s = Math.max(0, Math.round((now - at) / 1000)); + if (s < 45) return "just now"; + const m = Math.round(s / 60); + if (m < 60) return `${m} min ago`; + const h = Math.round(m / 60); + if (h < 48) return `${h} h ago`; + return `${Math.round(h / 24)} d ago`; +} + +/** Where the app and its library live, from `agent_connection`. */ +export interface AgentConnection { + exe: string; + db: string; + attachments: string | null; +} + +function serverArgs(c: AgentConnection): string[] { + const args = ["mcp", "--db", c.db]; + if (c.attachments) args.push("--attachments", c.attachments); + return args; +} + +const shellQuote = (s: string) => (/^[\w./:@-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`); + +/** One line for Claude Code. */ +export function claudeCodeCommand(c: AgentConnection): string { + return ["claude", "mcp", "add", "instantnotes", "--", c.exe, ...serverArgs(c)] + .map(shellQuote) + .join(" "); +} + +/** The `mcpServers` entry most other clients take (Claude Desktop, Cursor). */ +export function mcpServersJson(c: AgentConnection): string { + return JSON.stringify( + { mcpServers: { instantnotes: { command: c.exe, args: serverArgs(c) } } }, + null, + 2, + ); +} diff --git a/src/lib/agreements.svelte.ts b/src/lib/agreements.svelte.ts new file mode 100644 index 0000000..4453cc6 --- /dev/null +++ b/src/lib/agreements.svelte.ts @@ -0,0 +1,108 @@ +// The Jam-Sw agreement gate for Svelte apps: the rules every platform follows +// (installers README, "The agreement gate"), with no UI of its own. Copied +// into the app by installers/scripts/apply.mjs, so never edit the copy. +// +// Render it with AgreementGate.svelte, or in the app's own way, as long as +// nothing in the app is usable until `done` is true and every document is +// shown with its own agreement. The texts come from agreements.json, which +// apply.mjs writes from the same strings as LICENSE and EULA.txt. + +import manifest from '../../src-tauri/installer/agreements.json'; + +export interface AgreementDocument { + id: string; + title: string; + /** Agreement is stored per version; a new version asks again. */ + version: string; + text: string; +} + +export interface AgreementCopy { + heading: string; + lead: string; + agree: string; + agreed: string; + decline: string; +} + +/** Document id to the version agreed to. */ +export type Accepted = Record; + +type Versioned = Pick; + +function asAccepted(stored: unknown): Accepted { + if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {}; + return Object.fromEntries( + Object.entries(stored).filter(([, v]) => typeof v === 'string'), + ) as Accepted; +} + +/** Ids of the documents still to agree to, in manifest order. */ +export function pendingDocuments(documents: Versioned[], stored: unknown): string[] { + const accepted = asAccepted(stored); + return documents.filter((d) => accepted[d.id] !== d.version).map((d) => d.id); +} + +/** What to store after agreeing to `id`: the version shown for it, the other + * listed documents as they were, and nothing for documents no longer listed. */ +export function withAccepted(documents: Versioned[], stored: unknown, id: string): Accepted { + const accepted = asAccepted(stored); + const next: Accepted = {}; + for (const d of documents) { + if (d.id === id) next[d.id] = d.version; + else if (d.id in accepted) next[d.id] = accepted[d.id]; + } + return next; +} + +const KEY: string = manifest.storageKey; + +function read(): unknown { + try { + const raw = localStorage.getItem(KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + // Unreadable or unavailable: nothing counts as agreed, the safe side. + return {}; + } +} + +class Agreements { + readonly documents: AgreementDocument[] = manifest.documents; + readonly copy: AgreementCopy = manifest.copy; + #stored = $state(read()); + readonly pending = $derived(pendingDocuments(this.documents, this.#stored)); + + /** True once every document is agreed to at its current version. */ + get done(): boolean { + return this.pending.length === 0; + } + + isAgreed(id: string): boolean { + return this.documents.some((d) => d.id === id) && !this.pending.includes(id); + } + + agree(id: string): void { + const next = withAccepted(this.documents, this.#stored, id); + this.#stored = next; + try { + localStorage.setItem(KEY, JSON.stringify(next)); + } catch { + // Not stored: the gate asks again next launch, which is the safe side. + } + } + + /** Re-read what this device agreed to; another window may have agreed. */ + refresh(): void { + this.#stored = read(); + } +} + +export const agreements = new Agreements(); + +if (typeof window !== 'undefined') { + window.addEventListener('storage', (e) => { + if (e.key === KEY) agreements.refresh(); + }); + window.addEventListener('focus', () => agreements.refresh()); +} diff --git a/src/lib/agreements.test.ts b/src/lib/agreements.test.ts new file mode 100644 index 0000000..f6aebf1 --- /dev/null +++ b/src/lib/agreements.test.ts @@ -0,0 +1,26 @@ +// Conformance: this app's gate follows the shared rules. The cases are +// installers/gate/conformance.json, copied next to agreements.json by +// installers/scripts/apply.mjs; never edit the copy. + +import { describe, expect, it } from 'vitest'; +import conformance from '../../src-tauri/installer/agreements.conformance.json'; +import manifest from '../../src-tauri/installer/agreements.json'; +import { pendingDocuments, withAccepted } from './agreements.svelte'; + +describe('agreement gate conformance', () => { + for (const c of conformance.cases) { + it(c.name, () => { + let stored: unknown = c.stored; + for (const id of c.accept ?? []) stored = withAccepted(conformance.documents, stored, id); + expect(pendingDocuments(conformance.documents, stored)).toEqual(c.pending); + if (c.storedAfter) expect(stored).toEqual(c.storedAfter); + }); + } + + it('lists every document with a title, a version, and its text', () => { + expect(manifest.documents.length).toBeGreaterThan(0); + for (const d of manifest.documents) { + expect(d.title && d.version && d.text).toBeTruthy(); + } + }); +}); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 0856bf8..d408ccc 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -2,28 +2,55 @@ // Every command is a typed wrapper; errors become ApiError with API.md codes. import { invoke } from "@tauri-apps/api/core"; +import { ERROR_CODES, isErrorCode, type ErrorCode } from "./error-codes"; +import type { AgentConnection } from "$lib/agent-activity"; import type { + AttachmentCleanup, + LibraryGraph, CaptureLatencySummary, CreateNoteInput, + DashboardStats, + FeedbackInput, + ImportOutcome, Note, NoteFilter, SearchResult, + StickyLevel, + StickiesScan, Tag, TagWithCount, UpdateNotePatch, + VaultReport, + VaultStatus, Workspace, WorkspaceWithCount, } from "./types"; export class ApiError extends Error { - code: string; - constructor(code: string, message: string) { + code: ErrorCode; + constructor(code: ErrorCode, message: string) { super(message); this.name = "ApiError"; this.code = code; } } +// The one place a rejection becomes an ApiError, so an unknown code can only +// be mishandled once. A rejection that is not the { code, message } shape +// never left the core (a dropped IPC call, a thrown string), which is a +// failure to complete the operation: STORAGE_ERROR. +function asApiError(e: unknown): ApiError { + if (e && typeof e === "object" && "code" in e && "message" in e) { + const code = String(e.code); + const message = String(e.message); + if (isErrorCode(code)) return new ApiError(code, message); + // The backend sent a code this build does not know, so no friendly copy + // exists for it; keep it in the message rather than losing it. + return new ApiError(ERROR_CODES.STORAGE_ERROR, `${code}: ${message}`); + } + return new ApiError(ERROR_CODES.STORAGE_ERROR, String(e)); +} + async function call( cmd: string, args?: Record, @@ -31,10 +58,7 @@ async function call( try { return await invoke(cmd, args); } catch (e) { - if (e && typeof e === "object" && "code" in e && "message" in e) { - throw new ApiError(String(e.code), String(e.message)); - } - throw new ApiError("STORAGE_ERROR", String(e)); + throw asApiError(e); } } @@ -50,6 +74,7 @@ export const softDeleteNote = (id: string) => export const restoreNote = (id: string) => call("restore_note", { id }); export const permanentlyDeleteNote = (id: string, confirm: boolean) => call("permanently_delete_note", { id, confirm }); +export const libraryGraph = () => call("library_graph"); export const listNotes = (filter: NoteFilter = {}) => call("list_notes", { filter }); export const searchNotes = (text: string, limit = 50) => @@ -74,8 +99,6 @@ export const destroyNotes = (ids: string[], confirm: boolean) => // ---- tags ---- export const listTags = () => call("list_tags"); -export const getOrCreateTag = (name: string) => - call("get_or_create_tag", { name }); export const updateTag = (id: string, name?: string, color?: string) => call("update_tag", { id, name, color }); export const deleteTag = (id: string) => call("delete_tag", { id }); @@ -143,6 +166,23 @@ export const setWindowVibrancy = (material: string | null) => export const setWindowTheme = (variant: "light" | "dark") => call("set_window_theme", { variant }); +// ---- stickies ---- +// A sticky is a note popped out into its own window, which is then that +// note's only editor. Popping in resolves once the sticky's edits are on disk +// and its window is gone. The level and geometry commands act on the calling +// sticky window, never on an id the webview names. +export const popOutNote = (id: string) => call("pop_out_note", { id }); +export const popInNote = (id: string) => call("pop_in_note", { id }); +export const listStickies = () => call("list_stickies"); +export const answerPopIn = (saved: boolean) => call("answer_pop_in", { saved }); +export const getStickyView = () => + call<{ level: StickyLevel; collapsed: boolean }>("get_sticky_view"); +export const setStickyLevel = (level: StickyLevel) => + call("set_sticky_level", { level }); +export const setStickyCollapsed = (collapsed: boolean) => + call("set_sticky_collapsed", { collapsed }); +export const saveStickyGeometry = () => call("save_sticky_geometry"); + // ---- theme files ---- // Byte I/O for portable .intheme.json files; the open/save dialog runs in JS. export const exportThemeFile = (path: string, contents: string) => @@ -167,13 +207,55 @@ export const saveAttachment = async ( headers: { "x-attachment-ext": ext }, }); } catch (e) { - if (e && typeof e === "object" && "code" in e && "message" in e) { - throw new ApiError(String(e.code), String(e.message)); - } - throw new ApiError("STORAGE_ERROR", String(e)); + throw asApiError(e); } }; export const getAttachmentsDir = () => call("get_attachments_dir"); +// Copy a dialog-picked image into attachments (the "copy in" storage mode); +// returns the stored filename. `allow` opens one existing local image to the +// asset protocol so a linked (not copied) image can render inline. +export const importImageFile = (path: string) => + call("import_image_file", { path }); +export const allowImageFile = (path: string) => + call("allow_image_file", { path }); +export const openAttachmentsFolder = () => + call("open_attachments_folder"); +// Images no note references any more (older than an hour), and removing +// them along with their unchanged copies in the live vault. +export const unusedAttachments = () => call("unused_attachments"); +export const removeUnusedAttachments = () => + call("remove_unused_attachments"); + +// ---- vault export ---- +// Stage 1 of the portable vault (openspec/changes/feat-portable-vault-sync): +// a one-way, read-only snapshot. SQLite stays authoritative; nothing reads +// this folder back yet. The destination is chosen by a native folder-picker +// dialog in JS, same trust boundary as exportNoteFile. +export const exportVault = (dest: string) => call("export_vault", { dest }); + +// ---- live vault mirror ---- +// Stage 2: every change is written into the chosen folder shortly after it +// happens. SQLite stays authoritative; the folder is written, never read. +export const getVaultStatus = () => call("get_vault_status"); +/** Start, move, or (with null) stop the mirror. */ +export const setVaultFolder = (path: string | null) => + call("set_vault_folder", { path }); +export const verifyVault = () => call("verify_vault"); + +// ---- import (Settings > Import) ---- +// The folder comes from a native folder picker, which is also what lets the +// app read another app's data on macOS. Rust only ever reads it. +/** Where Stickies keeps its notes, for the picker to open at; null off macOS. */ +export const stickiesLocation = () => call("stickies_location"); +export const scanStickies = (folder: string) => call("scan_stickies", { folder }); +export const importStickies = (folder: string, ids: string[], space: string | null) => + call("import_stickies", { folder, ids, space }); + +// ---- dashboard + feedback ---- +export const getLibraryStats = () => call("library_stats"); +export const submitFeedback = (input: FeedbackInput) => + call("submit_feedback", { input }); +export const openFeedbackLog = () => call("open_feedback_log"); // ---- app lifecycle ---- // Answer to "app:quit-requested": pending edits are flushed, exit for real now. @@ -183,3 +265,9 @@ export const quitApp = () => call("quit_app"); // webview has listeners attached, so an event would be lost. export const getShortcutFailure = () => call("get_shortcut_failure"); + +// ---- agents ---- +// The app executable (which is also the MCP server) and the live library, so +// Settings > Agents prints a connect command that works as shown. +export const getAgentConnection = () => + call("agent_connection"); diff --git a/src/lib/api/contract.test.ts b/src/lib/api/contract.test.ts new file mode 100644 index 0000000..958ddc7 --- /dev/null +++ b/src/lib/api/contract.test.ts @@ -0,0 +1,81 @@ +// The one test that crosses the Rust/TypeScript boundary. Types cannot reach +// across IPC, so every fact both sides must agree on is asserted here against +// the Rust source itself: the error codes, the event names, and the rule that +// neither is ever written as a loose literal again. +// +// It reads source files rather than a generated artifact so it needs no build +// step and cannot pass against a stale one. + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { ERROR_CODES } from "./error-codes"; +import { EVENTS } from "./events"; + +// vitest runs from the project root (jsdom leaves import.meta.url a http URL). +const root = `${process.cwd()}/`; +const read = (path: string) => readFileSync(root + path, "utf8"); + +function sourceFiles(dir: string, exts: string[]): string[] { + return readdirSync(root + dir).flatMap((entry) => { + const rel = `${dir}/${entry}`; + if (statSync(root + rel).isDirectory()) return sourceFiles(rel, exts); + return exts.some((e) => entry.endsWith(e)) ? [rel] : []; + }); +} + +describe("error codes", () => { + // The core's AppError::code() is the authority for the strings. + const core = read("src-tauri/core/src/error.rs"); + const codeArms = core + .slice(core.indexOf("pub fn code("), core.indexOf("pub fn is_corruption(")) + .matchAll(/=> "([A-Z_]+)"/g); + const fromCore = new Set([...codeArms].map((m) => m[1])); + + it("matches the codes the core emits", () => { + expect(fromCore.size).toBeGreaterThan(0); + expect([...fromCore].sort()).toEqual(Object.keys(ERROR_CODES).sort()); + }); + + it("matches the API.md table", () => { + const section = read("docs/API.md").split("## 14. Error codes")[1]; + const documented = [...section.matchAll(/^\| `([A-Z_]+)` \|/gm)].map((m) => m[1]); + expect(documented.sort()).toEqual(Object.keys(ERROR_CODES).sort()); + }); + + it("matches the Rust ErrorCode enum", () => { + const shell = read("src-tauri/src/error.rs"); + const arms = [...shell.matchAll(/=> "([A-Z_]+)"/g)].map((m) => m[1]); + expect(arms.sort()).toEqual(Object.keys(ERROR_CODES).sort()); + }); + + it("is the only way a command error names a code", () => { + // `code:` as a literal is how "VALIDATION" (never a real code) reached + // eight call sites and broke the friendly copy for every validation error. + const literal = new RegExp('code:\\s*"'); + for (const file of sourceFiles("src-tauri/src", [".rs"])) { + expect(literal.test(read(file)), file).toBe(false); + } + }); +}); + +describe("event names", () => { + const rust = read("src-tauri/src/events.rs"); + const declared = [...rust.matchAll(/pub const [A-Z_]+: &str = "([^"]+)"/g)].map((m) => m[1]); + + it("matches the Rust list exactly", () => { + expect(declared.length).toBe(Object.keys(EVENTS).length); + expect(declared.sort()).toEqual([...Object.values(EVENTS)].sort()); + }); + + it("is the only way either side names an event", () => { + const emit = new RegExp('emit\\(\\s*"'); + for (const file of sourceFiles("src-tauri/src", [".rs"])) { + expect(emit.test(read(file)), file).toBe(false); + } + const listen = new RegExp('listen\\(\\s*"'); + for (const file of sourceFiles("src", [".ts", ".svelte"])) { + if (file.endsWith(".test.ts")) continue; + expect(listen.test(read(file)), file).toBe(false); + } + }); +}); diff --git a/src/lib/api/error-codes.ts b/src/lib/api/error-codes.ts new file mode 100644 index 0000000..e6c3c16 --- /dev/null +++ b/src/lib/api/error-codes.ts @@ -0,0 +1,21 @@ +// The frontend's one definition of the error codes a command can reject with +// (API.md §14). The authority for the strings is the core's `AppError::code()` +// in src-tauri/core/src/error.rs; `contract.test.ts` holds this list, that +// match, and the API.md table equal, which is the only check that can cross +// the IPC boundary. + +export const ERROR_CODES = { + NOT_FOUND: "NOT_FOUND", + VALIDATION_ERROR: "VALIDATION_ERROR", + CONFLICT: "CONFLICT", + STORAGE_ERROR: "STORAGE_ERROR", + MIGRATION_ERROR: "MIGRATION_ERROR", +} as const; + +export type ErrorCode = keyof typeof ERROR_CODES; + +/** Narrow a code off the wire. A string that fails this means the backend and + * this build disagree, which `contract.test.ts` exists to prevent. */ +export function isErrorCode(value: string): value is ErrorCode { + return value in ERROR_CODES; +} diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts new file mode 100644 index 0000000..8204c08 --- /dev/null +++ b/src/lib/api/events.ts @@ -0,0 +1,33 @@ +// The frontend's one definition of the event names the backend emits. The +// Rust mirror is src-tauri/src/events.rs; `contract.test.ts` holds the two +// lists equal, so renaming one side alone fails a test instead of silently +// dropping a listener. + +export const EVENTS = { + NOTES_CHANGED: "notes:changed", + TAGS_CHANGED: "tags:changed", + WORKSPACES_CHANGED: "workspaces:changed", + SETTINGS_OPEN: "settings:open", + MENU_NEW_NOTE: "menu:new-note", + MENU_NEW_WHITEBOARD: "menu:new-whiteboard", + MENU_EXPORT_NOTE: "menu:export-note", + UPDATER_CHECK: "updater:check", + SHORTCUT_FAILED: "shortcut:failed", + VAULT_STATUS: "vault:status", + APP_QUIT_REQUESTED: "app:quit-requested", + CAPTURE_SHOWN: "capture:shown", + MENU_TOGGLE_STICKY: "menu:toggle-sticky", + STICKIES_CHANGED: "stickies:changed", + STICKY_CLOSE_REQUESTED: "sticky:close-requested", + LIBRARY_EXTERNAL_CHANGE: "library:external-change", +} as const; + +export type AppEvent = (typeof EVENTS)[keyof typeof EVENTS]; + +/** The three events that mean "the library changed, re-query it". Anything + * drawing the whole library (the store, the graph) listens to all three. */ +export const LIBRARY_CHANGED_EVENTS = [ + EVENTS.NOTES_CHANGED, + EVENTS.TAGS_CHANGED, + EVENTS.WORKSPACES_CHANGED, +] as const; diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index a800656..f7c9b50 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -1,6 +1,12 @@ // IPC contract types shared by the Svelte UI and the Rust desktop layer. // Field names are camelCase over the wire (serde). +import type { ErrorCode } from "./error-codes"; +import type { FeedbackCategory } from "$lib/feedback"; + +/** A Markdown document, or a whiteboard whose text is kept in `body`. */ +export type ContentKind = "document" | "whiteboard"; + export interface Note { id: string; title: string; @@ -12,8 +18,16 @@ export interface Note { isArchived: boolean; isDeleted: boolean; deletedAt?: string | null; + contentKind: ContentKind; + /** A whiteboard's canvas (JSON). Only `getNote` carries it: list rows and + * `updateNote`'s reply leave it out, since a board can hold images. */ + surfaceData?: string | null; } +/** Where a sticky sits: above every window, as an ordinary window, or below + * every window like a desktop widget. */ +export type StickyLevel = "float" | "normal" | "desktop"; + export interface Tag { id: string; name: string; @@ -48,6 +62,12 @@ export interface UpdateNotePatch { body?: string; isPinned?: boolean; isArchived?: boolean; + /** Only ever "whiteboard": converting is one-way. */ + contentKind?: ContentKind; + /** A whiteboard's canvas; rejected on a document. */ + surfaceData?: string; + /** Apply only if the note's updatedAt still equals this; CONFLICT if not. */ + expectedUpdatedAt?: string; } export interface NoteFilter { @@ -76,7 +96,7 @@ export interface SearchResult { } export interface AppErrorPayload { - code: string; + code: ErrorCode; message: string; } @@ -86,3 +106,94 @@ export interface CaptureLatencySummary { medianMs: number | null; samples: number; } + +/** Aggregate library + attachment counts for the Settings dashboard. */ +export interface DashboardStats { + notesTotal: number; + notesActive: number; + notesPinned: number; + notesArchived: number; + notesTrashed: number; + tags: number; + spaces: number; + attachmentsCount: number; + attachmentsBytes: number; +} + +/** One in-app feedback submission, persisted to the local log by the backend. */ +export interface FeedbackInput { + category: FeedbackCategory; + message: string; + appVersion?: string | null; + /** Opt-in diagnostics snapshot the user agreed to attach. */ + diagnostics?: unknown; +} + +/** The live vault mirror (API.md §12). `path` is null when mirroring is off. */ +export interface VaultStatus { + path: string | null; + /** Notes changed since their file was last written. */ + pending: number; + lastError: string | null; + lastFlushedAt: string | null; +} + +/** A read-only comparison of the vault folder with the library. Paths are + * relative to the vault folder. */ +export interface VaultReport { + checked: number; + /** Notes whose file is gone. */ + missing: string[]; + /** Files that no longer match their note (edited outside the app). */ + diverged: string[]; + /** Markdown files in the folder that are not notes the mirror wrote. */ + orphans: string[]; + pending: number; + manifestOk: boolean; +} + +/** One Apple Sticky as Settings > Import previews it (API.md §16). */ +export interface StickyPreview { + id: string; + /** The title the note will get. */ + title: string; + /** The start of the note's Markdown. */ + text: string; + /** The sticky's paper, `#rrggbb`, when Stickies recorded it. */ + color: string | null; + createdAt: string; + updatedAt: string; + images: number; + /** Imported before, and its note still exists. */ + imported: boolean; +} + +/** A Stickies folder, read. `readable` is false when macOS refused access. */ +export interface StickiesScan { + folder: string; + readable: boolean; + stickies: StickyPreview[]; +} + +export interface ImportOutcome { + imported: number; + /** Imported before, and their note still exists. */ + skipped: number; + /** The Space they were filed in, when one was named and any landed. */ + workspaceId: string | null; +} + +/** What an attachment cleanup removed, or would remove. */ +export interface AttachmentCleanup { + count: number; + bytes: number; +} + +/** The library as a graph: live notes, the tags and Spaces they carry, and + * one link per membership. Derived on every read; nothing is stored. */ +export interface LibraryGraph { + notes: { id: string; title: string; contentKind: ContentKind; isPinned: boolean }[]; + tags: { id: string; name: string; color?: string | null }[]; + spaces: { id: string; name: string }[]; + links: { noteId: string; targetId: string; kind: "tag" | "space" }[]; +} diff --git a/src/lib/app.css b/src/lib/app.css index 224d370..2b6ea56 100644 --- a/src/lib/app.css +++ b/src/lib/app.css @@ -93,6 +93,12 @@ textarea { line-height: var(--leading); letter-spacing: var(--tracking); } +/* Paint a caret only in the focused editor. Without this, loading a note's + document while the editor is blurred (switching between notes) could leave a + stray native caret drawn at the top of the text in WKWebView. */ +.cm-editor:not(.cm-focused) .cm-content { + caret-color: transparent; +} .cm-editor .cm-line { padding: 0 calc(16px * var(--density)); } @@ -142,3 +148,63 @@ html[data-vibrancy] .editor-pane { opacity: 0; } } + +/* Agent presence. What an agent connected over MCP is reading or changing + right now is drawn on the thing itself: a note row, a Space or tag, the + open note. Components only set data-agent="read" | "write" from the agents + store (src/lib/stores/agents.svelte.ts), which clears it a few seconds + after the last call; every rule for how it looks lives here. A read is a + light passing over; a write holds a firmer outline. */ +[data-agent] { + position: relative; +} +[data-agent]::after { + content: ""; + position: absolute; + inset: 1px; + border-radius: var(--radius); + pointer-events: none; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent); + background: linear-gradient( + 100deg, + transparent 30%, + color-mix(in srgb, var(--accent) 18%, transparent) 50%, + transparent 70% + ); + background-size: 300% 100%; + animation: + agent-in 160ms ease-out, + agent-scan 1.6s ease-in-out infinite; +} +[data-agent="write"]::after { + box-shadow: inset 0 0 0 1.5px var(--accent); + background-color: color-mix(in srgb, var(--accent) 9%, transparent); +} +/* The open note: an outline around the page, no sweep across the text. */ +.editor-body[data-agent]::after { + inset: 0; + border-radius: 0; + background: none; + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), + inset 0 0 24px color-mix(in srgb, var(--accent) 10%, transparent); + animation: agent-in 160ms ease-out; +} +@keyframes agent-in { + from { + opacity: 0; + } +} +@keyframes agent-scan { + from { + background-position: 100% 0; + } + to { + background-position: 0% 0; + } +} +@media (prefers-reduced-motion: reduce) { + [data-agent]::after { + animation: none; + } +} diff --git a/src/lib/changelog.test.ts b/src/lib/changelog.test.ts new file mode 100644 index 0000000..49389c9 --- /dev/null +++ b/src/lib/changelog.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { parseChangelog } from "./changelog"; +import changelogRaw from "../../CHANGELOG.md?raw"; +import pkg from "../../package.json"; + +const SAMPLE = `# Changelog + +## [Unreleased] + +## [0.8.0] - 2026-07-11 + +### Added +- Spaces: a place you go rather than + a label you hunt for. +- Paste or drop an image into a note. + +### Changed +- Markdown preview is more dependable. + +## [0.7.0] - 2026-06-01 + +### Added +- The very first thing. +`; + +// 0.9.0 sits undated in the real changelog until the release is cut. +const UNDATED = `# Changelog + +## [Unreleased] + +## [0.9.0] + +### Added +- A dashboard on the Settings front page. + +## [0.8.0] - 2026-07-11 + +### Added +- Spaces. +`; + +// Force CRLF whatever the input already has. On a Windows checkout the bundled +// CHANGELOG.md arrives as CRLF, so replacing "\n" alone would make it "\r\r\n" +// and test something no checkout produces. Template literals in this file are +// always LF: the language normalizes line endings inside them when parsing. +const toCRLF = (s: string) => s.replace(/\r?\n/g, "\r\n"); + +describe("parseChangelog", () => { + it("extracts the section for a version with its date", () => { + const r = parseChangelog(SAMPLE, "0.8.0"); + expect(r).not.toBeNull(); + expect(r!.version).toBe("0.8.0"); + expect(r!.date).toBe("2026-07-11"); + expect(r!.sections.map((s) => s.heading)).toEqual(["Added", "Changed"]); + }); + + it("folds bullet continuation lines into one item", () => { + const r = parseChangelog(SAMPLE, "0.8.0"); + expect(r!.sections[0].items[0]).toBe( + "Spaces: a place you go rather than a label you hunt for.", + ); + expect(r!.sections[0].items).toHaveLength(2); + }); + + it("stops at the next release heading", () => { + const r = parseChangelog(SAMPLE, "0.8.0"); + // Nothing from 0.7.0 leaks in. + const all = r!.sections.flatMap((s) => s.items).join(" "); + expect(all).not.toContain("The very first thing"); + }); + + it("returns null for a version that is not present", () => { + expect(parseChangelog(SAMPLE, "9.9.9")).toBeNull(); + }); + + it("ignores the Unreleased placeholder that has no content", () => { + const r = parseChangelog(SAMPLE, "Unreleased"); + expect(r).not.toBeNull(); + expect(r!.sections).toHaveLength(0); + }); + + // A release is undated for its whole pre-release life: the date is written + // when it is cut. The dashboard shows that section the entire time, so an + // undated heading has to parse like any other. + it("reads a release heading that carries no date", () => { + const r = parseChangelog(UNDATED, "0.9.0"); + expect(r).not.toBeNull(); + expect(r!.date).toBeNull(); + expect(r!.sections).toEqual([ + { heading: "Added", items: ["A dashboard on the Settings front page."] }, + ]); + }); + + it("stops an undated release at the dated one below it", () => { + const r = parseChangelog(UNDATED, "0.9.0"); + expect(r!.sections.flatMap((s) => s.items).join(" ")).not.toContain("Spaces"); + }); + + // A Windows checkout converts the file to CRLF, so the parser sees a trailing + // \r on every line. `.` does not match \r and the bullet pattern has no `\s*` + // before its `$`, so splitting on "\n" alone dropped every item and the + // dashboard showed an empty "What's new" on Windows only. + it("parses a CRLF changelog the same as an LF one", () => { + const lf = parseChangelog(SAMPLE, "0.8.0"); + const crlf = parseChangelog(toCRLF(SAMPLE), "0.8.0"); + expect(crlf).toEqual(lf); + expect(crlf!.sections[0].items[0]).toBe( + "Spaces: a place you go rather than a label you hunt for.", + ); + }); + + it("finds the running version's section in a CRLF copy of the bundled file", () => { + const r = parseChangelog(toCRLF(changelogRaw), pkg.version); + expect(r).not.toBeNull(); + expect(r!.sections.length).toBeGreaterThan(0); + }); + + // The bundled file is the input the dashboard actually parses, and the + // version the app reports is the section it looks for. If a release is cut + // without a changelog entry, "What's new" silently disappears; this fails + // instead. + it("finds a section for the running version in the bundled changelog", () => { + const r = parseChangelog(changelogRaw, pkg.version); + expect(r).not.toBeNull(); + expect(r!.sections.length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts new file mode 100644 index 0000000..67e6d34 --- /dev/null +++ b/src/lib/changelog.ts @@ -0,0 +1,90 @@ +// Pure parser for the bundled CHANGELOG.md, so the Settings dashboard can show +// "what's new" for the installed version without a network call (the webview +// CSP blocks external fetches). Kept free of the raw import so it stays +// unit-testable; the component supplies the markdown via Vite's `?raw`. +// +// Expected shape (Keep a Changelog): +// ## [0.8.0] - 2026-07-11 +// ### Added +// - a bullet +// continued onto the next line +// ### Changed +// - ... + +export interface ChangelogSection { + /** "Added", "Changed", "Fixed", etc. */ + heading: string; + /** One entry per bullet, continuation lines folded in. */ + items: string[]; +} + +export interface ChangelogRelease { + version: string; + /** The date after the version, or null when the heading carries none. */ + date: string | null; + sections: ChangelogSection[]; +} + +const RELEASE_RE = /^##\s+\[([^\]]+)\]\s*(?:-\s*(.+))?\s*$/; +const SECTION_RE = /^###\s+(.+?)\s*$/; +const BULLET_RE = /^[-*]\s+(.+)$/; + +/** + * Extract the release block for `version` (matched exactly against the text in + * the `## [..]` heading). Returns null when that version is not present. + */ +export function parseChangelog(md: string, version: string): ChangelogRelease | null { + // Split on either ending: a Windows checkout gets CRLF, and a trailing \r is + // not whitespace to `.`, so a bullet's `(.+)$` would never reach the end of + // the line and every item would be dropped. + const lines = md.split(/\r?\n/); + let i = 0; + + // Find the target release heading. + let release: ChangelogRelease | null = null; + for (; i < lines.length; i++) { + const m = lines[i].match(RELEASE_RE); + if (m && m[1].trim() === version) { + release = { version, date: m[2]?.trim() || null, sections: [] }; + i++; + break; + } + } + if (!release) return null; + + let section: ChangelogSection | null = null; + for (; i < lines.length; i++) { + const line = lines[i]; + // The next release heading ends this block. + if (RELEASE_RE.test(line)) break; + + const sec = line.match(SECTION_RE); + if (sec) { + section = { heading: sec[1], items: [] }; + release.sections.push(section); + continue; + } + + const bullet = line.match(BULLET_RE); + if (bullet) { + // Bullets before any `###` fall under an unnamed section so nothing is + // silently dropped. + if (!section) { + section = { heading: "", items: [] }; + release.sections.push(section); + } + section.items.push(bullet[1].trim()); + continue; + } + + // A non-blank, non-bullet line indented under the current bullet is a + // continuation; fold it into the last item. + if (section && section.items.length > 0 && line.trim() !== "") { + section.items[section.items.length - 1] += ` ${line.trim()}`; + } + } + + // Drop empty sections (a `###` with no bullets carries nothing to show). + release.sections = release.sections.filter((s) => s.items.length > 0); + return release; +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index a9a6bcb..cc4ab05 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -7,9 +7,11 @@ import { library } from "$lib/stores/library.svelte"; import { sidebar } from "$lib/stores/sidebar.svelte"; import { theme } from "$lib/stores/theme.svelte"; import { contexting } from "$lib/stores/contexting.svelte"; +import { confirmConvertToWhiteboard } from "$lib/whiteboard/convert"; import { exportTheme, importTheme } from "$lib/themes/share"; import { BODY_FONTS } from "$lib/themes/fonts"; -import { modKey } from "$lib/platform"; +import { modKey, shiftKey } from "$lib/platform"; +import { isVirtualNoteId } from "$lib/update/space"; import type { Command } from "$lib/command-filter"; export type { Command } from "$lib/command-filter"; @@ -64,6 +66,19 @@ export function buildThemeCommands(): Command[] { export function buildCommands(): Command[] { const commands: Command[] = [ { id: "note.new", title: "New note", group: "Notes", shortcut: `${modKey}N`, run: () => library.newNote() }, + { + id: "note.newWhiteboard", + title: "New whiteboard", + group: "Notes", + shortcut: `${modKey}${shiftKey}N`, + run: () => library.newWhiteboard(), + }, + { + id: "view.graph", + title: "Show graph", + group: "View", + run: () => library.selectGraph(), + }, { id: "view.sidebar", title: sidebar.collapsed ? "Show sidebar" : "Hide sidebar", @@ -111,6 +126,27 @@ export function buildCommands(): Command[] { }, }, ); + if (!n.isDeleted && !isVirtualNoteId(n.id)) { + const out = library.isSticky(n.id); + commands.push({ + id: "note.sticky", + title: out ? "Bring back from sticky" : "Open as sticky", + group: "Notes", + prefix: notePrefix, + shortcut: `${modKey}${shiftKey}O`, + run: () => library.toggleSticky(), + }); + } + // One-way, so only documents offer it, and it confirms first. + if (!n.isDeleted && n.contentKind !== "whiteboard" && !library.isSticky(n.id)) { + commands.push({ + id: "note.toWhiteboard", + title: "Turn into whiteboard…", + group: "Notes", + prefix: notePrefix, + run: () => void confirmConvertToWhiteboard(), + }); + } } commands.push( diff --git a/src/lib/components/ContextMenu.svelte b/src/lib/components/ContextMenu.svelte index 59e9bd0..be9df94 100644 --- a/src/lib/components/ContextMenu.svelte +++ b/src/lib/components/ContextMenu.svelte @@ -4,6 +4,9 @@ // first item on open and returns to the invoker when the menu goes away. interface MenuItem { label: string; + /** Keyboard equivalent, shown dimmed on the right so the menu teaches + * the shortcut rather than replacing it. */ + hint?: string; danger?: boolean; run: () => void; } @@ -12,8 +15,18 @@ x, y, items, + anchor, onclose, - }: { x: number; y: number; items: MenuItem[]; onclose: () => void } = $props(); + }: { + x: number; + y: number; + items: MenuItem[]; + /** The control that opened the menu, when one did. Presses on it do not + * count as "outside", so that control can own the toggle instead of the + * menu closing on press and its click reopening. */ + anchor?: HTMLElement; + onclose: () => void; + } = $props(); let menuEl = $state(); let left = $state(-9999); @@ -36,7 +49,8 @@ }); function onWindowEvent(e: Event) { - if (e.target instanceof Node && menuEl?.contains(e.target)) return; + if (!(e.target instanceof Node)) return onclose(); + if (menuEl?.contains(e.target) || anchor?.contains(e.target)) return; onclose(); } @@ -93,7 +107,8 @@ queueMicrotask(item.run); }} > - {item.label} + {item.label} + {#if item.hint}{item.hint}{/if} {/each} @@ -112,6 +127,9 @@ box-shadow: var(--shadow-lg); } .item { + display: flex; + align-items: baseline; + gap: 18px; text-align: left; padding: 5px 12px; border-radius: var(--radius); @@ -123,6 +141,13 @@ background: var(--bg-hover); outline: none; } + .label { + flex: 1; + } + .hint { + color: var(--text-tertiary); + font-size: 12px; + } .item.danger { color: var(--danger); } diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index bdc3a76..aac9d47 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -8,7 +8,7 @@ // kernel to the app: Rust APIs in, settings effects in, edits out. import { onMount } from "svelte"; import { EditorView, keymap, placeholder as cmPlaceholder } from "@codemirror/view"; - import { EditorState } from "@codemirror/state"; + import { EditorState, type Extension, type StateEffect } from "@codemirror/state"; import { history, defaultKeymap, historyKeymap } from "@codemirror/commands"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { languages } from "@codemirror/language-data"; @@ -22,20 +22,32 @@ setPreviewMode, setLinkPrefs, setAttachmentsBase, + linkedImagePaths, } from "$lib/editor"; import { linkPrefs } from "$lib/stores/links.svelte"; import { listIndentChanges } from "$lib/list-indent"; - import { getAttachmentsDir, openUrl, saveAttachment } from "$lib/api/client"; + import { + getAttachmentsDir, + openUrl, + saveAttachment, + allowImageFile, + } from "$lib/api/client"; import { toasts } from "$lib/stores/toasts.svelte"; + import { applyExternalEdit, externalEdit } from "$lib/external-edit"; let { value = "", + docKey, placeholder = "", previewMode = false, onchange, onactive, }: { value?: string; + /** Which document `value` belongs to (the note id). A new key loads a + * clean state; a new value under the same key is an outside edit and + * lands as a change, keeping the caret and the undo history. */ + docKey?: string; placeholder?: string; previewMode?: boolean; onchange?: (v: string) => void; @@ -45,13 +57,18 @@ let container: HTMLDivElement; let view: EditorView | undefined; let applyingExternal = false; + let loadedKey: string | undefined; + // Built once and reused for every note load, so each note gets a fresh state + // (clean selection, its own undo history) with identical behavior. + let extensions: Extension[] = []; + // Absolute attachments dir, cached once it arrives from Rust so each note + // load can re-seed it after setState resets the field. + let attachmentsBase: string | null = null; onMount(() => { - view = new EditorView({ - state: EditorState.create({ - doc: value, - extensions: [ + extensions = [ history(), + externalEdit, // Formatting shortcuts take precedence over the defaults. Cmd-K is the // command palette (handled at the window level), so link uses Cmd-Shift-K. keymap.of([ @@ -101,29 +118,87 @@ onactive?.(activeMarks(u.state)); } }), - ], - }), + ]; + view = new EditorView({ + state: EditorState.create({ doc: value, selection: { anchor: 0 }, extensions }), parent: container, }); + loadedKey = docKey; // Seed the toolbar before the first edit or selection change. onactive?.(activeMarks(view.state)); // Attachment images can only resolve once Rust reports where they live; - // until then they render as markdown text, then swap in. + // until then they render as markdown text, then swap in. Cache the dir so + // each later note load can re-seed it after setState resets the field. void getAttachmentsDir() - .then((dir) => view?.dispatch({ effects: setAttachmentsBase.of(dir) })) + .then((dir) => { + attachmentsBase = dir; + view?.dispatch({ effects: setAttachmentsBase.of(dir) }); + }) .catch(() => {}); return () => view?.destroy(); }); + // setState resets every state field to its default, so re-apply the dynamic + // ones (preview mode, link prefs, attachments base) whenever a note loads. + function seedEffects(): StateEffect[] { + const effects: StateEffect[] = [ + setPreviewMode.of(previewMode), + setLinkPrefs.of(linkPrefs.snapshot()), + ]; + if (attachmentsBase !== null) { + effects.push(setAttachmentsBase.of(attachmentsBase)); + } + return effects; + } + + // Load a note's body with a CLEAN state: a fresh selection at the top and its + // own undo history. This is the fix for the stray caret that used to linger + // in notes being switched between, and it stops an undo from reaching back + // into the previously open note. Linked (absolute-path) images are permitted + // into the asset scope before the state renders so they load on first paint. + async function loadDoc(next: string, key: string | undefined): Promise { + // Guard edit echoes to the OUTGOING note across the (possible) async gap + // while linked images are permitted, and across the state swap itself. + applyingExternal = true; + try { + const linked = linkedImagePaths(next); + if (linked.length > 0) { + await Promise.all(linked.map((p) => allowImageFile(p).catch(() => {}))); + // A newer note may have been requested while we awaited; let its own + // effect run apply it instead of clobbering with a stale body. + if (value !== next) return; + } + if (!view) return; + view.setState( + EditorState.create({ doc: next, selection: { anchor: 0 }, extensions }), + ); + loadedKey = key; + view.dispatch({ effects: seedEffects() }); + onactive?.(activeMarks(view.state)); + } finally { + applyingExternal = false; + } + } + $effect(() => { - // Sync external value changes (note switching) into the editor. + // Sync external value changes into the editor. Another note loads a clean + // state, even when its text happens to match the last one's; the same + // note changed from outside (an agent's edit) lands as a change. const next = value; - if (view && next !== view.state.doc.toString()) { + const key = docKey; + if (!view) return; + const changed = next !== view.state.doc.toString(); + if (key !== undefined && key === loadedKey) { + if (!changed) return; applyingExternal = true; - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: next }, - }); - applyingExternal = false; + try { + applyExternalEdit(view, next); + } finally { + applyingExternal = false; + } + } else if (changed || key !== loadedKey) { + // Without a key, any new text is a new document, as before keys. + void loadDoc(next, key); } }); @@ -148,6 +223,19 @@ view?.focus(); } + // Insert text at the current selection (replacing it), then focus. Flows out + // through onchange like any edit, so it auto-saves and is undoable. Used by + // the "Insert image..." action for a dialog-picked file. + export function insertText(text: string) { + if (!view) return; + const { from, to } = view.state.selection.main; + view.dispatch({ + changes: { from, to, insert: text }, + selection: { anchor: from + text.length }, + }); + view.focus(); + } + // Apply a formatting action to the current selection. Flows out through // onchange like any user edit, so it stays undoable and auto-saved. export function applyFormat(kind: FormatKind) { diff --git a/src/lib/components/Editor.test.ts b/src/lib/components/Editor.test.ts new file mode 100644 index 0000000..dd38aaf --- /dev/null +++ b/src/lib/components/Editor.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +// +// Regression coverage for the per-note state isolation fix (loadDoc, +// Editor.svelte): each note used to share one CodeMirror state, so undo could +// reach into a previously open note and the caret could carry over. Neither +// existing suite exercises this — library.svelte.test.ts's "undo" coverage is +// workspace-delete undo, and kernel.test.ts's "caret" coverage is fold +// behavior over a headless EditorState, not a mounted view switching notes. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/svelte"; +import { EditorView } from "@codemirror/view"; +import { undo } from "@codemirror/commands"; +import Editor from "./Editor.svelte"; + +vi.mock("$lib/api/client", () => ({ + getAttachmentsDir: vi.fn().mockResolvedValue("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/data/attachments"), + openUrl: vi.fn().mockResolvedValue(undefined), + saveAttachment: vi.fn().mockResolvedValue("x.png"), + allowImageFile: vi.fn().mockResolvedValue(undefined), + getSetting: vi.fn().mockResolvedValue(undefined), + setSetting: vi.fn().mockResolvedValue(undefined), +})); + +afterEach(cleanup); + +interface EditorHandle { + insertText: (t: string) => void; +} + +/** Recover the mounted EditorView from the DOM, the way findFromDOM is meant + * to be used: Editor.svelte deliberately exposes no view of its own. */ +function findView(container: HTMLElement): EditorView { + const dom = container.querySelector(".cm-content"); + if (!dom) throw new Error("CodeMirror content DOM not found"); + const view = EditorView.findFromDOM(dom as HTMLElement); + if (!view) throw new Error("EditorView not found from DOM"); + return view; +} + +describe("Editor per-note state isolation", () => { + it("undo after switching notes never reaches the previous note's edit", async () => { + const noteA = "Note A original"; + const noteB = "Note B original"; + const { component, container, rerender } = render(Editor, { value: noteA }) as unknown as { + component: EditorHandle; + container: HTMLElement; + rerender: (props: Record) => Promise; + }; + + // Simulate typing in note A: move the caret to the end (insertText + // inserts at the current selection), then make an edit CM6's history + // tracks like any other. + const view = findView(container); + view.dispatch({ selection: { anchor: noteA.length } }); + component.insertText(" plus a typed addition"); + expect(view.state.doc.toString()).toBe("Note A original plus a typed addition"); + + // Switch to note B on the SAME component instance by changing the `value` + // prop, exactly like the library selecting a different note — a fresh + // render() would mount a brand new EditorView and trivially pass this + // test regardless of whether loadDoc's isolation actually works. + await rerender({ value: noteB }); + // loadDoc is async (it awaits linkedImagePaths/allowImageFile before + // calling setState); let its microtasks flush. + await Promise.resolve(); + await Promise.resolve(); + + const viewAfterSwitch = findView(container); + // Undo must be a no-op on B's fresh history, not a reach back into A. + undo(viewAfterSwitch); + const doc = viewAfterSwitch.state.doc.toString(); + expect(doc).toBe(noteB); + expect(doc).not.toContain("typed addition"); + expect(doc).not.toContain("Note A"); + }); + + it("the caret resets to the start when switching notes, not carried over", async () => { + const noteA = "Note A original text"; + const noteB = "Note B original text"; + const { container, rerender } = render(Editor, { value: noteA }) as unknown as { + container: HTMLElement; + rerender: (props: Record) => Promise; + }; + + const view = findView(container); + // Move the caret away from the start, as if the user clicked into the + // middle of note A. + view.dispatch({ selection: { anchor: 10 } }); + expect(view.state.selection.main.anchor).toBe(10); + + await rerender({ value: noteB }); + await Promise.resolve(); + await Promise.resolve(); + + const viewAfterSwitch = findView(container); + expect(viewAfterSwitch.state.selection.main.anchor).toBe(0); + expect(viewAfterSwitch.state.selection.main.head).toBe(0); + }); +}); diff --git a/src/lib/components/GraphView.svelte b/src/lib/components/GraphView.svelte new file mode 100644 index 0000000..d1aa8be --- /dev/null +++ b/src/lib/components/GraphView.svelte @@ -0,0 +1,411 @@ + + +
+
+

Graph

+ {#if graph && graph.nodes.length > 0} + + {plural(counts.note, "note", "notes")} · {plural(counts.tag, "tag", "tags")} · + {plural(counts.space, "Space", "Spaces")} + + + {/if} +
+ + +
+ {#if failed} +

The graph couldn't load. Your notes are fine; try again in a moment.

+ {:else if graph && graph.nodes.length === 0} +

+ Nothing to draw yet. Tag notes or add them to Spaces, and they appear here, linked by + what they share. +

+ {:else if graph} + + + {#each graph.edges as e, i (i)} + {@const a = byId.get(e.source)} + {@const b = byId.get(e.target)} + {#if a && b} + + {/if} + {/each} + {#each graph.nodes as n (n.id)} + {@const r = nodeRadius(n)} + open(n)} + onkeydown={(e) => onNodeKey(e, n)} + onpointerenter={() => (hovered = n.id)} + onpointerleave={() => (hovered = null)} + onfocus={() => (hovered = n.id)} + onblur={() => (hovered = null)} + > + {#if n.board} + + {:else} + + {/if} + {#if showLabel(n)} + {n.label} + {/if} + + {/each} + + + {/if} +
+ + {#if graph && graph.unconnectedNotes > 0} +

+ {plural(graph.unconnectedNotes, "note", "notes")} with no tags or Spaces + {graph.unconnectedNotes === 1 ? "isn't" : "aren't"} shown. + This feature is a W.I.P. +

+ {/if} +
+ + diff --git a/src/lib/components/GraphView.test.ts b/src/lib/components/GraphView.test.ts new file mode 100644 index 0000000..f17c8c1 --- /dev/null +++ b/src/lib/components/GraphView.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, fireEvent, cleanup, waitFor } from "@testing-library/svelte"; +import GraphView from "./GraphView.svelte"; +import { libraryGraph } from "$lib/api/client"; +import { library } from "$lib/stores/library.svelte"; +import { listen } from "@tauri-apps/api/event"; +import type { LibraryGraph } from "$lib/api/types"; + +vi.mock("$lib/api/client", () => ({ libraryGraph: vi.fn() })); +vi.mock("$lib/stores/library.svelte", () => ({ + library: { + selected: null, + select: vi.fn(), + setTagFilter: vi.fn(), + selectWorkspace: vi.fn(), + }, +})); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +const lib = library as unknown as { + selected: { id: string } | null; + select: ReturnType; + setTagFilter: ReturnType; + selectWorkspace: ReturnType; +}; + +const note = (id: string, title: string) => ({ + id, + title, + contentKind: "document" as const, + isPinned: false, +}); + +const GRAPH: LibraryGraph = { + notes: [note("n1", "Alpha"), note("n2", "Beta"), note("n3", "Loose")], + tags: [{ id: "t1", name: "ideas" }], + spaces: [{ id: "s1", name: "Research" }], + links: [ + { noteId: "n1", targetId: "t1", kind: "tag" }, + { noteId: "n2", targetId: "s1", kind: "space" }, + ], +}; + +let handlers: Record void> = {}; + +beforeEach(() => { + handlers = {}; + lib.selected = null; + lib.select.mockReset(); + lib.setTagFilter.mockReset(); + lib.selectWorkspace.mockReset(); + vi.mocked(libraryGraph).mockReset().mockResolvedValue(GRAPH); + vi.mocked(listen).mockReset().mockImplementation(async (event, handler) => { + handlers[event] = handler as () => void; + return () => {}; + }); +}); + +afterEach(cleanup); + +describe("GraphView", () => { + it("draws every connected note, tag, and Space as a button", async () => { + const { findByRole, getByRole } = render(GraphView); + expect(await findByRole("button", { name: "Note: Alpha" })).toBeTruthy(); + expect(getByRole("button", { name: "Note: Beta" })).toBeTruthy(); + expect(getByRole("button", { name: "Tag: #ideas" })).toBeTruthy(); + expect(getByRole("button", { name: "Space: Research" })).toBeTruthy(); + }); + + it("says how many notes it leaves out, and why", async () => { + const { findByText } = render(GraphView); + expect(await findByText(/1 note with no tags or Spaces isn't shown\. This feature is a W\.I\.P\./)).toBeTruthy(); + }); + + it("opens a note, filters by a tag, and goes into a Space", async () => { + const { findByRole, getByRole } = render(GraphView); + await fireEvent.click(await findByRole("button", { name: "Note: Alpha" })); + expect(lib.select).toHaveBeenCalledWith("n1"); + await fireEvent.click(getByRole("button", { name: "Tag: #ideas" })); + expect(lib.setTagFilter).toHaveBeenCalledWith("t1"); + await fireEvent.click(getByRole("button", { name: "Space: Research" })); + expect(lib.selectWorkspace).toHaveBeenCalledWith("s1"); + }); + + it("works from the keyboard", async () => { + const { findByRole } = render(GraphView); + await fireEvent.keyDown(await findByRole("button", { name: "Note: Beta" }), { key: "Enter" }); + expect(lib.select).toHaveBeenCalledWith("n2"); + }); + + it("explains itself when nothing is connected yet", async () => { + vi.mocked(libraryGraph).mockResolvedValue({ ...GRAPH, links: [] }); + const { findByText } = render(GraphView); + expect(await findByText(/Tag notes or add them to Spaces/)).toBeTruthy(); + }); + + it("redraws when notes, tags, or Spaces change", async () => { + render(GraphView); + await waitFor(() => expect(libraryGraph).toHaveBeenCalledTimes(1)); + for (const event of ["notes:changed", "tags:changed", "workspaces:changed"]) { + expect(handlers[event]).toBeTypeOf("function"); + } + handlers["notes:changed"](); + await waitFor(() => expect(libraryGraph).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/lib/components/LicenseLocked.svelte b/src/lib/components/LicenseLocked.svelte new file mode 100644 index 0000000..7cc4fa8 --- /dev/null +++ b/src/lib/components/LicenseLocked.svelte @@ -0,0 +1,40 @@ + + +
+

Agree to the license and EULA in InstantNotes to start writing.

+ +
+ + diff --git a/src/lib/components/LicenseNote.svelte b/src/lib/components/LicenseNote.svelte new file mode 100644 index 0000000..a3e9f7e --- /dev/null +++ b/src/lib/components/LicenseNote.svelte @@ -0,0 +1,106 @@ + + +{#if doc} +
+
+

{doc.title}

+
+ + +
{doc.text}
+
+

{agreements.copy.lead}

+ + {#if agreed} + ✓ {agreements.copy.agreed} + {:else} + + {/if} +
+
+{/if} + + diff --git a/src/lib/components/NoteEditor.svelte b/src/lib/components/NoteEditor.svelte index 75a4640..47286a5 100644 --- a/src/lib/components/NoteEditor.svelte +++ b/src/lib/components/NoteEditor.svelte @@ -1,18 +1,64 @@ -{#if library.selected} +{#if library.selected && library.isSticky(library.selected.id)} + + {@const id = library.selected.id} +
+ +

{library.selected.title || "Untitled"}

+

This note is open as a sticky. Edit it there, or bring it back here.

+
+ + +
+
+{:else if library.selected}
library.editTitle(e.currentTarget.value)} aria-label="Note title" /> + {#if !isVirtual}
{#if library.selected.isDeleted} {:else} + {#if !isBoard} + + + {/if} {/if}
+ {/if}
+ {#if !isVirtual}
{#each library.selectedTags as tag (tag.id)} @@ -114,29 +195,57 @@ {/each}
- {#if editorPrefs.toolbarOpen} - editorRef?.applyFormat(k)} /> {/if} -
- library.editBody(v)} - onactive={(a) => (active = a)} - /> -
+ {#if isBoard} +
+ + {#key library.selected.id} + library.editBoard(id, edit)} + registerFlush={(flush) => library.onBeforeFlush(flush)} + onlinkopen={(url) => void openUrl(url)} + /> + {/key} +
+ {:else} + {#if editorPrefs.toolbarOpen} + editorRef?.applyFormat(k)} /> + {/if} +
+ library.editBody(v)} + onactive={(a) => (active = a)} + /> +
+ {/if}
+ {#if !isVirtual} - {#if library.saveState === "saving"}Saving…{:else if library.saveState === "failed"}Not saved{:else}Saved · {formatDate(library.selected.updatedAt)}{/if} + {#if library.saveState === "saving"}Saving…{:else if library.saveState === "failed"}Not saved{:else}Saved · {editorPrefs.showExactTime ? formatExact(library.selected.updatedAt) : formatDate(library.selected.updatedAt)}{/if} + {/if} {#if library.error} {library.error} + {:else if isBoard} + Whiteboard {:else} {@const n = wordCount(library.selected.body)} {n} {n === 1 ? "word" : "words"} @@ -145,6 +254,46 @@ {/if} diff --git a/src/lib/components/UpdateNote.test.ts b/src/lib/components/UpdateNote.test.ts new file mode 100644 index 0000000..d7662cc --- /dev/null +++ b/src/lib/components/UpdateNote.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render } from "@testing-library/svelte"; +import UpdateNote from "./UpdateNote.svelte"; +import { updater } from "$lib/stores/updater.svelte"; + +vi.mock("@tauri-apps/plugin-updater", () => ({ check: vi.fn() })); + +afterEach(cleanup); + +beforeEach(() => { + updater.status = "available"; + updater.version = "0.10.0"; + updater.currentVersion = "0.9.0"; + updater.notes = null; + updater.date = null; + updater.progress = null; + updater.error = null; + updater.sizeDelta = null; + updater.deltaState = "idle"; +}); + +describe("UpdateNote", () => { + it("names the version jump and offers the Update button", async () => { + const run = vi + .spyOn(updater, "downloadAndInstall") + .mockResolvedValue(undefined); + const { getByText } = render(UpdateNote); + + expect(getByText("update 0.9.0 → 0.10.0")).toBeTruthy(); + await fireEvent.click(getByText("Update")); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("shows the size delta against the running version", () => { + updater.sizeDelta = 1_258_291; + updater.deltaState = "ready"; + const { getByText } = render(UpdateNote); + expect( + getByText("The download is 1.2 MB larger than v0.9.0."), + ).toBeTruthy(); + }); + + it("shows progress while installing", () => { + updater.status = "downloading"; + updater.progress = 0.42; + const { getByText } = render(UpdateNote); + expect(getByText(/42% · Installing v0.10.0/)).toBeTruthy(); + }); + + it("answers the finished install with Ok, which dismisses", async () => { + updater.status = "ready"; + const done = vi.spyOn(updater, "acknowledge").mockImplementation(() => {}); + const { getByText } = render(UpdateNote); + + expect(getByText(/is installed/)).toBeTruthy(); + await fireEvent.click(getByText("Ok")); + expect(done).toHaveBeenCalledTimes(1); + }); + + it("surfaces a failed install with a retry", async () => { + updater.status = "error"; + updater.error = "network unreachable"; + const run = vi + .spyOn(updater, "downloadAndInstall") + .mockResolvedValue(undefined); + const { getByText } = render(UpdateNote); + + expect(getByText("network unreachable")).toBeTruthy(); + await fireEvent.click(getByText("Try again")); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/components/UpdatePanel.svelte b/src/lib/components/UpdatePanel.svelte deleted file mode 100644 index 4b41412..0000000 --- a/src/lib/components/UpdatePanel.svelte +++ /dev/null @@ -1,386 +0,0 @@ - - -{#if open} - -{/if} - - diff --git a/src/lib/components/WelcomeScreen.svelte b/src/lib/components/WelcomeScreen.svelte index 258bb19..f4c0484 100644 --- a/src/lib/components/WelcomeScreen.svelte +++ b/src/lib/components/WelcomeScreen.svelte @@ -5,7 +5,7 @@ import { updater } from "$lib/stores/updater.svelte"; import { captureShortcut, modKey } from "$lib/platform"; - let { appVersion, onShowUpdate }: { appVersion: string; onShowUpdate: () => void } = + let { appVersion, onOpenUpdate }: { appVersion: string; onOpenUpdate: () => void } = $props(); // Set when the global capture hotkey could not be registered at startup @@ -29,32 +29,34 @@

InstantNotes {#if appVersion}v{appVersion}{/if} - {#if updater.status === "available"} - - {:else if updater.status === "downloading"} - - Downloading{updater.progress != null - ? ` ${Math.round(updater.progress * 100)}%` - : "…"} - - {:else if updater.status === "ready"} - - {:else if updater.status === "error"} - + {#if updater.pendingUpdate} + {#if updater.status === "downloading"} + + {:else if updater.status === "ready"} + + {:else if updater.status === "error"} + + {:else} + + {/if} {/if}

Select a note, or press {captureShortcut} anywhere to capture.

@@ -136,10 +138,6 @@ letter-spacing: 0.3px; cursor: pointer; } - .update-pill.passive { - cursor: default; - opacity: 0.8; - } .update-pill.failed { border-color: #d05656; color: #d05656; diff --git a/src/lib/components/settings/PrefRow.svelte b/src/lib/components/settings/PrefRow.svelte new file mode 100644 index 0000000..e5f55da --- /dev/null +++ b/src/lib/components/settings/PrefRow.svelte @@ -0,0 +1,58 @@ + + +
+ + {label} + {#if sub}{sub}{/if} + + {@render control()} +
+ + diff --git a/src/lib/components/settings/SegmentedRow.svelte b/src/lib/components/settings/SegmentedRow.svelte new file mode 100644 index 0000000..d1fbd89 --- /dev/null +++ b/src/lib/components/settings/SegmentedRow.svelte @@ -0,0 +1,118 @@ + + + + {#snippet control()} +
+ {#each options as o, i (o.value)} + + + {/each} +
+ {/snippet} +
+ + diff --git a/src/lib/components/settings/SettingsAgents.svelte b/src/lib/components/settings/SettingsAgents.svelte new file mode 100644 index 0000000..db3537e --- /dev/null +++ b/src/lib/components/settings/SettingsAgents.svelte @@ -0,0 +1,220 @@ + + +
+

Agents

+

+ Connect Claude Code, Codex, Cursor, or any agent that speaks the Model + Context Protocol. It works through the same search and the same rules you + do, and whatever it reads or changes lights up in your library while it + does it. +

+ + agents.setAccess(v)} + /> + + Connect an agent + {#if agents.connection} + {@const conn = agents.connection} +
+
+ Claude Code: run once in a terminal + +
+
{claudeCodeCommand(conn)}
+
+ Other apps: add to their MCP settings + +
+
{mcpServersJson(conn)}
+
+

+ The agent runs InstantNotes itself in the background to reach your notes, + so it works whether or not this window is open. Access applies to every + agent, and turning it off takes effect on their next request. +

+ {:else} +

Finding this app's location…

+ {/if} + + Recent activity + {#if agents.recent.length === 0} +

No agent has connected yet.

+ {:else} +
    + {#each agents.recent as entry, i (`${entry.at}-${i}`)} +
  • + {clientLabel(entry.client)} + {describeActivity(entry)} + {timeAgo(entry.at, now)} +
  • + {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/settings/SettingsContexting.svelte b/src/lib/components/settings/SettingsContexting.svelte index fc357b0..739a1d7 100644 --- a/src/lib/components/settings/SettingsContexting.svelte +++ b/src/lib/components/settings/SettingsContexting.svelte @@ -2,16 +2,29 @@ import { onMount } from "svelte"; import { modKey } from "$lib/platform"; import { contexting } from "$lib/stores/contexting.svelte"; - import { renderTemplate, TEMPLATE_VARS } from "$lib/contexting-format"; + import { + renderTemplate, + TEMPLATE_VARS, + type ContextImageMode, + } from "$lib/contexting-format"; import { library } from "$lib/stores/library.svelte"; import type { Note, Tag } from "$lib/api/types"; + import SegmentedRow from "$lib/components/settings/SegmentedRow.svelte"; onMount(() => void contexting.init()); - // Live preview for the copy template, using the open note or a sample stand-in. + const IMAGE_MODE_OPTIONS: { value: ContextImageMode; label: string }[] = [ + { value: "absolute", label: "Absolute path" }, + { value: "keep", label: "Keep reference" }, + { value: "strip", label: "Remove" }, + ]; + + // Live preview for the copy template, using the open note or a sample + // stand-in. The sample carries an image so the image-handling choice is + // visible in the preview. const SAMPLE_NOTE: Pick = { title: "Sample note", - body: "The quick brown fox.", + body: "The quick brown fox.\n![diagram](attachments/example.png)", updatedAt: new Date().toISOString(), }; const SAMPLE_TAGS: Pick[] = [{ name: "example" }]; @@ -19,7 +32,10 @@ const preview = $derived.by(() => { const note = library.selected; const tags = note ? library.selectedTags : SAMPLE_TAGS; - return renderTemplate(contexting.copyTemplate, note ?? SAMPLE_NOTE, tags); + return renderTemplate(contexting.copyTemplate, note ?? SAMPLE_NOTE, tags, { + imageMode: contexting.imageMode, + attachmentsDir: contexting.attachmentsDir, + }); }); @@ -45,6 +61,15 @@ {/each}
+ Images + contexting.setImageMode(v)} + /> + Preview
{preview}
diff --git a/src/lib/components/settings/SettingsEditor.svelte b/src/lib/components/settings/SettingsEditor.svelte new file mode 100644 index 0000000..8ff6eb0 --- /dev/null +++ b/src/lib/components/settings/SettingsEditor.svelte @@ -0,0 +1,57 @@ + + +
+

Editor

+

+ How writing and the note itself present. These apply everywhere a note is + open. +

+ + Timestamps + editorPrefs.setShowExactTime(v)} + /> + + Formatting + editorPrefs.toggleToolbar()} + /> +
+ + diff --git a/src/lib/components/settings/SettingsEditor.test.ts b/src/lib/components/settings/SettingsEditor.test.ts new file mode 100644 index 0000000..ee19404 --- /dev/null +++ b/src/lib/components/settings/SettingsEditor.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +// `editorPrefs.init()`'s read-from-settings path (and the touched-field race +// guard) is covered directly in stores/editor.svelte.test.ts; this file only +// covers what the page renders and persists, following SettingsImages.test.ts's +// convention of resetting the singleton's $state fields directly per test. +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, fireEvent, cleanup } from "@testing-library/svelte"; +import SettingsEditor from "./SettingsEditor.svelte"; +import { editorPrefs } from "$lib/stores/editor.svelte"; +import { setSetting } from "$lib/api/client"; + +vi.mock("$lib/api/client", () => ({ + getSetting: vi.fn().mockResolvedValue(undefined), + setSetting: vi.fn().mockResolvedValue(undefined), +})); + +afterEach(cleanup); + +beforeEach(() => { + editorPrefs.showExactTime = false; + editorPrefs.toolbarOpen = false; + vi.mocked(setSetting).mockClear(); +}); + +describe("SettingsEditor", () => { + it("shows Show exact save time off by default", async () => { + const { findByRole } = render(SettingsEditor); + const sw = await findByRole("switch", { name: "Show exact save time" }); + expect(sw.getAttribute("aria-checked")).toBe("false"); + }); + + it("toggling Show exact save time persists the setting", async () => { + const { findByRole } = render(SettingsEditor); + const sw = await findByRole("switch", { name: "Show exact save time" }); + await fireEvent.click(sw); + expect(sw.getAttribute("aria-checked")).toBe("true"); + expect(vi.mocked(setSetting)).toHaveBeenCalledWith("editor.showExactTime", true); + }); + + it("reflects an already-on showExactTime from the store", async () => { + editorPrefs.showExactTime = true; + const { findByRole } = render(SettingsEditor); + const sw = await findByRole("switch", { name: "Show exact save time" }); + expect(sw.getAttribute("aria-checked")).toBe("true"); + }); + + it("toggling the formatting toolbar default persists separately from the timestamp toggle", async () => { + const { findByRole } = render(SettingsEditor); + const sw = await findByRole("switch", { name: "Open the formatting toolbar by default" }); + await fireEvent.click(sw); + expect(vi.mocked(setSetting)).toHaveBeenCalledWith("editor.toolbarOpen", true); + // The other toggle must not have moved. + expect( + (await findByRole("switch", { name: "Show exact save time" })).getAttribute( + "aria-checked", + ), + ).toBe("false"); + }); +}); diff --git a/src/lib/components/settings/SettingsFeedback.svelte b/src/lib/components/settings/SettingsFeedback.svelte new file mode 100644 index 0000000..d438fd3 --- /dev/null +++ b/src/lib/components/settings/SettingsFeedback.svelte @@ -0,0 +1,225 @@ + + + + + diff --git a/src/lib/components/settings/SettingsFeedback.test.ts b/src/lib/components/settings/SettingsFeedback.test.ts new file mode 100644 index 0000000..8e41025 --- /dev/null +++ b/src/lib/components/settings/SettingsFeedback.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, fireEvent, cleanup } from "@testing-library/svelte"; +import SettingsFeedback from "./SettingsFeedback.svelte"; +import { submitFeedback, openFeedbackLog, openUrl } from "$lib/api/client"; +import { toasts } from "$lib/stores/toasts.svelte"; + +vi.mock("$lib/api/client", () => ({ + getLibraryStats: vi.fn().mockResolvedValue({ + notesTotal: 12, + notesActive: 10, + notesPinned: 1, + notesArchived: 1, + notesTrashed: 0, + tags: 3, + spaces: 2, + attachmentsCount: 4, + attachmentsBytes: 2048, + }), + submitFeedback: vi.fn().mockResolvedValue(undefined), + openFeedbackLog: vi.fn().mockResolvedValue(undefined), + openUrl: vi.fn().mockResolvedValue(undefined), +})); + +afterEach(cleanup); + +beforeEach(() => { + vi.mocked(submitFeedback).mockReset().mockResolvedValue(undefined); + vi.mocked(openFeedbackLog).mockReset().mockResolvedValue(undefined); + vi.mocked(openUrl).mockReset().mockResolvedValue(undefined); + toasts.items = []; +}); + +function open() { + return render(SettingsFeedback, { appVersion: "0.9.0" }); +} + +function lastToast(): string | undefined { + return toasts.items.at(-1)?.message; +} + +describe("SettingsFeedback", () => { + it("defaults to the Bug category with Bug-flavored placeholder copy", () => { + const { getByRole, getByPlaceholderText } = open(); + const radios = getByRole("radiogroup").querySelectorAll('[role="radio"]'); + expect([...radios].map((r) => r.textContent)).toEqual(["Bug", "Idea", "Other"]); + expect([...radios].map((r) => r.getAttribute("aria-checked"))).toEqual([ + "true", + "false", + "false", + ]); + expect(getByPlaceholderText(/what did you expect/i)).toBeTruthy(); + }); + + it("switching category changes the placeholder copy", async () => { + const { getByRole, getByPlaceholderText } = open(); + await fireEvent.click(getByRole("radio", { name: "Idea" })); + expect(getByPlaceholderText(/better for you/i)).toBeTruthy(); + }); + + it("disables Send while the message is empty or only whitespace", async () => { + const { getByRole, getByLabelText } = open(); + const send = getByRole("button", { name: /send feedback/i }) as HTMLButtonElement; + expect(send.disabled).toBe(true); + + await fireEvent.input(getByLabelText("Message"), { target: { value: " " } }); + expect(send.disabled).toBe(true); + + await fireEvent.input(getByLabelText("Message"), { target: { value: "found a bug" } }); + expect(send.disabled).toBe(false); + }); + + it("submits, clears the message, and opens the prefilled GitHub issue on success", async () => { + const { getByRole, getByLabelText } = open(); + await fireEvent.input(getByLabelText("Message"), { target: { value: "found a bug" } }); + await fireEvent.click(getByRole("button", { name: /send feedback/i })); + + expect(vi.mocked(submitFeedback)).toHaveBeenCalledWith( + expect.objectContaining({ category: "bug", message: "found a bug", appVersion: "0.9.0" }), + ); + expect(vi.mocked(openUrl)).toHaveBeenCalled(); + expect((getByLabelText("Message") as HTMLTextAreaElement).value).toBe(""); + expect(lastToast()).toMatch(/thanks/i); + }); + + it("keeps the draft and reports the error when the local save fails", async () => { + vi.mocked(submitFeedback).mockRejectedValueOnce(new Error("disk full")); + const { getByRole, getByLabelText } = open(); + await fireEvent.input(getByLabelText("Message"), { target: { value: "found a bug" } }); + await fireEvent.click(getByRole("button", { name: /send feedback/i })); + + expect(vi.mocked(openUrl)).not.toHaveBeenCalled(); + expect((getByLabelText("Message") as HTMLTextAreaElement).value).toBe("found a bug"); + expect(lastToast()).toMatch(/couldn't send feedback/i); + }); + + it("still saves and clears the draft when only the GitHub hand-off fails", async () => { + vi.mocked(openUrl).mockRejectedValueOnce(new Error("no browser")); + const { getByRole, getByLabelText } = open(); + await fireEvent.input(getByLabelText("Message"), { target: { value: "found a bug" } }); + await fireEvent.click(getByRole("button", { name: /send feedback/i })); + + expect(vi.mocked(submitFeedback)).toHaveBeenCalled(); + expect((getByLabelText("Message") as HTMLTextAreaElement).value).toBe(""); + expect(lastToast()).toMatch(/saved locally, but couldn't open github/i); + }); + + it("reveals the feedback log from its button", async () => { + const { getByRole } = open(); + await fireEvent.click(getByRole("button", { name: /reveal saved feedback/i })); + expect(vi.mocked(openFeedbackLog)).toHaveBeenCalled(); + }); + + it("hides the diagnostics preview when the toggle is off, without dropping it from the submission", async () => { + const { getByRole, getByLabelText, queryByText } = open(); + await fireEvent.click(getByRole("switch", { name: "Include diagnostics" })); + expect(queryByText(/App version/)).toBeNull(); + + await fireEvent.input(getByLabelText("Message"), { target: { value: "an idea" } }); + await fireEvent.click(getByRole("button", { name: /send feedback/i })); + expect(vi.mocked(submitFeedback)).toHaveBeenCalledWith( + expect.objectContaining({ diagnostics: null }), + ); + }); +}); diff --git a/src/lib/components/settings/SettingsImages.svelte b/src/lib/components/settings/SettingsImages.svelte new file mode 100644 index 0000000..fa306d4 --- /dev/null +++ b/src/lib/components/settings/SettingsImages.svelte @@ -0,0 +1,261 @@ + + +
+

Images

+

+ Screenshots and drawings you paste, drop, or insert into notes. How they are + stored, how large they preview, and where they live. +

+ + Storage + imagePrefs.setStorage(v)} + /> +

+ {#if imagePrefs.storage === "copy"} + Images are copied into the attachments folder, so exported markdown stays + self-contained. This is the safe default. + {:else} + A picked file is referenced by its path and read from where it sits. + Pasted or dropped screenshots have no original file, so they are always + copied in regardless of this setting. + {/if} +

+ + Preview + + {#snippet control()} +
+ imagePrefs.setMaxPreviewHeight(+e.currentTarget.value)} + aria-label="Maximum image preview height" + aria-describedby={heightSubId} + /> + {imagePrefs.maxPreviewHeight}px +
+ {/snippet} +
+ + Stored images +
+
+ Count + {attachmentsCount ?? "-"} +
+
+ Total size + {formatBytes(attachmentsBytes)} +
+
+ Unused + + {#if !unused}-{:else if unused.count === 0}None{:else}{plural(unused.count)}, {formatBytes(unused.bytes)}{/if} + +
+
+ Location + {attachmentsDir || "-"} +
+
+ + +
+
+
+ + diff --git a/src/lib/components/settings/SettingsImages.test.ts b/src/lib/components/settings/SettingsImages.test.ts new file mode 100644 index 0000000..58771bb --- /dev/null +++ b/src/lib/components/settings/SettingsImages.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +// `imagePrefs.init()`'s read-from-settings path is covered directly in +// stores/images.svelte.test.ts; this file only covers what the page renders +// and persists, following SettingsView.test.ts's convention of stubbing +// getSetting to resolve undefined (defaults) throughout. +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, fireEvent, cleanup } from "@testing-library/svelte"; +import SettingsImages from "./SettingsImages.svelte"; +import { imagePrefs, DEFAULT_MAX_HEIGHT } from "$lib/stores/images.svelte"; +import { + setSetting, + openAttachmentsFolder, + unusedAttachments, + removeUnusedAttachments, +} from "$lib/api/client"; +import { confirmDialog } from "$lib/stores/confirm.svelte"; +import { toasts } from "$lib/stores/toasts.svelte"; + +vi.mock("$lib/api/client", () => ({ + getSetting: vi.fn().mockResolvedValue(undefined), + setSetting: vi.fn().mockResolvedValue(undefined), + getLibraryStats: vi.fn().mockResolvedValue({ + notesTotal: 0, + notesActive: 0, + notesPinned: 0, + notesArchived: 0, + notesTrashed: 0, + tags: 0, + spaces: 0, + attachmentsCount: 3, + attachmentsBytes: 4096, + }), + getAttachmentsDir: vi.fn().mockResolvedValue("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/data/attachments"), + openAttachmentsFolder: vi.fn().mockResolvedValue(undefined), + unusedAttachments: vi.fn(), + removeUnusedAttachments: vi.fn(), +})); +vi.mock("$lib/stores/confirm.svelte", () => ({ + confirmDialog: { ask: vi.fn() }, +})); + +afterEach(cleanup); + +// `imagePrefs` is a module-level singleton shared across every test in this +// file (it also outlives `init()`'s one-shot fetch guard), so reset its state +// directly rather than depending on a fresh settings read per test. +beforeEach(() => { + imagePrefs.storage = "copy"; + imagePrefs.maxPreviewHeight = DEFAULT_MAX_HEIGHT; + vi.mocked(setSetting).mockClear(); + vi.mocked(openAttachmentsFolder).mockClear(); + vi.mocked(unusedAttachments).mockReset().mockResolvedValue({ count: 0, bytes: 0 }); + vi.mocked(removeUnusedAttachments).mockReset(); + vi.mocked(confirmDialog.ask).mockReset(); +}); + +describe("SettingsImages", () => { + it("shows the storage mode row with copy selected by default", async () => { + const { findByRole, getByRole } = render(SettingsImages); + await findByRole("radiogroup", { name: "When adding an image" }); + const radios = getByRole("radiogroup").querySelectorAll('[role="radio"]'); + expect([...radios].map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]); + }); + + it("switching to Link original persists the setting", async () => { + const { findByRole } = render(SettingsImages); + const link = await findByRole("radio", { name: "Link original" }); + await fireEvent.click(link); + expect(vi.mocked(setSetting)).toHaveBeenCalledWith("images.storage", "link"); + }); + + it("reflects an already-link storage mode", async () => { + imagePrefs.storage = "link"; + const { findByRole } = render(SettingsImages); + const radio = await findByRole("radio", { name: "Link original" }); + expect(radio.getAttribute("aria-checked")).toBe("true"); + }); + + it("starts the preview-height slider at the default and within its bounds", async () => { + const { findByLabelText } = render(SettingsImages); + const slider = (await findByLabelText("Maximum image preview height")) as HTMLInputElement; + expect(slider.value).toBe(String(DEFAULT_MAX_HEIGHT)); + expect(slider.min).toBe("120"); + expect(slider.max).toBe("900"); + }); + + it("persists a moved slider value", async () => { + const { findByLabelText } = render(SettingsImages); + const slider = (await findByLabelText("Maximum image preview height")) as HTMLInputElement; + await fireEvent.input(slider, { target: { value: "600" } }); + expect(vi.mocked(setSetting)).toHaveBeenCalledWith("images.maxPreviewHeight", 600); + }); + + it("shows the attachments count, size, and location once loaded", async () => { + const { findByText } = render(SettingsImages); + expect(await findByText("3")).toBeTruthy(); + expect(await findByText("4 KB")).toBeTruthy(); + expect(await findByText("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/data/attachments")).toBeTruthy(); + }); + + it("opens the attachments folder from its button", async () => { + const { findByRole } = render(SettingsImages); + await fireEvent.click(await findByRole("button", { name: "Open attachments folder" })); + expect(vi.mocked(openAttachmentsFolder)).toHaveBeenCalled(); + }); + + it("reports images no note uses any more, with their size", async () => { + vi.mocked(unusedAttachments).mockResolvedValue({ count: 2, bytes: 3 * 1024 * 1024 }); + const { findByText, findByRole } = render(SettingsImages); + expect(await findByText("2 images, 3 MB")).toBeTruthy(); + expect((await findByRole("button", { name: "Remove unused…" })).hasAttribute("disabled")).toBe( + false, + ); + }); + + it("says so, with nothing to press, when every image is in use", async () => { + const { findByText, findByRole } = render(SettingsImages); + expect(await findByText("None")).toBeTruthy(); + expect((await findByRole("button", { name: "Remove unused…" })).hasAttribute("disabled")).toBe( + true, + ); + }); + + it("removes them only after confirming, then reports what it freed", async () => { + vi.mocked(unusedAttachments) + .mockResolvedValueOnce({ count: 2, bytes: 2048 }) + .mockResolvedValue({ count: 0, bytes: 0 }); + vi.mocked(removeUnusedAttachments).mockResolvedValue({ count: 2, bytes: 2048 }); + vi.mocked(confirmDialog.ask).mockResolvedValue(true); + const show = vi.spyOn(toasts, "show"); + const { findByRole, findByText } = render(SettingsImages); + await findByText("2 images, 2 KB"); + + await fireEvent.click(await findByRole("button", { name: "Remove unused…" })); + + const ask = vi.mocked(confirmDialog.ask).mock.calls[0][0]; + expect(ask.body).toMatch(/Trash/); + expect(vi.mocked(removeUnusedAttachments)).toHaveBeenCalledTimes(1); + expect(await findByText("None")).toBeTruthy(); + expect(show).toHaveBeenCalledWith("Removed 2 unused images (2 KB)."); + }); + + it("does nothing when the confirm is declined", async () => { + vi.mocked(unusedAttachments).mockResolvedValue({ count: 1, bytes: 10 }); + vi.mocked(confirmDialog.ask).mockResolvedValue(false); + const { findByRole } = render(SettingsImages); + const button = await findByRole("button", { name: "Remove unused…" }); + await vi.waitFor(() => expect(button.hasAttribute("disabled")).toBe(false)); + await fireEvent.click(button); + expect(vi.mocked(removeUnusedAttachments)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/components/settings/SettingsImport.svelte b/src/lib/components/settings/SettingsImport.svelte new file mode 100644 index 0000000..507cb0f --- /dev/null +++ b/src/lib/components/settings/SettingsImport.svelte @@ -0,0 +1,479 @@ + + +
+

Import

+

+ Bring notes in from other apps. The app they came from keeps its copy; + nothing there changes. +

+ + Apple Stickies + + {#if phase.kind === "start" || phase.kind === "reading"} + + {#snippet control()} + + {/snippet} + +

+ macOS keeps each app's data private, so you show InstantNotes where + Stickies keeps its notes. The picker opens there; choose Open. +

+ {:else if phase.kind === "denied"} + + {:else if phase.kind === "error"} + + {:else if phase.kind === "done"} + {@const workspaceId = phase.outcome.workspaceId} +
+

{describeImport(phase.outcome, phase.space)}

+
+ {#if workspaceId} + + {/if} + +
+
+ {:else if scan && scan.stickies.length === 0} +
+

There are no stickies in that folder.

+

Stickies keeps them in {STICKIES_PATH_HINT}.

+
+ +
+
+ {:else if scan} +
+ + {describeSelection(scan.stickies.length, selected.length, scan.stickies.length - importable.length)} + + + +
+ +
+ {#each scan.stickies as sticky (sticky.id)} + {@const on = selected.includes(sticky.id)} + + {/each} +
+ + + {#snippet control()} + + {/snippet} + + +
+ + +
+

+ Stickies keeps its notes. Formatting Markdown can't hold, like fonts, + sizes, and text colors, stays behind. +

+ {/if} +
+ + diff --git a/src/lib/components/settings/SettingsImport.test.ts b/src/lib/components/settings/SettingsImport.test.ts new file mode 100644 index 0000000..e9cb6c5 --- /dev/null +++ b/src/lib/components/settings/SettingsImport.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, fireEvent, cleanup, waitFor } from "@testing-library/svelte"; +import SettingsImport from "./SettingsImport.svelte"; +import { importStickies, openUrl, scanStickies } from "$lib/api/client"; +import { chooseStickiesFolder, PRIVACY_SETTINGS_URL } from "$lib/stickies-import"; +import type { StickiesScan, StickyPreview } from "$lib/api/types"; + +vi.mock("$lib/api/client", () => ({ + scanStickies: vi.fn(), + importStickies: vi.fn(), + openUrl: vi.fn(), + stickiesLocation: vi.fn(), +})); +vi.mock("$lib/stickies-import", async (importOriginal) => ({ + ...(await importOriginal()), + chooseStickiesFolder: vi.fn(), +})); + +const FOLDER = "/Users/me/Library/Containers/com.apple.Stickies/Data/Library/Stickies"; + +function sticky(id: string, title: string, over: Partial = {}): StickyPreview { + return { + id, + title, + text: `${title}\nsome words`, + color: "#fef49c", + createdAt: "2024-01-01T00:00:00.000000Z", + updatedAt: "2024-02-01T00:00:00.000000Z", + images: 0, + imported: false, + ...over, + }; +} + +const BOARD: StickiesScan = { + folder: FOLDER, + readable: true, + stickies: [ + sticky("A", "Groceries", { images: 2 }), + sticky("B", "Call the bank", { color: null }), + sticky("C", "Old list", { imported: true }), + ], +}; + +let onShowSpace: ReturnType; + +function page() { + onShowSpace = vi.fn(); + return render(SettingsImport, { onShowSpace }); +} + +async function choose(view: ReturnType, scan: StickiesScan = BOARD) { + vi.mocked(scanStickies).mockResolvedValue(scan); + await fireEvent.click(view.getByRole("button", { name: /Choose Stickies Folder/ })); +} + +afterEach(cleanup); + +beforeEach(() => { + vi.mocked(chooseStickiesFolder).mockReset().mockResolvedValue({ path: FOLDER }); + vi.mocked(scanStickies).mockReset(); + vi.mocked(importStickies).mockReset(); + vi.mocked(openUrl).mockReset(); +}); + +describe("Settings > Import", () => { + it("reads the folder the user picks and shows every sticky, all new ones chosen", async () => { + const view = page(); + await choose(view); + + expect(scanStickies).toHaveBeenCalledWith(FOLDER); + const groceries = await view.findByRole("button", { name: /^Groceries/ }); + expect(groceries.getAttribute("aria-pressed")).toBe("true"); + expect(groceries.textContent).toContain("2 images"); + const old = view.getByRole("button", { name: /^Old list.*already imported/ }); + expect((old as HTMLButtonElement).disabled).toBe(true); + expect(old.textContent).toContain("Imported"); + expect(view.getByText("3 stickies · 2 selected · 1 already imported")).toBeTruthy(); + expect(view.getByRole("button", { name: "Import 2 Stickies" })).toBeTruthy(); + }); + + it("imports the chosen ones into the named Space, then shows it", async () => { + vi.mocked(importStickies).mockResolvedValue({ imported: 1, skipped: 0, workspaceId: "ws-1" }); + const view = page(); + await choose(view); + + await fireEvent.click(await view.findByRole("button", { name: /^Call the bank/ })); + const input = view.getByRole("textbox", { name: /Space/ }) as HTMLInputElement; + expect(input.value).toBe("Apple Stickies"); + await fireEvent.input(input, { target: { value: "Desk" } }); + await fireEvent.click(view.getByRole("button", { name: "Import 1 Sticky" })); + + expect(importStickies).toHaveBeenCalledWith(FOLDER, ["A"], "Desk"); + expect(await view.findByText("Imported 1 sticky into Desk.")).toBeTruthy(); + await fireEvent.click(view.getByRole("button", { name: "Show Them" })); + expect(onShowSpace).toHaveBeenCalledWith("ws-1"); + }); + + it("files them in no Space when the field is empty", async () => { + vi.mocked(importStickies).mockResolvedValue({ imported: 2, skipped: 0, workspaceId: null }); + const view = page(); + await choose(view); + const input = (await view.findByRole("textbox", { name: /Space/ })) as HTMLInputElement; + await fireEvent.input(input, { target: { value: " " } }); + await fireEvent.click(view.getByRole("button", { name: "Import 2 Stickies" })); + + expect(importStickies).toHaveBeenCalledWith(FOLDER, ["A", "B"], null); + expect(await view.findByText("Imported 2 stickies.")).toBeTruthy(); + expect(view.queryByRole("button", { name: "Show Them" })).toBeNull(); + }); + + it("with nothing chosen there is nothing to import", async () => { + const view = page(); + await choose(view); + await fireEvent.click(await view.findByRole("button", { name: "Select None" })); + const button = view.getByRole("button", { name: "Import 0 Stickies" }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + }); + + it("explains a refusal from macOS and opens Privacy & Security", async () => { + const view = page(); + await choose(view, { folder: FOLDER, readable: false, stickies: [] }); + + expect(await view.findByText(/didn't let InstantNotes read that folder/)).toBeTruthy(); + await fireEvent.click(view.getByRole("button", { name: /Open Privacy/ })); + expect(openUrl).toHaveBeenCalledWith(PRIVACY_SETTINGS_URL); + }); + + it("says where Stickies keeps its notes when a folder has none", async () => { + const view = page(); + await choose(view, { folder: "/Users/me/Desktop", readable: true, stickies: [] }); + expect(await view.findByText("There are no stickies in that folder.")).toBeTruthy(); + expect(view.getByText(/com\.apple\.Stickies/)).toBeTruthy(); + }); + + it("does nothing when the picker is cancelled", async () => { + vi.mocked(chooseStickiesFolder).mockResolvedValue({ cancelled: true }); + const view = page(); + await fireEvent.click(view.getByRole("button", { name: /Choose Stickies Folder/ })); + await waitFor(() => expect(chooseStickiesFolder).toHaveBeenCalled()); + expect(scanStickies).not.toHaveBeenCalled(); + expect(view.getByRole("button", { name: /Choose Stickies Folder/ })).toBeTruthy(); + }); +}); diff --git a/src/lib/components/settings/SettingsLinks.svelte b/src/lib/components/settings/SettingsLinks.svelte index fe5aa6b..932ae27 100644 --- a/src/lib/components/settings/SettingsLinks.svelte +++ b/src/lib/components/settings/SettingsLinks.svelte @@ -3,8 +3,37 @@ import { openUrl } from "$lib/api/client"; import { modKey } from "$lib/platform"; import { linkPrefs } from "$lib/stores/links.svelte"; + import type { LinkOpenWith, LinkUnderline } from "$lib/editor"; + import SegmentedRow from "$lib/components/settings/SegmentedRow.svelte"; + import ToggleRow from "$lib/components/settings/ToggleRow.svelte"; - onMount(() => void linkPrefs.init()); + const OPEN_WITH_OPTIONS: { value: LinkOpenWith; label: string }[] = [ + { value: "click", label: "Click" }, + { value: "modclick", label: `${modKey} Click` }, + ]; + const UNDERLINE_OPTIONS: { value: LinkUnderline; label: string }[] = [ + { value: "always", label: "Always" }, + { value: "hover", label: "On hover" }, + { value: "never", label: "Never" }, + ]; + + // Track whether a Cmd/Ctrl modifier is held so the sample link shows the + // pointer cursor exactly when a modifier-click would open it, mirroring the + // editor's behavior in the "open with Cmd/Ctrl+Click" mode. + let modHeld = $state(false); + onMount(() => { + void linkPrefs.init(); + const onKey = (e: KeyboardEvent) => (modHeld = e.metaKey || e.ctrlKey); + const onBlur = () => (modHeld = false); + window.addEventListener("keydown", onKey, true); + window.addEventListener("keyup", onKey, true); + window.addEventListener("blur", onBlur, true); + return () => { + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("keyup", onKey, true); + window.removeEventListener("blur", onBlur, true); + }; + }); // The sample link mirrors the reading (preview) behavior so trying it here // feels exactly like clicking in a note. @@ -30,6 +59,7 @@ class="sample-link ul-{linkPrefs.underline}" class:ext={linkPrefs.externalIndicator} class:plain-click={linkPrefs.openWith === "click"} + class:mod-ready={linkPrefs.openWith === "modclick" && modHeld} title={linkPrefs.tooltip ? "https://example.com" : undefined} onclick={sampleLinkClick} >the write-up for the numbers. @@ -42,75 +72,33 @@ : " everywhere, so a stray click never leaves the note."}

-
- Open links with -
- - -
-
+ linkPrefs.setOpenWith(v)} + /> -
- Underline -
- - - -
-
+ linkPrefs.setUnderline(v)} + /> -
- - Show destination on hover - The URL stays hidden in the text; hovering reveals where a link goes. - - -
+ linkPrefs.setTooltip(v)} + /> -
- - Mark external links with ↗ - A small arrow after links that leave the app. - - -
+ linkPrefs.setExternalIndicator(v)} + /> diff --git a/src/lib/components/settings/SettingsVault.svelte b/src/lib/components/settings/SettingsVault.svelte new file mode 100644 index 0000000..d714475 --- /dev/null +++ b/src/lib/components/settings/SettingsVault.svelte @@ -0,0 +1,297 @@ + + +
+

Vault

+

+ Your notes as plain Markdown files, with their tags and Spaces, in a folder + you choose. Readable and editable in any editor, with InstantNotes closed, + on any device. +

+ + Live vault + {#if !status?.path} + + {#snippet control()} + + {/snippet} + + {:else} +
+
+ Folder + {status.path} +
+
+ Status + {statusLine} +
+
+ + + +
+
+ {#if reportLines.length} +
    + {#each reportLines as line (line)} +
  • {line}
  • + {/each} +
+ {/if} +

+ InstantNotes writes these files; it does not read them back yet. An edit + made in the folder is replaced the next time that note changes here, and + Check vault lists any file that no longer matches. +

+ {/if} + + One-time copy + + {#snippet control()} + + {/snippet} + +
+ + diff --git a/src/lib/components/settings/SettingsVault.test.ts b/src/lib/components/settings/SettingsVault.test.ts new file mode 100644 index 0000000..5df8efc --- /dev/null +++ b/src/lib/components/settings/SettingsVault.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, fireEvent, cleanup, waitFor } from "@testing-library/svelte"; +import SettingsVault from "./SettingsVault.svelte"; +import { exportVaultToFolder } from "$lib/vault-export"; +import { chooseVaultFolder } from "$lib/vault-mirror"; +import { getVaultStatus, setVaultFolder, verifyVault } from "$lib/api/client"; +import { toasts } from "$lib/stores/toasts.svelte"; +import type { VaultStatus } from "$lib/api/types"; + +vi.mock("$lib/vault-export", () => ({ + exportVaultToFolder: vi.fn(), +})); +vi.mock("$lib/vault-mirror", async (importOriginal) => ({ + ...(await importOriginal()), + chooseVaultFolder: vi.fn(), +})); +vi.mock("$lib/api/client", () => ({ + getVaultStatus: vi.fn(), + setVaultFolder: vi.fn(), + verifyVault: vi.fn(), +})); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(() => Promise.resolve(() => {})), +})); + +const OFF: VaultStatus = { path: null, pending: 0, lastError: null, lastFlushedAt: null }; + +function on(overrides: Partial = {}): VaultStatus { + return { path: "/Users/me/Vault", pending: 0, lastError: null, lastFlushedAt: "t", ...overrides }; +} + +afterEach(cleanup); + +beforeEach(() => { + vi.mocked(exportVaultToFolder).mockReset(); + vi.mocked(chooseVaultFolder).mockReset(); + vi.mocked(getVaultStatus).mockReset().mockResolvedValue(OFF); + vi.mocked(setVaultFolder).mockReset(); + vi.mocked(verifyVault).mockReset(); + toasts.items = []; +}); + +describe("SettingsVault live mirror", () => { + it("offers to choose a folder while mirroring is off", async () => { + const { findByRole, queryByRole } = render(SettingsVault); + expect(await findByRole("button", { name: /Choose folder/ })).toBeTruthy(); + expect(queryByRole("button", { name: /Stop mirroring/ })).toBeNull(); + }); + + it("starts mirroring into the picked folder", async () => { + vi.mocked(chooseVaultFolder).mockResolvedValue({ path: "/Users/me/Vault" }); + vi.mocked(setVaultFolder).mockResolvedValue(on({ pending: 12 })); + const { findByRole, findByText } = render(SettingsVault); + + await fireEvent.click(await findByRole("button", { name: /Choose folder/ })); + + expect(setVaultFolder).toHaveBeenCalledWith("/Users/me/Vault"); + expect(await findByText("/Users/me/Vault")).toBeTruthy(); + expect(await findByText(/Writing 12 notes/)).toBeTruthy(); + }); + + it("does nothing when the folder picker is cancelled", async () => { + vi.mocked(chooseVaultFolder).mockResolvedValue({ cancelled: true }); + const { findByRole } = render(SettingsVault); + await fireEvent.click(await findByRole("button", { name: /Choose folder/ })); + await waitFor(() => expect(chooseVaultFolder).toHaveBeenCalled()); + expect(setVaultFolder).not.toHaveBeenCalled(); + }); + + it("shows the backend's reason when a folder is refused", async () => { + vi.mocked(chooseVaultFolder).mockResolvedValue({ path: "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/Library/InstantNotes" }); + vi.mocked(setVaultFolder).mockRejectedValue( + new Error("choose a folder outside the app's own data folder"), + ); + const { findByRole } = render(SettingsVault); + await fireEvent.click(await findByRole("button", { name: /Choose folder/ })); + await waitFor(() => + expect(toasts.items.map((t) => t.message)).toEqual([ + expect.stringContaining("outside the app's own data folder"), + ]), + ); + }); + + it("reports an up-to-date mirror", async () => { + vi.mocked(getVaultStatus).mockResolvedValue(on()); + const { findByText } = render(SettingsVault); + expect(await findByText(/Up to date/)).toBeTruthy(); + }); + + it("explains a missing folder as paused, not failed", async () => { + vi.mocked(getVaultStatus).mockResolvedValue( + on({ pending: 3, lastError: "vault folder not found: /Volumes/USB/Vault" }), + ); + const { findByText } = render(SettingsVault); + expect(await findByText(/Paused: the folder can't be found/)).toBeTruthy(); + }); + + it("shows any other error as paused with its message", async () => { + vi.mocked(getVaultStatus).mockResolvedValue(on({ lastError: "note x: disk full" })); + const { findByText } = render(SettingsVault); + expect(await findByText(/Paused: note x: disk full/)).toBeTruthy(); + }); + + it("stops mirroring and returns to the off state", async () => { + vi.mocked(getVaultStatus).mockResolvedValue(on()); + vi.mocked(setVaultFolder).mockResolvedValue(OFF); + const { findByRole } = render(SettingsVault); + + await fireEvent.click(await findByRole("button", { name: /Stop mirroring/ })); + + expect(setVaultFolder).toHaveBeenCalledWith(null); + expect(await findByRole("button", { name: /Choose folder/ })).toBeTruthy(); + }); + + it("checks the vault and shows what it found", async () => { + vi.mocked(getVaultStatus).mockResolvedValue(on()); + vi.mocked(verifyVault).mockResolvedValue({ + checked: 3, + missing: [], + diverged: ["Plan.md"], + orphans: [], + pending: 0, + manifestOk: true, + }); + const { findByRole, findByText } = render(SettingsVault); + + await fireEvent.click(await findByRole("button", { name: /Check vault/ })); + + expect(await findByText(/1 file was edited outside InstantNotes: Plan.md/)).toBeTruthy(); + }); +}); + +describe("SettingsVault", () => { + it("shows a success toast after a completed export", async () => { + vi.mocked(exportVaultToFolder).mockResolvedValue({ ok: true }); + const { getByRole } = render(SettingsVault); + + await fireEvent.click(getByRole("button", { name: /Export a copy/ })); + + await waitFor(() => + expect(toasts.items.map((t) => t.message)).toEqual([ + expect.stringContaining("Exported."), + ]), + ); + }); + + it("shows no toast when the user cancels the folder picker", async () => { + vi.mocked(exportVaultToFolder).mockResolvedValue({ cancelled: true }); + const { getByRole } = render(SettingsVault); + + await fireEvent.click(getByRole("button", { name: /Export a copy/ })); + await waitFor(() => expect(vi.mocked(exportVaultToFolder)).toHaveBeenCalled()); + + expect(toasts.items).toEqual([]); + }); + + it("shows the error on failure", async () => { + vi.mocked(exportVaultToFolder).mockResolvedValue({ ok: false, error: "disk full" }); + const { getByRole } = render(SettingsVault); + + await fireEvent.click(getByRole("button", { name: /Export a copy/ })); + + await waitFor(() => + expect(toasts.items.map((t) => t.message)).toEqual([ + expect.stringContaining("disk full"), + ]), + ); + }); + + it("disables the button while exporting", async () => { + let resolve!: (v: { ok: true }) => void; + vi.mocked(exportVaultToFolder).mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + const { getByRole } = render(SettingsVault); + const button = getByRole("button", { name: /Export a copy/ }) as HTMLButtonElement; + + await fireEvent.click(button); + expect(button.disabled).toBe(true); + + resolve({ ok: true }); + await waitFor(() => expect(button.disabled).toBe(false)); + }); +}); diff --git a/src/lib/components/settings/ToggleRow.svelte b/src/lib/components/settings/ToggleRow.svelte new file mode 100644 index 0000000..9e9cded --- /dev/null +++ b/src/lib/components/settings/ToggleRow.svelte @@ -0,0 +1,82 @@ + + + + {#snippet control()} + + {/snippet} + + + diff --git a/src/lib/components/settings/rows.test.ts b/src/lib/components/settings/rows.test.ts new file mode 100644 index 0000000..e431754 --- /dev/null +++ b/src/lib/components/settings/rows.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment jsdom +// The two settings row primitives. They are shared by every settings page, so +// their props and their keyboard behavior are covered here rather than +// re-tested through each page that mounts them. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, fireEvent, cleanup } from "@testing-library/svelte"; +import SegmentedRow from "./SegmentedRow.svelte"; +import ToggleRow from "./ToggleRow.svelte"; + +afterEach(cleanup); + +const OPTIONS = [ + { value: "copy", label: "Copy in" }, + { value: "link", label: "Link original" }, + { value: "ask", label: "Ask" }, +]; + +// A key press lands on the focused segment, which is the selected one: the +// group itself is never a tab stop. +function press(radios: HTMLElement[], key: string) { + const focused = radios.find((r) => r.getAttribute("tabindex") === "0") ?? radios[0]; + return fireEvent.keyDown(focused, { key }); +} + +function segment(overrides: Record = {}) { + return { + label: "When adding an image", + options: OPTIONS, + value: "copy", + onchange: vi.fn(), + ...overrides, + }; +} + +describe("SegmentedRow", () => { + it("renders one radio per option and marks the selected one", () => { + const { getAllByRole } = render(SegmentedRow, segment({ value: "link" })); + const radios = getAllByRole("radio"); + expect(radios.map((r) => r.textContent)).toEqual(["Copy in", "Link original", "Ask"]); + expect(radios.map((r) => r.getAttribute("aria-checked"))).toEqual(["false", "true", "false"]); + }); + + it("makes only the selected segment a tab stop", () => { + const { getAllByRole } = render(SegmentedRow, segment({ value: "link" })); + expect(getAllByRole("radio").map((r) => r.getAttribute("tabindex"))).toEqual(["-1", "0", "-1"]); + }); + + it("reports the new value on click, and stays quiet when it is already selected", async () => { + const props = segment(); + const { getAllByRole } = render(SegmentedRow, props); + await fireEvent.click(getAllByRole("radio")[2]); + expect(props.onchange).toHaveBeenCalledWith("ask"); + + await fireEvent.click(getAllByRole("radio")[0]); + expect(props.onchange).toHaveBeenCalledTimes(1); + }); + + it("moves the selection with the arrow keys, wrapping at both ends", async () => { + const props = segment(); + const { getAllByRole } = render(SegmentedRow, props); + const radios = getAllByRole("radio"); + + await press(radios, "ArrowRight"); + expect(props.onchange).toHaveBeenLastCalledWith("link"); + + // Down and up are the same axis as right and left on a segmented control. + await press(radios, "ArrowDown"); + expect(props.onchange).toHaveBeenLastCalledWith("link"); + + // From the first option, going back wraps to the last. + await press(radios, "ArrowLeft"); + expect(props.onchange).toHaveBeenLastCalledWith("ask"); + }); + + it("jumps to the ends with Home and End", async () => { + const props = segment({ value: "link" }); + const { getAllByRole } = render(SegmentedRow, props); + const radios = getAllByRole("radio"); + + await press(radios, "End"); + expect(props.onchange).toHaveBeenLastCalledWith("ask"); + + await press(radios, "Home"); + expect(props.onchange).toHaveBeenLastCalledWith("copy"); + }); + + it("keeps focus on the segment the keyboard moved to", async () => { + const { getAllByRole } = render(SegmentedRow, segment()); + const radios = getAllByRole("radio"); + await press(radios, "End"); + expect(document.activeElement).toBe(radios[2]); + }); + + it("leaves keys it does not handle to the page", async () => { + const props = segment(); + const { getAllByRole } = render(SegmentedRow, props); + const notPrevented = await press(getAllByRole("radio"), "Tab"); + expect(notPrevented).toBe(true); + expect(props.onchange).not.toHaveBeenCalled(); + }); + + it("is inert when disabled, by pointer and by keyboard", async () => { + const props = segment({ disabled: true }); + const { getAllByRole } = render(SegmentedRow, props); + const radios = getAllByRole("radio"); + expect(radios.every((r) => (r as HTMLButtonElement).disabled)).toBe(true); + + await fireEvent.click(radios[1]); + await press(radios, "ArrowRight"); + expect(props.onchange).not.toHaveBeenCalled(); + }); + + it("announces the sub line as the control's description", () => { + const sub = "Copy keeps a portable copy inside InstantNotes."; + const { getByRole, getByText } = render(SegmentedRow, segment({ sub })); + const describedBy = getByRole("radiogroup").getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); + expect(getByText(sub).id).toBe(describedBy); + }); + + it("carries no description when there is no sub line", () => { + const { getByRole } = render(SegmentedRow, segment()); + expect(getByRole("radiogroup").getAttribute("aria-describedby")).toBe(null); + }); +}); + +function toggle(overrides: Record = {}) { + return { + label: "Show exact save time", + checked: false, + onchange: vi.fn(), + ...overrides, + }; +} + +describe("ToggleRow", () => { + it("reports the flipped value on click", async () => { + const props = toggle(); + const { getByRole } = render(ToggleRow, props); + const sw = getByRole("switch", { name: "Show exact save time" }); + expect(sw.getAttribute("aria-checked")).toBe("false"); + await fireEvent.click(sw); + expect(props.onchange).toHaveBeenCalledWith(true); + }); + + it("reports false when it is already on", async () => { + const props = toggle({ checked: true }); + const { getByRole } = render(ToggleRow, props); + expect(getByRole("switch").getAttribute("aria-checked")).toBe("true"); + await fireEvent.click(getByRole("switch")); + expect(props.onchange).toHaveBeenCalledWith(false); + }); + + it("is inert when disabled", async () => { + const props = toggle({ disabled: true }); + const { getByRole } = render(ToggleRow, props); + expect((getByRole("switch") as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(getByRole("switch")); + expect(props.onchange).not.toHaveBeenCalled(); + }); + + it("announces the sub line as the switch's description", () => { + const sub = "The exact time is always on hover."; + const { getByRole, getByText } = render(ToggleRow, toggle({ sub })); + const describedBy = getByRole("switch").getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); + expect(getByText(sub).id).toBe(describedBy); + }); +}); diff --git a/src/lib/components/whiteboard/WhiteboardCanvas.svelte b/src/lib/components/whiteboard/WhiteboardCanvas.svelte new file mode 100644 index 0000000..880106d --- /dev/null +++ b/src/lib/components/whiteboard/WhiteboardCanvas.svelte @@ -0,0 +1,202 @@ + + +
+{#if failed} +

This whiteboard couldn't load. Its drawing is safe and nothing was changed.

+{/if} + + diff --git a/src/lib/components/whiteboard/WhiteboardCanvas.test.ts b/src/lib/components/whiteboard/WhiteboardCanvas.test.ts new file mode 100644 index 0000000..366595a --- /dev/null +++ b/src/lib/components/whiteboard/WhiteboardCanvas.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment jsdom +// The whiteboard canvas's save path. Excalidraw, React, and ReactDOM are +// replaced with recorders: the test drives `onChange` exactly as Excalidraw +// calls it and checks what reaches the save queue, and when. +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, cleanup, waitFor } from "@testing-library/svelte"; +import WhiteboardCanvas from "./WhiteboardCanvas.svelte"; +import { serializeBoard, emptyBoard } from "$lib/whiteboard/document"; + +type Rendered = { type: unknown; props: Record; children: unknown[] }; +const rendered: Rendered[] = []; +const unmount = vi.fn(); + +vi.mock("react", () => ({ + createElement: (type: unknown, props: Record | null, ...children: unknown[]) => ({ + type, + props: props ?? {}, + children, + }), +})); +vi.mock("react-dom/client", () => ({ + createRoot: () => ({ render: (el: Rendered) => rendered.push(el), unmount }), +})); +vi.mock("@excalidraw/excalidraw/index.css", () => ({})); +vi.mock("$lib/whiteboard/excalidraw", () => { + const Stub = () => null; + const MainMenu = Object.assign(() => null, { + DefaultItems: { ChangeCanvasBackground: Stub, ClearCanvas: Stub, Help: Stub }, + }); + const WelcomeScreen = Object.assign(() => null, { + Hints: { MenuHint: Stub, ToolbarHint: Stub, HelpHint: Stub }, + }); + return { + loadExcalidraw: async () => ({ Excalidraw: Stub, MainMenu, WelcomeScreen }), + }; +}); + +const rect = (id: string, version = 1, extra = {}) => ({ + id, + type: "rectangle", + x: 0, + y: 0, + version, + ...extra, +}); +const label = (id: string, text: string) => ({ id, type: "text", x: 0, y: 0, version: 1, text }); + +const STORED = serializeBoard({ ...emptyBoard(), elements: [rect("r1") as never] }); + +function mount(overrides: Record = {}) { + const onchange = vi.fn(); + let flushHook: (() => void) | null = null; + const onlinkopen = vi.fn(); + const view = render(WhiteboardCanvas, { + noteId: "b1", + surfaceData: STORED, + readonly: false, + theme: "light", + onchange, + registerFlush: (f: () => void) => { + flushHook = f; + return () => (flushHook = null); + }, + onlinkopen, + ...overrides, + }); + return { view, onchange, onlinkopen, flushHook: () => flushHook }; +} + +const latest = () => rendered[rendered.length - 1]; +const excalidrawProps = () => latest().props; + +beforeEach(() => { + rendered.length = 0; + unmount.mockReset(); + vi.useFakeTimers({ shouldAdvanceTime: true }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("WhiteboardCanvas", () => { + it("opens the stored board", async () => { + mount(); + await waitFor(() => expect(rendered.length).toBe(1)); + expect(excalidrawProps().initialData.elements).toEqual([rect("r1")]); + expect(excalidrawProps().theme).toBe("light"); + }); + + it("does not save when nothing on the board changed (scroll, zoom, select)", async () => { + const { onchange } = mount(); + await waitFor(() => expect(rendered.length).toBe(1)); + excalidrawProps().onChange([rect("r1")], { scrollX: 40 }, {}); + await vi.advanceTimersByTimeAsync(1000); + expect(onchange).not.toHaveBeenCalled(); + }); + + it("saves an edit, with the words on the board as the note's text", async () => { + const { onchange } = mount(); + await waitFor(() => expect(rendered.length).toBe(1)); + excalidrawProps().onChange( + [rect("r1", 2), label("t1", "ship it")], + { viewBackgroundColor: "#fff", selectedElementIds: { r1: true } }, + {}, + ); + expect(onchange).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(250); + expect(onchange).toHaveBeenCalledTimes(1); + const [id, edit] = onchange.mock.calls[0]; + expect(id).toBe("b1"); + expect(edit.body).toBe("ship it"); + const saved = JSON.parse(edit.surfaceData).data; + expect(saved.elements.map((e: { id: string }) => e.id)).toEqual(["r1", "t1"]); + // Selection is session state, not part of the board. + expect(saved.appState).not.toHaveProperty("selectedElementIds"); + expect(saved.appState.viewBackgroundColor).toBe("#fff"); + }); + + it("batches a burst of changes into one save of the latest scene", async () => { + const { onchange } = mount(); + await waitFor(() => expect(rendered.length).toBe(1)); + for (let v = 2; v <= 6; v++) excalidrawProps().onChange([rect("r1", v)], {}, {}); + await vi.advanceTimersByTimeAsync(250); + expect(onchange).toHaveBeenCalledTimes(1); + expect(JSON.parse(onchange.mock.calls[0][1].surfaceData).data.elements[0].version).toBe(6); + }); + + it("leaves deleted elements and the images only they used out of the save", async () => { + const { onchange } = mount(); + await waitFor(() => expect(rendered.length).toBe(1)); + excalidrawProps().onChange( + [ + rect("r1", 2), + { ...rect("img1"), type: "image", fileId: "f1", isDeleted: true }, + { ...rect("img2"), type: "image", fileId: "f2" }, + ], + {}, + { f1: { id: "f1" }, f2: { id: "f2" } }, + ); + await vi.advanceTimersByTimeAsync(250); + const saved = JSON.parse(onchange.mock.calls[0][1].surfaceData).data; + expect(saved.elements.map((e: { id: string }) => e.id)).toEqual(["r1", "img2"]); + expect(Object.keys(saved.files)).toEqual(["f2"]); + }); + + // The defect that kept the whiteboard off 0.9.0: an edit still waiting to + // be serialized was dropped when the board closed (note switch, quit). + it("hands a waiting edit over when the board closes, instead of dropping it", async () => { + const { view, onchange } = mount(); + await waitFor(() => expect(rendered.length).toBe(1)); + excalidrawProps().onChange([rect("r1", 2)], {}, {}); + view.unmount(); + expect(onchange).toHaveBeenCalledTimes(1); + expect(onchange.mock.calls[0][0]).toBe("b1"); + expect(unmount).toHaveBeenCalled(); + }); + + it("hands a waiting edit over when the library flushes (quit, switch, trash)", async () => { + const { onchange, flushHook } = mount(); + await waitFor(() => expect(rendered.length).toBe(1)); + excalidrawProps().onChange([rect("r1", 2)], {}, {}); + flushHook()?.(); + expect(onchange).toHaveBeenCalledTimes(1); + // Nothing is left to fire later. + await vi.advanceTimersByTimeAsync(1000); + expect(onchange).toHaveBeenCalledTimes(1); + }); + + it("follows the app theme and read-only state without remounting", async () => { + const { view } = mount(); + await waitFor(() => expect(rendered.length).toBe(1)); + await view.rerender({ theme: "dark", readonly: true }); + await waitFor(() => expect(excalidrawProps().theme).toBe("dark")); + expect(excalidrawProps().viewModeEnabled).toBe(true); + expect(unmount).not.toHaveBeenCalled(); + }); + + it("opens web links in the browser, not inside the app", async () => { + const { onlinkopen } = mount(); + await waitFor(() => expect(rendered.length).toBe(1)); + const preventDefault = vi.fn(); + excalidrawProps().onLinkOpen({ link: "https://example.com" }, { preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + expect(onlinkopen).toHaveBeenCalledWith("https://example.com"); + }); +}); diff --git a/src/lib/contexting-format.test.ts b/src/lib/contexting-format.test.ts index 4891307..1a4266c 100644 --- a/src/lib/contexting-format.test.ts +++ b/src/lib/contexting-format.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { renderTemplate, DEFAULT_TEMPLATE } from "./contexting-format"; +import { renderTemplate, applyImageMode, DEFAULT_TEMPLATE } from "./contexting-format"; const note = { title: "Groceries", body: "milk\neggs", updatedAt: "2026-06-18T10:00:00.000Z" }; @@ -37,4 +37,65 @@ describe("renderTemplate", () => { expect(out).toContain("milk\neggs"); expect(out).toContain("
"); }); + + it("rewrites attachment images to an absolute path when asked", () => { + const withImg = { ...note, body: "see ![shot](attachments/a1.png)" }; + const out = renderTemplate("{content}", withImg, [], { + imageMode: "absolute", + attachmentsDir: "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/data/attachments", + }); + expect(out).toBe("see ![shot](/data/attachments/a1.png)"); + }); +}); + +describe("applyImageMode", () => { + const body = "before ![alt](attachments/x.png) after ![web](https://e.com/y.png)"; + + it("keeps references untouched in keep mode", () => { + expect(applyImageMode(body, "keep", "/d")).toBe(body); + }); + + it("rewrites only attachment refs in absolute mode", () => { + expect(applyImageMode(body, "absolute", "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/d/att")).toBe( + "before ![alt](/d/att/x.png) after ![web](https://e.com/y.png)", + ); + }); + + it("leaves attachment refs alone in absolute mode with no dir", () => { + expect(applyImageMode(body, "absolute", null)).toBe(body); + }); + + it("removes images in strip mode", () => { + expect(applyImageMode("a ![x](attachments/x.png) b", "strip", null)).toBe("a b"); + }); + + // A "link the original file" image is an absolute local path, not an + // attachments/ reference (NoteEditor.svelte inserts `![](${picked})` verbatim). + // Contexting has no way to know whether a tool reading the copied text can + // reach that path, so all three modes leave it exactly as written. + const linked = "![shot](/Users/jam/Pictures/shot.png)"; + const copied = "![alt](attachments/x.png)"; + const both = `${linked} and ${copied}`; + + it("leaves a linked image untouched in keep mode", () => { + expect(applyImageMode(linked, "keep", "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/d/att")).toBe(linked); + }); + + it("leaves a linked image untouched in absolute mode", () => { + expect(applyImageMode(linked, "absolute", "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/d/att")).toBe(linked); + }); + + it("strips a linked image the same as a copied one", () => { + expect(applyImageMode(linked, "strip", null)).toBe(""); + }); + + it("a note with both a linked and a copied image: absolute mode rewrites only the copied one", () => { + expect(applyImageMode(both, "absolute", "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/d/att")).toBe( + `${linked} and ![alt](/d/att/x.png)`, + ); + }); + + it("a note with both a linked and a copied image: strip mode removes both", () => { + expect(applyImageMode(both, "strip", null)).toBe(" and "); + }); }); diff --git a/src/lib/contexting-format.ts b/src/lib/contexting-format.ts index fd9b962..d7ae7ea 100644 --- a/src/lib/contexting-format.ts +++ b/src/lib/contexting-format.ts @@ -11,22 +11,73 @@ export const DEFAULT_TEMPLATE = `\ntags: {tags}\n{content} /** Placeholders the template understands, surfaced in the settings UI. */ export const TEMPLATE_VARS = ["{title}", "{tags}", "{date}", "{content}"] as const; +/** + * How images in the note body travel when the note is copied as context: + * - `keep`: leave the markdown reference as written. + * - `absolute`: rewrite stored-attachment references to their absolute file + * path, so a tool/agent that can read files finds the image. + * - `strip`: remove images entirely for a text-only context. + */ +export type ContextImageMode = "keep" | "absolute" | "strip"; + +export const DEFAULT_IMAGE_MODE: ContextImageMode = "absolute"; + +const ATTACHMENT_PREFIX = "attachments/"; +const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g; + +/** + * Apply the image mode to a note body. `attachmentsDir` (absolute, no trailing + * slash) is only needed for `absolute`; when it is missing, attachment + * references are left as written rather than producing a broken path. + */ +export function applyImageMode( + body: string, + mode: ContextImageMode, + attachmentsDir: string | null, +): string { + if (mode === "keep") return body; + if (mode === "strip") { + // Drop the image and any now-empty line it left behind. + return body.replace(IMAGE_RE, "").replace(/[^\S\n]+\n/g, "\n"); + } + // absolute + return body.replace(IMAGE_RE, (whole, alt: string, url: string) => { + if (!url.startsWith(ATTACHMENT_PREFIX)) return whole; + if (!attachmentsDir) return whole; + const name = url.slice(ATTACHMENT_PREFIX.length); + return `![${alt}](${attachmentsDir}/${name})`; + }); +} + +export interface RenderOptions { + imageMode?: ContextImageMode; + /** Absolute attachments directory, required only for `absolute` image mode. */ + attachmentsDir?: string | null; +} + /** * Fill {title}, {tags}, {date}, {content} from a note and its tags. Tags render * as space-separated #hashtags to match how they are written in the editor; * {date} uses the note's last-updated date in the local format. Unknown - * placeholders are left untouched. + * placeholders are left untouched. Images in the body are transformed per + * `opts.imageMode` before {content} is substituted. */ export function renderTemplate( template: string, note: Pick, tags: Pick[], + opts: RenderOptions = {}, ): string { + const body = applyImageMode( + note.body, + opts.imageMode ?? "keep", + opts.attachmentsDir ?? null, + ); const values: Record = { title: note.title || "Untitled", tags: tags.map((t) => `#${t.name}`).join(" "), date: new Date(note.updatedAt).toLocaleDateString(), - content: note.body, + content: body, }; return template.replace(/\{(title|tags|date|content)\}/g, (_, key: string) => values[key] ?? ""); } diff --git a/src/lib/editor/constructs/image.ts b/src/lib/editor/constructs/image.ts index 88dad2e..39446f7 100644 --- a/src/lib/editor/constructs/image.ts +++ b/src/lib/editor/constructs/image.ts @@ -7,7 +7,7 @@ import { Decoration, WidgetType } from "@codemirror/view"; import type { SyntaxNodeRef } from "@lezer/common"; -import { attachmentSrc, attachmentsBaseField } from "../images"; +import { imageSrc, attachmentsBaseField } from "../images"; import type { ConstructSpec, Emit, ScanContext } from "../types"; class ImageWidget extends WidgetType { @@ -46,7 +46,7 @@ export class ImageSpec implements ConstructSpec { if (!cx.preview) return true; const url = node.node.getChild("URL"); if (!url) return true; - const src = attachmentSrc( + const src = imageSrc( cx.state.doc.sliceString(url.from, url.to), cx.state.field(attachmentsBaseField), this.convert, diff --git a/src/lib/editor/images.test.ts b/src/lib/editor/images.test.ts index 250c92f..9c968f1 100644 --- a/src/lib/editor/images.test.ts +++ b/src/lib/editor/images.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { attachmentSrc, extForMime, attachmentMarkdown } from "./images"; +import { + attachmentSrc, + extForMime, + attachmentMarkdown, + localFilePath, + imageSrc, + linkedImagePaths, +} from "./images"; const convert = (p: string) => `asset://${p}`; @@ -25,6 +32,43 @@ describe("attachmentSrc", () => { }); }); +describe("localFilePath", () => { + it("passes through an absolute POSIX path", () => { + expect(localFilePath("/Users/jam/shot.png")).toBe("/Users/jam/shot.png"); + }); + it("decodes a file:// URL to its path", () => { + expect(localFilePath("file:///Users/jam/my%20shot.png")).toBe("/Users/jam/my shot.png"); + }); + it("accepts a Windows drive path", () => { + expect(localFilePath("C:\\Users\\jam\\shot.png")).toBe("C:\\Users\\jam\\shot.png"); + }); + it("rejects attachments, remote, and data URLs", () => { + expect(localFilePath("attachments/x.png")).toBeNull(); + expect(localFilePath("https://e.com/x.png")).toBeNull(); + expect(localFilePath("data:image/png;base64,AAAA")).toBeNull(); + }); +}); + +describe("imageSrc", () => { + it("resolves stored attachments through the base dir", () => { + expect(imageSrc("attachments/a1.png", "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/data/App", convert)).toBe("asset:///data/App/a1.png"); + }); + it("resolves a linked absolute file directly", () => { + expect(imageSrc("/Users/jam/shot.png", null, convert)).toBe("asset:///Users/jam/shot.png"); + }); + it("returns null for remote images", () => { + expect(imageSrc("https://e.com/x.png", "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/data/App", convert)).toBeNull(); + }); +}); + +describe("linkedImagePaths", () => { + it("collects unique linked local paths, ignoring attachments and remotes", () => { + const body = + "![a](/x/1.png) ![b](attachments/2.png) ![c](/x/1.png) ![d](https://e.com/3.png)"; + expect(linkedImagePaths(body)).toEqual(["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/x/1.png"]); + }); +}); + describe("extForMime", () => { it("maps supported image types", () => { expect(extForMime("image/png")).toBe("png"); diff --git a/src/lib/editor/images.ts b/src/lib/editor/images.ts index 86317d1..0ccc83c 100644 --- a/src/lib/editor/images.ts +++ b/src/lib/editor/images.ts @@ -55,6 +55,58 @@ export function attachmentSrc( return base ? convert(`${base}/${name}`) : null; } +/** + * A local filesystem path this reference points at, or null when it is not a + * local file (stored attachment, remote URL, or data URL). Used for the "link + * the original file" image mode: an absolute POSIX path, a `file://` URL, or a + * Windows drive path all resolve; everything else does not. + */ +export function localFilePath(url: string): string | null { + const u = url.trim(); + if (u.startsWith(ATTACHMENT_PREFIX)) return null; + if (/^https?:\/\//i.test(u) || u.startsWith("data:")) return null; + if (u.startsWith("file://")) { + try { + return decodeURIComponent(new URL(u).pathname); + } catch { + return null; + } + } + if (u.startsWith("/")) return u; + if (/^[a-zA-Z]:[\\/]/.test(u)) return u; + return null; +} + +/** + * Resolve any renderable image reference to a webview-loadable src, or null + * when it can't render inline. Handles both stored attachments (relative, + * needs the base dir) and linked local files (absolute, rendered through the + * asset protocol once the path has been allowed into the asset scope). + */ +export function imageSrc( + url: string, + base: string | null, + convert: (path: string) => string, +): string | null { + const att = attachmentSrc(url, base, convert); + if (att) return att; + const local = localFilePath(url); + return local ? convert(local) : null; +} + +/** Absolute local paths of every linked (non-attachment) image in a body, so + * the caller can allow them into the asset scope before they render. */ +export function linkedImagePaths(body: string): string[] { + const out = new Set(); + const re = /!\[[^\]]*\]\(([^)\s]+)\)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(body)) !== null) { + const p = localFilePath(m[1]); + if (p) out.add(p); + } + return [...out]; +} + /** File extension for a pasteable image MIME type, or null to skip the file. */ export function extForMime(mime: string): string | null { switch (mime) { diff --git a/src/lib/editor/index.ts b/src/lib/editor/index.ts index 07b0684..df5ad44 100644 --- a/src/lib/editor/index.ts +++ b/src/lib/editor/index.ts @@ -13,7 +13,7 @@ import { convertFileSrc } from "@tauri-apps/api/core"; import { previewKernel } from "./kernel"; import { CaretGuard } from "./caret"; import { markerBackspaceKeymap } from "./blocks"; -import { linkOpenHandler, linkPrefsField } from "./links"; +import { linkOpenHandler, linkPrefsField, modKeyCursor } from "./links"; import { attachmentsBaseField, imageCapture } from "./images"; import { editModeTaskToggle } from "./tasks"; import { kernelTheme } from "./theme"; @@ -74,6 +74,7 @@ export function editorKernel(opts: EditorKernelOpts): Extension { extension, new CaretGuard(plugin).extension(), linkOpenHandler(opts.openUrl), + modKeyCursor(), editModeTaskToggle(), imageCapture({ save: opts.saveImage, onError: opts.onImageError }), kernelTheme, @@ -98,6 +99,9 @@ export { setAttachmentsBase, attachmentsBaseField, attachmentSrc, + imageSrc, + localFilePath, + linkedImagePaths, extForMime, attachmentMarkdown, } from "./images"; diff --git a/src/lib/editor/links.test.ts b/src/lib/editor/links.test.ts index 5155ce5..da7d3a0 100644 --- a/src/lib/editor/links.test.ts +++ b/src/lib/editor/links.test.ts @@ -1,8 +1,15 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { ensureSyntaxTree } from "@codemirror/language"; -import { linkAt, normalizeHref, linkMarkClass, DEFAULT_LINK_PREFS } from "./links"; +import { + linkAt, + normalizeHref, + linkMarkClass, + modKeyCursor, + DEFAULT_LINK_PREFS, +} from "./links"; // Same language setup as the editor (GFM base) so autolinks and bare URLs // parse the way they do in the app. @@ -107,3 +114,61 @@ describe("linkAt", () => { expect(linkAt(stateOf(doc), 2)).toBeNull(); }); }); + +// Cursor feedback across the three link-opening modes. Mode 1 (preview + +// "click") gets its pointer from linkMarkClass's cm-link-clickable, asserted +// above ("defaults: ... clickable in preview"). Modes 2 (preview + +// "modclick") and 3 (edit mode, any openWith) get theirs from cm-mod-held +// while the modifier is held — the CSS pairing is `.cm-mod-held +// .cm-link-target { cursor: pointer }` (editor/theme.ts), so this only needs +// to prove the class itself toggles correctly; which mode is active doesn't +// change ModKeyCursor's behavior, since it listens at the window level +// regardless of preview/openWith. +describe("modKeyCursor", () => { + function mountView(): EditorView { + return new EditorView({ + state: EditorState.create({ extensions: [modKeyCursor()] }), + parent: document.body, + }); + } + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("adds cm-mod-held while Cmd or Ctrl is held, and removes it on release", () => { + const view = mountView(); + expect(view.dom.classList.contains("cm-mod-held")).toBe(false); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Meta", metaKey: true })); + expect(view.dom.classList.contains("cm-mod-held")).toBe(true); + + window.dispatchEvent(new KeyboardEvent("keyup", { key: "Meta", metaKey: false })); + expect(view.dom.classList.contains("cm-mod-held")).toBe(false); + view.destroy(); + }); + + it("also responds to Ctrl, so the feedback is correct on every platform", () => { + const view = mountView(); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true })); + expect(view.dom.classList.contains("cm-mod-held")).toBe(true); + view.destroy(); + }); + + it("clears the held state on window blur, so it can't get stuck on", () => { + const view = mountView(); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Meta", metaKey: true })); + expect(view.dom.classList.contains("cm-mod-held")).toBe(true); + + window.dispatchEvent(new FocusEvent("blur")); + expect(view.dom.classList.contains("cm-mod-held")).toBe(false); + view.destroy(); + }); + + it("removes the class on destroy, so a stale modifier state can't leak into the next note", () => { + const view = mountView(); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Meta", metaKey: true })); + view.destroy(); + expect(view.dom.classList.contains("cm-mod-held")).toBe(false); + }); +}); diff --git a/src/lib/editor/links.ts b/src/lib/editor/links.ts index ab4ddff..cdf7727 100644 --- a/src/lib/editor/links.ts +++ b/src/lib/editor/links.ts @@ -10,7 +10,7 @@ // the pointer cursor, underline, tooltip, and open target cannot disagree. import { syntaxTree } from "@codemirror/language"; -import { EditorView } from "@codemirror/view"; +import { EditorView, ViewPlugin } from "@codemirror/view"; import { StateEffect, StateField, @@ -108,6 +108,43 @@ export function linkMarkClass(prefs: LinkPrefsSnapshot, preview: boolean): strin // Click handling // --------------------------------------------------------------------------- +/** + * Pointer feedback for modifier-click. Because a Cmd/Ctrl+click always opens a + * link (in either open-with mode), the pointer cursor is truthful whenever the + * modifier is held over link text. This fills the gap where "open with + * Cmd/Ctrl+Click" gave no cursor change at all. Listens on the window (not just + * the editor DOM) so the feedback shows while merely reading, not only while + * the editor is focused. Both metaKey and ctrlKey count, so it is correct on + * every platform. + */ +class ModKeyCursor { + #held = false; + #view: EditorView; + constructor(view: EditorView) { + this.#view = view; + window.addEventListener("keydown", this.#onKey, true); + window.addEventListener("keyup", this.#onKey, true); + window.addEventListener("blur", this.#reset, true); + } + #onKey = (e: KeyboardEvent): void => this.#set(e.metaKey || e.ctrlKey); + #reset = (): void => this.#set(false); + #set(held: boolean): void { + if (held === this.#held) return; + this.#held = held; + this.#view.dom.classList.toggle("cm-mod-held", held); + } + destroy(): void { + window.removeEventListener("keydown", this.#onKey, true); + window.removeEventListener("keyup", this.#onKey, true); + window.removeEventListener("blur", this.#reset, true); + this.#view.dom.classList.remove("cm-mod-held"); + } +} + +export function modKeyCursor(): Extension { + return ViewPlugin.fromClass(ModKeyCursor); +} + /** * The open-on-click handler. `open` receives a normalized, scheme-checked * URL. The click must land on rendered link text: the mark decoration spans diff --git a/src/lib/editor/theme.ts b/src/lib/editor/theme.ts index 35f62c7..adf6404 100644 --- a/src/lib/editor/theme.ts +++ b/src/lib/editor/theme.ts @@ -55,6 +55,12 @@ export const kernelTheme = EditorView.baseTheme({ ".cm-link-clickable": { cursor: "pointer", }, + // While a Cmd/Ctrl modifier is held, any link reads as clickable (a + // modifier-click always opens). This is the cursor feedback for the + // "open with Cmd/Ctrl+Click" mode, which otherwise gave none. + ".cm-mod-held .cm-link-target": { + cursor: "pointer", + }, ".cm-link-ul-always": { textDecoration: "underline", }, @@ -87,10 +93,11 @@ export const kernelTheme = EditorView.baseTheme({ textDecoration: "line-through", color: "var(--text-tertiary)", }, - // Images + // Images. Max height is a user setting (Images settings page) surfaced as a + // CSS var on the editor container; 420px is the default when unset. ".cm-image-preview": { maxWidth: "100%", - maxHeight: "420px", + maxHeight: "var(--image-max-height, 420px)", borderRadius: "6px", verticalAlign: "text-bottom", }, diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts index 1ab7e24..fea173c 100644 --- a/src/lib/errors.test.ts +++ b/src/lib/errors.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; -import { friendlyMessage } from "./errors"; +import { ERROR_CODES, type ErrorCode } from "./api/error-codes"; +import { friendlyMessage, GENERIC_MESSAGE } from "./errors"; describe("friendlyMessage", () => { test("maps known API error codes to friendly copy", () => { @@ -10,16 +11,27 @@ describe("friendlyMessage", () => { expect(friendlyMessage("MIGRATION_ERROR")).toBeTruthy(); }); - test("unknown code falls back to provided message", () => { - expect(friendlyMessage("WEIRD_CODE", "backend said no")).toBe("backend said no"); + test("every code has copy of its own", () => { + const seen = new Set(); + for (const code of Object.keys(ERROR_CODES) as ErrorCode[]) { + const copy = friendlyMessage(code); + expect(copy).not.toBe(GENERIC_MESSAGE); + expect(seen.has(copy)).toBe(false); + seen.add(copy); + } }); - test("unknown code without fallback yields generic copy", () => { - expect(friendlyMessage("WEIRD_CODE")).toMatch(/something went wrong/i); + // A code outside the registry can only arrive from a backend this build does + // not match; the type stops it at the client boundary (client.ts's + // asApiError), so here it is only the last-resort behaviour that matters. + test("a code from outside the registry falls back", () => { + const unknown = "WEIRD_CODE" as ErrorCode; + expect(friendlyMessage(unknown, "backend said no")).toBe("backend said no"); + expect(friendlyMessage(unknown)).toBe(GENERIC_MESSAGE); }); test("never exposes raw codes to users", () => { - for (const code of ["NOT_FOUND", "STORAGE_ERROR", "WEIRD_CODE"]) { + for (const code of [...(Object.keys(ERROR_CODES) as ErrorCode[]), "WEIRD_CODE" as ErrorCode]) { expect(friendlyMessage(code)).not.toContain(code); } }); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 38c75df..38aa24c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,5 +1,9 @@ -/** Single place mapping AppError codes (API.md §11) to user-facing copy. */ -const MESSAGES: Record = { +import type { ErrorCode } from "$lib/api/error-codes"; + +/** Single place mapping error codes (API.md §14) to user-facing copy. The + * Record is exhaustive on purpose: a new code does not compile until it has + * copy here. */ +const MESSAGES: Record = { NOT_FOUND: "That note couldn't be found — it may have been deleted.", VALIDATION_ERROR: "That didn't look right. Please check the input and try again.", CONFLICT: "That name is already in use.", @@ -7,6 +11,9 @@ const MESSAGES: Record = { MIGRATION_ERROR: "The notes database needs attention — your data is safe, but the app couldn't upgrade it.", }; -export function friendlyMessage(code: string, fallback?: string): string { - return MESSAGES[code] ?? fallback ?? "Something went wrong. Please try again."; +/** What to say when nothing more specific is known. */ +export const GENERIC_MESSAGE = "Something went wrong. Please try again."; + +export function friendlyMessage(code: ErrorCode, fallback?: string): string { + return MESSAGES[code] ?? fallback ?? GENERIC_MESSAGE; } diff --git a/src/lib/external-edit.test.ts b/src/lib/external-edit.test.ts new file mode 100644 index 0000000..1792f3a --- /dev/null +++ b/src/lib/external-edit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { history, undo } from "@codemirror/commands"; +import { externalEdit, externalEditSpec, minimalChange } from "./external-edit"; + +describe("minimalChange", () => { + it("finds an append", () => { + expect(minimalChange("a\nb", "a\nb\n- c")).toEqual({ from: 3, to: 3, insert: "\n- c" }); + }); + it("finds a replacement in the middle", () => { + expect(minimalChange("one two three", "one 2 three")).toEqual({ + from: 4, + to: 7, + insert: "2", + }); + }); + it("handles repeated characters without overlapping prefix and suffix", () => { + const c = minimalChange("aaa", "aaaa")!; + expect("aaa".slice(0, c.from) + c.insert + "aaa".slice(c.to)).toBe("aaaa"); + }); + it("is null when nothing changed", () => { + expect(minimalChange("same", "same")).toBeNull(); + }); +}); + +describe("externalEditSpec", () => { + const state = (doc: string, caret: number) => + EditorState.create({ + doc, + selection: EditorSelection.cursor(caret), + extensions: [history(), externalEdit], + }); + + it("keeps the caret where the user left it when text lands after it", () => { + const s = state("Groceries\nmilk", 4); + const next = s.update(externalEditSpec(s, "Groceries\nmilk\neggs")!).state; + expect(next.doc.toString()).toBe("Groceries\nmilk\neggs"); + expect(next.selection.main.head).toBe(4); + }); + + it("moves the caret with its text when the edit lands before it", () => { + const s = state("milk", 4); + const next = s.update(externalEditSpec(s, "fresh milk")!).state; + expect(next.selection.main.head).toBe(10); + }); + + it("can be undone like any edit", () => { + const s = state("draft", 0); + const edited = s.update(externalEditSpec(s, "draft\nagent line")!).state; + let undone = edited; + undo({ state: edited, dispatch: (tr) => (undone = tr.state) }); + expect(undone.doc.toString()).toBe("draft"); + }); + + it("is null when the editor already shows the text", () => { + expect(externalEditSpec(state("x", 0), "x")).toBeNull(); + }); +}); diff --git a/src/lib/external-edit.ts b/src/lib/external-edit.ts new file mode 100644 index 0000000..acb70a4 --- /dev/null +++ b/src/lib/external-edit.ts @@ -0,0 +1,102 @@ +// An edit that arrives from outside the editor (an agent writing to the +// open note) lands as an ordinary CodeMirror change, not a reload: the +// caret and selection map through it, it joins the undo history (Cmd-Z +// takes the agent's edit back out), and the text it inserted flashes so +// the user sees what changed and where. + +import { + EditorState, + StateEffect, + StateField, + type TransactionSpec, +} from "@codemirror/state"; +import { Decoration, EditorView, type DecorationSet } from "@codemirror/view"; + +/** How long inserted text stays highlighted. */ +const FLASH_MS = 2400; + +export interface TextChange { + from: number; + to: number; + insert: string; +} + +/** + * The one contiguous replacement that turns `before` into `after`: the + * common prefix and suffix stay, the middle is replaced. Null when equal. + */ +export function minimalChange(before: string, after: string): TextChange | null { + if (before === after) return null; + const max = Math.min(before.length, after.length); + let start = 0; + while (start < max && before[start] === after[start]) start++; + let end = 0; + while ( + end < max - start && + before[before.length - 1 - end] === after[after.length - 1 - end] + ) { + end++; + } + return { + from: start, + to: before.length - end, + insert: after.slice(start, after.length - end), + }; +} + +const flash = StateEffect.define<{ from: number; to: number }>(); +const clearFlash = StateEffect.define(); +const flashMark = Decoration.mark({ class: "cm-external-flash" }); + +const flashField = StateField.define({ + create: () => Decoration.none, + update(deco, tr) { + let next = deco.map(tr.changes); + for (const e of tr.effects) { + if (e.is(flash)) next = Decoration.set([flashMark.range(e.value.from, e.value.to)]); + if (e.is(clearFlash)) next = Decoration.none; + } + return next; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +const flashTheme = EditorView.baseTheme({ + ".cm-external-flash": { + backgroundColor: "var(--accent-soft)", + boxShadow: "0 0 0 1px var(--accent-soft)", + borderRadius: "3px", + animation: `cm-external-flash ${FLASH_MS}ms ease-out forwards`, + }, + "@keyframes cm-external-flash": { + "0%": { backgroundColor: "color-mix(in srgb, var(--accent) 38%, transparent)" }, + "100%": { backgroundColor: "transparent", boxShadow: "none" }, + }, +}); + +/** The editor extension that draws the flash. */ +export const externalEdit = [flashField, flashTheme]; + +/** The transaction that brings `state` to `next`, or null if already there. */ +export function externalEditSpec(state: EditorState, next: string): TransactionSpec | null { + const change = minimalChange(state.doc.toString(), next); + if (!change) return null; + const end = change.from + change.insert.length; + return { + changes: change, + effects: end > change.from ? flash.of({ from: change.from, to: end }) : [], + userEvent: "external", + }; +} + +/** Apply an outside edit to a live view and schedule the flash to clear. */ +export function applyExternalEdit(view: EditorView, next: string): boolean { + const spec = externalEditSpec(view.state, next); + if (!spec) return false; + view.dispatch(spec); + setTimeout(() => { + // The view may be gone (note closed) by then. + if (view.dom.isConnected) view.dispatch({ effects: clearFlash.of(null) }); + }, FLASH_MS); + return true; +} diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts new file mode 100644 index 0000000..cd4e67a --- /dev/null +++ b/src/lib/feedback.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { githubIssueUrl, diagnosticsMarkdown } from "./feedback"; + +describe("githubIssueUrl", () => { + it("prefixes the title by category and derives it from the first line", () => { + const url = githubIssueUrl({ category: "bug", message: "It crashes\nmore detail" }); + const parsed = new URL(url); + expect(parsed.origin + parsed.pathname).toBe( + "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/Jam-Sw/InstantNotes/issues/new", + ); + expect(parsed.searchParams.get("title")).toBe("Bug: It crashes"); + expect(parsed.searchParams.get("labels")).toBe("bug"); + expect(parsed.searchParams.get("body")).toBe("It crashes\nmore detail"); + }); + + it("appends diagnostics under a divider when provided", () => { + const url = githubIssueUrl({ + category: "idea", + message: "Add tabs", + diagnostics: "- App version: 0.8.0", + }); + const body = new URL(url).searchParams.get("body") ?? ""; + expect(body).toContain("Add tabs"); + expect(body).toContain("---"); + expect(body).toContain("- App version: 0.8.0"); + expect(new URL(url).searchParams.get("labels")).toBe("enhancement"); + }); + + it("caps the title and falls back when the message is blank", () => { + const long = "x".repeat(200); + const title = new URL(githubIssueUrl({ category: "other", message: long })).searchParams.get( + "title", + ); + expect(title).toHaveLength(60); + const fallback = new URL( + githubIssueUrl({ category: "other", message: " " }), + ).searchParams.get("title"); + expect(fallback).toBe("Feedback"); + }); + + it("does not truncate a message within the URL body limit", () => { + const message = "x".repeat(1500); + const body = new URL(githubIssueUrl({ category: "bug", message })).searchParams.get("body"); + expect(body).toBe(message); + }); + + it("truncates a very long message so the URL stays a bounded size", () => { + const message = "x".repeat(50_000); + const url = githubIssueUrl({ category: "bug", message }); + // A generous, deliberately loose upper bound: whatever the exact cap, + // the URL must never scale with a 50,000-character report. + expect(url.length).toBeLessThan(3000); + const body = new URL(url).searchParams.get("body") ?? ""; + expect(body).toContain("truncated"); + expect(body).toContain("saved locally"); + }); + + it("truncates the message but keeps the full diagnostics block", () => { + const message = "x".repeat(50_000); + const diagnostics = "- App version: 0.9.0\n- Platform: macOS\n- Notes: 1\n- Attachments: 0"; + const body = new URL( + githubIssueUrl({ category: "bug", message, diagnostics }), + ).searchParams.get("body"); + expect(body).toContain(diagnostics); + }); +}); + +describe("diagnosticsMarkdown", () => { + it("lists exactly the snapshot fields", () => { + const md = diagnosticsMarkdown({ + appVersion: "0.8.0", + platform: "macOS", + notes: 42, + attachments: 7, + }); + expect(md).toBe( + "- App version: 0.8.0\n- Platform: macOS\n- Notes: 42\n- Attachments: 7", + ); + }); +}); diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts new file mode 100644 index 0000000..f3d2298 --- /dev/null +++ b/src/lib/feedback.ts @@ -0,0 +1,76 @@ +// Pure helpers for the in-app feedback flow: the category set, the diagnostics +// snapshot format, and the prefilled GitHub issue URL. Kept free of runes and +// Tauri so it is unit-testable; the component supplies live values. + +export type FeedbackCategory = "bug" | "idea" | "other"; + +export const FEEDBACK_CATEGORIES: { value: FeedbackCategory; label: string }[] = [ + { value: "bug", label: "Bug" }, + { value: "idea", label: "Idea" }, + { value: "other", label: "Other" }, +]; + +/** The optional diagnostics snapshot a user can attach. Exactly these fields + * are shown in the preview and stored, so nothing is sent that was not seen. */ +export interface FeedbackDiagnostics { + appVersion: string; + platform: string; + notes: number; + attachments: number; +} + +/** Human-readable diagnostics block, used both in the preview and the issue. */ +export function diagnosticsMarkdown(d: FeedbackDiagnostics): string { + return [ + `- App version: ${d.appVersion || "unknown"}`, + `- Platform: ${d.platform}`, + `- Notes: ${d.notes}`, + `- Attachments: ${d.attachments}`, + ].join("\n"); +} + +const DEFAULT_REPO = "Jam-Sw/InstantNotes"; + +const TITLE_PREFIX: Record = { + bug: "Bug: ", + idea: "Idea: ", + other: "", +}; + +const LABEL: Record = { + bug: "bug", + idea: "enhancement", + other: "feedback", +}; + +// A GitHub "new issue" URL is handed to the OS shell (`open_url`), whose +// length limits are far stricter than a browser's address bar in some +// environments. The full message is always in the local feedback.jsonl +// record regardless, so it is safe to truncate what rides in the URL. +const MAX_URL_BODY_CHARS = 1500; +const TRUNCATION_NOTE = "\n\n[message truncated — the full text is saved locally]"; + +/** + * Build a prefilled GitHub "new issue" URL from a feedback submission. The + * title is derived from the first line of the message; the diagnostics, when + * present, are appended under a divider. The message is truncated past + * `MAX_URL_BODY_CHARS` so a long report cannot produce a URL the OS shell + * refuses to open; the diagnostics block is never truncated, since it is + * always short. + */ +export function githubIssueUrl(opts: { + category: FeedbackCategory; + message: string; + diagnostics?: string | null; + repo?: string; +}): string { + const repo = opts.repo ?? DEFAULT_REPO; + const trimmed = opts.message.trim(); + const firstLine = trimmed.split("\n")[0].slice(0, 60) || "Feedback"; + const title = `${TITLE_PREFIX[opts.category]}${firstLine}`; + const truncated = trimmed.length > MAX_URL_BODY_CHARS; + let body = truncated ? trimmed.slice(0, MAX_URL_BODY_CHARS) + TRUNCATION_NOTE : trimmed; + if (opts.diagnostics) body += `\n\n---\n${opts.diagnostics}`; + const params = new URLSearchParams({ title, body, labels: LABEL[opts.category] }); + return `https://github.com/${repo}/issues/new?${params.toString()}`; +} diff --git a/src/lib/folder-picker.ts b/src/lib/folder-picker.ts new file mode 100644 index 0000000..3782fe2 --- /dev/null +++ b/src/lib/folder-picker.ts @@ -0,0 +1,22 @@ +// The native folder picker, for the settings pages that ask for a folder. +// On macOS a folder chosen here is also one the app may read, even another +// app's data, which is why Settings > Import goes through it. + +import { open } from "@tauri-apps/plugin-dialog"; + +export type FolderChoice = { path: string } | { cancelled: true } | { error: string }; + +/** Prompt for one folder, optionally starting in `defaultPath`. */ +export async function pickFolder(options: { + title: string; + defaultPath?: string; +}): Promise { + let picked: string | string[] | null; + try { + picked = await open({ directory: true, multiple: false, ...options }); + } catch (e) { + return { error: e instanceof Error ? e.message : "Could not open the folder picker." }; + } + if (!picked || typeof picked !== "string") return { cancelled: true }; + return { path: picked }; +} diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts index 6d53456..ae7094e 100644 --- a/src/lib/format.test.ts +++ b/src/lib/format.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { preview, wordCount } from "./format"; +import { formatExact, formatBytes, preview, wordCount } from "./format"; describe("preview", () => { it("collapses whitespace and trims", () => { @@ -13,6 +13,29 @@ describe("preview", () => { }); }); +describe("formatExact", () => { + it("includes the year, month, day, and minute-precise time", () => { + // A fixed instant; assert the pieces rather than an exact locale string so + // the test is not tied to one runtime's formatting. + const s = formatExact("2026-07-11T14:55:00.000Z"); + const d = new Date("2026-07-11T14:55:00.000Z"); + expect(s).toContain(String(d.getFullYear())); + expect(s).toContain(String(d.getMinutes()).padStart(2, "0")); + }); +}); + +describe("formatBytes", () => { + it("is 0 B for zero or negative", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(-5)).toBe("0 B"); + }); + it("shows KB and MB with sensible precision", () => { + expect(formatBytes(340 * 1024)).toBe("340 KB"); + expect(formatBytes(1.5 * 1024 * 1024)).toBe("1.5 MB"); + expect(formatBytes(12 * 1024 * 1024)).toBe("12 MB"); + }); +}); + describe("wordCount", () => { it("counts whitespace-delimited words", () => { expect(wordCount("one two three")).toBe(3); diff --git a/src/lib/format.ts b/src/lib/format.ts index 38e5b2b..4bb2389 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -11,6 +11,30 @@ export function formatDate(iso: string): string { : d.toLocaleDateString([], { month: "short", day: "numeric" }); } +/** Full local date and time to the minute (e.g. "Jul 11, 2026, 2:55 PM"). + * The always-available exact stamp: shown inline when the user opts in, and + * offered on hover everywhere the short `formatDate` is displayed. */ +export function formatExact(iso: string): string { + return new Date(iso).toLocaleString([], { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +/** Human byte size (e.g. "0 B", "340 KB", "1.2 MB"). Base-1024, one decimal + * once past kilobytes. */ +export function formatBytes(n: number): string { + if (!Number.isFinite(n) || n <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024))); + const val = n / 1024 ** i; + const shown = i === 0 ? String(val) : val.toFixed(val >= 10 || val % 1 === 0 ? 0 : 1); + return `${shown} ${units[i]}`; +} + /** One-line note preview: collapse all whitespace, trim, cap at 90 chars. */ export function preview(body: string): string { return body.replace(/\s+/g, " ").trim().slice(0, 90); diff --git a/src/lib/graph/layout.test.ts b/src/lib/graph/layout.test.ts new file mode 100644 index 0000000..abd9c76 --- /dev/null +++ b/src/lib/graph/layout.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { LAYOUT_TICKS, buildGraph, layoutGraph, neighbors, startLayout } from "./layout"; +import type { LibraryGraph } from "$lib/api/types"; + +const note = (id: string, title = id, extra = {}) => ({ + id, + title, + contentKind: "document" as const, + isPinned: false, + ...extra, +}); + +function library(): LibraryGraph { + return { + notes: [note("n1", "Alpha"), note("n2", "Beta"), note("n3", "Gamma"), note("lone")], + tags: [ + { id: "t1", name: "ideas", color: "#ff8800" }, + { id: "t2", name: "unused" }, + ], + spaces: [{ id: "s1", name: "Research" }], + links: [ + { noteId: "n1", targetId: "t1", kind: "tag" }, + { noteId: "n2", targetId: "t1", kind: "tag" }, + { noteId: "n3", targetId: "s1", kind: "space" }, + ], + }; +} + +describe("buildGraph", () => { + it("draws notes, tags, and Spaces that are connected, and counts the rest", () => { + const g = buildGraph(library()); + expect(g.nodes.map((n) => n.id).sort()).toEqual(["n1", "n2", "n3", "s1", "t1"]); + expect(g.edges).toHaveLength(3); + expect(g.unconnectedNotes).toBe(1); + }); + + it("labels each kind the way the rest of the app does", () => { + const g = buildGraph(library()); + const byId = new Map(g.nodes.map((n) => [n.id, n])); + expect(byId.get("t1")).toMatchObject({ kind: "tag", label: "#ideas", color: "#ff8800" }); + expect(byId.get("s1")).toMatchObject({ kind: "space", label: "Research" }); + expect(byId.get("n1")).toMatchObject({ kind: "note", label: "Alpha" }); + }); + + it("sizes a tag or Space by how many notes it gathers", () => { + const g = buildGraph(library()); + const byId = new Map(g.nodes.map((n) => [n.id, n])); + expect(byId.get("t1")?.degree).toBe(2); + expect(byId.get("s1")?.degree).toBe(1); + }); + + it("ignores a link to a note or target that is not in the graph", () => { + const lib = library(); + lib.links.push({ noteId: "ghost", targetId: "t1", kind: "tag" }); + lib.links.push({ noteId: "n1", targetId: "missing", kind: "space" }); + expect(buildGraph(lib).edges).toHaveLength(3); + }); +}); + +describe("layoutGraph", () => { + it("places the same library the same way every time", () => { + const a = layoutGraph(buildGraph(library())); + const b = layoutGraph(buildGraph(library())); + expect(a.nodes.map((n) => [n.x, n.y])).toEqual(b.nodes.map((n) => [n.x, n.y])); + }); + + it("gives every node a finite position", () => { + const g = layoutGraph(buildGraph(library())); + for (const n of g.nodes) { + expect(Number.isFinite(n.x) && Number.isFinite(n.y)).toBe(true); + } + }); + + it("pulls a note toward the tag it carries, away from unrelated hubs", () => { + const g = layoutGraph(buildGraph(library())); + const at = new Map(g.nodes.map((n) => [n.id, n])); + const dist = (a: string, b: string) => + Math.hypot(at.get(a)!.x - at.get(b)!.x, at.get(a)!.y - at.get(b)!.y); + expect(dist("n1", "t1")).toBeLessThan(dist("n1", "s1")); + }); + + it("starts nodes it has placed before where they were, so a refresh does not reshuffle", () => { + const first = layoutGraph(buildGraph(library())); + const previous = new Map(first.nodes.map((n) => [n.id, { x: n.x, y: n.y }])); + const again = layoutGraph(buildGraph(library()), { previous, ticks: 0 }); + expect(again.nodes.map((n) => [n.x, n.y])).toEqual(first.nodes.map((n) => [n.x, n.y])); + }); +}); + +describe("refreshing a laid-out library", () => { + it("keeps what was already there nearly still when one note is added", () => { + const lib = library(); + for (let i = 0; i < 30; i++) { + lib.notes.push(note(`m${i}`)); + lib.links.push({ noteId: `m${i}`, targetId: i % 2 ? "t1" : "s1", kind: i % 2 ? "tag" : "space" }); + } + const first = layoutGraph(buildGraph(lib)); + const previous = new Map(first.nodes.map((n) => [n.id, { x: n.x, y: n.y }])); + lib.notes.push(note("new")); + lib.links.push({ noteId: "new", targetId: "t1", kind: "tag" }); + const after = new Map(layoutGraph(buildGraph(lib), { previous }).nodes.map((n) => [n.id, n])); + const moved = first.nodes.map((n) => Math.hypot(after.get(n.id)!.x - n.x, after.get(n.id)!.y - n.y)); + const mean = moved.reduce((a, b) => a + b, 0) / moved.length; + expect(mean).toBeLessThan(4); + // The newcomer still lands by the tag it carries. + const newcomer = after.get("new")!; + const hub = after.get("t1")!; + expect(Math.hypot(newcomer.x - hub.x, newcomer.y - hub.y)).toBeLessThan(90); + }); +}); + +describe("startLayout", () => { + it("settled in steps, lands exactly where a one-go layout does", () => { + const run = startLayout(buildGraph(library())); + let rested = false; + let steps = 0; + while (!rested) { + rested = run.step(7); + steps++; + } + expect(steps).toBe(Math.ceil(LAYOUT_TICKS / 7)); + expect(run.snapshot().nodes).toEqual(layoutGraph(buildGraph(library())).nodes); + }); +}); + +describe("neighbors", () => { + it("is the node and everything one link away", () => { + const g = buildGraph(library()); + expect([...neighbors(g, "t1")].sort()).toEqual(["n1", "n2", "t1"]); + expect([...neighbors(g, "n3")].sort()).toEqual(["n3", "s1"]); + }); +}); diff --git a/src/lib/graph/layout.ts b/src/lib/graph/layout.ts new file mode 100644 index 0000000..4f32a5d --- /dev/null +++ b/src/lib/graph/layout.ts @@ -0,0 +1,204 @@ +// The Graph view's model: which nodes and edges to draw, and where. Pure, so +// the picture is the same on every visit: the force layout starts from +// positions seeded by each node's id and draws its jitter from a seeded +// generator, never Math.random. + +import { + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, + type SimulationLinkDatum, + type SimulationNodeDatum, +} from "d3-force"; +import type { LibraryGraph } from "$lib/api/types"; + +export type NodeKind = "note" | "tag" | "space"; + +export interface GraphNode { + id: string; + kind: NodeKind; + label: string; + color?: string | null; + pinned?: boolean; + board?: boolean; + /** How many links touch the node; a hub's size follows it. */ + degree: number; + x: number; + y: number; +} + +export interface GraphEdge { + source: string; + target: string; + kind: "tag" | "space"; +} + +export interface Graph { + nodes: GraphNode[]; + edges: GraphEdge[]; + /** Live notes with no tag and no Space: real, but nothing to connect. */ + unconnectedNotes: number; +} + +/** The drawable graph: every note, tag, and Space with at least one link. */ +export function buildGraph(lib: LibraryGraph): Graph { + const notes = new Map(lib.notes.map((n) => [n.id, n])); + const targets = new Map([ + ...lib.tags.map((t) => [t.id, { label: `#${t.name}`, kind: "tag" as const, color: t.color }] as const), + ...lib.spaces.map((s) => [s.id, { label: s.name, kind: "space" as const }] as const), + ]); + const edges = lib.links + .filter((l) => notes.has(l.noteId) && targets.has(l.targetId)) + .map((l) => ({ source: l.noteId, target: l.targetId, kind: l.kind })); + + const degree = new Map(); + for (const e of edges) { + degree.set(e.source, (degree.get(e.source) ?? 0) + 1); + degree.set(e.target, (degree.get(e.target) ?? 0) + 1); + } + const nodes: GraphNode[] = []; + for (const n of lib.notes) { + if (!degree.has(n.id)) continue; + nodes.push({ + id: n.id, + kind: "note", + label: n.title || "Untitled", + pinned: n.isPinned, + board: n.contentKind === "whiteboard", + degree: degree.get(n.id) ?? 0, + x: 0, + y: 0, + }); + } + for (const [id, t] of targets) { + if (!degree.has(id)) continue; + nodes.push({ id, ...t, degree: degree.get(id) ?? 0, x: 0, y: 0 }); + } + const unconnectedNotes = lib.notes.filter((n) => !degree.has(n.id)).length; + return { nodes, edges, unconnectedNotes }; +} + +/** A 32-bit hash of a string (FNV-1a), for seeding. */ +function hash(s: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193); + return h >>> 0; +} + +/** A small seeded generator (mulberry32) for the simulation's jitter. */ +function seeded(seed: number): () => number { + let a = seed; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Drawn radius of a node, shared with the view so collisions match. */ +export function nodeRadius(n: Pick): number { + return n.kind === "note" ? 5 : 7 + Math.min(14, Math.sqrt(n.degree) * 3); +} + +type SimNode = GraphNode & SimulationNodeDatum; + +/** Ticks a layout runs to reach rest. */ +export const LAYOUT_TICKS = 300; + +export interface LayoutRun { + /** Advance up to `ticks` steps; true once the layout has come to rest. */ + step(ticks: number): boolean; + /** The graph with every node where the layout has it now. */ + snapshot(): Graph; +} + +/** + * Start a force layout. Nodes placed by an earlier layout (`previous`) start + * where they were, so a refresh after an edit moves little; new ones start at + * a spot seeded by their id. The caller drives it with `step`, so a large + * library can settle across animation frames instead of freezing the view. + */ +export function startLayout( + graph: Graph, + previous?: Map, +): LayoutRun { + const nodes: SimNode[] = graph.nodes.map((n) => { + const known = previous?.get(n.id); + if (known) return { ...n, x: known.x, y: known.y }; + const h = hash(n.id); + const angle = ((h & 0xffff) / 0x10000) * 2 * Math.PI; + const radius = 40 + ((h >>> 16) / 0x10000) * 260; + return { ...n, x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }; + }); + const links: SimulationLinkDatum[] = graph.edges.map((e) => ({ + source: e.source, + target: e.target, + })); + const sim = forceSimulation(nodes) + .randomSource(seeded(nodes.length)) + .force( + "link", + forceLink>(links) + .id((d) => d.id) + .distance(46) + .strength(0.7), + ) + .force("charge", forceManyBody().strength((d) => (d.kind === "note" ? -60 : -260))) + .force("collide", forceCollide((d) => nodeRadius(d) + 3)) + .force("x", forceX(0).strength(0.04)) + .force("y", forceY(0).strength(0.04)) + .stop(); + // A refresh of a laid-out library starts cool: the known nodes are already + // at rest, and only the newcomers need to find their place. + const known = previous ? graph.nodes.filter((n) => previous.has(n.id)).length : 0; + if (known * 2 >= graph.nodes.length && known > 0) sim.alpha(0.1); + let done = 0; + return { + step(ticks) { + const n = Math.min(ticks, LAYOUT_TICKS - done); + for (let i = 0; i < n; i++) sim.tick(); + done += n; + return done >= LAYOUT_TICKS || nodes.length === 0; + }, + snapshot() { + return { + ...graph, + nodes: nodes.map(({ id, kind, label, color, pinned, board, degree, x, y }) => ({ + id, + kind, + label, + color, + pinned, + board, + degree, + x: x ?? 0, + y: y ?? 0, + })), + }; + }, + }; +} + +/** A layout run to rest in one go (`ticks: 0` gives the starting positions). */ +export function layoutGraph( + graph: Graph, + options: { previous?: Map; ticks?: number } = {}, +): Graph { + const run = startLayout(graph, options.previous); + run.step(options.ticks ?? LAYOUT_TICKS); + return run.snapshot(); +} + +/** A node and everything one link away from it. */ +export function neighbors(graph: Graph, id: string): Set { + const out = new Set([id]); + for (const e of graph.edges) { + if (e.source === id) out.add(e.target); + if (e.target === id) out.add(e.source); + } + return out; +} diff --git a/src/lib/note-groups.test.ts b/src/lib/note-groups.test.ts index 0c9e28b..689c080 100644 --- a/src/lib/note-groups.test.ts +++ b/src/lib/note-groups.test.ts @@ -15,6 +15,7 @@ function mkNote(id: string, updatedAt: Date, overrides: Partial = {}): Not isPinned: false, isArchived: false, isDeleted: false, + contentKind: "document", ...overrides, }; } diff --git a/src/lib/stickies-import.test.ts b/src/lib/stickies-import.test.ts new file mode 100644 index 0000000..412a7fc --- /dev/null +++ b/src/lib/stickies-import.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { open } from "@tauri-apps/plugin-dialog"; +import { stickiesLocation } from "$lib/api/client"; +import { + chooseStickiesFolder, + describeImport, + describeSelection, + stickyBody, + stickyDate, +} from "./stickies-import"; + +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() })); +vi.mock("$lib/api/client", () => ({ stickiesLocation: vi.fn() })); + +const STICKIES = "/Users/me/Library/Containers/com.apple.Stickies/Data/Library/Stickies"; + +beforeEach(() => { + vi.mocked(open).mockReset(); + vi.mocked(stickiesLocation).mockReset(); +}); + +describe("chooseStickiesFolder", () => { + it("opens the picker already in Stickies' folder", async () => { + vi.mocked(stickiesLocation).mockResolvedValue(STICKIES); + vi.mocked(open).mockResolvedValue(STICKIES); + expect(await chooseStickiesFolder()).toEqual({ path: STICKIES }); + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ directory: true, multiple: false, defaultPath: STICKIES }), + ); + }); + + it("still opens, just not there, when the location is unknown", async () => { + vi.mocked(stickiesLocation).mockRejectedValue(new Error("no home")); + vi.mocked(open).mockResolvedValue(null); + expect(await chooseStickiesFolder()).toEqual({ cancelled: true }); + expect(vi.mocked(open).mock.calls[0][0]?.defaultPath).toBeUndefined(); + }); +}); + +describe("the page's words", () => { + it("shows a sticky's text under its title, not the title again", () => { + expect(stickyBody("\nGroceries\n- milk\n- eggs")).toBe("- milk\n- eggs"); + expect(stickyBody("Only a title")).toBe(""); + expect(stickyBody("")).toBe(""); + }); + + it("dates this year's stickies without a year and older ones with it", () => { + const now = new Date("2026-09-30T12:00:00Z"); + expect(stickyDate("2026-03-04T12:00:00Z", now)).not.toMatch(/2026/); + expect(stickyDate("2022-03-04T12:00:00Z", now)).toMatch(/2022/); + }); + + it("counts what is on the board", () => { + expect(describeSelection(1, 1, 0)).toBe("1 sticky · 1 selected"); + expect(describeSelection(12, 9, 3)).toBe("12 stickies · 9 selected · 3 already imported"); + }); + + it("says what an import did, and where", () => { + expect(describeImport({ imported: 12, skipped: 0, workspaceId: "w" }, "Apple Stickies")).toBe( + "Imported 12 stickies into Apple Stickies.", + ); + expect(describeImport({ imported: 1, skipped: 0, workspaceId: null }, null)).toBe( + "Imported 1 sticky.", + ); + expect(describeImport({ imported: 2, skipped: 1, workspaceId: "w" }, "Work")).toBe( + "Imported 2 stickies into Work. 1 was already here.", + ); + expect(describeImport({ imported: 0, skipped: 3, workspaceId: null }, "Work")).toBe( + "Nothing new to import. 3 were already here.", + ); + }); +}); diff --git a/src/lib/stickies-import.ts b/src/lib/stickies-import.ts new file mode 100644 index 0000000..49a4f38 --- /dev/null +++ b/src/lib/stickies-import.ts @@ -0,0 +1,71 @@ +// Settings > Import, Apple Stickies: picking the folder, and the words the +// page shows. Reading and converting happen in Rust +// (openspec/changes/feat-stickies-import). + +import { stickiesLocation } from "$lib/api/client"; +import type { ImportOutcome } from "$lib/api/types"; +import { pickFolder, type FolderChoice } from "$lib/folder-picker"; + +/** The Space an import files into unless the user names another. */ +export const DEFAULT_SPACE = "Apple Stickies"; + +/** Where Stickies keeps its notes, said when a folder holds none. */ +export const STICKIES_PATH_HINT = "~/Library/Containers/com.apple.Stickies/Data/Library/Stickies"; + +/** Privacy & Security, where macOS lets an app read other apps' data. */ +export const PRIVACY_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy"; + +/** Stickies' yellow, for a sticky whose color was not recorded. */ +export const STICKY_YELLOW = "#fef49c"; + +/** Prompt for the Stickies folder, starting in it. Choosing it is what lets + * the app read it: macOS 27 refuses another app's data otherwise. */ +export async function chooseStickiesFolder(): Promise { + const defaultPath = (await stickiesLocation().catch(() => null)) ?? undefined; + return pickFolder({ title: "Choose your Stickies folder", defaultPath }); +} + +/** A sticky's text under its title: everything after the first line that + * has anything on it. */ +export function stickyBody(text: string): string { + const lines = text.split("\n"); + const first = lines.findIndex((l) => l.trim() !== ""); + return first < 0 ? "" : lines.slice(first + 1).join("\n").trim(); +} + +/** "Jun 5" this year, "Jun 5, 2022" before it: stickies can be years old. */ +export function stickyDate(iso: string, now = new Date()): string { + const d = new Date(iso); + const sameYear = d.getFullYear() === now.getFullYear(); + return d.toLocaleDateString([], { + month: "short", + day: "numeric", + ...(sameYear ? {} : { year: "numeric" }), + }); +} + +function stickies(n: number): string { + return n === 1 ? "1 sticky" : `${n} stickies`; +} + +/** The line above the miniatures. */ +export function describeSelection(total: number, selected: number, imported: number): string { + const parts = [stickies(total), `${selected} selected`]; + if (imported) parts.push(`${imported} already imported`); + return parts.join(" · "); +} + +/** What an import did, in one or two sentences. */ +export function describeImport(outcome: ImportOutcome, space: string | null): string { + const n = outcome.imported; + let line = + n === 0 + ? "Nothing new to import." + : space && outcome.workspaceId + ? `Imported ${stickies(n)} into ${space}.` + : `Imported ${stickies(n)}.`; + if (outcome.skipped) { + line += ` ${outcome.skipped === 1 ? "1 was" : `${outcome.skipped} were`} already here.`; + } + return line; +} diff --git a/src/lib/stores/agents.svelte.test.ts b/src/lib/stores/agents.svelte.test.ts new file mode 100644 index 0000000..75bdf72 --- /dev/null +++ b/src/lib/stores/agents.svelte.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => {}) })); +vi.mock("$lib/api/client", () => ({ + getSetting: vi.fn(async () => null), + setSetting: vi.fn(async () => {}), + getAgentConnection: vi.fn(async () => null), +})); + +import { agents, PRESENCE_MS } from "./agents.svelte"; +import type { AgentActivity } from "$lib/agent-activity"; + +const entry = (over: Partial): AgentActivity => ({ + at: 1, + client: "claude-code", + tool: "get_note", + kind: "read", + noteIds: [], + noteCount: 0, + titles: [], + space: null, + tag: null, + query: null, + ...over, +}); + +describe("agents presence", () => { + it("lights what a call touched, then lets it fade", () => { + const t0 = 1_000_000; + agents.play( + [ + entry({ tool: "list_notes", space: "Ideas", noteIds: ["a", "b"] }), + entry({ tool: "append_to_note", kind: "write", noteIds: ["b"], tag: "#Plan" }), + ], + t0, + ); + expect(agents.noteMark("a")).toBe("read"); + expect(agents.noteMark("b")).toBe("write"); + expect(agents.spaceActive("ideas")).toBe(true); + expect(agents.tagActive("plan")).toBe(true); + expect(agents.current?.tool).toBe("append_to_note"); + expect(agents.recent[0].tool).toBe("append_to_note"); + expect(agents.lastWriter("b")).toBe("claude-code"); + + // A later call on "a" keeps it lit past the first call's time. + agents.play([entry({ noteIds: ["a"] })], t0 + PRESENCE_MS - 1); + agents.expire(t0 + PRESENCE_MS); + expect(agents.noteMark("a")).toBe("read"); + expect(agents.noteMark("b")).toBeNull(); + expect(agents.spaceActive("Ideas")).toBe(false); + + agents.expire(t0 + 2 * PRESENCE_MS); + expect(agents.noteMark("a")).toBeNull(); + expect(agents.current).toBeNull(); + }); + + it("ignores an empty batch", () => { + const before = agents.recent.length; + agents.play([]); + expect(agents.recent.length).toBe(before); + }); +}); diff --git a/src/lib/stores/agents.svelte.ts b/src/lib/stores/agents.svelte.ts new file mode 100644 index 0000000..92399b9 --- /dev/null +++ b/src/lib/stores/agents.svelte.ts @@ -0,0 +1,156 @@ +// Agents (Svelte 5 runes): the access level, the recent activity log, and +// live presence, meaning which notes, Spaces, and tags an agent is reading +// or changing right now. Presence is what lets the library show it on the +// things themselves (a glow on the row, the open note, the Space) instead +// of in a panel of its own. Each mark fades a few seconds after the last +// call that touched it. +// +// Fed by `library:external-change`, which the shell emits when another +// process writes to the library (src-tauri/src/shell/agents.rs). + +import { listen } from "@tauri-apps/api/event"; +import { getAgentConnection, getSetting, setSetting } from "$lib/api/client"; +import { EVENTS } from "$lib/api/events"; +import { toasts } from "$lib/stores/toasts.svelte"; +import { + AGENT_ACCESS_KEY, + AGENT_ACTIVITY_KEY, + clientLabel, + parseAccess, + parseActivityLog, + type AgentAccess, + type AgentActivity, + type AgentConnection, +} from "$lib/agent-activity"; + +/** How long a touched note, Space, or tag stays lit after the last call. */ +export const PRESENCE_MS = 4000; +const RECENT_KEEP = 30; + +export type AgentMark = AgentActivity["kind"]; + +class AgentsStore { + access = $state("off"); + /** Newest first. */ + recent = $state([]); + /** The latest call while it is fresh; drives the sidebar's live line. */ + current = $state(null); + connection = $state(null); + + #notes = $state(new Map()); + #spaces = $state(new Set()); + #tags = $state(new Set()); + // When each mark lapses, so a later call extends it and an earlier timer + // does not clear it early. + #until = new Map(); + #loaded = false; + + async init(): Promise { + if (this.#loaded) return; + this.#loaded = true; + await listen(EVENTS.LIBRARY_EXTERNAL_CHANGE, (e) => { + this.play(parseActivityLog(e.payload)); + }); + try { + const [access, log] = await Promise.all([ + getSetting(AGENT_ACCESS_KEY), + getSetting(AGENT_ACTIVITY_KEY), + ]); + this.access = parseAccess(access); + this.recent = parseActivityLog(log); + } catch { + // Best-effort, like every settings store: presence still works. + } + } + + /** The executable and library path, for the connect commands. */ + async loadConnection(): Promise { + try { + this.connection = await getAgentConnection(); + } catch { + this.connection = null; + } + } + + setAccess(access: AgentAccess): void { + this.access = access; + void setSetting(AGENT_ACCESS_KEY, access); + } + + /** Show new calls, oldest first, as they arrive. */ + play(entries: AgentActivity[], now = Date.now()): void { + if (entries.length === 0) return; + const until = now + PRESENCE_MS; + const notes = new Map(this.#notes); + const spaces = new Set(this.#spaces); + const tags = new Set(this.#tags); + for (const e of entries) { + for (const id of e.noteIds) { + notes.set(id, e.kind); + this.#until.set(`n:${id}`, until); + } + if (e.space) { + spaces.add(e.space.toLowerCase()); + this.#until.set(`s:${e.space.toLowerCase()}`, until); + } + if (e.tag) { + const tag = e.tag.replace(/^#/, "").toLowerCase(); + tags.add(tag); + this.#until.set(`t:${tag}`, until); + } + } + this.#notes = notes; + this.#spaces = spaces; + this.#tags = tags; + const latest = entries[entries.length - 1]; + this.current = latest; + this.#until.set("current", until); + this.recent = [...entries].reverse().concat(this.recent).slice(0, RECENT_KEEP); + setTimeout(() => this.expire(), PRESENCE_MS + 50); + } + + /** Drop marks whose time is up. Public so tests can drive the clock. */ + expire(now = Date.now()): void { + const lapsed = (key: string) => (this.#until.get(key) ?? 0) <= now; + const notes = [...this.#notes].filter(([id]) => !lapsed(`n:${id}`)); + if (notes.length !== this.#notes.size) this.#notes = new Map(notes); + const spaces = [...this.#spaces].filter((s) => !lapsed(`s:${s}`)); + if (spaces.length !== this.#spaces.size) this.#spaces = new Set(spaces); + const tags = [...this.#tags].filter((t) => !lapsed(`t:${t}`)); + if (tags.length !== this.#tags.size) this.#tags = new Set(tags); + if (this.current && lapsed("current")) this.current = null; + for (const [key, at] of this.#until) if (at <= now) this.#until.delete(key); + } + + /** "read" or "write" while an agent is on this note, else null. */ + noteMark(id: string): AgentMark | null { + return this.#notes.get(id) ?? null; + } + + spaceActive(name: string): boolean { + return this.#spaces.has(name.toLowerCase()); + } + + tagActive(name: string): boolean { + return this.#tags.has(name.toLowerCase()); + } + + /** The client that last wrote to a note, from the log, if any did. */ + lastWriter(noteId: string): string | null { + const hit = this.recent.find((e) => e.kind === "write" && e.noteIds.includes(noteId)); + return hit?.client ?? null; + } +} + +export const agents = new AgentsStore(); + +/** The user's typing replaced an agent's edit to a note. Say so, by name, and + * offer it back; the restore is itself an edit, so Cmd-Z undoes it. Shown by + * whichever window was editing the note: the library or its sticky. */ +export function announceOverwrite(noteId: string, restore: () => void): void { + const who = clientLabel(agents.lastWriter(noteId) ?? ""); + toasts.show(`${who}'s change to this note was replaced by your typing.`, { + label: "Restore theirs", + run: restore, + }); +} diff --git a/src/lib/stores/contexting.svelte.ts b/src/lib/stores/contexting.svelte.ts index 6d04841..50045d8 100644 --- a/src/lib/stores/contexting.svelte.ts +++ b/src/lib/stores/contexting.svelte.ts @@ -1,15 +1,26 @@ // Contexting (Svelte 5 runes): the user-editable template for copying a note as -// LLM-ready context. Persisted to the existing settings KV like the editor and -// theme stores. Rendering logic lives in the pure contexting-format module. +// LLM-ready context, plus how images in the note travel. Persisted to the +// existing settings KV like the editor and theme stores. Rendering logic lives +// in the pure contexting-format module. -import { getSetting, setSetting } from "$lib/api/client"; +import { getSetting, setSetting, getAttachmentsDir } from "$lib/api/client"; import type { Note, Tag } from "$lib/api/types"; -import { DEFAULT_TEMPLATE, renderTemplate } from "$lib/contexting-format"; +import { + DEFAULT_TEMPLATE, + DEFAULT_IMAGE_MODE, + renderTemplate, + type ContextImageMode, +} from "$lib/contexting-format"; const KEY_TEMPLATE = "contexting.copyTemplate"; +const KEY_IMAGE_MODE = "contexting.imageMode"; class ContextingStore { copyTemplate = $state(DEFAULT_TEMPLATE); + imageMode = $state(DEFAULT_IMAGE_MODE); + // Absolute attachments directory, needed to rewrite attachment images to + // their real path in "absolute" mode. Fetched once and cached. + attachmentsDir = $state(null); #loaded = false; @@ -17,10 +28,16 @@ class ContextingStore { if (this.#loaded) return; this.#loaded = true; try { - const t = await getSetting(KEY_TEMPLATE); + const [t, mode, dir] = await Promise.all([ + getSetting(KEY_TEMPLATE), + getSetting(KEY_IMAGE_MODE), + getAttachmentsDir().catch(() => null), + ]); if (typeof t === "string" && t.length > 0) this.copyTemplate = t; + if (mode === "keep" || mode === "absolute" || mode === "strip") this.imageMode = mode; + if (typeof dir === "string") this.attachmentsDir = dir; } catch { - // Settings are best-effort; keep the default template silently. + // Settings are best-effort; keep the defaults silently. } } @@ -29,9 +46,17 @@ class ContextingStore { void setSetting(KEY_TEMPLATE, t); } + setImageMode(mode: ContextImageMode): void { + this.imageMode = mode; + void setSetting(KEY_IMAGE_MODE, mode); + } + /** Render the active template for a note and its tags, ready for the clipboard. */ render(note: Note, tags: Tag[]): string { - return renderTemplate(this.copyTemplate, note, tags); + return renderTemplate(this.copyTemplate, note, tags, { + imageMode: this.imageMode, + attachmentsDir: this.attachmentsDir, + }); } } diff --git a/src/lib/stores/editor.svelte.test.ts b/src/lib/stores/editor.svelte.test.ts new file mode 100644 index 0000000..2db43f7 --- /dev/null +++ b/src/lib/stores/editor.svelte.test.ts @@ -0,0 +1,83 @@ +// `editorPrefs` is a module-level singleton whose `init()` only reads +// settings once (guarded by a private #loaded flag), so — like `imagePrefs` +// in images.svelte.test.ts — each test loads a fresh copy of the module via +// vi.resetModules() + dynamic import. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getSetting, setSetting } from "$lib/api/client"; + +vi.mock("$lib/api/client", () => ({ + getSetting: vi.fn(), + setSetting: vi.fn().mockResolvedValue(undefined), +})); + +const mockGetSetting = vi.mocked(getSetting); +const mockSetSetting = vi.mocked(setSetting); + +async function load() { + const mod = await import("./editor.svelte"); + return mod.editorPrefs; +} + +beforeEach(() => { + vi.resetModules(); + mockGetSetting.mockReset().mockResolvedValue(undefined); + mockSetSetting.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("editorPrefs", () => { + it("defaults to showExactTime off before init", async () => { + const prefs = await load(); + expect(prefs.showExactTime).toBe(false); + }); + + it("reads a persisted showExactTime on init", async () => { + mockGetSetting.mockImplementation(async (key: string) => + key === "editor.showExactTime" ? true : undefined, + ); + const prefs = await load(); + await prefs.init(); + expect(prefs.showExactTime).toBe(true); + }); + + it("only fetches settings on the first init call", async () => { + const prefs = await load(); + await prefs.init(); + await prefs.init(); + expect(mockGetSetting).toHaveBeenCalledTimes(3); // zoom + toolbarOpen + showExactTime, once each + }); + + it("falls back to defaults silently when the settings read fails", async () => { + mockGetSetting.mockRejectedValue(new Error("no backend")); + const prefs = await load(); + await expect(prefs.init()).resolves.toBeUndefined(); + expect(prefs.showExactTime).toBe(false); + }); + + it("setShowExactTime updates state and persists immediately", async () => { + const prefs = await load(); + prefs.setShowExactTime(true); + expect(prefs.showExactTime).toBe(true); + expect(mockSetSetting).toHaveBeenCalledWith("editor.showExactTime", true); + + prefs.setShowExactTime(false); + expect(prefs.showExactTime).toBe(false); + expect(mockSetSetting).toHaveBeenCalledWith("editor.showExactTime", false); + }); + + it("re-reading after setShowExactTime does not clobber the value with a stale init", async () => { + // init() is one-shot; calling set before init ever resolves must not let + // a slow settings read overwrite the user's explicit change afterward. + mockGetSetting.mockImplementation(async (key: string) => + key === "editor.showExactTime" ? false : undefined, + ); + const prefs = await load(); + const initDone = prefs.init(); + prefs.setShowExactTime(true); + await initDone; + expect(prefs.showExactTime).toBe(true); + }); +}); diff --git a/src/lib/stores/editor.svelte.ts b/src/lib/stores/editor.svelte.ts index 509ec3e..2d80a52 100644 --- a/src/lib/stores/editor.svelte.ts +++ b/src/lib/stores/editor.svelte.ts @@ -6,6 +6,7 @@ import { getSetting, setSetting } from "$lib/api/client"; const KEY_ZOOM = "editor.zoom"; const KEY_TOOLBAR = "editor.toolbarOpen"; +const KEY_EXACT_TIME = "editor.showExactTime"; const MIN = 0.7; const MAX = 2.5; const STEP = 0.1; @@ -13,29 +14,51 @@ const STEP = 0.1; class EditorPrefs { zoom = $state(1); toolbarOpen = $state(false); + // Show the full minute-precise save time inline in the editor status bar. + // The exact time is always available on hover regardless of this toggle. + showExactTime = $state(false); #loaded = false; + // A setter called while init()'s read is still in flight must win: without + // this, the read resolving afterward would silently overwrite the user's + // change back to the old persisted value. + #touched = new Set(); async init(): Promise { if (this.#loaded) return; this.#loaded = true; try { - const [z, open] = await Promise.all([ + const [z, open, exact] = await Promise.all([ getSetting(KEY_ZOOM), getSetting(KEY_TOOLBAR), + getSetting(KEY_EXACT_TIME), ]); - if (typeof z === "number" && z >= MIN && z <= MAX) this.zoom = z; - if (typeof open === "boolean") this.toolbarOpen = open; + if (!this.#touched.has(KEY_ZOOM) && typeof z === "number" && z >= MIN && z <= MAX) { + this.zoom = z; + } + if (!this.#touched.has(KEY_TOOLBAR) && typeof open === "boolean") { + this.toolbarOpen = open; + } + if (!this.#touched.has(KEY_EXACT_TIME) && typeof exact === "boolean") { + this.showExactTime = exact; + } } catch { // Settings are best-effort; fall back to defaults silently. } } + setShowExactTime(v: boolean): void { + this.#touched.add(KEY_EXACT_TIME); + this.showExactTime = v; + void setSetting(KEY_EXACT_TIME, v); + } + #clamp(z: number): number { return Math.min(MAX, Math.max(MIN, +z.toFixed(1))); } setZoom(z: number): void { + this.#touched.add(KEY_ZOOM); this.zoom = this.#clamp(z); void setSetting(KEY_ZOOM, this.zoom); } @@ -53,6 +76,7 @@ class EditorPrefs { } toggleToolbar(): void { + this.#touched.add(KEY_TOOLBAR); this.toolbarOpen = !this.toolbarOpen; void setSetting(KEY_TOOLBAR, this.toolbarOpen); } diff --git a/src/lib/stores/images.svelte.test.ts b/src/lib/stores/images.svelte.test.ts new file mode 100644 index 0000000..a881694 --- /dev/null +++ b/src/lib/stores/images.svelte.test.ts @@ -0,0 +1,111 @@ +// `imagePrefs` is a module-level singleton whose `init()` only reads settings +// once (guarded by a private #loaded flag), so — like `library` in +// library.svelte.test.ts — each test loads a fresh copy of the module via +// vi.resetModules() + dynamic import rather than reusing one instance across +// tests. Unlike the settings-page component tests, this exercises the +// init()-reads-a-persisted-value path directly, without rendering Svelte. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getSetting, setSetting } from "$lib/api/client"; + +vi.mock("$lib/api/client", () => ({ + getSetting: vi.fn(), + setSetting: vi.fn().mockResolvedValue(undefined), +})); + +const mockGetSetting = vi.mocked(getSetting); +const mockSetSetting = vi.mocked(setSetting); + +async function load() { + const mod = await import("./images.svelte"); + return mod.imagePrefs; +} + +beforeEach(() => { + vi.resetModules(); + mockGetSetting.mockReset().mockResolvedValue(undefined); + mockSetSetting.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("imagePrefs", () => { + it("defaults to copy storage and a 420px preview height before init", async () => { + const prefs = await load(); + expect(prefs.storage).toBe("copy"); + expect(prefs.maxPreviewHeight).toBe(420); + }); + + it("reads a persisted storage mode and height on init", async () => { + mockGetSetting.mockImplementation(async (key: string) => { + if (key === "images.storage") return "link"; + if (key === "images.maxPreviewHeight") return 600; + return undefined; + }); + const prefs = await load(); + await prefs.init(); + expect(prefs.storage).toBe("link"); + expect(prefs.maxPreviewHeight).toBe(600); + }); + + it("ignores a stored value that isn't copy or link", async () => { + mockGetSetting.mockImplementation(async (key: string) => + key === "images.storage" ? "invalid" : undefined, + ); + const prefs = await load(); + await prefs.init(); + expect(prefs.storage).toBe("copy"); + }); + + it("clamps a persisted height below the minimum on init", async () => { + mockGetSetting.mockImplementation(async (key: string) => + key === "images.maxPreviewHeight" ? 10 : undefined, + ); + const prefs = await load(); + await prefs.init(); + expect(prefs.maxPreviewHeight).toBe(120); + }); + + it("clamps a persisted height above the maximum on init", async () => { + mockGetSetting.mockImplementation(async (key: string) => + key === "images.maxPreviewHeight" ? 5000 : undefined, + ); + const prefs = await load(); + await prefs.init(); + expect(prefs.maxPreviewHeight).toBe(900); + }); + + it("only fetches settings on the first init call", async () => { + const prefs = await load(); + await prefs.init(); + await prefs.init(); + expect(mockGetSetting).toHaveBeenCalledTimes(2); // storage + height, once each + }); + + it("falls back to defaults silently when the settings read fails", async () => { + mockGetSetting.mockRejectedValue(new Error("no backend")); + const prefs = await load(); + await expect(prefs.init()).resolves.toBeUndefined(); + expect(prefs.storage).toBe("copy"); + expect(prefs.maxPreviewHeight).toBe(420); + }); + + it("setStorage updates state and persists immediately", async () => { + const prefs = await load(); + prefs.setStorage("link"); + expect(prefs.storage).toBe("link"); + expect(mockSetSetting).toHaveBeenCalledWith("images.storage", "link"); + }); + + it("setMaxPreviewHeight clamps and persists the clamped value", async () => { + const prefs = await load(); + prefs.setMaxPreviewHeight(50); + expect(prefs.maxPreviewHeight).toBe(120); + expect(mockSetSetting).toHaveBeenCalledWith("images.maxPreviewHeight", 120); + + prefs.setMaxPreviewHeight(1200); + expect(prefs.maxPreviewHeight).toBe(900); + expect(mockSetSetting).toHaveBeenCalledWith("images.maxPreviewHeight", 900); + }); +}); diff --git a/src/lib/stores/images.svelte.ts b/src/lib/stores/images.svelte.ts new file mode 100644 index 0000000..3167d3f --- /dev/null +++ b/src/lib/stores/images.svelte.ts @@ -0,0 +1,61 @@ +// Image preferences (Svelte 5 runes): how images are stored when added to a +// note, and how large they render inline. Persisted to the settings KV like the +// other preference stores. +// +// Storage mode governs INSERTION only (paste, drop, and "Insert image..."): +// - "copy": image bytes are copied into the app's attachments folder, so +// exported markdown stays portable. Pasted screenshots are always copied, +// since there is no original file to link. +// - "link": a file picked from disk is referenced in place by its absolute +// path (rendered through the asset protocol). Saves disk but breaks if the +// original file moves or is deleted. + +import { getSetting, setSetting } from "$lib/api/client"; + +export type ImageStorage = "copy" | "link"; + +const KEY_STORAGE = "images.storage"; +const KEY_MAX_HEIGHT = "images.maxPreviewHeight"; + +const MIN_HEIGHT = 120; +const MAX_HEIGHT = 900; +export const DEFAULT_MAX_HEIGHT = 420; + +class ImagePrefs { + storage = $state("copy"); + maxPreviewHeight = $state(DEFAULT_MAX_HEIGHT); + + #loaded = false; + + async init(): Promise { + if (this.#loaded) return; + this.#loaded = true; + try { + const [storage, height] = await Promise.all([ + getSetting(KEY_STORAGE), + getSetting(KEY_MAX_HEIGHT), + ]); + if (storage === "copy" || storage === "link") this.storage = storage; + if (typeof height === "number") this.maxPreviewHeight = this.#clampHeight(height); + } catch { + // Settings are best-effort; fall back to defaults silently. + } + } + + #clampHeight(h: number): number { + return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.round(h))); + } + + setStorage(v: ImageStorage): void { + this.storage = v; + void setSetting(KEY_STORAGE, v); + } + + setMaxPreviewHeight(h: number): void { + this.maxPreviewHeight = this.#clampHeight(h); + void setSetting(KEY_MAX_HEIGHT, this.maxPreviewHeight); + } +} + +export const imagePrefs = new ImagePrefs(); +export const IMAGE_HEIGHT_RANGE = { min: MIN_HEIGHT, max: MAX_HEIGHT }; diff --git a/src/lib/stores/library.agents.svelte.test.ts b/src/lib/stores/library.agents.svelte.test.ts new file mode 100644 index 0000000..3cca7c7 --- /dev/null +++ b/src/lib/stores/library.agents.svelte.test.ts @@ -0,0 +1,202 @@ +// The library store when an agent writes to the library from another process +// (instantnotes mcp): the open note takes the agent's edit when the user has +// nothing unsaved, and a save that meets an agent's edit keeps the user's +// typing, says so, and offers the agent's version back. +// +// Same harness as library.svelte.test.ts: client and events mocked, fake +// timers, a fresh module graph per test. + +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { + ApiError, + getNote, + listNotes, + listTags, + listWorkspaces, + tagsForNote, + updateNote, + workspacesForNote, +} from "$lib/api/client"; +import { listen } from "@tauri-apps/api/event"; +import { EVENTS } from "$lib/api/events"; +import type { Note } from "$lib/api/types"; +import type { AgentActivity } from "$lib/agent-activity"; + +vi.mock("$lib/api/client", () => { + class ApiError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } + } + return { + ApiError, + getNote: vi.fn(), + updateNote: vi.fn(), + listNotes: vi.fn(), + listTags: vi.fn(), + listWorkspaces: vi.fn(), + tagsForNote: vi.fn(), + workspacesForNote: vi.fn(), + listWorkspaceTags: vi.fn(), + getSetting: vi.fn(), + setSetting: vi.fn(), + getAgentConnection: vi.fn(), + }; +}); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); +vi.mock("@excalidraw/excalidraw", () => ({ convertToExcalidrawElements: vi.fn() })); + +const mockGetNote = vi.mocked(getNote); +const mockUpdateNote = vi.mocked(updateNote); +const mockListen = vi.mocked(listen); + +const T0 = "2026-01-01T00:00:00.000000Z"; +const T1 = "2026-01-01T00:05:00.000000Z"; +const T2 = "2026-01-01T00:06:00.000000Z"; + +const mkNote = (id: string, over: Partial = {}): Note => ({ + id, + title: "Plan", + body: "Plan", + createdAt: T0, + updatedAt: T0, + isPinned: false, + isArchived: false, + isDeleted: false, + contentKind: "document", + ...over, +}); + +const write = (noteId: string): AgentActivity => ({ + at: 1, + client: "claude-code", + tool: "append_to_note", + kind: "write", + noteIds: [noteId], + noteCount: 1, + titles: ["Plan"], + space: null, + tag: null, + query: null, +}); + +/** Load a fresh store, run init, and hand back the external-change listener. */ +async function setup() { + const { library } = await import("$lib/stores/library.svelte"); + const { toasts } = await import("$lib/stores/toasts.svelte"); + const { agents } = await import("$lib/stores/agents.svelte"); + await library.init(); + const call = mockListen.mock.calls.find(([name]) => name === EVENTS.LIBRARY_EXTERNAL_CHANGE); + const external = call![1] as (e: { payload: unknown }) => void; + mockGetNote.mockResolvedValueOnce(mkNote("n1")); + await library.select("n1"); + return { library, toasts, agents, external }; +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mockGetNote.mockReset(); + mockUpdateNote.mockReset(); + vi.mocked(listNotes).mockReset().mockResolvedValue([]); + vi.mocked(listTags).mockReset().mockResolvedValue([]); + vi.mocked(listWorkspaces).mockReset().mockResolvedValue([]); + vi.mocked(tagsForNote).mockReset().mockResolvedValue([]); + vi.mocked(workspacesForNote).mockReset().mockResolvedValue([]); + mockListen.mockReset().mockResolvedValue(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("an agent writes to the open note", () => { + it("with nothing unsaved, the note takes the agent's version", async () => { + const { library, external } = await setup(); + mockGetNote.mockResolvedValueOnce(mkNote("n1", { body: "Plan\n- agent step", updatedAt: T1 })); + + external({ payload: [write("n1")] }); + await vi.advanceTimersByTimeAsync(0); + + expect(mockGetNote).toHaveBeenLastCalledWith("n1", false); + expect(library.selected?.body).toBe("Plan\n- agent step"); + + // The next save is based on the agent's version, so it is no conflict. + mockUpdateNote.mockResolvedValueOnce(mkNote("n1", { body: "Plan\n- agent step!", updatedAt: T2 })); + library.editBody("Plan\n- agent step!"); + await vi.advanceTimersByTimeAsync(400); + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "Plan\n- agent step!", + expectedUpdatedAt: T1, + }); + }); + + it("leaves a note alone while the user has unsaved typing", async () => { + const { library, external } = await setup(); + library.editBody("Plan, mid-sentence"); + mockGetNote.mockClear(); + + external({ payload: [write("n1")] }); + await vi.advanceTimersByTimeAsync(0); + + expect(mockGetNote).not.toHaveBeenCalled(); + expect(library.selected?.body).toBe("Plan, mid-sentence"); + }); + + it("ignores a write to some other note", async () => { + const { library, external } = await setup(); + mockGetNote.mockClear(); + external({ payload: [write("n2"), { ...write("n1"), kind: "read" }] }); + await vi.advanceTimersByTimeAsync(0); + expect(mockGetNote).not.toHaveBeenCalled(); + expect(library.selected?.body).toBe("Plan"); + }); +}); + +describe("a save meets an agent's edit", () => { + it("keeps the user's typing, names the agent, and offers theirs back", async () => { + const { library, toasts, agents } = await setup(); + agents.play([write("n1")]); + const theirs = "Plan\n- agent step"; + mockUpdateNote + .mockRejectedValueOnce(new ApiError("CONFLICT" as never, "changed")) + .mockResolvedValueOnce(mkNote("n1", { body: "Plan, mine", updatedAt: T2 })); + mockGetNote.mockResolvedValueOnce(mkNote("n1", { body: theirs, updatedAt: T1 })); + + library.editBody("Plan, mine"); + await vi.advanceTimersByTimeAsync(400); + + expect(mockUpdateNote).toHaveBeenLastCalledWith("n1", { + body: "Plan, mine", + expectedUpdatedAt: T1, + }); + expect(library.saveState).toBe("saved"); + const toast = toasts.items.at(-1)!; + expect(toast.message).toBe("Claude Code's change to this note was replaced by your typing."); + expect(toast.action?.label).toBe("Restore theirs"); + + // Restoring is an ordinary edit, based on the version just written. + mockUpdateNote.mockResolvedValueOnce(mkNote("n1", { body: theirs, updatedAt: "2026-01-01T00:07:00.000000Z" })); + toast.action!.run(); + expect(library.selected?.body).toBe(theirs); + await vi.advanceTimersByTimeAsync(400); + expect(mockUpdateNote).toHaveBeenLastCalledWith("n1", { body: theirs, expectedUpdatedAt: T2 }); + }); + + it("says nothing when only the note's details changed, not its text", async () => { + const { library, toasts } = await setup(); + mockUpdateNote + .mockRejectedValueOnce(new ApiError("CONFLICT" as never, "changed")) + .mockResolvedValueOnce(mkNote("n1", { body: "Plan, mine", updatedAt: T2 })); + // Pinned elsewhere: newer version, same body. + mockGetNote.mockResolvedValueOnce(mkNote("n1", { isPinned: true, updatedAt: T1 })); + + library.editBody("Plan, mine"); + await vi.advanceTimersByTimeAsync(400); + + expect(mockUpdateNote).toHaveBeenCalledTimes(2); + expect(toasts.items).toHaveLength(0); + }); +}); diff --git a/src/lib/stores/library.svelte.test.ts b/src/lib/stores/library.svelte.test.ts index bbb2678..9918de9 100644 --- a/src/lib/stores/library.svelte.test.ts +++ b/src/lib/stores/library.svelte.test.ts @@ -22,8 +22,11 @@ import { listNotes, listTags, listWorkspaces, + listStickies, listWorkspaceTags, permanentlyDeleteNote, + popInNote, + popOutNote, renameWorkspace, searchNotes, softDeleteNote, @@ -33,6 +36,8 @@ import { workspacesForNote, } from "$lib/api/client"; import { listen } from "@tauri-apps/api/event"; +import { EVENTS } from "$lib/api/events"; +import { UPDATE_NOTE_ID, UPDATE_SPACE_ID } from "$lib/update/space"; import type { Note, SearchResult, @@ -75,6 +80,9 @@ vi.mock("$lib/api/client", () => { addTagToNote: vi.fn(), removeTagFromNote: vi.fn(), tagsForNote: vi.fn(), + listStickies: vi.fn(), + popOutNote: vi.fn(), + popInNote: vi.fn(), }; }); @@ -82,6 +90,14 @@ vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(), })); +// Converting a note lays its text onto the board through Excalidraw's own +// element builder; the stand-in keeps each skeleton and stamps an id. +vi.mock("@excalidraw/excalidraw", () => ({ + convertToExcalidrawElements: vi.fn((skeletons: object[]) => + skeletons.map((sk, i) => ({ ...sk, id: `el${i}`, version: 1 })), + ), +})); + const mockCreateNote = vi.mocked(createNote); const mockGetNote = vi.mocked(getNote); const mockUpdateNote = vi.mocked(updateNote); @@ -101,6 +117,9 @@ const mockGetOrCreateWorkspace = vi.mocked(getOrCreateWorkspace); const mockAddNoteToWorkspace = vi.mocked(addNoteToWorkspace); const mockListWorkspaceTags = vi.mocked(listWorkspaceTags); const mockListen = vi.mocked(listen); +const mockListStickies = vi.mocked(listStickies); +const mockPopOutNote = vi.mocked(popOutNote); +const mockPopInNote = vi.mocked(popInNote); function mkNote(id: string, overrides: Partial = {}): Note { return { @@ -112,6 +131,7 @@ function mkNote(id: string, overrides: Partial = {}): Note { isPinned: false, isArchived: false, isDeleted: false, + contentKind: "document", ...overrides, }; } @@ -177,6 +197,9 @@ beforeEach(() => { mockAddNoteToWorkspace.mockReset(); mockListWorkspaceTags.mockReset().mockResolvedValue([]); mockListen.mockReset().mockResolvedValue(() => {}); + mockListStickies.mockReset().mockResolvedValue([]); + mockPopOutNote.mockReset().mockResolvedValue(undefined); + mockPopInNote.mockReset().mockResolvedValue(undefined); }); afterEach(() => { @@ -197,7 +220,12 @@ describe("save queue", () => { expect(mockUpdateNote).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(400); - expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "new body" }); + // A document save names the version it was based on (the one opened), so + // an agent's write in between is caught rather than overwritten. + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "new body", + expectedUpdatedAt: "2026-01-01T00:00:00Z", + }); // Write still in flight: must not claim "saved" over unpersisted data. expect(library.saveState).toBe("saving"); @@ -260,7 +288,10 @@ describe("flushPendingEdits", () => { library.editBody("flush me"); const flushed = library.flushPendingEdits(); // The write must fire synchronously off the flush, not off the timer. - expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "flush me" }); + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "flush me", + expectedUpdatedAt: "2026-01-01T00:00:00Z", + }); write.resolve(mkNote("n1", { body: "flush me" })); await flushed; @@ -368,7 +399,10 @@ describe("destroy paths drop queued edits (regression: fixed 2026-07-08)", () => library.editBody("kept"); await vi.advanceTimersByTimeAsync(400); - expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "kept" }); + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "kept", + expectedUpdatedAt: "2026-01-01T00:00:00Z", + }); }); }); @@ -384,6 +418,7 @@ describe("soft delete flushes queued edits (Undo restores the last keystrokes)", expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "last keystrokes", + expectedUpdatedAt: "2026-01-01T00:00:00Z", }); expect(mockSoftDeleteNote).toHaveBeenCalledWith("n1"); // The write must land before the trash, or a restore loses the edit. @@ -408,6 +443,7 @@ describe("soft delete flushes queued edits (Undo restores the last keystrokes)", expect(mockUpdateNote).toHaveBeenCalledWith("n1", { body: "unsaved bulk edit", + expectedUpdatedAt: "2026-01-01T00:00:00Z", }); expect(mockSoftDeleteNotes).toHaveBeenCalledWith(["n1"]); await vi.advanceTimersByTimeAsync(3000); @@ -521,9 +557,11 @@ describe("init ordering", () => { const initPromise = library.init(); expect(mockListen.mock.calls.map((c) => c[0])).toEqual([ - "notes:changed", - "tags:changed", - "workspaces:changed", + EVENTS.NOTES_CHANGED, + EVENTS.TAGS_CHANGED, + EVENTS.WORKSPACES_CHANGED, + EVENTS.STICKIES_CHANGED, + EVENTS.LIBRARY_EXTERNAL_CHANGE, ]); expect(mockListNotes).not.toHaveBeenCalled(); expect(mockListTags).not.toHaveBeenCalled(); @@ -545,7 +583,7 @@ describe("init ordering", () => { const p2 = library.init(); await Promise.all([p1, p2]); - expect(mockListen).toHaveBeenCalledTimes(3); + expect(mockListen).toHaveBeenCalledTimes(5); // Two listNotes calls: the visible list and the revisit count. expect(mockListNotes).toHaveBeenCalledTimes(2); expect(mockListTags).toHaveBeenCalledTimes(1); @@ -558,7 +596,7 @@ describe("init ordering", () => { mockListNotes.mockClear(); const handler = mockListen.mock.calls.find( - (c) => c[0] === "notes:changed", + (c) => c[0] === EVENTS.NOTES_CHANGED, )?.[1] as (() => void) | undefined; expect(handler).toBeTypeOf("function"); handler!(); @@ -803,3 +841,310 @@ describe("revisit mode (open-loop resurfacing)", () => { await vi.advanceTimersByTimeAsync(0); }); }); + +describe("whiteboards", () => { + const BOARD = JSON.stringify({ + v: 1, + engine: "excalidraw", + data: { elements: [], appState: {}, files: {} }, + }); + + it("queues a board save like a body edit and persists canvas and text together", async () => { + const library = await load(); + await selectNote(library, "b1", { contentKind: "whiteboard", surfaceData: BOARD }); + mockUpdateNote.mockResolvedValue(mkNote("b1", { contentKind: "whiteboard" })); + + library.editBoard("b1", { surfaceData: "next", body: "words" }); + expect(library.saveState).toBe("saving"); + expect(library.selected?.surfaceData).toBe("next"); + expect(library.selected?.body).toBe("words"); + + await vi.advanceTimersByTimeAsync(400); + expect(mockUpdateNote).toHaveBeenCalledWith("b1", { surfaceData: "next", body: "words" }); + await vi.advanceTimersByTimeAsync(0); + expect(library.saveState).toBe("saved"); + // The reply leaves the canvas out; the open note keeps its own copy. + expect(library.selected?.surfaceData).toBe("next"); + }); + + it("quit collects a board's unsent change before flushing", async () => { + const library = await load(); + await selectNote(library, "b1", { contentKind: "whiteboard", surfaceData: BOARD }); + mockUpdateNote.mockResolvedValue(mkNote("b1", { contentKind: "whiteboard" })); + const off = library.onBeforeFlush(() => + library.editBoard("b1", { surfaceData: "last stroke", body: "" }), + ); + + await library.flushPendingEdits(); + expect(mockUpdateNote).toHaveBeenCalledWith("b1", { surfaceData: "last stroke", body: "" }); + + off(); + mockUpdateNote.mockClear(); + await library.flushPendingEdits(); + expect(mockUpdateNote).not.toHaveBeenCalled(); + }); + + it("switching notes collects the board's unsent change for the board, not the next note", async () => { + const library = await load(); + await selectNote(library, "b1", { contentKind: "whiteboard", surfaceData: BOARD }); + mockUpdateNote.mockResolvedValue(mkNote("b1", { contentKind: "whiteboard" })); + library.onBeforeFlush(() => library.editBoard("b1", { surfaceData: "drawn", body: "" })); + + await selectNote(library, "n2"); + await vi.advanceTimersByTimeAsync(0); + expect(mockUpdateNote).toHaveBeenCalledWith("b1", { surfaceData: "drawn", body: "" }); + expect(library.selected?.id).toBe("n2"); + expect(library.selected?.surfaceData).toBeUndefined(); + }); + + it("reopening a board with an unsaved change shows the change, not the disk copy", async () => { + const library = await load(); + await selectNote(library, "b1", { contentKind: "whiteboard", surfaceData: BOARD }); + mockUpdateNote.mockReturnValue(new Promise(() => {})); + library.editBoard("b1", { surfaceData: "unsaved", body: "text" }); + await selectNote(library, "n2"); + await selectNote(library, "b1", { contentKind: "whiteboard", surfaceData: BOARD }); + expect(library.selected?.surfaceData).toBe("unsaved"); + expect(library.selected?.body).toBe("text"); + }); + + it("converting lays the note's text onto the board as one text element", async () => { + const library = await load(); + await selectNote(library, "n1", { body: "Roadmap\nship it" }); + mockUpdateNote.mockResolvedValue(mkNote("n1", { contentKind: "whiteboard" })); + + library.editBody("Roadmap\nship it today"); + await library.convertToWhiteboard(); + + const calls = mockUpdateNote.mock.calls; + // The pending body edit lands first, so nothing typed is lost. + expect(calls[0]).toEqual([ + "n1", + { body: "Roadmap\nship it today", expectedUpdatedAt: "2026-01-01T00:00:00Z" }, + ]); + const [id, patch] = calls[calls.length - 1]; + expect(id).toBe("n1"); + expect(patch.contentKind).toBe("whiteboard"); + const board = JSON.parse(patch.surfaceData as string); + expect(board.engine).toBe("excalidraw"); + expect(board.data.elements).toHaveLength(1); + expect(board.data.elements[0]).toMatchObject({ + type: "text", + text: "Roadmap\nship it today", + }); + expect(library.selected?.contentKind).toBe("whiteboard"); + expect(library.selected?.surfaceData).toBe(patch.surfaceData); + }); + + it("converting an empty note gives an empty board", async () => { + const library = await load(); + await selectNote(library, "n1", { body: " " }); + mockUpdateNote.mockResolvedValue(mkNote("n1", { contentKind: "whiteboard" })); + await library.convertToWhiteboard(); + const patch = mockUpdateNote.mock.calls[0][1]; + expect(JSON.parse(patch.surfaceData as string).data.elements).toEqual([]); + }); + + it("does not convert a trashed note or a board", async () => { + const library = await load(); + await selectNote(library, "n1", { isDeleted: true }); + await library.convertToWhiteboard(); + await selectNote(library, "b1", { contentKind: "whiteboard" }); + await library.convertToWhiteboard(); + expect(mockUpdateNote).not.toHaveBeenCalled(); + }); + + it("New whiteboard never converts the open note when creating the new one fails", async () => { + const library = await load(); + await selectNote(library, "n1", { body: "my writing" }); + mockCreateNote.mockRejectedValue(new Error("disk full")); + await library.newWhiteboard(); + expect(mockUpdateNote).not.toHaveBeenCalled(); + expect(library.selected?.contentKind).toBe("document"); + }); + + it("New whiteboard creates a note and opens it as an empty board", async () => { + const library = await load(); + mockCreateNote.mockResolvedValue(mkNote("new1")); + mockGetNote.mockResolvedValue(mkNote("new1")); + mockUpdateNote.mockResolvedValue(mkNote("new1", { contentKind: "whiteboard" })); + + await library.newWhiteboard(); + + expect(mockCreateNote).toHaveBeenCalled(); + const [id, patch] = mockUpdateNote.mock.calls[0]; + expect(id).toBe("new1"); + expect(patch.contentKind).toBe("whiteboard"); + expect(library.selected?.contentKind).toBe("whiteboard"); + }); +}); + +describe("graph view", () => { + it("is its own place: entering it leaves every other view", async () => { + const library = await load(); + library.selectWorkspace("ws1"); + library.selectGraph(); + expect(library.graphMode).toBe(true); + expect(library.activeWorkspaceId).toBeNull(); + expect(library.revisitMode).toBe(false); + await vi.advanceTimersByTimeAsync(0); + }); + + it("any other place in the sidebar leaves it", async () => { + const library = await load(); + for (const go of [ + () => library.selectWorkspace(null), + () => library.selectWorkspace("ws1"), + () => library.selectRevisit(), + () => library.setTagFilter("t1"), + () => library.setStatusFilter("archived"), + ]) { + library.selectGraph(); + go(); + expect(library.graphMode).toBe(false); + } + await vi.advanceTimersByTimeAsync(0); + }); + + it("opening a note leaves it, so the note is what shows", async () => { + const library = await load(); + library.selectGraph(); + await selectNote(library, "n1"); + expect(library.graphMode).toBe(false); + expect(library.selected?.id).toBe("n1"); + }); + + it("creating a note leaves it", async () => { + const library = await load(); + mockCreateNote.mockResolvedValue(mkNote("new1")); + mockGetNote.mockResolvedValue(mkNote("new1")); + library.selectGraph(); + await library.newNote(); + expect(library.graphMode).toBe(false); + }); +}); + +describe("the update Space (synthetic)", () => { + it("is never queried and holds no store rows of its own", async () => { + const library = await load(); + mockListNotes.mockClear(); + + library.selectWorkspace(UPDATE_SPACE_ID); + await vi.advanceTimersByTimeAsync(0); + + expect(library.activeWorkspaceId).toBe(UPDATE_SPACE_ID); + expect(mockListNotes).not.toHaveBeenCalled(); + expect(library.notes).toEqual([]); + }); + + it("opens a synthetic note without fetching it, and keeps its edits local", async () => { + const library = await load(); + const note = mkNote(UPDATE_NOTE_ID, { title: "update 0.9.0 → 0.10.0" }); + + library.selectVirtual(note); + expect(library.selected?.id).toBe(UPDATE_NOTE_ID); + expect(library.selectedTags).toEqual([]); + expect(mockGetNote).not.toHaveBeenCalled(); + + library.editBody("typed into the release notes"); + expect(library.selected?.body).toBe("typed into the release notes"); + // Nothing is ever written: a synthetic note is not user data. + await vi.advanceTimersByTimeAsync(3000); + expect(mockUpdateNote).not.toHaveBeenCalled(); + expect(library.saveState).not.toBe("saving"); + }); + + it("a new note is not filed under the synthetic Space", async () => { + const library = await load(); + mockCreateNote.mockResolvedValue(mkNote("new1")); + mockGetNote.mockResolvedValue(mkNote("new1")); + library.selectWorkspace(UPDATE_SPACE_ID); + await vi.advanceTimersByTimeAsync(0); + + await library.newNote(); + + expect(library.activeWorkspaceId).toBeNull(); + expect(mockAddNoteToWorkspace).not.toHaveBeenCalled(); + }); +}); + +describe("stickies", () => { + it("writes the note's pending edit before popping it out", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockResolvedValue(mkNote("n1", { body: "typed" })); + library.editBody("typed"); + mockListStickies.mockResolvedValue(["n1"]); + + await library.popOut("n1"); + + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "typed", + expectedUpdatedAt: "2026-01-01T00:00:00Z", + }); + expect(mockUpdateNote.mock.invocationCallOrder[0]).toBeLessThan( + mockPopOutNote.mock.invocationCallOrder[0], + ); + expect(library.isSticky("n1")).toBe(true); + }); + + it("keeps the note here when its pending edit cannot be written", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "locked")); + library.editBody("typed"); + + await library.popOut("n1"); + + expect(mockPopOutNote).not.toHaveBeenCalled(); + expect(library.isSticky("n1")).toBe(false); + }); + + it("stops editing a note while it is a sticky", async () => { + const library = await load(); + await selectNote(library, "n1", { body: "disk" }); + mockListStickies.mockResolvedValue(["n1"]); + await library.refreshStickies(); + + library.editBody("from the library"); + library.editTitle("Renamed"); + library.editBoard("n1", { surfaceData: "{}", body: "board" }); + await vi.advanceTimersByTimeAsync(3000); + + expect(library.selected?.body).toBe("disk"); + expect(mockUpdateNote).not.toHaveBeenCalled(); + }); + + it("reopens the note from disk when its sticky comes back", async () => { + const library = await load(); + await selectNote(library, "n1", { body: "before" }); + mockListStickies.mockResolvedValue(["n1"]); + await library.refreshStickies(); + + mockListStickies.mockResolvedValue([]); + mockGetNote.mockResolvedValueOnce(mkNote("n1", { body: "typed in the sticky" })); + await library.popIn("n1"); + + expect(mockPopInNote).toHaveBeenCalledWith("n1"); + expect(library.isSticky("n1")).toBe(false); + expect(library.selected?.body).toBe("typed in the sticky"); + }); + + it("brings a sticky back before trashing its note, and not at all if it cannot save", async () => { + const library = await load(); + await selectNote(library, "n1"); + mockListStickies.mockResolvedValue(["n1"]); + await library.refreshStickies(); + + mockPopInNote.mockRejectedValueOnce(new ApiError("STORAGE_ERROR", "sticky kept")); + await library.deleteSelected(); + expect(mockSoftDeleteNote).not.toHaveBeenCalled(); + + mockListStickies.mockResolvedValue([]); + mockGetNote.mockResolvedValue(mkNote("n1")); + mockSoftDeleteNote.mockResolvedValue(mkNote("n1", { isDeleted: true })); + await library.deleteSelected(); + expect(mockPopInNote).toHaveBeenCalledTimes(2); + expect(mockSoftDeleteNote).toHaveBeenCalledWith("n1"); + }); +}); diff --git a/src/lib/stores/library.svelte.ts b/src/lib/stores/library.svelte.ts index 33fe667..f50a228 100644 --- a/src/lib/stores/library.svelte.ts +++ b/src/lib/stores/library.svelte.ts @@ -12,7 +12,10 @@ import { listNotes, listTags, listWorkspaces, + listStickies, listWorkspaceTags, + popInNote, + popOutNote, removeNoteFromWorkspace, removeTagFromNote, renameWorkspace, @@ -37,10 +40,20 @@ import type { WorkspaceWithCount, } from "$lib/api/types"; import { debounce } from "$lib/debounce"; -import { friendlyMessage } from "$lib/errors"; -import { SaveQueue, type SaveState } from "$lib/stores/library/save-queue.svelte"; +import { ERROR_CODES } from "$lib/api/error-codes"; +import { EVENTS } from "$lib/api/events"; +import { friendlyMessage, GENERIC_MESSAGE } from "$lib/errors"; +import { isUpdateSpaceId, isVirtualNoteId } from "$lib/update/space"; +import { + SaveQueue, + type QueuedEdit, + type SaveState, +} from "$lib/stores/library/save-queue.svelte"; import { SelectionModel } from "$lib/stores/library/selection.svelte"; import { toasts } from "$lib/stores/toasts.svelte"; +import { announceOverwrite } from "$lib/stores/agents.svelte"; +import { mayHaveWritten, parseActivityLog, type AgentActivity } from "$lib/agent-activity"; +import { boardFromText } from "$lib/whiteboard/excalidraw"; import { listen } from "@tauri-apps/api/event"; export type { SaveState }; @@ -71,6 +84,8 @@ class LibraryStore { // Revisit: capture-born notes never opened in the library. The count keeps // the sidebar entry honest (hidden at zero); the mode filters the list. revisitMode = $state(false); + /** The Graph view: the library drawn as notes, tags, and Spaces. */ + graphMode = $state(false); revisitCount = $state(0); searchText = $state(""); notes = $state([]); @@ -81,6 +96,9 @@ class LibraryStore { selectedTags = $state([]); selectedWorkspaces = $state([]); error = $state(null); + // Notes popped out as stickies. Each sticky is its note's only editor, so + // the library stops editing a note the moment it appears here. + stickyIds = $state>(new Set()); // Ids checked for bulk actions (the open note's id on a plain click; grows // via cmd-click / shift-click). Size > 1 swaps the editor for the bulk panel. @@ -91,22 +109,40 @@ class LibraryStore { return this.#selection.ids; } - // Body persistence (debounce, retry, flush) lives in its own single-writer + // Edit persistence (debounce, retry, flush) lives in its own single-writer // unit; the store composes one and delegates. A confirmed write updates the // open note; a terminal failure surfaces an error. #saveQueue = new SaveQueue({ onPersisted: async (id, updated) => { if (this.selected?.id === id) { - // Keep local body if the user kept typing past this save. - const localBody = this.selected.body; - this.selected = { ...updated, body: localBody }; + // Keep the local body and canvas: the user may have kept editing + // past this save, and the reply never carries the canvas. + const { body, surfaceData } = this.selected; + this.selected = { ...updated, body, surfaceData }; this.selectedTags = await tagsForNote(id); } this.error = null; }, onError: (e) => this.#fail(e), + onOverwrote: (id, theirs) => announceOverwrite(id, () => this.#restoreExternal(id, theirs)), }); + // Editors that hold an edit not yet handed to the queue (a whiteboard + // batches canvas changes before serializing them) register here, and are + // asked to hand it over before any flush, trash, or note switch. + #beforeFlush = new Set<() => void>(); + + /** Register a hook run before every flush, trash, and note switch; + * returns the unregister function. */ + onBeforeFlush(hook: () => void): () => void { + this.#beforeFlush.add(hook); + return () => this.#beforeFlush.delete(hook); + } + + #collectPending(): void { + for (const hook of this.#beforeFlush) hook(); + } + #initialized = false; #refreshDebounced = debounce(() => void this.refresh(), 50); @@ -126,21 +162,96 @@ class LibraryStore { // Listeners before the initial fetches: a change event arriving during // startup must trigger a re-query, not be dropped. await Promise.all([ - listen("notes:changed", () => { + listen(EVENTS.NOTES_CHANGED, () => { this.#refreshDebounced(); this.#revisitCountDebounced(); }), - listen("tags:changed", () => void this.refreshTags()), - listen("workspaces:changed", () => void this.refreshWorkspaces()), + listen(EVENTS.TAGS_CHANGED, () => void this.refreshTags()), + listen(EVENTS.WORKSPACES_CHANGED, () => void this.refreshWorkspaces()), + listen(EVENTS.STICKIES_CHANGED, () => void this.refreshStickies()), + listen(EVENTS.LIBRARY_EXTERNAL_CHANGE, (e) => { + void this.#adoptExternal(parseActivityLog(e.payload)); + }), ]); await Promise.all([ this.refresh(), this.refreshTags(), this.refreshWorkspaces(), this.#refreshRevisitCount(), + this.refreshStickies(), ]); } + // ---- stickies ---- + + isSticky(id: string | undefined): boolean { + return id !== undefined && this.stickyIds.has(id); + } + + /** Re-read which notes are stickies. A note that just came back from one + * is reopened from disk if it is the open note: the sticky wrote it last. */ + async refreshStickies(): Promise { + try { + const next = new Set(await listStickies()); + const returned = [...this.stickyIds].filter((id) => !next.has(id)); + this.stickyIds = next; + const open = this.selected?.id; + if (open && returned.includes(open)) await this.#open(open); + } catch (e) { + this.#fail(e); + } + } + + /** + * Pop a note out as a sticky. Every pending edit is written first and the + * note's must have landed: the sticky loads the note from disk, and an edit + * still queued here would later overwrite whatever is typed there. + */ + async popOut(id: string): Promise { + if (isVirtualNoteId(id)) return; + await this.flushPendingEdits(); + if (this.#saveQueue.peek(id) !== undefined) { + toasts.show("Couldn't save this note, so it stays here for now."); + return; + } + try { + await popOutNote(id); + await this.refreshStickies(); + } catch (e) { + this.#fail(e); + } + } + + /** Bring a sticky back into the library. Resolves once its edits are on + * disk and the open note shows them. */ + async popIn(id: string): Promise { + await this.#popInAll([id]); + } + + /** The File menu's toggle: pop the open note out, or back in. */ + async toggleSticky(): Promise { + const note = this.selected; + if (!note || note.isDeleted) return; + await (this.isSticky(note.id) ? this.popIn(note.id) : this.popOut(note.id)); + } + + /** Pop in whichever of these notes are stickies, before anything that ends + * their life here (trash, destroy). False when a sticky could not save, + * in which case the caller must not go ahead. */ + async #popInAll(ids: string[]): Promise { + const stickies = ids.filter((id) => this.stickyIds.has(id)); + if (stickies.length === 0) return true; + try { + for (const id of stickies) await popInNote(id); + return true; + } catch (e) { + this.#fail(e); + return false; + } finally { + await this.refreshStickies(); + } + } + #filter(): NoteFilter { if (this.revisitMode) return this.#revisitFilter(); const f: NoteFilter = {}; @@ -171,6 +282,16 @@ class LibraryStore { async refresh(): Promise { const token = ++this.#refreshToken; + // The update Space is synthetic: its two notes come from the updater, not + // the store, so there is nothing to query. A search still runs globally, + // which is why it is the one thing that takes precedence over the Space. + if (isUpdateSpaceId(this.activeWorkspaceId) && !this.searchText.trim()) { + this.searchResults = null; + this.notes = []; + this.workspaceTags = []; + this.error = null; + return; + } try { const text = this.searchText.trim(); if (text) { @@ -230,6 +351,7 @@ class LibraryStore { // so it clears revisit and search but keeps the space/tag scope. this.statusFilter = filter; this.revisitMode = false; + this.graphMode = false; this.searchText = ""; this.clearMultiSelect(); void this.refresh(); @@ -247,11 +369,19 @@ class LibraryStore { this.scopedTagId = null; this.workspaceTags = []; this.revisitMode = false; + this.graphMode = false; this.statusFilter = "active"; this.searchText = ""; this.clearMultiSelect(); } + /** Show the library as a graph. */ + selectGraph(): void { + this.#resetForNavigation(); + this.graphMode = true; + void this.refresh(); + } + /** Show All Notes (null) or one workspace's collected notes. */ selectWorkspace(workspaceId: string | null): void { this.#resetForNavigation(); @@ -318,7 +448,8 @@ class LibraryStore { this.workspaceTags = []; // The workspace can be deleted between the list refresh and this // query; refreshWorkspaces resets the selection, nothing to surface. - if (!(e instanceof ApiError && e.code === "NOT_FOUND")) this.#fail(e); + if (!(e instanceof ApiError && e.code === ERROR_CODES.NOT_FOUND)) + this.#fail(e); } } @@ -338,19 +469,40 @@ class LibraryStore { } async select(id: string): Promise { + // Opening a note shows it, wherever it was chosen from (the graph, the + // palette), so the graph steps aside. + this.graphMode = false; this.#selection.reset([id], id); await this.#open(id); } + /** + * Open one of the update Space's synthetic notes. It has no row in the store, + * so there is nothing to fetch and nothing to persist; the pending edit of + * the note being left is still flushed first, exactly as a real switch does. + */ + selectVirtual(note: Note): void { + this.graphMode = false; + this.#collectPending(); + this.#saveQueue.flushDebounce(); + this.#selection.reset([note.id], note.id); + this.selected = note; + this.selectedTags = []; + this.selectedWorkspaces = []; + this.error = null; + } + async #open(id: string): Promise { // Flush any pending edit of the previous note before switching. + this.#collectPending(); this.#saveQueue.flushDebounce(); try { const note = await getNote(id, true); + this.#saveQueue.known(note); // A queued edit (debounced or awaiting retry) is newer than what disk // returned; showing the disk body would fork the note's history. const queued = this.#saveQueue.peek(id); - this.selected = queued !== undefined ? { ...note, body: queued } : note; + this.selected = queued !== undefined ? { ...note, ...queued } : note; [this.selectedTags, this.selectedWorkspaces] = await Promise.all([ tagsForNote(id), workspacesForNote(id), @@ -448,8 +600,10 @@ class LibraryStore { async bulkDelete(): Promise { const ids = [...this.multiSelected]; + if (!(await this.#popInAll(ids))) return; // Trash is reversible and Undo promises fidelity: persist any pending // edit first, so a restored note holds the user's last keystrokes. + this.#collectPending(); this.#saveQueue.cancelDebounce(); await this.#saveQueue.flushIds(ids); this.#saveQueue.drop(ids); @@ -481,6 +635,7 @@ class LibraryStore { */ async destroyNotes(ids: string[]): Promise { if (ids.length === 0) return; + if (!(await this.#popInAll(ids))) return; // Destroyed notes must also forget their queued edits, or the retry and // every later flush re-attempts a write against a row that no longer // exists and surfaces NOT_FOUND forever. @@ -520,12 +675,21 @@ class LibraryStore { } } - async newNote(): Promise { + /** Create a note and open it. Resolves to its id, or null if creating + * failed (the error is already surfaced). */ + async newNote(): Promise { try { const activeTag = this.activeTagId ? this.tags.find((t) => t.id === this.activeTagId) : null; + // A note cannot be born in the synthetic update Space; creating one there + // drops the view back to All Notes rather than filing the note under a + // workspace id that is not in the database. + if (isUpdateSpaceId(this.activeWorkspaceId)) { + this.activeWorkspaceId = null; + } + const note = await createNote( activeTag ? { tags: [activeTag.name] } : {}, ); @@ -542,8 +706,10 @@ class LibraryStore { this.searchText = ""; void this.refresh(); await this.select(note.id); + return note.id; } catch (e) { this.#fail(e); + return null; } } @@ -634,7 +800,7 @@ class LibraryStore { const message = e instanceof ApiError ? friendlyMessage(e.code, e.message) - : friendlyMessage(""); + : GENERIC_MESSAGE; return { ok: false, message }; } } @@ -662,17 +828,67 @@ class LibraryStore { } editBody(body: string): void { - if (!this.selected) return; + // A sticky is the note's only editor; see stickyIds. + if (!this.selected || this.isSticky(this.selected.id)) return; // Optimistic local state; persistence is debounced. The note is dirty // from this moment until a write of this (or a newer) body succeeds. this.selected.body = body; - this.#saveQueue.queue(this.selected.id, body); + // A synthetic note (the update Space's release notes) is not user data: + // the edit lives while the note is open and is gone when it closes. + if (isVirtualNoteId(this.selected.id)) return; + this.#saveQueue.queue(this.selected.id, { body }); + } + + /** + * A whiteboard save: the canvas and the text written on it, queued like a + * body edit (debounced, retried, flushed on switch and quit). Takes the id + * because a board hands over its last change while the library is already + * switching away from it. + */ + editBoard(id: string, edit: Required): void { + if (this.isSticky(id)) return; + if (this.selected?.id === id) { + this.selected.surfaceData = edit.surfaceData; + this.selected.body = edit.body; + } + this.#saveQueue.queue(id, edit); + } + + /** + * Turn the open note into a whiteboard, for good. Its text goes onto the + * board as a text block, so nothing written disappears; the confirm lives + * with the callers (whiteboard/convert.ts). + */ + async convertToWhiteboard(): Promise { + const note = this.selected; + if (!note || note.isDeleted || note.contentKind === "whiteboard") return; + if (this.isSticky(note.id)) return; + await this.flushPendingEdits(); + const current = this.selected; + if (current?.id !== note.id) return; + try { + const surfaceData = await boardFromText(current.body); + await this.#applyUpdate(note.id, { contentKind: "whiteboard", surfaceData }); + } catch (e) { + this.#fail(e); + } + } + + /** A new note, opened as an empty whiteboard. Converts only the note it + * just created: if creating failed, the open note is someone's writing. */ + async newWhiteboard(): Promise { + const id = await this.newNote(); + if (id && this.selected?.id === id) await this.convertToWhiteboard(); } editTitle(title: string): void { - if (!this.selected) return; + if (!this.selected || this.isSticky(this.selected.id)) return; const trimmed = title.trim(); if (!trimmed || trimmed === this.selected.title) return; + if (isVirtualNoteId(this.selected.id)) { + this.selected.title = trimmed; + return; + } void this.#applyUpdate(this.selected.id, { title: trimmed }); } @@ -693,8 +909,10 @@ class LibraryStore { async deleteSelected(): Promise { if (!this.selected) return; const id = this.selected.id; + if (!(await this.#popInAll([id]))) return; // Trash is reversible and Undo promises fidelity: persist any pending // edit first, so a restored note holds the user's last keystrokes. + this.#collectPending(); this.#saveQueue.cancelDebounce(); await this.#saveQueue.flushIds([id]); this.#saveQueue.drop([id]); @@ -750,6 +968,7 @@ class LibraryStore { * queued for the next flush. */ async flushPendingEdits(): Promise { + this.#collectPending(); await this.#saveQueue.flushAll(); } @@ -772,16 +991,56 @@ class LibraryStore { toasts.show(message); } + /** + * Another process (an agent) wrote while a note is open. With nothing + * unsaved, the open note takes the new version and the editor applies it + * as a change under the caret. With unsaved typing it is left alone: that + * save meets the other write through the version check (SaveQueue). + */ + async #adoptExternal(entries: AgentActivity[]): Promise { + const open = this.selected; + if (!open || isVirtualNoteId(open.id) || open.contentKind === "whiteboard") return; + if (!mayHaveWritten(entries, open.id)) return; + const shown = open.body; + // The user may have typed, or moved on, while this was read. + const fresh = await this.#saveQueue.readExternal( + open.id, + () => this.selected?.id === open.id && this.selected.body === shown, + ); + if (!fresh || !this.selected) return; + this.selected = { ...fresh, surfaceData: this.selected.surfaceData }; + [this.selectedTags, this.selectedWorkspaces] = await Promise.all([ + tagsForNote(open.id), + workspacesForNote(open.id), + ]); + } + + /** "Restore theirs": put an agent's overwritten body back, as an edit. */ + #restoreExternal(id: string, body: string): void { + if (this.selected?.id === id) { + this.selected.body = body; + this.#saveQueue.queue(id, { body }); + } else { + void this.#applyUpdate(id, { body }); + } + } + async #applyUpdate( id: string, patch: Parameters[1], ): Promise { try { const updated = await updateNote(id, patch); + this.#saveQueue.known(updated); if (this.selected?.id === id) { - // Keep local body if user kept typing past this save. - const localBody = this.selected.body; - this.selected = { ...updated, body: patch.body ?? localBody }; + // Keep local body if user kept typing past this save, and the local + // canvas, which the reply never carries. + const { body, surfaceData } = this.selected; + this.selected = { + ...updated, + body: patch.body ?? body, + surfaceData: patch.surfaceData ?? surfaceData, + }; this.selectedTags = await tagsForNote(id); } this.error = null; @@ -794,7 +1053,7 @@ class LibraryStore { this.error = e instanceof ApiError ? friendlyMessage(e.code, e.message) - : friendlyMessage(""); + : GENERIC_MESSAGE; } } diff --git a/src/lib/stores/library/save-queue.svelte.ts b/src/lib/stores/library/save-queue.svelte.ts index 5013344..91b0355 100644 --- a/src/lib/stores/library/save-queue.svelte.ts +++ b/src/lib/stores/library/save-queue.svelte.ts @@ -1,6 +1,7 @@ -// The library's body-save queue: debounced writes, one quiet retry, and +// The library's edit-save queue: debounced writes, one quiet retry, and // flush-on-switch/blur/quit, kept as a single-writer unit apart from the rest -// of the store. It owns every "is this note persisted" decision; the store +// of the store. A queued edit is a document's body, or a whiteboard's canvas +// with the text on it; either way the newest edit per note replaces the last. It owns every "is this note persisted" decision; the store // composes one instance and delegates. // // The only outward coupling is the open note: a confirmed write updates it, and @@ -14,15 +15,22 @@ import { withSetEntry, withoutSetEntries, } from "$lib/reactive-collections"; -import { updateNote } from "$lib/api/client"; -import type { Note } from "$lib/api/types"; +import { ApiError, getNote, updateNote } from "$lib/api/client"; +import { ERROR_CODES } from "$lib/api/error-codes"; +import type { Note, UpdateNotePatch } from "$lib/api/types"; + +/** What the queue persists for a note: `{ body }` for a document, + * `{ surfaceData, body }` for a whiteboard. */ +export type QueuedEdit = Pick; /** Selected-note save status for the editor status bar. */ export type SaveState = "saved" | "saving" | "failed"; -// One quiet retry this long after a failed body save; most failures (a +// One quiet retry this long after a failed save; most failures (a // competing writer briefly holding the database lock) clear well within it. const SAVE_RETRY_MS = 2000; +// Re-reads before giving up when another process keeps writing the same note. +const CONFLICT_ATTEMPTS = 3; export interface SaveQueueDeps { /** Apply a confirmed write. Called for every successful persist so the store @@ -30,22 +38,33 @@ export interface SaveQueueDeps { onPersisted: (id: string, updated: Note) => Promise | void; /** Surface a terminal failure (after the single retry) to the store. */ onError: (e: unknown) => void; + /** A save replaced a body someone else (an agent) wrote after this window + * last saw the note. `theirs` is that body, for the user to restore. */ + onOverwrote?: (id: string, theirs: string) => void; } +/** The last version of a note this window loaded or wrote. */ +type DiskVersion = Pick; + export class SaveQueue { - // Bodies not yet confirmed persisted, by note id. An entry is only removed by + // Edits not yet confirmed persisted, by note id. An entry is only removed by // a successful write, so a failed save stays queued for the next flush (note // switch, blur, quit) instead of being silently dropped. Reassigned on change // so the status bar tracks it reactively. - #unsaved = $state(new Map()); + #unsaved = $state(new Map()); // Note ids whose save failed even after the retry; drives "Not saved". #failed = $state(new Set()); // Scheduled retry per note id, so a newer write, a drop, or a flush can // cancel it before it fires a stray write behind the caller's back. #retryTimers = new Map>(); - - #debounced = debounce((id: string, body: string) => { - void this.#persist(id, body, true); + // Per note, the version the user's edits are based on. A document save + // carries its updatedAt, so a write from another process in between (an + // agent) is noticed instead of silently overwritten. Not reactive: nothing + // renders it. + #disk = new Map(); + + #debounced = debounce((id: string, edit: QueuedEdit) => { + void this.#persist(id, edit, true); }, 400); #deps: SaveQueueDeps; @@ -54,12 +73,40 @@ export class SaveQueue { this.#deps = deps; } - /** The queued (freshest) body for an id, or undefined; lets the opener show + /** The queued (freshest) edit for an id, or undefined; lets the opener show * it instead of the disk copy, which would fork the note's history. */ - peek(id: string): string | undefined { + peek(id: string): QueuedEdit | undefined { return this.#unsaved.get(id); } + /** Record the version of a note just loaded from disk. Only moves forward, + * so a slow read can never rewind past a save that already landed. */ + known(note: Pick): void { + const current = this.#disk.get(note.id); + // Timestamps are UTC ISO-8601, so string order is time order. + if (current && current.updatedAt >= note.updatedAt) return; + this.#disk.set(note.id, { updatedAt: note.updatedAt, body: note.body }); + } + + /** + * Read a note another process (an agent) just wrote, for the window to show + * in place. Null while this window has unsaved typing for it, before or + * after the read, or once `stillShown` says the user moved on: that typing + * meets the other write through the version check instead (see #write). + */ + async readExternal(id: string, stillShown: () => boolean): Promise { + if (this.peek(id) !== undefined) return null; + let fresh: Note; + try { + fresh = await getNote(id, false); + } catch { + return null; + } + if (!stillShown() || this.peek(id) !== undefined) return null; + this.known(fresh); + return fresh; + } + /** Save status of one note id, for the editor status bar. */ stateFor(id: string | undefined): SaveState { if (!id || !this.#unsaved.has(id)) return "saved"; @@ -67,9 +114,9 @@ export class SaveQueue { } /** Queue an optimistic edit; the write is debounced (400ms). */ - queue(id: string, body: string): void { - this.#unsaved = withMapEntry(this.#unsaved, id, body); - this.#debounced(id, body); + queue(id: string, edit: QueuedEdit): void { + this.#unsaved = withMapEntry(this.#unsaved, id, edit); + this.#debounced(id, edit); } /** Run the pending debounced write now, e.g. before switching notes. */ @@ -81,14 +128,14 @@ export class SaveQueue { * Persist every queued edit now, no retry (note switch, window blur, export, * quit). Cancels the debounce rather than flushing it: the retry-enabled path * could otherwise fire a stray write after this resolves. #unsaved already - * holds the latest body for every note, so one no-retry write per note covers + * holds the latest edit for every note, so one no-retry write per note covers * the just-typed edit too. */ async flushAll(): Promise { this.#debounced.cancel(); await Promise.all( - [...this.#unsaved.entries()].map(([id, body]) => - this.#persist(id, body, false), + [...this.#unsaved.entries()].map(([id, edit]) => + this.#persist(id, edit, false), ), ); } @@ -99,7 +146,7 @@ export class SaveQueue { await Promise.all( ids .filter((id) => this.#unsaved.has(id)) - .map((id) => this.#persist(id, this.#unsaved.get(id) as string, false)), + .map((id) => this.#persist(id, this.#unsaved.get(id) as QueuedEdit, false)), ); } @@ -113,18 +160,51 @@ export class SaveQueue { drop(ids: string[]): void { this.#unsaved = withoutMapKeys(this.#unsaved, ids); this.#failed = withoutSetEntries(this.#failed, ids); - for (const id of ids) this.#clearRetryTimer(id); + for (const id of ids) { + this.#clearRetryTimer(id); + this.#disk.delete(id); + } + } + + /** + * Write one edit. A document edit is conditional on the version it was + * based on; when another process wrote in between, the user's edit still + * wins (it is what they are looking at), but only after the other body is + * read, so the user is told and can restore it. A whiteboard canvas and a + * note never loaded in this window are written as before. + */ + async #write(id: string, edit: QueuedEdit): Promise { + const base = this.#disk.get(id); + if (!base || edit.surfaceData !== undefined) return updateNote(id, edit); + let expected = base.updatedAt; + let theirs: string | null = null; + for (let attempt = 1; ; attempt++) { + try { + const updated = await updateNote(id, { ...edit, expectedUpdatedAt: expected }); + // A newer updatedAt with the same body is a pin, a rename, a tag: + // nothing of theirs was replaced, so nothing to say. + if (theirs !== null && theirs !== base.body) this.#deps.onOverwrote?.(id, theirs); + return updated; + } catch (e) { + const conflict = e instanceof ApiError && e.code === ERROR_CODES.CONFLICT; + if (!conflict || attempt >= CONFLICT_ATTEMPTS) throw e; + const disk = await getNote(id, false); + expected = disk.updatedAt; + theirs = disk.body; + } + } } - async #persist(id: string, body: string, canRetry: boolean): Promise { + async #persist(id: string, edit: QueuedEdit, canRetry: boolean): Promise { // Any write attempt for this id, whether from the debounce, a retry, or a // flush, supersedes an outstanding scheduled retry for the same id. this.#clearRetryTimer(id); try { - const updated = await updateNote(id, { body }); + const updated = await this.#write(id, edit); + this.known(updated); // Confirmed on disk. Clear the queue entry unless a newer edit superseded - // the body this write carried. - if (this.#unsaved.get(id) === body) { + // the one this write carried. + if (this.#unsaved.get(id) === edit) { this.#unsaved = withoutMapKeys(this.#unsaved, [id]); } if (this.#failed.has(id)) { diff --git a/src/lib/stores/license-space.svelte.test.ts b/src/lib/stores/license-space.svelte.test.ts new file mode 100644 index 0000000..1a028ad --- /dev/null +++ b/src/lib/stores/license-space.svelte.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +// The License Space: the library's form of the installers agreement gate. +// The rules themselves are agreements.test.ts (the shared conformance cases); +// this covers what InstantNotes adds: which document is open, and the lock. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import manifest from "../../../src-tauri/installer/agreements.json"; + +const KEY = manifest.storageKey; +const [license, eula] = manifest.documents; + +async function load() { + vi.resetModules(); + return (await import("./license-space.svelte")).licenseSpace; +} + +beforeEach(() => localStorage.clear()); + +describe("License Space", () => { + it("locks a first launch and opens the source license first", async () => { + const space = await load(); + expect(space.locked).toBe(true); + expect(space.documents.map((d) => d.id)).toEqual(["license", "eula"]); + expect(space.shown?.id).toBe("license"); + }); + + it("needs each document agreed on its own, then unlocks", async () => { + const space = await load(); + space.agree("license"); + expect(space.locked).toBe(true); + expect(space.isAgreed("license")).toBe(true); + // The next document still to agree to opens by itself. + expect(space.shown?.id).toBe("eula"); + + space.agree("eula"); + expect(space.locked).toBe(false); + expect(JSON.parse(localStorage.getItem(KEY)!)).toEqual({ + license: license.version, + eula: eula.version, + }); + }); + + it("stays open on whichever document was picked", async () => { + const space = await load(); + space.show("eula"); + expect(space.shown?.id).toBe("eula"); + }); + + it("asks again for a document whose version changed, and only that one", async () => { + localStorage.setItem(KEY, JSON.stringify({ license: license.version, eula: "2000-01-01" })); + const space = await load(); + expect(space.locked).toBe(true); + expect(space.isAgreed("license")).toBe(true); + expect(space.shown?.id).toBe("eula"); + }); + + it("opens unlocked once both are agreed at their current versions", async () => { + localStorage.setItem(KEY, JSON.stringify({ license: license.version, eula: eula.version })); + expect((await load()).locked).toBe(false); + }); +}); diff --git a/src/lib/stores/license-space.svelte.ts b/src/lib/stores/license-space.svelte.ts new file mode 100644 index 0000000..0d98142 --- /dev/null +++ b/src/lib/stores/license-space.svelte.ts @@ -0,0 +1,46 @@ +// The license agreement as a place in the sidebar, the way an update is +// (update-space.ts): a License Space whose notes are the source license and +// the EULA. It renders the installers agreement gate (agreements.svelte.ts) +// in InstantNotes' own form. Until both documents are agreed it is the only +// place the library window offers, and capture and stickies stand down +// (installers README, "The agreement gate", rule 3). Derived from +// `agreements` and never stored, so it cannot become user data. + +import { agreements, type AgreementDocument } from "$lib/agreements.svelte"; + +export const LICENSE_SPACE_NAME = "License"; + +class LicenseSpace { + #shownId = $state(null); + + /** True until every document is agreed at its current version. */ + get locked(): boolean { + return !agreements.done; + } + + get documents(): AgreementDocument[] { + return agreements.documents; + } + + /** The open document: the one picked, else the first still to agree to. */ + get shown(): AgreementDocument | undefined { + const id = this.#shownId ?? agreements.pending[0]; + return this.documents.find((d) => d.id === id) ?? this.documents[0]; + } + + isAgreed(id: string): boolean { + return agreements.isAgreed(id); + } + + show(id: string): void { + this.#shownId = id; + } + + /** Agree to one document, then open the next one still to agree to. */ + agree(id: string): void { + agreements.agree(id); + this.#shownId = agreements.pending[0] ?? null; + } +} + +export const licenseSpace = new LicenseSpace(); diff --git a/src/lib/stores/sticky.svelte.test.ts b/src/lib/stores/sticky.svelte.test.ts new file mode 100644 index 0000000..bb178b8 --- /dev/null +++ b/src/lib/stores/sticky.svelte.test.ts @@ -0,0 +1,191 @@ +// The sticky window's note: it is the note's only editor while popped out, so +// its guarantees are the ones the library's save queue gives, plus one of its +// own: a change event never replaces the body being typed. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError, getNote, updateNote } from "$lib/api/client"; +import type { Note } from "$lib/api/types"; +import type { AgentActivity } from "$lib/agent-activity"; +import { agents } from "./agents.svelte"; +import { toasts } from "./toasts.svelte"; +import { StickyNote } from "./sticky.svelte"; + +vi.mock("$lib/api/client", () => { + class ApiError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } + } + return { ApiError, getNote: vi.fn(), updateNote: vi.fn() }; +}); + +const mockGetNote = vi.mocked(getNote); +const mockUpdateNote = vi.mocked(updateNote); + +function mkNote(overrides: Partial = {}): Note { + return { + id: "n1", + title: "Groceries", + body: "milk", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + isPinned: false, + isArchived: false, + isDeleted: false, + contentKind: "document", + ...overrides, + }; +} + +async function loaded(): Promise { + const sticky = new StickyNote(); + mockGetNote.mockResolvedValueOnce(mkNote()); + await sticky.load("n1"); + return sticky; +} + +beforeEach(() => { + mockGetNote.mockReset(); + mockUpdateNote.mockReset(); +}); + +describe("StickyNote", () => { + it("opens the note as touched, like opening it in the library", async () => { + const sticky = await loaded(); + expect(mockGetNote).toHaveBeenCalledWith("n1", true); + expect(sticky.note?.body).toBe("milk"); + }); + + it("flushes a typed edit and reports it saved", async () => { + const sticky = await loaded(); + mockUpdateNote.mockResolvedValue(mkNote({ body: "milk, eggs" })); + sticky.editBody("milk, eggs"); + + expect(await sticky.flush()).toBe(true); + // Based on the version it opened, so an agent's write in between is seen. + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "milk, eggs", + expectedUpdatedAt: "2026-01-01T00:00:00Z", + }); + expect(sticky.saveState).toBe("saved"); + }); + + it("reports a failed flush so the window stays open with the text", async () => { + const sticky = await loaded(); + mockUpdateNote.mockRejectedValue(new ApiError("STORAGE_ERROR", "locked")); + sticky.editBody("milk, eggs"); + + expect(await sticky.flush()).toBe(false); + expect(sticky.note?.body).toBe("milk, eggs"); + expect(sticky.saveState).toBe("failed"); + }); + + it("asks a whiteboard for its batched canvas before flushing", async () => { + const sticky = new StickyNote(); + mockGetNote.mockResolvedValueOnce(mkNote({ contentKind: "whiteboard", surfaceData: "{}" })); + await sticky.load("n1"); + mockUpdateNote.mockResolvedValue(mkNote({ contentKind: "whiteboard" })); + sticky.onBeforeFlush(() => sticky.editBoard("n1", { surfaceData: '{"a":1}', body: "a" })); + + await sticky.flush(); + + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { surfaceData: '{"a":1}', body: "a" }); + }); + + it("takes new metadata from a change event but keeps its own body", async () => { + const sticky = await loaded(); + mockUpdateNote.mockResolvedValue(mkNote()); + sticky.editBody("typing, not yet saved"); + mockGetNote.mockResolvedValueOnce(mkNote({ title: "Renamed", isPinned: true, body: "disk" })); + + await sticky.refreshMeta(); + + expect(sticky.note?.title).toBe("Renamed"); + expect(sticky.note?.isPinned).toBe(true); + expect(sticky.note?.body).toBe("typing, not yet saved"); + }); + + it("goes quiet when its note is trashed or destroyed behind it", async () => { + const trashed = await loaded(); + mockGetNote.mockResolvedValueOnce(mkNote({ isDeleted: true })); + await trashed.refreshMeta(); + expect(trashed.gone).toBe(true); + trashed.editBody("after the trash"); + expect(trashed.note?.body).toBe("milk"); + + const destroyed = await loaded(); + mockGetNote.mockRejectedValueOnce(new ApiError("NOT_FOUND", "gone")); + await destroyed.refreshMeta(); + expect(destroyed.gone).toBe(true); + // Nothing is left queued against a row that no longer exists. + expect(await destroyed.flush()).toBe(true); + expect(mockUpdateNote).not.toHaveBeenCalled(); + }); +}); + +describe("StickyNote when an agent writes its note", () => { + const write = (noteId: string): AgentActivity => ({ + at: 1, + client: "claude-code", + tool: "append_to_note", + kind: "write", + noteIds: [noteId], + noteCount: 1, + titles: ["Groceries"], + space: null, + tag: null, + query: null, + }); + + it("takes the agent's version in place when nothing is unsaved", async () => { + const sticky = await loaded(); + mockGetNote.mockResolvedValueOnce( + mkNote({ body: "milk\n- bread", updatedAt: "2026-01-01T00:05:00Z" }), + ); + + await sticky.adoptExternal([write("other"), { ...write("n1"), kind: "read" }]); + expect(sticky.note?.body).toBe("milk"); + + await sticky.adoptExternal([write("n1")]); + expect(sticky.note?.body).toBe("milk\n- bread"); + + // The next save is based on the agent's version: no conflict, no toast. + mockUpdateNote.mockResolvedValueOnce(mkNote({ body: "milk\n- bread!" })); + sticky.editBody("milk\n- bread!"); + await sticky.flush(); + expect(mockUpdateNote).toHaveBeenCalledWith("n1", { + body: "milk\n- bread!", + expectedUpdatedAt: "2026-01-01T00:05:00Z", + }); + }); + + it("keeps unsaved typing, then names the agent and offers its text back", async () => { + const sticky = await loaded(); + agents.play([write("n1")]); + sticky.editBody("milk, mine"); + await sticky.adoptExternal([write("n1")]); + expect(sticky.note?.body).toBe("milk, mine"); + + const theirs = "milk\n- bread"; + mockUpdateNote + .mockRejectedValueOnce(new ApiError("CONFLICT", "changed")) + .mockResolvedValueOnce(mkNote({ body: "milk, mine", updatedAt: "2026-01-01T00:06:00Z" })); + mockGetNote.mockResolvedValueOnce(mkNote({ body: theirs, updatedAt: "2026-01-01T00:05:00Z" })); + + expect(await sticky.flush()).toBe(true); + const toast = toasts.items.at(-1)!; + expect(toast.message).toBe("Claude Code's change to this note was replaced by your typing."); + + // Restoring is an ordinary edit in this window. + mockUpdateNote.mockResolvedValueOnce(mkNote({ body: theirs })); + toast.action!.run(); + expect(sticky.note?.body).toBe(theirs); + await sticky.flush(); + expect(mockUpdateNote).toHaveBeenLastCalledWith("n1", { + body: theirs, + expectedUpdatedAt: "2026-01-01T00:06:00Z", + }); + }); +}); diff --git a/src/lib/stores/sticky.svelte.ts b/src/lib/stores/sticky.svelte.ts new file mode 100644 index 0000000..93ba0dc --- /dev/null +++ b/src/lib/stores/sticky.svelte.ts @@ -0,0 +1,133 @@ +// One sticky window's note (Svelte 5 runes). While a note is a sticky this is +// its only editor in the app: the library shows a placeholder instead, so the +// save queue here is the note's single in-app writer, the same SaveQueue the +// library composes. +// +// Only metadata follows change events (title, pin, trash). The body follows +// an agent's write the way the library's open note does (`adoptExternal`), +// never a plain change event: re-reading it then would race this window's own +// unsaved typing. + +import { ApiError, getNote } from "$lib/api/client"; +import type { Note } from "$lib/api/types"; +import { ERROR_CODES } from "$lib/api/error-codes"; +import { friendlyMessage, GENERIC_MESSAGE } from "$lib/errors"; +import { mayHaveWritten, type AgentActivity } from "$lib/agent-activity"; +import { announceOverwrite } from "$lib/stores/agents.svelte"; +import { + SaveQueue, + type QueuedEdit, + type SaveState, +} from "$lib/stores/library/save-queue.svelte"; + +export class StickyNote { + note = $state(null); + /** The note was destroyed or moved to the Trash behind this window. */ + gone = $state(false); + error = $state(null); + + #queue = new SaveQueue({ + onPersisted: (id, updated) => { + if (this.note?.id !== id) return; + // Keep the local body and canvas: typing may have continued past this + // save, and the reply never carries the canvas. + const { body, surfaceData } = this.note; + this.note = { ...updated, body, surfaceData }; + this.error = null; + }, + onError: (e) => this.#fail(e), + onOverwrote: (id, theirs) => + announceOverwrite(id, () => { + if (this.note?.id === id) this.editBody(theirs); + }), + }); + + // A whiteboard batches canvas changes before handing them over; it + // registers here and is asked for them before every flush. + #beforeFlush = new Set<() => void>(); + + get saveState(): SaveState { + return this.#queue.stateFor(this.note?.id); + } + + onBeforeFlush(hook: () => void): () => void { + this.#beforeFlush.add(hook); + return () => this.#beforeFlush.delete(hook); + } + + async load(id: string): Promise { + try { + this.note = await getNote(id, true); + this.#queue.known(this.note); + this.gone = this.note.isDeleted; + this.error = null; + } catch (e) { + this.#fail(e); + if (e instanceof ApiError && e.code === ERROR_CODES.NOT_FOUND) this.gone = true; + } + } + + editBody(body: string): void { + if (!this.note || this.gone) return; + this.note.body = body; + this.#queue.queue(this.note.id, { body }); + } + + editBoard(id: string, edit: Required): void { + if (this.note?.id !== id || this.gone) return; + this.note.surfaceData = edit.surfaceData; + this.note.body = edit.body; + this.#queue.queue(id, edit); + } + + /** Persist everything now. True when nothing is left unsaved, which is + * the only answer that lets the window close. */ + async flush(): Promise { + for (const hook of this.#beforeFlush) hook(); + await this.#queue.flushAll(); + return this.saveState === "saved"; + } + + /** Follow a change event: take new metadata, keep this window's body. A + * note that went to the Trash or was destroyed marks the sticky gone, and + * its queued edits are dropped since there is no live row to write. */ + async refreshMeta(): Promise { + const current = this.note; + if (!current || this.gone) return; + try { + const fresh = await getNote(current.id, false); + if (this.note?.id !== current.id) return; + const { body, surfaceData } = this.note; + this.note = { ...fresh, body, surfaceData }; + if (fresh.isDeleted) this.#markGone(); + } catch (e) { + if (e instanceof ApiError && e.code === ERROR_CODES.NOT_FOUND) this.#markGone(); + } + } + + /** An agent wrote. With nothing unsaved here, the sticky takes the new + * version and the editor applies it under the caret; with unsaved typing + * it is left alone, and that save meets the agent's write through the + * version check, which offers the agent's text back. */ + async adoptExternal(entries: AgentActivity[]): Promise { + const open = this.note; + if (!open || this.gone || open.contentKind === "whiteboard") return; + if (!mayHaveWritten(entries, open.id)) return; + const shown = open.body; + const fresh = await this.#queue.readExternal( + open.id, + () => this.note?.id === open.id && this.note.body === shown && !this.gone, + ); + if (!fresh || !this.note) return; + this.note = { ...fresh, surfaceData: this.note.surfaceData }; + } + + #markGone(): void { + this.gone = true; + if (this.note) this.#queue.drop([this.note.id]); + } + + #fail(e: unknown): void { + this.error = e instanceof ApiError ? friendlyMessage(e.code, e.message) : GENERIC_MESSAGE; + } +} diff --git a/src/lib/stores/update-space.ts b/src/lib/stores/update-space.ts new file mode 100644 index 0000000..1d5932c --- /dev/null +++ b/src/lib/stores/update-space.ts @@ -0,0 +1,31 @@ +// The update notification as a place in the sidebar: a synthetic Space whose +// two notes are the version jump and the release notes. It is derived from the +// updater and never stored, so it cannot be searched, tagged, exported, or +// synced, and it vanishes the moment there is nothing to install. + +import { updater } from "$lib/stores/updater.svelte"; +import { buildUpdateNotes } from "$lib/update/space"; +import type { Note } from "$lib/api/types"; + +function view() { + return { + version: updater.version as string, + currentVersion: updater.currentVersion as string, + date: updater.date, + notes: updater.notes, + }; +} + +export const updateSpace = { + /** True while the notification belongs in the sidebar. */ + get visible(): boolean { + return updater.pendingUpdate; + }, + get notes(): Note[] { + return updater.pendingUpdate ? buildUpdateNotes(view()) : []; + }, + /** The update note itself: the one the welcome pill opens. */ + get leadNote(): Note | null { + return updater.pendingUpdate ? (buildUpdateNotes(view())[0] ?? null) : null; + }, +}; diff --git a/src/lib/stores/updater.svelte.test.ts b/src/lib/stores/updater.svelte.test.ts new file mode 100644 index 0000000..13f043c --- /dev/null +++ b/src/lib/stores/updater.svelte.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { check, type Update } from "@tauri-apps/plugin-updater"; +import { fetchUpdateSizeDelta } from "$lib/update/release-size"; + +vi.mock("@tauri-apps/plugin-updater", () => ({ check: vi.fn() })); +vi.mock("$lib/update/release-size", () => ({ fetchUpdateSizeDelta: vi.fn() })); + +const mockCheck = vi.mocked(check); +const mockDelta = vi.mocked(fetchUpdateSizeDelta); + +function mkUpdate(overrides: Partial = {}): Update { + return { + version: "0.10.0", + currentVersion: "0.9.0", + body: "### Added\n- Something new", + date: "2026-09-24T00:00:00.000Z", + downloadAndInstall: vi.fn(async () => {}), + ...overrides, + } as unknown as Update; +} + +async function load() { + const mod = await import("$lib/stores/updater.svelte"); + return mod.updater; +} + +async function toastsShown(): Promise { + const { toasts } = await import("$lib/stores/toasts.svelte"); + return toasts.items.map((t) => t.message); +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mockCheck.mockReset(); + mockDelta.mockReset().mockResolvedValue(null); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("manual vs automatic checks", () => { + it("answers a manual check that finds nothing with a toast", async () => { + mockCheck.mockResolvedValue(null); + const updater = await load(); + + await updater.checkNow({ manual: true }); + + expect(updater.status).toBe("uptodate"); + expect(updater.pendingUpdate).toBe(false); + expect(await toastsShown()).toContain("InstantNotes is up to date"); + }); + + it("stays silent when an automatic check finds nothing", async () => { + mockCheck.mockResolvedValue(null); + const updater = await load(); + + await updater.checkNow(); + + expect(updater.status).toBe("idle"); + expect(await toastsShown()).toEqual([]); + }); + + it("surfaces a manual check failure, and stays silent on an automatic one", async () => { + mockCheck.mockRejectedValue(new Error("offline")); + const updater = await load(); + + await updater.checkNow({ manual: true }); + expect(updater.status).toBe("error"); + expect(updater.pendingUpdate).toBe(false); + expect(await toastsShown()).toHaveLength(1); + + mockCheck.mockRejectedValue(new Error("offline")); + await updater.checkNow(); + expect(updater.status).toBe("idle"); + }); +}); + +describe("an offered update", () => { + it("turns on the Space and carries the manifest through", async () => { + mockCheck.mockResolvedValue(mkUpdate()); + const updater = await load(); + + await updater.checkNow(); + await vi.advanceTimersByTimeAsync(0); + + expect(updater.status).toBe("available"); + expect(updater.pendingUpdate).toBe(true); + expect(updater.version).toBe("0.10.0"); + expect(updater.currentVersion).toBe("0.9.0"); + expect(updater.notes).toBe("### Added\n- Something new"); + expect(updater.date).toBe("2026-09-24T00:00:00.000Z"); + }); + + it("loads the size delta in the background and keeps it when known", async () => { + mockCheck.mockResolvedValue(mkUpdate()); + mockDelta.mockResolvedValue(1_200_000); + const updater = await load(); + + await updater.checkNow(); + await vi.advanceTimersByTimeAsync(0); + + expect(mockDelta).toHaveBeenCalledWith( + expect.objectContaining({ currentVersion: "0.9.0", version: "0.10.0" }), + ); + expect(updater.sizeDelta).toBe(1_200_000); + expect(updater.deltaState).toBe("ready"); + }); + + it("reports an unavailable delta without failing the check", async () => { + mockCheck.mockResolvedValue(mkUpdate()); + mockDelta.mockResolvedValue(null); + const updater = await load(); + + await updater.checkNow(); + await vi.advanceTimersByTimeAsync(0); + + expect(updater.deltaState).toBe("unavailable"); + expect(updater.status).toBe("available"); + }); + + it("reports progress while installing and lands on ready", async () => { + const update = mkUpdate({ + downloadAndInstall: vi.fn(async (onEvent) => { + onEvent({ event: "Started", data: { contentLength: 100 } }); + onEvent({ event: "Progress", data: { chunkLength: 25 } }); + onEvent({ event: "Progress", data: { chunkLength: 25 } }); + }), + }); + mockCheck.mockResolvedValue(update); + const updater = await load(); + await updater.checkNow(); + + await updater.downloadAndInstall(); + + expect(updater.progress).toBe(0.5); + expect(updater.status).toBe("ready"); + expect(updater.pendingUpdate).toBe(true); + }); + + it("keeps the Space up when the install fails, so Try again is reachable", async () => { + const update = mkUpdate({ + downloadAndInstall: vi.fn(async () => { + throw new Error("network"); + }), + }); + mockCheck.mockResolvedValue(update); + const updater = await load(); + await updater.checkNow(); + + await updater.downloadAndInstall(); + + expect(updater.status).toBe("error"); + expect(updater.error).toBe("network"); + expect(updater.pendingUpdate).toBe(true); + }); +}); + +describe("acknowledge", () => { + it("takes the Space down, and a later check does not bring it back", async () => { + const update = mkUpdate(); + mockCheck.mockResolvedValue(update); + const updater = await load(); + await updater.checkNow(); + await updater.downloadAndInstall(); + expect(updater.pendingUpdate).toBe(true); + + updater.acknowledge(); + expect(updater.status).toBe("idle"); + expect(updater.pendingUpdate).toBe(false); + + await updater.checkNow(); + expect(updater.pendingUpdate).toBe(false); + }); +}); diff --git a/src/lib/stores/updater.svelte.ts b/src/lib/stores/updater.svelte.ts index 01ef4f1..21fd159 100644 --- a/src/lib/stores/updater.svelte.ts +++ b/src/lib/stores/updater.svelte.ts @@ -1,17 +1,17 @@ // Self-update state (Svelte 5 runes). Checks GitHub Releases via the Tauri // updater plugin. Automatic checks stay silent unless an update exists (the -// happy path is invisible); a manual check — from the tray "Check for -// Updates…" — surfaces the outcome either way, including "up to date" and -// errors, so the user is never left guessing. The UI (UpdatePanel) reads this -// store to show exactly what is being updated and to what version. +// happy path is invisible); a manual check - from the tray "Check for +// Updates…" - answers with a toast either way, so the user is never left +// guessing. +// +// An available update is surfaced as a synthetic Space (see +// `$lib/update/space.ts`), so this store is the single source for the versions, +// the release notes, the install progress, and the best-effort download-size +// delta. Nothing here writes to SQLite or the vault. -import { relaunch } from "@tauri-apps/plugin-process"; import { check, type Update } from "@tauri-apps/plugin-updater"; -import { getSetting, setSetting, deleteSetting } from "$lib/api/client"; -import { library } from "$lib/stores/library.svelte"; -import { snoozeDeadline, type SnoozeKind } from "$lib/updater-snooze"; - -export type { SnoozeKind } from "$lib/updater-snooze"; +import { toasts } from "$lib/stores/toasts.svelte"; +import { fetchUpdateSizeDelta } from "$lib/update/release-size"; export type UpdateStatus = | "idle" @@ -22,8 +22,10 @@ export type UpdateStatus = | "ready" | "error"; +/** Whether the offered build's size against the running one is known yet. */ +export type DeltaState = "idle" | "loading" | "ready" | "unavailable"; + const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; -const KEY_SNOOZE = "updater.snoozeUntil"; class UpdaterStore { status = $state("idle"); @@ -33,25 +35,45 @@ class UpdaterStore { currentVersion = $state(null); /** Release notes for the available update, verbatim from the manifest. */ notes = $state(null); + /** The release's own date, used to date the synthetic notes. */ + date = $state(null); // 0..1 while downloading, null when total size is unknown. progress = $state(null); error = $state(null); + /** Offered size minus running size, in bytes; null when unknown. */ + sizeDelta = $state(null); + deltaState = $state("idle"); #update: Update | null = null; #timer: ReturnType | null = null; - // Persisted epoch-ms deadline for a snoozed reminder; null when not snoozed. - #snoozeUntil: number | null = null; - // "On next launch" - suppress for this process only, never persisted. - #suppressThisSession = false; - #snoozeLoaded = false; + // A version the user answered with "Ok": kept off the sidebar for the rest + // of this run. The update is installable until the app relaunches, so without + // this the next check would surface the same notification again. + #acknowledgedVersion: string | null = null; + + /** + * Whether the update Space should be showing: an update was found and the + * user has not answered it. Covers every state of the install once found, so + * the Space never blinks out mid-download or on a failed install. + */ + get pendingUpdate(): boolean { + if (this.version == null || this.version === this.#acknowledgedVersion) { + return false; + } + return ( + this.status === "available" || + this.status === "checking" || + this.status === "downloading" || + this.status === "ready" || + this.status === "error" + ); + } /** Check once now, then every RECHECK_INTERVAL_MS while running. */ start() { if (this.#timer) return; this.#timer = setInterval(() => void this.checkNow(), RECHECK_INTERVAL_MS); - // Honor a persisted snooze before the first check so "remind me later" - // carries across launches. - void this.#loadSnooze().then(() => this.checkNow()); + void this.checkNow(); } stop() { @@ -63,17 +85,14 @@ class UpdaterStore { /** * Look for a newer release. Automatic checks (`manual` false) stay silent on - * "no update" and on failure — updates are a suggestion, never an - * obstruction. A manual check reports both outcomes so the user gets an - * answer. + * "no update" and on failure - updates are a suggestion, never an + * obstruction. A manual check reports both outcomes with a toast, since there + * is no dialog to answer into. */ async checkNow(opts: { manual?: boolean } = {}) { const manual = opts.manual ?? false; - // Never interrupt a download or a pending restart. + // Never interrupt a download or a pending acknowledgment. if (this.status === "downloading" || this.status === "ready") return; - // A manual check (tray "Check for Updates…") always runs; automatic checks - // stay quiet while the reminder is snoozed. - if (!manual && this.#isSnoozed()) return; this.status = "checking"; this.error = null; try { @@ -83,14 +102,20 @@ class UpdaterStore { this.version = update.version; this.currentVersion = update.currentVersion; this.notes = update.body?.trim() || null; + this.date = update.date ?? null; this.status = "available"; + void this.#loadSizeDelta(); } else { this.status = manual ? "uptodate" : "idle"; + if (manual) toasts.show("InstantNotes is up to date"); } } catch (e) { if (manual) { this.error = e instanceof Error ? e.message : String(e); this.status = "error"; + toasts.show( + "Couldn't check for updates. Check your connection and try again.", + ); } else { // Offline, private repo, or no manifest yet: stay silent. this.status = "idle"; @@ -125,63 +150,35 @@ class UpdaterStore { } } - async restart() { - if (this.status !== "ready") return; - // Flush here, before relaunch: the Rust quit interceptor deliberately - // lets the restart's exit request pass untouched (holding it would - // strand the freshly installed update), so this is the only place the - // pending edits can be saved on the update path. - await library.flushPendingEdits(); - await relaunch(); - } - /** - * Snooze the "update available" reminder. Automatic checks stay quiet until - * the deadline (or, for "on next launch", until the app restarts). The - * pending update is kept, so it re-surfaces once the snooze lapses. + * The user answered the notification's "Ok". The update is installed and + * applies on the next launch; take the Space down now rather than leaving it + * up as a done screen. */ - snooze(kind: SnoozeKind) { - const until = snoozeDeadline(kind, Date.now()); - this.#snoozeUntil = until; - if (until != null) { - this.#suppressThisSession = false; - void setSetting(KEY_SNOOZE, until); - } else { - // Session-only: don't persist a deadline. - this.#suppressThisSession = true; - void deleteSetting(KEY_SNOOZE); - } - if (this.status === "available") this.status = "idle"; - } - - #isSnoozed(): boolean { - if (this.#suppressThisSession) return true; - return this.#snoozeUntil != null && Date.now() < this.#snoozeUntil; - } - - async #loadSnooze() { - if (this.#snoozeLoaded) return; - this.#snoozeLoaded = true; - try { - const until = await getSetting(KEY_SNOOZE); - if (typeof until !== "number") return; - if (Date.now() < until) { - this.#snoozeUntil = until; - } else { - // Lapsed - drop the stale deadline so it can't suppress forever. - void deleteSetting(KEY_SNOOZE); - } - } catch { - // Settings are best-effort; default to not snoozed. - } + acknowledge() { + this.#acknowledgedVersion = this.version; + this.status = "idle"; + this.error = null; } - /** Clear a transient result (up-to-date / error) so the indicator hides. */ - dismiss() { - if (this.status === "uptodate" || this.status === "error") { - this.status = "idle"; - this.error = null; + async #loadSizeDelta(): Promise { + const version = this.version; + const currentVersion = this.currentVersion; + if (!version || !currentVersion) { + this.deltaState = "unavailable"; + return; } + this.deltaState = "loading"; + this.sizeDelta = null; + const delta = await fetchUpdateSizeDelta({ + currentVersion, + version, + userAgent: navigator.userAgent, + }); + // A newer check superseded this one; its own load owns the field now. + if (this.version !== version) return; + this.sizeDelta = delta; + this.deltaState = delta == null ? "unavailable" : "ready"; } } diff --git a/src/lib/update/release-size.test.ts b/src/lib/update/release-size.test.ts new file mode 100644 index 0000000..44c1161 --- /dev/null +++ b/src/lib/update/release-size.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchUpdateSizeDelta, selectAsset } from "./release-size"; + +const MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15"; +const WIN_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; +const LINUX_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"; + +const MAC_ASSETS = [ + { name: "InstantNotes_aarch64.app.tar.gz", size: 4_000_000 }, + { name: "InstantNotes_aarch64.app.tar.gz.sig", size: 400 }, + { name: "InstantNotes_0.9.0_aarch64.dmg", size: 9_000_000 }, +]; +const WIN_ASSETS = [ + { name: "InstantNotes_0.9.0_x64-setup.exe", size: 3_000_000 }, + { name: "InstantNotes_0.9.0_x64-setup.exe.sig", size: 400 }, +]; +const LINUX_ASSETS = [ + { name: "InstantNotes_0.9.0_amd64.AppImage", size: 80_000_000 }, + { name: "InstantNotes_0.9.0_amd64.AppImage.sig", size: 400 }, +]; + +describe("selectAsset", () => { + it("picks the updater artifact, not its signature or an installer", () => { + expect(selectAsset(MAC_ASSETS, MAC_UA)?.size).toBe(4_000_000); + expect(selectAsset(WIN_ASSETS, WIN_UA)?.size).toBe(3_000_000); + expect(selectAsset(LINUX_ASSETS, LINUX_UA)?.size).toBe(80_000_000); + }); + + it("returns null when nothing matches", () => { + expect(selectAsset(MAC_ASSETS, WIN_UA)).toBeNull(); + expect(selectAsset([], MAC_UA)).toBeNull(); + }); +}); + +/** A fetch stand-in keyed by the release tag in the URL. */ +function fakeFetch(byTag: Record) { + return vi.fn(async (url: string) => { + const tag = Object.keys(byTag).find((t) => url.includes(`/tags/${t}`)); + return { + ok: tag !== undefined, + json: async () => ({ assets: tag ? byTag[tag] : [] }), + } as Response; + }) as unknown as typeof fetch; +} + +describe("fetchUpdateSizeDelta", () => { + it("diffs the running and offered updater artifacts", async () => { + const fetchImpl = fakeFetch({ + "v0.9.0": [{ name: "InstantNotes_aarch64.app.tar.gz", size: 4_000_000 }], + "v0.10.0": [{ name: "InstantNotes_aarch64.app.tar.gz", size: 5_200_000 }], + }); + const delta = await fetchUpdateSizeDelta({ + currentVersion: "0.9.0", + version: "0.10.0", + userAgent: MAC_UA, + fetchImpl, + }); + expect(delta).toBe(1_200_000); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("is null when either release has no matching artifact", async () => { + const fetchImpl = fakeFetch({ + "v0.10.0": [{ name: "InstantNotes_aarch64.app.tar.gz", size: 5_200_000 }], + }); + expect( + await fetchUpdateSizeDelta({ + currentVersion: "0.9.0", + version: "0.10.0", + userAgent: MAC_UA, + fetchImpl, + }), + ).toBeNull(); + }); + + it("is null, never a throw, when the network fails", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("offline"); + }) as unknown as typeof fetch; + expect( + await fetchUpdateSizeDelta({ + currentVersion: "0.9.0", + version: "0.10.0", + userAgent: MAC_UA, + fetchImpl, + }), + ).toBeNull(); + }); +}); diff --git a/src/lib/update/release-size.ts b/src/lib/update/release-size.ts new file mode 100644 index 0000000..9b7af12 --- /dev/null +++ b/src/lib/update/release-size.ts @@ -0,0 +1,77 @@ +// How much bigger or smaller the offered build is than the one running. +// +// The updater plugin only reports a size once a download starts, so the delta +// is read from the published GitHub release assets instead: the running +// version's updater artifact against the offered one. This is a nicety, never a +// gate - any failure (offline, rate limit, a release whose asset was named +// differently) resolves to null and the UI simply omits the line. + +const REPO = "Jam-Sw/InstantNotes"; + +export interface ReleaseAsset { + name: string; + size: number; +} + +/** The updater artifact for this platform, by the names tauri-action uploads. */ +export function assetMatcher(userAgent: string): (name: string) => boolean { + if (/mac|darwin/i.test(userAgent)) return (n) => n.endsWith(".app.tar.gz"); + if (/win/i.test(userAgent)) return (n) => n.endsWith("-setup.exe"); + return (n) => n.endsWith(".AppImage"); +} + +/** The one updater artifact in a release, skipping its `.sig` sidecar. */ +export function selectAsset( + assets: ReleaseAsset[], + userAgent: string, +): ReleaseAsset | null { + const matches = assetMatcher(userAgent); + return ( + assets.find((a) => !a.name.endsWith(".sig") && matches(a.name)) ?? null + ); +} + +interface GhRelease { + assets?: { name?: string; size?: number }[]; +} + +async function fetchReleaseAssets( + tag: string, + fetchImpl: typeof fetch, +): Promise { + const res = await fetchImpl( + `https://api.github.com/repos/${REPO}/releases/tags/${tag}`, + { headers: { Accept: "application/vnd.github+json" } }, + ); + if (!res.ok) return []; + const data = (await res.json()) as GhRelease; + return (data.assets ?? []).map((a) => ({ + name: a.name ?? "", + size: a.size ?? 0, + })); +} + +/** + * Offered size minus running size, in bytes. `null` when either side cannot be + * resolved for this platform, or on any network or parsing failure. + */ +export async function fetchUpdateSizeDelta(opts: { + currentVersion: string; + version: string; + userAgent: string; + fetchImpl?: typeof fetch; +}): Promise { + const doFetch = opts.fetchImpl ?? fetch; + try { + const [older, newer] = await Promise.all([ + fetchReleaseAssets(`v${opts.currentVersion}`, doFetch), + fetchReleaseAssets(`v${opts.version}`, doFetch), + ]); + const from = selectAsset(older, opts.userAgent); + const to = selectAsset(newer, opts.userAgent); + if (!from || !to) return null; + return to.size - from.size; + } catch { + return null; + } +} diff --git a/src/lib/update/space.test.ts b/src/lib/update/space.test.ts new file mode 100644 index 0000000..1cc2e7a --- /dev/null +++ b/src/lib/update/space.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + buildUpdateNotes, + isReleaseNotesNoteId, + isUpdateNoteId, + isUpdateSpaceId, + isVirtualNoteId, + RELEASE_NOTES_NOTE_ID, + releaseNotesTitle, + sizeDeltaLine, + UPDATE_NOTE_ID, + UPDATE_SPACE_ID, + updateNoteTitle, +} from "./space"; + +const view = { + version: "0.10.0", + currentVersion: "0.9.0", + date: "2026-09-24T00:00:00.000Z", + notes: "### Added\n- Something new", +}; + +describe("synthetic ids", () => { + it("recognizes exactly the update Space and its notes", () => { + expect(isUpdateSpaceId(UPDATE_SPACE_ID)).toBe(true); + expect(isUpdateSpaceId("ws-1")).toBe(false); + expect(isUpdateSpaceId(null)).toBe(false); + + expect(isUpdateNoteId(UPDATE_NOTE_ID)).toBe(true); + expect(isReleaseNotesNoteId(RELEASE_NOTES_NOTE_ID)).toBe(true); + expect(isVirtualNoteId(UPDATE_NOTE_ID)).toBe(true); + expect(isVirtualNoteId(RELEASE_NOTES_NOTE_ID)).toBe(true); + expect(isVirtualNoteId("n1")).toBe(false); + expect(isVirtualNoteId(undefined)).toBe(false); + }); +}); + +describe("titles", () => { + it("names the update note by the version jump", () => { + expect(updateNoteTitle("0.9.0", "0.10.0")).toBe("update 0.9.0 → 0.10.0"); + }); + + it("names the release-notes note by the new version", () => { + expect(releaseNotesTitle("0.10.0")).toBe("What's new in 0.10.0"); + }); +}); + +describe("buildUpdateNotes", () => { + it("returns the update note first, then the release notes", () => { + const notes = buildUpdateNotes(view); + expect(notes.map((n) => n.id)).toEqual([ + UPDATE_NOTE_ID, + RELEASE_NOTES_NOTE_ID, + ]); + expect(notes[0].title).toBe("update 0.9.0 → 0.10.0"); + expect(notes[1].title).toBe("What's new in 0.10.0"); + expect(notes[1].body).toBe(view.notes); + // Ordinary notes in every way the list cares about. + expect(notes.every((n) => n.contentKind === "document")).toBe(true); + expect(notes.every((n) => !n.isArchived && !n.isDeleted)).toBe(true); + }); + + it("gives the update note a preview even when there are no release notes", () => { + const notes = buildUpdateNotes({ ...view, notes: null }); + expect(notes[0].body.length).toBeGreaterThan(0); + expect(notes[1].body).toBe(""); + }); +}); + +describe("size delta", () => { + it("phrases the delta against the running version", () => { + expect(sizeDeltaLine(1_258_291, "0.9.0")).toBe( + "1.2 MB larger than v0.9.0", + ); + expect(sizeDeltaLine(-348_160, "0.9.0")).toBe( + "340 KB smaller than v0.9.0", + ); + expect(sizeDeltaLine(0, "0.9.0")).toBe("the same size as v0.9.0"); + expect(sizeDeltaLine(null, "0.9.0")).toBeNull(); + }); +}); diff --git a/src/lib/update/space.ts b/src/lib/update/space.ts new file mode 100644 index 0000000..bffb2d5 --- /dev/null +++ b/src/lib/update/space.ts @@ -0,0 +1,98 @@ +// The synthetic Space that carries an available update. Nothing here reads the +// store or the API: the Space and its two notes are derived from the updater's +// state and rendered through the ordinary sidebar and note rows, so the +// notification never becomes user data and cannot reach the vault. Pure, so it +// stays unit-testable without runes. + +import type { Note } from "$lib/api/types"; +import { formatBytes } from "$lib/format"; + +/** Sentinel ids no real workspace or note can hold (ids are UUIDs). */ +export const UPDATE_SPACE_ID = "update-space"; +export const UPDATE_NOTE_ID = "update-note"; +export const RELEASE_NOTES_NOTE_ID = "update-release-notes"; + +export const UPDATE_SPACE_NAME = "Update"; + +// The Space has no real birth; when the manifest carries no release date, fall +// back to the epoch so the date helpers render something stable rather than +// "now" (which would make the notification look newly changed on every read). +const EPOCH = "1970-01-01T00:00:00.000Z"; + +export function isUpdateSpaceId(id: string | null | undefined): boolean { + return id === UPDATE_SPACE_ID; +} + +export function isUpdateNoteId(id: string | null | undefined): boolean { + return id === UPDATE_NOTE_ID; +} + +export function isReleaseNotesNoteId(id: string | null | undefined): boolean { + return id === RELEASE_NOTES_NOTE_ID; +} + +/** Any note that only exists while an update is offered. */ +export function isVirtualNoteId(id: string | null | undefined): boolean { + return isUpdateNoteId(id) || isReleaseNotesNoteId(id); +} + +/** "update 0.9.0 → 0.10.0" - the version jump, the note's whole subject. */ +export function updateNoteTitle( + currentVersion: string, + version: string, +): string { + return `update ${currentVersion} → ${version}`; +} + +/** "What's new in 0.10.0" - the release notes, which are just a note. */ +export function releaseNotesTitle(version: string): string { + return `What's new in ${version}`; +} + +export interface UpdateView { + version: string; + currentVersion: string; + date?: string | null; + notes?: string | null; +} + +export function buildUpdateNotes(view: UpdateView): Note[] { + const at = view.date ?? EPOCH; + const base = { + createdAt: at, + updatedAt: at, + isPinned: false, + isArchived: false, + isDeleted: false, + contentKind: "document" as const, + }; + const notes = view.notes?.trim() ?? ""; + return [ + { + ...base, + id: UPDATE_NOTE_ID, + title: updateNoteTitle(view.currentVersion, view.version), + // Only the list row ever shows this; opening the note renders the update + // page, not the body. + body: `Update InstantNotes from ${view.currentVersion} to ${view.version}.`, + }, + { + ...base, + id: RELEASE_NOTES_NOTE_ID, + title: releaseNotesTitle(view.version), + body: notes, + }, + ]; +} + +/** "1.2 MB larger than v0.9.0", or null when the size is unknown. */ +export function sizeDeltaLine( + bytes: number | null, + fromVersion: string, +): string | null { + if (bytes == null || !Number.isFinite(bytes)) return null; + if (bytes === 0) return `the same size as v${fromVersion}`; + return `${formatBytes(Math.abs(bytes))} ${ + bytes > 0 ? "larger" : "smaller" + } than v${fromVersion}`; +} diff --git a/src/lib/updater-snooze.test.ts b/src/lib/updater-snooze.test.ts deleted file mode 100644 index b3d57d0..0000000 --- a/src/lib/updater-snooze.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { snoozeDeadline, DAY_MS } from "./updater-snooze"; - -describe("snoozeDeadline", () => { - const now = 1_000_000; - - it("pushes 'tomorrow' out one day", () => { - expect(snoozeDeadline("tomorrow", now)).toBe(now + DAY_MS); - }); - - it("pushes 'week' out seven days", () => { - expect(snoozeDeadline("week", now)).toBe(now + 7 * DAY_MS); - }); - - it("returns null for 'launch' - session-only, no persisted deadline", () => { - expect(snoozeDeadline("launch", now)).toBeNull(); - }); -}); diff --git a/src/lib/updater-snooze.ts b/src/lib/updater-snooze.ts deleted file mode 100644 index 45d68ec..0000000 --- a/src/lib/updater-snooze.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Pure "remind me later" cadence math for the update dialog, kept out of the -// rune store so it stays unit-testable in the plain-node vitest environment. - -/** The cadences offered by the update dialog's "Later ▾" menu. */ -export type SnoozeKind = "tomorrow" | "week" | "launch"; - -export const DAY_MS = 24 * 60 * 60 * 1000; - -/** - * When a reminder snoozed at `now` should next surface. `"launch"` has no fixed - * deadline - it suppresses for the session only - so it returns null. - */ -export function snoozeDeadline(kind: SnoozeKind, now: number): number | null { - switch (kind) { - case "tomorrow": - return now + DAY_MS; - case "week": - return now + 7 * DAY_MS; - case "launch": - return null; - } -} diff --git a/src/lib/vault-export.test.ts b/src/lib/vault-export.test.ts new file mode 100644 index 0000000..c27fe1f --- /dev/null +++ b/src/lib/vault-export.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { open } from "@tauri-apps/plugin-dialog"; +import { exportVault } from "$lib/api/client"; +import { exportVaultToFolder } from "./vault-export"; + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); +vi.mock("$lib/api/client", () => ({ + exportVault: vi.fn(), +})); + +beforeEach(() => { + vi.mocked(open).mockReset(); + vi.mocked(exportVault).mockReset(); +}); + +describe("exportVaultToFolder", () => { + it("writes to the folder the user picked", async () => { + vi.mocked(open).mockResolvedValue("/Users/me/notes-vault"); + vi.mocked(exportVault).mockResolvedValue(undefined); + + const result = await exportVaultToFolder(); + + expect(exportVault).toHaveBeenCalledWith("/Users/me/notes-vault"); + expect(result).toEqual({ ok: true }); + }); + + it("reports cancellation without calling the backend when no folder is picked", async () => { + vi.mocked(open).mockResolvedValue(null); + + const result = await exportVaultToFolder(); + + expect(exportVault).not.toHaveBeenCalled(); + expect(result).toEqual({ cancelled: true }); + }); + + it("surfaces the backend error message on failure", async () => { + vi.mocked(open).mockResolvedValue("/dest"); + vi.mocked(exportVault).mockRejectedValue(new Error("disk full")); + + const result = await exportVaultToFolder(); + + expect(result).toEqual({ ok: false, error: "disk full" }); + }); + + it("surfaces a dialog failure without calling the backend", async () => { + vi.mocked(open).mockRejectedValue(new Error("dialog unavailable")); + + const result = await exportVaultToFolder(); + + expect(exportVault).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: false, error: "dialog unavailable" }); + }); +}); diff --git a/src/lib/vault-export.ts b/src/lib/vault-export.ts new file mode 100644 index 0000000..2d82ace --- /dev/null +++ b/src/lib/vault-export.ts @@ -0,0 +1,31 @@ +// Stage 1 vault export: prompt for a destination folder, then hand off to +// the Rust writer (openspec/changes/feat-portable-vault-sync, SEQUENCE.md +// unit 7). One-way and read-only from the app's perspective: nothing reads +// this folder back yet, so there is nothing here to validate before writing, +// unlike theme import (src/lib/themes/share.ts). + +import { open } from "@tauri-apps/plugin-dialog"; +import { exportVault } from "$lib/api/client"; + +export type ExportResult = + | { ok: true } + | { ok: false; error: string } + | { cancelled: true }; + +/** Prompt for a folder and write the whole library into it as a vault. */ +export async function exportVaultToFolder(): Promise { + let dest: string | string[] | null; + try { + dest = await open({ directory: true, multiple: false }); + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : "Could not open the folder picker." }; + } + if (!dest || typeof dest !== "string") return { cancelled: true }; + + try { + await exportVault(dest); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : "Could not export the vault." }; + } +} diff --git a/src/lib/vault-mirror.test.ts b/src/lib/vault-mirror.test.ts new file mode 100644 index 0000000..a0ea987 --- /dev/null +++ b/src/lib/vault-mirror.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { open } from "@tauri-apps/plugin-dialog"; +import { chooseVaultFolder, describeVaultReport } from "./vault-mirror"; +import type { VaultReport } from "$lib/api/types"; + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + +beforeEach(() => { + vi.mocked(open).mockReset(); +}); + +function report(overrides: Partial = {}): VaultReport { + return { + checked: 0, + missing: [], + diverged: [], + orphans: [], + pending: 0, + manifestOk: true, + ...overrides, + }; +} + +describe("chooseVaultFolder", () => { + it("returns the folder the user picked", async () => { + vi.mocked(open).mockResolvedValue("/Users/me/Vault"); + expect(await chooseVaultFolder()).toEqual({ path: "/Users/me/Vault" }); + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ directory: true, multiple: false }), + ); + }); + + it("reports cancellation when no folder is picked", async () => { + vi.mocked(open).mockResolvedValue(null); + expect(await chooseVaultFolder()).toEqual({ cancelled: true }); + }); + + it("surfaces a dialog failure", async () => { + vi.mocked(open).mockRejectedValue(new Error("no dialog")); + expect(await chooseVaultFolder()).toEqual({ error: "no dialog" }); + }); +}); + +describe("describeVaultReport", () => { + it("says everything matches for a clean vault", () => { + expect(describeVaultReport(report({ checked: 412 }))).toEqual([ + "All 412 notes match their files.", + ]); + }); + + it("uses the singular for one note", () => { + expect(describeVaultReport(report({ checked: 1 }))).toEqual([ + "The 1 note matches its file.", + ]); + }); + + it("mentions notes still being written", () => { + expect(describeVaultReport(report({ checked: 10, pending: 2 }))).toEqual([ + "All 10 notes match their files.", + "2 more are still being written.", + ]); + }); + + it("lists each kind of difference, naming a few files", () => { + const lines = describeVaultReport( + report({ + checked: 20, + diverged: ["A.md"], + missing: ["B.md", "C.md"], + orphans: ["1.md", "2.md", "3.md", "4.md", "5.md"], + manifestOk: false, + }), + ); + expect(lines).toEqual([ + "1 file was edited outside InstantNotes: A.md", + "2 note files are missing: B.md, C.md", + "5 files are not notes InstantNotes wrote: 1.md, 2.md, 3.md and 2 more", + "instantnotes.yaml does not match your tags and Spaces.", + ]); + }); +}); diff --git a/src/lib/vault-mirror.ts b/src/lib/vault-mirror.ts new file mode 100644 index 0000000..74cda43 --- /dev/null +++ b/src/lib/vault-mirror.ts @@ -0,0 +1,54 @@ +// The live vault mirror (stage 2 of openspec/changes/feat-portable-vault-sync): +// picking its folder, and turning a verify report into sentences for the +// Vault settings page. The writing itself happens in Rust. + +import { pickFolder, type FolderChoice } from "$lib/folder-picker"; +import type { VaultReport } from "$lib/api/types"; + +/** Prompt for the folder the mirror writes into. */ +export function chooseVaultFolder(): Promise { + return pickFolder({ title: "Choose a vault folder" }); +} + +const NAMED = 3; + +function names(paths: string[]): string { + const shown = paths.slice(0, NAMED).join(", "); + const rest = paths.length - NAMED; + return rest > 0 ? `${shown} and ${rest} more` : shown; +} + +function count(n: number, one: string, many: string): string { + return `${n} ${n === 1 ? one : many}`; +} + +/** One sentence per finding, clean result first-class rather than silent. */ +export function describeVaultReport(r: VaultReport): string[] { + const lines: string[] = []; + if (r.diverged.length) { + lines.push( + `${count(r.diverged.length, "file was", "files were")} edited outside InstantNotes: ${names(r.diverged)}`, + ); + } + if (r.missing.length) { + lines.push( + `${count(r.missing.length, "note file is", "note files are")} missing: ${names(r.missing)}`, + ); + } + if (r.orphans.length) { + lines.push( + `${count(r.orphans.length, "file is", "files are")} not notes InstantNotes wrote: ${names(r.orphans)}`, + ); + } + if (!r.manifestOk) lines.push("instantnotes.yaml does not match your tags and Spaces."); + + if (lines.length === 0) { + lines.push( + r.checked === 1 + ? "The 1 note matches its file." + : `All ${r.checked} notes match their files.`, + ); + } + if (r.pending > 0) lines.push(`${r.pending} more ${r.pending === 1 ? "is" : "are"} still being written.`); + return lines; +} diff --git a/src/lib/whiteboard/convert.test.ts b/src/lib/whiteboard/convert.test.ts new file mode 100644 index 0000000..b3083af --- /dev/null +++ b/src/lib/whiteboard/convert.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { confirmConvertToWhiteboard } from "./convert"; +import { library } from "$lib/stores/library.svelte"; +import { confirmDialog } from "$lib/stores/confirm.svelte"; + +vi.mock("$lib/stores/library.svelte", () => ({ + library: { selected: null, convertToWhiteboard: vi.fn() }, +})); +vi.mock("$lib/stores/confirm.svelte", () => ({ + confirmDialog: { ask: vi.fn() }, +})); + +const lib = library as unknown as { + selected: Record | null; + convertToWhiteboard: ReturnType; +}; +const ask = vi.mocked(confirmDialog.ask); + +beforeEach(() => { + lib.convertToWhiteboard.mockReset(); + ask.mockReset(); + lib.selected = { id: "n1", isDeleted: false, contentKind: "document" }; +}); + +describe("confirmConvertToWhiteboard", () => { + it("says the text moves onto the board and that there is no way back", async () => { + ask.mockResolvedValue(false); + await confirmConvertToWhiteboard(); + const { body } = ask.mock.calls[0][0]; + expect(body).toMatch(/text moves onto the board/); + expect(body).toMatch(/can't be turned back/); + }); + + it("converts only when confirmed", async () => { + ask.mockResolvedValue(false); + expect(await confirmConvertToWhiteboard()).toBe(false); + expect(lib.convertToWhiteboard).not.toHaveBeenCalled(); + + ask.mockResolvedValue(true); + expect(await confirmConvertToWhiteboard()).toBe(true); + expect(lib.convertToWhiteboard).toHaveBeenCalledTimes(1); + }); + + it("does not ask about a board or a trashed note", async () => { + lib.selected = { id: "b1", isDeleted: false, contentKind: "whiteboard" }; + expect(await confirmConvertToWhiteboard()).toBe(false); + lib.selected = { id: "n1", isDeleted: true, contentKind: "document" }; + expect(await confirmConvertToWhiteboard()).toBe(false); + expect(ask).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/whiteboard/convert.ts b/src/lib/whiteboard/convert.ts new file mode 100644 index 0000000..1cf8262 --- /dev/null +++ b/src/lib/whiteboard/convert.ts @@ -0,0 +1,19 @@ +// Turning a document into a whiteboard, from the palette. It is one-way, so +// it asks first, and the dialog says exactly what happens to the text. + +import { library } from "$lib/stores/library.svelte"; +import { confirmDialog } from "$lib/stores/confirm.svelte"; + +/** Ask, then convert the open note. True only when it converted. */ +export async function confirmConvertToWhiteboard(): Promise { + const note = library.selected; + if (!note || note.isDeleted || note.contentKind === "whiteboard") return false; + const ok = await confirmDialog.ask({ + title: "Turn this note into a whiteboard?", + body: "Its text moves onto the board as a text block, and stays searchable. A whiteboard can't be turned back into a text note.", + confirmLabel: "Make Whiteboard", + }); + if (!ok) return false; + await library.convertToWhiteboard(); + return true; +} diff --git a/src/lib/whiteboard/document.test.ts b/src/lib/whiteboard/document.test.ts new file mode 100644 index 0000000..7ddcc96 --- /dev/null +++ b/src/lib/whiteboard/document.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + boardText, + emptyBoard, + excalidrawFile, + parseBoard, + sceneFingerprint, + serializeBoard, + type BoardElement, +} from "./document"; + +const text = (id: string, x: number, y: number, t: string, extra = {}): BoardElement => ({ + id, + type: "text", + x, + y, + version: 1, + text: t, + ...extra, +}); + +describe("parseBoard", () => { + it("round-trips a saved board", () => { + const board = { ...emptyBoard(), elements: [text("a", 0, 0, "hi")] }; + expect(parseBoard(serializeBoard(board))).toEqual(board); + }); + + it("stores the envelope the vault reads", () => { + const stored = JSON.parse(serializeBoard(emptyBoard())); + expect(stored.v).toBe(1); + expect(stored.engine).toBe("excalidraw"); + expect(stored.data.elements).toEqual([]); + }); + + it("opens a missing, broken, or other-engine board as empty", () => { + for (const raw of [ + null, + "", + "not json", + JSON.stringify({ v: 1, engine: "svelte-flow", data: { nodes: [{ id: "1" }] } }), + JSON.stringify({ v: 1, engine: "excalidraw", data: { elements: "nope" } }), + ]) { + expect(parseBoard(raw).elements).toEqual([]); + } + }); +}); + +describe("boardText", () => { + it("reads text top to bottom, then left to right", () => { + const els = [ + text("c", 0, 200, "third"), + text("b", 300, 0, "second"), + text("a", 0, 0, "first"), + ]; + expect(boardText(els)).toBe("first\n\nsecond\n\nthird"); + }); + + it("skips deleted elements and blank text", () => { + const els = [ + text("a", 0, 0, "kept"), + text("b", 0, 10, "gone", { isDeleted: true }), + text("c", 0, 20, " "), + { id: "r", type: "rectangle", x: 0, y: 0, version: 1 }, + ]; + expect(boardText(els)).toBe("kept"); + }); + + it("prefers the unwrapped text over the wrapped display text", () => { + const els = [text("a", 0, 0, "a long\nline", { originalText: "a long line" })]; + expect(boardText(els)).toBe("a long line"); + }); + + it("includes frame names, so a titled frame is searchable", () => { + const els = [{ id: "f", type: "frame", x: 0, y: 0, version: 1, name: "Q3 plan" }]; + expect(boardText(els)).toBe("Q3 plan"); + }); + + it("is empty for an empty board", () => { + expect(boardText([])).toBe(""); + }); +}); + +describe("sceneFingerprint", () => { + const base = [text("a", 0, 0, "x"), text("b", 0, 0, "y")]; + + it("is stable for the same scene", () => { + expect(sceneFingerprint(base, {})).toBe(sceneFingerprint([...base], {})); + }); + + it("changes when any element's version changes", () => { + const edited = [base[0], { ...base[1], version: 2 }]; + expect(sceneFingerprint(edited, {})).not.toBe(sceneFingerprint(base, {})); + }); + + it("changes when an element is added or removed", () => { + expect(sceneFingerprint(base.slice(0, 1), {})).not.toBe(sceneFingerprint(base, {})); + }); + + it("changes when an image file is added", () => { + expect(sceneFingerprint(base, { f1: {} })).not.toBe(sceneFingerprint(base, {})); + }); +}); + +describe("excalidrawFile", () => { + it("is the standard Excalidraw file, the same one the vault writes", () => { + const board = { ...emptyBoard(), elements: [text("a", 0, 0, "hi")] }; + const file = JSON.parse(excalidrawFile(board)); + expect(file).toEqual({ + type: "excalidraw", + version: 2, + source: "InstantNotes", + elements: board.elements, + appState: board.appState, + files: {}, + }); + }); +}); diff --git a/src/lib/whiteboard/document.ts b/src/lib/whiteboard/document.ts new file mode 100644 index 0000000..19df93b --- /dev/null +++ b/src/lib/whiteboard/document.ts @@ -0,0 +1,119 @@ +// A whiteboard's stored canvas and the text written on it. Pure helpers: the +// canvas component (ExcalidrawCanvas.svelte) and the library store use these, +// and the Rust vault reads the same envelope (core/src/vault/board.rs). +// +// Stored in note.surfaceData: +// { "v": 1, "engine": "excalidraw", "data": { elements, appState, files } } + +export const EXCALIDRAW_ENGINE = "excalidraw"; + +/** The fields of an Excalidraw element this module reads. */ +export interface BoardElement { + id: string; + type: string; + x: number; + y: number; + version: number; + isDeleted?: boolean; + text?: string; + originalText?: string; + name?: string | null; + [key: string]: unknown; +} + +export interface Board { + elements: BoardElement[]; + appState: Record; + files: Record; +} + +export function emptyBoard(): Board { + return { elements: [], appState: { viewBackgroundColor: "transparent" }, files: {} }; +} + +export function serializeBoard(board: Board): string { + return JSON.stringify({ v: 1, engine: EXCALIDRAW_ENGINE, data: board }); +} + +const isObject = (v: unknown): v is Record => + !!v && typeof v === "object" && !Array.isArray(v); + +/** + * The board stored in `surfaceData`. Anything unreadable, including boards + * from the pre-release engines ("shell", "svelte-flow"), opens empty rather + * than failing: the note itself stays intact either way. + */ +export function parseBoard(raw: string | null | undefined): Board { + let parsed: unknown; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + return emptyBoard(); + } + if (!isObject(parsed) || parsed.engine !== EXCALIDRAW_ENGINE || !isObject(parsed.data)) { + return emptyBoard(); + } + const { elements, appState, files } = parsed.data; + return { + elements: Array.isArray(elements) ? (elements as BoardElement[]) : [], + appState: isObject(appState) ? appState : emptyBoard().appState, + files: isObject(files) ? files : {}, + }; +} + +/** + * The words on a board, in reading order (top to bottom, then left to + * right), one block per text element or named frame. This becomes the note's + * body, so search, #tags, and the vault's Markdown file see what the board + * shows. + */ +export function boardText(elements: readonly BoardElement[]): string { + return elements + .filter((el) => !el.isDeleted) + .map((el) => { + const words = + el.type === "text" + ? (typeof el.originalText === "string" ? el.originalText : el.text) + : el.type === "frame" || el.type === "magicframe" + ? el.name + : null; + return { el, words: typeof words === "string" ? words.trim() : "" }; + }) + .filter((entry) => entry.words) + .sort((a, b) => a.el.y - b.el.y || a.el.x - b.el.x) + .map((entry) => entry.words) + .join("\n\n"); +} + +/** + * A cheap identity for what a board holds. Excalidraw bumps an element's + * version on every change to it, so this moves exactly when the drawing + * does, and stays put for scrolling, zooming, and selection, which fire the + * same change events but are not edits. + */ +export function sceneFingerprint( + elements: readonly { id: string; version: number }[], + files: Record, +): string { + let hash = 5381; + const mix = (s: string) => { + for (let i = 0; i < s.length; i++) hash = ((hash * 33) ^ s.charCodeAt(i)) >>> 0; + }; + for (const el of elements) mix(`${el.id}:${el.version};`); + for (const id of Object.keys(files)) mix(`f${id};`); + return `${elements.length}:${hash}`; +} + +/** A board as a standard `.excalidraw` file: the same content the vault + * mirror writes beside the note (core/src/vault/board.rs), for Export. */ +export function excalidrawFile(board: Board): string { + const file = { + type: "excalidraw", + version: 2, + source: "InstantNotes", + elements: board.elements, + appState: board.appState, + files: board.files, + }; + return `${JSON.stringify(file, null, 2)}\n`; +} diff --git a/src/lib/whiteboard/excalidraw.ts b/src/lib/whiteboard/excalidraw.ts new file mode 100644 index 0000000..58b98d6 --- /dev/null +++ b/src/lib/whiteboard/excalidraw.ts @@ -0,0 +1,33 @@ +// Loading Excalidraw, the canvas engine behind whiteboards. It is large and +// only a board needs it, so it loads on first use, never with the app. + +import { emptyBoard, serializeBoard, type BoardElement } from "./document"; + +/** Where the build serves Excalidraw's fonts (scripts/copy-excalidraw-fonts.mjs). + * Without it Excalidraw fetches them from a CDN, which the app's content + * security policy blocks: InstantNotes makes no network requests. */ +export const EXCALIDRAW_ASSET_PATH = "/excalidraw/"; + +export function loadExcalidraw() { + const w = window as Window & { EXCALIDRAW_ASSET_PATH?: string }; + w.EXCALIDRAW_ASSET_PATH ??= EXCALIDRAW_ASSET_PATH; + return import("@excalidraw/excalidraw"); +} + +/** + * A new board holding `text` as one text element at the origin, so + * converting a note to a whiteboard carries its words onto the canvas + * instead of hiding them. Empty text gives an empty board without loading + * the engine. + */ +export async function boardFromText(text: string): Promise { + const board = emptyBoard(); + const trimmed = text.trim(); + if (trimmed) { + const { convertToExcalidrawElements } = await loadExcalidraw(); + board.elements = convertToExcalidrawElements([ + { type: "text", x: 0, y: 0, text: trimmed }, + ]) as unknown as BoardElement[]; + } + return serializeBoard(board); +} diff --git a/src/lib/whiteboard/keys.test.ts b/src/lib/whiteboard/keys.test.ts new file mode 100644 index 0000000..627c5a5 --- /dev/null +++ b/src/lib/whiteboard/keys.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { isWhiteboardTarget } from "./keys"; + +afterEach(() => document.body.replaceChildren()); + +describe("isWhiteboardTarget", () => { + it("is true inside a board's canvas and inside Excalidraw's dialogs", () => { + document.body.innerHTML = ` +
+
+
`; + expect(isWhiteboardTarget(document.getElementById("c"))).toBe(true); + expect(isWhiteboardTarget(document.getElementById("d"))).toBe(true); + expect(isWhiteboardTarget(document.getElementById("row"))).toBe(false); + }); + + it("is false for non-elements", () => { + expect(isWhiteboardTarget(null)).toBe(false); + expect(isWhiteboardTarget(window)).toBe(false); + }); +}); diff --git a/src/lib/whiteboard/keys.ts b/src/lib/whiteboard/keys.ts new file mode 100644 index 0000000..46f3285 --- /dev/null +++ b/src/lib/whiteboard/keys.ts @@ -0,0 +1,10 @@ +// A whiteboard owns its keyboard: arrows nudge shapes, Cmd+A selects them, +// Cmd+= zooms the canvas, Backspace deletes a shape. The library window's +// shortcuts must stand aside for keys aimed at a board, or an arrow press +// would switch notes and Cmd+Backspace would trash the board itself. + +/** Whether a key event's target is on a board, or in one of Excalidraw's + * dialogs, which it renders outside the board's own element. */ +export function isWhiteboardTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest("[data-whiteboard], .excalidraw") !== null; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 732f1ba..6789c7c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,9 +1,12 @@ - - {@render children()} - +{@render children()} {#if import.meta.env.DEV}
dev · {buildTag}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 673ff6f..01927b4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,71 +1,127 @@ -{#if settingsOpen} - (settingsOpen = false)} /> +{#if settingsOpen && !licenseSpace.locked} + (settingsOpen = false)} + onShowSpace={(id) => { + library.selectWorkspace(id); + settingsOpen = false; + }} + /> {:else}
{/if} - -
- {#if library.multiSelected.size > 1} - - {:else if library.selected} - - {:else} - (updatePanelOpen = true)} /> - {/if} -
+ {#if library.graphMode && !licenseSpace.locked} + +
+ +
+ {:else} + +
+ {#if licenseSpace.locked} + + {:else if library.multiSelected.size > 1} + + {:else if library.selected} + {#if isUpdateNoteId(library.selected.id)} + + {:else} + + {/if} + {:else} + + {/if} +
+ {/if} {/if} - @@ -327,4 +431,9 @@ min-width: 0; min-height: 0; } + .graph-span { + grid-column: span 2; + min-width: 0; + min-height: 0; + } diff --git a/src/routes/capture/+page.svelte b/src/routes/capture/+page.svelte index 5e3294a..ec91ceb 100644 --- a/src/routes/capture/+page.svelte +++ b/src/routes/capture/+page.svelte @@ -5,6 +5,7 @@ import { onMount } from "svelte"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; + import { EVENTS } from "$lib/api/events"; import { captureInputReady, createNote, @@ -17,6 +18,8 @@ import { debounce } from "$lib/debounce"; import { modKey } from "$lib/platform"; import { theme } from "$lib/stores/theme.svelte"; + import { agreements } from "$lib/agreements.svelte"; + import LicenseLocked from "$lib/components/LicenseLocked.svelte"; const DRAFT_KEY = "capture.draft"; // One visible beat of "Saved" before the panel hides, so success reads as more @@ -27,7 +30,8 @@ let saving = $state(false); let saved = $state(false); let errorMsg = $state(null); - let textarea: HTMLTextAreaElement; + // Absent while the license gate stands in for it (LicenseLocked). + let textarea = $state(); // Set while we hide the panel ourselves so the blur that hiding triggers does // not fire a second dismiss; cleared when focus returns on the next reveal. let hiding = false; @@ -46,7 +50,7 @@ onMount(() => { void theme.init(); void restoreDraft(); - const unlisten = listen("capture:shown", () => { + const unlisten = listen(EVENTS.CAPTURE_SHOWN, () => { // Re-read the theme: it may have changed in the library while hidden. void theme.init(); void restoreDraft(); @@ -69,8 +73,8 @@ }); // Quit handshake: push a debounced draft write through before the process // exits, so the draft is not 300ms stale on the next launch. Only the - // library window answers with quit_app. - const unlistenQuit = listen("app:quit-requested", () => { + // library window and stickies answer with quit_app. + const unlistenQuit = listen(EVENTS.APP_QUIT_REQUESTED, () => { persistDraft.flush(); }); textarea?.focus(); @@ -155,6 +159,9 @@
+ {#if !agreements.done} + + {:else}