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
+(``), 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 `` `` ``
+ 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 ).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: ` for one note, `notes: 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 `/instantnotes.db`,
+plus loose images under `/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 `/attachments/` into `/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` (`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 → ` (conflict from ).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/
+ .rtfd/ one package per sticky
+ TXT.rtf the text, Cocoa RTF, \ansicpg1252
+ 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`:
+
+| 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`.
+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 `.` names in the attachments folder and are
+referenced as ``, 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.` 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 " 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;
+
+/** `../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,
+ tag: Option,
+ query: Option,
+}
+
+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::>(),
+ "noteCount": notes.len(),
+ "titles": notes.iter().take(TITLES).map(|(_, t)| t).collect::>(),
+ "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 [--attachments ]
+//! ```
+//!
+//! 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- ) -> Option {
+ if args.next().as_deref() != Some(SUBCOMMAND.as_ref()) {
+ return None;
+ }
+ let mut db: Option = None;
+ let mut attachments: Option = 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 [--attachments ]");
+ 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 . 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,
+ 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 {
+ 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 = 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 {
+ 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 {
+ 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 {
+ 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;
+
+pub(crate) struct Tools<'a> {
+ store: &'a mut Store,
+ attachments_dir: Option,
+ /// `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) -> 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 {
+ 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::>() }))
+ }
+ "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::>() }))
+ }
+ "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::>() }))
+ }
+
+ 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::>() }))
+ }
+
+ 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::>(),
+ "spaces": spaces.iter().map(|w| &w.name).collect::>(),
+ "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/`; 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 {
+ 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 {
+ 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::>().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 {
+ 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(args: Value) -> Result {
+ 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,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListArgs {
+ space: Option,
+ tag: Option,
+ status: Option,
+ limit: Option,
+ offset: Option,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct CreateArgs {
+ body: String,
+ title: Option,
+ #[serde(default)]
+ tags: Vec,
+ space: Option,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+struct UpdateArgs {
+ id: String,
+ expected_updated_at: String,
+ title: Option,
+ body: Option,
+}
+
+#[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 {
+ 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 {
+ 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 = 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 `/attachments`, and notes reference them as
+//! `attachments/`. 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/`. 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 {
+ 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 {
+ 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,
+) -> io::Result> {
+ 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!["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 {
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: ``, 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(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("\nWhiteboard photo"),
+ "Whiteboard photo"
+ );
+ assert_eq!(
+ derive_title(" \n\nTwo shots"),
+ "Two shots"
+ );
+ assert_eq!(derive_title(""), "Untitled");
+ // An image inside a line of words is part of the title line.
+ assert_eq!(derive_title("See  here"), "See  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 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 {
+ 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,
+ /// `\ucN`: fallback characters that follow each `\uN`.
+ uc: usize,
+}
+
+enum Piece {
+ Text {
+ text: String,
+ style: Style,
+ link: Option,
+ },
+ /// 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 {
+ image: F,
+ group: Group,
+ stack: Vec,
+ /// 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,
+ para: Vec,
+ 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,
+ /// `\uc` fallback characters still to skip.
+ fallback: usize,
+}
+
+impl Option> Reader {
+ 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::().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) {
+ 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 {
+ 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 {
+ 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 `.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,
+ /// `#rrggbb`, when the state file knows the sticky.
+ pub color: Option,
+ 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> {
+ let colors = read_colors(&dir.join(STATE_FILE));
+ let mut stickies: Vec = 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) -> Option {
+ 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 {
+ 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,
}
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::::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 {
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 {
})
}
+/// Get-or-create a workspace inside an existing transaction/connection.
+fn workspace_get_or_create(conn: &Connection, raw_name: &str) -> Result {
+ 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 {
+ fn init(mut conn: Connection) -> Result {
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 {
+ 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 {
+ 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 {
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> {
+ 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)> = 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
- ,
+ ) -> Result> {
+ 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 = 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
- ,
+ ) -> Result {
+ 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 {
+ 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(
+ &self,
+ sql: &str,
+ map: impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result,
+ ) -> Result> {
+ let mut stmt = self.conn.prepare(sql)?;
+ let rows = stmt.query_map([], map)?;
+ Ok(rows.collect::>>()?)
+ }
+}
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> {
+ 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,
+ space: Option<&str>,
+ ) -> Result {
+ 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 = 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> {
+ let raw: Option = 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 = 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,
+ 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 {
+ 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 {
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 {
+ 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 {
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 {
+ let count = |sql: &str| -> Result { 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,
+ last_flushed_at: Option,
+}
+
+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,
+ sha: Option,
+ board_sha: Option,
+}
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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::>()?
+ };
+ 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