diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 03a13e8..f74deca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,8 +11,7 @@ updates: dependency-type: development ignore: # Majors are taken by hand: React, Vite, TypeScript, Tailwind, and KaTeX - # follow the pinned ZenNotes toolchain (the shell's copies are what - # app-core runs on via resolve.dedupe), and Capacitor needs a migration + # follow the pinned core package peer requirements, and Capacitor needs a migration # pass with a simulator run. KaTeX is 0.x, so its minors are majors. - dependency-name: "*" update-types: ["version-update:semver-major"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 569ec9a..dd403d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ concurrency: jobs: verify: - name: TypeScript, source pin, and iOS build + name: TypeScript, package boundary, and iOS build runs-on: macos-latest steps: - name: Check out repository @@ -29,13 +29,12 @@ jobs: - name: Install mobile dependencies run: npm ci - - name: Prepare exact ZenNotes source - run: npm run source:prepare + - name: Verify installed core packages + run: npm run boundaries:check - name: Reject high-severity production advisories run: | npm audit --omit=dev --audit-level=high - npm --prefix .zennotes-source audit --omit=dev --audit-level=high - name: Test and typecheck bridges run: | diff --git a/.gitignore b/.gitignore index afe71d0..180d8e6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ build/ docs/releases/* + +/dist-boundary-check/ diff --git a/.zennotes-commit b/.zennotes-commit deleted file mode 100644 index 261c8c9..0000000 --- a/.zennotes-commit +++ /dev/null @@ -1 +0,0 @@ -431907dfb63a59192ff414839673446564ee737e diff --git a/README.md b/README.md index 4220a80..14b3ce1 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,15 @@ the [zennotes monorepo](https://github.com/ZenNotes/zennotes)) inside a WKWebVie by a local-first vault on the device filesystem. Implements the architecture in `docs/specs/mobile/` (Phase 0 + the on-device parts of Phase 1). -The zennotes repo is consumed **read-only at the exact commit in -`.zennotes-commit`**. `npm run source:prepare` checks that commit out under the -ignored `.zennotes-source/` directory and installs its locked dependencies. -Every typecheck and release build verifies the pin; no ambient sibling checkout -can silently change a mobile binary. +The shell consumes immutable, compiled `@zennotes/app-core`, +`@zennotes/bridge-contract`, and `@zennotes/shared-domain` archives. The current +local candidates live in `vendor/zennotes`; its manifest records their source +identity and checksums. A clean checkout installs them with `npm ci`, without a +source clone or sibling repository. They have not been published. + +`npm run boundaries:check` verifies the pins, installed versions, singleton +React/CodeMirror peers, and public export usage. Native storage, iCloud, stable +vault identities, keyboard behavior, sync, and preferences remain in this repo. ## Architecture @@ -26,21 +30,21 @@ src/ events.ts VaultChangeEvent emitter (in-app writes + rescan) ui-mobile/ MobileShell.tsx bottom nav (capture ⊕ / search / sidebar / palette), - phone drawer behavior via the shared Zustand store + phone drawer behavior via public core snapshots/actions mobile.css safe areas, overlay drawers, keyboard handling ios/ Capacitor-generated Xcode project (appId md.zennotes) ``` -Key decisions (all forced by "don't modify the zennotes repo"): +Host decisions: -- **`runtime: 'web'`** — the bridge contract has no `'mobile'` runtime yet. +- **`hostKind: 'ios'`, `runtime: 'web'`** — the bridge contract has no `'mobile'` runtime yet. Every desktop-only affordance in app-core gates on `runtime === 'desktop'`, so `'web'` + the capability flags produces correct mobile behavior. When the contract gains `'mobile'` + the new capability flags (spec 02), flip it here. - **Vault location** — `Documents/ZenNotes/` in the app container (visible in the Files app via `UIFileSharingEnabled`). First run creates `My Vault` seeded with the official demo tour (imported read-only from - `apps/desktop/src/main/demo-tour-data.ts`). + `@zennotes/shared-domain/demo-tour-data`). - **On-disk contract is byte-compatible with desktop**: same folder layout (`inbox|quick|archive|trash`, `assets/`, legacy `attachements/` recognized — the misspelling is intentional and load-bearing), same `.zennotes/` @@ -48,7 +52,7 @@ Key decisions (all forced by "don't modify the zennotes repo"): rules, same NoteMeta extraction regexes. Includes desktop 2.20's `systemFolderPaths` remaps (vault.json can point `inbox` at `01 - Entry/` etc.) — classification, walking, capture targets, the drawer, and database - path composition all resolve through `@shared/system-folder-paths`, so a + path composition all resolve through `@zennotes/shared-domain/system-folder-paths`, so a remapped vault synced from a Mac files notes identically here. - **Desktop 2.20 features on mobile**: renaming a note carries its leading `# heading` along (runs in the shared store — nothing to port, verified on @@ -65,7 +69,7 @@ Key decisions (all forced by "don't modify the zennotes repo"): Settings → Editor → Text replacements), configurable tab size, manual kanban card order (`kanbanCardOrder` passes through the mobile vault.json layer verbatim). Remote reads use the shared absence-aware reader - (`@shared/remote-absence`): a 500 from a schema read surfaces as an error + (`@zennotes/shared-domain/remote-absence`): a 500 from a schema read surfaces as an error instead of adopting-and-overwriting the database sidecar; pre-2.20.2 servers that answer 500 for missing files are probed once per connection. - **TikZ** is capability-gated off (no WASM TeX on device); blocks show the @@ -77,8 +81,9 @@ Key decisions (all forced by "don't modify the zennotes repo"): ## Build & run ```sh -npm install -npm run sync # prepare pinned source + vite build + cap sync ios +npm ci +npm run boundaries:check +npm run sync # vite build + cap sync ios npx cap open ios # open in Xcode, or: xcodebuild -workspace ios/App/App.xcworkspace -scheme App \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build @@ -88,9 +93,10 @@ Dev loop against a browser (no simulator): `npm run dev` — note Capacitor plugins are absent in a plain browser, so vault I/O won't work; use the simulator for real testing. -To adopt a newer ZenNotes core, update `.zennotes-commit` to a reviewed full -commit SHA and run `npm run upstream`. Commit the pin with the mobile changes -that depend on it. +To adopt a newer core, copy the reviewed package archives and portable manifest +into `vendor/zennotes`, update the three exact dependencies, and refresh the lockfile. +Run the boundary check, tests, typecheck, and native build before changing the pin. +Retain the previous artifacts for rollback. Never resolve a mutable branch at build time. ## What works today (verified on the iPhone 17 Pro simulator) @@ -135,8 +141,7 @@ that depend on it. - The spec-06 **editing toolbar** docked above the soft keyboard (undo/redo, checkbox, bullet, heading cycle, bold/italic/highlight/code, link, wikilink, - tag, indent/outdent, dismiss) — drives the shared editor via the store's - `editorViewRef` + app-core's `lib/cm-format.ts`; auto-hides with a hardware + tag, indent/outdent, dismiss) — drives the shared editor through named public commands; auto-hides with a hardware keyboard - **Long-press context menus**: a 450ms press on chrome surfaces synthesizes the `contextmenu` event the desktop handlers already listen for (the @@ -213,8 +218,8 @@ kept off the object-storage request and a five-minute mobile transfer timeout. ## Release verification -Pull requests and `main` run bridge tests, a pinned-source typecheck, -production dependency audits for both repositories, a Capacitor sync, and an +Pull requests and `main` run bridge tests, an installed-package typecheck, +production dependency audits, a Capacitor sync, and an Xcode `build-for-testing` of the app and Cloud UI-test targets. Dependabot opens weekly npm and GitHub Actions updates. @@ -232,3 +237,5 @@ signed object upload, completion, manifest, and cleanup with a deterministic - Home-screen widget / App Shortcuts capture entry points - iPad split view (two notes side by side); Android (Phase 2) - Store distribution work (signing, TestFlight, App Store listing — spec 08) + +For device-level package checks, see [native boundary validation](docs/native-boundary-validation.md). diff --git a/docs/native-boundary-validation.md b/docs/native-boundary-validation.md new file mode 100644 index 0000000..a81ef7a --- /dev/null +++ b/docs/native-boundary-validation.md @@ -0,0 +1,60 @@ +# Native package boundary validation + +The host consumes the three archives pinned in `vendor/zennotes/manifest.json`. +There is no main-repository checkout or private editor/store import in the build. + +## Package checks + +```sh +npm ci +npm run boundaries:check +npm run typecheck +npm test +npm run build +``` + +Run the same checks in a fresh copy containing the package manifest/lock, vendor +archives, source, public assets, tooling, TypeScript/Vite/Tailwind/PostCSS config, +and native project. Omit `node_modules` and any historical source clone. + +## Disposable native runtime + +Use a newly created simulator/emulator with no real account or vault. The fixture +creates a uniquely named test vault and writes notes and attachments. Do not put +this fixture in a release or install it on a personal device. + +1. Run `npm run build:boundary-fixture`. Only this explicit command adds + `tooling/native-boundary-fixture.ts` to the app; ordinary `npm run build` does not. +2. In a disposable checkout, copy `dist-boundary-check/` into `dist/`, then run + `npx cap sync ios` and build the native debug/simulator app as below. +3. Install and launch on the disposable device. The fixture checks native typing, + exact Unicode and trailing whitespace, search, task observation, attachments, + note rename, comments, trash/restore, and whole-vault rename under the public + workspace transition lock. +4. Read `Documents/boundary-validation.json` in the app data container. It must + report `passed-awaiting-restart` with 20 checks and no error. +5. Terminate and relaunch the app without clearing its data. The report must now + be `restart-passed`, including vault identity, selected note and exact bytes. +6. Remove the disposable device when finished. Before a normal build, use + `npm run build` and `npx cap sync ios` to replace the fixture assets. + +The fixture uses public core APIs and the native filesystem bridge. It needs no +account credentials. It does not prove live Cloud sync, iCloud account behavior, +or every third-party storage provider; those remain separate release checks. + +## iOS native checks + +From the repository root, on a configured Xcode installation: + +```sh +xcodebuild build-for-testing -workspace ios/App/App.xcworkspace \ + -scheme AppCloudUITests -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath /tmp/zn-boundary-ios-build CODE_SIGNING_ALLOWED=NO +``` + +Install the simulator app on a newly created simulator with `simctl install`. +Use `simctl get_app_container DEVICE md.zennotes data` to locate the report. +Do not run account-backed Cloud UI tests against a personal account as part of +this fixture. iCloud remains native-host-owned and requires a separate test +account/device validation; package adoption does not change its implementation. diff --git a/docs/releases/v1.9.8/APP_STORE_DESCRIPTION.txt b/docs/releases/v1.9.8/APP_STORE_DESCRIPTION.txt new file mode 100644 index 0000000..28985ef --- /dev/null +++ b/docs/releases/v1.9.8/APP_STORE_DESCRIPTION.txt @@ -0,0 +1,42 @@ +ZenNotes is a local-first markdown notes app for writing, organizing, and connecting ideas without giving up ownership of your files. + +YOUR NOTES, YOUR FILES + +Every note is a plain .md file in a vault you control. Start with storage on your iPhone or iPad, use iCloud Drive, or open a folder through the system document picker. Local and iCloud vaults work without an account. + +ZENNOTES CLOUD + +Connect an optional ZenNotes Cloud plan to: +• Sync a vault across ZenNotes desktop and mobile +• Sync notes and attachments +• Create manual and automatic daily backups +• Restore a full vault or a single note from a backup +• Publish notes to the web and manage their public links + +Cloud never replaces the free local-first workflow. Use it only when you want hosted sync, backup, or publishing. + +WRITE IN MARKDOWN + +Use a focused editor with headings, lists, tasks, tables, code, links, wikilinks, tags, callouts, footnotes, and frontmatter. A mobile formatting bar keeps common actions close to the keyboard, and a pinch resizes the text to your eyes. + +RICH, OFFLINE PREVIEW + +Render KaTeX math, Mermaid diagrams, JSXGraph, function plots, tables, callouts, and more directly on your device. + +ORGANIZE YOUR WAY + +Pin your go-to notes and folders to the top, swipe rows to act on them, create and rename nested folders, browse tags, search the full vault, manage tasks, create periodic notes, and work with table databases stored as CSV. + +CAPTURE QUICKLY + +Create a quick note from the app or send text and links to ZenNotes from the iOS share sheet. + +HOME SCREEN WIDGETS + +Start a note, open a recent one, or check today's tasks without opening the app. New Note also lives on the Lock Screen, and the widgets wear your theme. + +PRIVACY BY DEFAULT + +ZenNotes has no advertising or tracking. Local and iCloud notes stay in the storage you choose. When you enable ZenNotes Cloud, the account identity, connected-device information, and vault content needed for the Cloud features you use are sent to the ZenNotes service. Payment details are handled by Stripe. + +ZenNotes is open source. Your vault remains useful outside the app because it is made of ordinary files. diff --git a/docs/releases/v1.9.8/APP_STORE_METADATA.md b/docs/releases/v1.9.8/APP_STORE_METADATA.md new file mode 100644 index 0000000..ce5237f --- /dev/null +++ b/docs/releases/v1.9.8/APP_STORE_METADATA.md @@ -0,0 +1,95 @@ +# App Store Connect metadata: ZenNotes 1.9.8 + +| Field | Value | Limit | +| --- | --- | --- | +| Name | ZenNotes: Markdown Notes | 30 | +| Subtitle | Plain-file notes, math, tasks | 30 | +| Category | Productivity (secondary: Utilities) | n/a | +| Keywords | markdown,notes,widgets,sync,backup,offline,icloud,wikilink,math,tasks,vault,editor | 100 | +| Promotional text | see `PROMOTIONAL_TEXT.txt` | 170 | +| Description | see `APP_STORE_DESCRIPTION.txt` (updated: widgets section) | 4000 | +| What's New | see `WHATS_NEW.txt` | 4000 | +| Review notes | see `APP_STORE_REVIEW_NOTES.txt` (no account required) | n/a | +| Support URL | https://github.com/ZenNotes/zennotes/issues | n/a | +| Marketing URL | https://zennotes.org | n/a | +| Privacy policy URL | Unchanged from 1.9 | n/a | +| Age rating | Unchanged from 1.9 (4+) | n/a | +| Price | Free app; optional external SaaS subscription | n/a | +| Version | 1.9.8 (build 19) | n/a | + +## App Privacy + +**Unchanged from 1.9.7.** The widgets read a summary file the app writes +into its App Group container on the device: note titles, paths and +modification dates, today's task lines, and the theme's colors. No note +bodies, nothing off the device, no new processing. From app core 2.46, +comment authorship is a name stored in the note's own comment file inside +your vault, and saved Tasks filters are a device preference. No new +permissions, data collection, accounts, background modes, network +services, or third-party SDKs. The App Group (`group.md.zennotes`) was +already in use by the Share Extension; the new extension only adds a +second reader. + +## Release checks + +- Version 1.9.8 (build 19) is set in all six Xcode build configurations + (App, ShareExtension, ZenWidgets × Debug/Release), committed on its own + after the widget commit. Build 19 is unused: the last archive in Xcode + is 1.9.7 (18) from 2026-09-08, and no 1.9.8 build was ever archived or + uploaded. +- Branch: `release/1.9.8` off main, opened as `release/1.9.9` on the + assumption that 1.9.8 had shipped and renamed on 2026-09-09. Commits: + the widgets, the comment-sidecar fix, the version bump, this pack. main + already carried the + a3e638fc pin (app core 2.46.0 plus the js-yaml / svgo lockfile fix) + and the shell's own js-yaml bump. +- Supersedes the pin-only `release/1.9.8` still on origin (1.9.8 build 19 + at the `v2.46.0` tag `da59c372`, pack "app core 2.46"), which was never + archived, submitted, or merged; its local copy was deleted when this + branch took the name, so the first push needs `--force-with-lease`. 1.9.7 shipped on app core 2.45, so its + five phone-visible changes are folded into this pack's What's New, + review notes, and release notes. +- Pin: `.zennotes-commit` = `a3e638fc`, on ZenNotes/zennotes `main`. + Between 1.9.7's pin (`3301a29d`, one past `v2.45.0`) and this one, + app-core gains comment threads with authors and replies, the @ menu + calendar, saved Tasks filters, the folder-based Kanban board, and the + two math fixes; the keymap unbind and ignored-keys features are desktop + surfaces; the bridge contract gains optional comment fields (`author`, + `parentId`) and the `kanbanFolderRoot` view setting. +- Manual mirror (`git diff --stat 3301a29d..a3e638fc -- packages/bridge-contract + apps/desktop/src/main/vault.ts`): desktop `vault.ts` dropped its private + comment normalizer for the shared `@shared/note-comments`, which keeps + `author` and `parentId`. The shell's `MobileVault.writeNoteComments` + still rebuilt each record from a fixed field list, and app-core hands + over the whole list on every comment action, so one reply, resolve, or + delete on the phone would have flattened every thread and dropped every + name the desktop or an assistant had written. Fixed in this release: + `src/bridge/vault-fs.ts` reads and writes the sidecar through the shared + normalizer, as desktop does. `kanbanFolderRoot` needs nothing: the shell + keeps no field list for view settings. +- New target: `ZenWidgets`, bundle `md.zennotes.ZenWidgets`, deployment + target 15.0 like the rest, embedded in Embed Foundation Extensions, App + Group entitlement only, privacy manifest with no required-reason APIs. + Wired by `tooling/add-widget-extension.rb`; `add-privacy-manifests.rb` + knows the target. +- Verified 2026-09-08 on the iPhone 17 Pro simulator (iOS 26.5): the + gallery previews, all three widgets placed, warm and cold taps land on + the right note and task line, a new note from the widget, the widgets + updating within seconds of a change; xcodebuild clean with no warnings + in the new files; boot-path chunking check unchanged. +- Re-verified 2026-09-09 after renumbering 1.9.9 (20) to 1.9.8 (19) and + the comment-sidecar fix: `npm test` 45/45, `npm run typecheck` clean at + the pin, `npm run build` clean, and `dist/index.html` modulepreloads + unchanged (rolldown runtime, app-local-assets, vendor-react, + vendor-editor, app-wikilinks, markdown-lines; no mermaid, + vendor-markdown, or vendor-highlight chunk). The pbxproj change is the + twelve version strings only. `MobileVault` cannot run under `node + --test` (path aliases), so the fix rests on the typecheck, the bundle, + and upstream's own `note-comments.test.ts`; not re-run on the simulator. +- Not verified on a device or on iOS 15/16 hardware for this release. The + app-core 2.46 changes were verified on the desktop build for the 2.46.0 + release, not on the phone. +- Matching port: Android ZenNotes/zennotesandroid 1.1.18 (versionCode + 20): the widgets, with its Play notes also carrying the app core 2.46 + changes from 1.1.17. Its release notes say "in step with iPhone 1.9.9"; + that is this release. diff --git a/docs/releases/v1.9.8/APP_STORE_REVIEW_NOTES.txt b/docs/releases/v1.9.8/APP_STORE_REVIEW_NOTES.txt new file mode 100644 index 0000000..bf9eb67 --- /dev/null +++ b/docs/releases/v1.9.8/APP_STORE_REVIEW_NOTES.txt @@ -0,0 +1,14 @@ +ZenNotes 1.9.8 adds Home Screen and Lock Screen widgets and moves the shared app core from 2.45 to 2.46. No new permissions, data collection, accounts, network services, or privacy-answer changes. + +WHAT CHANGED +- A new WidgetKit extension (bundle md.zennotes.ZenWidgets) offers three widgets: New Note (small, plus Lock Screen circular, rectangular, and inline on iOS 16+), Recent Notes (medium, large), and Today's Tasks (medium, large). +- The widgets render a small summary the app writes into its existing App Group container (group.md.zennotes, already used by the Share Extension): note titles, paths, and dates; task lines; the theme's colors. Note bodies never leave the app's own storage, and nothing is sent anywhere. +- Tapping a widget opens the app through its existing zennotes:// URL scheme: New Note creates a note in the Inbox and opens it; a Recent Notes row opens that note; a task row opens the note at that task's line; the tasks header opens the Tasks view. +- The shared app core moves from 2.45 to 2.46: comment threads that show who wrote each entry, a calendar in the editor's @ date menu, saved Tasks filters, a Kanban board with one column per folder, and math rendering fixes. Editor and view behavior inside the web bundle only. The optional ZenNotes Cloud subscription is unchanged. + +REVIEWER STEPS +1. First launch: choose "On this iPhone"; the welcome note opens. No account is required. +2. Long-press the Home Screen, tap Edit, then Add Widget, and search "ZenNotes". Three widgets are listed; add any of them. +3. New Note: tap it; the app opens a fresh note with its title field focused. Recent Notes: tap a row; that note opens. Today's Tasks: the welcome note's checklist appears; tap a row and the note opens on that line. +4. Optional, iOS 16+: long-press the Lock Screen, Customize, Lock Screen, tap the widget area, and add ZenNotes' New Note. +5. Everything else is unchanged from 1.9.7. diff --git a/docs/releases/v1.9.8/PROMOTIONAL_TEXT.txt b/docs/releases/v1.9.8/PROMOTIONAL_TEXT.txt new file mode 100644 index 0000000..e695b7f --- /dev/null +++ b/docs/releases/v1.9.8/PROMOTIONAL_TEXT.txt @@ -0,0 +1 @@ +Widgets: start a note, open a recent one, or see today's tasks from the Home Screen and the Lock Screen. App core 2.46 underneath. diff --git a/docs/releases/v1.9.8/RELEASE_NOTES.md b/docs/releases/v1.9.8/RELEASE_NOTES.md new file mode 100644 index 0000000..e2c5805 --- /dev/null +++ b/docs/releases/v1.9.8/RELEASE_NOTES.md @@ -0,0 +1,75 @@ +# ZenNotes for iPhone and iPad 1.9.8: widgets and app core 2.46 + +Home Screen and Lock Screen widgets, the first native surface the shell +adds beyond the share sheet. Three of them, zero configuration, wearing +whatever theme the app wears. Underneath, the app core moves from 2.45.0 +to 2.46.0: the pin release that was prepared as 1.9.8 and never shipped +on its own rides along here. + +## What changes on the phone + +- **New Note.** A small Home Screen widget, and on iOS 16 and later a + Lock Screen circle, rectangle, or inline line. One tap creates a note + in the Inbox and opens it with the title focused, the same path as the + ⊕ sheet's New note. +- **Recent Notes.** Medium or large. Your pinned notes first, in the + order you pinned them (the drawer's own rule), then the ones you edited + last, each with its "3h ago" stamp. A row opens the note; the + in the + header starts a new one. +- **Today's Tasks.** Medium or large. The Home dashboard's Today bucket: + due today, overdue, or undated, with overdue tasks first and their + count in the header. A row lands on the task's line in its note; the + header opens the Tasks view. "All clear" when nothing is due. +- **Live.** The widgets update within seconds of a change: a saved note, + a pinned one, a ticked task, a theme switch. Time stamps keep counting + between updates. + +## App core 2.46 + +- **Comment threads with names.** A reply files under the comment it + answers and every entry shows who wrote it. An assistant connected over + MCP on your desktop can answer in the same threads (list_comments, + add_comment, reply_to_comment, resolve_comment); its replies arrive on + the phone through your synced vault, signed with its name. +- **Pick any date from the @ menu.** The list ends with Date…, which opens + a calendar; Today, Yesterday, Tomorrow and Now stay one tap away. +- **Saved Tasks filters.** Name a filter once and recall it from the chips + under the Tasks header; the list lives in your preferences. +- **The Kanban Folder board** gives every note folder its own column, or + the children of one folder you name (kanban_folder_root). +- **Math.** A $$ block inside a callout renders in both views, and Typst + formulas match KaTeX's size with square-root bars in the text color. + +Not on the phone: the desktop-only keymap changes (Unbind, ignored keys), +the CLI fix and the remote-template routes, which need a desktop or a +ZenNotes server. + +## Under the hood + +- A WidgetKit extension target, `ZenWidgets` (`md.zennotes.ZenWidgets`), + wired by `tooling/add-widget-extension.rb` the way the Share Extension + is; deployment target stays iOS 15.0, with the Lock Screen families + gated to 16+ and the container background API to 17+. +- A widget cannot see the vault, so the shell publishes a snapshot into + the App Group it already shares with the Share Extension: pinned and + recent note titles, paths and dates; today's tasks; the theme's colors. + No note bodies. `src/bridge/widgets.ts` publishes on change, throttled + to one reload per 8 s while typing and flushed on backgrounding; + `WidgetBridgePlugin.swift` writes the file and reloads the timelines. +- Taps are `zennotes://` links the shell runs after the workspace + restores, so the cold-launch landing runs first and the link wins. The + newest link wins while booting. +- Pinned to upstream `a3e638fc`, on ZenNotes/zennotes `main`: app core + 2.46.0 plus the js-yaml and svgo lockfile fix for the advisories + published on 2026-09-08. The bridge contract gains optional comment + fields (`author`, `parentId`) and the `kanbanFolderRoot` view setting. + The on-device vault now reads and writes the comment sidecar through + the same shared normalizer desktop uses, so a comment action on the + phone keeps the names and threads instead of rebuilding each record + from the old field list. Harper stays off the phone. + +No new permissions, services, or data collection. Local-first storage and +iCloud Drive continue to work without an account; self-hosted and +ZenNotes Cloud vaults remain optional. The same release ships on Android +as ZenNotes 1.1.18: the widgets, with the app core 2.46 changes from +1.1.17 in its notes. diff --git a/docs/releases/v1.9.8/SOCIAL.md b/docs/releases/v1.9.8/SOCIAL.md new file mode 100644 index 0000000..3ccd014 --- /dev/null +++ b/docs/releases/v1.9.8/SOCIAL.md @@ -0,0 +1,20 @@ +# Social copy: ZenNotes iPhone 1.9.8 + +## Short + +ZenNotes for iPhone and iPad 1.9.8 adds widgets: start a note, open a +recent one, or see today's tasks from the Home Screen, and start a note +from the Lock Screen. App core 2.46 underneath. + +## Long + +ZenNotes for iPhone and iPad 1.9.8 puts your notes on the Home Screen. +New Note starts a note in your Inbox with one tap, on the Home Screen or +the Lock Screen. Recent Notes shows your pinned notes first, then the +ones you edited last, with a + to start another. Today's Tasks shows +what's due today, overdue first, and lands you on the task's line when +you tap it. The widgets wear your theme and update within seconds of a +change. Underneath, app core 2.46: comment threads with names, a +calendar in the @ menu, saved Tasks filters, and a Kanban board with one +column per folder. Nothing new is collected: the widgets read a small +summary stored on your device, never your note bodies. diff --git a/docs/releases/v1.9.8/WHATS_NEW.txt b/docs/releases/v1.9.8/WHATS_NEW.txt new file mode 100644 index 0000000..77b311c --- /dev/null +++ b/docs/releases/v1.9.8/WHATS_NEW.txt @@ -0,0 +1,16 @@ +ZenNotes 1.9.8 puts your notes on the Home Screen, and brings app core 2.46 to the phone. + +• New Note widget. One tap starts a note in your Inbox with the title ready to type. Small on the Home Screen; on iOS 16 and later also as a Lock Screen circle, rectangle, or inline line. +• Recent Notes widget. Your pinned notes first, then the ones you edited last, with a + to start a new one. Tap a row to open it. Medium or large. +• Today's Tasks widget. What's due today, overdue, or waiting for a date, overdue first, with the count in the header. Tap a task to land on its line; tap the header for the Tasks view. +• The widgets wear your theme and update within seconds of a change, so the Home Screen stays current without opening the app. + +Underneath, app core 2.46: + +• Comment threads with names. A reply files under the comment it answers, every entry shows who wrote it, and an assistant connected over MCP on your desktop can answer in the same threads; its replies arrive on the phone through your synced vault, signed with its name. +• Pick any date from the @ menu. Date… opens a calendar; Today, Yesterday, Tomorrow and Now stay one tap away. +• Saved Tasks filters. Name a filter once and recall it from the chips under the Tasks header. +• The Kanban Folder board gives every note folder its own column, or the children of one folder you name. +• Display math renders inside callouts, and Typst formulas match KaTeX's size, with square-root bars in the text color. + +No new permissions or data collection: the widgets read a small summary stored on your device, never your note bodies. Local and iCloud vaults work without an account. diff --git a/docs/releases/v1.9.8/twitter-post.md b/docs/releases/v1.9.8/twitter-post.md new file mode 100644 index 0000000..42d6d8e --- /dev/null +++ b/docs/releases/v1.9.8/twitter-post.md @@ -0,0 +1,5 @@ +ZenNotes for iPhone and iPad 1.9.8 is out. + +Widgets. New Note on the Home Screen and the Lock Screen, one tap to a fresh note. Recent Notes, pinned first. Today's Tasks, overdue first, a tap away from the task's line. They wear your theme and update within seconds of a change. + +App core 2.46 underneath: comment threads with names, a calendar in the @ menu, saved Tasks filters, a Kanban board with one column per folder. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index f6b48e8..b4d34ef 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -7,28 +7,51 @@ objects = { /* Begin PBXBuildFile section */ + 1620DB6173A8CFE3D41D46DA /* ZenWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F342B601563BB02825474641 /* ZenWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 172569C163FC68A0C0C58F02 /* WidgetTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3175A5673A3065A7A684CC9D /* WidgetTheme.swift */; }; 230C7E4950AA2E50A25A394C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F7650E04BDA8754AB2119967 /* PrivacyInfo.xcprivacy */; }; 283DF465CD667CDB34C0DE25 /* CloudFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CCEA5991955FBC0360 /* CloudFlowUITests.swift */; }; + 283DF466CD667CDB34C0DE25 /* DeepLinkUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */; }; + 283DF467CD667CDB34C0DE25 /* FavoriteUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CEEA5991955FBC0360 /* FavoriteUITests.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 3322BE59AC62263A587F4D6B /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 85847153B31EF561562A9281 /* SwiftUI.framework */; }; + 4444E1477FBE380A04E0BFE0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A7702CA7CC6312CAD599CD7 /* Foundation.framework */; }; 46516E19362E75EC2801386B /* ShareInboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80582C35DDDDF14031E4EA94 /* ShareInboxPlugin.swift */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; - FB56F003004840A2A553E931 /* KeyboardBackdropPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F5A2D64394F429AF0ED50 /* KeyboardBackdropPlugin.swift */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 5CA8650C3B7DF616A8B69229 /* ZNViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24C226FEA7EA30D28C20477 /* ZNViewController.swift */; }; + 5CA8650D3B7DF616A8B69229 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24C2270EA7EA30D28C20477 /* SceneDelegate.swift */; }; + 60BB3C8B9335A8DF5C0C752D /* NewNoteWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 486736422F5CB5716CAA674F /* NewNoteWidget.swift */; }; + 66714DDFC6EB0A9732DE176A /* WidgetBridgePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830C64B43794EBCBD9091276 /* WidgetBridgePlugin.swift */; }; + 6B30A84670984EC66F0422E2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6A7467BA76B7322966D7EA03 /* PrivacyInfo.xcprivacy */; }; + 80DB78E0507751992FCE4245 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4ED02E2781BE90C097D648B8 /* WidgetKit.framework */; }; 88E96F6793476380FB4028C7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D61B685B388CE720F2588065 /* PrivacyInfo.xcprivacy */; }; + 9929F4C805DC04F7A7F2058C /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EAB64B0086A91CBCB3B5802 /* WidgetSnapshot.swift */; }; 9B5224B5172D08349CEDEFFC /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018C9D85A9EF4E14FC95804A /* ShareViewController.swift */; }; + 9F314931CA3D31AAFCF26E15 /* TasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA702ECFEE20C3DACF6336FB /* TasksWidget.swift */; }; A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; + A4CABD8562655E4574EA3944 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB10756BA75711FC150E34FD /* Assets.xcassets */; }; B1F519EA73BD94F20A3F1DD1 /* ICloudVaultPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA3D1BD7D10EE9B188D5FBF /* ICloudVaultPlugin.swift */; }; + B516881658A9D3C9AFC1259E /* RecentNotesWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81BE1DA68F6223E14C0761AA /* RecentNotesWidget.swift */; }; BA7E23B65E109265261E0F5E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 155E04D763AD80DA2E56D38D /* Foundation.framework */; }; D17E4E8551A97CF08FDE5F82 /* FolderPickerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A91962841901607F2906263 /* FolderPickerPlugin.swift */; }; DE19FCF1249C6A4D528227C2 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E1424FEA5946506EA505941C /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F59E347AA2D96EB117D7C2CD /* ZenWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C218F6CD95CD249E2926D1DA /* ZenWidgetsBundle.swift */; }; + FB56F003004840A2A553E931 /* KeyboardBackdropPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F5A2D64394F429AF0ED50 /* KeyboardBackdropPlugin.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 604D2CAAF799146B5F1A1C7F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = A0081C14C7FAA80567CB18EC; + remoteInfo = ZenWidgets; + }; 7042DBA548F62B8A89076836 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 504EC2FC1FED79650016851F /* Project object */; @@ -53,6 +76,7 @@ dstSubfolderSpec = 13; files = ( DE19FCF1249C6A4D528227C2 /* ShareExtension.appex in Embed Foundation Extensions */, + 1620DB6173A8CFE3D41D46DA /* ZenWidgets.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -64,7 +88,11 @@ 10A78CB5D81347F3CBBC9579 /* AppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 155E04D763AD80DA2E56D38D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 3175A5673A3065A7A684CC9D /* WidgetTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetTheme.swift; sourceTree = ""; }; + 3A7702CA7CC6312CAD599CD7 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 424F5A2D64394F429AF0ED50 /* KeyboardBackdropPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KeyboardBackdropPlugin.swift; sourceTree = ""; }; + 486736422F5CB5716CAA674F /* NewNoteWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewNoteWidget.swift; sourceTree = ""; }; + 4ED02E2781BE90C097D648B8 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/WidgetKit.framework; sourceTree = DEVELOPER_DIR; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -74,15 +102,29 @@ 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; 56C2868F946FC6AE7F918E2A /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6A7467BA76B7322966D7EA03 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 7B09FFFEE67FC0E73BE167CD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 80582C35DDDDF14031E4EA94 /* ShareInboxPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInboxPlugin.swift; sourceTree = ""; }; + 81BE1DA68F6223E14C0761AA /* RecentNotesWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecentNotesWidget.swift; sourceTree = ""; }; + 830C64B43794EBCBD9091276 /* WidgetBridgePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetBridgePlugin.swift; sourceTree = ""; }; + 85847153B31EF561562A9281 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/SwiftUI.framework; sourceTree = DEVELOPER_DIR; }; 8A91962841901607F2906263 /* FolderPickerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FolderPickerPlugin.swift; sourceTree = ""; }; + 8EAB64B0086A91CBCB3B5802 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; A24C226FEA7EA30D28C20477 /* ZNViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ZNViewController.swift; sourceTree = ""; }; + A24C2270EA7EA30D28C20477 /* SceneDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + C218F6CD95CD249E2926D1DA /* ZenWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ZenWidgetsBundle.swift; sourceTree = ""; }; CC30A5CCEA5991955FBC0360 /* CloudFlowUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudFlowUITests.swift; sourceTree = ""; }; + CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeepLinkUITests.swift; sourceTree = ""; }; + CC30A5CEEA5991955FBC0360 /* FavoriteUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FavoriteUITests.swift; sourceTree = ""; }; D61B685B388CE720F2588065 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; D956FF4D03CEB97B048B0860 /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; + DB10756BA75711FC150E34FD /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E1424FEA5946506EA505941C /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + E826831F57D7B9D9D5121461 /* ZenWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ZenWidgets.entitlements; sourceTree = ""; }; + EA702ECFEE20C3DACF6336FB /* TasksWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TasksWidget.swift; sourceTree = ""; }; + F342B601563BB02825474641 /* ZenWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ZenWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F7650E04BDA8754AB2119967 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; FAA3D1BD7D10EE9B188D5FBF /* ICloudVaultPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ICloudVaultPlugin.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; @@ -97,6 +139,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 75067D2C3F9B2DE5E3FA6FE6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4444E1477FBE380A04E0BFE0 /* Foundation.framework in Frameworks */, + 80DB78E0507751992FCE4245 /* WidgetKit.framework in Frameworks */, + 3322BE59AC62263A587F4D6B /* SwiftUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 7D23A80D4913A33A86B77D59 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -133,6 +185,7 @@ 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, A9B8728D56A50AB5408DE0FB /* ShareExtension */, BBA22C854FA25C0FEB3E21B8 /* AppUITests */, + E09334D859B0854D0FE3C5FE /* ZenWidgets */, ); sourceTree = ""; }; @@ -142,6 +195,7 @@ 504EC3041FED79650016851F /* App.app */, E1424FEA5946506EA505941C /* ShareExtension.appex */, 10A78CB5D81347F3CBBC9579 /* AppUITests.xctest */, + F342B601563BB02825474641 /* ZenWidgets.appex */, ); name = Products; sourceTree = ""; @@ -151,6 +205,7 @@ children = ( 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + A24C2270EA7EA30D28C20477 /* SceneDelegate.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -163,6 +218,7 @@ FAA3D1BD7D10EE9B188D5FBF /* ICloudVaultPlugin.swift */, 8A91962841901607F2906263 /* FolderPickerPlugin.swift */, D61B685B388CE720F2588065 /* PrivacyInfo.xcprivacy */, + 830C64B43794EBCBD9091276 /* WidgetBridgePlugin.swift */, ); path = App; sourceTree = ""; @@ -180,6 +236,9 @@ isa = PBXGroup; children = ( 155E04D763AD80DA2E56D38D /* Foundation.framework */, + 3A7702CA7CC6312CAD599CD7 /* Foundation.framework */, + 4ED02E2781BE90C097D648B8 /* WidgetKit.framework */, + 85847153B31EF561562A9281 /* SwiftUI.framework */, ); name = iOS; sourceTree = ""; @@ -200,11 +259,31 @@ isa = PBXGroup; children = ( CC30A5CCEA5991955FBC0360 /* CloudFlowUITests.swift */, + CC30A5CDEA5991955FBC0360 /* DeepLinkUITests.swift */, + CC30A5CEEA5991955FBC0360 /* FavoriteUITests.swift */, ); name = AppUITests; path = AppUITests; sourceTree = ""; }; + E09334D859B0854D0FE3C5FE /* ZenWidgets */ = { + isa = PBXGroup; + children = ( + C218F6CD95CD249E2926D1DA /* ZenWidgetsBundle.swift */, + 8EAB64B0086A91CBCB3B5802 /* WidgetSnapshot.swift */, + 3175A5673A3065A7A684CC9D /* WidgetTheme.swift */, + 486736422F5CB5716CAA674F /* NewNoteWidget.swift */, + 81BE1DA68F6223E14C0761AA /* RecentNotesWidget.swift */, + EA702ECFEE20C3DACF6336FB /* TasksWidget.swift */, + 7B09FFFEE67FC0E73BE167CD /* Info.plist */, + E826831F57D7B9D9D5121461 /* ZenWidgets.entitlements */, + DB10756BA75711FC150E34FD /* Assets.xcassets */, + 6A7467BA76B7322966D7EA03 /* PrivacyInfo.xcprivacy */, + ); + name = ZenWidgets; + path = ZenWidgets; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -241,12 +320,30 @@ ); dependencies = ( 328F1E5314F407002A3AD7DE /* PBXTargetDependency */, + B60643996DA006DC755EB4F2 /* PBXTargetDependency */, ); name = App; productName = App; productReference = 504EC3041FED79650016851F /* App.app */; productType = "com.apple.product-type.application"; }; + A0081C14C7FAA80567CB18EC /* ZenWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7C8A1DA0D090CC1FD271B28F /* Build configuration list for PBXNativeTarget "ZenWidgets" */; + buildPhases = ( + 212E05C406E5D787E692B6A3 /* Sources */, + 75067D2C3F9B2DE5E3FA6FE6 /* Frameworks */, + 561EA786F4F8154947CC511A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ZenWidgets; + productName = ZenWidgets; + productReference = F342B601563BB02825474641 /* ZenWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; C4333346707B0BA0DC86389D /* ShareExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 0A1F00A0FA0C6A15D03A4E31 /* Build configuration list for PBXNativeTarget "ShareExtension" */; @@ -296,6 +393,7 @@ 504EC3031FED79650016851F /* App */, C4333346707B0BA0DC86389D /* ShareExtension */, 1D1E21A139C8A67B180DBB69 /* AppUITests */, + A0081C14C7FAA80567CB18EC /* ZenWidgets */, ); }; /* End PBXProject section */ @@ -315,6 +413,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 561EA786F4F8154947CC511A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A4CABD8562655E4574EA3944 /* Assets.xcassets in Resources */, + 6B30A84670984EC66F0422E2 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B09E33C2E6C9AEE8E88B8710 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -377,16 +484,31 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 212E05C406E5D787E692B6A3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F59E347AA2D96EB117D7C2CD /* ZenWidgetsBundle.swift in Sources */, + 9929F4C805DC04F7A7F2058C /* WidgetSnapshot.swift in Sources */, + 172569C163FC68A0C0C58F02 /* WidgetTheme.swift in Sources */, + 60BB3C8B9335A8DF5C0C752D /* NewNoteWidget.swift in Sources */, + B516881658A9D3C9AFC1259E /* RecentNotesWidget.swift in Sources */, + 9F314931CA3D31AAFCF26E15 /* TasksWidget.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 504EC3001FED79650016851F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + 5CA8650D3B7DF616A8B69229 /* SceneDelegate.swift in Sources */, 46516E19362E75EC2801386B /* ShareInboxPlugin.swift in Sources */, 5CA8650C3B7DF616A8B69229 /* ZNViewController.swift in Sources */, FB56F003004840A2A553E931 /* KeyboardBackdropPlugin.swift in Sources */, B1F519EA73BD94F20A3F1DD1 /* ICloudVaultPlugin.swift in Sources */, D17E4E8551A97CF08FDE5F82 /* FolderPickerPlugin.swift in Sources */, + 66714DDFC6EB0A9732DE176A /* WidgetBridgePlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -395,6 +517,8 @@ buildActionMask = 2147483647; files = ( 283DF465CD667CDB34C0DE25 /* CloudFlowUITests.swift in Sources */, + 283DF466CD667CDB34C0DE25 /* DeepLinkUITests.swift in Sources */, + 283DF467CD667CDB34C0DE25 /* FavoriteUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -413,6 +537,12 @@ target = 504EC3031FED79650016851F /* App */; targetProxy = E82844074641B4C71B7708C5 /* PBXContainerItemProxy */; }; + B60643996DA006DC755EB4F2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ZenWidgets; + target = A0081C14C7FAA80567CB18EC /* ZenWidgets */; + targetProxy = 604D2CAAF799146B5F1A1C7F /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -452,6 +582,27 @@ }; name = Release; }; + 4FC356E793114A0E062EDB3F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 24; + DEVELOPMENT_TEAM = WYY7PK57DM; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ZenWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.11.0; + PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 504EC3141FED79650016851F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -566,12 +717,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.11.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -588,12 +739,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -608,12 +759,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -624,6 +775,28 @@ }; name = Release; }; + 993B81FA9C1D533125041F86 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 24; + DEVELOPMENT_TEAM = WYY7PK57DM; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ZenWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.11.0; + PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; C39978C52715031CB13E5F59 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -646,12 +819,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.11.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -700,6 +873,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7C8A1DA0D090CC1FD271B28F /* Build configuration list for PBXNativeTarget "ZenWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 993B81FA9C1D533125041F86 /* Release */, + 4FC356E793114A0E062EDB3F /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index c3cd83b..6b5b746 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -1,49 +1,26 @@ import UIKit import Capacitor +/// Process-level life cycle only. Everything tied to the UI — the window, +/// URL opens, universal links, foreground/background transitions — belongs +/// to SceneDelegate: with a scene manifest in Info.plist UIKit stops calling +/// the UIApplicationDelegate counterparts, so none are implemented here. @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. return true } - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Resolved by name against the Info.plist scene manifest, which stays + // the single place the delegate class and storyboard are declared. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } - - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - // Called when the app was launched with a url. Feel free to add additional processing here, - // but if you want the App API to support tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(app, open: url, options: options) - } - - func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - // Called when the app was launched with an activity, including Universal Links. - // Feel free to add additional processing here, but if you want the App API to support - // tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + // Called when the application is about to terminate. Save data if appropriate. } } diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index c7710b7..472a74f 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -33,6 +33,25 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/ios/App/App/SceneDelegate.swift b/ios/App/App/SceneDelegate.swift new file mode 100644 index 0000000..3d81d83 --- /dev/null +++ b/ios/App/App/SceneDelegate.swift @@ -0,0 +1,71 @@ +import Capacitor +import UIKit + +/// Scene-based life cycle (Info.plist `UIApplicationSceneManifest`). iOS 27 +/// refuses to launch apps built with its SDK that still run the classic +/// UIApplicationDelegate-only life cycle (#26), so this is the launch path +/// now. The manifest names Main.storyboard, so UIKit builds the window and +/// its ZNViewController before `scene(_:willConnectTo:options:)`; the only +/// job left here is URL delivery, which UIKit routes to the scene instead of +/// `application(_:open:options:)` — Capacitor 7 has no scene proxy of its +/// own, so every URL is fed to `ApplicationDelegateProxy`, the same call the +/// old AppDelegate made, and `@capacitor/app` sees nothing new. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + private var pendingLaunchObserver: NSObjectProtocol? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + let urlContexts = connectionOptions.urlContexts + let userActivities = connectionOptions.userActivities + guard !urlContexts.isEmpty || !userActivities.isEmpty else { return } + + // Cold launch from a URL. The bridge's plugins do not exist until the + // bridge view controller has loaded, so an `.capacitorOpenURL` posted + // now would have no `appUrlOpen` listener. Hold the launch payload + // until the view has appeared, as Capacitor 8.5's own scene proxy does. + pendingLaunchObserver = NotificationCenter.default.addObserver( + forName: ZNViewController.didAppearNotification, object: nil, queue: .main + ) { [weak self] _ in + guard let self else { return } + if let observer = self.pendingLaunchObserver { + NotificationCenter.default.removeObserver(observer) + self.pendingLaunchObserver = nil + } + self.scene(scene, openURLContexts: urlContexts) + for activity in userActivities { + self.scene(scene, continue: activity) + } + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + // Widget taps (zennotes:// links) are remembered for the WebView to + // consume at boot; see WidgetBridgePlugin.consumeLaunchLink. + WidgetBridgePlugin.stashLaunchLink(context.url) + _ = ApplicationDelegateProxy.shared.application( + UIApplication.shared, open: context.url, options: Self.openURLOptions(context.options) + ) + } + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + _ = ApplicationDelegateProxy.shared.application( + UIApplication.shared, continue: userActivity, restorationHandler: { _ in } + ) + } + + /// `UIScene.OpenURLOptions` in the shape `application(_:open:options:)` + /// used to receive, so the proxy's `appUrlOpen` payload is unchanged. + private static func openURLOptions(_ options: UIScene.OpenURLOptions) -> [UIApplication.OpenURLOptionsKey: Any] { + var mapped: [UIApplication.OpenURLOptionsKey: Any] = [.openInPlace: options.openInPlace] + if let sourceApplication = options.sourceApplication { + mapped[.sourceApplication] = sourceApplication + } + if let annotation = options.annotation { + mapped[.annotation] = annotation + } + return mapped + } +} diff --git a/ios/App/App/WidgetBridgePlugin.swift b/ios/App/App/WidgetBridgePlugin.swift new file mode 100644 index 0000000..9ef0ef5 --- /dev/null +++ b/ios/App/App/WidgetBridgePlugin.swift @@ -0,0 +1,83 @@ +import Capacitor +import Foundation +import WidgetKit + +/// App-local Capacitor plugin behind the Home Screen / Lock Screen widgets +/// (ios/App/ZenWidgets). The WebView publishes the widget snapshot +/// (src/bridge/widgets.ts builds it from the store) and this writes it into +/// the App Group — the only place a widget extension can read — then asks +/// WidgetKit to re-render. The extension never sees the vault itself. +@objc(WidgetBridgePlugin) +public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "WidgetBridgePlugin" + public let jsName = "ZenWidgets" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "consumeLaunchLink", returnType: CAPPluginReturnPromise) + ] + + private let appGroupId = "group.md.zennotes" + /// Mirrored by WidgetSnapshotStore.relativePath in the extension. + private let snapshotPath = "widgets/snapshot.json" + + /// The latest zennotes:// link this process was opened with, for the + /// WebView to consume at boot (deep-links.ts). SceneDelegate stashes every + /// URL open here; the newest wins. Same contract as the Android plugin, + /// where it exists because Capacitor's getLaunchUrl can be stale for a + /// recreated activity — kept on both so the shell code stays shared. + private static var pendingLaunchLink: String? + + static func stashLaunchLink(_ url: URL) { + guard url.scheme?.lowercased() == "zennotes" else { return } + pendingLaunchLink = url.absoluteString + } + + @objc func consumeLaunchLink(_ call: CAPPluginCall) { + let link = Self.pendingLaunchLink + Self.pendingLaunchLink = nil + call.resolve(["url": link ?? NSNull()]) + } + + private func snapshotURL() -> URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupId)? + .appendingPathComponent(snapshotPath) + } + + @objc func update(_ call: CAPPluginCall) { + guard let json = call.getString("snapshot"), !json.isEmpty else { + call.reject("snapshot is required") + return + } + DispatchQueue.global(qos: .utility).async { + guard let url = self.snapshotURL() else { + call.reject("The App Group container is unavailable.") + return + } + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + // Atomic: a widget waking mid-write must see the old snapshot + // or the new one, never a truncated file. + try Data(json.utf8).write(to: url, options: .atomic) + } catch { + call.reject("Could not write the widget snapshot: \(error.localizedDescription)") + return + } + WidgetCenter.shared.reloadAllTimelines() + call.resolve() + } + } + + @objc func clear(_ call: CAPPluginCall) { + DispatchQueue.global(qos: .utility).async { + if let url = self.snapshotURL() { + try? FileManager.default.removeItem(at: url) + } + WidgetCenter.shared.reloadAllTimelines() + call.resolve() + } + } +} diff --git a/ios/App/App/ZNViewController.swift b/ios/App/App/ZNViewController.swift index 3b52b0a..eada332 100644 --- a/ios/App/App/ZNViewController.swift +++ b/ios/App/App/ZNViewController.swift @@ -3,12 +3,23 @@ import UIKit /// App-local plugins have to be registered by hand (packaged plugins are /// auto-discovered; in-app ones are not). Main.storyboard points its bridge -/// view controller at this subclass. +/// view controller at this subclass; the scene manifest names that +/// storyboard, so UIKit instantiates it as the scene's root. class ZNViewController: CAPBridgeViewController { + /// Posted from `viewDidAppear`, i.e. once the bridge and every plugin + /// exist. SceneDelegate holds cold-launch URLs until the first one. + static let didAppearNotification = Notification.Name("ZNViewControllerDidAppear") + override open func capacitorDidLoad() { bridge?.registerPluginInstance(ShareInboxPlugin()) bridge?.registerPluginInstance(ICloudVaultPlugin()) bridge?.registerPluginInstance(FolderPickerPlugin()) bridge?.registerPluginInstance(KeyboardBackdropPlugin()) + bridge?.registerPluginInstance(WidgetBridgePlugin()) + } + + override open func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + NotificationCenter.default.post(name: Self.didAppearNotification, object: self) } } diff --git a/ios/App/AppUITests/DeepLinkUITests.swift b/ios/App/AppUITests/DeepLinkUITests.swift new file mode 100644 index 0000000..558e323 --- /dev/null +++ b/ios/App/AppUITests/DeepLinkUITests.swift @@ -0,0 +1,118 @@ +import XCTest + +/// `zennotes://` links reach the WebView through SceneDelegate (the scene +/// life cycle routes URL opens there, never to AppDelegate). A widget tap +/// while the app runs arrives as `scene(_:openURLContexts:)`; a tap that +/// launches the app arrives in the connection options and is held until the +/// bridge view has appeared. Both must end in `note.new.inbox` running, so +/// the proof is a note name of the form "Untitled" / "Untitled N" that was +/// not on screen before the link — whatever the layout: the phone shell +/// shows it in the title field, the iPad's desktop layout in a tab and the +/// breadcrumb (regression for #26). +final class DeepLinkUITests: XCTestCase { + private let newNoteLink = URL(string: "zennotes://new")! + private let untitledPattern = "^Untitled( \\d+)?$" + + override func setUpWithError() throws { + continueAfterFailure = false + // A confirmation left over from an earlier run would swallow the tap. + let stale = springboard.buttons["Cancel"] + if stale.exists { + stale.tap() + } + } + + private var springboard: XCUIApplication { + XCUIApplication(bundleIdentifier: "com.apple.springboard") + } + + func testWarmLinkOpensNewNote() throws { + let app = XCUIApplication() + app.launch() + let before = settledUntitledNames(in: app) + + open(newNoteLink) + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) + XCTAssertNotNil( + waitForNewUntitledName(in: app, notIn: before, timeout: 20), + "no new untitled note appeared after zennotes://new (on screen before: \(before.sorted()))" + ) + } + + func testColdLaunchLinkOpensNewNote() throws { + // The workspace restore reopens the last note, so what is on screen + // after a plain launch is exactly what the cold link launch will show + // before the link runs. + let app = XCUIApplication() + app.launch() + let before = settledUntitledNames(in: app) + app.terminate() + XCTAssertTrue(app.wait(for: .notRunning, timeout: 10)) + + open(newNoteLink) + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 30)) + XCTAssertNotNil( + waitForNewUntitledName(in: app, notIn: before, timeout: 40), + "no new untitled note appeared after a cold zennotes://new launch (on screen before: \(before.sorted()))" + ) + } + + /// Opens the link the way a widget tap does; the simulator may first ask + /// "Open in ZenNotes?", which a real widget tap never does. + private func open(_ url: URL) { + XCUIDevice.shared.system.open(url) + let confirm = springboard.buttons["Open"] + if confirm.waitForExistence(timeout: 3) { + confirm.tap() + } + } + + /// Every "Untitled" / "Untitled N" currently on screen: element labels + /// (tabs, breadcrumbs, list rows) plus text field values (the phone title + /// field is labelled "Untitled" and carries the name as its value). + private func untitledNames(in app: XCUIApplication) -> Set { + let labelled = app.descendants(matching: .any) + .matching(NSPredicate(format: "label MATCHES %@", untitledPattern)) + var names = Set(labelled.allElementsBoundByIndex.map(\.label)) + for field in app.textFields.allElementsBoundByIndex { + if let value = field.value as? String, value.range(of: untitledPattern, options: .regularExpression) != nil { + names.insert(value) + } + } + return names + } + + /// Waits for the WebView to render and the workspace restore to finish: + /// the set of untitled names on screen has to hold still for three + /// seconds. Restoring reopens the last note, which may itself be untitled. + private func settledUntitledNames(in app: XCUIApplication, timeout: TimeInterval = 40) -> Set { + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: timeout)) + let deadline = Date().addingTimeInterval(timeout) + var names = untitledNames(in: app) + var stableSince = Date() + while Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(1)) + let now = untitledNames(in: app) + if now != names { + names = now + stableSince = Date() + } else if Date().timeIntervalSince(stableSince) >= 3, app.staticTexts.firstMatch.exists { + return names + } + } + return names + } + + private func waitForNewUntitledName(in app: XCUIApplication, notIn before: Set, timeout: TimeInterval) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let name = untitledNames(in: app).subtracting(before).first { + return name + } + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + return nil + } +} diff --git a/ios/App/AppUITests/FavoriteUITests.swift b/ios/App/AppUITests/FavoriteUITests.swift new file mode 100644 index 0000000..0781d20 --- /dev/null +++ b/ios/App/AppUITests/FavoriteUITests.swift @@ -0,0 +1,191 @@ +import XCTest + +/// Favorites are the vault's list (vault.json): the section Home and the +/// desktop sidebar show. Until #810 a phone could not add to it, because the +/// only routes were the desktop sidebar's context menu and a Vim leader +/// chord, so the Home Favorites section stayed empty on the iPhone. The ••• +/// sheet (Open menu → More) and the long-press note menu now carry +/// "Add to Favorites" / "Remove from Favorites", and toggling through one +/// flips what the other shows, because both read the same shell snapshot. +/// +/// The test opens a fresh inbox note through `zennotes://new` (the widget +/// link, same as DeepLinkUITests) so it never depends on which notes the +/// simulator's vault holds, favorites it from the ••• sheet, checks the +/// long-press menu on the drawer row sees that state and removes it again, +/// then trashes the note it made. +final class FavoriteUITests: XCTestCase { + private let newNoteLink = URL(string: "zennotes://new")! + + override func setUpWithError() throws { + continueAfterFailure = false + let stale = springboard.buttons["Cancel"] + if stale.exists { + stale.tap() + } + } + + private var springboard: XCUIApplication { + XCUIApplication(bundleIdentifier: "com.apple.springboard") + } + + func testMoreSheetAndLongPressMenuToggleFavorite() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + guard app.windows.firstMatch.frame.width < 768 else { + throw XCTSkip("phone shell only: the iPad runs the desktop layout with the sidebar's context menu") + } + + open(newNoteLink) + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) + let noteName = try XCTUnwrap(waitForOpenNoteName(in: app, timeout: 20), "no note title field after zennotes://new") + putKeyboardAway(in: app) + + // 1. ••• sheet on the open note: Add, then the label flips to Remove. + openMoreSheet(in: app) + let add = element(label: "Add to Favorites", in: app) + XCTAssertTrue(add.waitForExistence(timeout: 5), "the ••• sheet has no Add to Favorites row") + add.tap() + + openMoreSheet(in: app) + let remove = element(label: "Remove from Favorites", in: app) + XCTAssertTrue(remove.waitForExistence(timeout: 5), "the ••• sheet did not flip to Remove from Favorites") + dismissSheet(in: app) + + // 2. Long-press menu on the drawer row sees the same state and undoes it. + openBrowse(in: app) + let row = hittableButton(label: noteName, in: app) + XCTAssertTrue(row.waitForExistence(timeout: 10), "drawer row for \(noteName) not found") + scrollUntilHittable(row, in: app) + row.press(forDuration: 0.8) + let removeFromRow = element(label: "Remove from Favorites", in: app) + XCTAssertTrue(removeFromRow.waitForExistence(timeout: 5), "the long-press menu has no Remove from Favorites row") + removeFromRow.tap() + + row.press(forDuration: 0.8) + let addFromRow = element(label: "Add to Favorites", in: app) + XCTAssertTrue(addFromRow.waitForExistence(timeout: 5), "the long-press menu did not flip back to Add to Favorites") + dismissSheet(in: app) + closeDrawer(in: app) + + // 3. ••• sheet agrees, and cleans up the note this test created. + openMoreSheet(in: app) + XCTAssertTrue(element(label: "Add to Favorites", in: app).waitForExistence(timeout: 5), "the ••• sheet did not follow the long-press removal") + let delete = element(label: "Delete", in: app) + XCTAssertTrue(delete.exists) + delete.tap() + let confirm = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Move to'")).firstMatch + if confirm.waitForExistence(timeout: 3) { + confirm.tap() + } + } + + /// Opens the link the way a widget tap does; the simulator may first ask + /// "Open in ZenNotes?", which a real widget tap never does. + private func open(_ url: URL) { + XCUIDevice.shared.system.open(url) + let confirm = springboard.buttons["Open"] + if confirm.waitForExistence(timeout: 3) { + confirm.tap() + } + } + + /// The phone title field is labelled "Untitled" and carries the note's + /// name as its value ("Untitled", "Untitled 3", ...). + private func waitForOpenNoteName(in app: XCUIApplication, timeout: TimeInterval) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + for field in app.textFields.allElementsBoundByIndex { + if let value = field.value as? String, value.hasPrefix("Untitled") { + return value + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } + return nil + } + + private func openMoreSheet(in app: XCUIApplication) { + let openMenu = app.buttons["Open menu"] + XCTAssertTrue(openMenu.waitForExistence(timeout: 5)) + // The keyboard must be down here: with it up the ensō button is + // display:none, and a dismiss that left DOM focus in the editor + // brought the keyboard back on this very tap (the bug this test + // caught). Assert both sides of that so a regression names itself. + XCTAssertEqual(app.keyboards.count, 0, "keyboard still up before tapping the ensō button") + openMenu.tap() + let more = element(label: "More", in: app) + XCTAssertTrue( + more.waitForExistence(timeout: 5), + "the ensō menu did not open (keyboards=\(app.keyboards.count))" + ) + more.tap() + } + + private func openBrowse(in app: XCUIApplication) { + let openMenu = app.buttons["Open menu"] + XCTAssertTrue(openMenu.waitForExistence(timeout: 5)) + openMenu.tap() + let browse = element(label: "Browse", in: app) + XCTAssertTrue(browse.waitForExistence(timeout: 5)) + browse.tap() + } + + /// A fresh note opens with its title focused and the keyboard up, and the + /// ensō hides for as long as the keyboard shows (mobile.css `.zn-kb-open`). + /// The title field has no dismiss control; the editor's formatting toolbar + /// does, so Return first moves focus into the body (the title's Enter + /// behaviour), then the toolbar's button puts the keyboard away. A + /// simulator with a hardware keyboard attached never raised it, so the + /// ensō is already there and nothing needs doing. + private func putKeyboardAway(in app: XCUIApplication) { + let openMenu = app.buttons["Open menu"] + if openMenu.waitForExistence(timeout: 2) { + return + } + let dismiss = app.buttons["Dismiss keyboard"] + if !dismiss.exists { + app.typeText("\n") + } + XCTAssertTrue(dismiss.waitForExistence(timeout: 5), "no Dismiss keyboard button: the editor toolbar did not come up") + dismiss.tap() + XCTAssertTrue(openMenu.waitForExistence(timeout: 5), "the ensō stayed hidden: the keyboard did not go away") + } + + /// Bottom sheets close on a tap on their backdrop, which covers the top + /// of the screen. + private func dismissSheet(in app: XCUIApplication) { + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.08)).tap() + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + } + + /// The drawer is a left panel at most 330pt wide; its backdrop is the + /// strip to its right, so the tap that closes it goes near the right edge. + private func closeDrawer(in app: XCUIApplication) { + app.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap() + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + } + + private func scrollUntilHittable(_ element: XCUIElement, in app: XCUIApplication) { + for _ in 0..<8 where !element.isHittable { + app.swipeUp() + } + } + + private func element(label: String, in app: XCUIApplication) -> XCUIElement { + app.descendants(matching: .any) + .matching(NSPredicate(format: "label == %@", label)) + .firstMatch + } + + private func hittableButton(label: String, in app: XCUIApplication) -> XCUIElement { + let matches = app.buttons.matching(NSPredicate(format: "label == %@", label)) + for index in 0.. 21.0) - - Capacitor (7.6.8): + - Capacitor (7.6.9): - CapacitorCordova - CapacitorApp (7.1.2): - Capacitor - CapacitorClipboard (7.0.4): - Capacitor - - CapacitorCordova (7.6.8) + - CapacitorCordova (7.6.9) - CapacitorFilesystem (7.1.8): - Capacitor - IONFilesystemLib (~> 1.1.1) @@ -74,10 +74,10 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AparajitaCapacitorSecureStorage: 502bff73187cf9d0164459458ccf47ec65d5895a - Capacitor: ff6bf01336ac353098378828aff465095a89e459 + Capacitor: 46067d1c8df89760dbed78780d436327d588c726 CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106 CapacitorClipboard: d1f123674cf413125db816a45e8f70e8770972fc - CapacitorCordova: e61ee8c40101b8cd011d0224261606a290c082cf + CapacitorCordova: 2080fc0d6bfbc9dcc0e6d88e77fa624190d343ca CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296 CapacitorHaptics: 1b145fa83c622e5383a3bac04ea81e2514f0b0f5 CapacitorKeyboard: a2e0869edd229490ce36aed2549c0d6b95e27ee8 @@ -88,6 +88,6 @@ SPEC CHECKSUMS: IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a KeychainSwift: 4a71a45c802fd9e73906457c2dcbdbdc06c9419d -PODFILE CHECKSUM: 8c2072557d285523b52c09b408d696d31beaa1a6 +PODFILE CHECKSUM: 22927e348daa753f82fe2ad9a8f18100024b7007 COCOAPODS: 1.17.0 diff --git a/ios/App/ZenWidgets/Assets.xcassets/Contents.json b/ios/App/ZenWidgets/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/App/ZenWidgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/Contents.json b/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/Contents.json new file mode 100644 index 0000000..4896f13 --- /dev/null +++ b/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enso.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/enso.png b/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/enso.png new file mode 100644 index 0000000..d82d859 Binary files /dev/null and b/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/enso.png differ diff --git a/ios/App/ZenWidgets/Info.plist b/ios/App/ZenWidgets/Info.plist new file mode 100644 index 0000000..fdfa93d --- /dev/null +++ b/ios/App/ZenWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ZenNotes + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/App/ZenWidgets/NewNoteWidget.swift b/ios/App/ZenWidgets/NewNoteWidget.swift new file mode 100644 index 0000000..f919c44 --- /dev/null +++ b/ios/App/ZenWidgets/NewNoteWidget.swift @@ -0,0 +1,148 @@ +import SwiftUI +import WidgetKit + +/// One tap → a fresh note in the Inbox, title focused (the ⊕ sheet's "New +/// note"). Small on the Home Screen; circular / rectangular / inline on the +/// Lock Screen from iOS 16. +struct NewNoteEntry: TimelineEntry { + let date: Date + let vaultName: String? + let palette: WidgetPalette +} + +struct NewNoteProvider: TimelineProvider { + func placeholder(in context: Context) -> NewNoteEntry { + NewNoteEntry(date: Date(), vaultName: "My Vault", palette: .fallback) + } + + func getSnapshot(in context: Context, completion: @escaping (NewNoteEntry) -> Void) { + completion(entry(now: Date())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + // Nothing here drifts with time; the app reloads on vault or theme + // changes. The long horizon only guards against a missed reload. + completion(Timeline(entries: [entry(now: now)], policy: .after(now.addingTimeInterval(12 * 3600)))) + } + + private func entry(now: Date) -> NewNoteEntry { + let snapshot = WidgetSnapshotStore.load() + return NewNoteEntry( + date: now, + vaultName: snapshot?.vaultName, + palette: WidgetPalette(theme: snapshot?.theme) + ) + } +} + +struct NewNoteWidget: Widget { + static let kind = "md.zennotes.widgets.new-note" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: NewNoteProvider()) { entry in + NewNoteWidgetView(entry: entry) + } + .configurationDisplayName("New Note") + .description("Start a new note with one tap.") + .supportedFamilies(Self.families) + } + + private static var families: [WidgetFamily] { + var families: [WidgetFamily] = [.systemSmall] + if #available(iOS 16.0, *) { + families += [.accessoryCircular, .accessoryRectangular, .accessoryInline] + } + return families + } +} + +struct NewNoteWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: NewNoteEntry + + var body: some View { + Group { + switch family { + case .systemSmall, .systemMedium, .systemLarge, .systemExtraLarge: + NewNoteSmallView(entry: entry) + default: + if #available(iOS 16.0, *) { + NewNoteAccessoryView(family: family, vaultName: entry.vaultName) + .zenAccessoryBackground() + } else { + NewNoteSmallView(entry: entry) + } + } + } + .widgetURL(ZenLinks.newNote) + } +} + +private struct NewNoteSmallView: View { + let entry: NewNoteEntry + + var body: some View { + let p = entry.palette + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + Image("Enso") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + Spacer(minLength: 0) + ZStack { + Circle().fill(p.accent) + Image(systemName: "plus") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(p.bg) + } + .frame(width: 30, height: 30) + } + Spacer(minLength: 0) + Text("New note") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(p.fg) + Text(entry.vaultName ?? "ZenNotes") + .font(.system(size: 12)) + .foregroundColor(p.muted) + .lineLimit(1) + .padding(.top, 2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .zenWidgetBackground(p.bg) + } +} + +@available(iOS 16.0, *) +private struct NewNoteAccessoryView: View { + let family: WidgetFamily + let vaultName: String? + + var body: some View { + switch family { + case .accessoryCircular: + ZStack { + AccessoryWidgetBackground() + Image(systemName: "square.and.pencil") + .font(.system(size: 22, weight: .medium)) + } + case .accessoryRectangular: + HStack(spacing: 8) { + Image(systemName: "square.and.pencil") + .font(.system(size: 20, weight: .medium)) + VStack(alignment: .leading, spacing: 1) { + Text("New note") + .font(.headline) + Text(vaultName ?? "ZenNotes") + .font(.caption) + .opacity(0.8) + .lineLimit(1) + } + Spacer(minLength: 0) + } + default: + Label("New note", systemImage: "square.and.pencil") + } + } +} diff --git a/ios/App/ZenWidgets/PrivacyInfo.xcprivacy b/ios/App/ZenWidgets/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..590abf8 --- /dev/null +++ b/ios/App/ZenWidgets/PrivacyInfo.xcprivacy @@ -0,0 +1,17 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/ios/App/ZenWidgets/RecentNotesWidget.swift b/ios/App/ZenWidgets/RecentNotesWidget.swift new file mode 100644 index 0000000..28df005 --- /dev/null +++ b/ios/App/ZenWidgets/RecentNotesWidget.swift @@ -0,0 +1,145 @@ +import SwiftUI +import WidgetKit + +/// Pinned notes first, then the ones edited last — the Home dashboard's +/// Recent list with the drawer's pins on top. Every row opens its note; the +/// header's + starts a new one. +struct RecentNotesEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot? + let palette: WidgetPalette +} + +struct RecentNotesProvider: TimelineProvider { + /// The "3h ago" stamps drift with no app activity at all, so one + /// timeline re-renders the same snapshot a few times over the next + /// hours; a real change reloads everything from the app. + private static let refreshOffsetsMinutes: [Double] = [0, 5, 15, 30, 60, 120, 240, 480] + + func placeholder(in context: Context) -> RecentNotesEntry { + RecentNotesEntry(date: Date(), snapshot: WidgetSnapshotStore.sample(), palette: .fallback) + } + + func getSnapshot(in context: Context, completion: @escaping (RecentNotesEntry) -> Void) { + let real = WidgetSnapshotStore.load() + // The gallery should show a lived-in widget, not an empty vault. + let snapshot = (context.isPreview && (real?.notes.isEmpty ?? true)) + ? WidgetSnapshotStore.sample() + : real + completion(RecentNotesEntry(date: Date(), snapshot: snapshot, palette: WidgetPalette(theme: real?.theme))) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let snapshot = WidgetSnapshotStore.load() + let palette = WidgetPalette(theme: snapshot?.theme) + let entries = Self.refreshOffsetsMinutes.map { offset in + RecentNotesEntry(date: now.addingTimeInterval(offset * 60), snapshot: snapshot, palette: palette) + } + completion(Timeline(entries: entries, policy: .atEnd)) + } +} + +struct RecentNotesWidget: Widget { + static let kind = "md.zennotes.widgets.recent-notes" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: RecentNotesProvider()) { entry in + RecentNotesWidgetView(entry: entry) + } + .configurationDisplayName("Recent Notes") + .description("Your pinned notes, then the ones you edited last.") + .supportedFamilies([.systemMedium, .systemLarge]) + } +} + +struct RecentNotesWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: RecentNotesEntry + + private var isLarge: Bool { family == .systemLarge } + private var maxRows: Int { isLarge ? 9 : 4 } + private var rowHeight: CGFloat { isLarge ? 30 : 22 } + + var body: some View { + let p = entry.palette + let notes = Array((entry.snapshot?.notes ?? []).prefix(maxRows)) + VStack(alignment: .leading, spacing: 0) { + header(p) + if notes.isEmpty { + emptyState(p) + } else { + ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in + Link(destination: ZenLinks.open(note.path)) { + row(note, p) + } + if index < notes.count - 1 { + Rectangle().fill(p.bg2).frame(height: 0.5) + } + } + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .zenWidgetBackground(p.bg) + .widgetURL(ZenLinks.home) + } + + private func header(_ p: WidgetPalette) -> some View { + HStack(spacing: 6) { + Image("Enso") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + Text(entry.snapshot?.vaultName ?? "ZenNotes") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(p.muted) + .lineLimit(1) + Spacer(minLength: 8) + Link(destination: ZenLinks.newNote) { + ZStack { + Circle().fill(p.accent.opacity(0.18)) + Image(systemName: "plus") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(p.accent) + } + .frame(width: 22, height: 22) + } + } + .frame(height: 22) + .padding(.bottom, 4) + } + + private func row(_ note: WidgetNote, _ p: WidgetPalette) -> some View { + HStack(spacing: 8) { + Image(systemName: note.pinned ? "pin.fill" : "doc.text") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(note.pinned ? p.accent : p.muted) + .frame(width: 14) + Text(note.title) + .font(.system(size: isLarge ? 14 : 13, weight: .medium)) + .foregroundColor(p.fg) + .lineLimit(1) + Spacer(minLength: 8) + Text(WidgetFormat.timeAgo(note.updatedDate, now: entry.date)) + .font(.system(size: 11)) + .foregroundColor(p.muted) + .lineLimit(1) + } + .frame(height: rowHeight) + .contentShape(Rectangle()) + } + + private func emptyState(_ p: WidgetPalette) -> some View { + VStack(alignment: .leading, spacing: 3) { + Spacer(minLength: 0) + Text(entry.snapshot == nil ? "Open ZenNotes once" : "No notes yet") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(p.fg) + Text(entry.snapshot == nil ? "The widget fills in from your vault." : "Tap + to write your first.") + .font(.system(size: 12)) + .foregroundColor(p.muted) + Spacer(minLength: 0) + } + } +} diff --git a/ios/App/ZenWidgets/TasksWidget.swift b/ios/App/ZenWidgets/TasksWidget.swift new file mode 100644 index 0000000..73642f3 --- /dev/null +++ b/ios/App/ZenWidgets/TasksWidget.swift @@ -0,0 +1,175 @@ +import SwiftUI +import WidgetKit + +/// The Home dashboard's Today bucket: due today, overdue, and undated open +/// tasks, in the Tasks view's order. A row jumps to the task's line in its +/// note; the header (and any empty space) opens the Tasks view. +struct TasksEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot? + let palette: WidgetPalette +} + +struct TasksProvider: TimelineProvider { + func placeholder(in context: Context) -> TasksEntry { + TasksEntry(date: Date(), snapshot: WidgetSnapshotStore.sample(), palette: .fallback) + } + + func getSnapshot(in context: Context, completion: @escaping (TasksEntry) -> Void) { + let real = WidgetSnapshotStore.load() + let snapshot = (context.isPreview && (real?.tasks.isEmpty ?? true)) + ? WidgetSnapshotStore.sample() + : real + completion(TasksEntry(date: Date(), snapshot: snapshot, palette: WidgetPalette(theme: real?.theme))) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let snapshot = WidgetSnapshotStore.load() + let entry = TasksEntry(date: now, snapshot: snapshot, palette: WidgetPalette(theme: snapshot?.theme)) + // Re-render at local midnight so "due today" turns overdue on time; + // the app reloads on every task change in between. + let midnight = Calendar.current.nextDate( + after: now, matching: DateComponents(hour: 0, minute: 0, second: 5), matchingPolicy: .nextTime + ) ?? now.addingTimeInterval(6 * 3600) + completion(Timeline(entries: [entry], policy: .after(midnight))) + } +} + +struct TasksWidget: Widget { + static let kind = "md.zennotes.widgets.tasks" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: TasksProvider()) { entry in + TasksWidgetView(entry: entry) + } + .configurationDisplayName("Today's Tasks") + .description("What's due today, overdue, or waiting for a date.") + .supportedFamilies([.systemMedium, .systemLarge]) + } +} + +struct TasksWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: TasksEntry + + private var isLarge: Bool { family == .systemLarge } + private var maxRows: Int { isLarge ? 8 : 3 } + private var rowHeight: CGFloat { isLarge ? 34 : 30 } + + var body: some View { + let p = entry.palette + let todayIso = WidgetFormat.isoDate(entry.date) + let all = entry.snapshot?.tasks ?? [] + let rows = Array(all.prefix(maxRows)) + let hidden = max(0, (entry.snapshot?.todayCount ?? 0) - rows.count) + VStack(alignment: .leading, spacing: 0) { + header(p, todayIso: todayIso) + if rows.isEmpty { + emptyState(p) + } else { + ForEach(rows) { task in + Link(destination: ZenLinks.task(id: task.id, path: task.path)) { + row(task, p, todayIso: todayIso) + } + } + if hidden > 0 && isLarge { + Text("+\(hidden) more") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(p.muted) + .padding(.top, 4) + } + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .zenWidgetBackground(p.bg) + .widgetURL(ZenLinks.tasks) + } + + private func header(_ p: WidgetPalette, todayIso: String) -> some View { + let today = entry.snapshot?.todayCount ?? 0 + let overdue = (entry.snapshot?.tasks ?? []).filter { isOverdue($0, todayIso: todayIso) }.count + let overdueTotal = max(overdue, entry.snapshot?.overdueCount ?? 0) + return HStack(spacing: 6) { + Image(systemName: "checkmark.square") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(p.accent) + Text("Today") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(p.fg) + Spacer(minLength: 8) + if overdueTotal > 0 { + Text("\(overdueTotal) overdue") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(p.red) + } else if today > 0 { + Text("\(today) open") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(p.muted) + } + } + .frame(height: 22) + .padding(.bottom, 4) + } + + private func isOverdue(_ task: WidgetTask, todayIso: String) -> Bool { + if let due = task.due { return due < todayIso } + return task.overdue + } + + private func row(_ task: WidgetTask, _ p: WidgetPalette, todayIso: String) -> some View { + let overdue = isOverdue(task, todayIso: todayIso) + var detail = task.noteTitle + if overdue, let due = task.due { + detail = "\(WidgetFormat.shortDate(iso: due)) · \(task.noteTitle)" + } + return HStack(spacing: 8) { + Image(systemName: task.inProgress ? "circle.lefthalf.filled" : "square") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(overdue ? p.red : p.muted) + .frame(width: 16) + VStack(alignment: .leading, spacing: 1) { + Text(task.content) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(p.fg) + .lineLimit(1) + Text(detail) + .font(.system(size: 10.5)) + .foregroundColor(overdue ? p.red : p.muted) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .frame(height: rowHeight) + .contentShape(Rectangle()) + } + + private func emptyState(_ p: WidgetPalette) -> some View { + let ready = entry.snapshot?.tasksReady ?? false + return VStack(alignment: .leading, spacing: 3) { + Spacer(minLength: 0) + if entry.snapshot == nil || !ready { + Text("Open ZenNotes once") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(p.fg) + Text("Your tasks show up after the first scan.") + .font(.system(size: 12)) + .foregroundColor(p.muted) + } else { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(p.accent) + Text("All clear") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(p.fg) + } + Text("Nothing due today.") + .font(.system(size: 12)) + .foregroundColor(p.muted) + } + Spacer(minLength: 0) + } + } +} diff --git a/ios/App/ZenWidgets/WidgetSnapshot.swift b/ios/App/ZenWidgets/WidgetSnapshot.swift new file mode 100644 index 0000000..4354651 --- /dev/null +++ b/ios/App/ZenWidgets/WidgetSnapshot.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Mirror of src/bridge/widget-snapshot.ts — the app is the writer. Every +/// field a later shell might add or drop decodes as optional, so an older +/// extension never fails on a newer snapshot (and vice versa). +struct WidgetThemeData: Decodable { + var mode: String? + var bg: String? + var bg1: String? + var bg2: String? + var fg: String? + var fg2: String? + var muted: String? + var accent: String? + var red: String? +} + +struct WidgetNote: Decodable, Identifiable { + var id: String { path } + let path: String + let title: String + let folder: String? + /// ms since epoch. + let updatedAt: Double + let pinned: Bool + + var updatedDate: Date { Date(timeIntervalSince1970: updatedAt / 1000) } +} + +struct WidgetTask: Decodable, Identifiable { + let id: String + let path: String + let noteTitle: String + let content: String + /// ISO YYYY-MM-DD; nil for an undated task. + let due: String? + let overdue: Bool + let inProgress: Bool + let priority: String? +} + +struct WidgetTaskCounts: Decodable { + let today: Int + let overdue: Int +} + +struct WidgetSnapshot: Decodable { + let version: Int + /// ms since epoch. + let generatedAt: Double + let vaultName: String? + let theme: WidgetThemeData? + let notes: [WidgetNote] + let tasks: [WidgetTask] + let taskCounts: WidgetTaskCounts? + let tasksReady: Bool? + + var generatedDate: Date { Date(timeIntervalSince1970: generatedAt / 1000) } + var todayCount: Int { taskCounts?.today ?? tasks.count } + var overdueCount: Int { taskCounts?.overdue ?? tasks.filter { $0.overdue }.count } +} + +enum WidgetSnapshotStore { + static let appGroupId = "group.md.zennotes" + /// Mirrored by WidgetBridgePlugin.snapshotPath in the app. + static let relativePath = "widgets/snapshot.json" + + static var url: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupId)? + .appendingPathComponent(relativePath) + } + + static func load() -> WidgetSnapshot? { + guard let url = url, let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(WidgetSnapshot.self, from: data) + } + + /// Gallery previews and placeholders — what a lived-in vault looks like. + static func sample(now: Date = Date()) -> WidgetSnapshot { + let ms = now.timeIntervalSince1970 * 1000 + let minute = 60_000.0 + let hour = 3_600_000.0 + let today = WidgetFormat.isoDate(now) + let yesterday = WidgetFormat.isoDate(now.addingTimeInterval(-86_400)) + return WidgetSnapshot( + version: 1, + generatedAt: ms, + vaultName: "My Vault", + theme: nil, + notes: [ + WidgetNote(path: "inbox/Reading list.md", title: "Reading list", folder: "inbox", + updatedAt: ms - 25 * minute, pinned: true), + WidgetNote(path: "inbox/Product ideas.md", title: "Product ideas", folder: "inbox", + updatedAt: ms - 3 * hour, pinned: false), + WidgetNote(path: "inbox/Meeting notes.md", title: "Meeting notes", folder: "inbox", + updatedAt: ms - 6 * hour, pinned: false), + WidgetNote(path: "quick/Grocery run.md", title: "Grocery run", folder: "quick", + updatedAt: ms - 26 * hour, pinned: false), + WidgetNote(path: "inbox/Trip planning.md", title: "Trip planning", folder: "inbox", + updatedAt: ms - 2 * 24 * hour, pinned: false), + WidgetNote(path: "inbox/Weekly review.md", title: "Weekly review", folder: "inbox", + updatedAt: ms - 3 * 24 * hour, pinned: false), + WidgetNote(path: "inbox/Book notes.md", title: "Book notes", folder: "inbox", + updatedAt: ms - 4 * 24 * hour, pinned: false), + WidgetNote(path: "inbox/Recipes.md", title: "Recipes", folder: "inbox", + updatedAt: ms - 5 * 24 * hour, pinned: false), + WidgetNote(path: "inbox/Journal.md", title: "Journal", folder: "inbox", + updatedAt: ms - 6 * 24 * hour, pinned: false) + ], + tasks: [ + WidgetTask(id: "inbox/Today.md#0", path: "inbox/Today.md", noteTitle: "Today", + content: "Reply to the design review", due: today, overdue: false, + inProgress: false, priority: nil), + WidgetTask(id: "inbox/Trip planning.md#2", path: "inbox/Trip planning.md", + noteTitle: "Trip planning", content: "Book the October flights", + due: yesterday, overdue: true, inProgress: false, priority: "high"), + WidgetTask(id: "inbox/Product ideas.md#1", path: "inbox/Product ideas.md", + noteTitle: "Product ideas", content: "Draft the release notes", + due: today, overdue: false, inProgress: true, priority: nil), + WidgetTask(id: "inbox/Today.md#3", path: "inbox/Today.md", noteTitle: "Today", + content: "Call the dentist", due: nil, overdue: false, + inProgress: false, priority: nil), + WidgetTask(id: "inbox/Weekly review.md#0", path: "inbox/Weekly review.md", + noteTitle: "Weekly review", content: "Plan next week's focus", + due: today, overdue: false, inProgress: false, priority: nil), + WidgetTask(id: "quick/Grocery run.md#0", path: "quick/Grocery run.md", + noteTitle: "Grocery run", content: "Pick up coffee beans", + due: nil, overdue: false, inProgress: false, priority: nil) + ], + taskCounts: WidgetTaskCounts(today: 6, overdue: 1), + tasksReady: true + ) + } +} diff --git a/ios/App/ZenWidgets/WidgetTheme.swift b/ios/App/ZenWidgets/WidgetTheme.swift new file mode 100644 index 0000000..f33cd10 --- /dev/null +++ b/ios/App/ZenWidgets/WidgetTheme.swift @@ -0,0 +1,155 @@ +import SwiftUI +import WidgetKit + +/// The widgets wear the app's active theme: the shell samples the `--z-*` +/// tokens (background, foreground, accent, …) into the snapshot and this +/// resolves them to SwiftUI colors, falling back to ZenNotes' default +/// dark-hard palette before the first publish. +struct WidgetPalette { + let isDark: Bool + let bg: Color + let bg1: Color + let bg2: Color + let fg: Color + let fg2: Color + let muted: Color + let accent: Color + let red: Color + + static let fallback = WidgetPalette( + isDark: true, + bg: Color(hex: "#1d2021") ?? .black, + bg1: Color(hex: "#32302f") ?? .gray, + bg2: Color(hex: "#3c3836") ?? .gray, + fg: Color(hex: "#d4be98") ?? .white, + fg2: Color(hex: "#ddc7a1") ?? .white, + muted: Color(hex: "#a89984") ?? .gray, + accent: Color(hex: "#e78a4e") ?? .orange, + red: Color(hex: "#ea6962") ?? .red + ) +} + +extension WidgetPalette { + init(theme: WidgetThemeData?) { + let f = WidgetPalette.fallback + self.init( + isDark: theme?.mode != "light", + bg: Color(hex: theme?.bg) ?? f.bg, + bg1: Color(hex: theme?.bg1) ?? f.bg1, + bg2: Color(hex: theme?.bg2) ?? f.bg2, + fg: Color(hex: theme?.fg) ?? f.fg, + fg2: Color(hex: theme?.fg2) ?? f.fg2, + muted: Color(hex: theme?.muted) ?? f.muted, + accent: Color(hex: theme?.accent) ?? f.accent, + red: Color(hex: theme?.red) ?? f.red + ) + } +} + +extension Color { + /// `#rrggbb` (the hash optional) → sRGB color; anything else is nil. + init?(hex: String?) { + guard var text = hex?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } + if text.hasPrefix("#") { text.removeFirst() } + guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil } + self.init( + .sRGB, + red: Double((value >> 16) & 0xff) / 255, + green: Double((value >> 8) & 0xff) / 255, + blue: Double(value & 0xff) / 255, + opacity: 1 + ) + } +} + +enum WidgetFormat { + /// The Home dashboard's stamp: just now, 5m ago, 3h ago, yesterday, + /// 4d ago, then a short date. + static func timeAgo(_ date: Date, now: Date) -> String { + let minutes = Int((now.timeIntervalSince(date) / 60).rounded()) + if minutes < 1 { return "just now" } + if minutes < 60 { return "\(minutes)m ago" } + let hours = Int((Double(minutes) / 60).rounded()) + if hours < 24 { return "\(hours)h ago" } + let days = Int((Double(hours) / 24).rounded()) + if days == 1 { return "yesterday" } + if days < 7 { return "\(days)d ago" } + return shortDate(date) + } + + /// Local calendar day as ISO YYYY-MM-DD — the form task `due` uses, so + /// overdue is a plain string comparison. + static func isoDate(_ date: Date) -> String { + let parts = Calendar.current.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", parts.year ?? 1970, parts.month ?? 1, parts.day ?? 1) + } + + static func shortDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("MMM d") + return formatter.string(from: date) + } + + /// "Sep 5" for an ISO due date; the raw string if it doesn't parse. + static func shortDate(iso: String) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + guard let date = formatter.date(from: iso) else { return iso } + return shortDate(date) + } +} + +/// The `zennotes://` links the shell runs (src/ui-mobile/widget-links.ts is +/// the parser and the source of truth for the vocabulary). +enum ZenLinks { + static let newNote = URL(string: "zennotes://new")! + static let tasks = URL(string: "zennotes://tasks")! + static let home = URL(string: "zennotes://home")! + + static func open(_ path: String) -> URL { + URL(string: "zennotes://open?path=\(encode(path))") ?? home + } + + static func task(id: String, path: String) -> URL { + URL(string: "zennotes://task?id=\(encode(id))&path=\(encode(path))") ?? open(path) + } + + /// Only unreserved characters stay bare: `#` in task ids and `&` in + /// titles would otherwise split the URL. + private static let unreserved = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + + private static func encode(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? "" + } +} + +extension View { + /// iOS 17 draws widgets inside a system container (margins included); + /// earlier releases get the same look from a plain background + padding. + @ViewBuilder + func zenWidgetBackground(_ color: Color) -> some View { + if #available(iOS 17.0, *) { + self.containerBackground(for: .widget) { color } + } else { + self + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(color) + } + } + + /// Lock Screen accessories render vibrant on the system's own material; + /// iOS 17 still wants a container background declared. + @ViewBuilder + func zenAccessoryBackground() -> some View { + if #available(iOS 17.0, *) { + self.containerBackground(for: .widget) { Color.clear } + } else { + self + } + } +} diff --git a/ios/App/ZenWidgets/ZenWidgets.entitlements b/ios/App/ZenWidgets/ZenWidgets.entitlements new file mode 100644 index 0000000..0904259 --- /dev/null +++ b/ios/App/ZenWidgets/ZenWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.md.zennotes + + + diff --git a/ios/App/ZenWidgets/ZenWidgetsBundle.swift b/ios/App/ZenWidgets/ZenWidgetsBundle.swift new file mode 100644 index 0000000..0753314 --- /dev/null +++ b/ios/App/ZenWidgets/ZenWidgetsBundle.swift @@ -0,0 +1,16 @@ +import SwiftUI +import WidgetKit + +/// The ZenNotes widget gallery: capture (New Note), pick up where you left +/// off (Recent Notes), and what's due (Today's Tasks). All three render from +/// the snapshot the app publishes into the App Group (WidgetSnapshot.swift) +/// — the extension never touches the vault — and every tap is a +/// `zennotes://` link the shell resolves (src/ui-mobile/deep-links.ts). +@main +struct ZenWidgetsBundle: WidgetBundle { + var body: some Widget { + NewNoteWidget() + RecentNotesWidget() + TasksWidget() + } +} diff --git a/package-lock.json b/package-lock.json index 0dff3fc..6b2043e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,13 @@ "@codemirror/search": "^6.7.2", "@codemirror/state": "^6.7.4", "@codemirror/view": "^6.43.11", + "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.11.6", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.15", "function-plot": "^1.25.3", @@ -64,6 +68,7 @@ "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^6.1.1", "autoprefixer": "^10.6.0", + "esbuild": "^0.28.2", "harper.js": "^2.10.0", "postcss": "^8.5.28", "tailwindcss": "^3.4.17", @@ -112,6 +117,15 @@ "node": ">=20.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -655,18 +669,18 @@ } }, "node_modules/@codemirror/state": { - "version": "6.7.4", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.4.tgz", - "integrity": "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA==", + "version": "6.7.5", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.5.tgz", + "integrity": "sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "node_modules/@codemirror/view": { - "version": "6.43.11", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.11.tgz", - "integrity": "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg==", + "version": "6.43.12", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.12.tgz", + "integrity": "sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", @@ -675,463 +689,2207 @@ "w3c-keyname": "^2.2.4" } }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "license": "MIT" - }, - "node_modules/@iconify/utils": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", - "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@antfu/install-pkg": "^1.1.0", - "@iconify/types": "^2.0.0", - "import-meta-resolve": "^4.2.0" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@ionic/cli-framework-output": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", - "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@ionic/utils-terminal": "2.3.5", - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-array": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", - "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-fs": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", - "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/fs-extra": "^8.0.0", - "debug": "^4.0.0", - "fs-extra": "^9.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-fs/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@ionic/utils-object": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", - "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-process": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", - "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@ionic/utils-object": "2.1.6", - "@ionic/utils-terminal": "2.3.5", - "debug": "^4.0.0", - "signal-exit": "^3.0.3", - "tree-kill": "^1.2.2", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", - "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-subprocess": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", - "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@ionic/utils-array": "2.1.6", - "@ionic/utils-fs": "3.1.7", - "@ionic/utils-process": "2.1.12", - "@ionic/utils-stream": "3.1.7", - "@ionic/utils-terminal": "2.3.5", - "cross-spawn": "^7.0.3", - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-terminal": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", - "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/slice-ansi": "^4.0.0", - "debug": "^4.0.0", - "signal-exit": "^3.0.3", - "slice-ansi": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "tslib": "^2.0.1", - "untildify": "^4.0.0", - "wrap-ansi": "^7.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@excalidraw/excalidraw": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@excalidraw/excalidraw/-/excalidraw-0.18.1.tgz", + "integrity": "sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "6.0.2", + "@excalidraw/laser-pointer": "1.3.1", + "@excalidraw/mermaid-to-excalidraw": "2.2.2", + "@excalidraw/random-username": "1.1.0", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-tabs": "1.0.2", + "browser-fs-access": "0.29.1", + "canvas-roundrect-polyfill": "0.0.1", + "clsx": "1.1.1", + "cross-env": "7.0.3", + "es6-promise-pool": "2.5.0", + "fractional-indexing": "3.2.0", + "fuzzy": "0.1.3", + "image-blob-reduce": "3.0.1", + "jotai": "2.11.0", + "jotai-scope": "0.7.2", + "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", + "nanoid": "3.3.3", + "open-color": "1.9.1", + "pako": "2.0.3", + "perfect-freehand": "1.2.0", + "pica": "7.1.1", + "png-chunk-text": "1.0.0", + "png-chunks-encode": "1.0.0", + "png-chunks-extract": "1.0.0", + "points-on-curve": "1.0.1", + "pwacompat": "2.0.17", + "roughjs": "4.6.4", + "sass": "1.51.0", + "tunnel-rat": "0.1.2" + }, + "peerDependencies": { + "react": "^17.0.2 || ^18.2.0 || ^19.0.0", + "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@braintree/sanitize-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", + "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/immutable": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/points-on-curve": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz", + "integrity": "sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/roughjs": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.4.tgz", + "integrity": "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/roughjs/node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/sass": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", + "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", + "license": "MIT", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@excalidraw/laser-pointer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", + "integrity": "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==", + "license": "MIT" + }, + "node_modules/@excalidraw/markdown-to-text": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz", + "integrity": "sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==", + "license": "MIT" + }, + "node_modules/@excalidraw/mermaid-to-excalidraw": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz", + "integrity": "sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==", + "license": "MIT", + "dependencies": { + "@excalidraw/markdown-to-text": "0.1.2", + "@mermaid-js/parser": "^0.6.3", + "mermaid": "^11.12.1", + "nanoid": "4.0.2" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@excalidraw/random-username": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@excalidraw/random-username/-/random-username-1.1.0.tgz", + "integrity": "sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", + "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz", + "integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", + "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/@myriaddreamin/typst-ts-renderer": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@myriaddreamin/typst-ts-renderer/-/typst-ts-renderer-0.7.0.tgz", + "integrity": "sha512-3sXIGxZn9MufPrPn6251DeuLf2FIEILYNzY5lX0XOJmaIYqgvQ3qpfqkCjgQD+splBvt5R1N3BuuNFrZKsHhMw==", + "license": "Apache-2.0" + }, + "node_modules/@myriaddreamin/typst-ts-web-compiler": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@myriaddreamin/typst-ts-web-compiler/-/typst-ts-web-compiler-0.7.0.tgz", + "integrity": "sha512-nMwMcfOBy5pABPDuVM7/u8425SxhPUM+OJe6daqqgVOT3+neCTz4JKwx2L8XasfEHTNvd0cUI07LR9q5ADo7Gw==", + "license": "Apache-2.0" + }, + "node_modules/@myriaddreamin/typst.ts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@myriaddreamin/typst.ts/-/typst.ts-0.7.0.tgz", + "integrity": "sha512-JtO5Td/1QesH1IBKAZtbayFNK8u2sl3iz18Y67uYqBEdcXiu3z+wpZcDDKlmVJvAcCgSjHTnuk6ZLfkYclrGGQ==", + "license": "Apache-2.0", + "dependencies": { + "idb": "^7.1.1" + }, + "peerDependencies": { + "@myriaddreamin/typst-ts-renderer": "^0.7.0", + "@myriaddreamin/typst-ts-web-compiler": "^0.7.0" + }, + "peerDependenciesMeta": { + "@myriaddreamin/typst-ts-renderer": { + "optional": true + }, + "@myriaddreamin/typst-ts-web-compiler": { + "optional": true + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz", + "integrity": "sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.0.tgz", + "integrity": "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@lezer/common": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", - "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", - "license": "MIT" - }, - "node_modules/@lezer/cpp": { + "node_modules/@radix-ui/react-popover": { "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", - "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@lezer/css": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", - "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.0" + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@lezer/go": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", - "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.0" + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@lezer/highlight": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", - "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.3.0" + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@lezer/html": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", - "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz", + "integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-collection": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-use-callback-ref": "1.0.0", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/java": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", - "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10" } }, - "node_modules/@lezer/javascript": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", - "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.1.3", - "@lezer/lr": "^1.3.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/json": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", - "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/lr": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", - "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/markdown": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz", - "integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.5.0", - "@lezer/highlight": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/php": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", - "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.1.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/python": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", - "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/rust": { + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", - "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz", + "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-roving-focus": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz", + "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/sass": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", - "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/xml": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", - "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/yaml": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", - "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.4.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", - "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", - "license": "MIT" + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@mermaid-js/parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", - "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", "license": "MIT", "dependencies": { - "@chevrotain/types": "~11.1.2" + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@radix-ui/react-use-callback-ref": "1.1.0" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", "license": "MIT", - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@radix-ui/rect": "1.1.0" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@oxc-project/types": { - "version": "0.148.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", - "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", - "dev": true, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, "node_modules/@replit/codemirror-vim": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.4.0.tgz", @@ -1413,7 +3171,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/d3": { @@ -1725,9 +3483,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", - "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", "dev": true, "license": "MIT", "dependencies": { @@ -1908,6 +3666,97 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/@zennotes/app-core": { + "version": "2.53.0-core.h598c8d004c9228a3", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "integrity": "sha512-OdDukylM9XIVAQZfjEvxqAbLDTBUd0HpIHggRQsyegFpHTbR5cOL3pTUdmv3k56f3S22SegMRqQCs1BQdoUJSg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language-data": "^6.5.1", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.5.8", + "@excalidraw/excalidraw": "^0.18.1", + "@myriaddreamin/typst-ts-renderer": "^0.7.0", + "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", + "@myriaddreamin/typst.ts": "^0.7.0", + "@replit/codemirror-vim": "^6.3.0", + "@xyflow/react": "^12.11.2", + "@zennotes/bridge-contract": "2.53.0-boundaries.h193dbe157c4e64d2", + "@zennotes/shared-domain": "2.53.0-boundaries.h193dbe157c4e64d2", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "harper.js": "^2.7.0", + "hast": "^1.0.0", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mdast": "^3.0.0", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.3.2" + }, + "peerDependencies": { + "@codemirror/language": "^6.10.6", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/common": "^1.5.2", + "@lezer/highlight": "^1.2.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "vite": "^6.4.3 || ^7.0.0 || ^8.0.0", + "zustand": "^5.0.2" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/@zennotes/bridge-contract": { + "version": "2.53.0-boundaries.h193dbe157c4e64d2", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "integrity": "sha512-HO/4BploqBPtJ4Lu4/Wg+YfXuUxHF8RIzqPaA6jAnh20p6zporqtiJbfjQAtu/nCPqeCXgXc3yk9fr/s0ehvKw==", + "license": "MIT" + }, + "node_modules/@zennotes/shared-domain": { + "version": "2.53.0-boundaries.h193dbe157c4e64d2", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "integrity": "sha512-Uk53Icfq7IFasrxNrJN+tfGFFb5FaOgbb99IehRAmJZgkXG3IDW+RXsO4Sh3E4EqwTJk/sq68DCVmc1SNgbYtQ==", + "license": "MIT", + "dependencies": { + "@zennotes/bridge-contract": "2.53.0-boundaries.h193dbe157c4e64d2", + "lz-string": "^1.5.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1945,7 +3794,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -1971,6 +3819,18 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -1992,9 +3852,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.0.tgz", - "integrity": "sha512-A26d6qs9kqGgkmImIXMYvXTzqb4Qv7AVgpY1NXzr9Y659J8qHHnLOO/zE8ewIGFMprOolAoRAQYDgNryXIJKBw==", + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", "dev": true, "funding": [ { @@ -2070,9 +3930,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", - "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2096,7 +3956,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2135,7 +3994,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -2144,10 +4002,16 @@ "node": ">=8" } }, + "node_modules/browser-fs-access": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.29.1.tgz", + "integrity": "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==", + "license": "Apache-2.0" + }, "node_modules/browserslist": { - "version": "4.28.9", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", - "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", "dev": true, "funding": [ { @@ -2165,11 +4029,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.20", + "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.420", - "node-releases": "^2.0.54", - "update-browserslist-db": "^1.3.2" + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -2228,6 +4092,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-roundrect-polyfill": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz", + "integrity": "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==", + "license": "MIT" + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2272,7 +4142,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -2297,7 +4166,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -2322,6 +4190,15 @@ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, + "node_modules/clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -2386,17 +4263,43 @@ "layout-base": "^1.0.0" } }, + "node_modules/crc-32": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/crelt": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3003,12 +4906,18 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3052,9 +4961,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.427", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", - "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "version": "1.5.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", + "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", "dev": true, "license": "ISC" }, @@ -3120,6 +5029,57 @@ "benchmarks" ] }, + "node_modules/es6-promise-pool": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", + "integrity": "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3258,14 +5218,12 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -3296,6 +5254,15 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fractional-indexing": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", + "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", + "license": "CC0-1.0", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, "node_modules/fs-extra": { "version": "11.3.6", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", @@ -3315,7 +5282,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3355,6 +5321,23 @@ "interval-arithmetic-eval": "^0.5.3" } }, + "node_modules/fuzzy": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz", + "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -3386,6 +5369,12 @@ "node": ">=10.13.0" } }, + "node_modules/glur": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz", + "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3418,7 +5407,6 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/harper.js/-/harper.js-2.10.0.tgz", "integrity": "sha512-s6BDuqtRbX8M/5exH1G5M7VzAuv6l0TagbRxMY37OsrT/1kWFjWmqHydWrkYFqTUW+fFhu4Hj2yUxvChp862Pg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "fflate": "^0.8.2" @@ -3437,6 +5425,13 @@ "node": ">= 0.4" } }, + "node_modules/hast": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hast/-/hast-1.0.0.tgz", + "integrity": "sha512-vFUqlRV5C+xqP76Wwq2SrM0kipnmpxJm7OfvVXpB35Fp+Fn4MV+ozr+JZr5qFvyR1q/U+Foim2x+3P+x9S1PLA==", + "deprecated": "Renamed to rehype", + "license": "MIT" + }, "node_modules/hast-util-from-dom": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", @@ -3676,6 +5671,30 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/image-blob-reduce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz", + "integrity": "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==", + "license": "MIT", + "dependencies": { + "pica": "^7.1.0" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -3690,7 +5709,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -3735,7 +5753,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -3789,7 +5806,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3809,7 +5825,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -3822,7 +5837,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -3857,7 +5871,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -3870,6 +5883,37 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jotai": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", + "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/jotai-scope": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/jotai-scope/-/jotai-scope-0.7.2.tgz", + "integrity": "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==", + "license": "MIT", + "peerDependencies": { + "jotai": ">=2.9.2", + "react": ">=17.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3973,7 +6017,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -4256,6 +6300,18 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -4302,6 +6358,15 @@ "node": ">=12.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -4334,6 +6399,13 @@ "mr-parser": "^0.2.1" } }, + "node_modules/mdast": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast/-/mdast-3.0.0.tgz", + "integrity": "sha512-xySmf8g4fPKMeC07jXGz971EkLbWAJ83s4US2Tj9lEdnZ142UP5grN73H1Xd3HzrdbU5o9GYYP/y8F9ZSwLE9g==", + "deprecated": "`mdast` was renamed to `remark`", + "license": "MIT" + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -5298,6 +7370,16 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multimath": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz", + "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "object-assign": "^4.1.1" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -5314,7 +7396,6 @@ "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, "funding": [ { "type": "github", @@ -5364,6 +7445,15 @@ "double-bits": "^1.1.0" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/node-releases": { "version": "2.0.55", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", @@ -5378,7 +7468,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5388,7 +7477,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5422,6 +7510,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-color": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz", + "integrity": "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==", + "license": "MIT" + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5435,6 +7529,12 @@ "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", "license": "MIT" }, + "node_modules/pako": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -5457,7 +7557,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5504,18 +7603,36 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-freehand": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz", + "integrity": "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==", + "license": "MIT" + }, + "node_modules/pica": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz", + "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "inherits": "^2.0.3", + "multimath": "^2.0.0", + "object-assign": "^4.1.1", + "webworkify": "^1.5.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5559,6 +7676,31 @@ "node": ">=10.4.0" } }, + "node_modules/png-chunk-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz", + "integrity": "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==", + "license": "MIT" + }, + "node_modules/png-chunks-encode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz", + "integrity": "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0", + "sliced": "^1.0.1" + } + }, + "node_modules/png-chunks-extract": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", + "integrity": "sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -5579,7 +7721,7 @@ "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -5738,6 +7880,21 @@ "dev": true, "license": "MIT" }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -5772,6 +7929,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pwacompat": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz", + "integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==", + "license": "Apache-2.0" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5818,6 +7981,75 @@ "react": "^18.3.1" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5847,7 +8079,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -6098,7 +8329,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.148.0", @@ -6197,6 +8428,63 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.104.1.tgz", + "integrity": "sha512-yDA+1aIG3EHgN4V/BvuhCvu61FF6hEd4e+9DxikUm9U0CAGuvdIZ/UYy7qbOxjhbbWQraoyLlMuzdRGOSV5Bmw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/sax": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", @@ -6243,7 +8531,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6256,7 +8543,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6294,11 +8580,17 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/sliced": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", + "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", + "deprecated": "Unsupported", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6566,7 +8858,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -6583,7 +8875,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6601,7 +8893,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -6614,7 +8906,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -6675,6 +8966,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -6862,6 +9190,49 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -6937,7 +9308,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", @@ -7015,7 +9386,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -7052,11 +9423,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webworkify": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", + "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 1414af7..da09de1 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,15 @@ "homepage": "https://zennotes.org", "scripts": { "dev": "vite", - "source:prepare": "sh tooling/prepare-zennotes.sh", - "prebuild": "npm run source:prepare", "build": "vite build", "test": "node --test", "e2e:cloud": "node tooling/cloud-direct-upload-e2e.mjs", - "pretypecheck": "npm run source:prepare", "typecheck": "tsc --noEmit", "sync": "npm run build && cap sync ios", - "upstream": "sh tooling/upstream-check.sh", - "ios": "npm run build && cap sync ios && cap open ios" + "upstream": "npm run boundaries:check && npm run typecheck", + "ios": "npm run build && cap sync ios && cap open ios", + "boundaries:check": "node tooling/check-core-boundary.mjs", + "build:boundary-fixture": "node tooling/build-native-boundary-fixture.mjs" }, "dependencies": { "@aparajita/capacitor-secure-storage": "7.1.6", @@ -66,7 +65,11 @@ "unist-util-visit": "^5.0.0", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.2", - "zustand": "^5.0.15" + "zustand": "^5.0.15", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.53.0-core.h598c8d004c9228a3.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.53.0-boundaries.h193dbe157c4e64d2.tgz", + "@lezer/common": "^1.5.2" }, "devDependencies": { "@capacitor/cli": "^7.6.9", @@ -79,6 +82,16 @@ "postcss": "^8.5.28", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", - "vite": "^8.3.0" + "vite": "^8.3.0", + "esbuild": "^0.28.2" + }, + "overrides": { + "@excalidraw/excalidraw": { + "nanoid": "3.3.18" + }, + "@excalidraw/mermaid-to-excalidraw": { + "@mermaid-js/parser": "1.2.1", + "nanoid": "5.1.16" + } } } diff --git a/src/bridge/cloud-sync-client.test.ts b/src/bridge/cloud-sync-client.test.ts new file mode 100644 index 0000000..b4928a5 --- /dev/null +++ b/src/bridge/cloud-sync-client.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +it('allows the full publishing timeout through the native iOS transport', async () => { + const requests: Array<{ connectTimeout?: number; readTimeout?: number }> = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + CapacitorHttp: { + request: async (options: { connectTimeout?: number; readTimeout?: number }) => { + requests.push(options) + return { status: 200, data: { id: 1, slug: 'test', url: 'https://example.test/s/test' } } + } + } + } + }) + const client = createCloudSyncClient('https://example.test', 'test-only') + const note = { note_path: 'Test.md', title: 'Test', markdown: 'Latest content' } + await client.publishNote(note) + await client.updatePublishedNote(1, note) + // Capacitor 7 iOS applies connectTimeout ?? readTimeout to the entire + // URLRequest, so a shorter connection value silently wins over readTimeout. + assert.deepEqual(requests.map(({ connectTimeout, readTimeout }) => ({ connectTimeout, readTimeout })), [ + { connectTimeout: 300_000, readTimeout: 300_000 }, + { connectTimeout: 300_000, readTimeout: 300_000 } + ]) +}) diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index 665cf24..91114ed 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -39,6 +39,7 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync const transport: CloudSyncHttpTransport = { async request(request: CloudSyncHttpRequest): Promise { const multipart = request.body instanceof FormData + const timeoutMs = request.timeoutMs ?? 300_000 const response = await CapacitorHttp.request({ method: request.method, url: `${normalizedBaseUrl}${request.path}`, @@ -55,12 +56,10 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync ? await serializeFormData(request.body) : request.body, ...(multipart ? { dataType: 'formData' as const } : {}), - connectTimeout: 30_000, - // Generous on purpose: a first sync of an attachment-heavy vault - // legitimately pushes 100-item base64 batches over cellular, and a - // timeout here retries into the same wall forever. Desktop's fetch - // transport has no read timeout at all. - readTimeout: request.timeoutMs ?? 300_000 + // Capacitor iOS uses connectTimeout ahead of readTimeout for the + // entire request. Keep them equal so long publications can finish. + connectTimeout: timeoutMs, + readTimeout: timeoutMs }) if (response.status < 200 || response.status >= 300) { diff --git a/src/bridge/cloud-sync-refresh.test.ts b/src/bridge/cloud-sync-refresh.test.ts new file mode 100644 index 0000000..196ec0d --- /dev/null +++ b/src/bridge/cloud-sync-refresh.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { trackCloudSyncChanges } from './cloud-sync-refresh.ts' + +describe('cloud sync refresh', () => { + it('retains a failed refresh for the next host operation', async () => { + const state = { changed: false } + const fs = { readdir: async () => [], stat: async () => null, readBase64: async () => '', + writeText: async () => {}, writeBase64: async () => {}, deleteFile: async () => {}, rename: async () => {} } + const first = trackCloudSyncChanges(fs, async () => { throw new Error('unavailable') }, state) + first.markChanged() + await assert.rejects(first.refresh(), /unavailable/) + let refreshes = 0 + const next = trackCloudSyncChanges(fs, async () => { refreshes++ }, state) + await next.refresh() + await next.refresh() + assert.equal(refreshes, 1) + }) + function fixture(failWrite = false) { + let refreshes = 0 + const changes = trackCloudSyncChanges({ + readdir: async () => [], stat: async () => null, readBase64: async () => '', + writeText: async () => { if (failWrite) throw new Error('disk full') }, + writeBase64: async () => {}, deleteFile: async () => {}, rename: async () => {} + }, async () => { refreshes++ }) + return { changes, count: () => refreshes } + } + + it('does not refresh the editor after a read-only / no-op sync', async () => { + const { changes, count } = fixture() + await changes.fs.readdir('') + await changes.fs.readBase64('note.md') + await changes.refresh() + assert.equal(count(), 0) + }) + + it('refreshes once for a batch of pulled notes, assets, moves and deletions', async () => { + const { changes, count } = fixture() + await changes.fs.writeText('note.md', 'new content') + await changes.fs.writeBase64('image.png', 'AA==') + await changes.fs.rename('note.md', 'renamed.md') + await changes.fs.deleteFile('old.md') + await changes.refresh() + assert.equal(count(), 1) + }) + + it('still refreshes partial filesystem changes when a sync fails', async () => { + const { changes, count } = fixture(true) + await assert.rejects(changes.fs.writeText('note.md', 'partial'), /disk full/) + await changes.refresh() + assert.equal(count(), 1) + }) + + it('refreshes externally changed files found during scanning without a pull', async () => { + const { changes, count } = fixture() + changes.markChanged() + await changes.refresh() + assert.equal(count(), 1) + }) +}) diff --git a/src/bridge/cloud-sync-refresh.ts b/src/bridge/cloud-sync-refresh.ts new file mode 100644 index 0000000..34f5ba7 --- /dev/null +++ b/src/bridge/cloud-sync-refresh.ts @@ -0,0 +1,30 @@ +import type { PortableCloudSyncFileSystem } from '@zennotes/shared-domain/cloud-sync-portable-filesystem' + +/** One host operation owns this tracker. Mark before writes: a native write + * can change the disk before rejecting. A no-op sync must not rebuild every + * editor surface, but partial failed pulls still need to become visible. */ +export function trackCloudSyncChanges( + fs: PortableCloudSyncFileSystem, + refresh: () => Promise, + state = { changed: false } +) { + const markChanged = () => { state.changed = true } + return { + markChanged, + fs: { + ...fs, + writeText: async (path: string, value: string) => { markChanged(); await fs.writeText(path, value) }, + writeBase64: async (path: string, value: string) => { markChanged(); await fs.writeBase64(path, value) }, + deleteFile: async (path: string) => { markChanged(); await fs.deleteFile(path) }, + rename: async (from: string, to: string) => { markChanged(); await fs.rename(from, to) } + }, + async refresh() { + if (!state.changed) return + state.changed = false + try { await refresh() } catch (error) { + state.changed = true + throw error + } + } + } +} diff --git a/src/bridge/cloud-sync-repository.test.ts b/src/bridge/cloud-sync-repository.test.ts new file mode 100644 index 0000000..292c82e --- /dev/null +++ b/src/bridge/cloud-sync-repository.test.ts @@ -0,0 +1,317 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { describe, it } from 'node:test' +import type { CloudSyncContent, CloudSyncMutation } from '@zennotes/bridge-contract/cloud-sync' +import type { + CloudSyncLocalItem, CloudSyncState, CloudSyncStoredConflict +} from '@zennotes/shared-domain/cloud-sync-engine' +import type { CloudSyncRepository } from '@zennotes/shared-domain/cloud-sync-coordinator' + +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const { CachedCloudSyncRepository } = await loadMobileModule('./src/bridge/cloud-sync-repository') +const { CloudSyncCoordinator } = await loadMobileModule('@zennotes/shared-domain/cloud-sync-coordinator') + +type StoredFile = { bytes: Buffer; mtime: number } +function harness(initial: Record = { 'note.md': 'Hello' }) { + const files = new Map( + Object.entries(initial).map(([path, body]) => [path, { bytes: Buffer.from(body), mtime: 1000 }]) + ) + let cache: unknown = null + let state: CloudSyncState | null = null + let clock = 1000 + const reads: string[] = [] + const failures = { cacheRead: false, cacheWrite: false, stateRead: false, directory: false, file: false } + let onRead: ((path: string) => void) | undefined + const put = (path: string, body: string | Buffer) => { + files.set(path, { bytes: Buffer.from(body), mtime: ++clock }) + } + const stat = async (path: string) => { + const file = files.get(path) + if (file) return { type: 'file' as const, size: file.bytes.length, mtime: file.mtime } + if ([...files.keys()].some((name) => name.startsWith(path + '/'))) { + return { type: 'directory' as const, size: 0, mtime: 1000 } + } + return null + } + const readdir = async (directory: string) => { + if (failures.directory) throw new Error('Directory unavailable') + const entries = new Map() + const prefix = directory ? directory + '/' : '' + for (const [path, file] of files) { + if (!path.startsWith(prefix)) continue + const relative = path.slice(prefix.length) + const [name, nested] = relative.split('/') + entries.set(name, { + name, type: nested ? 'directory' : 'file', + size: nested ? 0 : file.bytes.length, mtime: nested ? 1000 : file.mtime + }) + } + return [...entries.values()] + } + const readBase64 = async (path: string) => { + reads.push(path) + if (failures.file) throw new Error('File unavailable') + onRead?.(path) + const file = files.get(path) + if (!file) throw new Error('File missing') + return file.bytes.toString('base64') + } + const fs = { + readdir, + stat: async (path: string) => (await stat(path))?.type ?? null, + readBase64, + writeText: async (path: string, data: string) => put(path, data), + writeBase64: async (path: string, data: string) => put(path, Buffer.from(data, 'base64')), + deleteFile: async (path: string) => { files.delete(path) }, + rename: async (from: string, to: string) => { + const file = files.get(from) + if (!file) throw new Error('File missing') + files.set(to, file) + files.delete(from) + } + } + const native = { readdirStrict: readdir, readBase64, statOrNull: stat, stat } + const store = { + loadTracked: async () => { + if (failures.stateRead) throw new Error('State unavailable') + return state + }, + loadCache: async () => { + if (failures.cacheRead) throw new Error('Cache unavailable') + return cache + }, + saveCache: async (next: unknown) => { + if (failures.cacheWrite) throw new Error('Cache unavailable') + cache = structuredClone(next) + } + } + const repository: CloudSyncRepository = new CachedCloudSyncRepository(fs, native, store) + function acknowledge(items: CloudSyncLocalItem[]) { + state = { + version: 1, vault_id: 'vault-1', cursor: 1, + items: Object.fromEntries(items.map((item, index) => [`item-${index}`, { + item_id: `item-${index}`, path: item.path, kind: item.kind, revision: 1, + sha256: item.content.sha256, byte_length: item.content.byte_length, + media_type: item.content.media_type + }])) + } + } + const coordinator = () => { + const mutations: CloudSyncMutation[] = [] + const remote = { + manifest: async () => ({ data: [], cursor: state?.cursor ?? 0, next_page: null }), + changes: async () => ({ data: [], cursor: state?.cursor ?? 0, has_more: false }), + mutate: async (_vaultId: string, body: { mutations: CloudSyncMutation[] }) => { + // Serialization is deliberately real: a cache placeholder must never be uploaded. + mutations.push(...JSON.parse(JSON.stringify(body.mutations))) + return { + acknowledged: body.mutations.map((mutation) => ({ + operation_id: mutation.operation_id, item_id: mutation.item_id, revision: 2, sequence: 1 + })), + conflicts: [], cursor: 1 + } + } + } + let id = 0 + return { + mutations, + service: new CloudSyncCoordinator('vault-1', remote, repository, { + load: async () => state, + save: async (next: CloudSyncState) => { state = structuredClone(next) } + }, { itemId: () => `new-${++id}`, operationId: () => `op-${++id}` }) + } + } + return { + files, reads, failures, repository, acknowledge, coordinator, put, + setReadHook: (hook: typeof onRead) => { onRead = hook }, + get cache() { return cache }, set cache(next: unknown) { cache = next }, + get state() { return state }, set state(next: CloudSyncState | null) { state = next } + } +} + +function content(text: string): CloudSyncContent { + return { + encoding: 'utf8', data: text, sha256: createHash('sha256').update(text).digest('hex'), + byte_length: Buffer.byteLength(text), media_type: 'text/markdown' + } +} + +function pending(path: string, local: CloudSyncContent, cloud = content('Other device')): CloudSyncStoredConflict { + return { + id: 'conflict-1', item_id: 'item-0', kind: 'content', sequence: 2, + base: { path, revision: 1, kind: 'text', content: content('Base') }, + local: { path, revision: null, kind: 'text', content: local }, + cloud: { path, revision: 2, kind: 'text', content: cloud } + } +} + +describe('cached mobile Cloud scan', () => { + it('reads and hashes new text and binary files with the same portable semantics', async () => { + const bytes = Buffer.from([0, 255, 1, 128]) + const h = harness({ 'note.md': 'Hello', 'assets/photo.png': bytes, '.zennotes/cache.json': '{}' }) + const items = await h.repository.scan() + assert.deepEqual(items.map((item) => item.path), ['assets/photo.png', 'note.md']) + assert.equal(items[0].kind, 'binary') + assert.equal(items[0].content.data, bytes.toString('base64')) + assert.equal(items[0].content.sha256, createHash('sha256').update(bytes).digest('hex')) + assert.deepEqual(items[1].content, content('Hello')) + }) + + it('does not reread unchanged acknowledged files on the next sync', async () => { + const h = harness({ 'a.md': 'A', 'b.md': 'B', 'assets/p.png': Buffer.from([0, 255]) }) + h.acknowledge(await h.repository.scan()) + h.reads.length = 0 + const result = await h.coordinator().service.sync() + assert.deepEqual(h.reads, []) + assert.equal(result.pushed, 0) + assert.equal(result.pendingConflicts.length, 0) + }) + + it('rereads only the edited file and uploads its complete current bytes', async () => { + const h = harness({ 'a.md': 'A', 'b.md': 'B' }) + h.acknowledge(await h.repository.scan()) + h.put('a.md', 'Changed') + h.reads.length = 0 + const c = h.coordinator() + const result = await c.service.sync() + assert.deepEqual(h.reads, ['a.md']) + assert.equal(result.pushed, 1) + assert.equal(c.mutations[0].type, 'upsert') + if (c.mutations[0].type === 'upsert') assert.equal(c.mutations[0].content.data, 'Changed') + }) + + + it('rereads a same-size edit when its modification time changes', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.put('note.md', 'World') + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'World') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('rereads a size change even if a provider preserves the modification time', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.files.set('note.md', { bytes: Buffer.from('Longer content'), mtime: 1000 }) + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Longer content') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('rereads files whose scanned content has not yet been acknowledged', async () => { + const h = harness() + await h.repository.scan() + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('rereads an unacknowledged edit even when its fingerprint is cached', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.put('note.md', 'Local edit') + await h.repository.scan() + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Local edit') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('keeps rename and deletion semantics intact without uploading cached bytes', async () => { + const h = harness({ 'a.md': 'A', 'b.md': 'B' }) + h.acknowledge(await h.repository.scan()) + h.files.set('renamed.md', h.files.get('a.md')!) + h.files.delete('a.md') + h.files.delete('b.md') + const c = h.coordinator() + await c.service.sync() + assert.deepEqual(c.mutations.map((mutation) => mutation.type).sort(), ['delete', 'move']) + assert.equal(c.mutations.find((mutation) => mutation.type === 'move')?.path, 'renamed.md') + assert.deepEqual(Object.keys(h.cache as object), ['renamed.md']) + }) + + for (const failure of ['cacheRead', 'stateRead', 'cacheWrite'] as const) { + it(`falls back safely when ${failure} fails`, async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.reads.length = 0 + h.failures[failure] = true + const items = await h.repository.scan() + assert.equal(items[0].content.sha256, content('Hello').sha256) + if (failure !== 'cacheWrite') assert.deepEqual(h.reads, ['note.md']) + }) + } + + it('rebuilds a lost or malformed cache from actual file content', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + for (const damaged of [null, { 'note.md': { mtime: 1000, sha256: 'not-an-entry' } }]) { + h.cache = damaged + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + } + }) + + for (const mtime of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + it(`does not trust unavailable or invalid modification time ${mtime}`, async () => { + const h = harness() + h.files.get('note.md')!.mtime = mtime + h.acknowledge(await h.repository.scan()) + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + }) + } + + it('does not cache a file that changes while its bytes are being read', async () => { + const h = harness() + h.setReadHook((path) => { h.put(path, 'World') }) + h.acknowledge(await h.repository.scan()) + h.setReadHook(undefined) + // Restore the old metadata as can happen with coarse timestamps: the + // unstable read must not leave a reusable cache entry for that fingerprint. + h.files.get('note.md')!.mtime = 1000 + h.reads.length = 0 + await h.repository.scan() + assert.deepEqual(h.reads, ['note.md']) + }) + + for (const failure of ['directory', 'file'] as const) { + it(`fails closed on a ${failure} read error, without manufacturing a deletion`, async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + const priorCache = structuredClone(h.cache) + h.put('note.md', 'Unreadable new edit') + h.failures[failure] = true + const c = h.coordinator() + await assert.rejects(c.service.sync(), /unavailable/) + assert.deepEqual(c.mutations, []) + assert.deepEqual(h.cache, priorCache) + }) + } +}) + +describe('cached scan with the pinned conflict coordinator', () => { + it('returns real bytes for review when pending local content matches acknowledged content', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.state!.pending_conflicts = { 'conflict-1': pending('note.md', content('Hello')) } + h.reads.length = 0 + const details = await h.coordinator().service.getConflict('conflict-1') + assert.equal(details.local.text, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('can locate and read a moved pending-conflict file even when its new path is acknowledged', async () => { + const h = harness({ 'renamed.md': 'Hello' }) + h.acknowledge(await h.repository.scan()) + h.state!.pending_conflicts = { 'conflict-1': pending('old.md', content('Hello')) } + h.reads.length = 0 + const details = await h.coordinator().service.getConflict('conflict-1') + assert.equal(details.local.path, 'renamed.md') + assert.equal(details.local.text, 'Hello') + assert.deepEqual(h.reads, ['renamed.md']) + }) +}) diff --git a/src/bridge/cloud-sync-repository.ts b/src/bridge/cloud-sync-repository.ts index ba31925..cf5063f 100644 --- a/src/bridge/cloud-sync-repository.ts +++ b/src/bridge/cloud-sync-repository.ts @@ -1,25 +1,24 @@ /** * PortableCloudSyncRepository with a scan cache. The upstream portable scan - * reads and hashes every file's full bytes across the WebKit bridge on every + * reads and hashes every file's full bytes across the native bridge on every * sync run — a 60-second background cadence on app-core's auto-sync — which * scales battery and memory cost with vault size. This subclass skips the * read for files that are provably not needed: * * skip ⇔ (mtime AND size unchanged since the last real read) * AND (that read's hash equals the acked sync state's hash) + * AND (the file is not involved in a pending conflict) * * The engine (cloud-sync-engine planCloudSyncMutations) touches * `content.data` only for items whose hash differs from the tracked state or - * that the state does not know; the bootstrap path compares hashes only. A - * skipped item therefore never needs its bytes — and to keep that a proven - * invariant rather than a hope, its `data` property THROWS if anything reads - * it: a failed sync run instead of silently pushing content we never read. + * that the state does not know. The host disables skipping for review and + * restore actions, which need real bytes even for acknowledged content. + * A skipped item's `data` property THROWS if unexpectedly consumed, rather + * than silently pushing content we never read. * - * Cache safety is one-directional by construction: a stale or lost cache - * only causes extra reads (miss → full read), never a wrong skip — a written - * file has a new mtime/size, and a hash the state doesn't vouch for is a - * miss. The residual risk is the standard mtime+size fingerprint collision - * every file watcher accepts. + * A lost cache or unknown timestamp causes a full read. Like other + * metadata-based caches, this relies on the provider updating mtime or size + * when content changes; same-size writes preserving mtime cannot be detected. */ import type { CloudSyncContent, @@ -37,7 +36,7 @@ import { } from '@zennotes/shared-domain/cloud-sync-portable-filesystem' import type { CloudSyncLocalItem, CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' import type { NativeFs } from './native-fs' -import { base64ToBytes, bytesToBase64 } from './base64' +import { cloudSyncWorkBudget, decodeCloudSyncBase64 } from './cloud-sync-work' export interface ScanCacheEntry { mtime: number @@ -59,8 +58,9 @@ export interface ScanCacheStore { export class CachedCloudSyncRepository extends PortableCloudSyncRepository { constructor( fs: PortableCloudSyncFileSystem, - private readonly native: NativeFs, - private readonly store: ScanCacheStore + private readonly native: Pick, + private readonly store: ScanCacheStore, + private readonly onChanged: () => void = () => {} ) { super(fs) } @@ -71,6 +71,7 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { const nextCache: ScanCache = {} const items: CloudSyncLocalItem[] = [] await this.walkCached('', trackedSha, cache, nextCache, items) + if (Object.keys(cache).some((path) => !nextCache[path])) this.onChanged() // Cache loss is only a slow next scan — never let it fail the sync run. await this.store.saveCache(nextCache).catch(() => {}) return items.sort((left, right) => left.path.localeCompare(right.path)) @@ -84,10 +85,12 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { items: CloudSyncLocalItem[] ): Promise { // readdirStrict entries carry mtime and size, so validating the cache - // costs no extra stat calls. An evicted iCloud file surfaces with its - // stub's mtime/size — a guaranteed miss, so it gets downloaded and read. + // costs no extra stat calls on a hit. Fresh reads are checked again + // before their fingerprints can be reused on a later scan. const entries = await this.native.readdirStrict(directory) + const checkpoint = cloudSyncWorkBudget() for (const entry of entries) { + await checkpoint() const relPath = directory ? `${directory}/${entry.name}` : entry.name if (entry.type === 'directory') { if (shouldTraverseCloudSyncDirectory(relPath)) { @@ -111,7 +114,15 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { } const item = await this.readItemFresh(path) - nextCache[path] = { + if (!cached || cached.mtime !== entry.mtime || cached.size !== entry.size || cached.sha256 !== item.content.sha256) { + this.onChanged() + } + // Never seed a reusable fingerprint from an unstable native read. + // Unknown provider timestamps are deliberately always cache misses. + const after = await this.native.statOrNull(path).catch(() => null) + if (validFingerprint(entry) && after?.type === 'file' && + after.mtime === entry.mtime && after.size === entry.size && + item.content.byte_length === entry.size) nextCache[path] = { mtime: entry.mtime, size: entry.size, sha256: item.content.sha256, @@ -124,19 +135,19 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { } // --------------------------------------------------------------------- - // Mirrored 1:1 from upstream cloud-sync-portable-filesystem.ts readItem - // (whose helpers are module-private) — keep in lockstep. + // Preserve upstream readItem's encoding/hash semantics while yielding + // during large base64 decoding and avoiding a binary re-encode. // --------------------------------------------------------------------- private async readItemFresh(path: string): Promise { - const bytes = base64ToBytes(await this.native.readBase64(path)) + const { bytes, base64 } = await decodeCloudSyncBase64(await this.native.readBase64(path)) const text = decodeText(path, bytes) return { path, kind: text === null ? 'binary' : 'text', content: { encoding: text === null ? 'base64' : 'utf8', - data: text === null ? bytesToBase64(bytes) : text, + data: text === null ? base64 : text, sha256: await sha256(bytes), byte_length: bytes.byteLength, media_type: mediaType(path, text !== null) @@ -171,9 +182,28 @@ function trackedShaByPath(state: CloudSyncState | null): Map { out.set(cloudSyncPathKey(item.path), item.sha256) } } + // Conflict review/resolution consumes actual bytes, even if a version is + // already acknowledged. Hash exclusions also cover moved local versions. + const paths = new Set() + const hashes = new Set() + for (const conflict of Object.values(state.pending_conflicts ?? {})) { + for (const snapshot of [conflict.base, conflict.local, conflict.cloud]) { + if (snapshot?.path) paths.add(cloudSyncPathKey(snapshot.path)) + if (snapshot?.content?.sha256) hashes.add(snapshot.content.sha256) + } + for (const path of conflict.paused_paths ?? []) paths.add(cloudSyncPathKey(path)) + } + for (const [path, hash] of out) { + if (paths.has(path) || hashes.has(hash)) out.delete(path) + } return out } +function validFingerprint(entry: { mtime?: number; size?: number }): boolean { + return typeof entry.mtime === 'number' && Number.isFinite(entry.mtime) && entry.mtime > 0 && + typeof entry.size === 'number' && Number.isSafeInteger(entry.size) && entry.size >= 0 +} + function normalizeScanCache(raw: unknown): ScanCache { if (!raw || typeof raw !== 'object') return {} const out: ScanCache = {} @@ -181,8 +211,7 @@ function normalizeScanCache(raw: unknown): ScanCache { const entry = value as Partial | null if ( entry && - typeof entry.mtime === 'number' && - typeof entry.size === 'number' && + validFingerprint(entry) && typeof entry.sha256 === 'string' && (entry.kind === 'text' || entry.kind === 'binary') && typeof entry.byte_length === 'number' && @@ -260,9 +289,8 @@ function mediaType(path: string, text: boolean): string { return MEDIA_TYPES[extension(path)] ?? (text ? 'text/plain' : 'application/octet-stream') } -async function sha256(bytes: Uint8Array): Promise { - const input = Uint8Array.from(bytes).buffer - const digest = await crypto.subtle.digest('SHA-256', input) +async function sha256(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes.buffer) return [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, '0')) .join('') diff --git a/src/bridge/cloud-sync-rescan.test.ts b/src/bridge/cloud-sync-rescan.test.ts new file mode 100644 index 0000000..9c3fb95 --- /dev/null +++ b/src/bridge/cloud-sync-rescan.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } }) +const { MobileVault, onVaultChange } = await loadMobileModule(['./src/bridge/vault-fs', './src/bridge/events']) + +it('batches a foreground/cloud refresh into one resync without triggering local autosync events', async () => { + const events: unknown[] = [] + const unsubscribe = onVaultChange((event: unknown) => events.push(event)) + const invalidated: string[] = [] + const vault = { + settingsCache: {}, + metaCache: new Map([ + ['deleted.md', { meta: { updatedAt: 1 }, size: 1 }], + ['changed.md', { meta: { updatedAt: 1 }, size: 1 }] + ]), + listNotes: async () => [ + { path: 'changed.md', updatedAt: 2, size: 2, folder: 'inbox' }, + { path: 'new.md', updatedAt: 2, size: 2, folder: 'inbox' } + ], + invalidateMeta: (path: string) => invalidated.push(path), + folderOf: async () => 'inbox' + } + try { + await MobileVault.prototype.rescan.call(vault) + assert.deepEqual(events, [{ kind: 'change', path: '', folder: 'inbox', scope: 'resync' }]) + assert.deepEqual(invalidated, ['deleted.md']) + assert.equal(vault.settingsCache, null) + } finally { unsubscribe() } +}) diff --git a/src/bridge/cloud-sync-work.test.ts b/src/bridge/cloud-sync-work.test.ts new file mode 100644 index 0000000..61a2480 --- /dev/null +++ b/src/bridge/cloud-sync-work.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { decodeCloudSyncBase64, yieldToUi } from './cloud-sync-work.ts' + +describe('cloud sync cooperative work', () => { + it('decodes exact bytes, including padding, whitespace and data URLs', async () => { + for (const size of [0, 1, 2, 3, 49_151, 49_152, 49_153, 300_001]) { + const expected = Buffer.alloc(size) + for (let i = 0; i < size; i++) expected[i] = i % 256 + const base64 = expected.toString('base64') + const result = await decodeCloudSyncBase64(`data:image/png;base64,\n${base64}\n`) + assert.deepEqual(Buffer.from(result.bytes), expected) + assert.equal(result.base64, base64) + } + }) + + it('lets pending input run before a large attachment finishes decoding', async () => { + const input = Buffer.alloc(8_100_000, 125).toString('base64') + let inputProcessed = false + const timer = setTimeout(() => { inputProcessed = true }, 0) + try { + const result = await decodeCloudSyncBase64(input) + assert.equal(inputProcessed, true) + assert.equal(result.bytes.length, 8_100_000) + assert.equal(result.bytes.at(-1), 125) + } finally { + clearTimeout(timer) + } + }) + + it('uses a real task boundary on older WebViews without scheduler.yield', async () => { + let inputProcessed = false + setTimeout(() => { inputProcessed = true }, 0) + await yieldToUi() + assert.equal(inputProcessed, true) + }) + + it('rejects malformed base64 instead of hashing damaged bytes', async () => { + await assert.rejects(decodeCloudSyncBase64('AA!A')) + }) +}) diff --git a/src/bridge/cloud-sync-work.ts b/src/bridge/cloud-sync-work.ts new file mode 100644 index 0000000..2f47c18 --- /dev/null +++ b/src/bridge/cloud-sync-work.ts @@ -0,0 +1,41 @@ +/** Shared with the iOS shell. Async filesystem calls do not move the + * following JavaScript off the editor's thread. Give input a real turn. + * https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield + * The timer fallback also supports iOS 15 / older Android WebViews. */ +export function yieldToUi(): Promise { + const scheduler = (globalThis as typeof globalThis & { + scheduler?: { yield?: () => Promise } + }).scheduler + return scheduler?.yield ? scheduler.yield() : new Promise((resolve) => setTimeout(resolve, 0)) +} + +export function cloudSyncWorkBudget(): () => Promise | undefined { + let started = performance.now() + return () => { + if (performance.now() - started < 8) return + return yieldToUi().then(() => { started = performance.now() }) + } +} + +/** Decode in bounded chunks instead of Uint8Array.from(string, callback), + * which visits every byte through an allocating JS iterator. Keep the + * original base64 for binary uploads rather than encoding the bytes again. */ +export async function decodeCloudSyncBase64(value: string): Promise<{ + bytes: Uint8Array + base64: string +}> { + if (value.length >= 262_144) await yieldToUi() + const base64 = (value.includes(',') ? value.slice(value.indexOf(',') + 1) : value).replace(/\s/g, '') + const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0 + const bytes = new Uint8Array(Math.max(0, Math.floor(base64.length * 3 / 4) - padding)) + const checkpoint = cloudSyncWorkBudget() + let offset = 0 + // Multiples of four keep each base64 chunk independently decodable. + for (let start = 0; start < base64.length; start += 65_536) { + const binary = atob(base64.slice(start, start + 65_536)) + for (let i = 0; i < binary.length; i++) bytes[offset++] = binary.charCodeAt(i) + await checkpoint() + } + if (offset !== bytes.length) throw new Error('Invalid Cloud file encoding.') + return { bytes, base64 } +} diff --git a/src/bridge/events.ts b/src/bridge/events.ts index 2c23ecb..859336a 100644 --- a/src/bridge/events.ts +++ b/src/bridge/events.ts @@ -5,7 +5,7 @@ * runs after every sync. The subscription API is identical to desktop/web so * app-core consumes it as-is. */ -import type { VaultChangeEvent } from '@bridge-contract/ipc' +import type { VaultChangeEvent } from '@zennotes/bridge-contract/ipc' type VaultChangeListener = (ev: VaultChangeEvent) => void diff --git a/src/bridge/imported-assets.ts b/src/bridge/imported-assets.ts index 1195a1f..6c0cd60 100644 --- a/src/bridge/imported-assets.ts +++ b/src/bridge/imported-assets.ts @@ -1,13 +1,13 @@ /** * Where an imported file goes and how it is linked. * - * A leaf module on purpose: `vault-core` reaches `@shared/*` through a Vite + * A leaf module on purpose: `vault-core` reaches `@zennotes/shared-domain/*` through a Vite * alias, which plain `node --test` cannot resolve, so nothing there is * unit-testable. These rules are where the attachment bugs hid, so they live * where a test can reach them. The vault core re-exports them so existing * importers remain unaffected. */ -import type { ImportedAssetKind } from '@bridge-contract/ipc' +import type { ImportedAssetKind } from '@zennotes/bridge-contract/ipc' export const ASSETS_DIR = 'assets' diff --git a/src/bridge/link-metadata.ts b/src/bridge/link-metadata.ts index 57b7fbb..a90425d 100644 --- a/src/bridge/link-metadata.ts +++ b/src/bridge/link-metadata.ts @@ -14,7 +14,7 @@ * (metadata lives in ``, so the whole page is never needed). */ import { CapacitorHttp } from '@capacitor/core' -import type { LinkMetadata } from '@shared/ipc' +import type { LinkMetadata } from '@zennotes/shared-domain/ipc' const TIMEOUT_MS = 6000 const MAX_CHARS = 512 * 1024 diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 7e7aa6d..5217979 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -1,3 +1,4 @@ +import { relocateLocalVault } from '@zennotes/app-core/workspace' /** * The mobile `window.zen` — third ZenBridge implementation (after Electron IPC * and the web HTTP bridge). Vault operations run against the on-device vault @@ -36,13 +37,13 @@ import type { VaultTextSearchBackendPreference, VaultTextSearchCapabilities, VaultTextSearchMatch -} from '@shared/ipc' -import { createDatabaseOps } from '@shared/database-ops' +} from '@zennotes/shared-domain/ipc' +import { createDatabaseOps } from '@zennotes/shared-domain/database-ops' import type { CustomCodeLanguage, CustomCodeLanguageInstallInput, CustomCodeLanguageUpdateInput -} from '@shared/custom-code-languages' +} from '@zennotes/shared-domain/custom-code-languages' import type { ApplyWorkflowInput, WorkflowFile, @@ -50,12 +51,12 @@ import type { WorkflowRunSummary, WorkflowUndoResult, WriteWorkflowInput -} from '@bridge-contract/workflows' +} from '@zennotes/bridge-contract/workflows' import type { McpClientStatus, McpInstructionsPayload, McpServerRuntime -} from '@shared/mcp-clients' +} from '@zennotes/shared-domain/mcp-clients' import { MobileVault } from './vault-fs' import { listVaultDirs, VAULTS_DIR } from './native-fs' import { randomUUID } from './uuid' @@ -102,6 +103,7 @@ import { restoreMobileCloudBackup, restoreMobileCloudBackupNote, syncMobileCloudVault, + hasMobileCloudVaultChanges, updateMobileCloudBackupSchedule, unlinkMobileCloudVault, deleteMobileCloudVault, @@ -262,23 +264,22 @@ export async function renameVault(entry: MobileVaultEntry, newName: string): Pro if (!clean) throw new Error('Enter a name.') if (clean === entry.name) return if (entry.tier === 'external') throw new Error('Rename this folder in the Files app.') - await assertNameFree(entry.tier, clean) const wasCurrent = isCurrentVaultEntry(entry) - if (entry.tier === 'icloud') { - await Filesystem.rename({ - from: await icloudVaultUrl(entry.name), - to: await icloudVaultUrl(clean) - }) - if (wasCurrent) await openVaultByName(clean, await icloudVaultUrl(clean)) - } else { - await Filesystem.rename({ - from: `${VAULTS_DIR}/${entry.name}`, - to: `${VAULTS_DIR}/${clean}`, - directory: Directory.Documents, - toDirectory: Directory.Documents - }) - if (wasCurrent) await openVaultByName(clean) - } + const token = (name: string): string => entry.tier === 'icloud' + ? `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(name)}` : `${VAULT_ROOT_PREFIX}${name}` + let from = '', to = '' + await relocateLocalVault({ + ...(wasCurrent ? { reopen: { source: token(entry.name), destination: token(clean) } } : {}), + move: async () => { + await assertNameFree(entry.tier, clean) + from = entry.tier === 'icloud' ? await icloudVaultUrl(entry.name) : `${VAULTS_DIR}/${entry.name}` + to = entry.tier === 'icloud' ? await icloudVaultUrl(clean) : `${VAULTS_DIR}/${clean}` + await Filesystem.rename({ from, to, ...(entry.tier === 'icloud' ? {} : { directory: Directory.Documents, toDirectory: Directory.Documents }) }) + }, + rollback: async () => { + await Filesystem.rename({ from: to, to: from, ...(entry.tier === 'icloud' ? {} : { directory: Directory.Documents, toDirectory: Directory.Documents }) }) + } + }) } /** Permanently removes the vault directory and everything in it. The UI owns @@ -309,23 +310,26 @@ export function forgetExternalVault(): void { * the hood, so notes transfer — not copy). Reopens it when it's current. */ export async function moveVault(entry: MobileVaultEntry, to: 'local' | 'icloud'): Promise { if (entry.tier === 'external' || entry.tier === to) return - await assertNameFree(to, entry.name) const wasCurrent = isCurrentVaultEntry(entry) - const localPath = await localVaultPath(entry.name) - if (to === 'icloud') { - const status = await icloudStatus() - if (!status.available) { - throw new Error('iCloud is not available. Sign in to iCloud and turn on iCloud Drive.') + const token = (tier: 'local' | 'icloud'): string => tier === 'icloud' + ? `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(entry.name)}` : `${VAULT_ROOT_PREFIX}${entry.name}` + let localPath = '' + await relocateLocalVault({ + ...(wasCurrent ? { reopen: { source: token(entry.tier), destination: token(to) } } : {}), + move: async () => { + await assertNameFree(to, entry.name) + localPath = await localVaultPath(entry.name) + if (to === 'icloud') { + const status = await icloudStatus() + if (!status.available) throw new Error('iCloud is not available. Sign in to iCloud and turn on iCloud Drive.') + await ICloudVault.enable({ localPath, name: entry.name }) + } else await ICloudVault.disable({ name: entry.name, localPath }) + }, + rollback: async () => { + if (to === 'icloud') await ICloudVault.disable({ name: entry.name, localPath }) + else await ICloudVault.enable({ localPath, name: entry.name }) } - await ICloudVault.enable({ localPath, name: entry.name }) - } else { - await ICloudVault.disable({ name: entry.name, localPath }) - } - if (wasCurrent) { - setStoragePref(to) - if (to === 'icloud') await openVaultByName(entry.name, await icloudVaultUrl(entry.name)) - else await openVaultByName(entry.name) - } + }) } const MOBILE_CAPABILITIES: ZenCapabilities = { @@ -359,7 +363,14 @@ function mobileAppInfo(): ZenAppInfo { version: appVersion, description: 'ZenNotes for iPhone', homepage: 'https://zennotes.org', - runtime: 'web' + runtime: 'web', + hostKind: 'ios', + // WKWebView's user agent names the iOS version and the WebKit build, the + // two lines a bug report from a phone needs beside the app version + // (#814); nothing else here is guessed. + ...(typeof navigator !== 'undefined' && navigator.userAgent + ? { engine: navigator.userAgent } + : {}) } } @@ -580,7 +591,7 @@ export async function importPendingShares(): Promise { } // -------------------------------------------------------------------- -// Databases — the shared composition (@shared/database-ops, extracted from +// Databases — the shared composition (@zennotes/shared-domain/database-ops, extracted from // the web bridge in 2.20) bound to the active vault's file ops. Remap-aware: // the shared layout honors vault.json `systemFolderPaths` when composing // `.base/` paths, which the old local copy of this glue did not. @@ -799,8 +810,8 @@ export const mobileBridge: ZenBridge = { getCapabilities: (): ZenCapabilities => MOBILE_CAPABILITIES, getAppInfo: (): ZenAppInfo => mobileAppInfo(), - platform: async () => 'darwin' as NodeJS.Platform, - platformSync: () => 'darwin' as NodeJS.Platform, + platform: async () => 'darwin' as const, + platformSync: () => 'darwin' as const, listSystemFonts: async () => [ 'Avenir', 'Charter', @@ -852,6 +863,10 @@ export const mobileBridge: ZenBridge = { resolveCloudSettingsConflict: (choice) => resolveMobileCloudSettingsConflict(activeMobileVault(), choice), syncCloudVault: () => syncMobileCloudVault(activeMobileVault()), + hasCloudVaultChanges: () => { + const vault = activeVault() + return vault instanceof MobileVault ? hasMobileCloudVaultChanges(vault) : Promise.resolve(false) + }, getCloudBootstrapConflict: (conflict) => getMobileCloudBootstrapConflict(activeMobileVault(), conflict), resolveCloudBootstrapConflict: (resolution) => @@ -1093,6 +1108,7 @@ export const mobileBridge: ZenBridge = { // External file links name OS paths outside the iOS sandbox; the exact // 'desktop-only' token makes app-core show its friendly toast. openExternalFile: async () => ({ ok: false, error: 'desktop-only' }), + openExternalUrl: async () => ({ ok: false, error: 'desktop-only' }), // A vault attachment, unlike an arbitrary OS path, is a file this app owns // — so the phone can genuinely open it (share sheet / Quick Look) instead // of answering 'desktop-only' the way the web bridge must. diff --git a/src/bridge/mobile-cloud-auth.test.ts b/src/bridge/mobile-cloud-auth.test.ts new file mode 100644 index 0000000..8787175 --- /dev/null +++ b/src/bridge/mobile-cloud-auth.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { it } from 'node:test' +import { registerPlugin } from '@capacitor/core' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const account = { + base_url: 'https://zennotes.org', connected_at: '2026-09-14T12:00:00Z', + user: { name: 'Test', email: 'test@example.test' }, + device: { id: 'test-device', name: 'Test phone', platform: 'ios' } +} +const credential = JSON.stringify({ base_url: account.base_url, token: 'test-only', account }) + +async function coldLaunch(saved: Map) { + // Exercise the real Capacitor lazy proxy: concurrent first calls can + // instantiate separate implementations, each with its own key prefix. + const secureStorage = registerPlugin(`TestCloudStorage${randomUUID()}`, { + web: async () => { + await Promise.resolve() + return new class { + prefix = 'capacitor-storage_' + async setKeyPrefix(prefix: string) { this.prefix = prefix } + async setSynchronize(_value: boolean) {} + async setDefaultKeychainAccess(_value: unknown) {} + async getItem(key: string) { return saved.get(this.prefix + key) ?? null } + async setItem(key: string, value: string) { saved.set(this.prefix + key, value) } + async removeItem(key: string) { saved.delete(this.prefix + key) } + }() + } + }) + const api = await loadMobileModule('./src/bridge/mobile-cloud-auth.ts', { + '@capacitor/core': { Capacitor: { isNativePlatform: () => true }, CapacitorHttp: {} }, + '@capacitor/app': { App: { addListener: async () => ({}), getLaunchUrl: async () => null } }, + '@aparajita/capacitor-secure-storage': { + SecureStorage: secureStorage, + KeychainAccess: { whenUnlockedThisDeviceOnly: 'device-only' } + }, + './cloud-sync-client': { createCloudSyncClient: () => assert.fail('status must only read storage') } + }) + await api.configureMobileCloudAuth('test-version') + return api +} + +for (const prefix of ['zennotes.cloud.', 'capacitor-storage_']) { + it(`loads a saved account from ${prefix} on every cold launch`, async () => { + const saved = new Map([[prefix + 'credential', credential]]) + for (let launch = 0; launch < 2; launch++) { + const api = await coldLaunch(saved) + const statuses = await Promise.all([api.getMobileCloudAccountStatus(), api.getMobileCloudAccountStatus()]) + assert.deepEqual(statuses, [{ state: 'connected', account }, { state: 'connected', account }]) + assert.deepEqual([...saved.keys()], ['zennotes.cloud.credential']) + } + }) +} + +it('preserves the canonical account and prevents a legacy credential from returning after logout', async () => { + const saved = new Map([ + ['zennotes.cloud.credential', credential], + ['capacitor-storage_credential', JSON.stringify({ base_url: account.base_url, token: 'old-test-token', account })] + ]) + const api = await coldLaunch(saved) + assert.equal((await api.authenticatedCredential()).token, 'test-only') + await api.logoutMobileCloudAccount() + assert.equal(saved.size, 0) + assert.deepEqual(await (await coldLaunch(saved)).getMobileCloudAccountStatus(), { state: 'disconnected', account: null }) +}) + +it('rejects invalid recovered credentials through the shared auth validator', async () => { + const invalid = JSON.stringify({ base_url: 'https://wrong.example.test', token: 'test-only', account }) + const saved = new Map([['capacitor-storage_credential', invalid]]) + const api = await coldLaunch(saved) + assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'disconnected', account: null }) + assert.equal(saved.size, 0) +}) + +it('recovers pending sign-in state across cold launches', async () => { + const pending = JSON.stringify({ + base_url: account.base_url, state: 'test-state', code_verifier: 'a'.repeat(43), expires_at: '2099-01-01T00:00:00Z' + }) + const saved = new Map([['capacitor-storage_pending-auth', pending]]) + for (let launch = 0; launch < 2; launch++) { + const api = await coldLaunch(saved) + assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'connecting', account: null }) + assert.deepEqual([...saved.entries()], [['zennotes.cloud.pending-auth', pending]]) + } +}) diff --git a/src/bridge/mobile-cloud-auth.ts b/src/bridge/mobile-cloud-auth.ts index b0b91a4..6bc2712 100644 --- a/src/bridge/mobile-cloud-auth.ts +++ b/src/bridge/mobile-cloud-auth.ts @@ -36,17 +36,13 @@ const accountListeners = new Set<(status: CloudAccountStatus) => void>() let authFlow: CloudAuthFlow | null = null let callbackQueue = Promise.resolve() -// Lazy and retryable: a module-level Promise.all that rejected once would -// poison every later storage call for the whole session. +// Lazy and retryable so a transient native storage failure does not poison +// every later account read for the session. let secureStorageSetup: Promise | null = null function secureStorageReady(): Promise { if (!Capacitor.isNativePlatform()) return Promise.resolve() if (!secureStorageSetup) { - secureStorageSetup = Promise.all([ - SecureStorage.setKeyPrefix('zennotes.cloud.'), - SecureStorage.setSynchronize(false), - SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly) - ]).then(() => undefined) + secureStorageSetup = configureSecureStorage() secureStorageSetup.catch(() => { secureStorageSetup = null }) @@ -54,6 +50,43 @@ function secureStorageReady(): Promise { return secureStorageSetup } +async function configureSecureStorage(): Promise { + // The Capacitor proxy loads its implementation lazily. Parallel first + // calls can initialize separate instances and lose the configured prefix. + await SecureStorage.setKeyPrefix('zennotes.cloud.') + await SecureStorage.setSynchronize(false) + await SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly) + await migrateAuthStoragePrefix() +} + +async function migrateAuthStoragePrefix(): Promise { + const keys = [CREDENTIAL_KEY, PENDING_AUTH_KEY] + const canonical = new Map() + for (const key of keys) canonical.set(key, await SecureStorage.getItem(key)) + + // Affected builds could persist auth under the plugin's default prefix. + // All storage callers await setup, so none can see this temporary prefix. + // CloudAuthFlow still validates every recovered record before using it. + try { + await SecureStorage.setKeyPrefix('capacitor-storage_') + const legacy = new Map() + for (const key of keys) { + const value = await SecureStorage.getItem(key) + if (value !== null) legacy.set(key, value) + } + await SecureStorage.setKeyPrefix('zennotes.cloud.') + for (const [key, value] of legacy) { + if (canonical.get(key) === null) await SecureStorage.setItem(key, value) + } + // Remove superseded records as well, so logout cannot resurrect an older + // credential on the next launch. Copying must finish before removal. + await SecureStorage.setKeyPrefix('capacitor-storage_') + for (const key of legacy.keys()) await SecureStorage.removeItem(key) + } finally { + await SecureStorage.setKeyPrefix('zennotes.cloud.') + } +} + const storage: CloudAuthStorage = { async loadPending(): Promise { if (!Capacitor.isNativePlatform()) return null @@ -110,9 +143,25 @@ export async function configureMobileCloudAuth(appVersion: string): Promise scheduleAuthCallback(url)) + await CapApp.addListener('appUrlOpen', ({ url }) => { + if (isCloudAuthUrl(url)) scheduleAuthCallback(url) + }) const launch = await CapApp.getLaunchUrl() - if (launch?.url) scheduleAuthCallback(launch.url) + if (launch?.url && isCloudAuthUrl(launch.url)) scheduleAuthCallback(launch.url) +} + +/** The scheme is shared with the widget links (ui-mobile/widget-links.ts); + * only `zennotes://auth…` is this module's to handle. */ +function isCloudAuthUrl(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl.trim()) + return ( + parsed.protocol === 'zennotes:' && + (parsed.hostname || parsed.pathname.replace(/^\/+/, '')) === 'auth' + ) + } catch { + return false + } } export async function getMobileCloudAccountStatus(): Promise { diff --git a/src/bridge/mobile-cloud-sync.integration.test.ts b/src/bridge/mobile-cloud-sync.integration.test.ts new file mode 100644 index 0000000..28fcc8e --- /dev/null +++ b/src/bridge/mobile-cloud-sync.integration.test.ts @@ -0,0 +1,484 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { describe, it } from 'node:test' +import type { + CloudSyncChange, CloudSyncContent, CloudSyncManifestItem, CloudSyncMutation +} from '@zennotes/bridge-contract/cloud-sync' +import type { CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +function textContent(text: string): CloudSyncContent { + return { + encoding: 'utf8', data: text, byte_length: Buffer.byteLength(text), + sha256: createHash('sha256').update(text).digest('hex'), media_type: 'text/markdown' + } +} + +/** Real mobile adapter -> host service -> coordinator -> cached repository. + * Only Capacitor storage/auth/layout and the server boundary are fakes. */ +async function fixture(initial: Record = { 'note.md': 'Original' }) { + const persisted = new Map() + const files = new Map(Object.entries(initial).map(([path, data]) => [ + path, { bytes: Buffer.from(data), mtime: 1000 } + ])) + const remoteItems = new Map() + const revisions = new Map() + const feed: CloudSyncChange[] = [] + const reads: string[] = [] + const refreshes: Record[] = [] + const uploaded: CloudSyncMutation[] = [] + const manifestRequests: unknown[] = [] + let accountStatus = { state: 'connected', account: { base_url: 'https://sync.example.test' } } + let cursor = 0 + let clock = 1000 + let failWritePath: string | null = null + let beforeChanges: (() => void) | undefined + + const put = (path: string, bytes: string | Buffer) => { + files.set(path, { bytes: Buffer.from(bytes), mtime: ++clock }) + } + function remoteText(path: string, text: string) { + const previous = [...remoteItems.values()].find((item) => item.path === path) + const itemId = previous?.item_id ?? 'remote-' + path + const content = textContent(text) + const revision = (previous?.revision ?? 0) + 1 + const item: CloudSyncManifestItem = { + item_id: itemId, path, kind: 'text', revision, content, + sha256: content.sha256, byte_length: content.byte_length, media_type: content.media_type + } + remoteItems.set(itemId, item) + revisions.set(itemId + ':' + revision, structuredClone(item)) + feed.push({ + sequence: ++cursor, item_id: itemId, type: 'upsert', path, + previous_path: previous?.path ?? null, revision, content + }) + return item + } + const remote = { + listVaults: async () => ({ data: [{ id: 'vault-1', name: 'Test vault' }, { id: 'vault-2', name: 'Other vault' }] }), + manifest: async (_vaultId: string, options?: unknown) => { + manifestRequests.push(options) + return { data: [...remoteItems.values()], cursor, next_page: null } + }, + changes: async (_vaultId: string, after: number) => { + beforeChanges?.() + return { data: feed.filter((change) => change.sequence > after), cursor, has_more: false } + }, + revision: async (_vaultId: string, itemId: string, revision: number) => { + const item = revisions.get(itemId + ':' + revision) + assert.ok(item) + return { data: { ...item, deleted: false } } + }, + mutate: async (_vaultId: string, request: { mutations: CloudSyncMutation[] }) => { + uploaded.push(...JSON.parse(JSON.stringify(request.mutations))) + const acknowledged = request.mutations.map((mutation) => { + const previous = remoteItems.get(mutation.item_id) + const revision = (previous?.revision ?? 0) + 1 + if (mutation.type === 'upsert') { + const item: CloudSyncManifestItem = { + item_id: mutation.item_id, path: mutation.path, kind: mutation.kind, + revision, content: structuredClone(mutation.content), + sha256: mutation.content.sha256, byte_length: mutation.content.byte_length, + media_type: mutation.content.media_type + } + remoteItems.set(item.item_id, item) + revisions.set(item.item_id + ':' + revision, structuredClone(item)) + feed.push({ + sequence: ++cursor, item_id: item.item_id, type: 'upsert', path: item.path, + previous_path: previous?.path ?? null, revision, content: item.content + }) + } else { + assert.ok(previous) + if (mutation.type === 'delete') remoteItems.delete(mutation.item_id) + else remoteItems.set(mutation.item_id, { ...previous, path: mutation.path, revision }) + feed.push({ + sequence: ++cursor, item_id: mutation.item_id, type: mutation.type, + path: mutation.type === 'move' ? mutation.path : previous.path, + previous_path: previous.path, revision + }) + } + return { operation_id: mutation.operation_id, item_id: mutation.item_id, revision, sequence: cursor } + }) + return { acknowledged, conflicts: [], cursor } + } + } + const native = { + rootPath: 'ZenNotes/Test', + async readdirStrict(directory: string) { + const entries = new Map() + const prefix = directory ? directory + '/' : '' + for (const [path, file] of files) { + if (!path.startsWith(prefix)) continue + const [name, nested] = path.slice(prefix.length).split('/') + entries.set(name, { + name, type: nested ? 'directory' : 'file', + size: nested ? 0 : file.bytes.length, mtime: nested ? 1000 : file.mtime + }) + } + return [...entries.values()] + }, + async statOrNull(path: string) { + const file = files.get(path) + return file ? { type: 'file' as const, mtime: file.mtime, size: file.bytes.length } : null + }, + async statVerified(path: string) { return files.has(path) ? 'file' : null }, + async readBase64(path: string) { + reads.push(path) + const file = files.get(path) + assert.ok(file) + return file.bytes.toString('base64') + }, + async writeText(path: string, data: string) { + put(path, data) + if (path === failWritePath) throw new Error('Native write failed after writing') + }, + async writeBase64(path: string, data: string) { + put(path, Buffer.from(data, 'base64')) + if (path === failWritePath) throw new Error('Native write failed after writing') + }, + async deleteFile(path: string) { files.delete(path) }, + async mkdir(_path: string) {}, + async rename(from: string, to: string) { + const file = files.get(from) + assert.ok(file) + files.set(to, file) + files.delete(from) + } + } + const vault = { + rootLabel: 'ZenNotes/Test', fs: native, + async rescan() { + refreshes.push(Object.fromEntries([...files].map(([path, file]) => [path, file.bytes.toString()]))) + } + } + const api = await loadMobileModule('./src/bridge/mobile-cloud-sync', { + '@capacitor/filesystem': { + Directory: { Data: 'DATA', Cache: 'CACHE' }, Encoding: { UTF8: 'utf8' }, + Filesystem: { + async readFile({ path }: { path: string }) { + if (!persisted.has(path)) throw Object.assign(new Error('Missing'), { code: 'OS-PLUG-FILE-0008' }) + return { data: persisted.get(path)! } + }, + async writeFile({ path, data }: { path: string; data: string }) { persisted.set(path, data) }, + async deleteFile({ path }: { path: string }) { persisted.delete(path) } + } + }, + '@capacitor/share': { Share: {} }, + './vault-fs': { MobileVault: class {} }, + './cloud-layout': { reconcileLayoutForCloudJoin: async () => {} }, + './mobile-cloud-auth': { + authenticatedCredential: async () => ({ base_url: 'https://sync.example.test', token: 'test-only' }), + authenticatedClient: async () => remote, + getMobileCloudAccountStatus: async () => accountStatus + } + }) + await api.linkMobileCloudVault(vault, 'vault-1') + const stateKey = () => [...persisted.keys()].find((path) => path.includes('/states/')) + return { + api, vault, files, reads, uploaded, refreshes, remoteText, put, manifestRequests, remote, persisted, + setAccountStatus: (value: typeof accountStatus) => { accountStatus = value }, + sync: () => api.syncMobileCloudVault(vault), + setFailWrite: (path: string | null) => { failWritePath = path }, + setBeforeChanges: (callback: typeof beforeChanges) => { beforeChanges = callback }, + clearState: () => { const key = stateKey(); if (key) persisted.delete(key) }, + get state(): CloudSyncState { + const key = stateKey() + assert.ok(key) + return JSON.parse(persisted.get(key)!) + }, + set state(value: CloudSyncState) { + const key = stateKey() + assert.ok(key) + persisted.set(key, JSON.stringify(value)) + } + } +} + +describe('mobile Cloud adapter wiring', () => { + it('detects remote changes from a one-item metadata manifest without scanning local files', async () => { + const h = await fixture() + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), true) + assert.deepEqual(h.manifestRequests, []) + await h.sync() + h.manifestRequests.length = 0 + h.reads.length = 0 + h.refreshes.length = 0 + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + h.remoteText('note.md', 'Incoming edit') + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), true) + assert.deepEqual(h.manifestRequests, [ + { includeContent: false, perPage: 1 }, { includeContent: false, perPage: 1 } + ]) + assert.deepEqual(h.reads, []) + assert.deepEqual(h.refreshes, []) + await h.sync() + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + }) + + it('does not probe an unlinked, disconnected, or different-origin account', async () => { + const h = await fixture() + await h.sync() + h.manifestRequests.length = 0 + h.setAccountStatus({ state: 'disconnected', account: { base_url: 'https://sync.example.test' } }) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + h.setAccountStatus({ state: 'connected', account: { base_url: 'https://another.example.test' } }) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + await h.api.unlinkMobileCloudVault(h.vault) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + assert.deepEqual(h.manifestRequests, []) + }) + + it('does not rescan the vault or reread acknowledged bytes during a no-op sync', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.reads.length = 0 + const summary = await h.sync() + assert.equal(summary.pushed, 0) + assert.equal(summary.pulled, 0) + assert.deepEqual(h.refreshes, []) + assert.deepEqual(h.reads, []) + }) + + it('resynchronizes UI state once after a whole batch of pulled files', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.remoteText('note.md', 'Remote update') + h.remoteText('second.md', 'Second note') + h.remoteText('folder/third.md', 'Third note') + const summary = await h.sync() + assert.equal(summary.pulled, 3) + assert.equal(h.refreshes.length, 1) + assert.deepEqual(h.refreshes[0], { + 'note.md': 'Remote update', 'second.md': 'Second note', 'folder/third.md': 'Third note' + }) + h.refreshes.length = 0 + await h.sync() + assert.deepEqual(h.refreshes, []) + }) + + it('refreshes a newly discovered local edit even when nothing is downloaded', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.put('note.md', 'Local changes') + const summary = await h.sync() + assert.equal(summary.pulled, 0) + assert.equal(summary.pushed, 1) + assert.deepEqual(h.refreshes, [{ 'note.md': 'Local changes' }]) + const uploaded = h.uploaded.at(-1) + assert.equal(uploaded?.type, 'upsert') + if (uploaded?.type === 'upsert') assert.equal(uploaded.content.data, 'Local changes') + }) + + it('exposes partial native writes when a pull fails, and can retry safely', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.remoteText('note.md', 'Remote update') + h.remoteText('second.md', 'Partially written') + h.setFailWrite('second.md') + await assert.rejects(h.sync(), /Native write failed/) + assert.deepEqual(h.refreshes, [{ 'note.md': 'Remote update', 'second.md': 'Partially written' }]) + h.setFailWrite(null) + await h.sync() + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Remote update') + assert.equal(h.files.get('second.md')?.bytes.toString(), 'Partially written') + }) + + it('keeps a conflicting local edit visible when a remote version arrives', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.remoteText('note.md', 'Cloud replacement') + h.setBeforeChanges(() => { + h.setBeforeChanges(undefined) + h.put('note.md', 'Local replacement') + }) + const summary = await h.sync() + assert.equal(summary.pending_conflicts.length, 1) + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Local replacement') + assert.equal(h.refreshes.at(-1)?.['note.md'], 'Local replacement') + const details = await h.api.getMobileCloudConflict(h.vault, summary.pending_conflicts[0].id) + assert.equal(details.local.text, 'Local replacement') + assert.equal(details.cloud.text, 'Cloud replacement') + }) + + it('loads full pending-conflict review bytes despite an acknowledged warm scan cache', async () => { + const h = await fixture() + await h.sync() + const state = h.state + const [item] = Object.values(state.items) + state.pending_conflicts = { + [item.item_id]: { + id: item.item_id, item_id: item.item_id, kind: 'content', sequence: 2, + base: { path: item.path, revision: 1, kind: 'text', content: textContent('Base') }, + local: { path: item.path, revision: null, kind: 'text', content: textContent('Original') }, + cloud: { path: item.path, revision: 2, kind: 'text', content: textContent('Cloud replacement') } + } + } + h.state = state + h.reads.length = 0 + const details = await h.api.getMobileCloudConflict(h.vault, item.item_id) + assert.equal(details.local.text, 'Original') + assert.equal(details.cloud.text, 'Cloud replacement') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('loads full bootstrap review bytes when old scan cache survives a lost sync state', async () => { + const h = await fixture() + await h.sync() + const cloud = h.remoteText('note.md', 'Cloud replacement') + h.clearState() + h.reads.length = 0 + const details = await h.api.getMobileCloudBootstrapConflict(h.vault, { + code: 'BOOTSTRAP_CONTENT_CONFLICT', item_id: cloud.item_id, path: cloud.path, + local_sha256: textContent('Original').sha256, remote_sha256: cloud.sha256 + }) + assert.equal(details.local.text, 'Original') + assert.equal(details.cloud.text, 'Cloud replacement') + assert.deepEqual(h.reads, ['note.md']) + }) +}) + + +describe('deleted Cloud vault recovery (#791)', () => { + const serviceError = (status: number, code: string | null) => Object.assign( + new Error('ZenNotes Cloud request failed'), { name: 'CloudServiceRequestError', status, code } + ) + const missing = () => serviceError(404, 'NOT_FOUND') + + it('clears only the missing Cloud association and state while preserving local bytes and another link', async () => { + const h = await fixture({}) + await h.sync() + const ownStateKey = [...h.persisted.keys()].find((path) => path.includes('/states/'))! + const otherVault = { ...h.vault, rootLabel: 'ZenNotes/Other', fs: { ...h.vault.fs, rootPath: 'ZenNotes/Other' } } + const otherLink = await h.api.linkMobileCloudVault(otherVault, 'vault-2') + await h.api.syncMobileCloudVault(otherVault) + const otherStateKey = [...h.persisted.keys()].find((path) => path.includes('/states/') && path !== ownStateKey)! + const otherState = h.persisted.get(otherStateKey) + const note = '---\ntitle: Keep me\n---\r\nUnsent local edit 📝\r\n' + h.put('note.md', note) + h.remote.changes = async () => { throw missing() } + h.remote.manifest = async () => { throw missing() } + + await assert.rejects(h.sync()) + + assert.equal(await h.api.getMobileCloudVaultLink(h.vault), null) + assert.equal(h.persisted.has(ownStateKey), false) + assert.equal(h.files.get('note.md')?.bytes.toString(), note) + assert.deepEqual(await h.api.getMobileCloudVaultLink(otherVault), otherLink) + assert.equal(h.persisted.get(otherStateKey), otherState) + }) + + it('preserves complete conflict drafts in unique inactive state archives when a Cloud vault disappears', async () => { + const h = await fixture({ 'note.md': 'local version' }) + h.remoteText('note.md', 'cloud version') + const savedChanges = h.remote.changes + const savedManifest = h.remote.manifest + const snapshots: string[] = [] + const draft = 'Unsent merge draft\r\nKeep every byte 📝\r\n' + for (let attempt = 1; attempt <= 2; attempt++) { + h.remote.changes = savedChanges + h.remote.manifest = savedManifest + if (attempt > 1) await h.api.linkMobileCloudVault(h.vault, 'vault-1') + const conflict = (await h.sync()).pending_conflicts[0] + assert.ok(conflict) + await h.api.saveMobileCloudConflictDraft(h.vault, conflict.id, `${draft}${attempt}`) + const stateKey = [...h.persisted.keys()].find((path) => path.includes('/states/'))! + snapshots.push(h.persisted.get(stateKey)!) + h.remote.changes = async () => { throw missing() } + h.remote.manifest = async () => { throw missing() } + + await assert.rejects(h.sync()) + + assert.equal(h.persisted.has(stateKey), false) + assert.equal(await h.api.getMobileCloudVaultLink(h.vault), null) + } + const archived = [...h.persisted.entries()].filter(([path]) => !path.includes('/states/')) + for (const snapshot of snapshots) { + assert.equal(archived.filter(([, value]) => value === snapshot).length, 1) + } + assert.equal(h.files.get('note.md')?.bytes.toString(), 'local version') + }) + + it('retires the deleted link when a background metadata probe discovers it', async () => { + const h = await fixture({}) + await h.sync() + h.remote.manifest = async () => { throw missing() } + + await h.api.hasMobileCloudVaultChanges(h.vault).catch(() => undefined) + + assert.equal(await h.api.getMobileCloudVaultLink(h.vault), null) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + }) + + for (const [label, failure] of [ + ['offline', new Error('Network unavailable')], + ['unauthenticated', serviceError(401, 'UNAUTHENTICATED')], + ['forbidden', serviceError(403, 'FORBIDDEN')], + ['server failure', serviceError(503, null)], + ['unstructured proxy 404', serviceError(404, null)] + ] as const) { + it(`preserves the link and cursor after ${label}`, async () => { + const h = await fixture({}) + await h.sync() + const link = await h.api.getMobileCloudVaultLink(h.vault) + const state = structuredClone(h.state) + h.remote.changes = async () => { throw failure } + h.remote.manifest = async () => { throw failure } + + await assert.rejects(h.sync()) + await h.api.hasMobileCloudVaultChanges(h.vault).catch(() => undefined) + + assert.deepEqual(await h.api.getMobileCloudVaultLink(h.vault), link) + assert.deepEqual(h.state, state) + }) + } + + it('retains the link when a missing mutation resource belongs to an existing vault', async () => { + const h = await fixture({}) + await h.sync() + const link = await h.api.getMobileCloudVaultLink(h.vault) + h.put('note.md', 'Local note still exists') + h.remote.mutate = async () => { throw missing() } + + await assert.rejects(h.sync()) + + assert.deepEqual(await h.api.getMobileCloudVaultLink(h.vault), link) + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Local note still exists') + }) + + it('keeps the association when confirmation cannot reach an authenticated vault endpoint', async () => { + const h = await fixture({}) + await h.sync() + const link = await h.api.getMobileCloudVaultLink(h.vault) + h.remote.changes = async () => { throw missing() } + h.remote.manifest = async () => { throw serviceError(401, 'UNAUTHENTICATED') } + + await assert.rejects(h.sync()) + + assert.deepEqual(await h.api.getMobileCloudVaultLink(h.vault), link) + }) + + it('preserves a newer association when an older sync reports a deleted vault', async () => { + const h = await fixture({}) + await h.sync() + let begin!: () => void + let fail!: (error: Error) => void + const started = new Promise((resolve) => { begin = resolve }) + h.remote.changes = async () => { + begin() + return await new Promise((_resolve, reject) => { fail = reject }) + } + h.remote.manifest = async () => { throw missing() } + const rejected = assert.rejects(h.sync()) + await started + const replacement = await h.api.linkMobileCloudVault(h.vault, 'vault-2') + fail(missing()) + await rejected + + assert.deepEqual(await h.api.getMobileCloudVaultLink(h.vault), replacement) + }) +}) diff --git a/src/bridge/mobile-cloud-sync.ts b/src/bridge/mobile-cloud-sync.ts index 77dc695..c322be9 100644 --- a/src/bridge/mobile-cloud-sync.ts +++ b/src/bridge/mobile-cloud-sync.ts @@ -29,6 +29,7 @@ import type { PortableCloudSyncFileSystem } from '@zennotes/shared-domain/cloud- import type { CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' import { CachedCloudSyncRepository, type ScanCache } from './cloud-sync-repository' import { MobileVault } from './vault-fs' +import { trackCloudSyncChanges } from './cloud-sync-refresh' import { authenticatedCredential, authenticatedClient, @@ -38,6 +39,7 @@ import { isNotFoundError } from './native-fs' import { randomUUID } from './uuid' const STORAGE_ROOT = 'zennotes-cloud-sync' +const refreshStates = new WeakMap() const persistence: CloudSyncHostPersistence = { async loadLink(vaultKey: string): Promise { @@ -54,6 +56,23 @@ const persistence: CloudSyncHostPersistence = { }, async saveState(vaultKey: string, baseUrl: string, state: CloudSyncState): Promise { await writeJson(await statePath(vaultKey, baseUrl, state.vault_id), state) + }, + async retireState(vaultKey: string, baseUrl: string, vaultId: string): Promise { + const path = await statePath(vaultKey, baseUrl, vaultId) + let data: string + try { + const result = await Filesystem.readFile({ path, directory: Directory.Data, encoding: Encoding.UTF8 }) + data = typeof result.data === 'string' ? result.data : await result.data.text() + } catch (error) { + if (isNotFoundError(error)) return + throw error + } + // Preserve the complete state, including unsent conflict drafts, before unlinking. + await Filesystem.writeFile({ + path: path.replace('/states/', '/retired-states/').replace(/\.json$/, `.${crypto.randomUUID()}.json`), + directory: Directory.Data, encoding: Encoding.UTF8, data, recursive: true + }) + await deleteDataFile(path) } } @@ -98,9 +117,18 @@ export async function getMobileCloudSettingsConflict( ): Promise { const parked = await vault.fs.statOrNull(CLOUD_SYNC_SETTINGS_CONFLICT_PATH) if (parked?.type !== 'file') return null + const raw = await vault.fs.readTextOrNull(CLOUD_SYNC_SETTINGS_CONFLICT_PATH) + if (raw === null) return null + // The parsed copy lets the app show what differs and offer a per-section + // answer (desktop parity, #816). A copy that does not parse is still a + // pending question (the file is there, and sync will not touch vault.json + // until it is gone), so it is reported without the contents and the app + // asks whole-file. + const cloudSettings = parseParkedSettings(raw) return { path: CLOUD_SYNC_VAULT_SETTINGS_PATH, - cloud_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH + cloud_path: CLOUD_SYNC_SETTINGS_CONFLICT_PATH, + ...(cloudSettings ? { cloud_settings: cloudSettings } : {}) } } @@ -114,27 +142,38 @@ export async function resolveMobileCloudSettingsConflict( ): Promise { if (choice === 'cloud') { const raw = await vault.fs.readTextOrNull(CLOUD_SYNC_SETTINGS_CONFLICT_PATH) - let parsed: unknown = null - if (raw !== null) { - try { - parsed = JSON.parse(raw) - } catch { - parsed = null - } - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + const parsed = raw === null ? null : parseParkedSettings(raw) + if (!parsed) { throw new Error('The settings from the cloud could not be read, so nothing was changed.') } - await vault.setVaultSettings(parsed as Parameters[0]) + await vault.setVaultSettings( + parsed as unknown as Parameters[0] + ) } await vault.fs.deleteFile(CLOUD_SYNC_SETTINGS_CONFLICT_PATH).catch(() => {}) } +function parseParkedSettings(raw: string): Record | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + return parsed as Record +} + +/** Check the server cursor without scanning the vault or downloading attachment bytes. */ +export async function hasMobileCloudVaultChanges(vault: MobileVault): Promise { + return service.hasRemoteChanges(hostVault(vault)) +} + export async function syncMobileCloudVault(vault: MobileVault): Promise { // No emit here: the host service runs vault.rescan() after every sync // (cloud-sync-host-service run()'s finally), and rescan emits the one // 'resync' event app-core needs. - return service.sync(hostVault(vault)) + return service.sync(hostVault(vault, true)) } export async function getMobileCloudConflict( @@ -261,7 +300,7 @@ export async function restoreMobileCloudBackupNote( return service.restoreBackupNote(hostVault(vault), backupId, snapshotItemId) } -function hostVault(vault: MobileVault): CloudSyncHostVault { +function hostVault(vault: MobileVault, cacheScan = false): CloudSyncHostVault { const fs: PortableCloudSyncFileSystem = { // Strict on purpose: NativeFs.readdir maps failure to [], which scan() // would read as an empty vault and plan a delete for every tracked item. @@ -287,16 +326,21 @@ function hostVault(vault: MobileVault): CloudSyncHostVault { } const vaultKey = vault.fs.rootPath + const state = refreshStates.get(vault) ?? { changed: false } + refreshStates.set(vault, state) + const changes = trackCloudSyncChanges(fs, () => vault.rescan(), state) return { // The contract requires a key stable per local vault. rootLabel resolves // to an absolute container URI whose UUID iOS rotates on app update and // restore; rootPath (ZenNotes/) survives both, and keeps the link // when a vault migrates between the local and iCloud tiers. key: vaultKey, - repository: new CachedCloudSyncRepository(fs, vault.fs, { + repository: new CachedCloudSyncRepository(changes.fs, vault.fs, { // A transient failure here disables skipping for the run (full read) — // the safe direction — rather than failing or, worse, mis-skipping. loadTracked: async () => { + // Review/restore actions always receive real bytes, not scan placeholders. + if (!cacheScan) return null const link = await readJson(await linkPath(vaultKey)) if (!isRecord(link) || typeof link.base_url !== 'string' || typeof link.vault_id !== 'string') { return null @@ -307,8 +351,8 @@ function hostVault(vault: MobileVault): CloudSyncHostVault { }, loadCache: async () => readJson(await scanCachePath(vaultKey)), saveCache: async (cache: ScanCache) => writeJson(await scanCachePath(vaultKey), cache) - }), - refresh: () => vault.rescan() + }, changes.markChanged), + refresh: changes.refresh } } @@ -358,7 +402,11 @@ async function writeJson(path: string, value: unknown): Promise { } async function deleteDataFile(path: string): Promise { - await Filesystem.deleteFile({ path, directory: Directory.Data }).catch(() => {}) + try { + await Filesystem.deleteFile({ path, directory: Directory.Data }) + } catch (error) { + if (!isNotFoundError(error)) throw error + } } async function fingerprint(value: string): Promise { diff --git a/src/bridge/mobile-direct-upload.test.ts b/src/bridge/mobile-direct-upload.test.ts index 2a99f5b..cc80f7a 100644 --- a/src/bridge/mobile-direct-upload.test.ts +++ b/src/bridge/mobile-direct-upload.test.ts @@ -15,6 +15,26 @@ import { } from './mobile-direct-upload.ts' describe('mutateWithMobileDirectUploads', () => { + it('supplies a content type when production signs only the host so iOS sends the file body', () => { + const headers = { Host: 'objects.example.test' } + const options = mobileObjectUploadOptions({ + url: 'https://objects.example.test/upload?signature=signed', + method: 'PUT', headers, base64: 'AQID', byteLength: 3 + }) + assert.equal(options.headers['Content-Type'], 'application/octet-stream') + assert.equal(options.headers.Host, headers.Host) + assert.deepEqual(headers, { Host: 'objects.example.test' }) + }) + + it('preserves a signed content type regardless of header casing', () => { + const options = mobileObjectUploadOptions({ + url: 'https://objects.example.test/upload?signature=signed', + method: 'PUT', headers: { 'content-type': 'image/jpeg' }, base64: 'AQID', byteLength: 3 + }) + const contentTypes = Object.entries(options.headers).filter(([key]) => key.toLowerCase() === 'content-type') + assert.deepEqual(contentTypes, [['content-type', 'image/jpeg']]) + }) + it('builds a native binary PUT without an account bearer token or redirects', () => { const options = mobileObjectUploadOptions({ url: 'https://objects.example.test/upload?signature=signed', @@ -36,7 +56,7 @@ describe('mutateWithMobileDirectUploads', () => { }, data: 'AQID', dataType: 'file', - connectTimeout: 30_000, + connectTimeout: 300_000, readTimeout: 300_000, disableRedirects: true }) diff --git a/src/bridge/mobile-direct-upload.ts b/src/bridge/mobile-direct-upload.ts index 22aa87b..2c3ec09 100644 --- a/src/bridge/mobile-direct-upload.ts +++ b/src/bridge/mobile-direct-upload.ts @@ -57,13 +57,20 @@ export interface MobileObjectUploadOptions { export function mobileObjectUploadOptions( request: MobileObjectUploadRequest ): MobileObjectUploadOptions { + // Capacitor iOS only attaches the binary body when Content-Type exists. + // Production presigned URLs may return just Host; preserve signed headers. + const headers = { ...request.headers } + if (!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')) { + headers['Content-Type'] = 'application/octet-stream' + } return { url: request.url, method: request.method, - headers: request.headers, + headers, data: request.base64, dataType: 'file', - connectTimeout: 30_000, + // iOS uses connectTimeout ahead of readTimeout for the whole request. + connectTimeout: 300_000, readTimeout: 300_000, disableRedirects: true } diff --git a/src/bridge/native-fs.ts b/src/bridge/native-fs.ts index 617d16a..199182f 100644 --- a/src/bridge/native-fs.ts +++ b/src/bridge/native-fs.ts @@ -141,7 +141,8 @@ export class NativeFs { async readTextOrNull(relPath: string): Promise { try { return await this.readText(relPath) - } catch { + } catch (error) { + if (!isNotFoundError(error)) throw error return null } } diff --git a/src/bridge/remote-client.ts b/src/bridge/remote-client.ts index 7a8f80e..de7b89c 100644 --- a/src/bridge/remote-client.ts +++ b/src/bridge/remote-client.ts @@ -25,8 +25,8 @@ import type { VaultInfo, VaultSettings, VaultTextSearchMatch -} from '@shared/ipc' -import type { VaultTask } from '@shared/tasks' +} from '@zennotes/shared-domain/ipc' +import type { VaultTask } from '@zennotes/shared-domain/tasks' import { importedAssetFilename } from './imported-assets.ts' export interface RemoteClientOptions { diff --git a/src/bridge/remote-vault.ts b/src/bridge/remote-vault.ts index 80e56bf..9a9af24 100644 --- a/src/bridge/remote-vault.ts +++ b/src/bridge/remote-vault.ts @@ -29,12 +29,12 @@ import type { VaultInfo, VaultSettings, VaultTextSearchMatch -} from '@shared/ipc' -import type { VaultTask } from '@shared/tasks' -import type { CustomTemplateFile, WriteTemplateInput } from '@bridge-contract/templates' -import type { ImportedAsset } from '@shared/ipc' -import { createAbsenceAwareReader } from '@shared/remote-absence' -import { pastedImageFilename } from '@shared/pasted-image' +} from '@zennotes/shared-domain/ipc' +import type { VaultTask } from '@zennotes/shared-domain/tasks' +import type { CustomTemplateFile, WriteTemplateInput } from '@zennotes/bridge-contract/templates' +import type { ImportedAsset } from '@zennotes/shared-domain/ipc' +import { createAbsenceAwareReader } from '@zennotes/shared-domain/remote-absence' +import { pastedImageFilename } from '@zennotes/shared-domain/pasted-image' import { emitVaultChange } from './events' import { importedAssetFilename } from './imported-assets' import { RemoteClient, RemoteRequestError } from './remote-client' diff --git a/src/bridge/remote-workspace.ts b/src/bridge/remote-workspace.ts index 0e6c222..9da2f52 100644 --- a/src/bridge/remote-workspace.ts +++ b/src/bridge/remote-workspace.ts @@ -15,7 +15,7 @@ import type { RemoteWorkspaceProfileInput, ServerCapabilities, VaultInfo -} from '@shared/ipc' +} from '@zennotes/shared-domain/ipc' import { RemoteClient, normalizeBaseUrl } from './remote-client' import { randomUUID } from './uuid' import { RemoteVault } from './remote-vault' diff --git a/src/bridge/tikz.ts b/src/bridge/tikz.ts index 57fe7e6..a6131fe 100644 --- a/src/bridge/tikz.ts +++ b/src/bridge/tikz.ts @@ -16,7 +16,7 @@ * per-source caching, and a serialized render queue (the TeX engine does not * tolerate concurrent runs). */ -import type { TikzRenderResponse } from '@shared/ipc' +import type { TikzRenderResponse } from '@zennotes/shared-domain/ipc' const ASSET_BASE = 'tikzjax/' // Generous: the first render on a cold device includes engine load + wasm diff --git a/src/bridge/vault-core.ts b/src/bridge/vault-core.ts index aad3b3c..9e736ed 100644 --- a/src/bridge/vault-core.ts +++ b/src/bridge/vault-core.ts @@ -6,12 +6,12 @@ * The `attachements` (sic) constant is the intentional, load-bearing legacy * spelling — do not "fix" it and do not add an `attachments` variant. */ -import type { ImportedAssetKind, NoteFolder, VaultSettings } from '@bridge-contract/ipc' +import type { ImportedAssetKind, NoteFolder, VaultSettings } from '@zennotes/bridge-contract/ipc' import { resolveFolderPath, systemFolderForDirName, type SystemFolderPaths -} from '@shared/system-folder-paths' +} from '@zennotes/shared-domain/system-folder-paths' import { ASSETS_DIR } from './imported-assets' // Re-exported so every existing importer keeps reaching them here; they live in diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index 503ddad..05456a0 100644 --- a/src/bridge/vault-fs.ts +++ b/src/bridge/vault-fs.ts @@ -1,3 +1,4 @@ +import { relocateVaultEntries, type VaultRelocation, type VaultRelocationIO } from '@zennotes/shared-domain/vault-relocation' /** * The on-device vault: desktop `vault.ts` semantics reimplemented over the * Capacitor Filesystem (there is no Node `fs` in a WKWebView). Same on-disk @@ -19,27 +20,28 @@ import type { VaultDemoTourResult, VaultSettings, VaultTextSearchMatch -} from '@bridge-contract/ipc' -import { DEFAULT_VAULT_SETTINGS } from '@bridge-contract/ipc' -import type { CustomTemplateFile, WriteTemplateInput } from '@bridge-contract/templates' -import type { VaultTask } from '@shared/tasks' -import { parseTaskFile, parseTasksFromBody } from '@shared/tasks' -import { normalizeHarperVaultState } from '@shared/harper-settings' +} from '@zennotes/bridge-contract/ipc' +import { DEFAULT_VAULT_SETTINGS } from '@zennotes/bridge-contract/ipc' +import type { CustomTemplateFile, WriteTemplateInput } from '@zennotes/bridge-contract/templates' +import type { VaultTask } from '@zennotes/shared-domain/tasks' +import { parseTaskFile, parseTasksFromBody } from '@zennotes/shared-domain/tasks' +import { normalizeHarperVaultState } from '@zennotes/shared-domain/harper-settings' +import { normalizeNoteComments } from '@zennotes/shared-domain/note-comments' import { isPathExcludedFromTasks, normalizeTasksExcludedFolders -} from '@shared/tasks-excluded-folders' -import { pastedImageFilename } from '@shared/pasted-image' +} from '@zennotes/shared-domain/tasks-excluded-folders' +import { pastedImageFilename } from '@zennotes/shared-domain/pasted-image' import { randomUUID } from './uuid' import { bytesToBase64 } from './base64' -import { isFormDirName, isDatabaseInternalPath } from '@shared/databases' -import { emptyExcalidrawDocument } from '@shared/excalidraw' -import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@desktop-main/demo-tour-data' +import { isFormDirName, isDatabaseInternalPath } from '@zennotes/shared-domain/databases' +import { emptyExcalidrawDocument } from '@zennotes/shared-domain/excalidraw' +import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@zennotes/shared-domain/demo-tour-data' import { WELCOME_NOTE_PATH, WELCOME_NOTE_BODY } from './welcome-note' import { rewriteWikilinksForRename, type RenameNoteRef -} from '@desktop-main/wikilink-rename' +} from '@zennotes/shared-domain/wikilink-rename' import { NativeFs } from './native-fs' import { ensureDownloaded } from './icloud' import { emitVaultChange } from './events' @@ -82,7 +84,7 @@ import { normalizeSystemFolderPaths, resolveFolderPath, type SystemFolderPaths -} from '@shared/system-folder-paths' +} from '@zennotes/shared-domain/system-folder-paths' const META_CACHE_FILE = `${INTERNAL_VAULT_DIR}/mobile-note-meta-cache-v1.json` /** Restore metadata written next to each deleted asset (desktop parity). */ @@ -678,6 +680,37 @@ export class MobileVault { return await this.metaForPath(rel) } + private relocationIO(): VaultRelocationIO { + return { + stat: path => this.fs.statVerified(path), + mkdir: path => this.fs.mkdir(path), + rename: (from, to) => this.fs.rename(from, to) + } + } + + private async relocateNote(oldPath: string, newPath: string): Promise { + await relocateVaultEntries(this.relocationIO(), [ + { from: oldPath, to: newPath, required: true }, + { from: this.commentsPathFor(oldPath), to: this.commentsPathFor(newPath) } + ]) + this.invalidateMeta(oldPath) + } + + /** Detach both trees before cleanup so a failed move can restore the original. */ + private async detachContent(path: string, comments: string, required = true): Promise { + const temporary = `${INTERNAL_VAULT_DIR}/delete-${uuid()}` + const moves: VaultRelocation[] = [ + { from: path, to: `${temporary}/content`, required }, + { from: comments, to: `${temporary}/comments` } + ] + await relocateVaultEntries(this.relocationIO(), moves) + try { + if (await this.fs.statVerified(temporary) !== null) await this.fs.rmdir(temporary) + } catch (error) { + console.warn('Detached deleted content retained for cleanup', temporary, error) + } + } + async renameNote(relPath: string, nextTitle: string): Promise { const rel = resolveSafeRel(relPath) const folder = await this.folderOf(rel) @@ -692,9 +725,7 @@ export class MobileVault { } // Snapshot for inbound wikilink rewriting before the rename lands. const preNotes = await this.listNotes() - await this.fs.rename(rel, target) - this.invalidateMeta(rel) - await this.moveNoteComments(rel, target) + await this.relocateNote(rel, target) emitVaultChange({ kind: 'unlink', path: rel, folder, scope: 'content' }) emitVaultChange({ kind: 'add', path: target, folder, scope: 'content' }) await this.updateInboundWikilinks(preNotes, rel, trimmed) @@ -759,9 +790,7 @@ export class MobileVault { const baseTitle = stemName(filename) const finalTitle = await this.uniqueTitle(destDir, baseTitle, ext) const destRel = joinPath(destDir, `${finalTitle}${ext}`) - await this.fs.rename(rel, destRel) - this.invalidateMeta(rel) - await this.moveNoteComments(rel, destRel) + await this.relocateNote(rel, destRel) emitVaultChange({ kind: 'unlink', path: rel, folder: sourceFolder, scope: 'content' }) emitVaultChange({ kind: 'add', path: destRel, folder: target, scope: 'content' }) return await this.metaForPath(destRel) @@ -782,25 +811,17 @@ export class MobileVault { async emptyTrash(): Promise { const trashDir = await this.folderRootRel('trash') - const entries = await this.fs.readdir(trashDir) - for (const entry of entries) { - const rel = `${trashDir}/${entry.name}` - await this.removeNoteComments(rel) - if (entry.type === 'directory') { - await this.fs.rmdir(rel).catch(() => {}) - } else { - await this.fs.deleteFile(rel).catch(() => {}) - } - this.invalidateMeta(rel) - emitVaultChange({ kind: 'unlink', path: rel, folder: 'trash', scope: 'content' }) + await this.detachContent(trashDir, `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${trashDir}`, false) + for (const key of [...this.metaCache.keys()]) { + if (key.startsWith(`${trashDir}/`)) this.invalidateMeta(key) } + emitVaultChange({ kind: 'unlink', path: trashDir, folder: 'trash', scope: 'folder' }) } async deleteNote(relPath: string): Promise { const rel = resolveSafeRel(relPath) const folder = (await this.folderOf(rel)) ?? 'trash' - await this.fs.deleteFile(rel) - await this.removeNoteComments(rel) + await this.detachContent(rel, this.commentsPathFor(rel)) this.invalidateMeta(rel) emitVaultChange({ kind: 'unlink', path: rel, folder, scope: 'content' }) } @@ -841,9 +862,7 @@ export class MobileVault { `${await this.uniqueTitle(destDir, baseTitle, ext || '.md')}${ext || '.md'}` ) if (destRel === rel) return await this.metaForPath(rel) - await this.fs.rename(rel, destRel) - this.invalidateMeta(rel) - await this.moveNoteComments(rel, destRel) + await this.relocateNote(rel, destRel) emitVaultChange({ kind: 'unlink', path: rel, folder: sourceFolder, scope: 'content' }) emitVaultChange({ kind: 'add', path: destRel, folder: targetFolder, scope: 'content' }) return await this.metaForPath(destRel) @@ -877,7 +896,6 @@ export class MobileVault { } const parent = dirName(newRel) if (parent) await this.fs.mkdir(parent) - await this.fs.rename(oldRel, newRel) // Re-key folder icons/colors + drop stale meta cache entries under the old path. const settings = await this.getVaultSettings() const rekey = (map: Record): Record => { @@ -891,10 +909,30 @@ export class MobileVault { } return out } - await this.setVaultSettings({ + const settingsPath = `${INTERNAL_VAULT_DIR}/vault.json` + const hadSettings = await this.fs.statVerified(settingsPath) !== null + const originalSettings = hadSettings ? await this.fs.readText(settingsPath) : null + await relocateVaultEntries(this.relocationIO(), [ + { from: oldRel, to: newRel, required: true }, + { from: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${oldRel}`, + to: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${newRel}` } + ], async () => { + try { + await this.setVaultSettings({ ...settings, folderIcons: rekey(settings.folderIcons as Record) as VaultSettings['folderIcons'], folderColors: rekey(settings.folderColors as Record) as VaultSettings['folderColors'] + }) + } catch (error) { + try { + if (originalSettings !== null) await this.fs.writeText(settingsPath, originalSettings) + else if (await this.fs.statVerified(settingsPath) !== null) await this.fs.deleteFile(settingsPath) + this.settingsCache = settings + } catch (rollback) { + throw new AggregateError([error, rollback], 'FOLDER_STATE_UNCERTAIN: Could not restore vault settings') + } + throw error + } }) for (const key of [...this.metaCache.keys()]) { if (key.startsWith(`${oldRel}/`)) this.invalidateMeta(key) @@ -909,7 +947,7 @@ export class MobileVault { const clean = subpath.replace(/^\/+|\/+$/g, '') if (!clean) return const rel = resolveSafeRel(joinPath(topRel, clean)) - await this.fs.rmdir(rel) + await this.detachContent(rel, `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${rel}`) for (const key of [...this.metaCache.keys()]) { if (key.startsWith(`${rel}/`)) this.invalidateMeta(key) } @@ -944,14 +982,11 @@ export class MobileVault { } async readNoteComments(relPath: string): Promise { - const raw = await this.fs.readTextOrNull(this.commentsPathFor(resolveSafeRel(relPath))) + const rel = resolveSafeRel(relPath) + const raw = await this.fs.readTextOrNull(this.commentsPathFor(rel)) if (!raw) return [] try { - const parsed = JSON.parse(raw) as { comments?: NoteComment[] } | NoteComment[] - const list = Array.isArray(parsed) ? parsed : (parsed.comments ?? []) - return list - .filter((c) => c && typeof c === 'object') - .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)) + return normalizeNoteComments(JSON.parse(raw), rel) } catch { return [] } @@ -968,18 +1003,11 @@ export class MobileVault { async writeNoteComments(relPath: string, inputs: NoteCommentInput[]): Promise { const rel = resolveSafeRel(relPath) - const now = Date.now() - const comments: NoteComment[] = inputs.map((input) => ({ - id: input.id ?? uuid(), - notePath: rel, - anchorStart: Math.max(0, Math.min(input.anchorStart, input.anchorEnd)), - anchorEnd: Math.max(0, Math.max(input.anchorStart, input.anchorEnd)), - anchorText: (input.anchorText ?? '').slice(0, 500), - body: input.body ?? '', - createdAt: input.createdAt ?? now, - updatedAt: input.updatedAt ?? now, - resolvedAt: input.resolvedAt ?? null - })) + // Desktop's own normalizer (shared since app core 2.46). App-core always + // hands over the whole list, so the writer must keep the optional `author` + // and `parentId` a desktop or an assistant wrote, or one comment action on + // the phone flattens every thread and drops every name in the sidecar. + const comments = normalizeNoteComments(inputs, rel) await this.writeCommentsFile(rel, comments) emitVaultChange({ kind: 'change', @@ -990,24 +1018,6 @@ export class MobileVault { return comments } - private async moveNoteComments(oldRel: string, newRel: string): Promise { - const oldPath = this.commentsPathFor(oldRel) - const raw = await this.fs.readTextOrNull(oldPath) - if (raw === null) return - try { - const parsed = JSON.parse(raw) as { comments?: NoteComment[] } - const comments = (parsed.comments ?? []).map((c) => ({ ...c, notePath: newRel })) - await this.writeCommentsFile(newRel, comments) - } catch { - // unreadable sidecar — drop it - } - await this.fs.deleteFile(oldPath).catch(() => {}) - } - - private async removeNoteComments(rel: string): Promise { - await this.fs.deleteFile(this.commentsPathFor(rel)).catch(() => {}) - } - // ------------------------------------------------------------------- // Workspace state // ------------------------------------------------------------------- @@ -1473,7 +1483,7 @@ export class MobileVault { } } -// Pasted image naming lives in @shared/pasted-image (upstream 80303bb), which +// Pasted image naming lives in @zennotes/shared-domain/pasted-image (upstream 80303bb), which // is where the local copy that used to sit here went: the scrub of the // characters that break the `![[...]]` embed a paste writes has to agree // across desktop, web, the server and here, and one module is how it stays diff --git a/src/bridge/vault-lifecycle.test.ts b/src/bridge/vault-lifecycle.test.ts new file mode 100644 index 0000000..6f58675 --- /dev/null +++ b/src/bridge/vault-lifecycle.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } }) +const { MobileVault, NativeFs, DEFAULT_VAULT_SETTINGS } = await loadMobileModule([ + './src/bridge/vault-fs', './src/bridge/native-fs', '@zennotes/bridge-contract/ipc' +]) + +class MemoryFs { + files = new Map() + directories = new Set() + failMove: ((from: string, to: string) => boolean) | null = null + failWrite = false + async statVerified(path: string) { + return this.files.has(path) ? 'file' : this.directories.has(path) || [...this.files.keys()].some(key => key.startsWith(`${path}/`)) ? 'directory' : null + } + async exists(path: string) { return await this.statVerified(path) !== null } + async mkdir(path: string) { this.directories.add(path) } + async readText(path: string) { if (!this.files.has(path)) throw new Error('ENOENT'); return this.files.get(path)! } + async readTextOrNull(path: string) { return this.files.get(path) ?? null } + async writeText(path: string, body: string) { + if (this.failWrite) { this.failWrite = false; throw new Error('Settings write refused') } + this.files.set(path, body) + } + async deleteFile(path: string) { this.files.delete(path) } + async rmdir(path: string) { + for (const key of this.files.keys()) if (key === path || key.startsWith(`${path}/`)) this.files.delete(key) + for (const key of this.directories) if (key === path || key.startsWith(`${path}/`)) this.directories.delete(key) + } + async rename(from: string, to: string) { + if (this.failMove?.(from, to)) throw new Error('Provider refused move') + assert.ok(await this.exists(from)); assert.equal(await this.exists(to), false) + for (const [key, body] of [...this.files]) if (key === from || key.startsWith(`${from}/`)) { + this.files.set(to + key.slice(from.length), body); this.files.delete(key) + } + for (const key of [...this.directories]) if (key === from || key.startsWith(`${from}/`)) { + this.directories.add(to + key.slice(from.length)); this.directories.delete(key) + } + } +} +function fixture(rootMode = false) { + const fs = new MemoryFs(), vault = new MobileVault('Fixture') + Object.assign(vault, { + fs, settingsCache: { ...structuredClone(DEFAULT_VAULT_SETTINGS), primaryNotesLocation: rootMode ? 'root' : 'inbox', + systemFolderPaths: { inbox: 'Notes', archive: 'Old', trash: 'Bin', quick: 'Capture' } }, + listNotes: async () => [], invalidateMeta: () => {}, + metaForPath: async (path: string) => ({ path, title: path.split('/').pop() }) + }) + const note = `${rootMode ? '' : 'Notes/'}Work/One.md` + fs.files.set(note, '# One\n\nExact café 日本語. \n') + fs.files.set(`.zennotes/comments/${note}.comments.json`, '{malformed but preserved sidecar}') + return { fs, vault, note, original: new Map(fs.files) } +} +for (const rootMode of [false, true]) { + test(`native move and restore preserve bytes and sidecars with remapping (root=${rootMode})`, async () => { + const s = fixture(rootMode), result = await s.vault.moveToTrash(s.note) + assert.equal(result.path, 'Bin/Work/One.md') + assert.equal(s.fs.files.get(result.path), s.original.get(s.note)) + assert.equal(s.fs.files.get(`.zennotes/comments/${result.path}.comments.json`), '{malformed but preserved sidecar}') + const restored = await s.vault.restoreFromTrash(result.path) + assert.equal(restored.path, s.note) + assert.deepEqual(s.fs.files, s.original) + }) +} +test('native note relocation restores content when the comments provider fails', async () => { + const s = fixture() + s.fs.failMove = from => from.startsWith('.zennotes/comments/') + await assert.rejects(s.vault.moveToTrash(s.note), /Provider refused move/) + assert.deepEqual(s.fs.files, s.original) +}) +test('native folder rename moves database files and nested sidecars together', async () => { + const s = fixture() + s.fs.files.set('Notes/Work/Projects.base/data.csv', 'ID,Name\n1,One\n') + s.fs.files.set('Notes/Work/Projects.base/schema.json', '{"pages":{"1":"One.md"}}') + await s.vault.renameFolder('inbox', 'Work', 'Moved') + assert.equal(s.fs.files.get('Notes/Moved/Projects.base/data.csv'), 'ID,Name\n1,One\n') + assert.equal(s.fs.files.get('.zennotes/comments/Notes/Moved/One.md.comments.json'), '{malformed but preserved sidecar}') + assert.equal(s.fs.files.has(s.note), false) +}) +test('native folder rename restores content, comments and settings on metadata failure', async () => { + const s = fixture() + s.fs.files.set('.zennotes/vault.json', '{"keep":"exact settings"}\n') + s.original.set('.zennotes/vault.json', '{"keep":"exact settings"}\n') + s.fs.failWrite = true + await assert.rejects(s.vault.renameFolder('inbox', 'Work', 'Moved'), /Settings write refused/) + assert.deepEqual(s.fs.files, s.original) +}) +test('native Empty Trash rolls back if comments cannot detach, then deletes nested comments on retry', async () => { + const s = fixture() + await s.vault.moveToTrash(s.note) + const before = new Map(s.fs.files) + s.fs.failMove = from => from === '.zennotes/comments/Bin' + await assert.rejects(s.vault.emptyTrash(), /Provider refused move/) + assert.deepEqual(s.fs.files, before) + s.fs.failMove = null + await s.vault.emptyTrash() + assert.equal(s.fs.files.size, 0) + await s.vault.emptyTrash() +}) +test('native deletion does not report success after a failed sidecar move', async () => { + const s = fixture() + s.fs.failMove = from => from.startsWith('.zennotes/comments/') + await assert.rejects(s.vault.deleteNote(s.note), /Provider refused move/) + assert.deepEqual(s.fs.files, s.original) +}) +test('native absent-file reads propagate provider failures instead of allowing schema adoption', async () => { + const permission = new Error('Permission denied') + await assert.rejects(NativeFs.prototype.readTextOrNull.call({ readText: async () => { throw permission } }, 'schema.json'), permission) +}) diff --git a/src/bridge/widget-snapshot.test.ts b/src/bridge/widget-snapshot.test.ts new file mode 100644 index 0000000..5c911b5 --- /dev/null +++ b/src/bridge/widget-snapshot.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { WidgetNoteSource, WidgetTaskSource } from './widget-snapshot.ts' +import { + FALLBACK_WIDGET_THEME, + channelsToHex, + filterLiveTasks, + selectWidgetNotes, + selectWidgetTasks, + themeFromTokens +} from './widget-snapshot.ts' + +function note( + path: string, + updatedAt: number, + folder: WidgetNoteSource['folder'] = 'inbox', + title = path.replace(/^.*\//, '').replace(/\.md$/, '') +): WidgetNoteSource { + return { path, title, folder, updatedAt } +} + +test('pinned notes lead in pin order, then recents newest first', () => { + const notes = [ + note('inbox/Old.md', 100), + note('inbox/Newest.md', 900), + note('inbox/Pinned B.md', 300), + note('inbox/Pinned A.md', 200), + note('inbox/Middle.md', 500) + ] + const out = selectWidgetNotes(notes, ['inbox/Pinned B.md', 'inbox/Pinned A.md']) + assert.deepEqual( + out.map((n) => [n.path, n.pinned]), + [ + ['inbox/Pinned B.md', true], + ['inbox/Pinned A.md', true], + ['inbox/Newest.md', false], + ['inbox/Middle.md', false], + ['inbox/Old.md', false] + ] + ) +}) + +test('trash and archive never show; a stale pin is skipped; the cap holds', () => { + const notes = [ + note('trash/Gone.md', 999, 'trash'), + note('archive/Done.md', 998, 'archive'), + note('inbox/A.md', 3), + note('inbox/B.md', 2), + note('inbox/C.md', 1) + ] + const out = selectWidgetNotes(notes, ['inbox/Missing.md', 'trash/Gone.md'], 2) + assert.deepEqual( + out.map((n) => n.path), + ['inbox/A.md', 'inbox/B.md'] + ) + assert.equal(out.every((n) => !n.pinned), true) +}) + +test('an empty title reads Untitled and pins are not duplicated as recents', () => { + const out = selectWidgetNotes([note('inbox/x.md', 1, 'inbox', ' ')], ['inbox/x.md']) + assert.deepEqual(out, [ + { path: 'inbox/x.md', title: 'Untitled', folder: 'inbox', updatedAt: 1, pinned: true } + ]) +}) + +test('channel triplets become hex; anything else is rejected', () => { + assert.equal(channelsToHex('29 32 33'), '#1d2021') + assert.equal(channelsToHex(' 255 255 255 '), '#ffffff') + assert.equal(channelsToHex('0 0 0'), '#000000') + assert.equal(channelsToHex(''), null) + assert.equal(channelsToHex('#1d2021'), null) + assert.equal(channelsToHex('300 0 0'), null) +}) + +test('the theme reads every token it can and falls back per token', () => { + const tokens: Record = { + '--z-bg': '251 241 199', + '--z-accent': '195 94 10', + '--z-red': 'not a color' + } + const theme = themeFromTokens((t) => tokens[t] ?? '', 'light') + assert.equal(theme.mode, 'light') + assert.equal(theme.bg, '#fbf1c7') + assert.equal(theme.accent, '#c35e0a') + assert.equal(theme.red, FALLBACK_WIDGET_THEME.red) + assert.equal(theme.fg, FALLBACK_WIDGET_THEME.fg) +}) + +function task( + id: string, + content: string, + extra: Partial = {} +): WidgetTaskSource { + const sourcePath = id.slice(0, id.lastIndexOf('#')) + return { + id, + sourcePath, + noteTitle: sourcePath.replace(/^.*\//, '').replace(/\.md$/, ''), + content, + inProgress: false, + ...extra + } +} + +test('overdue tasks lead, the rest keep the bucket order, and counts cover the cut-off rows', () => { + const today = [ + task('inbox/A.md#1', 'Due today', { due: '2026-09-08', inProgress: true }), + task('inbox/B.md#0', ' Undated ', { priority: 'high' }), + task('inbox/A.md#0', 'Overdue thing', { due: '2026-09-05' }), + task('inbox/C.md#0', 'Older overdue', { due: '2026-09-01' }) + ] + const { tasks, counts } = selectWidgetTasks(today, 2, '2026-09-08', 3) + assert.deepEqual(counts, { today: 4, overdue: 2 }) + assert.deepEqual( + tasks.map((t) => t.content), + ['Overdue thing', 'Older overdue', 'Due today'] + ) + assert.deepEqual(tasks[0], { + id: 'inbox/A.md#0', + path: 'inbox/A.md', + noteTitle: 'A', + content: 'Overdue thing', + due: '2026-09-05', + overdue: true, + inProgress: false, + priority: null + }) + assert.equal(tasks[2]!.overdue, false) + assert.equal(tasks[2]!.inProgress, true) + const all = selectWidgetTasks(today, 2, '2026-09-08').tasks + assert.equal(all[3]!.content, 'Undated') + assert.equal(all[3]!.due, null) + assert.equal(all[3]!.priority, 'high') +}) + +test('tasks from deleted or trashed notes are dropped', () => { + const tasks = [ + { sourcePath: 'inbox/Keep.md', id: 'k' }, + { sourcePath: 'trash/Bin.md', id: 't' }, + { sourcePath: 'inbox/Gone.md', id: 'g' } + ] + const notes = [note('inbox/Keep.md', 1), note('trash/Bin.md', 1, 'trash')] + assert.deepEqual( + filterLiveTasks(tasks, notes).map((t) => t.id), + ['k'] + ) +}) diff --git a/src/bridge/widget-snapshot.ts b/src/bridge/widget-snapshot.ts new file mode 100644 index 0000000..e3c720a --- /dev/null +++ b/src/bridge/widget-snapshot.ts @@ -0,0 +1,226 @@ +/** + * The Home Screen / Lock Screen widget snapshot — the contract between the + * shell and the WidgetKit extension (ios/App/ZenWidgets). + * + * A widget extension runs in its own sandbox and cannot see the vault + * (on-device vaults live in the app's Documents container, iCloud vaults in + * the ubiquity container), so the app publishes what the widgets show — the + * active vault's pinned + recent notes, today's tasks, and the active theme's + * colors — as one JSON document in the App Group: WidgetBridgePlugin.swift + * writes it, WidgetSnapshot.swift decodes it. Keep the two sides in step; + * the Swift decoder treats every field a later shell might add as optional. + * + * Only pure selectors live here (node --test covers them). The store/pins + * wiring and the native call are in widgets.ts. + */ +import type { NoteMeta } from '@zennotes/bridge-contract/ipc' +import type { VaultTask } from '@zennotes/shared-domain/tasks' +import { parseThemeBackdropColor } from './keyboard-backdrop-color.ts' + +export const WIDGET_SNAPSHOT_VERSION = 1 +/** Large Recent Notes shows nine rows; a few spares cover pins that vanish. */ +export const WIDGET_MAX_NOTES = 12 +/** Large Tasks shows eight rows plus a "+N more" footer. */ +export const WIDGET_MAX_TASKS = 12 + +export interface WidgetTheme { + mode: 'light' | 'dark' + /** Hex colors (#rrggbb) sampled from the app-core `--z-*` tokens. */ + bg: string + bg1: string + bg2: string + fg: string + fg2: string + muted: string + accent: string + red: string +} + +export interface WidgetNote { + path: string + title: string + folder: string + /** ms since epoch, as NoteMeta reports it. */ + updatedAt: number + pinned: boolean +} + +export interface WidgetTask { + /** VaultTask id (`${sourcePath}#${taskIndex}`) — the tap link carries it + * back so the shell can jump to the exact line. */ + id: string + path: string + noteTitle: string + content: string + /** ISO YYYY-MM-DD or null for an undated task (those sit in Today too). */ + due: string | null + overdue: boolean + inProgress: boolean + priority: string | null +} + +export interface WidgetTaskCounts { + /** Everything in the Today bucket, not just the rows that fit. */ + today: number + overdue: number +} + +export interface WidgetSnapshot { + version: typeof WIDGET_SNAPSHOT_VERSION + /** ms since epoch. */ + generatedAt: number + vaultName: string | null + theme: WidgetTheme + /** Pinned notes first (in pin order), then the most recently edited. */ + notes: WidgetNote[] + /** The Today bucket, in the Tasks view's order. */ + tasks: WidgetTask[] + taskCounts: WidgetTaskCounts + /** False until the first task scan for this vault has landed, so the + * widget shows "loading" rather than a misleading "All clear". */ + tasksReady: boolean +} + +/** ZenNotes' default theme (dark-hard), used before the first publish and + * for any token the active theme leaves undefined. */ +export const FALLBACK_WIDGET_THEME: WidgetTheme = { + mode: 'dark', + bg: '#1d2021', + bg1: '#32302f', + bg2: '#3c3836', + fg: '#d4be98', + fg2: '#ddc7a1', + muted: '#a89984', + accent: '#e78a4e', + red: '#ea6962' +} + +const THEME_TOKENS: Record, string> = { + bg: '--z-bg', + bg1: '--z-bg-1', + bg2: '--z-bg-2', + fg: '--z-fg', + fg2: '--z-fg-2', + muted: '--z-grey-2', + accent: '--z-accent', + red: '--z-red' +} + +/** `"29 32 33"` (the `--z-*` channel triplet form) → `"#1d2021"`. */ +export function channelsToHex(value: string): string | null { + const color = parseThemeBackdropColor(value) + if (!color) return null + return ( + '#' + + [color.red, color.green, color.blue].map((n) => n.toString(16).padStart(2, '0')).join('') + ) +} + +/** Resolve the widget palette from a token reader (getComputedStyle in the + * app); tokens that don't parse keep the fallback value. */ +export function themeFromTokens( + read: (token: string) => string, + mode: WidgetTheme['mode'] +): WidgetTheme { + const theme: WidgetTheme = { ...FALLBACK_WIDGET_THEME, mode } + for (const [key, token] of Object.entries(THEME_TOKENS) as Array< + [Exclude, string] + >) { + const hex = channelsToHex(read(token)) + if (hex) theme[key] = hex + } + return theme +} + +export type WidgetNoteSource = Pick + +/** + * Pinned notes first, in the order they were pinned (the drawer's own + * convention — pins sort to the top of their group), then the most recently + * edited notes, mirroring the Home dashboard's Recent list. Trash and + * Archive never show; a pin whose note is gone is skipped, not surfaced. + */ +export function selectWidgetNotes( + notes: readonly WidgetNoteSource[], + pinnedPaths: readonly string[], + max = WIDGET_MAX_NOTES +): WidgetNote[] { + const live = notes.filter((n) => n.folder !== 'trash' && n.folder !== 'archive') + const byPath = new Map(live.map((n) => [n.path, n] as const)) + const out: WidgetNote[] = [] + const seen = new Set() + const push = (note: WidgetNoteSource, pinned: boolean): void => { + if (seen.has(note.path) || out.length >= max) return + seen.add(note.path) + out.push({ + path: note.path, + title: note.title.trim() || 'Untitled', + folder: note.folder, + updatedAt: note.updatedAt, + pinned + }) + } + for (const path of pinnedPaths) { + const note = byPath.get(path) + if (note) push(note, true) + } + for (const note of live.slice().sort((a, b) => b.updatedAt - a.updatedAt)) { + if (out.length >= max) break + push(note, false) + } + return out +} + +export type WidgetTaskSource = Pick< + VaultTask, + 'id' | 'sourcePath' | 'noteTitle' | 'content' | 'due' | 'inProgress' | 'priority' +> + +/** + * Drop tasks whose note no longer exists (or sits in Trash). App-core keeps + * `vaultTasks` fresh only while a tasks surface is on screen, so on the + * phone a note deleted from the drawer can leave its tasks in the cache + * until the next full scan — the widget must not show them. + */ +export function filterLiveTasks( + tasks: readonly T[], + notes: readonly Pick[] +): T[] { + const live = new Set(notes.filter((n) => n.folder !== 'trash').map((n) => n.path)) + return tasks.filter((t) => live.has(t.sourcePath)) +} + +/** + * The rows for the Tasks widget from the Today bucket app-core's + * `computeTasksRender` produces (due today, overdue, or undated — the same + * list the Home dashboard shows), plus the counts the header needs even + * when rows are cut off. `todayIso` is the local calendar day. + * + * One departure from the bucket's file order: overdue tasks lead. The + * widget shows three to eight rows under a header that counts the overdue + * ones, and in a vault with many undated tasks the bucket order would keep + * every overdue row out of sight. The sort is stable, so everything else + * keeps the app's order. + */ +export function selectWidgetTasks( + today: readonly WidgetTaskSource[], + overdueCount: number, + todayIso: string, + max = WIDGET_MAX_TASKS +): { tasks: WidgetTask[]; counts: WidgetTaskCounts } { + const isOverdue = (t: WidgetTaskSource): boolean => typeof t.due === 'string' && t.due < todayIso + const ordered = today.slice().sort((a, b) => Number(isOverdue(b)) - Number(isOverdue(a))) + const tasks = ordered.slice(0, max).map( + (t): WidgetTask => ({ + id: t.id, + path: t.sourcePath, + noteTitle: t.noteTitle, + content: t.content.trim() || 'Untitled task', + due: t.due ?? null, + overdue: typeof t.due === 'string' && t.due < todayIso, + inProgress: t.inProgress, + priority: t.priority ?? null + }) + ) + return { tasks, counts: { today: today.length, overdue: overdueCount } } +} diff --git a/src/bridge/widgets.ts b/src/bridge/widgets.ts new file mode 100644 index 0000000..3ec8982 --- /dev/null +++ b/src/bridge/widgets.ts @@ -0,0 +1,172 @@ +/** + * Widget publisher: keeps the App Group snapshot the WidgetKit extension + * renders (widget-snapshot.ts is the contract) in step with the store. + * + * Sources of change are the note index (every rescan and vault mutation + * replaces `notes`), the shared task cache, the drawer's pins, the active + * vault, and the theme. Each publish is a WidgetKit reload, so the first + * change after a quiet spell goes out almost at once and edits that keep + * landing (every autosave bumps a note's updatedAt) are coalesced to one + * publish per interval; backgrounding flushes whatever is pending so the + * Home Screen is current the moment the user leaves. + * + * Task freshness is this module's job too: app-core rescans a note's tasks + * on change only while a tasks surface is on screen (store.ts, + * `tasksSurfaceVisible`), which on the phone is rarely the case while + * editing. Notes whose updatedAt moved get a per-note rescan; a vault switch + * (or a big batch, e.g. iCloud landing many files) gets one full scan. + */ +import { App as CapApp } from '@capacitor/app' +import { Capacitor, registerPlugin } from '@capacitor/core' +import { getShellSnapshot, subscribeShell, type ShellSnapshot } from '@zennotes/app-core/shell' +import { getTasksSnapshot, subscribeTasks, refreshTasks, getTodayTasks, type TasksSnapshot } from '@zennotes/app-core/tasks' +import { subscribeSettings } from '@zennotes/app-core/settings' +import { toIsoDateLocal } from '@zennotes/shared-domain/tasks' +import { activeVaultStateKey, isMobileNoteIndexReady } from './mobile-bridge' +import { getPinnedNotes, subscribePins } from '../ui-mobile/pins' +import { + WIDGET_SNAPSHOT_VERSION, + selectWidgetNotes, + selectWidgetTasks, + themeFromTokens, + type WidgetSnapshot, + type WidgetTheme +} from './widget-snapshot' + +interface ZenWidgetsPlugin { + update(options: { snapshot: string }): Promise + clear(): Promise + /** The newest `zennotes://` link that launched or woke this process, once + * (deep-links.ts). Null when the app was opened normally. */ + consumeLaunchLink(): Promise<{ url: string | null }> +} + +export const ZenWidgets = registerPlugin('ZenWidgets') + +const PUBLISH_DEBOUNCE_MS = 400 +const PUBLISH_MIN_INTERVAL_MS = 8000 +/** Past this many changed notes one full scan beats per-note rescans. */ +const RESCAN_BATCH_LIMIT = 8 + +type StoreState = ShellSnapshot + +let timer = 0 +let lastPublishedAt = 0 +let lastPayload = '' +let taskVaultKey: string | null = null +let tasksSettled = false +let knownUpdatedAt = new Map() + +function themeMode(): WidgetTheme['mode'] { + return document.documentElement.dataset.themeMode === 'light' ? 'light' : 'dark' +} + +function buildSnapshot(state: StoreState, now: Date): Omit { + const style = getComputedStyle(document.documentElement) + const today = getTodayTasks(now) + const { tasks, counts } = selectWidgetTasks(today.tasks, today.overdueCount, toIsoDateLocal(now)) + return { + version: WIDGET_SNAPSHOT_VERSION, + vaultName: state.vault?.name ?? null, + theme: themeFromTokens((token) => style.getPropertyValue(token), themeMode()), + notes: selectWidgetNotes(state.notes, getPinnedNotes(activeVaultStateKey())), + tasks, + taskCounts: counts, + tasksReady: tasksSettled + } +} + +async function publish(): Promise { + const state = getShellSnapshot() + // No vault (onboarding, a switch in flight): keep whatever the widgets + // already show rather than blanking them. + if (!state.vault || !state.workspaceRestored) return + let body: Omit + try { + body = buildSnapshot(state, new Date()) + } catch (err) { + console.error('widget snapshot failed', err) + return + } + const payload = JSON.stringify(body) + if (payload === lastPayload) return + lastPayload = payload + lastPublishedAt = Date.now() + const snapshot: WidgetSnapshot = { ...body, generatedAt: Date.now() } + await ZenWidgets.update({ snapshot: JSON.stringify(snapshot) }).catch(() => {}) +} + +function schedule(): void { + if (timer) return + const wait = Math.max(PUBLISH_DEBOUNCE_MS, lastPublishedAt + PUBLISH_MIN_INTERVAL_MS - Date.now()) + timer = window.setTimeout(() => { + timer = 0 + void publish() + }, wait) +} + +function flush(): void { + if (!timer) return + window.clearTimeout(timer) + timer = 0 + void publish() +} + +function reconcileTasks(state: StoreState, prev: StoreState | null, tasks: TasksSnapshot, previousTasks: TasksSnapshot | null): void { + if (!state.vault || !state.workspaceRestored || !isMobileNoteIndexReady()) return + const key = activeVaultStateKey() + if (key !== taskVaultKey) { + taskVaultKey = key + tasksSettled = false + knownUpdatedAt = new Map(state.notes.map((n) => [n.path, n.updatedAt])) + if (!tasks.loading) void refreshTasks() + return + } + if (previousTasks?.loading && !tasks.loading) tasksSettled = true + if (!prev || state.notes === prev.notes) return + const changed: string[] = [] + const next = new Map() + for (const n of state.notes) { + next.set(n.path, n.updatedAt) + if (n.folder !== 'trash' && knownUpdatedAt.get(n.path) !== n.updatedAt) changed.push(n.path) + } + knownUpdatedAt = next + if (changed.length === 0) return + if (changed.length > RESCAN_BATCH_LIMIT) { + if (!tasks.loading) void refreshTasks() + return + } + for (const path of changed) void refreshTasks(path) +} + +/** Start publishing; returns the teardown (tests / hot paths — the shell + * itself never stops). No-op off the native platform. */ +export function installWidgetPublisher(): () => void { + if (!Capacitor.isNativePlatform()) return () => {} + let shell = getShellSnapshot() + let tasks = getTasksSnapshot() + const changed = (): void => { + const nextShell = getShellSnapshot(), nextTasks = getTasksSnapshot() + const previousShell = shell, previousTasks = tasks + // Store subscriptions can fire synchronously when refreshTasks starts. + shell = nextShell; tasks = nextTasks + reconcileTasks(nextShell, previousShell, nextTasks, previousTasks) + schedule() + } + const unsubShell = subscribeShell(changed) + const unsubTasks = subscribeTasks(changed) + const unsubSettings = subscribeSettings(schedule) + const unsubPins = subscribePins(schedule) + const appState = CapApp.addListener('appStateChange', ({ isActive }) => { + if (!isActive) flush() + }) + reconcileTasks(getShellSnapshot(), null, getTasksSnapshot(), null) + schedule() + return () => { + unsubShell(); unsubTasks(); unsubSettings() + unsubPins() + void appState.then((handle) => handle.remove()).catch(() => {}) + window.clearTimeout(timer) + timer = 0 + } +} diff --git a/src/main.tsx b/src/main.tsx index c602ed2..1cc03e3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,7 @@ */ import { App as CapApp } from '@capacitor/app' import { Keyboard, KeyboardResize } from '@capacitor/keyboard' +import { installMobileEditorHost } from './ui-mobile/editor-host' import { renderZenNotesApp } from '@zennotes/app-core/main' import { installMobileBridge, @@ -20,6 +21,8 @@ import { import { syncKeyboardBackdrop } from './bridge/keyboard-backdrop' import { configureMobileCloudAuth } from './bridge/mobile-cloud-auth' import { maybeRunFirstRunOnboarding } from './ui-mobile/Onboarding' +import { installWidgetPublisher } from './bridge/widgets' +import { installDeepLinks } from './ui-mobile/deep-links' import { mountMobileShell } from './ui-mobile/MobileShell' import { installHomeGuard } from './ui-mobile/nav' import { refreshVault, wireICloudLiveRefresh } from './ui-mobile/refresh' @@ -109,8 +112,13 @@ async function boot(): Promise { const root = document.getElementById('root') if (!root) throw new Error('Renderer root element #root was not found') + installMobileEditorHost() renderZenNotesApp(root) mountMobileShell() + // Home Screen / Lock Screen widgets: publish what they show, and run the + // links they open the app with (both wait for the workspace themselves). + installWidgetPublisher() + installDeepLinks() } void boot().catch((err) => { diff --git a/src/ui-mobile/EditorToolbar.tsx b/src/ui-mobile/EditorToolbar.tsx index c06ede0..49ebf95 100644 --- a/src/ui-mobile/EditorToolbar.tsx +++ b/src/ui-mobile/EditorToolbar.tsx @@ -2,95 +2,17 @@ * The mobile editing toolbar (spec 06's marquee input feature): a horizontally * scrollable formatting row docked above the soft keyboard while the * CodeMirror editor is focused. Actions drive the shared editor through the - * store's `editorViewRef` using app-core's own formatting helpers - * (lib/cm-format.ts) plus stock @codemirror/commands — no editor logic is - * duplicated, and no zennotes code changes. + * public semantic commands, without owning editor state or formatting logic. * * With the Capacitor Keyboard in `resize: native` mode the viewport shrinks * when the keyboard shows, so `bottom: 0` docks exactly on the keyboard's top. */ import React, { useEffect, useState } from 'react' import { Keyboard } from '@capacitor/keyboard' -import type { EditorView } from '@codemirror/view' -import { indentLess, indentMore, redo, undo } from '@codemirror/commands' -import { openSearchPanel } from '@codemirror/search' -import { EditorSelection } from '@codemirror/state' -import { useStore } from '@zennotes/app-core/store' -import { - setBlockType, - toggleWrap, - wrapLink, - type BlockType -} from '@zennotes/app-core/lib/cm-format' +import { runEditorCommand } from '@zennotes/app-core/editor' import { promptAttachFiles } from './attach' import { revealCaretAboveKeyboardSoon } from './editor-keyboard-scroll' -function view(): EditorView | null { - return useStore.getState().editorViewRef -} - -function withView(fn: (v: EditorView) => void): void { - const v = view() - if (!v) return - fn(v) - v.focus() -} - -/** Insert text at the cursor, placing the caret `caretOffset` chars in. */ -function insertSnippet(v: EditorView, text: string, caretOffset: number): void { - const { from, to } = v.state.selection.main - v.dispatch({ - changes: { from, to, insert: text }, - selection: EditorSelection.cursor(from + caretOffset) - }) -} - -/** - * Markers app-core's blockPrefix would put on these block types. app-core's - * setBlockType converts existing lines and deliberately skips blank ones, so - * on a fresh line Bullet / Checkbox / Heading did nothing until something was - * typed (Adib, device testing 2026-09-08). Desktop users just type the - * marker; on the phone the button IS the way to start a list. - */ -const BLANK_LINE_MARKERS: Partial> = { - bullet: '- ', - todo: '- [ ] ', - h1: '# ', - h2: '## ', - h3: '### ' -} - -/** - * setBlockType, plus the blank-line case it skips: a collapsed cursor on an - * empty (or whitespace-only) line gets the marker inserted after the existing - * indentation, caret after the marker. Selections and non-blank lines go - * through setBlockType unchanged. - */ -function applyBlockType(v: EditorView, type: BlockType): void { - const { from, to } = v.state.selection.main - const line = v.state.doc.lineAt(from) - const marker = BLANK_LINE_MARKERS[type] - if (marker !== undefined && from === to && line.text.trim() === '') { - // line.text is whitespace-only here, so it doubles as the indent. - const insert = line.text + marker - v.dispatch({ - changes: { from: line.from, to: line.to, insert }, - selection: EditorSelection.cursor(line.from + insert.length) - }) - return - } - setBlockType(v, type) -} - -/** Cycle the current line's heading level: none → # → ## → ### → none. */ -function cycleHeading(v: EditorView): void { - const line = v.state.doc.lineAt(v.state.selection.main.from) - const m = line.text.match(/^(#{1,6})\s/) - const level = m ? m[1]!.length : 0 - const next = level >= 3 ? 'paragraph' : (['h1', 'h2', 'h3'] as const)[level]! - applyBlockType(v, next) -} - interface ToolButton { key: string label: string @@ -105,13 +27,13 @@ const BUTTONS: ToolButton[] = [ key: 'undo', label: 'Undo', d: 'M9 14L4 9l5-5M4 9h10.5a5.5 5.5 0 015.5 5.5v0a5.5 5.5 0 01-5.5 5.5H11', - run: () => withView((v) => undo(v)) + run: () => runEditorCommand('undo') }, { key: 'redo', label: 'Redo', d: 'M15 14l5-5-5-5M20 9H9.5A5.5 5.5 0 004 14.5v0A5.5 5.5 0 009.5 20H13', - run: () => withView((v) => redo(v)) + run: () => runEditorCommand('redo') }, { key: 'find', @@ -121,10 +43,7 @@ const BUTTONS: ToolButton[] = [ // withView's editor refocus would immediately steal it back. Focus moves // input-to-input, so the keyboard stays up (Discord feedback, 2026-08-20: // "I have to exit the note to search for a word"). - run: () => { - const v = view() - if (v) openSearchPanel(v) - } + run: () => { runEditorCommand('open-search') } }, { key: 'attach', @@ -138,84 +57,84 @@ const BUTTONS: ToolButton[] = [ key: 'todo', label: 'Checkbox', d: 'M9 11l3 3L22 4M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11', - run: () => withView((v) => applyBlockType(v, 'todo')) + run: () => runEditorCommand('set-task-list') }, { key: 'bullet', label: 'Bullet list', d: 'M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01', - run: () => withView((v) => applyBlockType(v, 'bullet')) + run: () => runEditorCommand('set-bullet-list') }, { key: 'heading', label: 'Heading', glyph: 'H', d: '', - run: () => withView((v) => cycleHeading(v)) + run: () => runEditorCommand('cycle-heading') }, { key: 'bold', label: 'Bold', glyph: 'B', d: '', - run: () => withView((v) => toggleWrap(v, '**')) + run: () => runEditorCommand('toggle-bold') }, { key: 'italic', label: 'Italic', glyph: 'I', d: '', - run: () => withView((v) => toggleWrap(v, '*')) + run: () => runEditorCommand('toggle-italic') }, { key: 'strike', label: 'Strikethrough', d: 'M16 4H9a3 3 0 00-2.83 4M14 12a4 4 0 010 8H6M4 12h16', - run: () => withView((v) => toggleWrap(v, '~~')) + run: () => runEditorCommand('toggle-strikethrough') }, { key: 'highlight', label: 'Highlight', d: 'M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z', - run: () => withView((v) => toggleWrap(v, '==')) + run: () => runEditorCommand('toggle-highlight') }, { key: 'code', label: 'Inline code', d: 'M16 18l6-6-6-6M8 6l-6 6 6 6', - run: () => withView((v) => toggleWrap(v, '`')) + run: () => runEditorCommand('toggle-inline-code') }, { key: 'link', label: 'Link', d: 'M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71', - run: () => withView((v) => wrapLink(v)) + run: () => runEditorCommand('insert-link') }, { key: 'wikilink', label: 'Wikilink', glyph: '[[', d: '', - run: () => withView((v) => insertSnippet(v, '[[]]', 2)) + run: () => runEditorCommand('insert-wikilink') }, { key: 'tag', label: 'Tag', glyph: '#', d: '', - run: () => withView((v) => insertSnippet(v, '#', 1)) + run: () => runEditorCommand('insert-tag') }, { key: 'outdent', label: 'Outdent', d: 'M11 8h10M11 12h10M11 16h10M7 8l-4 4 4 4', - run: () => withView((v) => indentLess(v)) + run: () => runEditorCommand('outdent') }, { key: 'indent', label: 'Indent', d: 'M11 8h10M11 12h10M11 16h10M3 8l4 4-4 4', - run: () => withView((v) => indentMore(v)) + run: () => runEditorCommand('indent') } ] @@ -312,6 +231,15 @@ export function MobileEditorToolbar(): React.JSX.Element | null { onPointerDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()} onClick={() => { + // Blur first, as drawer-state / sheet-state / note-actions do: + // Keyboard.hide() alone resigns the WebView but leaves DOM focus + // in CodeMirror, so the very next touch anywhere (the ensō + // button, say) made the WebView first responder again and the + // keyboard came straight back, hiding the button under the + // finger before its click could land. Blurring also flips + // `editing`, so the toolbar closes with the keyboard. + const active = document.activeElement + if (active instanceof HTMLElement) active.blur() void Keyboard.hide().catch(() => {}) }} > diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 09e44d9..8354013 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -1,3 +1,4 @@ +import { dirOf } from './note-order' /** * The phone navigation drawer — a purpose-built mobile surface that REPLACES * app-core's desktop sidebar below 768px (which is hidden by CSS). Flat, @@ -7,23 +8,20 @@ */ import React, { useEffect, useMemo, useRef, useState } from 'react' import ReactDOM from 'react-dom/client' -import { useStore } from '@zennotes/app-core/store' -import type { NoteSortOrder } from '@zennotes/app-core/store' -import { confirmApp } from '@zennotes/app-core/lib/confirm-requests' -import { promptApp } from '@zennotes/app-core/lib/prompt-requests' -import { notePathWithinFolder } from '@zennotes/app-core/lib/vault-layout' -import { resolveFolderPath } from '@zennotes/shared-domain/system-folder-paths' -import { - csvPathForFormDir, - databaseTabPath, - FORM_DIR_SUFFIX, - isFormDirName -} from '@zennotes/shared-domain/databases' +import { getShellSnapshot, useShellSnapshot, setNoteSortOrder, type NoteSortOrder } from '@zennotes/app-core/shell' +import { getBrowseSnapshot, useBrowseSnapshot, getBrowseDirectory, requestCreateBrowseFolder, + requestRenameBrowseFolder, requestRenameBrowseDatabase, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' +import { useWorkspaceSnapshot, openLocalVault, pickLocalVault, refreshRemoteProfiles, connectRemoteWorkspace, + connectRemoteProfile, changeRemoteVaultPath, deleteRemoteProfile } from '@zennotes/app-core/workspace' +import { openNote, openAppPage } from '@zennotes/app-core/navigation' +import { showSearch } from '@zennotes/app-core/commands' +import { setSettingsVisible } from '@zennotes/app-core/settings' +import { confirm as confirmApp, prompt as promptApp } from '@zennotes/app-core/dialogs' +import { captureMobileWorkspace, reportActionError } from './workspace-context' import { Keyboard } from '@capacitor/keyboard' import { setDrawerOpen, takeDrawerPath, useDrawerOpen } from './drawer-state' import { openMobileSheet } from './sheet-state' import { goHome } from './nav' -import { dirOf, noteComparator, pinnedFirst } from './note-order' import { usePins, toggleNotePin, toggleFolderPin } from './pins' import { archiveNote, openNoteMenu, trashNote } from './note-actions' import { refreshVault } from './refresh' @@ -178,9 +176,7 @@ function NewVaultSheet({ tier === 'icloud' ? `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(clean)}` : `${VAULT_ROOT_PREFIX}${clean}` - useStore - .getState() - .openLocalVault(root) + openLocalVault(root) .then(() => onDone(true)) .catch((err) => { setError(String((err as Error)?.message ?? err)) @@ -197,12 +193,10 @@ function NewVaultSheet({ setBusy('pick') setError('') dismissKeyboard() - const before = useStore.getState().vault?.root ?? null - useStore - .getState() - .openVaultPicker() + const before = getShellSnapshot().vault?.root ?? null + pickLocalVault() .then(() => { - const after = useStore.getState().vault?.root ?? null + const after = getShellSnapshot().vault?.root ?? null if (after !== before) onDone(true) else setBusy(null) }) @@ -335,10 +329,8 @@ const TIER_SECTIONS = [ ] as const export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Element { - const currentName = useStore((s) => s.vault?.name ?? null) - const workspaceMode = useStore((s) => s.workspaceMode) - const remoteProfileId = useStore((s) => s.remoteWorkspaceInfo?.profileId ?? null) - const remoteProfiles = useStore((s) => s.remoteWorkspaceProfiles) + const currentName = useShellSnapshot().vault?.name ?? null + const { mode: workspaceMode, remoteProfileId, remoteProfiles } = useWorkspaceSnapshot() const [entries, setEntries] = useState(null) const [view, setView] = useState({ kind: 'list' }) const [busy, setBusy] = useState(null) @@ -357,7 +349,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele void icloudStatus() .then((s) => setCloudOk(Boolean(s.available && s.rootUrl))) .catch(() => {}) - void useStore.getState().refreshRemoteWorkspaceProfiles() + void refreshRemoteProfiles() }, []) // The storage pref tracks whichever tier is open (every switch path sets @@ -398,35 +390,23 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele }) } - const tokenFor = (tier: 'local' | 'icloud', name: string): string => - tier === 'icloud' - ? `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(name)}` - : `${VAULT_ROOT_PREFIX}${name}` - const submitRename = (entry: MobileVaultEntry): void => { if (entry.tier === 'external') return - const tier = entry.tier const clean = sanitizeNoteTitle(renameTo.trim()) dismissKeyboard() if (!clean || clean === entry.name) { setView({ kind: 'vault', entry }) return } - const wasCurrent = isCurrent(entry) manage('rename', async () => { await renameVault(entry, clean) - // Renaming the open vault: route the store through its normal switch so - // the whole workspace picks up the new identity. - if (wasCurrent) await useStore.getState().openLocalVault(tokenFor(tier, clean)) }) } const moveEntry = (entry: MobileVaultEntry, to: 'local' | 'icloud'): void => { if (entry.tier === 'external') return - const wasCurrent = isCurrent(entry) manage('move', async () => { await moveVault(entry, to) - if (wasCurrent) await useStore.getState().openLocalVault(tokenFor(to, entry.name)) }) } @@ -444,7 +424,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele onClose() // Let the sheet unmount so the guided URL/token prompts get focus. window.setTimeout(() => { - void useStore.getState().connectRemoteWorkspace() + void connectRemoteWorkspace() }, 30) } @@ -505,7 +485,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele disabled={current} onClick={() => act(`switch:${entry.root}`, () => - useStore.getState().openLocalVault(entry.root) + openLocalVault(entry.root) ) } > @@ -546,7 +526,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele disabled={current} onClick={() => act(`switch:${profile.id}`, () => - useStore.getState().connectRemoteWorkspaceProfile(profile.id) + connectRemoteProfile(profile.id) ) } > @@ -614,7 +594,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele className="zn-mobile-sheet-row" onClick={() => act(`switch:${view.entry.root}`, () => - useStore.getState().openLocalVault(view.entry.root) + openLocalVault(view.entry.root) ) } > @@ -779,7 +759,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele className="zn-mobile-sheet-row" onClick={() => act(`switch:${view.id}`, () => - useStore.getState().connectRemoteWorkspaceProfile(view.id) + connectRemoteProfile(view.id) ) } > @@ -796,7 +776,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele // Server-side folder browser renders as a modal — leave // the sheet first so it gets focus. window.setTimeout(() => { - void useStore.getState().changeRemoteWorkspaceVaultPath() + void changeRemoteVaultPath() }, 30) }} > @@ -809,7 +789,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele className="zn-mobile-sheet-row zn-danger" onClick={() => manage('remove', () => - useStore.getState().deleteRemoteWorkspaceProfile(view.id) + deleteRemoteProfile(view.id) ) } > @@ -831,32 +811,13 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele export function MobileDrawer(): React.JSX.Element | null { const open = useDrawerOpen() - const vaultName = useStore((s) => s.vault?.name ?? 'ZenNotes') - // The store subscription is only the change signal (root flips on every - // vault switch); pins key on the bridge's STABLE identity token — - // `vault.root` itself is friendlyVaultRoot()'s presentation copy, which a - // wording tweak would change, orphaning every pin (activeVaultStateKey). - const vaultRoot = useStore((s) => s.vault?.root ?? null) - const pinKey = useMemo(() => (vaultRoot ? activeVaultStateKey() : null), [vaultRoot]) + const browse = useBrowseSnapshot() + const vaultName = browse.vault?.name ?? 'ZenNotes' + const vaultRoot = browse.vault?.root ?? null + const pinKey = vaultRoot ? activeVaultStateKey() : null const pins = usePins(pinKey) - const notes = useStore((s) => s.notes) - const folders = useStore((s) => s.folders) - const primaryAtRoot = useStore((s) => s.vaultSettings.primaryNotesLocation === 'root') - // Primitive selectors only — returning a fresh object from a selector - // re-renders forever (Object.is on a new Set is never equal). - const dailyDir = useStore((s) => - s.vaultSettings.dailyNotes.enabled ? s.vaultSettings.dailyNotes.directory : null - ) - const weeklyDir = useStore((s) => - s.vaultSettings.weeklyNotes.enabled ? s.vaultSettings.weeklyNotes.directory : null - ) - const monthlyDir = useStore((s) => - s.vaultSettings.monthlyNotes.enabled ? s.vaultSettings.monthlyNotes.directory : null - ) - const noteSortOrder = useStore((s) => s.noteSortOrder) - // Stable reference from the store (replaced wholesale on settings reload), - // so this is selector-safe; needed for remap-aware path composition. - const vaultSettings = useStore((s) => s.vaultSettings) + const { daily: dailyDir, weekly: weeklyDir, monthly: monthlyDir } = browse.dateDirectories + const noteSortOrder = browse.noteSortOrder const dateDirs = useMemo(() => { const dirs = new Set() if (dailyDir) dirs.add(dailyDir) @@ -873,45 +834,13 @@ export function MobileDrawer(): React.JSX.Element | null { }, [open]) const { childFolders, childDatabases, childNotes } = useMemo(() => { - const inboxDir = resolveFolderPath('inbox', vaultSettings.systemFolderPaths) - const folderSet = new Map() - const databases: Array<[string, string, string]> = [] - for (const f of folders) { - if (f.folder !== 'inbox') continue - if (dirOf(f.subpath) !== path) continue - const name = f.subpath.split('/').pop() ?? f.subpath - if (isFormDirName(name)) { - // Databases are `.base` folders — surface them as openable rows. - const vaultRel = primaryAtRoot ? f.subpath : `${inboxDir}/${f.subpath}` - databases.push([ - databaseTabPath(csvPathForFormDir(vaultRel)), - name.slice(0, -FORM_DIR_SUFFIX.length), - f.subpath - ]) - continue - } - folderSet.set(f.subpath, name) - } - const noteRows = notes - .filter((n) => { - if (n.folder !== 'inbox') return false - const sub = notePathWithinFolder(n.path, 'inbox', vaultSettings) - return dirOf(sub) === path && !isFormDirName(dirOf(sub).split('/').pop() ?? '') - }) - .sort(noteComparator(noteSortOrder)) - // Pinned rows float to the top of their group, keeping the sort order - // within each half (pins.ts). - const pinnedNoteSet = new Set(pins.notes) - const pinnedFolderSet = new Set(pins.folders) + const rows = getBrowseDirectory(browse, path, pins) return { - childFolders: pinnedFirst( - [...folderSet.entries()].sort((a, b) => a[1].localeCompare(b[1])), - ([subpath]) => pinnedFolderSet.has(subpath) - ), - childDatabases: databases.sort((a, b) => a[1].localeCompare(b[1])), - childNotes: pinnedFirst(noteRows, (n) => pinnedNoteSet.has(n.path)) + childFolders: rows.folders.map(row => [row.directory, row.title] as [string, string]), + childDatabases: rows.databases.map(row => [row.path, row.title, row.directory] as [string, string, string]), + childNotes: [...rows.notes] } - }, [notes, folders, path, primaryAtRoot, noteSortOrder, vaultSettings, pins]) + }, [browse, path, pins]) if (!open) return null @@ -925,7 +854,6 @@ export function MobileDrawer(): React.JSX.Element | null { window.setTimeout(() => void action(), 30) } - const s = (): ReturnType => useStore.getState() return ( <> @@ -946,7 +874,6 @@ export function MobileDrawer(): React.JSX.Element | null { noteSortOrder={noteSortOrder} close={close} go={go} - s={s} onOpenVaults={() => openMobileSheet('vaults')} /> @@ -1118,7 +1045,6 @@ function MobileDrawerBody(props: { noteSortOrder: NoteSortOrder close: () => void go: (action: () => unknown) => void - s: () => ReturnType }): React.JSX.Element { const { vaultName, @@ -1136,25 +1062,24 @@ function MobileDrawerBody(props: { childNotes, noteSortOrder, close, - go, - s + go } = props const lp = useLongPress() const [sortOpen, setSortOpen] = useState(false) // Long-pressing a row opens its action sheet — the phone's right-click - // (Discord folder feedback, ported from the Android shell). Notes open the - // shell-wide note sheet (note-actions.tsx, shared with app-core's lists); - // folders get Rename/Delete here. Prompts overlay the open drawer (Modal - // layers above it), so the drawer stays put and its list refreshes in - // place via the vault change events. - const [folderMenu, setFolderMenu] = useState<{ subpath: string; name: string } | null>(null) + // (Discord folder feedback). Notes open the shell-wide note sheet + // (note-actions.tsx, shared with app-core's lists); folders get + // Rename/Delete here. Prompts overlay the open drawer (Modal layers above + // z-49), so the drawer stays put and its list refreshes in place via the + // vault change events. + const [folderMenu, setFolderMenu] = useState<{ kind: 'folder' | 'database'; subpath: string; name: string; host: ReturnType } | null>(null) const pinNote = (notePath: string): void => { if (!pinKey) return toggleNotePin( pinKey, notePath, - s().notes.map((n) => n.path) + getShellSnapshot().notes.map((n) => n.path) ) } @@ -1163,9 +1088,7 @@ function MobileDrawerBody(props: { toggleFolderPin( pinKey, subpath, - s() - .folders.filter((f) => f.folder === 'inbox') - .map((f) => f.subpath) + getBrowseSnapshot().folders.map(row => row.directory) ) } @@ -1176,63 +1099,18 @@ function MobileDrawerBody(props: { const scrollRef = useRef(null) - const renameFolderFromDrawer = (subpath: string, name: string): void => { + const renameFolderFromDrawer = (subpath: string, _name: string): void => { + const host = folderMenu?.host ?? captureMobileWorkspace() + const rename = folderMenu?.kind === 'database' ? requestRenameBrowseDatabase : requestRenameBrowseFolder setFolderMenu(null) - void (async () => { - const next = await promptApp({ - title: 'Rename folder', - initialValue: name, - okLabel: 'Rename', - validate: (v: string) => (v.includes('/') ? 'Folder name cannot contain "/"' : null) - }) - const clean = next?.trim() - if (!clean || clean === name) return - const parent = subpath.includes('/') ? subpath.slice(0, subpath.lastIndexOf('/')) : '' - try { - await s().renameFolder('inbox', subpath, parent ? `${parent}/${clean}` : clean) - } catch (err) { - window.alert(err instanceof Error ? err.message : String(err)) - } - })() + void rename(host, subpath).catch(reportActionError) } - const newFolderHere = (): void => { - void (async () => { - const leaf = path === '' ? '' : (path.split('/').pop() ?? '') - const name = await promptApp({ - title: leaf ? `New folder in ${leaf}` : 'New folder', - placeholder: 'Folder name', - okLabel: 'Create', - validate: (v: string) => (v.includes('/') ? 'Folder name cannot contain "/"' : null) - }) - const clean = name?.trim().replace(/^\/+|\/+$/g, '') - if (!clean) return - await s().createFolder('inbox', path === '' ? clean : `${path}/${clean}`) - })() + void requestCreateBrowseFolder(captureMobileWorkspace(), path).catch(reportActionError) } - - const deleteDatabase = (subpath: string, title: string): void => { - void (async () => { - const ok = await confirmApp({ - title: `Delete "${title}"?`, - description: 'All records will be permanently deleted. This cannot be undone.', - confirmLabel: 'Delete', - danger: true - }) - if (ok) await s().deleteFolder('inbox', subpath) - })() - } - - const deleteFolder = (subpath: string, name: string): void => { - void (async () => { - const ok = await confirmApp({ - title: `Delete "${name}"?`, - description: 'Everything inside will be permanently deleted. This cannot be undone.', - confirmLabel: 'Delete', - danger: true - }) - if (ok) await s().deleteFolder('inbox', subpath) - })() + const deleteFolder = (subpath: string, _name: string): void => { + const host = folderMenu?.host ?? captureMobileWorkspace() + void requestDeleteBrowseDirectory(host, subpath).catch(reportActionError) } return ( @@ -1251,7 +1129,7 @@ function MobileDrawerBody(props: { - @@ -1266,11 +1144,11 @@ function MobileDrawerBody(props: { Home - - @@ -1295,22 +1173,22 @@ function MobileDrawerBody(props: { )} - {/* The assets table only exists as a pane tab (zen://assets) — the palette's "Go to Files" drives the desktop sidebar list, which phones don't render, so this row is the phone's way in. */} - - - @@ -1350,7 +1228,7 @@ function MobileDrawerBody(props: { aria-checked={noteSortOrder === order} className={noteSortOrder === order ? 'is-active' : ''} onClick={() => { - s().setNoteSortOrder(order) + setNoteSortOrder(order) setSortOpen(false) }} > @@ -1373,7 +1251,7 @@ function MobileDrawerBody(props: { + {folderMenu.kind === 'folder' && ( + + )} diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 30d9f04..050f594 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -6,44 +6,24 @@ * full-screen flow. Rendered into its own React root so app-core stays * untouched; state is driven through the shared Zustand store. */ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import ReactDOM from 'react-dom/client' import { Haptics, ImpactStyle } from '@capacitor/haptics' import { Keyboard } from '@capacitor/keyboard' -import { useStore } from '@zennotes/app-core/store' -import { - getGesturePrefs, - setGesturePrefs, - type GesturePrefs, - type PullAction, - type SwipeAction -} from './gestures' -import type { TaskMutation } from '@zennotes/app-core/store' -import type { VaultTask } from '@shared/tasks' -import { toIsoDateLocal } from '@shared/tasks' -import { buildCommands } from '@zennotes/app-core/lib/commands' -import { findLeaf, updateLeaf } from '@zennotes/app-core/lib/pane-layout' -import { - paneModeForPath, - paneModesWithPathMode, - requestPaneMode -} from '@zennotes/app-core/lib/pane-mode' -import { - isSameFileHeadingLink, - resolveWikilinkTarget, - wikilinkHeadingAnchor -} from '@zennotes/app-core/lib/wikilinks' -import { - openDatabaseFromWikilink, - openWikilinkHeading -} from '@zennotes/app-core/lib/wikilink-navigation' +import { getShellSnapshot, useShellSnapshot, subscribeShell, getAdjacentNotePath, + getTagPresenceSnapshot, subscribeTagPresence } from '@zennotes/app-core/shell' +import { getBrowseSnapshot, requestCreateBrowseFolder, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' +import { getWorkspaceSnapshot, useWorkspaceSnapshot, subscribeWorkspace, readPersistedHomeState, + configureWorkspacePresentation, pickLocalVault, openLocalVault, connectRemoteProfile, refreshRemoteProfiles } from '@zennotes/app-core/workspace' +import { getSettingsSnapshot, useSettingsSnapshot, subscribeSettings, setSettingsVisible, setEditorFontSize } from '@zennotes/app-core/settings' +import { useEditorPresentation, setEditorMode, hasEditorSelection, runEditorCommand } from '@zennotes/app-core/editor' +import { getTasksSnapshot, moveTaskToColumn, type KanbanGroupBy } from '@zennotes/app-core/tasks' +import { runAppCommand, showCommandPalette, showSearch, showTemplates, showOutline } from '@zennotes/app-core/commands' +import { openNote, openWikilink, openTodayDailyNote } from '@zennotes/app-core/navigation' +import { requestTrashNote } from '@zennotes/app-core/notes' +import { getGesturePrefs, setGesturePrefs, type GesturePrefs, type PullAction, type SwipeAction } from './gestures' import { MobileEditorToolbar } from './EditorToolbar' -import { promptApp } from '@zennotes/app-core/lib/prompt-requests' -import { confirmApp } from '@zennotes/app-core/lib/confirm-requests' -import { notePathWithinFolder } from '@zennotes/app-core/lib/vault-layout' -import { noteTagsForCount } from '@zennotes/app-core/lib/tags' -import { resolveTypstPreambleFolder } from '@zennotes/app-core/lib/typst-preamble' -import { csvPathFromDatabaseTab, formDirFromCsvPath } from '@zennotes/shared-domain/databases' +import { captureMobileWorkspace, reportActionError } from './workspace-context' import { MobileDrawer } from './MobileDrawer' import { isDrawerOpen, setDrawerOpen, useDrawerOpen } from './drawer-state' import { goHome } from './nav' @@ -67,11 +47,6 @@ import { WELCOME_PENDING_KEY, FAB_HINT_KEY } from './Onboarding' import { WELCOME_NOTE_PATH } from '../bridge/welcome-note' import ensoUrl from '../assets/enso.png' import { getStoragePref } from '../bridge/icloud' -import { - createTagsEmptyStateTracker, - type TagsEmptyStateSnapshot -} from './tags-empty-state' -import { siblingNotesInDrawerOrder } from './note-order' import { getPinnedNotes, loadPins } from './pins' import { isSwipeRowGestureActive } from './SwipeRow' import { installNoteRowGestures, NOTE_ROW_SELECTOR } from './note-row-gestures' @@ -94,10 +69,8 @@ import { /** Run a command from the shared registry by id (same path the palette uses). */ function runCommand(id: string): void { - const cmd = buildCommands({ includeUnavailable: true }).find((c) => c.id === id) - if (!cmd) return - if (cmd.when && !cmd.when()) return - void cmd.run() + void runAppCommand(id).catch(reportActionError) + } function Icon({ d, filled }: { d: string; filled?: boolean }): React.JSX.Element { @@ -136,6 +109,7 @@ const ICONS = { tabs: 'M4 6h16M4 6v12h16V6M9 6v12', outline: 'M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01', rename: 'M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z', + star: 'M12 3l2.7 6.2 6.8.6-5.1 4.5 1.5 6.7L12 17.5 6.1 21l1.5-6.7L2.5 9.8l6.8-.6z', eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7zM12 15a3 3 0 100-6 3 3 0 000 6z', move: 'M5 8V6a2 2 0 012-2h3l2 2h7a2 2 0 012 2v10a2 2 0 01-2 2H7a2 2 0 01-2-2v-4M2 13h9m0 0l-3-3m3 3l-3 3', link: 'M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71', @@ -155,13 +129,23 @@ interface SheetRow { const RESTORE_ICON = 'M3 9l4-4m-4 4l4 4M3 9h13a5 5 0 015 5v0a5 5 0 01-5 5H9' -function noteRowsFor(folder: string | null): SheetRow[] { +function noteRowsFor(folder: string | null, favorite: boolean): SheetRow[] { const base: SheetRow[] = [ { id: 'nav.outline', label: 'Outline', icon: ICONS.outline }, { id: 'note.rename', label: 'Rename', icon: ICONS.rename }, { id: 'note.move', label: 'Move to…', icon: ICONS.move }, { id: 'note.copy-wikilink', label: 'Copy wikilink', icon: ICONS.link } ] + // Favorites are the vault's list (vault.json), the section Home and the + // desktop sidebar show; the palette command behind this row refuses + // trashed notes, so the row hides with it. (#810) + if (folder !== 'trash') { + base.push({ + id: 'note.favorite', + label: favorite ? 'Remove from Favorites' : 'Add to Favorites', + icon: ICONS.star + }) + } if (folder === 'archive') { base.push({ id: 'note.unarchive', label: 'Unarchive', icon: RESTORE_ICON }) } else if (folder === 'trash') { @@ -194,56 +178,22 @@ const APP_ROWS: SheetRow[] = [ ] function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { - const selectedPath = useStore((s) => s.selectedPath) - const workspaceMode = useStore((s) => s.workspaceMode) - // Virtual tabs (zen://help, zen://tasks, ...) aren't notes — their rows - // (rename/trash/...) would silently no-op. - const hasNote = Boolean(selectedPath) && !selectedPath?.startsWith('zen://') - const noteFolder = useStore((s) => { - if (!s.selectedPath) return null - return s.notes.find((n) => n.path === s.selectedPath)?.folder ?? null - }) - const calendarAvailable = useStore( - (s) => s.vaultSettings.dailyNotes.enabled || s.vaultSettings.weeklyNotes.enabled - ) - // With a database tab open, offer its removal — every open thing should be - // deletable from •••. - const dbFormDir = useStore((s) => { - const csv = csvPathFromDatabaseTab(s.selectedPath) - return csv ? formDirFromCsvPath(csv) : null - }) - const dbTitle = dbFormDir - ? (dbFormDir.split('/').pop() ?? '').replace(/\.base$/i, '') - : null - const title = useStore((s) => { - if (!s.selectedPath) return 'ZenNotes' - const note = s.notes.find((n) => n.path === s.selectedPath) - return note?.title ?? 'ZenNotes' - }) - + const shell = useShellSnapshot() + const { selectedPath, workspaceMode } = shell + const hasNote = !!shell.selectedNote + const noteFolder = shell.selectedNote?.folder ?? null + const { calendarAvailable } = useSettingsSnapshot() + const database = getBrowseSnapshot().databases.find(row => row.path === selectedPath) + const dbFormDir = database?.directory ?? null + const dbTitle = database?.title ?? null + const title = shell.selectedNote?.title ?? 'ZenNotes' + const workspace = useWorkspaceSnapshot() + const host = useMemo(() => captureMobileWorkspace(), [shell.vault, workspaceMode, workspace.generation, workspace.transitioning]) const deleteOpenDatabase = (): void => { - const formDir = dbFormDir - const label = dbTitle - if (!formDir || label === null) return + if (!dbFormDir) return onClose() window.setTimeout(() => { - void (async () => { - const ok = await confirmApp({ - title: `Delete "${label}"?`, - description: 'All records will be permanently deleted. This cannot be undone.', - confirmLabel: 'Delete', - danger: true - }) - if (!ok) return - // Remap-aware: the inbox may live in a renamed directory - // (vault.json systemFolderPaths), so strip the RESOLVED prefix. - const subpath = notePathWithinFolder( - formDir, - 'inbox', - useStore.getState().vaultSettings - ) - await useStore.getState().deleteFolder('inbox', subpath) - })() + void requestDeleteBrowseDirectory(host, dbFormDir).catch(reportActionError) }, 30) } @@ -253,7 +203,7 @@ function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { // focus lands in the right place. window.setTimeout(() => { if (id === 'zn.palette') { - useStore.getState().setCommandPaletteOpen(true) + showCommandPalette() return } if (id === 'zn.vaults') { @@ -261,26 +211,12 @@ function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { return } if (id === 'zn.pickfolder') { - void useStore.getState().openVaultPicker() + void pickLocalVault() return } + if (!host.isCurrent()) return if (id === 'note.trash') { - // Own the confirm copy ("Delete") — app-core's command would show its - // desktop "Move to Trash?" dialog. The bridge's unlink event closes - // the tab via applyChange. - void (async () => { - const st = useStore.getState() - const path = st.selectedPath - if (!path) return - const noteTitle = st.notes.find((n) => n.path === path)?.title - const ok = await confirmApp({ - title: `Delete "${noteTitle ?? 'this note'}"?`, - description: 'It will move to the trash.', - confirmLabel: 'Delete', - danger: true - }) - if (ok) await window.zen.moveToTrash(path) - })() + if (selectedPath) void requestTrashNote(host, selectedPath).catch(reportActionError) return } runCommand(id) @@ -335,7 +271,7 @@ function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { )} {hasNote && (
- {noteRowsFor(noteFolder).map((row) => ( + {noteRowsFor(noteFolder, !!selectedPath && shell.favorites.includes(selectedPath)).map((row) => (