diff --git a/.gitignore b/.gitignore index 297ea5d..4dbd05d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ dist/ build/ *.tsbuildinfo .vite/ +App/src-tauri/target/ +App/src-tauri/gen/schemas/ # Editor / IDE .idea/ diff --git a/AGENTS.md b/AGENTS.md index a846a27..a5b7aab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Project Instructions -This workspace is the Effortful Learning System. It contains two different +This workspace is Ignite, an effortful learning system. It contains two different kinds of work. Route the request before loading detailed instructions. ## Learning Sessions diff --git a/App/APP_IMPROVEMENT_HANDOFF.md b/App/APP_IMPROVEMENT_HANDOFF.md new file mode 100644 index 0000000..ec47aa8 --- /dev/null +++ b/App/APP_IMPROVEMENT_HANDOFF.md @@ -0,0 +1,373 @@ +# App Improvement Handoff + +## Purpose + +This document describes a small, measured improvement pass for the Effortful +Learning tracker. The goal is to reduce repeated filesystem work, simplify the +development installation, make the interface fully offline, and harden live +refresh behavior without changing what the application does or making future +product work harder. + +The changes should preserve both supported interfaces: + +- the Tauri 2 desktop app; +- the Fastify plus Vite browser development mode. + +The work should remain in React and TypeScript except for ordinary Tauri +configuration. Do not move application logic into Rust. + +## Current State + +The implementation described here is based on commit `9165a50` on +`codex/tauri-desktop`. + +The application currently has: + +- a React/Vite interface; +- a minimal Tauri 2 native shell; +- a Fastify backend for browser mode; +- a shared TypeScript vault, scheduler, statistics, and search core; +- a user-selected external Learning workspace; +- read, metadata, and watch permissions, but no learning-content write + permission; +- automatic refresh after relevant filesystem changes. + +At startup and after every detected change, `App.tsx` requests books, logs, +Today, and Ledger data independently. In desktop mode, Today and Ledger each +read books and logs again. This makes one logical refresh traverse the same +metadata and session logs several times. + +The current development layout also runs three independent pnpm installations: +one in `App/`, one in `App/backend/`, and one in `App/frontend/`. It therefore +maintains three lockfiles and three package stores. + +The HTML entry point loads Fraunces, Inter, and JetBrains Mono from Google Fonts +at runtime. + +## Measured Baseline + +These measurements were taken during the original audit. They are context, not +performance targets that must be reproduced exactly on another machine. + +- packaged macOS app: approximately 11 MB; +- idle main-process memory: approximately 75 MB RSS; +- frontend bundle: approximately 410 KB JavaScript, 126 KB gzip; +- representative full refresh with repeated reads: 3.14 ms median; +- representative refresh using one shared books/logs snapshot: 1.35 ms median; +- representative vault search: 18.8 ms median; +- the three pnpm package stores occupied approximately 2.7 GB in total. + +The app is already lightweight. This is a maintainability and scaling pass, not +a response to a user-visible performance failure. + +## Non-Negotiable Constraints + +1. The external Markdown vault remains the source of truth. +2. The application remains read-only with respect to learning content. +3. Do not add a database, cache files inside the vault, or a background service. +4. Do not commit private learning data, private paths, or real learner content. + Automated fixtures must continue to use `Learning.example/` or synthetic + in-memory data. +5. Desktop and browser modes must derive the same results from the shared + TypeScript core. +6. Preserve all current views, search behavior, book/log modals, theme state, + workspace selection, and automatic refresh behavior. +7. Preserve existing public API endpoints unless there is a compelling reason + to deprecate them. Adding a snapshot endpoint is acceptable. +8. Keep the code easy for an agent or human to modify. Prefer small explicit + functions and types over a new framework or generalized caching system. +9. Do not introduce search indexing, list virtualization, aggressive route + splitting, or new Rust business logic in this pass. Current measurements do + not justify them. + +## Priority 1: Load One Immutable Workspace Snapshot + +### Problem + +`App/frontend/src/App.tsx` currently performs: + +```ts +Promise.all([ + api.books(), + api.timeline(), + api.today(), + api.stats(), +]); +``` + +In desktop mode, `today()` and `stats()` in +`App/frontend/src/lib/desktop.ts` each call `readAllBooks()` and +`readAllLogs()` again. `readAllBooks()` also reads each book's logs to derive +`last_active`. One screen refresh therefore repeats directory listings, +metadata parsing, and log parsing. + +### Desired Design + +Add a typed snapshot that is created from one coherent read of the workspace: + +```ts +interface WorkspaceSnapshot { + books: BookSummary[]; + logs: SessionLog[]; + today: TodayPayload; + stats: StatsPayload; +} +``` + +Suggested implementation: + +1. Add a shared source-data type, for example: + + ```ts + interface WorkspaceData { + books: BookSummary[]; + logs: SessionLog[]; + } + ``` + +2. Extend `VaultService` with a method such as `readWorkspaceData()`. +3. Internally read every recognized book and its session logs once. A helper + can return `{ book, logs }` for one slug. Flatten and globally sort the logs + after all books have been read. +4. Derive each book's `progress.last_active` from the same logs returned in that + book's bundle. Do not call `readSessionLogs()` again for the summary. +5. Build `WorkspaceSnapshot` from that source data with + `buildTodayFrom(books, logs, todayISO)` and + `buildStatsFrom(books, logs, todayISO)`. Pass `todayISO` into the shared + function so both environments choose the date explicitly. +6. Add `snapshot()` to the frontend `TrackerApi`. +7. In desktop mode, `snapshot()` should call `readWorkspaceData()` once and + derive Today and Ledger from it. +8. In browser mode, add `GET /api/snapshot`, backed by the same shared + snapshot-building path. +9. Change `App.tsx` to perform one `api.snapshot()` call and update its four + state values from that single result. +10. Keep the existing books, timeline, Today, and stats endpoints working for + compatibility and focused requests. + +The snapshot should be immutable by convention. It only needs to live in React +state; do not persist it to disk or add a global cache with invalidation rules. + +### Tests + +- Verify that snapshot results equal the results of the existing individual + readers on `Learning.example/`. +- Add a synthetic counting `VaultReader` test demonstrating that one snapshot + reads each `book.md` and log file once. +- Verify that a book directory without `book.md` is still ignored. +- Verify that path traversal protections and missing optional directories keep + their current behavior. +- Verify that a failed snapshot does not partially replace the four pieces of + currently displayed state. + +## Priority 2: Consolidate the pnpm Workspace + +### Problem + +The repository currently has: + +- `App/pnpm-lock.yaml`; +- `App/backend/pnpm-lock.yaml`; +- `App/frontend/pnpm-lock.yaml`; +- three independent install commands and dependency stores. + +This makes dependency updates noisier and consumes unnecessary development +disk space. + +### Desired Design + +1. Add `App/pnpm-workspace.yaml`: + + ```yaml + packages: + - backend + - frontend + ``` + +2. Keep dependencies owned by the package that imports them. Do not move all + dependencies into the root package merely to shorten manifests. +3. Regenerate one authoritative `App/pnpm-lock.yaml` from the workspace root. +4. Remove the two nested lockfiles. +5. Simplify `install:all` to `pnpm install`, or retain the script as a clear + alias that runs only the root install. +6. Confirm that these commands still work from `App/`: + + ```bash + pnpm dev + pnpm -C backend test + pnpm -C frontend build + pnpm desktop:dev + pnpm desktop:check + pnpm desktop:build --no-bundle + ``` + +7. Add a convenient root validation script, for example: + + ```json + "check": "pnpm -C backend test && pnpm -C frontend build && pnpm desktop:check" + ``` + +Do not delete existing ignored `node_modules/` directories as part of the code +change. They are rebuildable local data, but removing them should be an +explicit local cleanup decision. The committed improvement is the workspace +layout and single lockfile. + +## Priority 3: Package the Existing Fonts Locally + +### Problem + +`App/frontend/index.html` currently contacts Google Fonts whenever the app is +opened. That introduces a network dependency, can cause fallback-font flashes, +and requires broader CSP allowances than a local desktop reader needs. + +### Desired Design + +1. Package the exact currently used families and ranges as WOFF2 assets: + + - Fraunces variable: optical size, weight 300–900, SOFT, and WONK axes; + - Inter: weight 300–700; + - JetBrains Mono: weights 400–500. + +2. Obtain the font files from their official upstream projects or another + authoritative distribution source. +3. Include the applicable font license files in the repository beside the font + assets. +4. Declare the fonts with `@font-face` in a dedicated stylesheet or near the + top of `styles.css`. +5. Keep the current CSS family names and fallback stacks unchanged so no + component code needs to change. +6. Remove the Google Fonts `preconnect` and stylesheet elements from + `frontend/index.html`. +7. Tighten the Tauri CSP by removing Google Fonts domains from `style-src` and + `font-src`. The packaged fonts should load from `self`. +8. Confirm that the existing Fraunces `fontVariationSettings` still work. The + selected Fraunces asset must contain the custom axes rather than a reduced + weight-only build. + +### Verification + +- Launch the packaged app with network access disabled and confirm that all + three families render. +- Compare Today, Library, Board, Chronicle, Ledger, search, and the detail + modals before and after the change at the same window size. +- Check headings, italics, numeric alignment, card wrapping, and modal layout. +- Confirm there are no requests to `fonts.googleapis.com` or + `fonts.gstatic.com`. + +The objective is identical typography with local delivery, not a visual +redesign. + +## Priority 4: Harden Filesystem Refresh Coordination + +### Problems + +- The desktop watcher currently watches the entire selected workspace and then + filters events by path. +- The React effect subscribes only after the initial load, leaving a small + interval in which a filesystem change can be missed. +- A burst of changes can begin another full refresh while a previous refresh is + still running. + +### Desired Design + +1. Watch only the existing `books/` and `cross-book/` roots. +2. Treat a missing optional `cross-book/` directory as normal. Continue + watching `books/` rather than failing the whole subscription. +3. Establish the subscription before starting the initial load. +4. Keep debounce behavior so a multi-file agent update becomes one refresh. +5. Serialize and coalesce refreshes: + - never run two snapshot reads concurrently; + - if a change occurs while one read is running, remember one pending + refresh; + - after the current read finishes, run exactly one more refresh using the + newest filesystem state; + - discard state updates after the component unmounts or the workspace + changes. +6. Preserve silent automatic updates. Do not add polling or a manual refresh + requirement. +7. Apply equivalent coalescing to browser-mode SSE refreshes in the shared + frontend coordinator. The backend's chokidar debounce may remain. + +Prefer a small, testable refresh coordinator or hook over interleaved booleans +spread across `App.tsx` and `desktop.ts`. + +### Tests + +- Simulate several notifications during a delayed load and verify that at most + one load runs at a time and one trailing refresh occurs. +- Verify that an event during initial loading is not lost. +- Verify cleanup on unmount and workspace switching. +- Manually update a synthetic `book.md` and log while the desktop app is open; + the visible views should update once the write burst settles. + +## Recommended Implementation Order + +1. Add workspace and snapshot types plus shared snapshot construction. +2. Refactor the vault reader to produce books and logs in one pass. +3. Add desktop and browser snapshot adapters and migrate `App.tsx`. +4. Add refresh serialization and narrow the desktop watch roots. +5. Convert `App/` to a pnpm workspace and regenerate the lockfile. +6. Package fonts locally and tighten CSP. +7. Update `App/README.md` with the new install command, snapshot architecture, + offline-font behavior, and validation command. +8. Run all validation and perform a desktop smoke test. + +Keeping these as logically separate commits is helpful for review, although a +single final pull request is fine. + +## Required Validation + +Run from the repository root unless otherwise noted: + +```bash +pnpm -C App install +pnpm -C App/backend test +pnpm -C App/frontend build +pnpm -C App desktop:check +pnpm -C App desktop:build --no-bundle +git diff --check +``` + +Also perform these manual checks: + +- first launch still requests a workspace; +- the selected workspace remains remembered across launches; +- Today, Library, Board, Chronicle, Ledger, search, book details, and log details + show the same information as before; +- filesystem changes made by an external agent appear automatically; +- auxiliary directories without `book.md` do not break loading; +- the app has no write permission for learning content; +- the browser/server mode still operates; +- the packaged desktop app renders correctly without internet access; +- no private vault files or paths appear in the Git diff. + +## Completion Criteria + +This improvement pass is complete when: + +- one logical refresh is backed by one coherent workspace-data read; +- Today and Ledger derive from the same books and logs shown elsewhere; +- refreshes are serialized and coalesced; +- desktop watching is limited to relevant workspace roots; +- `App/` uses one pnpm workspace lockfile and one installation; +- the current fonts are bundled locally with their licenses; +- the Tauri CSP no longer permits Google Fonts; +- all automated and manual validation above passes; +- behavior and appearance are unchanged aside from more deterministic offline + startup. + +## Explicitly Out of Scope + +- changing the learning model or scheduler; +- editing or migrating private learning content; +- adding app-based editing of the vault; +- adding an internal database; +- implementing a search index; +- virtualizing current lists; +- rewriting shared TypeScript logic in Rust; +- adding a background daemon; +- redesigning the interface; +- signing, notarization, auto-update, or release automation. + +If later measurements show a genuine bottleneck, optimize that measured path +separately. Do not pre-emptively add architectural machinery during this pass. diff --git a/App/README.md b/App/README.md index 4fb1fa2..a3cfeee 100644 --- a/App/README.md +++ b/App/README.md @@ -1,13 +1,96 @@ -# Effortful Learning Tracker App +# Ignite Tracker App -This folder contains the local read-only tracker app for an Effortful Learning -workspace. +This folder contains the tracker for an Ignite learning workspace. The +recommended interface is a Tauri 2 desktop app; the original local +browser/server mode remains available. The desktop app also provides a Codex +chat backed by the locally installed Codex CLI and the user's existing ChatGPT +sign-in. -The filesystem workspace remains the source of truth. In a public checkout the -app falls back to `../Learning.example`; on your own machine it can read a -private vault from `../Learning` or from `LEARNING_WORKSPACE`. +The learning vault stays outside the application and remains the source of +truth. There is no database. The tracker filesystem adapter remains read-only; +only the separate Codex process can propose and perform changes, under Codex's +normal sandbox and approval flow. -## Workspace Resolution +## Run the Desktop App + +Install Node.js, pnpm, and Rust, then from this folder run: + +```bash +pnpm install +pnpm desktop:dev +``` + +On first launch, choose the `Learning` folder that contains `books/`. The +folder-picker grant is remembered across launches. Access is recursive within +that selected folder, but the Tauri capability contains only read, metadata, +and watch permissions. + +An open window watches `books/` and `cross-book/`. When Codex, Claude Code, or +another process updates a valid vault file, the current views silently reload. +Changing app code during `desktop:dev` uses Vite's normal hot reload. + +On macOS, choose **Ignite → Reload** or press **Cmd+R** to reload the +current interface on demand. Development mode will then request the latest Vite +output. An installed production app must first be rebuilt and reinstalled before +Reload can show changed bundled code. + +### Codex Chat + +Open the **Codex** view to start a chat or resume existing Codex CLI, editor, +and app-server chats associated with the selected workspace. New chats use the +selected Learning folder as their working directory, `workspace-write` +sandboxing, and user-reviewed approvals. + +The app starts `codex app-server` over local stdio. It does not ask for an API +key or wrap the OpenAI API directly. Authenticate the installed CLI with your +ChatGPT subscription before opening the app: + +```bash +codex login +codex login status +``` + +On macOS, the app also discovers the Codex binary bundled with ChatGPT. On any +platform, set `CODEX_EXECUTABLE` to an explicit Codex CLI path if it is not on +the GUI application's `PATH`. + +This does not replace other entry points. Codex or Claude Code can still run in +a terminal and update the same external vault; the tracker watcher will pick up +those changes. Claude chat is not embedded in this version. + +Useful commands: + +```bash +pnpm desktop:check +pnpm desktop:build --no-bundle +pnpm desktop:build --bundles app --no-sign +``` + +The wrapper in `scripts/tauri.mjs` places Cargo's disposable build cache in the +operating system's temporary directory. This avoids macOS AppleDouble metadata +problems when the repository lives on an external volume. Set +`CARGO_TARGET_DIR` yourself to override that location. + +## Distribution + +`desktop:build` produces the native artifact for the operating system on which +it runs. macOS, Windows, and Linux all use the same React interface and shared +TypeScript vault core; each platform still needs its own native toolchain and +release build. + +For personal local use on macOS, an unsigned `.app` build is enough. Giving the +app to other Mac users cleanly requires an Apple Developer identity, signing, +and notarization. A future release workflow can build signed installers on each +platform and add Tauri's updater; that updater is not part of this first desktop +milestone. + +## Browser/Server Mode + +Run both the Fastify backend and Vite frontend with: + +```bash +pnpm dev +``` The backend resolves the workspace in this order: @@ -16,43 +99,67 @@ The backend resolves the workspace in this order: 3. `../Learning.example/.system/config.json` 4. `~/learning` -This lets the public repo ship a safe demo while private notes stay outside the -public history. +This mode is useful for ordinary browser development and remains behaviorally +aligned with the desktop app because both use the same shared vault core. +All tracker views continue to work in this mode. Codex chat is desktop-only +because its bridge is a native child process; the browser view explains how to +open it in Tauri. + +## Architecture + +- `frontend/`: the React/Vite interface, Codex App Server protocol client, a + Tauri filesystem adapter, and the original HTTP adapter. +- `shared/`: filesystem-independent parsing, search, scheduling, statistics, + and types used by both application modes. +- `backend/`: the optional Fastify file-serving API and its Node filesystem + adapter. +- `src-tauri/`: the small Rust shell, desktop capabilities, local Codex stdio + bridge, plugins, bundle configuration, and icons. -## Shape +Rust owns the native window and operating-system permissions. Product behavior +stays in TypeScript, so most interface and learning-system changes use the same +React workflow as before. -- `backend/`: local file-serving API that reads the learning workspace and exposes JSON. -- `frontend/`: React interface for the views below, plus Book Detail and a - full-log reading panel. -- `shared/`: shared types and parsing helpers, if useful. +Each top-level refresh reads book metadata and session logs once into a shared +workspace snapshot. Today, Library, Board, Chronicle, and Ledger are derived +from that same immutable in-memory value in both desktop and browser modes. +Filesystem notifications are serialized and coalesced so refresh reads never +overlap. + +Fraunces, Inter, and JetBrains Mono are bundled locally as variable WOFF2 +assets with their licenses. The interface therefore keeps its typography when +offline and does not contact Google Fonts at runtime. ## Views -- **Today ("The Desk")**: the landing view. A review queue of segments whose - memory is due for another retrieval pass, the next pipeline move per book, - reviews coming due within a week, and the week's effort at a glance. +- **Today ("The Desk")**: review queue, pipeline moves, upcoming reviews, and + the week's effort. - **Library**: the shelves. -- **Board**: kanban of books by status. -- **Chronicle**: the session timeline. Each entry links to the full log. -- **Ledger**: charts — weekly effort, retention over time (from `outcomes` - scores in log frontmatter), stage distribution, and a per-book difficulty - ledger. +- **Board**: books grouped by status. +- **Chronicle**: the session timeline with full-log reading. +- **Ledger**: weekly effort, retention, stage distribution, and difficulty. +- **Codex**: local subscription-backed Codex chat, history, streaming activity, + approvals, and user-input prompts. + +Search the whole vault with `Cmd+K` on macOS or `Ctrl+K` on Windows and Linux. + +## Validation -Search the whole vault (sources, logs, reconstructions, cards, notes) with -`⌘K` / `Ctrl+K`. +```bash +pnpm check +``` -## The Review Schedule +The check runs the backend tests against the public synthetic workspace, builds +the frontend, and validates the Tauri permission manifest and native shell. -The scheduler writes nothing. For each segment it derives, from the logs -alone: how many retrieval sessions have happened, when the segment was last -touched, and how the latest retrieval went (`outcomes` frontmatter). The next -review date is an interval ladder (3, 7, 21, 60, 120 days) scaled by segment -difficulty and by the last measured recall. Because it is derived, deleting or -editing a log automatically corrects the schedule. +## Review Schedule and Constraints -## Constraints +The scheduler writes nothing. It derives review dates from book metadata and +session logs, including retrieval count, difficulty, and recorded outcomes. +Editing or deleting a log therefore corrects the schedule on the next refresh. - No database. -- No AI API calls. -- The filesystem workspace remains the source of truth. -- The app may read files and render state, but should not modify learning content. +- No direct AI API integration and no API key handling in the app. +- No learning-content write permissions in the tracker adapter. Codex changes + remain isolated behind its own workspace sandbox and approvals. +- No private learning data in this public repository. diff --git a/App/backend/package.json b/App/backend/package.json index d4421ca..3ea7f40 100644 --- a/App/backend/package.json +++ b/App/backend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "test": "pnpm build && node --test test/vault.test.mjs", + "start": "node dist/backend/src/index.js" }, "dependencies": { "@fastify/cors": "^10.0.1", diff --git a/App/backend/pnpm-lock.yaml b/App/backend/pnpm-lock.yaml deleted file mode 100644 index c2f0093..0000000 --- a/App/backend/pnpm-lock.yaml +++ /dev/null @@ -1,819 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@fastify/cors': - specifier: ^10.0.1 - version: 10.1.0 - chokidar: - specifier: ^4.0.1 - version: 4.0.3 - fastify: - specifier: ^5.1.0 - version: 5.8.5 - gray-matter: - specifier: ^4.0.3 - version: 4.0.3 - marked: - specifier: ^15.0.4 - version: 15.0.12 - devDependencies: - '@types/node': - specifier: ^22.10.0 - version: 22.19.18 - tsx: - specifier: ^4.19.2 - version: 4.21.0 - typescript: - specifier: ^5.7.2 - version: 5.9.3 - -packages: - - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@fastify/ajv-compiler@4.0.5': - resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - - '@fastify/cors@10.1.0': - resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} - - '@fastify/error@4.2.0': - resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - - '@fastify/fast-json-stringify-compiler@5.0.3': - resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} - - '@fastify/forwarded@3.0.1': - resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} - - '@fastify/merge-json-schemas@0.2.1': - resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - - '@fastify/proxy-addr@5.1.0': - resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - - '@types/node@22.19.18': - resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==} - - abstract-logging@2.0.1: - resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} - - avvio@9.2.0: - resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - extend-shallow@2.0.1: - resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} - engines: {node: '>=0.10.0'} - - fast-decode-uri-component@1.0.1: - resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stringify@6.4.0: - resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} - - fast-querystring@1.1.2: - resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - - fastify-plugin@5.1.0: - resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - - fastify@5.8.5: - resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - find-my-way@9.6.0: - resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} - engines: {node: '>=20'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - - gray-matter@4.0.3: - resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} - engines: {node: '>=6.0'} - - ipaddr.js@2.4.0: - resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} - engines: {node: '>= 10'} - - is-extendable@0.1.1: - resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} - engines: {node: '>=0.10.0'} - - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - - json-schema-ref-resolver@3.0.0: - resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} - - light-my-request@6.6.0: - resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} - - marked@15.0.12: - resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} - engines: {node: '>= 18'} - hasBin: true - - mnemonist@0.40.0: - resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} - - obliterator@2.0.5: - resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} - - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} - hasBin: true - - process-warning@4.0.1: - resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - - real-require@1.0.0: - resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - ret@0.5.0: - resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} - engines: {node: '>=10'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - - safe-regex2@5.1.1: - resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} - hasBin: true - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - - section-matter@1.0.0: - resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} - engines: {node: '>=4'} - - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} - engines: {node: '>=10'} - hasBin: true - - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - - sonic-boom@4.2.1: - resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - strip-bom-string@1.0.0: - resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} - engines: {node: '>=0.10.0'} - - thread-stream@4.1.0: - resolution: {integrity: sha512-Bw6h2iBDt16v6iHLChBIoVYU8CBo9GPsW8TG7h1hRVhqKhIkH6N8qkxNSmiOZTKsCLPbtWG4ViWLkU6KeKXpig==} - engines: {node: '>=20'} - - toad-cache@3.7.0: - resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} - engines: {node: '>=12'} - - tsx@4.21.0: - resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} - engines: {node: '>=18.0.0'} - hasBin: true - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - -snapshots: - - '@esbuild/aix-ppc64@0.27.7': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - - '@esbuild/linux-mips64el@0.27.7': - optional: true - - '@esbuild/linux-ppc64@0.27.7': - optional: true - - '@esbuild/linux-riscv64@0.27.7': - optional: true - - '@esbuild/linux-s390x@0.27.7': - optional: true - - '@esbuild/linux-x64@0.27.7': - optional: true - - '@esbuild/netbsd-arm64@0.27.7': - optional: true - - '@esbuild/netbsd-x64@0.27.7': - optional: true - - '@esbuild/openbsd-arm64@0.27.7': - optional: true - - '@esbuild/openbsd-x64@0.27.7': - optional: true - - '@esbuild/openharmony-arm64@0.27.7': - optional: true - - '@esbuild/sunos-x64@0.27.7': - optional: true - - '@esbuild/win32-arm64@0.27.7': - optional: true - - '@esbuild/win32-ia32@0.27.7': - optional: true - - '@esbuild/win32-x64@0.27.7': - optional: true - - '@fastify/ajv-compiler@4.0.5': - dependencies: - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 3.1.2 - - '@fastify/cors@10.1.0': - dependencies: - fastify-plugin: 5.1.0 - mnemonist: 0.40.0 - - '@fastify/error@4.2.0': {} - - '@fastify/fast-json-stringify-compiler@5.0.3': - dependencies: - fast-json-stringify: 6.4.0 - - '@fastify/forwarded@3.0.1': {} - - '@fastify/merge-json-schemas@0.2.1': - dependencies: - dequal: 2.0.3 - - '@fastify/proxy-addr@5.1.0': - dependencies: - '@fastify/forwarded': 3.0.1 - ipaddr.js: 2.4.0 - - '@pinojs/redact@0.4.0': {} - - '@types/node@22.19.18': - dependencies: - undici-types: 6.21.0 - - abstract-logging@2.0.1: {} - - ajv-formats@3.0.1(ajv@8.20.0): - optionalDependencies: - ajv: 8.20.0 - - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - - atomic-sleep@1.0.0: {} - - avvio@9.2.0: - dependencies: - '@fastify/error': 4.2.0 - fastq: 1.20.1 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - cookie@1.1.1: {} - - dequal@2.0.3: {} - - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - - esprima@4.0.1: {} - - extend-shallow@2.0.1: - dependencies: - is-extendable: 0.1.1 - - fast-decode-uri-component@1.0.1: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stringify@6.4.0: - dependencies: - '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 3.1.2 - json-schema-ref-resolver: 3.0.0 - rfdc: 1.4.1 - - fast-querystring@1.1.2: - dependencies: - fast-decode-uri-component: 1.0.1 - - fast-uri@3.1.2: {} - - fastify-plugin@5.1.0: {} - - fastify@5.8.5: - dependencies: - '@fastify/ajv-compiler': 4.0.5 - '@fastify/error': 4.2.0 - '@fastify/fast-json-stringify-compiler': 5.0.3 - '@fastify/proxy-addr': 5.1.0 - abstract-logging: 2.0.1 - avvio: 9.2.0 - fast-json-stringify: 6.4.0 - find-my-way: 9.6.0 - light-my-request: 6.6.0 - pino: 10.3.1 - process-warning: 5.0.0 - rfdc: 1.4.1 - secure-json-parse: 4.1.0 - semver: 7.8.0 - toad-cache: 3.7.0 - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - find-my-way@9.6.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-querystring: 1.1.2 - safe-regex2: 5.1.1 - - fsevents@2.3.3: - optional: true - - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - - gray-matter@4.0.3: - dependencies: - js-yaml: 3.14.2 - kind-of: 6.0.3 - section-matter: 1.0.0 - strip-bom-string: 1.0.0 - - ipaddr.js@2.4.0: {} - - is-extendable@0.1.1: {} - - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - - json-schema-ref-resolver@3.0.0: - dependencies: - dequal: 2.0.3 - - json-schema-traverse@1.0.0: {} - - kind-of@6.0.3: {} - - light-my-request@6.6.0: - dependencies: - cookie: 1.1.1 - process-warning: 4.0.1 - set-cookie-parser: 2.7.2 - - marked@15.0.12: {} - - mnemonist@0.40.0: - dependencies: - obliterator: 2.0.5 - - obliterator@2.0.5: {} - - on-exit-leak-free@2.1.2: {} - - pino-abstract-transport@3.0.0: - dependencies: - split2: 4.2.0 - - pino-std-serializers@7.1.0: {} - - pino@10.3.1: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.0.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.1 - thread-stream: 4.1.0 - - process-warning@4.0.1: {} - - process-warning@5.0.0: {} - - quick-format-unescaped@4.0.4: {} - - readdirp@4.1.2: {} - - real-require@0.2.0: {} - - real-require@1.0.0: {} - - require-from-string@2.0.2: {} - - resolve-pkg-maps@1.0.0: {} - - ret@0.5.0: {} - - reusify@1.1.0: {} - - rfdc@1.4.1: {} - - safe-regex2@5.1.1: - dependencies: - ret: 0.5.0 - - safe-stable-stringify@2.5.0: {} - - section-matter@1.0.0: - dependencies: - extend-shallow: 2.0.1 - kind-of: 6.0.3 - - secure-json-parse@4.1.0: {} - - semver@7.8.0: {} - - set-cookie-parser@2.7.2: {} - - sonic-boom@4.2.1: - dependencies: - atomic-sleep: 1.0.0 - - split2@4.2.0: {} - - sprintf-js@1.0.3: {} - - strip-bom-string@1.0.0: {} - - thread-stream@4.1.0: - dependencies: - real-require: 1.0.0 - - toad-cache@3.7.0: {} - - tsx@4.21.0: - dependencies: - esbuild: 0.27.7 - get-tsconfig: 4.14.0 - optionalDependencies: - fsevents: 2.3.3 - - typescript@5.9.3: {} - - undici-types@6.21.0: {} diff --git a/App/backend/src/index.ts b/App/backend/src/index.ts index ed46bff..de65fbe 100644 --- a/App/backend/src/index.ts +++ b/App/backend/src/index.ts @@ -3,6 +3,7 @@ import cors from "@fastify/cors"; import chokidar from "chokidar"; import path from "node:path"; import { + invalidateWorkspace, loadConfig, readAllBooks, readAllLogs, @@ -11,6 +12,7 @@ import { } from "./workspace.js"; import { buildToday } from "./schedule.js"; import { buildStats } from "./stats.js"; +import { buildSnapshot } from "./snapshot.js"; import { searchWorkspace } from "./search.js"; const PORT = Number(process.env.PORT) || 3333; @@ -42,6 +44,8 @@ app.get("/api/timeline", async () => { return { logs }; }); +app.get("/api/snapshot", async () => buildSnapshot(config.workspace_path)); + app.get<{ Params: { slug: string; file: string } }>( "/api/books/:slug/logs/:file", async (req, reply) => { @@ -120,6 +124,7 @@ const watcher = chokidar.watch( let debounce: NodeJS.Timeout | null = null; watcher.on("all", (event, file) => { + invalidateWorkspace(config.workspace_path); if (debounce) clearTimeout(debounce); debounce = setTimeout(() => { broadcast("workspace-changed", { event, file }); diff --git a/App/backend/src/schedule.ts b/App/backend/src/schedule.ts index 6834d82..651799c 100644 --- a/App/backend/src/schedule.ts +++ b/App/backend/src/schedule.ts @@ -1,304 +1,22 @@ -import type { - BookSummary, - PipelineItem, - ReviewItem, - Segment, - SessionLog, - TodayPayload, -} from "../../shared/types.js"; -import { readAllBooks, readAllLogs } from "./workspace.js"; - -// --------------------------------------------------------------------------- -// Review scheduling, fully derived from the vault. The filesystem stays the -// source of truth: nothing here writes state, so the schedule can never -// disagree with the logs it is computed from. -// -// A segment becomes reviewable once it has at least one retrieval-type -// session. Each further retrieval pushes it up an interval ladder; the -// interval is then scaled by how hard the segment is and by how well the -// last retrieval actually went (when the log recorded outcomes). - -const RETRIEVAL_TYPES = new Set([ - "interrogation", - "reconstruction-review", - "recall", - "feynman", - "examination", -]); - -/** Days until the next review, by number of completed retrievals (1-based). */ -const BASE_INTERVALS = [3, 7, 21, 60, 120]; - -const DAY_MS = 86_400_000; - -function clamp(v: number, lo: number, hi: number): number { - return Math.min(hi, Math.max(lo, v)); -} - -function toDayNumber(iso: string): number | null { - const t = Date.parse(iso); - return Number.isNaN(t) ? null : Math.floor(t / DAY_MS); -} - -function dayNumberToISO(day: number): string { - return new Date(day * DAY_MS).toISOString().slice(0, 10); -} - -/** Local calendar date as YYYY-MM-DD (log dates are naive local dates too). */ -export function localToday(now = new Date()): string { - const y = now.getFullYear(); - const m = String(now.getMonth() + 1).padStart(2, "0"); - const d = String(now.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; -} - -/** A log's segment field may be "01-loops", "loops", or "01". */ -export function logMatchesSegment(ref: string | undefined, seg: Segment): boolean { - if (!ref) return false; - return ref === seg.slug || ref === seg.id || ref === `${seg.id}-${seg.slug}`; -} - -function meanOutcome(outcomes: Record | undefined): number | null { - if (!outcomes) return null; - const values = Object.values(outcomes); - if (values.length === 0) return null; - return values.reduce((a, b) => a + b, 0) / values.length; -} - -export function intervalDays( - retrievalCount: number, - difficulty: number, - lastOutcome: number | null, -): number { - const base = - BASE_INTERVALS[clamp(retrievalCount, 1, BASE_INTERVALS.length) - 1]; - // Difficulty 0 stretches the interval 1.5×, difficulty 1 halves it. - let factor = clamp(1.5 - difficulty, 0.5, 1.5); - // A measured outcome refines the estimate: 0.5 is neutral, 1.0 stretches, - // 0.0 halves. Without outcomes the difficulty rating stands alone. - if (lastOutcome !== null) { - factor *= clamp(0.5 + lastOutcome, 0.5, 1.5); - } - return Math.max(1, Math.round(base * factor)); -} - -interface SegmentReviewState { - lastTouchedDay: number; - lastTouchedISO: string; - retrievalCount: number; - lastOutcome: number | null; -} - -function reviewStateFor(seg: Segment, bookLogs: SessionLog[]): SegmentReviewState | null { - let lastTouchedDay = -Infinity; - let lastTouchedISO = ""; - let retrievalCount = 0; - let lastRetrievalDay = -Infinity; - let lastOutcome: number | null = null; - - for (const log of bookLogs) { - if (!logMatchesSegment(log.segment, seg)) continue; - const day = toDayNumber(log.date); - if (day === null) continue; - if (day > lastTouchedDay) { - lastTouchedDay = day; - lastTouchedISO = log.date; - } - if (RETRIEVAL_TYPES.has(log.type)) { - retrievalCount++; - if (day >= lastRetrievalDay) { - lastRetrievalDay = day; - const mean = meanOutcome(log.outcomes); - if (mean !== null) lastOutcome = mean; - } - } - } - - if (retrievalCount === 0 || !Number.isFinite(lastTouchedDay)) return null; - return { lastTouchedDay, lastTouchedISO, retrievalCount, lastOutcome }; -} - -const STAGE_ACTION: Record< - Segment["stage"], - { action: string; prompt?: string } | null -> = { - unread: { action: "Read" }, - read: { action: "Interrogate", prompt: "interrogation" }, - interrogated: { action: "Reconstruct from memory", prompt: "reconstruction-review" }, - reconstructed: { action: "Draft cards", prompt: "card-generation" }, - carded: { action: "Edit & import cards" }, - complete: null, +import type { TodayPayload } from "../../shared/types.js"; +import { + buildTodayFrom, + intervalDays, + localToday, + logMatchesSegment, + parseDurationMinutes, +} from "../../shared/schedule.js"; +import { readWorkspaceData } from "./workspace.js"; + +export { + buildTodayFrom, + intervalDays, + localToday, + logMatchesSegment, + parseDurationMinutes, }; -function suggestedSession(seg: Segment): string { - // Segments still mid-pipeline are best reviewed by advancing the pipeline; - // finished ones come back as free recall. - switch (seg.stage) { - case "read": - return "interrogation"; - case "interrogated": - return "reconstruction"; - case "reconstructed": - return "recall"; - default: - return "recall"; - } -} - -export function buildTodayFrom( - books: BookSummary[], - logs: SessionLog[], - todayISO: string, -): TodayPayload { - const todayDay = toDayNumber(todayISO) ?? 0; - const due: ReviewItem[] = []; - const upcoming: ReviewItem[] = []; - const pipeline: PipelineItem[] = []; - - const logsByBook = new Map(); - for (const log of logs) { - const list = logsByBook.get(log.book) ?? []; - list.push(log); - logsByBook.set(log.book, list); - } - - for (const book of books) { - const bookLogs = logsByBook.get(book.slug) ?? []; - - // Review queue — anything with retrieval history gets a due date. - for (const seg of book.segments) { - if (seg.stage === "unread" || seg.stage === "read") continue; - const state = reviewStateFor(seg, bookLogs); - if (!state) continue; - const interval = intervalDays( - state.retrievalCount, - seg.difficulty, - state.lastOutcome, - ); - const nextDay = state.lastTouchedDay + interval; - const daysOverdue = todayDay - nextDay; - const item: ReviewItem = { - book: book.slug, - book_title: book.title, - segment: seg, - last_touched: state.lastTouchedISO, - next_review: dayNumberToISO(nextDay), - days_overdue: daysOverdue, - interval_days: interval, - retrieval_count: state.retrievalCount, - suggested: suggestedSession(seg), - }; - if (daysOverdue >= 0) due.push(item); - else if (daysOverdue >= -7) upcoming.push(item); - } - - // Pipeline — the next move per book, so the desk stays calm. - if (book.status === "completed") continue; - if (book.segments.length === 0) { - pipeline.push({ - book: book.slug, - book_title: book.title, - book_status: book.status, - action: "Set up the book", - prompt: "setup-book", - }); - continue; - } - const next = book.segments.find((s) => s.stage !== "complete"); - if (!next) { - pipeline.push({ - book: book.slug, - book_title: book.title, - book_status: book.status, - action: "Final examination", - prompt: "examination", - }); - continue; - } - const move = STAGE_ACTION[next.stage]; - if (move) { - pipeline.push({ - book: book.slug, - book_title: book.title, - book_status: book.status, - segment: next, - action: move.action, - prompt: move.prompt, - }); - } - } - - // Most overdue first; ties broken by difficulty so the shakiest memory wins. - due.sort( - (a, b) => - b.days_overdue - a.days_overdue || b.segment.difficulty - a.segment.difficulty, - ); - upcoming.sort((a, b) => a.next_review.localeCompare(b.next_review)); - // Active books before queued ones. - pipeline.sort((a, b) => - a.book_status === b.book_status ? 0 : a.book_status === "active" ? -1 : 1, - ); - - return { - date: todayISO, - due, - upcoming, - pipeline, - stats: computeTodayStats(logs, todayDay, due.length), - }; -} - -function computeTodayStats( - logs: SessionLog[], - todayDay: number, - dueCount: number, -): TodayPayload["stats"] { - let sessions7d = 0; - let minutes7d = 0; - const activeDays = new Set(); - - for (const log of logs) { - const day = toDayNumber(log.date); - if (day === null) continue; - activeDays.add(day); - if (todayDay - day < 7 && todayDay - day >= 0) { - sessions7d++; - minutes7d += parseDurationMinutes(log.duration_approx); - } - } - - // Streak: consecutive days with at least one session, counting back from - // today (or from yesterday, so this morning's empty log doesn't zero it). - let streak = 0; - let cursor = activeDays.has(todayDay) ? todayDay : todayDay - 1; - while (activeDays.has(cursor)) { - streak++; - cursor--; - } - - return { - due_count: dueCount, - sessions_7d: sessions7d, - minutes_7d: minutes7d, - streak_days: streak, - }; -} - -/** "35m" → 35, "1h" → 60, "1h 20m" → 80. Unparseable → 0. */ -export function parseDurationMinutes(raw: string | undefined): number { - if (!raw) return 0; - let minutes = 0; - const h = raw.match(/(\d+)\s*h/i); - const m = raw.match(/(\d+)\s*m/i); - if (h) minutes += Number(h[1]) * 60; - if (m) minutes += Number(m[1]); - return minutes; -} - export async function buildToday(workspace: string): Promise { - const [books, logs] = await Promise.all([ - readAllBooks(workspace), - readAllLogs(workspace), - ]); + const { books, logs } = await readWorkspaceData(workspace); return buildTodayFrom(books, logs, localToday()); } diff --git a/App/backend/src/search.ts b/App/backend/src/search.ts index 50b9f95..f4938f1 100644 --- a/App/backend/src/search.ts +++ b/App/backend/src/search.ts @@ -1,230 +1,5 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; -import matter from "gray-matter"; -import type { SearchKind, SearchResult } from "../../shared/types.js"; -import { readAllBooks } from "./workspace.js"; +import { serviceFor } from "./workspace.js"; -// --------------------------------------------------------------------------- -// Dependency-free full-text search over the vault's markdown. A personal -// vault is small (megabytes at most), so a straight scan per query is -// simpler and more trustworthy than maintaining an index that can go stale. - -const MAX_RESULTS = 20; -const MAX_FILE_BYTES = 512 * 1024; -const TITLE_WEIGHT = 6; - -interface Candidate { - kind: SearchKind; - file: string; - absPath: string; - book?: string; - book_title?: string; - title: string; - log_file?: string; -} - -function isVisibleMarkdown(name: string): boolean { - return name.endsWith(".md") && !name.startsWith(".") && !name.startsWith("._"); -} - -async function listMarkdown(dir: string): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - return entries - .filter((e) => e.isFile() && isVisibleMarkdown(e.name)) - .map((e) => e.name); - } catch { - return []; - } -} - -function humanise(filename: string): string { - return filename - .replace(/\.md$/, "") - .replace(/^\d+[-.]?/, "") - .replace(/-/g, " ") - .replace(/(^|\s)\w/g, (c) => c.toUpperCase()) - .trim(); -} - -/** "2026-01-24-recall-loops.md" → "Recall Loops — 2026-01-24" */ -function logTitle(filename: string): string { - const m = filename.match(/^(\d{4}-\d{2}-\d{2})-(.+)\.md$/); - if (!m) return humanise(filename); - return `${humanise(`${m[2]}.md`)} — ${m[1]}`; -} - -async function collectCandidates(workspace: string): Promise { - const books = await readAllBooks(workspace); - const out: Candidate[] = []; - - for (const book of books) { - const bookDir = path.join(workspace, "books", book.slug); - const base = { book: book.slug, book_title: book.title }; - - out.push({ - ...base, - kind: "book", - file: `books/${book.slug}/book.md`, - absPath: path.join(bookDir, "book.md"), - title: book.title, - }); - - const segmentTitle = (filename: string): string => { - const stem = filename.replace(/\.md$/, ""); - const seg = book.segments.find( - (s) => stem === `${s.id}-${s.slug}` || stem === s.slug, - ); - return seg ? seg.title : humanise(filename); - }; - - for (const f of await listMarkdown(path.join(bookDir, "source"))) { - out.push({ - ...base, - kind: "source", - file: `books/${book.slug}/source/${f}`, - absPath: path.join(bookDir, "source", f), - title: segmentTitle(f), - }); - } - for (const f of await listMarkdown(path.join(bookDir, "logs"))) { - out.push({ - ...base, - kind: "log", - file: `books/${book.slug}/logs/${f}`, - absPath: path.join(bookDir, "logs", f), - title: logTitle(f), - log_file: f, - }); - } - for (const f of await listMarkdown(path.join(bookDir, "reconstructions"))) { - out.push({ - ...base, - kind: "reconstruction", - file: `books/${book.slug}/reconstructions/${f}`, - absPath: path.join(bookDir, "reconstructions", f), - title: segmentTitle(f), - }); - } - for (const f of await listMarkdown(path.join(bookDir, "cards"))) { - out.push({ - ...base, - kind: "cards", - file: `books/${book.slug}/cards/${f}`, - absPath: path.join(bookDir, "cards", f), - title: humanise(f), - }); - } - for (const special of ["thesis", "essay"] as const) { - out.push({ - ...base, - kind: special, - file: `books/${book.slug}/${special}.md`, - absPath: path.join(bookDir, `${special}.md`), - title: `${book.title} — ${special}`, - }); - } - } - - const crossDir = path.join(workspace, "cross-book"); - for (const f of await listMarkdown(crossDir)) { - out.push({ - kind: "cross-book", - file: `cross-book/${f}`, - absPath: path.join(crossDir, f), - title: humanise(f), - }); - } - - return out; -} - -function countOccurrences(haystack: string, needle: string): number { - let count = 0; - let idx = 0; - while (count < 50) { - idx = haystack.indexOf(needle, idx); - if (idx === -1) break; - count++; - idx += needle.length; - } - return count; -} - -/** Flatten markdown to plain-ish text so snippets read as prose. */ -function plainify(md: string): string { - return md - .replace(/^#{1,6}\s+/gm, "") - .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/[*_`>]/g, "") - .replace(/^[-+]\s+/gm, ""); -} - -function makeSnippet(content: string, terms: string[]): string { - const lower = content.toLowerCase(); - let hit = -1; - for (const term of terms) { - const idx = lower.indexOf(term); - if (idx !== -1 && (hit === -1 || idx < hit)) hit = idx; - } - if (hit === -1) hit = 0; - const start = Math.max(0, hit - 90); - const end = Math.min(content.length, hit + 170); - const raw = content.slice(start, end).replace(/\s+/g, " ").trim(); - return `${start > 0 ? "…" : ""}${raw}${end < content.length ? "…" : ""}`; -} - -export async function searchWorkspace( - workspace: string, - query: string, -): Promise { - const terms = query - .toLowerCase() - .split(/\s+/) - .filter((t) => t.length >= 2) - .slice(0, 6); - if (terms.length === 0) return []; - - const candidates = await collectCandidates(workspace); - const results: SearchResult[] = []; - - for (const cand of candidates) { - let raw: string; - try { - const stat = await fs.stat(cand.absPath); - if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue; - raw = await fs.readFile(cand.absPath, "utf8"); - } catch { - continue; - } - const content = matter(raw).content; - const contentLower = content.toLowerCase(); - const titleLower = cand.title.toLowerCase(); - - let score = 0; - let allPresent = true; - for (const term of terms) { - const hits = - countOccurrences(contentLower, term) + - TITLE_WEIGHT * countOccurrences(titleLower, term); - if (hits === 0) allPresent = false; - score += hits; - } - if (score === 0) continue; - if (allPresent && terms.length > 1) score *= 2; - - results.push({ - kind: cand.kind, - file: cand.file, - book: cand.book, - book_title: cand.book_title, - title: cand.title, - snippet: makeSnippet(plainify(content), terms), - score, - log_file: cand.log_file, - }); - } - - results.sort((a, b) => b.score - a.score); - return results.slice(0, MAX_RESULTS); +export function searchWorkspace(workspace: string, query: string) { + return serviceFor(workspace).search(query); } diff --git a/App/backend/src/snapshot.ts b/App/backend/src/snapshot.ts new file mode 100644 index 0000000..c194bad --- /dev/null +++ b/App/backend/src/snapshot.ts @@ -0,0 +1,11 @@ +import { localToday } from "../../shared/schedule.js"; +import { buildWorkspaceSnapshot } from "../../shared/snapshot.js"; +import type { WorkspaceSnapshot } from "../../shared/types.js"; +import { readWorkspaceData } from "./workspace.js"; + +export async function buildSnapshot( + workspace: string, + todayISO = localToday(), +): Promise { + return buildWorkspaceSnapshot(await readWorkspaceData(workspace), todayISO); +} diff --git a/App/backend/src/stats.ts b/App/backend/src/stats.ts index 12aa910..17fd8c6 100644 --- a/App/backend/src/stats.ts +++ b/App/backend/src/stats.ts @@ -1,130 +1,11 @@ -import type { - BookSummary, - RetentionPoint, - SegmentStage, - SessionLog, - StatsPayload, - WeekBucket, -} from "../../shared/types.js"; -import { localToday, parseDurationMinutes } from "./schedule.js"; -import { readAllBooks, readAllLogs } from "./workspace.js"; +import type { StatsPayload } from "../../shared/types.js"; +import { buildStatsFrom } from "../../shared/stats.js"; +import { localToday } from "../../shared/schedule.js"; +import { readWorkspaceData } from "./workspace.js"; -const DAY_MS = 86_400_000; -const WEEKS_SHOWN = 16; - -function toDayNumber(iso: string): number | null { - const t = Date.parse(iso); - return Number.isNaN(t) ? null : Math.floor(t / DAY_MS); -} - -/** Day number of the Monday of the week containing `day`. Day 0 is a Thursday. */ -function mondayOf(day: number): number { - return day - ((day + 3) % 7); -} - -function dayNumberToISO(day: number): string { - return new Date(day * DAY_MS).toISOString().slice(0, 10); -} - -export function buildStatsFrom( - books: BookSummary[], - logs: SessionLog[], - todayISO: string, -): StatsPayload { - const todayDay = toDayNumber(todayISO) ?? 0; - let currentMonday = mondayOf(todayDay); - - // If all recorded activity predates the trailing window, anchor the window - // to the latest session instead of showing sixteen empty weeks. - let latestLogDay = -Infinity; - for (const log of logs) { - const day = toDayNumber(log.date); - if (day !== null && day > latestLogDay) latestLogDay = day; - } - if ( - Number.isFinite(latestLogDay) && - mondayOf(latestLogDay) < currentMonday - (WEEKS_SHOWN - 1) * 7 - ) { - currentMonday = mondayOf(latestLogDay); - } - - // Fixed window of trailing weeks, zero-filled so quiet weeks stay visible. - const weeks: WeekBucket[] = []; - const weekIndex = new Map(); - for (let i = WEEKS_SHOWN - 1; i >= 0; i--) { - const monday = currentMonday - i * 7; - const bucket: WeekBucket = { - week_start: dayNumberToISO(monday), - sessions: 0, - minutes: 0, - }; - weeks.push(bucket); - weekIndex.set(monday, bucket); - } - - const retention: RetentionPoint[] = []; - let totalMinutes = 0; - - for (const log of logs) { - const day = toDayNumber(log.date); - if (day === null) continue; - const minutes = parseDurationMinutes(log.duration_approx); - totalMinutes += minutes; - - const bucket = weekIndex.get(mondayOf(day)); - if (bucket) { - bucket.sessions++; - bucket.minutes += minutes; - } - - if (log.outcomes) { - const values = Object.values(log.outcomes); - if (values.length > 0) { - retention.push({ - date: log.date, - book: log.book, - segment: log.segment, - value: values.reduce((a, b) => a + b, 0) / values.length, - concepts: values.length, - }); - } - } - } - retention.sort((a, b) => a.date.localeCompare(b.date)); - - const stage_counts: Record = { - unread: 0, - read: 0, - interrogated: 0, - reconstructed: 0, - carded: 0, - complete: 0, - }; - let segmentsComplete = 0; - for (const book of books) { - for (const seg of book.segments) { - stage_counts[seg.stage] = (stage_counts[seg.stage] ?? 0) + 1; - if (seg.stage === "complete") segmentsComplete++; - } - } - - return { - weeks, - stage_counts, - retention, - totals: { - sessions: logs.length, - minutes: totalMinutes, - books: books.length, - segments_complete: segmentsComplete, - }, - }; -} +export { buildStatsFrom }; export async function buildStats(workspace: string): Promise { - const [books, logs] = await Promise.all([ - readAllBooks(workspace), - readAllLogs(workspace), - ]); + const { books, logs } = await readWorkspaceData(workspace); return buildStatsFrom(books, logs, localToday()); } diff --git a/App/backend/src/workspace.ts b/App/backend/src/workspace.ts index b867e8b..3102a06 100644 --- a/App/backend/src/workspace.ts +++ b/App/backend/src/workspace.ts @@ -1,61 +1,42 @@ -import { promises as fs } from "node:fs"; -import { existsSync } from "node:fs"; -import path from "node:path"; +import { existsSync, promises as fs } from "node:fs"; import os from "node:os"; +import path from "node:path"; import matter from "gray-matter"; import { marked } from "marked"; -import type { - BookDetail, - BookMeta, - BookSummary, - LogDetail, - Segment, - SegmentStage, - SessionLog, -} from "../../shared/types.js"; - -const COMPLETED_STAGES: SegmentStage[] = ["complete"]; +import { + createVaultService, + type VaultReader, + type VaultService, +} from "../../shared/vault.js"; function resolveWorkspace(rawPath: string, baseDir = process.cwd()): string { if (rawPath.startsWith("~")) { return path.join(os.homedir(), rawPath.slice(1)); } - if (!path.isAbsolute(rawPath)) { - return path.resolve(baseDir, rawPath); - } + if (!path.isAbsolute(rawPath)) return path.resolve(baseDir, rawPath); return rawPath; } -async function loadWorkspaceConfig(configPath: string): Promise<{ workspace_path: string }> { +async function loadWorkspaceConfig( + configPath: string, +): Promise<{ workspace_path: string }> { const raw = await fs.readFile(configPath, "utf8"); - const parsed = JSON.parse(raw); + const parsed = JSON.parse(raw) as { workspace_path?: unknown }; const workspaceRoot = path.resolve(path.dirname(configPath), ".."); const configuredPath = - typeof parsed.workspace_path === "string" ? parsed.workspace_path : workspaceRoot; + typeof parsed.workspace_path === "string" + ? parsed.workspace_path + : workspaceRoot; return { workspace_path: resolveWorkspace(configuredPath, workspaceRoot) }; } export async function loadConfig(): Promise<{ workspace_path: string }> { - // Workspace resolution order: - // 1. LEARNING_WORKSPACE env override - // 2. private local ./Learning config - // 3. public ./Learning.example demo config - // 4. fallback ~/learning const envPath = process.env.LEARNING_WORKSPACE; - if (envPath) { - return { workspace_path: resolveWorkspace(envPath) }; - } + if (envPath) return { workspace_path: resolveWorkspace(envPath) }; const repoRoot = path.resolve(process.cwd(), "..", ".."); - const privateConfig = path.join( - repoRoot, - "Learning", - ".system", - "config.json", - ); - if (existsSync(privateConfig)) { - return loadWorkspaceConfig(privateConfig); - } + const privateConfig = path.join(repoRoot, "Learning", ".system", "config.json"); + if (existsSync(privateConfig)) return loadWorkspaceConfig(privateConfig); const exampleConfig = path.join( repoRoot, @@ -63,255 +44,99 @@ export async function loadConfig(): Promise<{ workspace_path: string }> { ".system", "config.json", ); - if (existsSync(exampleConfig)) { - return loadWorkspaceConfig(exampleConfig); - } + if (existsSync(exampleConfig)) return loadWorkspaceConfig(exampleConfig); - // Fallback: ~/learning return { workspace_path: resolveWorkspace("~/learning") }; } -async function readMarkdown(absPath: string): Promise<{ - data: Record; - content: string; -} | null> { - try { - const raw = await fs.readFile(absPath, "utf8"); - const parsed = matter(raw); - return { data: parsed.data as Record, content: parsed.content }; - } catch { - return null; - } -} - -function asString(v: unknown, fallback = ""): string { - if (typeof v === "string") return v; - if (v instanceof Date) return v.toISOString().slice(0, 10); - return fallback; -} -function asNumber(v: unknown, fallback = 0): number { - return typeof v === "number" && Number.isFinite(v) ? v : fallback; -} - -function normaliseSegments(raw: unknown): Segment[] { - if (!Array.isArray(raw)) return []; - return raw.map((s, i) => { - const obj = (s ?? {}) as Record; - return { - id: asString(obj.id, String(i + 1).padStart(2, "0")), - slug: asString(obj.slug, `segment-${i + 1}`), - title: asString(obj.title, `Segment ${i + 1}`), - summary: asString(obj.summary), - stage: (asString(obj.stage, "unread") as SegmentStage), - difficulty: asNumber(obj.difficulty, 0), - sessions: asNumber(obj.sessions, 0), - }; - }); -} - -function normaliseDifficultyMap(raw: unknown): Record { - if (!raw || typeof raw !== "object") return {}; - const out: Record = {}; - for (const [k, v] of Object.entries(raw as Record)) { - if (typeof v === "number") out[k] = v; - } - return out; -} +function createNodeReader(workspace: string): VaultReader { + const root = path.resolve(workspace); -function normaliseOutcomes(raw: unknown): Record | undefined { - if (!raw || typeof raw !== "object") return undefined; - const out: Record = {}; - for (const [k, v] of Object.entries(raw as Record)) { - if (typeof v === "number" && Number.isFinite(v)) { - out[k] = Math.min(1, Math.max(0, v)); + function resolveRelative(relativePath: string): string { + const absolute = path.resolve(root, ...relativePath.split("/")); + if (absolute !== root && !absolute.startsWith(`${root}${path.sep}`)) { + throw new Error("Vault path escapes the selected workspace"); } + return absolute; } - return Object.keys(out).length > 0 ? out : undefined; -} -function normaliseConnections(raw: unknown): BookMeta["connections"] { - if (!Array.isArray(raw)) return []; - return raw - .map((c) => { - const obj = (c ?? {}) as Record; + return { + readText: (relativePath) => fs.readFile(resolveRelative(relativePath), "utf8"), + async readDir(relativePath) { + return (await fs.readdir(resolveRelative(relativePath), { withFileTypes: true })).map( + (entry) => ({ + name: entry.name, + isFile: entry.isFile(), + isDirectory: entry.isDirectory(), + }), + ); + }, + async exists(relativePath) { + try { + await fs.access(resolveRelative(relativePath)); + return true; + } catch { + return false; + } + }, + async size(relativePath) { + try { + const info = await fs.stat(resolveRelative(relativePath)); + return info.isFile() ? info.size : null; + } catch { + return null; + } + }, + parseMarkdown(raw) { + const parsed = matter(raw); return { - target_book: asString(obj.target_book), - description: asString(obj.description), + data: parsed.data as Record, + content: parsed.content, }; - }) - .filter((c) => c.target_book.length > 0); -} - -function parseBookMeta(data: Record, slug: string): BookMeta { - return { - title: asString(data.title, slug), - author: asString(data.author), - slug: asString(data.slug, slug), - date_added: asString(data.date_added), - date_started: asString(data.date_started) || undefined, - date_completed: asString(data.date_completed) || undefined, - status: (asString(data.status, "queued") as BookMeta["status"]), - total_segments: asNumber(data.total_segments), - segments: normaliseSegments(data.segments), - difficulty_map: normaliseDifficultyMap(data.difficulty_map), - connections: normaliseConnections(data.connections), - cover: typeof data.cover === "string" ? data.cover : undefined, + }, + async renderMarkdown(markdown) { + const html = await marked.parse(markdown); + return typeof html === "string" ? html : ""; + }, }; } -async function listBookDirs(workspace: string): Promise { - const booksDir = path.join(workspace, "books"); - try { - const entries = await fs.readdir(booksDir, { withFileTypes: true }); - return entries - .filter( - (e) => - e.isDirectory() && - !e.name.startsWith(".") && - !e.name.startsWith("._"), - ) - .map((e) => e.name); - } catch { - return []; - } -} +const services = new Map(); -async function readSessionLogs(bookDir: string, slug: string): Promise { - const logsDir = path.join(bookDir, "logs"); - try { - const entries = await fs.readdir(logsDir, { withFileTypes: true }); - const files = entries.filter( - (e) => - e.isFile() && - e.name.endsWith(".md") && - !e.name.startsWith(".") && - !e.name.startsWith("._"), - ); - const logs: SessionLog[] = []; - for (const f of files) { - const md = await readMarkdown(path.join(logsDir, f.name)); - if (!md) continue; - const data = md.data; - logs.push({ - path: `logs/${f.name}`, - date: asString(data.date), - book: asString(data.book, slug), - segment: typeof data.segment === "string" ? data.segment : undefined, - type: asString(data.type, "session"), - duration_approx: - typeof data.duration_approx === "string" ? data.duration_approx : undefined, - summary: md.content.trim().slice(0, 400), - outcomes: normaliseOutcomes(data.outcomes), - }); - } - return logs.sort((a, b) => (a.date < b.date ? 1 : -1)); - } catch { - return []; +export function serviceFor(workspace: string): VaultService { + const key = path.resolve(workspace); + let service = services.get(key); + if (!service) { + service = createVaultService(createNodeReader(key)); + services.set(key, service); } + return service; } -export async function readBookSummary( - workspace: string, - slug: string, -): Promise { - const bookDir = path.join(workspace, "books", slug); - const md = await readMarkdown(path.join(bookDir, "book.md")); - if (!md) return null; - const meta = parseBookMeta(md.data, slug); - const completed = meta.segments.filter((s) => COMPLETED_STAGES.includes(s.stage)).length; - const total = meta.total_segments || meta.segments.length; - const logs = await readSessionLogs(bookDir, slug); - return { - ...meta, - progress: { - completed, - total, - last_active: logs[0]?.date, - }, - }; +export function invalidateWorkspace(workspace: string) { + services.get(path.resolve(workspace))?.invalidate(); } -export async function readBookDetail( - workspace: string, - slug: string, -): Promise { - const bookDir = path.join(workspace, "books", slug); - const md = await readMarkdown(path.join(bookDir, "book.md")); - if (!md) return null; - const meta = parseBookMeta(md.data, slug); - const completed = meta.segments.filter((s) => COMPLETED_STAGES.includes(s.stage)).length; - const total = meta.total_segments || meta.segments.length; - const logs = await readSessionLogs(bookDir, slug); - const body_html = await marked.parse(md.content); - const has_thesis = existsSync(path.join(bookDir, "thesis.md")); - const has_essay = existsSync(path.join(bookDir, "essay.md")); - return { - ...meta, - progress: { - completed, - total, - last_active: logs[0]?.date, - }, - body_html: typeof body_html === "string" ? body_html : "", - logs, - has_thesis, - has_essay, - }; +export function readBookSummary(workspace: string, slug: string) { + return serviceFor(workspace).readBookSummary(slug); } -export async function readAllBooks(workspace: string): Promise { - const slugs = await listBookDirs(workspace); - const out: BookSummary[] = []; - for (const slug of slugs) { - const s = await readBookSummary(workspace, slug); - if (s) out.push(s); - } - return out; +export function readBookDetail(workspace: string, slug: string) { + return serviceFor(workspace).readBookDetail(slug); } -const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; -const LOG_FILE_RE = /^[a-z0-9][a-z0-9._-]*\.md$/i; +export function readAllBooks(workspace: string) { + return serviceFor(workspace).readAllBooks(); +} -export async function readLogDetail( - workspace: string, - slug: string, - file: string, -): Promise { - // Both parts come straight from the URL — refuse anything that could - // escape the book's logs directory. - if (!SLUG_RE.test(slug) || !LOG_FILE_RE.test(file) || file.includes("..")) { - return null; - } - const logsDir = path.join(workspace, "books", slug, "logs"); - const absPath = path.resolve(logsDir, file); - if (!absPath.startsWith(path.resolve(logsDir) + path.sep)) return null; +export function readWorkspaceData(workspace: string) { + return serviceFor(workspace).readWorkspaceData(); +} - const md = await readMarkdown(absPath); - if (!md) return null; - const data = md.data; - const body_html = await marked.parse(md.content); - const bookMd = await readMarkdown(path.join(workspace, "books", slug, "book.md")); - return { - path: `logs/${file}`, - date: asString(data.date), - book: asString(data.book, slug), - book_title: bookMd ? asString(bookMd.data.title, slug) : undefined, - segment: typeof data.segment === "string" ? data.segment : undefined, - type: asString(data.type, "session"), - duration_approx: - typeof data.duration_approx === "string" ? data.duration_approx : undefined, - summary: md.content.trim().slice(0, 400), - outcomes: normaliseOutcomes(data.outcomes), - body_html: typeof body_html === "string" ? body_html : "", - }; +export function readLogDetail(workspace: string, slug: string, file: string) { + return serviceFor(workspace).readLogDetail(slug, file); } -export async function readAllLogs(workspace: string): Promise { - const slugs = await listBookDirs(workspace); - const all: SessionLog[] = []; - for (const slug of slugs) { - const logs = await readSessionLogs(path.join(workspace, "books", slug), slug); - all.push(...logs); - } - return all.sort((a, b) => (a.date < b.date ? 1 : -1)); +export function readAllLogs(workspace: string) { + return serviceFor(workspace).readAllLogs(); } diff --git a/App/backend/test/vault.test.mjs b/App/backend/test/vault.test.mjs new file mode 100644 index 0000000..83b66d4 --- /dev/null +++ b/App/backend/test/vault.test.mjs @@ -0,0 +1,396 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { buildStats } from "../dist/backend/src/stats.js"; +import { buildSnapshot } from "../dist/backend/src/snapshot.js"; +import { buildToday } from "../dist/backend/src/schedule.js"; +import { searchWorkspace } from "../dist/backend/src/search.js"; +import { createRefreshCoordinator } from "../dist/shared/refresh.js"; +import { buildTodayFrom } from "../dist/shared/schedule.js"; +import { buildStatsFrom } from "../dist/shared/stats.js"; +import { createVaultService } from "../dist/shared/vault.js"; +import { + readAllBooks, + readAllLogs, + readBookDetail, + readLogDetail, + readWorkspaceData, +} from "../dist/backend/src/workspace.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const workspace = path.resolve(here, "../../../Learning.example"); + +test("the shared vault core reads the public workspace end to end", async () => { + const books = await readAllBooks(workspace); + const logs = await readAllLogs(workspace); + const detail = await readBookDetail(workspace, books[0].slug); + const firstLog = logs[0]; + const logDetail = await readLogDetail( + workspace, + firstLog.book, + firstLog.path.replace("logs/", ""), + ); + const [today, stats, search] = await Promise.all([ + buildToday(workspace), + buildStats(workspace), + searchWorkspace(workspace, "feedback"), + ]); + + assert.equal(books.length, 2); + assert.equal(logs.length, 5); + assert.ok(detail?.body_html.includes("<")); + assert.ok(logDetail?.body_html.includes("<")); + assert.equal(today.pipeline.length, 2); + assert.equal(stats.totals.sessions, logs.length); + assert.deepEqual( + search.map(({ kind, title, score }) => ({ kind, title, score })), + [ + { kind: "book", title: "Systems and Feedback", score: 9 }, + { kind: "source", title: "Feedback Loops", score: 8 }, + { kind: "log", title: "Reconstruction Loops — 2026-01-11", score: 1 }, + { kind: "log", title: "Recall Loops — 2026-01-24", score: 1 }, + { kind: "cross-book", title: "Connections", score: 1 }, + ], + ); +}); + +test("log paths cannot escape the selected workspace", async () => { + assert.equal( + await readLogDetail(workspace, "clear-thinking-primer", "../book.md"), + null, + ); +}); + +test("one workspace snapshot matches the focused readers", async () => { + const todayISO = "2026-02-01"; + const [data, books, logs, snapshot] = await Promise.all([ + readWorkspaceData(workspace), + readAllBooks(workspace), + readAllLogs(workspace), + buildSnapshot(workspace, todayISO), + ]); + + assert.deepEqual(data.books, books); + assert.deepEqual(data.logs, logs); + assert.deepEqual(snapshot.books, books); + assert.deepEqual(snapshot.logs, logs); + assert.deepEqual(snapshot.today, buildTodayFrom(books, logs, todayISO)); + assert.deepEqual(snapshot.stats, buildStatsFrom(books, logs, todayISO)); +}); + +test("a workspace snapshot reads each metadata and log file once", async () => { + const files = new Map([ + [ + "books/real-book/book.md", + JSON.stringify({ + data: { + title: "Real Book", + slug: "real-book", + status: "active", + segments: [], + }, + content: "", + }), + ], + [ + "books/real-book/logs/2026-01-01-session.md", + JSON.stringify({ + data: { date: "2026-01-01", book: "real-book", type: "reading" }, + content: "One session.", + }), + ], + ]); + const reads = new Map(); + const reader = { + async readText(relativePath) { + reads.set(relativePath, (reads.get(relativePath) ?? 0) + 1); + const raw = files.get(relativePath); + if (!raw) throw new Error(`Unexpected read: ${relativePath}`); + return raw; + }, + async readDir(relativePath) { + if (relativePath === "books") { + return [ + { name: "real-book", isFile: false, isDirectory: true }, + { name: "notes", isFile: false, isDirectory: true }, + ]; + } + if (relativePath === "books/real-book/logs") { + return [ + { + name: "2026-01-01-session.md", + isFile: true, + isDirectory: false, + }, + ]; + } + throw new Error(`Missing optional directory: ${relativePath}`); + }, + async exists(relativePath) { + return relativePath === "books/real-book/book.md"; + }, + async size() { + return null; + }, + parseMarkdown(raw) { + return JSON.parse(raw); + }, + async renderMarkdown(markdown) { + return markdown; + }, + }; + + const data = await createVaultService(reader, { strict: true }).readWorkspaceData(); + + assert.deepEqual(data.books.map((book) => book.slug), ["real-book"]); + assert.equal(data.logs.length, 1); + assert.deepEqual(Object.fromEntries(reads), { + "books/real-book/book.md": 1, + "books/real-book/logs/2026-01-01-session.md": 1, + }); +}); + +test("search reuses an invalidatable index and bounds parallel file work", async () => { + const sourceFiles = Array.from( + { length: 24 }, + (_, index) => `${String(index + 1).padStart(2, "0")}-source.md`, + ); + const logFile = "2026-01-01-session.md"; + const files = new Map([ + [ + "books/real-book/book.md", + JSON.stringify({ + data: { + title: "Real Book", + slug: "real-book", + status: "active", + segments: [], + }, + content: "A quiet overview.", + }), + ], + [ + `books/real-book/logs/${logFile}`, + JSON.stringify({ data: {}, content: "Needle in the session log." }), + ], + ...sourceFiles.map((file, index) => [ + `books/real-book/source/${file}`, + JSON.stringify({ data: {}, content: `Needle in source ${index + 1}.` }), + ]), + ]); + const reads = new Map(); + let activeReads = 0; + let maxActiveReads = 0; + const reader = { + async readText(relativePath) { + reads.set(relativePath, (reads.get(relativePath) ?? 0) + 1); + activeReads++; + maxActiveReads = Math.max(maxActiveReads, activeReads); + try { + await new Promise((resolve) => setTimeout(resolve, 2)); + const raw = files.get(relativePath); + if (!raw) throw new Error(`Unexpected read: ${relativePath}`); + return raw; + } finally { + activeReads--; + } + }, + async readDir(relativePath) { + if (relativePath === "books") { + return [{ name: "real-book", isFile: false, isDirectory: true }]; + } + if (relativePath === "books/real-book/source") { + return sourceFiles.map((name) => ({ name, isFile: true, isDirectory: false })); + } + if (relativePath === "books/real-book/logs") { + return [{ name: logFile, isFile: true, isDirectory: false }]; + } + return []; + }, + async exists(relativePath) { + return relativePath === "books/real-book/book.md"; + }, + async size(relativePath) { + const raw = files.get(relativePath); + return raw ? Buffer.byteLength(raw) : null; + }, + parseMarkdown(raw) { + return JSON.parse(raw); + }, + async renderMarkdown(markdown) { + return markdown; + }, + }; + + const service = createVaultService(reader, { strict: true }); + const results = await service.search("needle"); + const readsAfterFirstSearch = [...reads.values()].reduce( + (total, count) => total + count, + 0, + ); + + assert.equal(results.length, 20); + assert.equal(reads.get(`books/real-book/logs/${logFile}`), 1); + assert.ok(sourceFiles.every((file) => reads.get(`books/real-book/source/${file}`) === 1)); + assert.ok(maxActiveReads > 1); + assert.ok(maxActiveReads <= 12); + + assert.equal((await service.search("source")).length, 20); + assert.equal( + [...reads.values()].reduce((total, count) => total + count, 0), + readsAfterFirstSearch, + ); + + service.invalidate(); + await service.search("needle"); + assert.equal(reads.get(`books/real-book/logs/${logFile}`), 2); +}); + +test("refreshes are serialized and notifications coalesce into one trailing load", async () => { + const gates = []; + const successes = []; + let calls = 0; + let active = 0; + let maxActive = 0; + const coordinator = createRefreshCoordinator({ + async load() { + const value = ++calls; + active++; + maxActive = Math.max(maxActive, active); + let release; + const waiting = new Promise((resolve) => { + release = resolve; + }); + gates.push(release); + await waiting; + active--; + return value; + }, + onSuccess(value) { + successes.push(value); + }, + onError(error) { + assert.fail(String(error)); + }, + }); + + coordinator.request(); + coordinator.request(); + coordinator.request(); + assert.equal(calls, 1); + gates[0](); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls, 2); + gates[1](); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(maxActive, 1); + assert.equal(calls, 2); + assert.deepEqual(successes, [1, 2]); +}); + +test("a failed refresh preserves the last complete snapshot", async () => { + const initial = { books: ["complete"], logs: [], today: {}, stats: {} }; + let displayed = null; + let error = null; + let call = 0; + const coordinator = createRefreshCoordinator({ + async load() { + call++; + if (call === 1) return initial; + throw new Error("snapshot failed"); + }, + onSuccess(value) { + displayed = value; + }, + onError(nextError) { + error = nextError; + }, + }); + + coordinator.request(); + await new Promise((resolve) => setImmediate(resolve)); + coordinator.request(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(displayed, initial); + assert.match(String(error), /snapshot failed/); +}); + +test("disposing a refresh coordinator suppresses stale state updates", async () => { + let release; + const waiting = new Promise((resolve) => { + release = resolve; + }); + const successes = []; + const coordinator = createRefreshCoordinator({ + async load() { + await waiting; + return "stale"; + }, + onSuccess(value) { + successes.push(value); + }, + onError(error) { + assert.fail(String(error)); + }, + }); + + coordinator.request(); + coordinator.dispose(); + release(); + await new Promise((resolve) => setImmediate(resolve)); + coordinator.request(); + + assert.deepEqual(successes, []); +}); + +test("book shelves ignore folders that do not contain book metadata", async () => { + const reader = { + async readText(relativePath) { + if (relativePath === "books/real-book/book.md") { + return [ + "---", + "title: Real Book", + "slug: real-book", + "status: queued", + "---", + "", + ].join("\n"); + } + throw new Error(`Unexpected read: ${relativePath}`); + }, + async readDir(relativePath) { + if (relativePath === "books") { + return [ + { name: "real-book", isFile: false, isDirectory: true }, + { name: "research-notes", isFile: false, isDirectory: true }, + ]; + } + throw new Error(`Missing directory: ${relativePath}`); + }, + async exists(relativePath) { + return relativePath === "books/real-book/book.md"; + }, + async size() { + return null; + }, + parseMarkdown(raw) { + return { + data: { title: "Real Book", slug: "real-book", status: "queued" }, + content: raw, + }; + }, + async renderMarkdown(markdown) { + return markdown; + }, + }; + + const service = createVaultService(reader, { strict: true }); + const books = await service.readAllBooks(); + const search = await service.search("real"); + + assert.deepEqual(books.map((book) => book.slug), ["real-book"]); + assert.deepEqual(search, []); +}); diff --git a/App/frontend/index.html b/App/frontend/index.html index 4ddb64b..02f5c1a 100644 --- a/App/frontend/index.html +++ b/App/frontend/index.html @@ -4,13 +4,8 @@ - Effortful Learning - - - + + Ignite
diff --git a/App/frontend/package.json b/App/frontend/package.json index dc12a62..568652b 100644 --- a/App/frontend/package.json +++ b/App/frontend/package.json @@ -9,8 +9,14 @@ "preview": "vite preview" }, "dependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", + "@tauri-apps/plugin-fs": "^2.0.0", + "dompurify": "^3.2.6", + "marked": "^15.0.4", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "yaml": "^2.0.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/App/frontend/pnpm-lock.yaml b/App/frontend/pnpm-lock.yaml deleted file mode 100644 index e08e051..0000000 --- a/App/frontend/pnpm-lock.yaml +++ /dev/null @@ -1,1449 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - react: - specifier: ^19.0.0 - version: 19.2.6 - react-dom: - specifier: ^19.0.0 - version: 19.2.6(react@19.2.6) - devDependencies: - '@tailwindcss/vite': - specifier: ^4.0.0 - version: 4.3.0(vite@6.4.2(jiti@2.7.0)(lightningcss@1.32.0)) - '@types/react': - specifier: ^19.0.0 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(jiti@2.7.0)(lightningcss@1.32.0)) - tailwindcss: - specifier: ^4.0.0 - version: 4.3.0 - typescript: - specifier: ^5.7.2 - version: 5.9.3 - vite: - specifier: ^6.0.3 - version: 6.4.2(jiti@2.7.0)(lightningcss@1.32.0) - -packages: - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.3': - resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - - '@rollup/rollup-android-arm-eabi@4.60.3': - resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.3': - resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.60.3': - resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.60.3': - resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.60.3': - resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.3': - resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.60.3': - resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.60.3': - resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.60.3': - resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.60.3': - resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.60.3': - resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.60.3': - resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.60.3': - resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.60.3': - resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.60.3': - resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.60.3': - resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.60.3': - resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.60.3': - resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.60.3': - resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.60.3': - resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.3': - resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.60.3': - resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.60.3': - resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.60.3': - resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.60.3': - resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} - cpu: [x64] - os: [win32] - - '@tailwindcss/node@4.3.0': - resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} - - '@tailwindcss/oxide-android-arm64@4.3.0': - resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.3.0': - resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.3.0': - resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.3.0': - resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': - resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': - resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': - resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': - resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.3.0': - resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': - resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': - resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.3.0': - resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} - engines: {node: '>= 20'} - - '@tailwindcss/vite@4.3.0': - resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - baseline-browser-mapping@2.10.29: - resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - electron-to-chromium@1.5.353: - resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} - - enhanced-resolve@5.21.2: - resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} - engines: {node: '>=10.13.0'} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} - hasBin: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} - engines: {node: ^10 || ^12 || >=14} - - react-dom@19.2.6: - resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} - peerDependencies: - react: ^19.2.6 - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react@19.2.6: - resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} - engines: {node: '>=0.10.0'} - - rollup@4.60.3: - resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - tailwindcss@4.3.0: - resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} - - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} - engines: {node: '>=6'} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - vite@6.4.2: - resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - -snapshots: - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.3': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.3 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.3': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@rolldown/pluginutils@1.0.0-beta.27': {} - - '@rollup/rollup-android-arm-eabi@4.60.3': - optional: true - - '@rollup/rollup-android-arm64@4.60.3': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.3': - optional: true - - '@rollup/rollup-darwin-x64@4.60.3': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.3': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.3': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.3': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.3': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.3': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.3': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.3': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.3': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.3': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.3': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.3': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.3': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.3': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.3': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.3': - optional: true - - '@tailwindcss/node@4.3.0': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.2 - jiti: 2.7.0 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.3.0 - - '@tailwindcss/oxide-android-arm64@4.3.0': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.3.0': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.3.0': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.3.0': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': - optional: true - - '@tailwindcss/oxide@4.3.0': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-x64': 4.3.0 - '@tailwindcss/oxide-freebsd-x64': 4.3.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-x64-musl': 4.3.0 - '@tailwindcss/oxide-wasm32-wasi': 4.3.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - - '@tailwindcss/vite@4.3.0(vite@6.4.2(jiti@2.7.0)(lightningcss@1.32.0))': - dependencies: - '@tailwindcss/node': 4.3.0 - '@tailwindcss/oxide': 4.3.0 - tailwindcss: 4.3.0 - vite: 6.4.2(jiti@2.7.0)(lightningcss@1.32.0) - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/estree@1.0.8': {} - - '@types/react-dom@19.2.3(@types/react@19.2.14)': - dependencies: - '@types/react': 19.2.14 - - '@types/react@19.2.14': - dependencies: - csstype: 3.2.3 - - '@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@2.7.0)(lightningcss@1.32.0))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.2(jiti@2.7.0)(lightningcss@1.32.0) - transitivePeerDependencies: - - supports-color - - baseline-browser-mapping@2.10.29: {} - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.29 - caniuse-lite: 1.0.30001792 - electron-to-chromium: 1.5.353 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - caniuse-lite@1.0.30001792: {} - - convert-source-map@2.0.0: {} - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - detect-libc@2.1.2: {} - - electron-to-chromium@1.5.353: {} - - enhanced-resolve@5.21.2: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escalade@3.2.0: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fsevents@2.3.3: - optional: true - - gensync@1.0.0-beta.2: {} - - graceful-fs@4.2.11: {} - - jiti@2.7.0: {} - - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - - json5@2.2.3: {} - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - ms@2.1.3: {} - - nanoid@3.3.12: {} - - node-releases@2.0.38: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - postcss@8.5.14: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - react-dom@19.2.6(react@19.2.6): - dependencies: - react: 19.2.6 - scheduler: 0.27.0 - - react-refresh@0.17.0: {} - - react@19.2.6: {} - - rollup@4.60.3: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.3 - '@rollup/rollup-android-arm64': 4.60.3 - '@rollup/rollup-darwin-arm64': 4.60.3 - '@rollup/rollup-darwin-x64': 4.60.3 - '@rollup/rollup-freebsd-arm64': 4.60.3 - '@rollup/rollup-freebsd-x64': 4.60.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 - '@rollup/rollup-linux-arm-musleabihf': 4.60.3 - '@rollup/rollup-linux-arm64-gnu': 4.60.3 - '@rollup/rollup-linux-arm64-musl': 4.60.3 - '@rollup/rollup-linux-loong64-gnu': 4.60.3 - '@rollup/rollup-linux-loong64-musl': 4.60.3 - '@rollup/rollup-linux-ppc64-gnu': 4.60.3 - '@rollup/rollup-linux-ppc64-musl': 4.60.3 - '@rollup/rollup-linux-riscv64-gnu': 4.60.3 - '@rollup/rollup-linux-riscv64-musl': 4.60.3 - '@rollup/rollup-linux-s390x-gnu': 4.60.3 - '@rollup/rollup-linux-x64-gnu': 4.60.3 - '@rollup/rollup-linux-x64-musl': 4.60.3 - '@rollup/rollup-openbsd-x64': 4.60.3 - '@rollup/rollup-openharmony-arm64': 4.60.3 - '@rollup/rollup-win32-arm64-msvc': 4.60.3 - '@rollup/rollup-win32-ia32-msvc': 4.60.3 - '@rollup/rollup-win32-x64-gnu': 4.60.3 - '@rollup/rollup-win32-x64-msvc': 4.60.3 - fsevents: 2.3.3 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - source-map-js@1.2.1: {} - - tailwindcss@4.3.0: {} - - tapable@2.3.3: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - typescript@5.9.3: {} - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - vite@6.4.2(jiti@2.7.0)(lightningcss@1.32.0): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.14 - rollup: 4.60.3 - tinyglobby: 0.2.16 - optionalDependencies: - fsevents: 2.3.3 - jiti: 2.7.0 - lightningcss: 1.32.0 - - yallist@3.1.1: {} diff --git a/App/frontend/public/favicon.ico b/App/frontend/public/favicon.ico new file mode 100644 index 0000000..516032b Binary files /dev/null and b/App/frontend/public/favicon.ico differ diff --git a/App/frontend/public/licenses/Fraunces-OFL.txt b/App/frontend/public/licenses/Fraunces-OFL.txt new file mode 100644 index 0000000..e0a3ac7 --- /dev/null +++ b/App/frontend/public/licenses/Fraunces-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/App/frontend/public/licenses/Inter-OFL.txt b/App/frontend/public/licenses/Inter-OFL.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/App/frontend/public/licenses/Inter-OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/App/frontend/public/licenses/JetBrainsMono-OFL.txt b/App/frontend/public/licenses/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..675d198 --- /dev/null +++ b/App/frontend/public/licenses/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/App/frontend/src/App.tsx b/App/frontend/src/App.tsx index c005ce8..5750479 100644 --- a/App/frontend/src/App.tsx +++ b/App/frontend/src/App.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useState } from "react"; -import type { - BookSummary, - SessionLog, - StatsPayload, - TodayPayload, -} from "../../shared/types"; +import type { WorkspaceSnapshot } from "../../shared/types"; +import { createRefreshCoordinator } from "../../shared/refresh"; import { api, subscribeWorkspace } from "./lib/api"; +import { + chooseWorkspace, + currentWorkspace, + isDesktopApp, +} from "./lib/desktop"; import { useTheme } from "./lib/theme"; import { persistView, readInitialView, type View } from "./lib/view"; import { Today } from "./components/Today"; @@ -18,12 +19,16 @@ import { LogModal, type LogRef } from "./components/LogModal"; import { SearchOverlay } from "./components/SearchOverlay"; import { ThemeToggle } from "./components/ThemeToggle"; import { ViewSwitcher } from "./components/ViewSwitcher"; +import { CodexChat } from "./components/CodexChat"; export default function App() { - const [books, setBooks] = useState(null); - const [logs, setLogs] = useState([]); - const [today, setToday] = useState(null); - const [stats, setStats] = useState(null); + const desktop = isDesktopApp(); + const [workspacePath, setWorkspacePath] = useState(() => + currentWorkspace(), + ); + const [choosingWorkspace, setChoosingWorkspace] = useState(false); + const [workspaceError, setWorkspaceError] = useState(null); + const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); const [openSlug, setOpenSlug] = useState(null); const [openLog, setOpenLog] = useState(null); @@ -36,22 +41,58 @@ export default function App() { persistView(next); }, []); - const load = useCallback(() => { - Promise.all([api.books(), api.timeline(), api.today(), api.stats()]) - .then(([b, l, t, s]) => { - setBooks(b); - setLogs(l); - setToday(t); - setStats(s); - }) - .catch((e) => setError(String(e))); + const selectWorkspace = useCallback(async () => { + setChoosingWorkspace(true); + setWorkspaceError(null); + try { + const selected = await chooseWorkspace(); + if (selected) { + setSnapshot(null); + setError(null); + setWorkspacePath(selected); + } + } catch (selectionError) { + setWorkspaceError(String(selectionError)); + } finally { + setChoosingWorkspace(false); + } }, []); useEffect(() => { - load(); - const off = subscribeWorkspace(load); - return off; - }, [load]); + if (desktop && !workspacePath) return undefined; + let disposed = false; + let unsubscribe: (() => void) | null = null; + const subscriptionAbort = new AbortController(); + const refresh = createRefreshCoordinator({ + load: () => api.snapshot(), + onSuccess: (nextSnapshot) => { + setError(null); + setSnapshot(nextSnapshot); + }, + onError: (loadError) => setError(String(loadError)), + }); + + void subscribeWorkspace(refresh.request, subscriptionAbort.signal) + .then((stop) => { + if (disposed) { + stop(); + return; + } + unsubscribe = stop; + refresh.request(); + }) + .catch((subscriptionError: unknown) => { + console.error("Could not subscribe to workspace changes", subscriptionError); + if (!disposed) refresh.request(); + }); + + return () => { + disposed = true; + subscriptionAbort.abort(); + refresh.dispose(); + unsubscribe?.(); + }; + }, [desktop, workspacePath]); const openLogFile = useCallback((book: string, file: string) => { setOpenLog({ book, file }); @@ -69,38 +110,66 @@ export default function App() { return () => document.removeEventListener("keydown", onKey); }, []); + if (desktop && !workspacePath) { + return ( + <> + + + + ); + } + return ( <> setSearchOpen(true)} + workspacePath={desktop ? workspacePath : null} + choosingWorkspace={choosingWorkspace} + onChooseWorkspace={desktop ? selectWorkspace : undefined} /> - {error ? ( - - ) : !books ? ( + {view === "chat" ? ( + + ) : error ? ( + + ) : !snapshot ? ( ) : ( <> - {view === "today" && - (today ? : )} - {view === "library" && } - {view === "kanban" && } + {view === "today" && ( + + )} + {view === "library" && ( + + )} + {view === "kanban" && ( + + )} {view === "timeline" && ( )} - {view === "stats" && - (stats ? ( - - ) : ( - - ))} + {view === "stats" && ( + + )} {openSlug && ( void; +}) { + return ( +
+
+
+

+ First launch +

+

+ Open your Learning workspace. +

+

+ Choose the folder that contains your books. The app receives read-only + access, keeps the vault outside the application, and refreshes when an + agent updates its files. +

+ + {error && ( +

+ {error} +

+ )} +
+
+
+ ); +} + function FullLoading() { return (
@@ -132,7 +247,15 @@ function FullLoading() { ); } -function FullError({ error }: { error: string }) { +function FullError({ + error, + desktop, + onChoose, +}: { + error: string; + desktop: boolean; + onChoose?: () => void; +}) { return (

- The backend could not be reached. Start it with{" "} - pnpm -C backend dev. + {desktop ? ( + "The selected workspace could not be read. You can choose it again without changing any learning files." + ) : ( + <> + The backend could not be reached. Start it with{" "} + + pnpm -C backend dev + + . + + )}

+ {onChoose && ( + + )}
         {error}
       
diff --git a/App/frontend/src/assets/fonts/Fraunces-OFL.txt b/App/frontend/src/assets/fonts/Fraunces-OFL.txt new file mode 100644 index 0000000..e0a3ac7 --- /dev/null +++ b/App/frontend/src/assets/fonts/Fraunces-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/App/frontend/src/assets/fonts/Fraunces[SOFT,WONK,opsz,wght].woff2 b/App/frontend/src/assets/fonts/Fraunces[SOFT,WONK,opsz,wght].woff2 new file mode 100644 index 0000000..c460c23 Binary files /dev/null and b/App/frontend/src/assets/fonts/Fraunces[SOFT,WONK,opsz,wght].woff2 differ diff --git a/App/frontend/src/assets/fonts/Inter-OFL.txt b/App/frontend/src/assets/fonts/Inter-OFL.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/App/frontend/src/assets/fonts/Inter-OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/App/frontend/src/assets/fonts/InterVariable.woff2 b/App/frontend/src/assets/fonts/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/App/frontend/src/assets/fonts/InterVariable.woff2 differ diff --git a/App/frontend/src/assets/fonts/JetBrainsMono-OFL.txt b/App/frontend/src/assets/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..675d198 --- /dev/null +++ b/App/frontend/src/assets/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/App/frontend/src/assets/fonts/JetBrainsMono[wght].woff2 b/App/frontend/src/assets/fonts/JetBrainsMono[wght].woff2 new file mode 100644 index 0000000..856547f Binary files /dev/null and b/App/frontend/src/assets/fonts/JetBrainsMono[wght].woff2 differ diff --git a/App/frontend/src/assets/fonts/README.md b/App/frontend/src/assets/fonts/README.md new file mode 100644 index 0000000..a6c7d8c --- /dev/null +++ b/App/frontend/src/assets/fonts/README.md @@ -0,0 +1,16 @@ +# Local fonts + +These WOFF2 files replace the app's former runtime Google Fonts request. They +are kept beside their SIL Open Font License files. + +Vite also copies the distributable license files from `public/licenses/` into +the production artifact so every packaged font ships with its license. + +- `Fraunces[SOFT,WONK,opsz,wght].woff2`: official Fraunces variable webfont, + pinned to upstream commit `7ccdec31c6028118dce3e47fe864e3744460371d`. +- `InterVariable.woff2`: official Inter 4.1 variable webfont. +- `JetBrainsMono[wght].woff2`: official JetBrains Mono variable webfont, + pinned to upstream commit `19371302b95d218af43299bce79ddbddd0bc364d`. + +The `@font-face` declarations in `fonts.css` expose only the ranges used by the +interface. Fraunces retains its `opsz`, `wght`, `SOFT`, and `WONK` axes. diff --git a/App/frontend/src/assets/fonts/fonts.css b/App/frontend/src/assets/fonts/fonts.css new file mode 100644 index 0000000..2228a57 --- /dev/null +++ b/App/frontend/src/assets/fonts/fonts.css @@ -0,0 +1,23 @@ +@font-face { + font-family: "Fraunces"; + src: url("./Fraunces[SOFT,WONK,opsz,wght].woff2") format("woff2"); + font-style: normal; + font-weight: 300 900; + font-display: swap; +} + +@font-face { + font-family: "Inter"; + src: url("./InterVariable.woff2") format("woff2"); + font-style: normal; + font-weight: 300 700; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("./JetBrainsMono[wght].woff2") format("woff2"); + font-style: normal; + font-weight: 400 500; + font-display: swap; +} diff --git a/App/frontend/src/components/BookDetail.tsx b/App/frontend/src/components/BookDetail.tsx index 9a82e06..3b4de82 100644 --- a/App/frontend/src/components/BookDetail.tsx +++ b/App/frontend/src/components/BookDetail.tsx @@ -42,7 +42,7 @@ export function BookDetail({ slug, onClose, onOpenLog }: Props) { aria-label="Close" onClick={onClose} className="absolute inset-0 cursor-default" - style={{ background: "rgba(8, 6, 4, 0.6)", backdropFilter: "blur(2px)" }} + style={{ background: "var(--backdrop)", backdropFilter: "blur(2px)" }} />