diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 6c55c71de..f8a782270 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Label PR based on changed files uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bc3ac474..5c8a47923 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: synkronus-cli: ${{ steps.filter.outputs.synkronus-cli }} steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 @@ -68,7 +68,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -111,7 +111,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -160,7 +160,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 @@ -212,7 +212,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 diff --git a/.github/workflows/e2e-attachments.yml b/.github/workflows/e2e-attachments.yml index 0ad9d62fe..4cc1bce72 100644 --- a/.github/workflows/e2e-attachments.yml +++ b/.github/workflows/e2e-attachments.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 @@ -73,7 +73,7 @@ jobs: timeout-minutes: 20 needs: contract steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 diff --git a/.github/workflows/formulus-android.yml b/.github/workflows/formulus-android.yml index 0a8024c9c..1775167e5 100644 --- a/.github/workflows/formulus-android.yml +++ b/.github/workflows/formulus-android.yml @@ -39,7 +39,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -83,7 +83,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download formplayer assets artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 @@ -132,7 +132,7 @@ jobs: run: pnpm run generate - name: Set up Java - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/ode-desktop.yml b/.github/workflows/ode-desktop.yml index e768b4678..0cf4fd14a 100644 --- a/.github/workflows/ode-desktop.yml +++ b/.github/workflows/ode-desktop.yml @@ -45,7 +45,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -117,7 +117,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -194,7 +194,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download embedded formplayer uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 @@ -355,7 +355,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download embedded formplayer uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 diff --git a/.github/workflows/sbom-release.yml b/.github/workflows/sbom-release.yml index 7ebc6566a..82133ad73 100644 --- a/.github/workflows/sbom-release.yml +++ b/.github/workflows/sbom-release.yml @@ -23,7 +23,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup pnpm # scripts/sbom/generate-sboms.mjs shells out to `pnpm list`; needs pnpm on PATH. diff --git a/.github/workflows/synkronus-cli.yml b/.github/workflows/synkronus-cli.yml index d02286a0f..979be68c6 100644 --- a/.github/workflows/synkronus-cli.yml +++ b/.github/workflows/synkronus-cli.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 @@ -81,7 +81,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 diff --git a/.github/workflows/synkronus-docker.yml b/.github/workflows/synkronus-docker.yml index 89709578e..475ad1265 100644 --- a/.github/workflows/synkronus-docker.yml +++ b/.github/workflows/synkronus-docker.yml @@ -41,7 +41,7 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 @@ -78,7 +78,7 @@ jobs: packages/components/pnpm-lock.yaml - name: Set up Java (OpenAPI Generator) - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 with: distribution: temurin java-version: '21' @@ -126,7 +126,7 @@ jobs: digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download docker build context uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -192,7 +192,7 @@ jobs: digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download docker build context uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 diff --git a/PRIVACYPOLICY.md b/PRIVACYPOLICY.md new file mode 100644 index 000000000..63a5e40a0 --- /dev/null +++ b/PRIVACYPOLICY.md @@ -0,0 +1,11 @@ +# Privacy Policy + +Your privacy is very important to us. Therefore we consider al your personal information yours and yours alone. No data is stored or transmitted and we do no include any advertising or analytics software that talks to third parties. + +## What information is collected? + +Formulus does not collect any personal information. All login information is only stored locally on your device. + +## Contact + +If you have questions or concerns about this Privacy Policy, please [contact us](https://forum.opendataensemble.org/). \ No newline at end of file diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 02f2fe803..a8fc263a6 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -1,6 +1,6 @@ # ODE Desktop — AI and developer guide -**ODE Desktop** (`desktop/`) is the Tauri + React + Rust app for **Data management** (observations, sync, import) and the **Forms / app workbench** (bundles, form preview, custom app embed). User-facing overview: [desktop/README.md](README.md). +**ODE Desktop** (`desktop/`) is the Tauri + React + Rust app for **Data management** (observations, sync, import, **export**) and the **Forms / app workbench** (bundles, form preview, custom app embed). User-facing overview: [desktop/README.md](README.md). Published docs: [ODE Desktop developer mode](https://opendataensemble.org/docs/guides/ode-desktop-developer-mode) (local custom app iteration). diff --git a/desktop/README.md b/desktop/README.md index 47673495d..258792e4b 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -12,10 +12,23 @@ The internal Rust crate may still be named `custodian`; user-facing strings use ## Who it is for - **Field and data staff** managing observations and sync (Data management). +- **Analysts** exporting local Parquet from Data → **Export** for offline analysis (R, Python, etc.). - **Form and app authors** testing bundles and custom apps against Synkronus before mobile deploy (Workbench). Relationship to other ODE components: **Synkronus** (API), **Formulus** + **formplayer** (runtime parity), **Portal** and **CLI** (same public API — no privileged desktop channel). Long-form prose here is intended to be **copy-friendly** for [opendataensemble.org](https://opendataensemble.org/) documentation; keep user-facing sections free of repo-only trivia (put contributor notes under **Development**). +## Data management — Export + +From **Data → Export**, ODE Desktop writes analyst-oriented Parquet from the **active profile’s local workspace** (not from Synkronus over the network): + +1. Choose a destination parent folder (last location is remembered for the session, like Import). +2. Desktop creates a leaf folder named **`YYYYMMDD`** under that parent (overwrite confirmation if it already exists). +3. One **`.parquet`** per form type, plus **`export_manifest.json`**, and **`snippets/`** load scripts (R, Python, Stata, Julia). +4. Optional **Include pending observations** (local rows not yet synced). +5. Optional **Include attachments** — copies referenced files into `attachments/` under the export folder. Observation data stores attachment **basenames**; prefix them with the workspace attachments path shown on Profiles / Export (or the export `attachments/` path when copied) to open files on disk. + +The Export page also shows copyable load snippets with variables named after each form type. Transforms and study-specific ETL stay **outside** Desktop. For a full **server** dump as a ZIP, use Portal or `synk data export`. + ## Install Official builds are produced with **`pnpm tauri build`** (artifacts vary by OS: `.msi`, `.dmg`, `.app`, AppImage, `.deb`, etc.). When releases are published, attach those bundles to **GitHub Releases** and link them from project docs. diff --git a/desktop/docs/UI_FEEDBACK.md b/desktop/docs/UI_FEEDBACK.md index 69ef43ad9..5842451b3 100644 --- a/desktop/docs/UI_FEEDBACK.md +++ b/desktop/docs/UI_FEEDBACK.md @@ -4,12 +4,13 @@ Single source of truth for how the shell surfaces status, errors, and confirmati ## Progress banner (full-width) -**Use for:** long-running sync, import, and **app bundle download/apply** jobs. +**Use for:** long-running sync, import, **export**, and **app bundle download/apply** jobs. - Renders below the mode switch / dev bar in `Shell`. - Shows spinner + status text; optional determinate progress bar when `bundleActivity.total > 0`. - Dismissible but reappears on next activity. - Bundle apply: Rust emits `bundle/apply-progress` and `bundle/index-rebuild`; `bundleActivity` in `useCustodianStore` drives the banner. +- Sync completion: keep the banner for short **summary** lines when a downloadable `syncDetailReport` is attached (e.g. missing-attachment push details). Offer **Save report** instead of dumping long ID lists into the banner. - Do not use for quick actions (save, auth, dev mirror refresh). ## Toasts (bottom-right stack) @@ -32,7 +33,7 @@ Single source of truth for how the shell surfaces status, errors, and confirmati ## Native confirm (Tauri) -**Use for:** destructive actions, import-with-issues, closing unsaved observation tabs. +**Use for:** destructive actions, skip already-synced import rows, closing unsaved observation tabs. - `confirmDestructiveAction()` for destructive flows (clear, profile-scoped wording). - `confirm()` from `@tauri-apps/plugin-dialog` for save-anyway / import-anyway / tab discard (Save / Don't save / Cancel via separate flows). @@ -41,7 +42,8 @@ Single source of truth for how the shell surfaces status, errors, and confirmati - `window.alert` — replace with toast or inline notice. - `window.confirm` — replace with Tauri `confirm`. -- Duplicate sync success in both banner and toast — prefer toast for short messages; keep banner only for multi-line sync summaries. +- Dumping long per-observation lists into the sync banner — use a short highlight + **Save report** (`syncDetailReport`). +- Duplicate sync success in both banner and toast — prefer toast for short messages; keep banner when a detail report is available or the message is multi-line. ## Dev mode bar diff --git a/desktop/docs/screens/import.md b/desktop/docs/screens/import.md index 5fe1ff55e..183abc3c6 100644 --- a/desktop/docs/screens/import.md +++ b/desktop/docs/screens/import.md @@ -11,9 +11,10 @@ Bring external JSON observation files into the active profile’s local reposito ## What to include - Drag-and-drop and multi-file picker for `.json` -- Pre-flight summary (counts, form types, attachment hints) -- Per-file parse/normalization issues -- Import action and clear/reset +- **Validate** then review results in-page (no “import anyway?” popup) +- Import action on the validation results panel (available with or without errors) +- Clear label when validation finds no errors +- Optional skip of Formulus-export rows that already appear synced (`syncedAt` ≥ `updatedAt`) — confirm dialog at staging time ## What to exclude @@ -23,17 +24,21 @@ Bring external JSON observation files into the active profile’s local reposito ## Key actions -- Stage files, review summary, import, clear +- Stage files → **Validate** → review results → **Import into local store** (or clear) +- When import JSON carries Formulus `syncedAt` metadata, confirm whether to skip already-synced observations and write only unsynced ones ## Data dependencies - Store: `import` flow via `tauriClient.importObservations`, refresh `loadObservations` / `loadHealth` after import - Active profile determines target SQLite repository +- Sync appearance: Formulus hail-mary export includes `syncedAt`; Desktop treats a row as already synced when `syncedAt` is meaningful and `updatedAt <= syncedAt` (same rule as Formulus pending detection) ## Observation indexes -When the active app bundle declares `observationIndexes` in `app.config.json`: +When the active app bundle declares `observationIndexes` in `app.config.json`, local file import updates indexes **incrementally** for the written rows (same as sync pull). A full background rebuild is reserved for bundle apply / empty index / explicit rebuild — not for import. -1. Import writes observations in batches (default 2000 rows per IPC call); intermediate batches skip index work. -2. After the final batch commits, Rust schedules **one** coalesced background full index rebuild (`bundle/index-rebuild` progress events). Overlapping rebuild requests while one is running are merged into a single follow-up pass. -3. Sync pull uses incremental indexing per page instead (no full rebuild after each pull). +## Large Formulus exports + +After staging JSON (folder / drop / Add JSON), Desktop runs a lightweight host scan of `syncedAt` / `updatedAt` and may offer to drop already-synced files **before** full parse + schema validation. Staging lists truncate after ~50 rows so tens of thousands of files do not freeze the UI. + +Import parse + JSON Schema validation + attachment reference checks run in **Rust (Rayon)** in one pass (`parse_and_validate_import_json_paths`), with form schemas loaded once from the active bundle. diff --git a/desktop/docs/screens/profiles.md b/desktop/docs/screens/profiles.md index 307124a0e..f7916b9fb 100644 --- a/desktop/docs/screens/profiles.md +++ b/desktop/docs/screens/profiles.md @@ -14,6 +14,7 @@ Define units of custody: each profile has its own Synkronus server, credentials, - Edit: display name, server URL, username, password (OS keyring when available) - Local repository file picker, workspace folder, optional attachments folder - Reload, save profile, clear saved password +- Authenticate button: auto-recovers session (refresh token / saved password) on load and profile switch; shows **Authenticated** only after a successful check, otherwise **Authenticate** - Warnings when secure storage is unavailable (password not persisted) ## What to exclude diff --git a/desktop/docs/screens/sync.md b/desktop/docs/screens/sync.md index f84f96ebf..f7a829938 100644 --- a/desktop/docs/screens/sync.md +++ b/desktop/docs/screens/sync.md @@ -18,6 +18,7 @@ Operational console to authenticate with Synkronus and exchange data: pull into - Pull and push actions - Recent operation log (per-session, on this screen) - Store-level `syncMessage` / `error` after operations +- Danger zone: reset local data (full, or **pending-only** so synced rows + sync offsets stay for a continued pull), re-create index, reset server repository ## What to exclude @@ -28,6 +29,7 @@ Operational console to authenticate with Synkronus and exchange data: pull into ## Key actions - Login, pull, push +- Reset local data (optional: pending observations only) ## Data dependencies diff --git a/desktop/package.json b/desktop/package.json index cd523288f..15f9bab27 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "ode-desktop", "private": true, - "version": "1.2.0", + "version": "1.3.0", "packageManager": "pnpm@10.33.2", "type": "module", "scripts": { diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 3270b51ea..d444b3b25 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -107,7 +107,7 @@ importers: version: 5.6.3 typescript-eslint: specifier: ^8.54.0 - version: 8.59.3(eslint@9.39.4)(typescript@5.6.3) + version: 8.66.0(eslint@9.39.4)(typescript@5.6.3) vite: specifier: ^6.0.3 version: 6.4.2(@types/node@20.19.41) @@ -127,6 +127,10 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.3': resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} @@ -169,6 +173,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -198,6 +206,10 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -397,6 +409,12 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -921,63 +939,63 @@ packages: '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} - '@typescript-eslint/eslint-plugin@8.59.3': - resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} + '@typescript-eslint/eslint-plugin@8.66.0': + resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.3 + '@typescript-eslint/parser': ^8.66.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.3': - resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==} + '@typescript-eslint/parser@8.66.0': + resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.3': - resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==} + '@typescript-eslint/project-service@8.66.0': + resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.59.3': - resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} + '@typescript-eslint/scope-manager@8.66.0': + resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.59.3': - resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.3': - resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==} + '@typescript-eslint/type-utils@8.66.0': + resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.59.3': - resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.59.3': - resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} + '@typescript-eslint/typescript-estree@8.66.0': + resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.3': - resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} + '@typescript-eslint/utils@8.66.0': + resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.59.3': - resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} + '@typescript-eslint/visitor-keys@8.66.0': + resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@4.7.0': @@ -1146,6 +1164,10 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1737,8 +1759,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} immer@11.1.11: @@ -2007,6 +2029,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -2143,6 +2169,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2329,8 +2359,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -2484,6 +2514,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2555,8 +2589,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.59.3: - resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==} + typescript-eslint@8.66.0: + resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2827,6 +2861,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': @@ -2889,6 +2929,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': @@ -2912,6 +2954,8 @@ snapshots: '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -3035,6 +3079,11 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -3378,8 +3427,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -3514,57 +3563,57 @@ snapshots: '@types/wrap-ansi@3.0.0': {} - '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.6.3))(eslint@9.39.4)(typescript@5.6.3)': + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.4)(typescript@5.6.3))(eslint@9.39.4)(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.6.3) - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/type-utils': 8.59.3(eslint@9.39.4)(typescript@5.6.3) - '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/parser': 8.66.0(eslint@9.39.4)(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/type-utils': 8.66.0(eslint@9.39.4)(typescript@5.6.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.4)(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.66.0 eslint: 9.39.4 - ignore: 7.0.5 + ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.6.3) typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.6.3)': + '@typescript-eslint/parser@8.66.0(eslint@9.39.4)(typescript@5.6.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3 eslint: 9.39.4 typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.3(typescript@5.6.3)': + '@typescript-eslint/project-service@8.66.0(typescript@5.6.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.6.3) - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.6.3) + '@typescript-eslint/types': 8.66.0 debug: 4.4.3 typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.59.3': + '@typescript-eslint/scope-manager@8.66.0': dependencies: - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 - '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.6.3)': + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.6.3)': dependencies: typescript: 5.6.3 - '@typescript-eslint/type-utils@8.59.3(eslint@9.39.4)(typescript@5.6.3)': + '@typescript-eslint/type-utils@8.66.0(eslint@9.39.4)(typescript@5.6.3)': dependencies: - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.6.3) - '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.6.3) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.6.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.4)(typescript@5.6.3) debug: 4.4.3 eslint: 9.39.4 ts-api-utils: 2.5.0(typescript@5.6.3) @@ -3572,37 +3621,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.59.3': {} + '@typescript-eslint/types@8.66.0': {} - '@typescript-eslint/typescript-estree@8.59.3(typescript@5.6.3)': + '@typescript-eslint/typescript-estree@8.66.0(typescript@5.6.3)': dependencies: - '@typescript-eslint/project-service': 8.59.3(typescript@5.6.3) - '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.6.3) - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/project-service': 8.66.0(typescript@5.6.3) + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.6.3) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.0 - tinyglobby: 0.2.16 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.6.3) typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.3(eslint@9.39.4)(typescript@5.6.3)': + '@typescript-eslint/utils@8.66.0(eslint@9.39.4)(typescript@5.6.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.6.3) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.6.3) eslint: 9.39.4 typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.59.3': + '@typescript-eslint/visitor-keys@8.66.0': dependencies: - '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/types': 8.66.0 eslint-visitor-keys: 5.0.1 '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@20.19.41))': @@ -3809,6 +3858,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.30 @@ -4325,6 +4378,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -4512,7 +4569,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.6: {} immer@11.1.11: {} @@ -4779,6 +4836,10 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.14 @@ -4916,6 +4977,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.5: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.14: @@ -5145,7 +5208,7 @@ snapshots: semver@6.3.1: {} - semver@7.8.0: {} + semver@7.8.5: {} set-cookie-parser@2.7.2: {} @@ -5331,6 +5394,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} @@ -5406,12 +5474,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.59.3(eslint@9.39.4)(typescript@5.6.3): + typescript-eslint@8.66.0(eslint@9.39.4)(typescript@5.6.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.6.3))(eslint@9.39.4)(typescript@5.6.3) - '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.6.3) - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.6.3) - '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.6.3) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.4)(typescript@5.6.3))(eslint@9.39.4)(typescript@5.6.3) + '@typescript-eslint/parser': 8.66.0(eslint@9.39.4)(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.6.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.4)(typescript@5.6.3) eslint: 9.39.4 typescript: 5.6.3 transitivePeerDependencies: diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ba6862051..76cc24848 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -15,7 +15,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -68,6 +71,180 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrow" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d285d16bce7d0be61912f7928342b673067b6b7d7ef6cc179258ba7de1fecf" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757ef1836251e88222542a7da2623bc1c9cb9e20afefa6db2c41e79991cd91d4" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9a4a4b2b5ecd0e04df03471661cb61f28bed3c7fd50994715129b01b2edb97" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "chrono-tz", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12b576ef18c1deb80925a248b25ad84f419198d791b8e293fc6aaa60441fe90" +dependencies = [ + "bytes", + "half", + "num-bigint 0.5.1", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68338a9096a5dc9bc11927c58c43a8526d96bf6abd2012ef6c0c9f505991cc79" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.23.1", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723fe4aeed7604e00b9883a465af4ff0a0e6c44c03e41a68c3d1cbc403e0e44d" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ipc" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149437b14371f5b9ec60f5ddc751483ae99d7a7072653c0075e5e469156eea7b" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", +] + +[[package]] +name = "arrow-ord" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c08dff0686cf23ca4f562803f191ccbeb726dbae6309cd4b4aaf65e0f2c979" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbec439386df71ad570e6758a946111322b9e9dc8db83b5527321f0b4c9119c2" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6fed2ca0d1eade57e811cbe73b98ad50cc08a1183e13b2d2aa43a7df593f40e" + +[[package]] +name = "arrow-select" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "466b19cf75130b891dc1b23a84b343c714c62c64c9c62e365c76aa0ff90a53fb" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c838a25bb3691e919e0f617616ac51a4ff8517a952e29ca133cf0c22b2ce65b1" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -222,6 +399,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -246,6 +432,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -307,6 +499,12 @@ dependencies = [ "piper", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli" version = "8.0.2" @@ -334,6 +532,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -485,6 +689,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "combine" version = "4.6.7" @@ -504,6 +718,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -622,6 +856,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -926,6 +1166,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "embed-resource" version = "3.0.8" @@ -1042,6 +1291,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1073,6 +1333,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.11.0", + "rustc_version", +] + [[package]] name = "flate2" version = "1.1.9" @@ -1083,6 +1353,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1152,6 +1433,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futf" version = "0.1.5" @@ -1169,6 +1460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1582,6 +1874,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1612,6 +1916,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" @@ -2112,6 +2422,33 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "reqwest 0.12.28", + "serde", + "serde_json", + "uuid-simd", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2150,12 +2487,75 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2205,6 +2605,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.15" @@ -2438,12 +2844,91 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.8", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.8", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2451,6 +2936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2600,11 +3086,14 @@ dependencies = [ [[package]] name = "odedesktop" -version = "1.2.0" +version = "1.3.0" dependencies = [ + "arrow", "chrono", "futures-util", + "jsonschema", "keyring", + "parquet", "rayon", "reqwest 0.12.28", "rusqlite", @@ -2701,6 +3190,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "pango" version = "0.18.3" @@ -2755,6 +3250,32 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "parquet" +version = "59.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7065842956a20c2a536924ce8e4d9955f7422451511b9eb7500d7bfe5077e59c" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "base64 0.23.1", + "bytes", + "chrono", + "half", + "hashbrown 0.17.1", + "num-bigint 0.5.1", + "num-integer", + "num-traits", + "seq-macro", + "snap", + "twox-hash", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -2797,6 +3318,15 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -2945,6 +3475,15 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.2", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -3392,6 +3931,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "referencing" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -3430,6 +3983,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -3790,6 +4344,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -4026,6 +4586,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "socket2" version = "0.6.3" @@ -4692,6 +5258,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -4985,6 +5560,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twox-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" + [[package]] name = "typeid" version = "1.0.3" @@ -5134,6 +5715,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -5152,6 +5744,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b78a4c426..f562e59e1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "odedesktop" -version = "1.2.0" +version = "1.3.0" description = "ODE Desktop" authors = ["OpenDataEnsemble.org"] edition = "2024" @@ -36,4 +36,7 @@ walkdir = "2" url = "2" urlencoding = "2" tokio = { version = "1", features = ["sync", "time", "macros"] } +arrow = { version = "59", default-features = false, features = ["chrono-tz"] } +parquet = { version = "59", default-features = false, features = ["arrow", "snap"] } +jsonschema = "0.30" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 30932472f..de8ba96e8 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -6,7 +6,11 @@ "permissions": [ "core:default", "opener:default", - "opener:allow-open-path", + { + "identifier": "opener:allow-open-path", + "allow": [{ "path": "**" }, { "path": "/**" }] + }, + "opener:allow-reveal-item-in-dir", "dialog:default" ] } diff --git a/desktop/src-tauri/src/data_export.rs b/desktop/src-tauri/src/data_export.rs new file mode 100644 index 000000000..717453b75 --- /dev/null +++ b/desktop/src-tauri/src/data_export.rs @@ -0,0 +1,1054 @@ +//! Local Parquet export from the profile workspace SQLite. +//! +//! Produces analyst-compatible files (envelope + top-level `data_*` columns), +//! not bit-for-bit parity with Synkronus server export. + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + fs::{self, File}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use arrow::array::{ArrayRef, BooleanBuilder, Float64Builder, Int64Builder, StringBuilder}; +use arrow::datatypes::{DataType, Field, Schema}; +use arrow::record_batch::RecordBatch; +use chrono::Utc; +use parquet::arrow::ArrowWriter; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{CustodianError, ObservationExtras, resolve_attachment_path}; + +const EXPORTER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportParquetRequest { + /// Parent directory chosen by the user; leaf `YYYYMMDD` is created underneath. + pub parent_dir: String, + #[serde(default)] + pub include_pending: bool, + #[serde(default)] + pub include_attachments: bool, + /// When true, replace an existing `YYYYMMDD` leaf folder. + #[serde(default)] + pub overwrite: bool, + #[serde(default)] + pub profile_label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportParquetResult { + pub export_dir: String, + pub form_type_counts: BTreeMap, + /// Absolute paths keyed by form type (same stem as the `.parquet` file when possible). + pub parquet_files: BTreeMap, + pub total_rows: usize, + pub attachments_copied: usize, + pub attachments_missing: usize, + pub include_pending: bool, + pub include_attachments: bool, + pub workspace_attachments_path: String, + pub export_attachments_path: Option, + pub manifest_path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExportManifest { + exported_at: String, + exporter_version: String, + include_pending: bool, + include_attachments: bool, + profile_label: Option, + workspace_attachments_path: String, + export_attachments_path: Option, + form_type_counts: BTreeMap, + total_rows: usize, + attachments_copied: usize, + attachments_missing: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ColKind { + Float64, + Boolean, + String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ExportRow { + observation_id: String, + form_type: String, + form_version: String, + created_at: String, + updated_at: String, + synced_at: Option, + deleted: bool, + version: i64, + geolocation: Option, + author: Option, + device_id: Option, + tags: Option, + pending: bool, + data: BTreeMap, +} + +/// Sanitize a form type for use as a filename stem. +pub fn sanitize_filename(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { + out.push(ch); + } else { + out.push('_'); + } + } + if out.is_empty() { + "unknown".to_string() + } else { + out + } +} + +/// Identifier safe for R / Python / Julia / Stata frame names. +pub fn sanitize_identifier(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + } else { + out.push('_'); + } + } + while out.contains("__") { + out = out.replace("__", "_"); + } + let out = out.trim_matches('_').to_string(); + if out.is_empty() { + "form".to_string() + } else if out.chars().next().is_some_and(|c| c.is_ascii_digit()) { + format!("f_{out}") + } else { + out + } +} + +/// Progress callback: (done, total, message). +pub type ExportProgressFn<'a> = dyn FnMut(usize, usize, &str) + 'a; + +/// Today's export leaf folder name (`YYYYMMDD`). +pub fn export_leaf_folder_name() -> String { + Utc::now().format("%Y%m%d").to_string() +} + +fn value_as_object_map(payload: &Value) -> BTreeMap { + match payload { + Value::Object(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + _ => BTreeMap::new(), + } +} + +fn json_value_to_export_cell(v: &Value) -> Value { + match v { + Value::Null => Value::Null, + Value::Bool(_) | Value::Number(_) | Value::String(_) => v.clone(), + other => Value::String(other.to_string()), + } +} + +fn infer_col_kind(values: &[&Value]) -> ColKind { + let mut saw_bool = false; + let mut saw_number = false; + let mut saw_other = false; + for v in values { + match v { + Value::Null => {} + Value::Bool(_) => saw_bool = true, + Value::Number(_) => saw_number = true, + Value::String(_) | Value::Array(_) | Value::Object(_) => saw_other = true, + } + } + if saw_other { + ColKind::String + } else if saw_bool && !saw_number { + ColKind::Boolean + } else if saw_number && !saw_bool { + ColKind::Float64 + } else { + // Mixed bool+number, or all null → string for analyst-safe parquet. + ColKind::String + } +} + +fn extras_deleted(extras: &Option) -> bool { + extras.as_ref().and_then(|e| e.deleted).unwrap_or(false) +} + +struct DbExportCandidate { + id: String, + payload: Value, + form_type: Option, + updated_at: Option, + dirty: bool, + sync_status: String, + last_saved_at: String, + extras: Option, +} + +fn row_from_db(c: DbExportCandidate) -> Option { + if c.sync_status == "conflict" { + return None; + } + if extras_deleted(&c.extras) { + return None; + } + let form_type = c + .form_type + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let pending = c.dirty && c.sync_status == "dirty"; + let created_at = c + .extras + .as_ref() + .and_then(|e| e.created_at.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| c.last_saved_at.clone()); + let updated = c + .updated_at + .filter(|s| !s.is_empty()) + .unwrap_or(c.last_saved_at); + let form_version = c + .extras + .as_ref() + .and_then(|e| e.form_version.clone()) + .unwrap_or_default(); + let synced_at = c.extras.as_ref().and_then(|e| e.synced_at.clone()); + let geolocation = c.extras.as_ref().and_then(|e| { + e.geolocation + .as_ref() + .map(|g| g.to_string()) + .filter(|s| s != "null") + }); + let author = c.extras.as_ref().and_then(|e| e.author.clone()); + let device_id = c.extras.as_ref().and_then(|e| e.device_id.clone()); + let tags = c + .extras + .as_ref() + .and_then(|e| e.tags.as_ref().and_then(|t| serde_json::to_string(t).ok())); + let data = value_as_object_map(&c.payload) + .into_iter() + .map(|(k, v)| (k, json_value_to_export_cell(&v))) + .collect(); + Some(ExportRow { + observation_id: c.id, + form_type, + form_version, + created_at, + updated_at: updated, + synced_at, + deleted: false, + version: 0, + geolocation, + author, + device_id, + tags, + pending, + data, + }) +} + +pub(crate) fn load_export_rows( + conn: &Connection, + include_pending: bool, +) -> Result, CustodianError> { + let sql = if include_pending { + "SELECT id, payload, form_type, updated_at, dirty, sync_status, last_saved_at, observation_extras + FROM observations + WHERE sync_status != 'conflict' + ORDER BY COALESCE(form_type, ''), id" + } else { + "SELECT id, payload, form_type, updated_at, dirty, sync_status, last_saved_at, observation_extras + FROM observations + WHERE sync_status != 'conflict' AND dirty = 0 + ORDER BY COALESCE(form_type, ''), id" + }; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], |row| { + let payload_raw: String = row.get(1)?; + let payload = serde_json::from_str::(&payload_raw).unwrap_or(Value::Null); + let dirty: i64 = row.get(4)?; + let sync_status: String = row.get(5)?; + let extras_raw: Option = row.get(7)?; + let extras = extras_raw.and_then(|s| { + let t = s.trim(); + if t.is_empty() { + None + } else { + serde_json::from_str(t).ok() + } + }); + Ok(( + row.get::<_, String>(0)?, + payload, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + dirty == 1, + sync_status, + row.get::<_, String>(6)?, + extras, + )) + })?; + + let mut out = Vec::new(); + for row in rows { + let (id, payload, form_type, updated_at, dirty, sync_status, last_saved_at, extras) = row?; + if let Some(r) = row_from_db(DbExportCandidate { + id, + payload, + form_type, + updated_at, + dirty, + sync_status, + last_saved_at, + extras, + }) { + out.push(r); + } + } + Ok(out) +} + +fn schema_for_rows(rows: &[ExportRow]) -> (Schema, Vec<(String, ColKind)>) { + let mut key_values: BTreeMap> = BTreeMap::new(); + for row in rows { + for (k, v) in &row.data { + key_values.entry(k.clone()).or_default().push(v); + } + } + let data_cols: Vec<(String, ColKind)> = key_values + .iter() + .map(|(k, vals)| (k.clone(), infer_col_kind(vals))) + .collect(); + + let mut fields = vec![ + Field::new("observation_id", DataType::Utf8, false), + Field::new("form_type", DataType::Utf8, false), + Field::new("form_version", DataType::Utf8, false), + Field::new("created_at", DataType::Utf8, false), + Field::new("updated_at", DataType::Utf8, false), + Field::new("synced_at", DataType::Utf8, true), + Field::new("deleted", DataType::Boolean, false), + Field::new("version", DataType::Int64, false), + Field::new("geolocation", DataType::Utf8, true), + Field::new("author", DataType::Utf8, true), + Field::new("device_id", DataType::Utf8, true), + Field::new("tags", DataType::Utf8, true), + Field::new("pending", DataType::Boolean, false), + ]; + for (key, kind) in &data_cols { + let dt = match kind { + ColKind::Float64 => DataType::Float64, + ColKind::Boolean => DataType::Boolean, + ColKind::String => DataType::Utf8, + }; + fields.push(Field::new(format!("data_{key}"), dt, true)); + } + (Schema::new(fields), data_cols) +} + +fn append_optional_string(builder: &mut StringBuilder, value: &Option) { + match value { + Some(s) => builder.append_value(s), + None => builder.append_null(), + } +} + +fn cell_as_f64(v: &Value) -> Option { + match v { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.parse().ok(), + _ => None, + } +} + +fn cell_as_bool(v: &Value) -> Option { + match v { + Value::Bool(b) => Some(*b), + Value::String(s) => match s.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Some(true), + "false" | "0" | "no" => Some(false), + _ => None, + }, + _ => None, + } +} + +fn cell_as_string(v: &Value) -> String { + match v { + Value::Null => String::new(), + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + other => other.to_string(), + } +} + +fn build_record_batch( + rows: &[ExportRow], + schema: &Schema, + data_cols: &[(String, ColKind)], +) -> Result { + let n = rows.len(); + let mut obs_id = StringBuilder::with_capacity(n, n * 36); + let mut form_type = StringBuilder::with_capacity(n, n * 16); + let mut form_version = StringBuilder::with_capacity(n, n * 8); + let mut created_at = StringBuilder::with_capacity(n, n * 24); + let mut updated_at = StringBuilder::with_capacity(n, n * 24); + let mut synced_at = StringBuilder::with_capacity(n, n * 24); + let mut deleted = BooleanBuilder::with_capacity(n); + let mut version = Int64Builder::with_capacity(n); + let mut geolocation = StringBuilder::with_capacity(n, n * 32); + let mut author = StringBuilder::with_capacity(n, n * 16); + let mut device_id = StringBuilder::with_capacity(n, n * 16); + let mut tags = StringBuilder::with_capacity(n, n * 16); + let mut pending = BooleanBuilder::with_capacity(n); + + for row in rows { + obs_id.append_value(&row.observation_id); + form_type.append_value(&row.form_type); + form_version.append_value(&row.form_version); + created_at.append_value(&row.created_at); + updated_at.append_value(&row.updated_at); + append_optional_string(&mut synced_at, &row.synced_at); + deleted.append_value(row.deleted); + version.append_value(row.version); + append_optional_string(&mut geolocation, &row.geolocation); + append_optional_string(&mut author, &row.author); + append_optional_string(&mut device_id, &row.device_id); + append_optional_string(&mut tags, &row.tags); + pending.append_value(row.pending); + } + + let mut columns: Vec = vec![ + Arc::new(obs_id.finish()), + Arc::new(form_type.finish()), + Arc::new(form_version.finish()), + Arc::new(created_at.finish()), + Arc::new(updated_at.finish()), + Arc::new(synced_at.finish()), + Arc::new(deleted.finish()), + Arc::new(version.finish()), + Arc::new(geolocation.finish()), + Arc::new(author.finish()), + Arc::new(device_id.finish()), + Arc::new(tags.finish()), + Arc::new(pending.finish()), + ]; + + for (key, kind) in data_cols { + match kind { + ColKind::Float64 => { + let mut b = Float64Builder::with_capacity(n); + for row in rows { + match row.data.get(key) { + None | Some(Value::Null) => b.append_null(), + Some(v) => match cell_as_f64(v) { + Some(f) => b.append_value(f), + None => b.append_null(), + }, + } + } + columns.push(Arc::new(b.finish())); + } + ColKind::Boolean => { + let mut b = BooleanBuilder::with_capacity(n); + for row in rows { + match row.data.get(key) { + None | Some(Value::Null) => b.append_null(), + Some(v) => match cell_as_bool(v) { + Some(x) => b.append_value(x), + None => b.append_null(), + }, + } + } + columns.push(Arc::new(b.finish())); + } + ColKind::String => { + let mut b = StringBuilder::with_capacity(n, n * 16); + for row in rows { + match row.data.get(key) { + None | Some(Value::Null) => b.append_null(), + Some(v) => b.append_value(cell_as_string(v)), + } + } + columns.push(Arc::new(b.finish())); + } + } + } + + RecordBatch::try_new(Arc::new(schema.clone()), columns) + .map_err(|e| CustodianError::Message(format!("arrow record batch: {e}"))) +} + +fn write_parquet_file(path: &Path, batch: &RecordBatch) -> Result<(), CustodianError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let file = File::create(path)?; + let mut writer = ArrowWriter::try_new(file, batch.schema(), None) + .map_err(|e| CustodianError::Message(format!("parquet writer: {e}")))?; + writer + .write(batch) + .map_err(|e| CustodianError::Message(format!("parquet write: {e}")))?; + writer + .close() + .map_err(|e| CustodianError::Message(format!("parquet close: {e}")))?; + Ok(()) +} + +/// Heuristic attachment basename extraction (mirrors Desktop import heuristic). +pub fn collect_attachment_basenames(value: &Value) -> HashSet { + let mut names = HashSet::new(); + walk_attachment_refs(value, 0, &mut names); + names +} + +fn basename_only(s: &str) -> String { + let t = s.trim().replace('\\', "/"); + t.rsplit('/').next().unwrap_or(&t).to_string() +} + +fn string_looks_like_attachment_ref(s: &str) -> bool { + let t = s.trim(); + if t.is_empty() || t.len() > 512 { + return false; + } + let b = basename_only(t); + if b.is_empty() || b.contains("..") { + return false; + } + let lower = b.to_ascii_lowercase(); + const EXTS: &[&str] = &[ + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".bmp", ".tif", ".tiff", ".pdf", ".mp3", + ".wav", ".m4a", ".aac", ".ogg", ".mp4", ".mov", ".webm", ".avi", ".mkv", ".svg", ".bin", + ".dat", ".zip", + ]; + if EXTS.iter().any(|e| lower.ends_with(e)) { + return true; + } + // UUID-like attachment ids without extension + if b.len() >= 32 + && b.chars() + .all(|c| c.is_ascii_hexdigit() || c == '-' || c == '_') + { + return true; + } + false +} + +fn walk_attachment_refs(v: &Value, depth: usize, out: &mut HashSet) { + if depth > 16 { + return; + } + match v { + Value::Null | Value::Bool(_) | Value::Number(_) => {} + Value::String(s) => { + if string_looks_like_attachment_ref(s) { + let b = basename_only(s); + if !b.is_empty() && !b.contains("..") { + out.insert(b); + } + } + } + Value::Array(arr) => { + for el in arr { + walk_attachment_refs(el, depth + 1, out); + } + } + Value::Object(map) => { + for key in ["attachmentId", "attachment_id", "filename"] { + if let Some(Value::String(s)) = map.get(key) { + let b = basename_only(s); + if !b.is_empty() && !b.contains("..") { + out.insert(b); + } + } + } + for (k, val) in map { + if k == "filename" || k == "attachmentId" || k == "attachment_id" { + continue; + } + walk_attachment_refs(val, depth + 1, out); + } + } + } +} + +fn prepare_export_dir(parent: &Path, overwrite: bool) -> Result { + let leaf = export_leaf_folder_name(); + let dest = parent.join(&leaf); + if dest.exists() { + if !overwrite { + return Err(CustodianError::Message(format!( + "export folder already exists: {}", + dest.display() + ))); + } + if dest.is_dir() { + fs::remove_dir_all(&dest)?; + } else { + fs::remove_file(&dest)?; + } + } + fs::create_dir_all(&dest)?; + Ok(dest) +} + +fn escape_for_double_quoted(path: &str) -> String { + path.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn escape_for_r_string(path: &str) -> String { + path.replace('\\', "/").replace('"', "\\\"") +} + +fn escape_for_stata(path: &str) -> String { + path.replace('\\', "/") +} + +fn write_load_snippets( + export_dir: &Path, + parquet_files: &BTreeMap, +) -> Result<(), CustodianError> { + if parquet_files.is_empty() { + return Ok(()); + } + let snippets_dir = export_dir.join("snippets"); + fs::create_dir_all(&snippets_dir)?; + + let mut entries: Vec<(&String, &String)> = parquet_files.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + + // Deduplicate identifiers if form types collide after sanitize. + let mut used: HashMap = HashMap::new(); + let mut named: Vec<(String, String)> = Vec::new(); + for (form_type, path) in &entries { + let base = sanitize_identifier(form_type); + let n = used.entry(base.clone()).or_insert(0); + *n += 1; + let ident = if *n == 1 { base } else { format!("{base}_{n}") }; + named.push((ident, (*path).clone())); + } + + // R + { + let mut body = + String::from("# ODE Desktop export — load Parquet with arrow\nlibrary(arrow)\n\n"); + for (ident, path) in &named { + body.push_str(&format!( + "{ident} <- read_parquet(\"{}\")\n", + escape_for_r_string(path) + )); + } + fs::write(snippets_dir.join("load_r.R"), body)?; + } + + // Python + { + let mut body = String::from( + "# ODE Desktop export — load Parquet with pandas\nimport pandas as pd\n\n", + ); + for (ident, path) in &named { + body.push_str(&format!( + "{ident} = pd.read_parquet(r\"{}\")\n", + path.replace('"', "") + )); + } + fs::write(snippets_dir.join("load_python.py"), body)?; + } + + // Stata 19+ native import parquet (one frame per form type) + { + let mut body = String::from( + "* ODE Desktop export — Stata 19+ import parquet (one frame per form type)\n\n", + ); + for (ident, path) in &named { + let p = escape_for_stata(path); + let quoted = if p.contains(char::is_whitespace) { + format!("\"{p}\"") + } else { + p + }; + body.push_str(&format!("capture frame drop {ident}\n")); + body.push_str(&format!("frame create {ident}\n")); + body.push_str(&format!( + "frame {ident}: import parquet using {quoted}, clear\n\n" + )); + } + fs::write(snippets_dir.join("load_stata.do"), body)?; + } + + // Julia + { + let mut body = String::from( + "# ODE Desktop export — load Parquet with Parquet2 + DataFrames\nusing Parquet2, DataFrames\n\n", + ); + for (ident, path) in &named { + body.push_str(&format!( + "{ident} = DataFrame(Parquet2.Dataset(\"{}\"))\n", + escape_for_double_quoted(path) + )); + } + fs::write(snippets_dir.join("load_julia.jl"), body)?; + } + + Ok(()) +} + +fn copy_referenced_attachments_with_progress( + workspace: &Path, + export_dir: &Path, + rows: &[ExportRow], + progress: &mut ExportProgressFn<'_>, +) -> Result<(usize, usize, PathBuf), CustodianError> { + let mut names: BTreeSet = BTreeSet::new(); + for row in rows { + let obj: serde_json::Map = row + .data + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + for n in collect_attachment_basenames(&Value::Object(obj)) { + names.insert(n); + } + } + let att_dir = export_dir.join("attachments"); + fs::create_dir_all(&att_dir)?; + let total = names.len().max(1); + let mut copied = 0usize; + let mut missing = 0usize; + for (i, name) in names.into_iter().enumerate() { + progress( + i, + total, + &format!("Copying attachments ({}/{})…", i + 1, total), + ); + match resolve_attachment_path(workspace, &name) { + Some(src) => { + let dest = att_dir.join(&name); + fs::copy(&src, &dest).map_err(CustodianError::Io)?; + copied += 1; + } + None => { + missing += 1; + } + } + } + progress(total, total, "Attachments copy finished"); + Ok((copied, missing, att_dir)) +} + +/// Write a Parquet export from already-loaded rows (DB lock should be released before calling). +pub fn write_parquet_export( + workspace: &Path, + req: &ExportParquetRequest, + rows: Vec, + progress: &mut ExportProgressFn<'_>, +) -> Result { + let parent = PathBuf::from(req.parent_dir.trim()); + if parent.as_os_str().is_empty() { + return Err(CustodianError::Message( + "parent directory is required".to_string(), + )); + } + if !parent.is_dir() { + return Err(CustodianError::Message(format!( + "parent directory does not exist: {}", + parent.display() + ))); + } + + progress(0, 1, "Preparing export folder…"); + let export_dir = prepare_export_dir(&parent, req.overwrite)?; + + let mut by_form: BTreeMap> = BTreeMap::new(); + for row in rows { + by_form.entry(row.form_type.clone()).or_default().push(row); + } + + let form_count = by_form.len().max(1); + let mut form_type_counts: BTreeMap = BTreeMap::new(); + let mut parquet_files: BTreeMap = BTreeMap::new(); + let mut total_rows = 0usize; + let mut used_names: HashMap = HashMap::new(); + + for (idx, (form_type, form_rows)) in by_form.iter().enumerate() { + if form_rows.is_empty() { + continue; + } + progress( + idx, + form_count, + &format!("Writing {form_type}.parquet ({}/{})…", idx + 1, form_count), + ); + let (schema, data_cols) = schema_for_rows(form_rows); + let batch = build_record_batch(form_rows, &schema, &data_cols)?; + let stem = sanitize_filename(form_type); + let count = used_names.entry(stem.clone()).or_insert(0); + *count += 1; + let file_stem = if *count == 1 { + stem + } else { + format!("{stem}_{}", *count) + }; + let path = export_dir.join(format!("{file_stem}.parquet")); + write_parquet_file(&path, &batch)?; + parquet_files.insert(form_type.clone(), path.to_string_lossy().to_string()); + form_type_counts.insert(form_type.clone(), form_rows.len()); + total_rows += form_rows.len(); + } + progress(form_count, form_count, "Parquet files written"); + + let workspace_attachments_path = workspace.join("attachments"); + let mut attachments_copied = 0usize; + let mut attachments_missing = 0usize; + let mut export_attachments_path = None; + + if req.include_attachments { + let flat: Vec = by_form.values().flatten().cloned().collect(); + let (copied, missing, att_dir) = + copy_referenced_attachments_with_progress(workspace, &export_dir, &flat, progress)?; + attachments_copied = copied; + attachments_missing = missing; + export_attachments_path = Some(att_dir.to_string_lossy().to_string()); + } + + progress(0, 1, "Writing load snippets…"); + write_load_snippets(&export_dir, &parquet_files)?; + + let manifest = ExportManifest { + exported_at: Utc::now().to_rfc3339(), + exporter_version: EXPORTER_VERSION.to_string(), + include_pending: req.include_pending, + include_attachments: req.include_attachments, + profile_label: req.profile_label.clone(), + workspace_attachments_path: workspace_attachments_path.to_string_lossy().to_string(), + export_attachments_path: export_attachments_path.clone(), + form_type_counts: form_type_counts.clone(), + total_rows, + attachments_copied, + attachments_missing, + }; + let manifest_path = export_dir.join("export_manifest.json"); + fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?; + + progress(1, 1, "Export complete"); + + let export_dir_out = export_dir + .canonicalize() + .unwrap_or_else(|_| export_dir.clone()); + + Ok(ExportParquetResult { + export_dir: export_dir_out.to_string_lossy().to_string(), + form_type_counts, + parquet_files, + total_rows, + attachments_copied, + attachments_missing, + include_pending: req.include_pending, + include_attachments: req.include_attachments, + workspace_attachments_path: workspace_attachments_path.to_string_lossy().to_string(), + export_attachments_path, + manifest_path: manifest_path.to_string_lossy().to_string(), + }) +} + +/// Preview destination path without writing (`{parent}/{YYYYMMDD}`). +pub fn preview_export_dir(parent_dir: &str) -> Result { + let parent = PathBuf::from(parent_dir.trim()); + if parent.as_os_str().is_empty() { + return Err(CustodianError::Message( + "parent directory is required".to_string(), + )); + } + Ok(parent + .join(export_leaf_folder_name()) + .to_string_lossy() + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::{Connection, params}; + use serde_json::json; + + fn setup_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + r#" + CREATE TABLE observations ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + form_type TEXT, + updated_at TEXT, + remote_updated_at TEXT, + dirty INTEGER NOT NULL DEFAULT 0, + sync_status TEXT NOT NULL DEFAULT 'clean', + conflict_payload TEXT, + last_saved_at TEXT NOT NULL, + last_pushed_at TEXT, + observation_extras TEXT + ); + "#, + ) + .unwrap(); + conn + } + + #[test] + fn sanitize_filename_replaces_unsafe_chars() { + assert_eq!(sanitize_filename("hh/person"), "hh_person"); + assert_eq!(sanitize_filename(""), "unknown"); + } + + #[test] + fn infer_col_kind_prefers_string_on_mixed() { + let a = json!(1); + let b = json!(true); + assert_eq!(infer_col_kind(&[&a, &b]), ColKind::String); + let c = json!(1.5); + let d = json!(2); + assert_eq!(infer_col_kind(&[&c, &d]), ColKind::Float64); + let e = json!(true); + let f = json!(false); + assert_eq!(infer_col_kind(&[&e, &f]), ColKind::Boolean); + } + + #[test] + fn flatten_and_write_parquet_roundtrip_shape() { + let conn = setup_db(); + conn.execute( + "INSERT INTO observations (id, payload, form_type, updated_at, dirty, sync_status, last_saved_at, observation_extras) + VALUES (?1, ?2, ?3, ?4, 0, 'clean', ?4, ?5)", + params![ + "obs-1", + json!({"age": 12, "name": "Ada", "meta": {"x": 1}}).to_string(), + "person", + "2026-01-01T00:00:00Z", + json!({"formVersion": "1", "createdAt": "2026-01-01T00:00:00Z", "author": "a"}).to_string(), + ], + ) + .unwrap(); + // pending row excluded by default + conn.execute( + "INSERT INTO observations (id, payload, form_type, updated_at, dirty, sync_status, last_saved_at) + VALUES ('obs-pending', '{}', 'person', '2026-01-02T00:00:00Z', 1, 'dirty', '2026-01-02T00:00:00Z')", + [], + ) + .unwrap(); + // conflict excluded + conn.execute( + "INSERT INTO observations (id, payload, form_type, updated_at, dirty, sync_status, last_saved_at) + VALUES ('obs-conflict', '{}', 'person', '2026-01-02T00:00:00Z', 1, 'conflict', '2026-01-02T00:00:00Z')", + [], + ) + .unwrap(); + + let base = std::env::temp_dir().join(format!( + "ode_export_test_{}_{}", + std::process::id(), + Utc::now().timestamp_millis() + )); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let ws = base.join("workspace"); + fs::create_dir_all(ws.join("attachments")).unwrap(); + let parent = base.join("out"); + fs::create_dir_all(&parent).unwrap(); + + let rows = load_export_rows(&conn, false).unwrap(); + let result = write_parquet_export( + &ws, + &ExportParquetRequest { + parent_dir: parent.to_string_lossy().to_string(), + include_pending: false, + include_attachments: false, + overwrite: true, + profile_label: Some("test".into()), + }, + rows, + &mut |_done, _total, _msg| {}, + ) + .unwrap(); + + assert_eq!(result.total_rows, 1); + assert_eq!(result.form_type_counts.get("person"), Some(&1)); + assert!(result.parquet_files.contains_key("person")); + let parquet_path = PathBuf::from(result.parquet_files.get("person").unwrap()); + assert!(parquet_path.is_file()); + assert!(PathBuf::from(&result.manifest_path).is_file()); + assert!( + PathBuf::from(&result.export_dir) + .join("snippets") + .join("load_r.R") + .is_file() + ); + + let rows = load_export_rows(&conn, true).unwrap(); + assert_eq!(rows.len(), 2); // clean + pending + assert!(rows.iter().any(|r| r.pending)); + + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn collect_attachment_basenames_from_objects() { + let v = json!({ + "photo": { "filename": "a.jpg", "attachmentId": "uuid-1" }, + "note": "hello", + "nested": [{ "filename": "b.png" }] + }); + let names = collect_attachment_basenames(&v); + assert!(names.contains("a.jpg")); + assert!(names.contains("uuid-1")); + assert!(names.contains("b.png")); + assert!(!names.contains("hello")); + } + + #[test] + fn sanitize_identifier_for_languages() { + assert_eq!(sanitize_identifier("censo_milda"), "censo_milda"); + assert_eq!(sanitize_identifier("hh-person"), "hh_person"); + assert_eq!(sanitize_identifier("2bad"), "f_2bad"); + } + + #[test] + fn schema_includes_pending_and_data_prefix() { + let rows = vec![ExportRow { + observation_id: "1".into(), + form_type: "f".into(), + form_version: "1".into(), + created_at: "t".into(), + updated_at: "t".into(), + synced_at: None, + deleted: false, + version: 0, + geolocation: None, + author: None, + device_id: None, + tags: None, + pending: false, + data: BTreeMap::from([("age".into(), json!(3))]), + }]; + let (schema, cols) = schema_for_rows(&rows); + let names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect(); + assert!(names.contains(&"pending".to_string())); + assert!(names.contains(&"data_age".to_string())); + assert_eq!(cols[0].1, ColKind::Float64); + } +} diff --git a/desktop/src-tauri/src/import_validate/attachments.rs b/desktop/src-tauri/src/import_validate/attachments.rs new file mode 100644 index 000000000..d197acc5c --- /dev/null +++ b/desktop/src-tauri/src/import_validate/attachments.rs @@ -0,0 +1,483 @@ +//! Attachment basename extraction for import validation (TS parity). + +use std::collections::HashSet; + +use serde_json::Value; + +const ATTACHMENT_SCHEMA_FORMATS: &[&str] = &["photo", "select_file", "signature", "audio", "video"]; + +const ATTACHMENT_BASENAME_EXT: &[&str] = &[ + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".tif", ".tiff", ".pdf", ".doc", + ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".csv", ".txt", ".mp3", ".mp4", ".m4a", ".wav", + ".aac", ".flac", ".webm", ".mov", ".mkv", ".svg", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SchemaPathSegment { + Key(String), + Each, +} + +fn is_object(v: &Value) -> bool { + v.is_object() +} + +fn resolve_json_pointer<'a>(root: &'a Value, ref_str: &str) -> Option<&'a Value> { + if !ref_str.starts_with("#/") { + return None; + } + let mut cur = root; + for raw in ref_str[2..].split('/') { + let p = raw.replace("~1", "/").replace("~0", "~"); + cur = cur.as_object()?.get(&p)?; + } + Some(cur) +} + +fn has_attachment_format(schema: &Value) -> bool { + schema + .get("format") + .and_then(|f| f.as_str()) + .is_some_and(|fmt| ATTACHMENT_SCHEMA_FORMATS.contains(&fmt)) +} + +fn path_sig(path: &[SchemaPathSegment]) -> String { + path.iter() + .map(|s| match s { + SchemaPathSegment::Key(k) => k.as_str(), + SchemaPathSegment::Each => "*", + }) + .collect::>() + .join("\0") +} + +/// Walk JSON Schema (draft-07 style) and collect property paths with attachment formats. +fn collect_attachment_paths_from_schema(schema_root: &Value) -> Vec> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + let mut stack: HashSet<*const Value> = HashSet::new(); + + fn visit( + schema_root: &Value, + schema: &Value, + path_prefix: &[SchemaPathSegment], + stack: &mut HashSet<*const Value>, + seen: &mut HashSet, + out: &mut Vec>, + ) { + if !schema.is_object() { + return; + } + let ptr = schema as *const Value; + if !stack.insert(ptr) { + return; + } + + if let Some(Value::String(r)) = schema.get("$ref") { + if let Some(resolved) = resolve_json_pointer(schema_root, r) { + visit(schema_root, resolved, path_prefix, stack, seen, out); + } + stack.remove(&ptr); + return; + } + + for combiner in ["allOf", "anyOf", "oneOf"] { + if let Some(Value::Array(arr)) = schema.get(combiner) { + for branch in arr { + visit(schema_root, branch, path_prefix, stack, seen, out); + } + } + } + if let Some(then_schema) = schema.get("then") + && then_schema.is_object() + { + visit(schema_root, then_schema, path_prefix, stack, seen, out); + } + if let Some(else_schema) = schema.get("else") + && else_schema.is_object() + { + visit(schema_root, else_schema, path_prefix, stack, seen, out); + } + + if has_attachment_format(schema) && !path_prefix.is_empty() { + let sig = path_sig(path_prefix); + if seen.insert(sig) { + out.push(path_prefix.to_vec()); + } + stack.remove(&ptr); + return; + } + + if let Some(Value::Object(props)) = schema.get("properties") { + for (key, sub) in props { + if !sub.is_object() { + continue; + } + let mut next_path = path_prefix.to_vec(); + next_path.push(SchemaPathSegment::Key(key.clone())); + + if has_attachment_format(sub) { + let sig = path_sig(&next_path); + if seen.insert(sig) { + out.push(next_path); + } + } else { + let is_array = match sub.get("type") { + Some(Value::String(t)) => t == "array", + Some(Value::Array(arr)) => arr.iter().any(|x| x.as_str() == Some("array")), + _ => false, + }; + if is_array { + if let Some(items) = sub.get("items") + && items.is_object() + { + if has_attachment_format(items) { + let mut segs = next_path; + segs.push(SchemaPathSegment::Each); + let sig = path_sig(&segs); + if seen.insert(sig) { + out.push(segs); + } + } else { + let mut segs = next_path; + segs.push(SchemaPathSegment::Each); + visit(schema_root, items, &segs, stack, seen, out); + } + } + } else { + visit(schema_root, sub, &next_path, stack, seen, out); + } + } + } + } + + match schema.get("additionalProperties") { + Some(Value::Bool(true)) => { + visit( + schema_root, + &Value::Object(Default::default()), + path_prefix, + stack, + seen, + out, + ); + } + Some(addl) if addl.is_object() => { + visit(schema_root, addl, path_prefix, stack, seen, out); + } + _ => {} + } + + stack.remove(&ptr); + } + + visit( + schema_root, + schema_root, + &[], + &mut stack, + &mut seen, + &mut out, + ); + out +} + +fn basename_only(raw: &str) -> String { + let t = raw.trim().replace('\\', "/"); + t.rsplit('/').next().unwrap_or("").trim().to_string() +} + +fn has_attachment_ext(basename: &str) -> bool { + let lower = basename.to_lowercase(); + ATTACHMENT_BASENAME_EXT + .iter() + .any(|ext| lower.ends_with(ext)) +} + +fn string_looks_like_attachment_ref(raw: &str) -> bool { + let t = raw.trim(); + if t.is_empty() || t == "*" { + return false; + } + if regex_is_mime(t) { + return false; + } + let b = basename_only(t); + if b.is_empty() || b == "*" || b.contains("..") { + return false; + } + if has_attachment_ext(&b) { + return true; + } + if regex_is_uuid(&b) { + return true; + } + if b.len() >= 12 + && b.chars().any(|c| c == '/' || c == '_' || c == '-') + && t.replace('\\', "/") + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-')) + { + return true; + } + false +} + +fn regex_is_mime(t: &str) -> bool { + // image/jpeg etc. + let bytes = t.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() { + return false; + } + let Some(slash) = t.find('/') else { + return false; + }; + if slash == 0 || slash + 1 >= t.len() { + return false; + } + t[..slash] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-')) + && t[slash + 1..] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '/')) +} + +fn regex_is_uuid(b: &str) -> bool { + if b.len() != 36 { + return false; + } + let bytes = b.as_bytes(); + for (i, ch) in bytes.iter().enumerate() { + match i { + 8 | 13 | 18 | 23 => { + if *ch != b'-' { + return false; + } + } + _ => { + if !ch.is_ascii_hexdigit() { + return false; + } + } + } + } + true +} + +fn extract_attachment_names_from_field_value(value: &Value) -> Vec { + let mut names = Vec::new(); + + fn walk(v: &Value, depth: usize, names: &mut Vec) { + if depth > 16 { + return; + } + match v { + Value::Null => {} + Value::String(s) => { + if string_looks_like_attachment_ref(s) { + let b = basename_only(s); + if !b.is_empty() && !b.contains("..") { + names.push(b); + } + } + } + Value::Array(arr) => { + for el in arr { + walk(el, depth + 1, names); + } + } + Value::Object(obj) => { + let id = obj + .get("attachmentId") + .and_then(|x| x.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .or_else(|| { + obj.get("attachment_id") + .and_then(|x| x.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + }); + if let Some(id) = id { + names.push(basename_only(id)); + } + if let Some(fn_) = obj.get("filename").and_then(|x| x.as_str()) + && !fn_.trim().is_empty() + { + let b = basename_only(fn_); + if !b.is_empty() && !b.contains("..") { + names.push(b); + } + } + for (k, val) in obj { + if k == "filename" || k == "attachmentId" || k == "attachment_id" { + continue; + } + walk(val, depth + 1, names); + } + } + _ => {} + } + } + + walk(value, 0, &mut names); + let mut uniq = HashSet::new(); + names + .into_iter() + .filter(|n| !n.is_empty() && uniq.insert(n.clone())) + .collect() +} + +fn values_at_schema_paths(data: &Value, paths: &[Vec]) -> Vec { + let mut values = Vec::new(); + + fn follow( + current: &Value, + segments: &[SchemaPathSegment], + idx: usize, + values: &mut Vec, + ) { + if idx >= segments.len() { + values.push(current.clone()); + return; + } + match &segments[idx] { + SchemaPathSegment::Each => { + if let Value::Array(arr) = current { + for el in arr { + follow(el, segments, idx + 1, values); + } + } + } + SchemaPathSegment::Key(key) => { + if let Value::Object(obj) = current + && let Some(next) = obj.get(key) + { + follow(next, segments, idx + 1, values); + } + } + } + } + + if !is_object(data) { + return values; + } + for p in paths { + follow(data, p, 0, &mut values); + } + values +} + +pub fn referenced_attachment_names_from_schema_and_data( + form_schema: &Value, + data: &Value, +) -> HashSet { + let paths = collect_attachment_paths_from_schema(form_schema); + let mut names = HashSet::new(); + for v in values_at_schema_paths(data, &paths) { + for n in extract_attachment_names_from_field_value(&v) { + names.insert(n); + } + } + names +} + +pub fn referenced_attachment_names_heuristic(data: &Value) -> HashSet { + let mut names = HashSet::new(); + + fn walk(v: &Value, depth: usize, names: &mut HashSet) { + if depth > 14 { + return; + } + match v { + Value::Array(arr) => { + for el in arr { + walk(el, depth + 1, names); + } + } + Value::Object(obj) => { + for (k, val) in obj { + let kl = k.to_lowercase(); + if (k == "attachmentId" || kl == "attachment_id") + && let Some(s) = val.as_str() + && !s.trim().is_empty() + { + names.insert(basename_only(s)); + } + if kl == "attachments" + && let Value::Array(arr) = val + { + for el in arr { + if let Value::Object(el_obj) = el { + let id = el_obj + .get("attachmentId") + .and_then(|x| x.as_str()) + .or_else(|| { + el_obj.get("attachment_id").and_then(|x| x.as_str()) + }) + .or_else(|| el_obj.get("id").and_then(|x| x.as_str())); + if let Some(id) = id + && !id.trim().is_empty() + { + names.insert(basename_only(id)); + } + if let Some(fn_) = el_obj.get("filename").and_then(|x| x.as_str()) + && !fn_.trim().is_empty() + { + names.insert(basename_only(fn_)); + } + } + } + } + walk(val, depth + 1, names); + } + } + _ => {} + } + } + + walk(data, 0, &mut names); + names +} + +/// Schema paths + heuristic (same union as TS `referencedNamesForObservation`). +pub fn referenced_attachment_names_for_observation( + form_schema: Option<&Value>, + data: &Value, +) -> HashSet { + let mut names = HashSet::new(); + if let Some(schema) = form_schema { + names.extend(referenced_attachment_names_from_schema_and_data( + schema, data, + )); + } + names.extend(referenced_attachment_names_heuristic(data)); + names +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn schema_photo_path_extracts_filename() { + let schema = json!({ + "type": "object", + "properties": { + "pic": { "type": "object", "format": "photo" } + } + }); + let data = json!({ "pic": { "filename": "a.jpg" } }); + let names = referenced_attachment_names_from_schema_and_data(&schema, &data); + assert!(names.contains("a.jpg")); + } + + #[test] + fn heuristic_finds_attachment_id() { + let data = json!({ "x": { "attachment_id": "uuid-here-12" } }); + let names = referenced_attachment_names_heuristic(&data); + assert!(names.contains("uuid-here-12")); + } +} diff --git a/desktop/src-tauri/src/import_validate/mod.rs b/desktop/src-tauri/src/import_validate/mod.rs new file mode 100644 index 000000000..d25fdc736 --- /dev/null +++ b/desktop/src-tauri/src/import_validate/mod.rs @@ -0,0 +1,532 @@ +//! Parallel import parse + JSON Schema validation + attachment reference checks. +//! +//! Mirrors Desktop TS `importValidation.ts` / `attachmentReferenceExtraction.ts` closely enough +//! for import preflight (AJV with `strict: false` and `validateFormats: false`). + +mod attachments; + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::Arc; + +use jsonschema::{Draft, Validator}; +use rayon::prelude::*; +use serde::Serialize; +use serde_json::Value; + +use crate::{ApiObservation, ParsedImportFileResult, parse_import_json_file}; + +pub use attachments::referenced_attachment_names_for_observation; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportIssue { + pub severity: &'static str, + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub observation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub form_type: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportValidateBatchResult { + pub files: Vec, + pub issues: Vec, + pub observation_count: usize, + pub form_type_count: usize, + pub referenced_attachment_names: Vec, + pub missing_attachment_names: Vec, + pub orphan_attachment_names: Vec, +} + +enum SchemaCompileState { + Ok(Arc), + CompileError(String), + Missing, +} + +/// Build reusable validators once per form type (AJV-like: formats not enforced). +fn compile_validators( + form_types: &HashSet, + schemas_by_form_type: &HashMap, +) -> HashMap { + let mut out = HashMap::new(); + for ft in form_types { + match schemas_by_form_type.get(ft) { + None => { + out.insert(ft.clone(), SchemaCompileState::Missing); + } + Some(schema) => match build_validator(schema) { + Ok(v) => { + out.insert(ft.clone(), SchemaCompileState::Ok(Arc::new(v))); + } + Err(e) => { + out.insert(ft.clone(), SchemaCompileState::CompileError(e)); + } + }, + } + } + out +} + +fn build_validator(schema: &Value) -> Result { + jsonschema::options() + .with_draft(Draft::Draft7) + .should_validate_formats(false) + .build(schema) + .map_err(|e| e.to_string()) +} + +fn normalize_basename(s: &str) -> String { + s.trim().to_lowercase() +} + +fn schema_error_message(instance_path: &str, error: &str) -> String { + let path = if instance_path.is_empty() { + "(root)" + } else { + instance_path + }; + format!("{path}: {error}") +} + +struct FileValidateOutcome { + file: ParsedImportFileResult, + issues: Vec, + referenced: HashSet, + form_types: HashSet, + observation_count: usize, +} + +fn push_observation_issues( + file_name: &str, + obs: &ApiObservation, + validators: &HashMap, + schemas_by_form_type: &HashMap, + issues: &mut Vec, + referenced: &mut HashSet, + form_types: &mut HashSet, +) { + let ft = obs + .form_type + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let schema = ft + .as_ref() + .and_then(|t| schemas_by_form_type.get(t.as_str())); + + match &ft { + None => { + issues.push(ImportIssue { + severity: "warning", + code: "missing_form_type".to_string(), + message: format!( + "Observation {} has no formType; schema validation was skipped (attachment checks use heuristics only).", + obs.observation_id + ), + file_name: Some(file_name.to_string()), + observation_id: Some(obs.observation_id.clone()), + form_type: None, + }); + } + Some(form_type) => { + form_types.insert(form_type.clone()); + match validators.get(form_type) { + Some(SchemaCompileState::Missing) | None => {} + Some(SchemaCompileState::CompileError(_)) => {} + Some(SchemaCompileState::Ok(validator)) => { + for error in validator.iter_errors(&obs.data) { + let path = error.instance_path.to_string(); + issues.push(ImportIssue { + severity: "error", + code: "schema_validation".to_string(), + message: format!( + "{}: {}", + obs.observation_id, + schema_error_message(&path, &error.to_string()) + ), + file_name: Some(file_name.to_string()), + observation_id: Some(obs.observation_id.clone()), + form_type: Some(form_type.clone()), + }); + } + } + } + } + } + + for name in referenced_attachment_names_for_observation(schema, &obs.data) { + referenced.insert(name); + } +} + +/// Parse + validate import JSON paths in parallel against preloaded form schemas. +pub fn parse_and_validate_paths( + paths: Vec, + schemas_by_form_type: &HashMap, + staged_attachment_basenames: &[String], +) -> ImportValidateBatchResult { + if paths.is_empty() { + return ImportValidateBatchResult { + files: Vec::new(), + issues: Vec::new(), + observation_count: 0, + form_type_count: 0, + referenced_attachment_names: Vec::new(), + missing_attachment_names: Vec::new(), + orphan_attachment_names: Vec::new(), + }; + } + + // Pass 1: parallel parse (ordered). + let mut parsed_index: Vec<(usize, ParsedImportFileResult)> = paths + .par_iter() + .enumerate() + .map(|(i, raw)| { + let p = Path::new(raw.trim()); + (i, parse_import_json_file(p)) + }) + .collect(); + parsed_index.sort_by_key(|(i, _)| *i); + + let mut form_types = HashSet::new(); + for (_, file) in &parsed_index { + if file.error.is_some() { + continue; + } + for obs in &file.observations { + if let Some(ft) = obs + .form_type + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + form_types.insert(ft.to_string()); + } + } + } + + let validators = compile_validators(&form_types, schemas_by_form_type); + + // Pass 2: parallel schema + attachment checks on already-parsed files. + let mut outcomes: Vec<(usize, FileValidateOutcome)> = parsed_index + .into_par_iter() + .map(|(i, file)| { + let mut issues = Vec::new(); + let mut referenced = HashSet::new(); + let mut file_form_types = HashSet::new(); + let mut observation_count = 0usize; + + if let Some(err) = &file.error { + issues.push(ImportIssue { + severity: "error", + code: "parse_file".to_string(), + message: format!("{}: {err}", file.file_name), + file_name: Some(file.file_name.clone()), + observation_id: None, + form_type: None, + }); + return ( + i, + FileValidateOutcome { + file, + issues, + referenced, + form_types: file_form_types, + observation_count, + }, + ); + } + + for obs in &file.observations { + observation_count += 1; + push_observation_issues( + &file.file_name, + obs, + &validators, + schemas_by_form_type, + &mut issues, + &mut referenced, + &mut file_form_types, + ); + } + + ( + i, + FileValidateOutcome { + file, + issues, + referenced, + form_types: file_form_types, + observation_count, + }, + ) + }) + .collect(); + outcomes.sort_by_key(|(i, _)| *i); + + let mut files = Vec::with_capacity(outcomes.len()); + let mut issues = Vec::new(); + let mut all_referenced = HashSet::new(); + let mut all_form_types = HashSet::new(); + let mut observation_count = 0usize; + + for (_, outcome) in outcomes { + observation_count += outcome.observation_count; + all_form_types.extend(outcome.form_types); + all_referenced.extend(outcome.referenced); + issues.extend(outcome.issues); + files.push(outcome.file); + } + + let mut missing_schema_types: Vec = all_form_types + .iter() + .filter(|ft| { + matches!( + validators.get(*ft), + Some(SchemaCompileState::Missing) | None + ) + }) + .cloned() + .collect(); + missing_schema_types.sort(); + for ft in missing_schema_types { + issues.push(ImportIssue { + severity: "error", + code: "missing_form_schema".to_string(), + message: format!("No form schema in the active app bundle for form type \"{ft}\"."), + file_name: None, + observation_id: None, + form_type: Some(ft), + }); + } + + let mut compile_error_types: Vec<(String, String)> = Vec::new(); + for ft in &all_form_types { + if let Some(SchemaCompileState::CompileError(msg)) = validators.get(ft) { + compile_error_types.push((ft.clone(), msg.clone())); + } + } + compile_error_types.sort_by(|a, b| a.0.cmp(&b.0)); + for (ft, msg) in compile_error_types { + issues.push(ImportIssue { + severity: "error", + code: "invalid_form_schema".to_string(), + message: format!("Could not compile JSON Schema for form type \"{ft}\": {msg}"), + file_name: None, + observation_id: None, + form_type: Some(ft), + }); + } + + let staged_norm: HashMap = { + let mut m = HashMap::new(); + for b in staged_attachment_basenames { + let k = normalize_basename(b); + if !k.is_empty() { + m.entry(k).or_insert_with(|| b.clone()); + } + } + m + }; + + let mut referenced_list: Vec = all_referenced.into_iter().collect(); + referenced_list.sort(); + + let mut missing = Vec::new(); + for ref_name in &referenced_list { + let kn = normalize_basename(ref_name); + if !kn.is_empty() && !staged_norm.contains_key(&kn) { + missing.push(ref_name.clone()); + } + } + missing.sort(); + + let referenced_norm: HashSet = referenced_list + .iter() + .map(|r| normalize_basename(r)) + .filter(|s| !s.is_empty()) + .collect(); + + let mut orphan: Vec = staged_norm + .iter() + .filter(|(norm, _)| !referenced_norm.contains(*norm)) + .map(|(_, display)| display.clone()) + .collect(); + orphan.sort(); + + for m in &missing { + issues.push(ImportIssue { + severity: "error", + code: "missing_attachment".to_string(), + message: format!("Referenced attachment \"{m}\" is not in the staged attachment list."), + file_name: None, + observation_id: None, + form_type: None, + }); + } + for o in &orphan { + issues.push(ImportIssue { + severity: "warning", + code: "orphan_attachment".to_string(), + message: format!( + "Staged attachment \"{o}\" is not referenced by any staged observation payload." + ), + file_name: None, + observation_id: None, + form_type: None, + }); + } + + ImportValidateBatchResult { + files, + issues, + observation_count, + form_type_count: all_form_types.len(), + referenced_attachment_names: referenced_list, + missing_attachment_names: missing, + orphan_attachment_names: orphan, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::fs; + + fn write_obs(dir: &std::path::Path, name: &str, body: &str) -> String { + let path = dir.join(name); + fs::write(&path, body).unwrap(); + path.to_string_lossy().to_string() + } + + #[test] + fn schema_validation_flags_type_mismatch() { + let mut schemas = HashMap::new(); + schemas.insert( + "PhotoForm".to_string(), + json!({ + "type": "object", + "properties": { + "pic": { "type": "object", "format": "photo" } + } + }), + ); + + let base = + std::env::temp_dir().join(format!("ode_import_validate_schema_{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let path = write_obs( + &base, + "a.json", + r#"{ + "observationId": "o1", + "formType": "PhotoForm", + "updatedAt": "2026-01-01T00:00:00Z", + "data": { "pic": "not-an-object" } + }"#, + ); + + let result = parse_and_validate_paths(vec![path], &schemas, &[]); + assert!( + result.issues.iter().any(|i| i.code == "schema_validation"), + "issues: {:?}", + result.issues + ); + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn photo_format_does_not_fail_when_formats_disabled() { + let mut schemas = HashMap::new(); + schemas.insert( + "PhotoForm".to_string(), + json!({ + "type": "object", + "properties": { + "pic": { "type": "object", "format": "photo" } + } + }), + ); + + let base = + std::env::temp_dir().join(format!("ode_import_validate_format_{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let path = write_obs( + &base, + "a.json", + r#"{ + "observationId": "o1", + "formType": "PhotoForm", + "updatedAt": "2026-01-01T00:00:00Z", + "data": { "pic": { "filename": "used.jpg" } } + }"#, + ); + + let result = parse_and_validate_paths(vec![path], &schemas, &["used.jpg".to_string()]); + assert!( + !result.issues.iter().any(|i| i.code == "schema_validation"), + "issues: {:?}", + result.issues + ); + assert!( + result + .referenced_attachment_names + .contains(&"used.jpg".to_string()) + ); + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn missing_and_orphan_attachments() { + let mut schemas = HashMap::new(); + schemas.insert( + "PhotoForm".to_string(), + json!({ + "type": "object", + "properties": { + "pic": { "type": "object", "format": "photo" } + } + }), + ); + + let base = + std::env::temp_dir().join(format!("ode_import_validate_att_{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let path = write_obs( + &base, + "a.json", + r#"{ + "observationId": "o1", + "formType": "PhotoForm", + "updatedAt": "2026-01-01T00:00:00Z", + "data": { "pic": { "filename": "missing.jpg" } } + }"#, + ); + + let result = parse_and_validate_paths(vec![path], &schemas, &["orphan.jpg".to_string()]); + assert!( + result + .missing_attachment_names + .contains(&"missing.jpg".to_string()) + ); + assert!( + result + .orphan_attachment_names + .contains(&"orphan.jpg".to_string()) + ); + let _ = fs::remove_dir_all(&base); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ce155a0db..2ad027fa4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -28,12 +28,14 @@ use zip::read::ZipArchive; use zip::write::SimpleFileOptions; use zip::{CompressionMethod, ZipWriter}; +mod data_export; +mod import_validate; mod observation_index; mod observation_query; mod sync_engine; #[derive(Debug, Error)] -enum CustodianError { +pub(crate) enum CustodianError { #[error("workspace is not configured")] WorkspaceMissing, #[error("path is outside workspace")] @@ -127,6 +129,15 @@ struct ServerProfile { custom_app_developer_mode: bool, #[serde(default)] custom_app_local_folder: Option, + /// Parent folder last chosen on the Export page (survives restart). + #[serde(default, skip_serializing_if = "Option::is_none")] + export_destination_parent: Option, + /// ISO timestamp of the last successful Parquet export for this profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + last_export_at: Option, + /// Summary of the last successful export (folder, counts, parquet paths). + #[serde(default, skip_serializing_if = "Option::is_none")] + last_export: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -294,7 +305,7 @@ fn migrate_attachments_flat_to_synced_layout(workspace: &Path) -> Result<(), Cus } /// Local resolution: draft → outbound queue (`pending` before `synced`) → loose under `attachments/`. -fn resolve_attachment_path(workspace: &Path, basename: &str) -> Option { +pub(crate) fn resolve_attachment_path(workspace: &Path, basename: &str) -> Option { let root = attachments_root(workspace); let candidates = [ root.join(ATTACH_SUBDIR_DRAFT).join(basename), @@ -535,7 +546,7 @@ struct WorkspaceItem { /// Row columns hold `observation_id`, `form_type`, `data` (payload), and `updated_at`; the rest live here. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] -struct ObservationExtras { +pub(crate) struct ObservationExtras { #[serde(default, skip_serializing_if = "Option::is_none")] form_version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -601,13 +612,13 @@ struct SaveObservationRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct ApiObservation { - observation_id: String, - data: Value, - form_type: Option, - updated_at: Option, +pub(crate) struct ApiObservation { + pub(crate) observation_id: String, + pub(crate) data: Value, + pub(crate) form_type: Option, + pub(crate) updated_at: Option, #[serde(default)] - extras: Option, + pub(crate) extras: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -838,6 +849,9 @@ fn default_app_config(data_dir: &Path) -> AppConfigFile { default_app_mode: DefaultAppMode::default(), custom_app_developer_mode: false, custom_app_local_folder: None, + export_destination_parent: None, + last_export_at: None, + last_export: None, }], } } @@ -860,6 +874,9 @@ fn migrate_legacy_workspace(workspace_path: &str, _data_dir: &Path) -> AppConfig default_app_mode: DefaultAppMode::default(), custom_app_developer_mode: false, custom_app_local_folder: None, + export_destination_parent: None, + last_export_at: None, + last_export: None, }], } } @@ -934,6 +951,24 @@ fn open_db(ctx: &AppCtxHandle) -> Result, CustodianError> { }) } +/// Open SQLite for long-running background work (full index rebuild) without +/// holding `workspace_sqlite_lock` for the entire job. Holding that mutex for a +/// multi-minute rebuild blocks every other DB command (import refresh, list, +/// health) and makes the UI look stuck on the last "Writing observations…" step. +/// +/// Concurrent short writers still take the mutex; this connection uses a busy +/// timeout so rebuild waits on them instead of starving the UI. +fn open_db_for_background_index_rebuild(ctx: &AppCtxHandle) -> Result { + let db_path = resolve_db_path(ctx)?; + if let Some(parent) = db_path.parent() { + fs::create_dir_all(parent)?; + } + let conn = Connection::open(&db_path)?; + conn.busy_timeout(std::time::Duration::from_secs(120))?; + init_db(&conn)?; + Ok(conn) +} + /// Caller must hold `workspace_sqlite_lock`. Opens the DB, checkpoints WAL, then closes. fn quiesce_sqlite_unlocked(ctx: &AppCtxHandle) -> Result<(), CustodianError> { let db_path = resolve_db_path(ctx)?; @@ -1480,33 +1515,51 @@ async fn download_synkronus_app_bundle_zip( Ok(buf) } -const DEV_MIRROR_BUNDLE_TOP_DIRS: [&str; 3] = ["app", "forms", "renderers"]; const SHARED_CHOICE_REF_PREFIX: &str = "forms/shared-choice-defs.schema.json#/$defs/"; -fn publish_form_bundle_rel_path(rel: &str) -> bool { +/// Normalize a path relative to the bundle root to forward-slash zip entry names. +fn zip_entry_path_normalized(rel: &Path) -> String { + rel.components() + .filter_map(|c| match c { + std::path::Component::Normal(s) => Some(s.to_string_lossy()), + _ => None, + }) + .collect::>() + .join("/") +} + +fn publish_path_has_skipped_segment(rel: &str) -> bool { + rel.split('/').any(|s| { + matches!( + s, + ".DS_Store" | "Thumbs.db" | "desktop.ini" | ".git" | "node_modules" + ) + }) +} + +/// Top-level sibling `forms/{form}/{schema,ui}.json` only (Synkronus is strict here). +fn publish_top_level_form_rel_path(rel: &str) -> bool { let parts: Vec<&str> = rel.split('/').collect(); - if parts.len() == 3 && parts[0] == "forms" { - return parts[2] == "schema.json" || parts[2] == "ui.json"; - } - if parts.len() == 4 && parts[0] == "app" && parts[1] == "forms" { - return parts[3] == "schema.json" || parts[3] == "ui.json"; - } - false + parts.len() == 3 && parts[0] == "forms" && (parts[2] == "schema.json" || parts[2] == "ui.json") } -/// Synkronus only accepts `forms/{form}/{schema,ui}.json` (and the `app/forms/…` variant). -fn publish_bundle_zip_entry_allowed(rel: &str) -> bool { - if rel.is_empty() { +/// Whether a relative zip entry may be published. +/// +/// - Full `app/` trees are allowed (including `app/forms/ext.json` and other nested artifacts). +/// - Top-level `forms/` is only for the legacy sibling layout and is schema/ui-only. +/// - When `omit_top_level_forms` is true (nested `app/forms/` present), skip top-level `forms/`. +fn publish_bundle_zip_entry_allowed(rel: &str, omit_top_level_forms: bool) -> bool { + if rel.is_empty() || publish_path_has_skipped_segment(rel) { return false; } if rel.starts_with("forms/") { - return publish_form_bundle_rel_path(rel); - } - if rel.starts_with("app/forms/") { - return publish_form_bundle_rel_path(rel); + if omit_top_level_forms { + return false; + } + return publish_top_level_form_rel_path(rel); } let top = rel.split('/').next().unwrap_or(""); - DEV_MIRROR_BUNDLE_TOP_DIRS.contains(&top) + matches!(top, "app" | "renderers") } fn forms_root_for_publish_schema(dev_local: &Path, rel: &str) -> Option { @@ -1659,7 +1712,30 @@ fn read_publish_schema_bytes( Ok(out) } +/// Strip the in-place host stub that [`CustomAppEmbed`] writes into +/// `bundles/dev-local/app/index.html` for the Desktop iframe (`asset://` base + +/// localhost `formulus-injection.js`). That block must never ship to Synkronus — +/// Formulus / mobile WebViews cannot resolve those URLs and render a blank screen. +fn strip_ode_desktop_injection(html: &str) -> String { + const START: &str = ""; + const END: &str = ""; + let Some(start) = html.find(START) else { + return html.to_string(); + }; + let Some(end_rel) = html[start..].find(END) else { + return html.to_string(); + }; + let end = start + end_rel + END.len(); + let mut out = String::with_capacity(html.len() - (end - start)); + out.push_str(&html[..start]); + out.push_str(&html[end..]); + out +} + /// Zips `bundles/dev-local/` into a temp file with Synkronus-compatible paths (`app/`, `forms/`, …). +/// +/// Prefer app-only layout when `app/forms/` exists (omit duplicate top-level `forms/`). +/// `app/index.html` is sanitized so ODE Desktop embed injection is never published. fn zip_dev_mirror_bundle(ws: &Path) -> Result { let dev_local = ws.join("bundles/dev-local"); let index = dev_local.join("app/index.html"); @@ -1668,6 +1744,7 @@ fn zip_dev_mirror_bundle(ws: &Path) -> Result { "developer mirror missing app/index.html — use Refresh app first".to_string(), )); } + let omit_top_level_forms = dev_local.join("app/forms").is_dir(); let zip_path = std::env::temp_dir().join(format!("ode-dev-bundle-{}.zip", Uuid::new_v4())); let file = fs::File::create(&zip_path)?; let mut zip = ZipWriter::new(BufWriter::new(file)); @@ -1683,8 +1760,8 @@ fn zip_dev_mirror_bundle(ws: &Path) -> Result { let rel = path .strip_prefix(&dev_local) .map_err(|e| CustodianError::Message(e.to_string()))?; - let name = rel.to_string_lossy(); - if !publish_bundle_zip_entry_allowed(&name) { + let name = zip_entry_path_normalized(rel); + if !publish_bundle_zip_entry_allowed(&name, omit_top_level_forms) { continue; } let bytes = if name.ends_with("schema.json") { @@ -1693,10 +1770,13 @@ fn zip_dev_mirror_bundle(ws: &Path) -> Result { } else { fs::read(path)? } + } else if name == "app/index.html" { + let html = fs::read_to_string(path)?; + strip_ode_desktop_injection(&html).into_bytes() } else { fs::read(path)? }; - zip.start_file(name.as_ref(), options) + zip.start_file(name.as_str(), options) .map_err(|e| CustodianError::Message(e.to_string()))?; zip.write_all(&bytes) .map_err(|e| CustodianError::Message(e.to_string()))?; @@ -1852,7 +1932,7 @@ fn spawn_observation_index_rebuild(app: tauri::AppHandle, ctx: AppCtxHandle, job if defs.is_empty() { return; } - let conn = match open_db(&ctx) { + let conn = match open_db_for_background_index_rebuild(&ctx) { Ok(c) => c, Err(err) => { emit_bundle_index_rebuild_progress( @@ -3574,6 +3654,53 @@ fn backup_workspace( .map_err(|e| e.to_string()) } +#[tauri::command] +fn preview_export_dir(parent_dir: String) -> Result { + data_export::preview_export_dir(&parent_dir).map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn export_observations_parquet( + request: data_export::ExportParquetRequest, + app: tauri::AppHandle, + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result { + let ctx = ctx.inner().clone(); + let app_for_block = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let emit = |phase: &str, message: &str, done: usize, total: usize| { + let _ = app_for_block.emit( + "export/progress", + serde_json::json!({ + "phase": phase, + "message": message, + "done": done, + "total": total, + }), + ); + }; + + emit("starting", "Reading observations…", 0, 1); + let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; + + // Hold the SQLite lock only while loading rows — never nest open_db inside + // with_workspace_fs_exclusive (same non-reentrant mutex → deadlock). + let rows = { + let conn = open_db(&ctx).map_err(|e| e.to_string())?; + data_export::load_export_rows(&conn, request.include_pending) + .map_err(|e| e.to_string())? + }; + + let mut progress = |done: usize, total: usize, message: &str| { + emit("running", message, done, total.max(1)); + }; + data_export::write_parquet_export(&ws, &request, rows, &mut progress) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("export task failed: {e}"))? +} + /// Moves `sqlite` and `attachments` under `workspace/previous_generations//`, then recreates a fresh layout. #[tauri::command] fn archive_workspace_for_repository_generation( @@ -3812,7 +3939,7 @@ fn read_host_text_file_inner(path: &Path) -> Result { fs::read_to_string(path).map_err(|e| e.to_string()) } -const MAX_HOST_TEXT_BATCH_PATHS: usize = 128; +const MAX_HOST_TEXT_BATCH_PATHS: usize = 512; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -3872,11 +3999,11 @@ fn host_path_is_directory(path: String) -> bool { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct ParsedImportFileResult { - file_name: String, - observations: Vec, +pub(crate) struct ParsedImportFileResult { + pub(crate) file_name: String, + pub(crate) observations: Vec, #[serde(skip_serializing_if = "Option::is_none")] - error: Option, + pub(crate) error: Option, } fn observation_id_from_obj(obj: &serde_json::Map) -> Option { @@ -4002,7 +4129,7 @@ fn extract_observations_from_json_value( Ok(observations) } -fn parse_import_json_file(path: &Path) -> ParsedImportFileResult { +pub(crate) fn parse_import_json_file(path: &Path) -> ParsedImportFileResult { let file_name = path .file_name() .and_then(|n| n.to_str()) @@ -4036,6 +4163,68 @@ fn parse_import_json_file(path: &Path) -> ParsedImportFileResult { } } +/// Load every `schema.json` under active/dev form roots (first-wins by form type). +fn load_bundle_form_schemas_for_ctx(ctx: &AppCtxHandle) -> Result, String> { + let roots = bundle_form_roots_for_ctx(ctx)?; + let mut out = HashMap::new(); + for root in roots { + let rd = match fs::read_dir(&root) { + Ok(r) => r, + Err(_) => continue, + }; + for entry in rd { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name().to_string_lossy().to_string(); + if !entry.file_type().map_err(|e| e.to_string())?.is_dir() { + continue; + } + if reserved_form_dir_name(&name) { + continue; + } + if out.contains_key(&name) { + continue; + } + let schema_path = entry.path().join("schema.json"); + let ui_path = entry.path().join("ui.json"); + if !(schema_path.is_file() && ui_path.is_file()) { + continue; + } + let form_schema: Value = + serde_json::from_str(&fs::read_to_string(&schema_path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + out.insert(name, form_schema); + } + } + Ok(out) +} + +/// Parse + schema-validate import JSON on the host (Rayon), loading form schemas from the active bundle. +#[tauri::command] +fn parse_and_validate_import_json_paths( + paths: Vec, + staged_attachment_basenames: Vec, + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result { + if paths.is_empty() { + return Ok(import_validate::parse_and_validate_paths( + paths, + &HashMap::new(), + &staged_attachment_basenames, + )); + } + if paths.len() > MAX_IMPORT_SCAN_ENTRIES { + return Err(format!( + "Too many JSON paths to validate (max {MAX_IMPORT_SCAN_ENTRIES})" + )); + } + let schemas = load_bundle_form_schemas_for_ctx(&ctx)?; + Ok(import_validate::parse_and_validate_paths( + paths, + &schemas, + &staged_attachment_basenames, + )) +} + /// Parse observation JSON files on the host (parallel) in import order. #[tauri::command] fn parse_import_observation_json_paths( @@ -4067,6 +4256,194 @@ fn parse_import_observation_json_paths( Ok(indexed.into_iter().map(|(_, r)| r).collect()) } +/// Same floor as Formulus / Desktop TS (`1980-01-01`). +const MIN_VALID_IMPORT_SYNCED_AT_MS: i64 = 315_532_800_000; + +fn parse_import_timestamp_ms(raw: &str) -> Option { + let t = raw.trim(); + if t.is_empty() { + return None; + } + if let Ok(dt) = DateTime::parse_from_rfc3339(t) { + return Some(dt.timestamp_millis()); + } + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S%.f") { + return Some(dt.and_utc().timestamp_millis()); + } + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S") { + return Some(dt.and_utc().timestamp_millis()); + } + None +} + +fn import_observation_apparently_synced(obj: &serde_json::Map) -> bool { + let synced_raw = optional_import_str(obj, "synced_at", "syncedAt"); + let Some(synced_ms) = synced_raw.as_deref().and_then(parse_import_timestamp_ms) else { + return false; + }; + if synced_ms <= MIN_VALID_IMPORT_SYNCED_AT_MS { + return false; + } + let updated_raw = optional_import_str(obj, "updated_at", "updatedAt"); + let Some(updated_ms) = updated_raw.as_deref().and_then(parse_import_timestamp_ms) else { + return true; + }; + updated_ms <= synced_ms +} + +fn import_json_root_observation_maps(root: &Value) -> Vec<&serde_json::Map> { + match root { + Value::Array(a) => a.iter().filter_map(|v| v.as_object()).collect(), + Value::Object(map) => { + if let Some(Value::Array(inner)) = map.get("observations") { + inner.iter().filter_map(|v| v.as_object()).collect() + } else { + vec![map] + } + } + _ => Vec::new(), + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ImportSyncAppearanceScanResult { + /// JSON files scanned. + file_count: usize, + observation_count: usize, + apparently_synced_count: usize, + unsynced_count: usize, + parse_error_count: usize, + /// Absolute paths to keep when skipping already-synced rows (pending + parse errors). + unsynced_paths: Vec, +} + +#[derive(Debug)] +struct ImportFileSyncScanRow { + path: String, + observation_count: usize, + apparently_synced_count: usize, + unsynced_count: usize, + parse_error: bool, +} + +fn scan_one_import_json_sync_appearance(path: &Path) -> ImportFileSyncScanRow { + let path_str = path.to_string_lossy().to_string(); + match read_host_text_file_inner(path) { + Err(_) => ImportFileSyncScanRow { + path: path_str, + observation_count: 0, + apparently_synced_count: 0, + unsynced_count: 0, + parse_error: true, + }, + Ok(text) => match serde_json::from_str::(&text) { + Err(_) => ImportFileSyncScanRow { + path: path_str, + observation_count: 0, + apparently_synced_count: 0, + unsynced_count: 0, + parse_error: true, + }, + Ok(root) => { + let maps = import_json_root_observation_maps(&root); + if maps.is_empty() { + return ImportFileSyncScanRow { + path: path_str, + observation_count: 0, + apparently_synced_count: 0, + unsynced_count: 0, + parse_error: true, + }; + } + let mut synced = 0usize; + let mut unsynced = 0usize; + for obj in maps { + if observation_id_from_obj(obj).is_none() { + unsynced += 1; + continue; + } + if import_observation_apparently_synced(obj) { + synced += 1; + } else { + unsynced += 1; + } + } + ImportFileSyncScanRow { + path: path_str, + observation_count: synced + unsynced, + apparently_synced_count: synced, + unsynced_count: unsynced, + parse_error: false, + } + } + }, + } +} + +/// Lightweight parallel scan of Formulus-style `syncedAt` / `updatedAt` without shipping payloads. +#[tauri::command] +fn scan_import_json_sync_appearance( + paths: Vec, +) -> Result { + if paths.is_empty() { + return Ok(ImportSyncAppearanceScanResult { + file_count: 0, + observation_count: 0, + apparently_synced_count: 0, + unsynced_count: 0, + parse_error_count: 0, + unsynced_paths: Vec::new(), + }); + } + if paths.len() > MAX_IMPORT_SCAN_ENTRIES { + return Err(format!( + "Too many JSON paths to scan (max {MAX_IMPORT_SCAN_ENTRIES})" + )); + } + + let file_count = paths.len(); + let rows: Vec = paths + .into_par_iter() + .map(|raw| { + let p = Path::new(raw.trim()); + scan_one_import_json_sync_appearance(p) + }) + .collect(); + + let mut observation_count = 0usize; + let mut apparently_synced_count = 0usize; + let mut unsynced_obs = 0usize; + let mut parse_error_count = 0usize; + let mut unsynced_paths = Vec::new(); + + for row in rows { + observation_count += row.observation_count; + apparently_synced_count += row.apparently_synced_count; + unsynced_obs += row.unsynced_count; + if row.parse_error { + parse_error_count += 1; + unsynced_paths.push(row.path); + } else if row.unsynced_count > 0 { + // Keep the file if any observation still needs import. + unsynced_paths.push(row.path); + } else if row.apparently_synced_count == 0 { + // Empty / unexpected — keep for validation to report. + unsynced_paths.push(row.path); + } + // else: all observations apparently synced → omit from unsynced_paths + } + + Ok(ImportSyncAppearanceScanResult { + file_count, + observation_count, + apparently_synced_count, + unsynced_count: unsynced_obs + parse_error_count, + parse_error_count, + unsynced_paths, + }) +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct AttachmentCopyPair { @@ -5064,10 +5441,11 @@ fn import_observations_run( conflicts += 1; } } - if !index_defs.is_empty() && !mark_pending { - // Sync pull: update indexes incrementally per page (no full rebuild follows). - // Local file import: skip here — `import_observations` schedules one background - // full rebuild after the batch commit (incremental work would be discarded). + if !index_defs.is_empty() { + // Sync pull and local file import both update the active generation + // incrementally. A full rebuild is reserved for bundle apply / empty + // index / explicit rebuild — not for adding a few hundred import rows + // on top of an already-indexed sync. let payload = serde_json::to_string(&observation.data).map_err(|e| e.to_string())?; let form_type = observation.form_type.as_deref().unwrap_or(""); observation_index::incremental_reindex( @@ -5110,7 +5488,9 @@ fn import_observations( let ctx_inner = ctx.inner().clone(); let mark = mark_pending.unwrap_or(false); let mut result = import_observations_run(observations, mark, &ctx_inner)?; - let should_schedule = schedule_index_rebuild.unwrap_or(mark) && result.imported > 0; + // Full rebuild is optional and off by default — import/sync maintain indexes + // incrementally. Callers that need a snapshot rebuild (rare) pass true. + let should_schedule = schedule_index_rebuild.unwrap_or(false) && result.imported > 0; if should_schedule { result.index_rebuild_scheduled = schedule_observation_index_rebuild(&app, &ctx_inner).is_some(); @@ -5232,12 +5612,52 @@ fn get_app_health(ctx: tauri::State<'_, AppCtxHandle>) -> Result) -> Result { +fn reset_local_workspace_data( + pending_only: Option, + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result { + let pending_only = pending_only.unwrap_or(false); with_workspace_fs_exclusive(&ctx, |ctx| { let db_path = resolve_db_path(ctx)?; let conn = Connection::open(&db_path)?; init_db(&conn)?; + + if pending_only { + conn.execute( + "DELETE FROM observation_history WHERE observation_id IN ( + SELECT id FROM observations + WHERE dirty = 1 OR sync_status IN ('dirty', 'conflict') + )", + [], + )?; + conn.execute( + "DELETE FROM observation_index WHERE observation_id IN ( + SELECT id FROM observations + WHERE dirty = 1 OR sync_status IN ('dirty', 'conflict') + )", + [], + )?; + conn.execute( + "DELETE FROM observations WHERE dirty = 1 OR sync_status IN ('dirty', 'conflict')", + [], + )?; + conn.execute_batch("PRAGMA wal_checkpoint(FULL);")?; + drop(conn); + + let ws = resolve_active_workspace_dir(ctx)?; + let pending_dir = attachments_root(&ws).join(ATTACH_SUBDIR_PENDING); + if pending_dir.exists() { + fs::remove_dir_all(&pending_dir)?; + } + ensure_workspace_layout(&ws)?; + return Ok(()); + } + conn.execute("DELETE FROM observation_history", [])?; conn.execute("DELETE FROM observations", [])?; conn.execute("DELETE FROM observation_index", [])?; @@ -5370,10 +5790,14 @@ pub fn run() { archive_workspace_for_repository_generation, move_workspace, backup_workspace, + preview_export_dir, + export_observations_parquet, write_workspace_attachment, copy_workspace_attachment_from_path, expand_import_staging_paths, parse_import_observation_json_paths, + parse_and_validate_import_json_paths, + scan_import_json_sync_appearance, copy_workspace_attachments_batch, read_host_text_file, host_path_is_directory, @@ -5423,10 +5847,11 @@ mod tests { ATTACHMENT_COPY_PROGRESS_MIN_INTERVAL, ApiObservation, CompressionMethod, ObservationExtras, SimpleFileOptions, ZipArchive, ZipWriter, apply_app_bundle_zip_at_workspace, attachment_copy_progress_step, bind_query_params, - build_observation_overview, extract_observations_from_json_value, init_db, - mirror_custom_app_dev_folder, parse_observation_extras, parse_time, - publish_bundle_zip_entry_allowed, resolve_attachment_path, - should_emit_attachment_copy_progress, should_mark_conflict, + build_observation_overview, extract_observations_from_json_value, + import_observation_apparently_synced, init_db, mirror_custom_app_dev_folder, + parse_observation_extras, parse_time, publish_bundle_zip_entry_allowed, + resolve_attachment_path, scan_import_json_sync_appearance, + should_emit_attachment_copy_progress, should_mark_conflict, strip_ode_desktop_injection, upsert_observation_from_local_import, validate_custom_app_dev_source_folder, zip_dev_mirror_bundle, }; @@ -5673,6 +6098,77 @@ mod tests { assert!(extras.geolocation.is_some()); } + #[test] + fn import_observation_apparently_synced_matches_formulus_rule() { + let synced: serde_json::Map = serde_json::from_value(serde_json::json!({ + "observationId": "a", + "updatedAt": "2026-08-15T11:00:00.000Z", + "syncedAt": "2026-08-15T12:00:00.000Z", + })) + .unwrap(); + assert!(import_observation_apparently_synced(&synced)); + + let pending: serde_json::Map = serde_json::from_value(serde_json::json!({ + "observationId": "b", + "updatedAt": "2026-08-15T13:00:00.000Z", + "syncedAt": "2026-08-15T12:00:00.000Z", + })) + .unwrap(); + assert!(!import_observation_apparently_synced(&pending)); + + let never: serde_json::Map = serde_json::from_value(serde_json::json!({ + "observationId": "c", + "updatedAt": "2026-08-15T13:00:00.000Z", + "syncedAt": null, + })) + .unwrap(); + assert!(!import_observation_apparently_synced(&never)); + } + + #[test] + fn scan_import_json_sync_appearance_keeps_unsynced_paths() { + let base = + std::env::temp_dir().join(format!("ode_import_sync_scan_{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let synced_path = base.join("synced.json"); + let pending_path = base.join("pending.json"); + fs::write( + &synced_path, + r#"{ + "observationId": "s1", + "formType": "t", + "updatedAt": "2026-01-01T00:00:00.000Z", + "syncedAt": "2026-01-02T00:00:00.000Z", + "data": {} + }"#, + ) + .unwrap(); + fs::write( + &pending_path, + r#"{ + "observationId": "p1", + "formType": "t", + "updatedAt": "2026-01-03T00:00:00.000Z", + "syncedAt": "2026-01-02T00:00:00.000Z", + "data": {} + }"#, + ) + .unwrap(); + let result = scan_import_json_sync_appearance(vec![ + synced_path.to_string_lossy().to_string(), + pending_path.to_string_lossy().to_string(), + ]) + .unwrap(); + assert_eq!(result.file_count, 2); + assert_eq!(result.observation_count, 2); + assert_eq!(result.apparently_synced_count, 1); + assert_eq!(result.unsynced_count, 1); + assert_eq!(result.unsynced_paths.len(), 1); + assert!(result.unsynced_paths[0].ends_with("pending.json")); + let _ = fs::remove_dir_all(&base); + } + #[test] fn upsert_observation_from_local_import_persists_extras() { let conn = Connection::open_in_memory().unwrap(); @@ -5772,15 +6268,33 @@ mod tests { #[test] fn zip_dev_mirror_bundle_produces_valid_layout() { + // Nested app/forms (CI style) plus a duplicate top-level forms/ copy from the mirror. let base = std::env::temp_dir().join(format!("ode_dev_zip_test_{}", std::process::id())); let _ = fs::remove_dir_all(&base); - fs::create_dir_all(base.join("bundles/dev-local/app")).unwrap(); + fs::create_dir_all(base.join("bundles/dev-local/app/forms/demo")).unwrap(); fs::create_dir_all(base.join("bundles/dev-local/forms/demo")).unwrap(); fs::write( base.join("bundles/dev-local/app/index.html"), b"", ) .unwrap(); + fs::write( + base.join("bundles/dev-local/app/forms/ext.json"), + br#"{"version":"1","renderers":{}}"#, + ) + .unwrap(); + fs::write( + base.join("bundles/dev-local/app/forms/forms-manifest.json"), + br#"[]"#, + ) + .unwrap(); + fs::write( + base.join("bundles/dev-local/app/forms/demo/schema.json"), + b"{}", + ) + .unwrap(); + fs::write(base.join("bundles/dev-local/app/forms/demo/ui.json"), b"{}").unwrap(); + // Duplicate sibling forms/ (mirror side-effect) — must be omitted from the zip. fs::write(base.join("bundles/dev-local/forms/demo/schema.json"), b"{}").unwrap(); fs::write(base.join("bundles/dev-local/forms/demo/ui.json"), b"{}").unwrap(); fs::write( @@ -5795,6 +6309,44 @@ mod tests { ) .unwrap(); + let zip_path = zip_dev_mirror_bundle(Path::new(&base)).unwrap(); + let file = fs::File::open(&zip_path).unwrap(); + let mut archive = ZipArchive::new(file).unwrap(); + let mut names: Vec = (0..archive.len()) + .map(|i| archive.by_index(i).unwrap().name().to_string()) + .collect(); + names.sort(); + assert!(names.contains(&"app/index.html".to_string())); + assert!(names.contains(&"app/forms/ext.json".to_string())); + assert!(names.contains(&"app/forms/forms-manifest.json".to_string())); + assert!(names.contains(&"app/forms/demo/schema.json".to_string())); + assert!(names.contains(&"app/forms/demo/ui.json".to_string())); + assert!(!names.iter().any(|n| n.starts_with("forms/"))); + let _ = fs::remove_file(&zip_path); + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn zip_dev_mirror_bundle_sibling_forms_strict_filter() { + // Legacy sibling forms/ only (no app/forms/) — schema/ui only, strip authoring junk. + let base = std::env::temp_dir().join(format!("ode_dev_zip_sibling_{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(base.join("bundles/dev-local/app")).unwrap(); + fs::create_dir_all(base.join("bundles/dev-local/forms/demo")).unwrap(); + fs::write( + base.join("bundles/dev-local/app/index.html"), + b"", + ) + .unwrap(); + fs::write(base.join("bundles/dev-local/forms/demo/schema.json"), b"{}").unwrap(); + fs::write(base.join("bundles/dev-local/forms/demo/ui.json"), b"{}").unwrap(); + fs::write( + base.join("bundles/dev-local/forms/shared-choice-defs.schema.json"), + br#"{"$defs":{"yesno":{"type":"string"}}}"#, + ) + .unwrap(); + fs::write(base.join("bundles/dev-local/forms/ext.json"), b"{}").unwrap(); + let zip_path = zip_dev_mirror_bundle(Path::new(&base)).unwrap(); let file = fs::File::open(&zip_path).unwrap(); let mut archive = ZipArchive::new(file).unwrap(); @@ -5806,7 +6358,7 @@ mod tests { assert!(names.contains(&"forms/demo/schema.json".to_string())); assert!(names.contains(&"forms/demo/ui.json".to_string())); assert!(!names.iter().any(|n| n.contains("shared-choice-defs"))); - assert!(!names.iter().any(|n| n.contains("extensions/"))); + assert!(!names.iter().any(|n| n == "forms/ext.json")); let _ = fs::remove_file(&zip_path); let _ = fs::remove_dir_all(&base); } @@ -5851,21 +6403,91 @@ mod tests { let _ = fs::remove_dir_all(&base); } + #[test] + fn strip_ode_desktop_injection_removes_embed_stub() { + let html = concat!( + "", + "", + "", + "", + "", + "", + "
", + ); + let cleaned = strip_ode_desktop_injection(html); + assert!(!cleaned.contains("ode-desktop-inject")); + assert!(!cleaned.contains("asset://localhost")); + assert!(!cleaned.contains("formulus-injection.js")); + assert!(cleaned.contains("./formulus-load.js")); + assert!(cleaned.contains("
")); + } + + #[test] + fn zip_dev_mirror_bundle_strips_desktop_inject_from_index() { + let base = + std::env::temp_dir().join(format!("ode_dev_zip_strip_inject_{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(base.join("bundles/dev-local/app")).unwrap(); + let poisoned = concat!( + "", + "", + "", + "", + ); + fs::write( + base.join("bundles/dev-local/app/index.html"), + poisoned.as_bytes(), + ) + .unwrap(); + + let zip_path = zip_dev_mirror_bundle(Path::new(&base)).unwrap(); + let file = fs::File::open(&zip_path).unwrap(); + let mut archive = ZipArchive::new(file).unwrap(); + let mut entry = archive.by_name("app/index.html").unwrap(); + let mut html = String::new(); + entry.read_to_string(&mut html).unwrap(); + assert!(!html.contains("ode-desktop-inject")); + assert!(!html.contains("asset://localhost")); + assert!(html.contains("./formulus-load.js")); + let _ = fs::remove_file(&zip_path); + let _ = fs::remove_dir_all(&base); + } + #[test] fn publish_bundle_zip_entry_allowed_filters_authoring_artifacts() { - assert!(publish_bundle_zip_entry_allowed("app/index.html")); + // Nested app/forms: allow full tree extras. + assert!(publish_bundle_zip_entry_allowed("app/index.html", true)); + assert!(publish_bundle_zip_entry_allowed( + "app/forms/household/ui.json", + true + )); + assert!(publish_bundle_zip_entry_allowed("app/forms/ext.json", true)); assert!(publish_bundle_zip_entry_allowed( - "forms/household/schema.json" + "app/forms/forms-manifest.json", + true + )); + assert!(!publish_bundle_zip_entry_allowed( + "forms/household/schema.json", + true )); + + // Sibling forms layout: schema/ui only at top-level forms/. assert!(publish_bundle_zip_entry_allowed( - "app/forms/household/ui.json" + "forms/household/schema.json", + false + )); + assert!(!publish_bundle_zip_entry_allowed( + "forms/shared-choice-defs.schema.json", + false )); assert!(!publish_bundle_zip_entry_allowed( - "forms/shared-choice-defs.schema.json" + "forms/extensions/helpers/queryHelpers.js", + false )); + assert!(!publish_bundle_zip_entry_allowed("forms/ext.json", false)); assert!(!publish_bundle_zip_entry_allowed( - "forms/extensions/helpers/queryHelpers.js" + "app/node_modules/pkg/index.js", + false )); - assert!(!publish_bundle_zip_entry_allowed("forms/ext.json")); } } diff --git a/desktop/src-tauri/src/observation_index.rs b/desktop/src-tauri/src/observation_index.rs index 62c0edc18..af2e74707 100644 --- a/desktop/src-tauri/src/observation_index.rs +++ b/desktop/src-tauri/src/observation_index.rs @@ -1,11 +1,15 @@ //! Local observation_index EAV table (never synced) + snapshot generation rebuild. +use rayon::prelude::*; use rusqlite::{Connection, params}; use serde::Deserialize; use serde_json::Value; use std::collections::HashSet; use std::path::Path; +/// Observations loaded / parsed per parallel chunk during a full rebuild. +const REBUILD_MAP_CHUNK_SIZE: usize = 512; + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ObservationIndexDef { @@ -85,48 +89,92 @@ fn form_type_matches(form_type: &str, patterns: Option<&Vec>) -> bool { false } -fn json_path_to_key(path: &str) -> String { - path.strip_prefix("$.").unwrap_or(path).to_string() +fn json_path_to_key(path: &str) -> &str { + path.strip_prefix("$.").unwrap_or(path) } -fn extract_scalar(payload: &str, path: &str) -> Option { - let v: Value = serde_json::from_str(payload).ok()?; - let key = json_path_to_key(path); - v.get(&key).cloned() +#[derive(Debug, Clone)] +struct IndexRow { + observation_id: String, + index_key: String, + value_text: Option, + value_num: Option, } -pub fn reindex_observation( - conn: &Connection, +/// Parse payload once and emit every matching EAV row (CPU-bound map step). +fn extract_index_rows( observation_id: &str, form_type: &str, payload: &str, defs: &[ObservationIndexDef], - generation: i64, -) -> rusqlite::Result<()> { - conn.execute( - "DELETE FROM observation_index WHERE observation_id = ?1 AND index_generation = ?2", - params![observation_id, generation], - )?; +) -> Vec { + let Ok(v) = serde_json::from_str::(payload) else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(defs.len()); for def in defs { if !form_type_matches(form_type, def.form_types.as_ref()) { continue; } - let Some(val) = extract_scalar(payload, &def.path) else { + let Some(val) = v.get(json_path_to_key(&def.path)) else { continue; }; if val.is_null() { continue; } - let (value_text, value_num) = scalar_to_columns(&val, def.value_type.as_deref()); - conn.execute( - "INSERT OR REPLACE INTO observation_index (observation_id, index_key, index_generation, value_text, value_num) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![observation_id, def.key, generation, value_text, value_num], - )?; + let (value_text, value_num) = scalar_to_columns(val, def.value_type.as_deref()); + out.push(IndexRow { + observation_id: observation_id.to_string(), + index_key: def.key.clone(), + value_text, + value_num, + }); + } + out +} + +fn insert_index_rows( + conn: &Connection, + generation: i64, + rows: &[IndexRow], +) -> rusqlite::Result<()> { + if rows.is_empty() { + return Ok(()); + } + // Full rebuild clears the target generation first; INSERT (not OR REPLACE) is fine + // and cheaper. Incremental reindex deletes the observation's rows first as well. + let mut stmt = conn.prepare( + "INSERT INTO observation_index (observation_id, index_key, index_generation, value_text, value_num) + VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + for row in rows { + stmt.execute(params![ + row.observation_id, + row.index_key, + generation, + row.value_text, + row.value_num, + ])?; } Ok(()) } +pub fn reindex_observation( + conn: &Connection, + observation_id: &str, + form_type: &str, + payload: &str, + defs: &[ObservationIndexDef], + generation: i64, +) -> rusqlite::Result<()> { + conn.execute( + "DELETE FROM observation_index WHERE observation_id = ?1 AND index_generation = ?2", + params![observation_id, generation], + )?; + let rows = extract_index_rows(observation_id, form_type, payload, defs); + insert_index_rows(conn, generation, &rows) +} + fn scalar_to_columns(val: &Value, value_type: Option<&str>) -> (Option, Option) { if (value_type == Some("number") || val.is_number()) && let Some(n) = val.as_f64() @@ -174,15 +222,6 @@ pub fn rebuild_all_indexes( cb(0, total, Some("Indexing observations…")); } - let mut stmt = conn.prepare("SELECT id, form_type, payload FROM observations")?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, String>(2)?, - )) - })?; - let progress_interval = if total < 50 { 1 } else if total < 500 { @@ -191,23 +230,59 @@ pub fn rebuild_all_indexes( 50 }; + // Map-join rebuild: + // 1. Load observation triples (desktop-scale; typically tens of thousands). + // 2. Parallel map: parse each payload once and extract all EAV rows. + // 3. Join: batch INSERT in one transaction (avoids per-row autocommit). + let observations: Vec<(String, String, String)> = { + let mut stmt = conn.prepare("SELECT id, form_type, payload FROM observations")?; + let mapped = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?.unwrap_or_default(), + row.get::<_, String>(2)?, + )) + })?; + mapped.collect::>>()? + }; + + // Drop secondary indexes for the bulk load; recreate after commit. + conn.execute_batch( + r#" + DROP INDEX IF EXISTS idx_observation_index_lookup; + DROP INDEX IF EXISTS idx_observation_index_lookup_num; + "#, + )?; + + let tx = conn.unchecked_transaction()?; let mut done = 0i64; - for row in rows { - let (id, form_type, payload) = row?; - let ft = form_type.unwrap_or_default(); - reindex_observation(conn, &id, &ft, &payload, defs, new_gen)?; - done += 1; + for chunk in observations.chunks(REBUILD_MAP_CHUNK_SIZE) { + let mapped: Vec = chunk + .par_iter() + .flat_map(|(id, ft, payload)| extract_index_rows(id, ft, payload, defs)) + .collect(); + insert_index_rows(&tx, new_gen, &mapped)?; + done += chunk.len() as i64; if let Some(ref mut cb) = progress && (total == 0 || done == total || done % progress_interval == 0) { cb(done, total, Some("Indexing observations…")); } } + tx.commit()?; if let Some(ref mut cb) = progress { cb(done, total, Some("Creating SQLite indexes…")); } + conn.execute_batch( + r#" + CREATE INDEX IF NOT EXISTS idx_observation_index_lookup + ON observation_index(index_generation, index_key, value_text, observation_id); + CREATE INDEX IF NOT EXISTS idx_observation_index_lookup_num + ON observation_index(index_generation, index_key, value_num, observation_id); + "#, + )?; recreate_sqlite_indexes(conn, defs)?; conn.execute( @@ -470,6 +545,41 @@ mod tests { assert_eq!(rows, 1); } + #[test] + fn rebuild_map_join_indexes_many_observations() { + let conn = test_conn(); + let defs = sample_defs(); + for i in 0..1200 { + conn.execute( + "INSERT INTO observations (id, form_type, payload) VALUES (?1, 'person', ?2)", + params![ + format!("obs{i}"), + format!(r#"{{"p_id":"P{i}","age":{}}}"#, i % 80) + ], + ) + .unwrap(); + } + let generation = rebuild_all_indexes(&conn, &defs, None).unwrap(); + assert_eq!(generation, 2); + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM observation_index WHERE index_generation = 2", + [], + |r| r.get(0), + ) + .unwrap(); + // Each person has p_id + age + assert_eq!(rows, 2400); + let lookup_ok: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_observation_index_lookup'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(lookup_ok, 1); + } + #[test] fn missing_sqlite_indexes_detects_absent_names() { let conn = test_conn(); diff --git a/desktop/src-tauri/src/observation_query.rs b/desktop/src-tauri/src/observation_query.rs index c2b15aadd..a4fc2a7fa 100644 --- a/desktop/src-tauri/src/observation_query.rs +++ b/desktop/src-tauri/src/observation_query.rs @@ -291,6 +291,23 @@ fn compile_json_extract( } }; let p = push_param(params, value); + + // A numeric comparison has to match the indexed path, which compares against + // `value_num` and therefore only ever considers values that are JSON numbers. + // SQLite orders by storage class rather than coercing, so `'abc' > 5` is true + // and every text value would otherwise satisfy a numeric filter here. This + // fallback is what `query_observations` switches to when the index is + // unusable, so the two paths have to return the same rows. + // + // json_type rather than typeof(json_extract(...)): JSON `true` extracts as + // the integer 1, so typeof would admit booleans that the index stores as text. + if value.is_number() { + let type_expr = format!("json_type(o.payload, '{json_path}')"); + return Ok(format!( + "({type_expr} IN ('integer','real') AND {expr} {sql_op} {p})" + )); + } + Ok(format!("{expr} {sql_op} {p}")) } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a82c8b562..4fc2191da 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ODE Desktop", - "version": "1.2.0", + "version": "1.3.0", "identifier": "org.opendataensemble.custodian", "build": { "beforeDevCommand": "pnpm dev", diff --git a/desktop/src/App.css b/desktop/src/App.css index 9e91b7be8..7b2831b5a 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -1278,6 +1278,16 @@ textarea { flex-wrap: wrap; } +.app-sync-banner-text { + flex: 1 1 12rem; + min-width: 0; +} + +.app-sync-banner-save-report { + flex-shrink: 0; + align-self: center; +} + .activity-progress { flex: 1 1 100%; height: 4px; @@ -1975,11 +1985,12 @@ button.secondary.danger { position: fixed; right: var(--space-lg); bottom: var(--space-lg); - z-index: 100; + /* Above app chrome / sticky controls; below nested formplayer overlay (300). */ + z-index: 250; display: flex; flex-direction: column-reverse; gap: var(--space-sm); - max-width: 22rem; + max-width: min(26rem, calc(100vw - 2 * var(--space-lg))); pointer-events: none; } @@ -1996,6 +2007,18 @@ button.secondary.danger { font-size: var(--font-size-sm); } +.toast-rich { + max-width: 100%; +} + +.toast-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + .toast-success { border-color: var(--color-success-border); background: var(--color-success-bg); @@ -2014,6 +2037,24 @@ button.secondary.danger { .toast-message { flex: 1; min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.toast-detail-list { + margin: 0; + padding: 0.35rem 0 0.15rem 1.1rem; + max-height: 9.5rem; + overflow-y: auto; + overscroll-behavior: contain; + font-size: 0.78rem; + line-height: 1.4; + color: var(--color-text-muted); + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.toast-detail-list li { + margin: 0.1rem 0; } .toast-dismiss { @@ -3044,3 +3085,122 @@ input:not([type]) { grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); } } + +.export-progress-panel { + margin-top: var(--space-md); +} + +.export-progress-row { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-sm); +} + +.export-page-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-md); + flex-wrap: wrap; +} + +.export-page-header h2 { + margin: 0; +} + +.export-last-at { + margin: 0; + font-size: 0.85rem; +} + +.export-dest-block { + margin-top: var(--space-lg); +} + +.export-dest-label { + display: block; + margin-bottom: var(--space-xs); + font-size: 0.9rem; + color: var(--color-text-muted); +} + +.export-dest-row { + display: flex; + gap: var(--space-sm); + align-items: center; +} + +.export-dest-row .field-block { + flex: 1; + min-width: 0; + margin-bottom: 0; + height: var(--control-height); + min-height: var(--control-height); + box-sizing: border-box; +} + +.export-dest-row button { + height: var(--control-height); + min-height: var(--control-height); + padding-top: 0; + padding-bottom: 0; + box-sizing: border-box; +} + +.export-snippets-panel { + margin-top: var(--space-lg); +} + +.export-snippets-panel h3 { + margin-top: 0; +} + +.export-snippet-tab-bar { + margin-bottom: var(--space-md); +} + +.export-snippet-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + min-width: 2rem; + min-height: 2rem; + padding: 0; + border: 1px solid transparent; + border-radius: 0.4rem; + background: transparent; + color: var(--color-text-muted); + box-shadow: none; +} + +.export-snippet-copy:hover:not(:disabled) { + background: #1f2941; + border-color: #455070; + color: var(--color-text); +} + +.export-snippet-copy:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.export-snippet-copy .material-symbols-outlined { + font-size: 1.15rem; +} + +.export-snippet-code { + margin: 0; + padding: var(--space-md); + max-height: 22rem; + overflow: auto; + background: #ffffff; + color: #111111; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 0.82rem; + line-height: 1.45; + white-space: pre; +} diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 73d7a120b..50fe4a056 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -60,6 +60,24 @@ vi.mock('./lib/tauriClient', () => ({ writeWorkspaceAttachment: vi.fn(), copyWorkspaceAttachmentFromPath: vi.fn(), expandImportStagingPaths: vi.fn().mockResolvedValue([]), + parseImportObservationJsonPaths: vi.fn().mockResolvedValue([]), + parseAndValidateImportJsonPaths: vi.fn().mockResolvedValue({ + files: [], + issues: [], + observationCount: 0, + formTypeCount: 0, + referencedAttachmentNames: [], + missingAttachmentNames: [], + orphanAttachmentNames: [], + }), + scanImportJsonSyncAppearance: vi.fn().mockResolvedValue({ + fileCount: 0, + observationCount: 0, + apparentlySyncedCount: 0, + unsyncedCount: 0, + parseErrorCount: 0, + unsyncedPaths: [], + }), readHostTextFile: vi.fn().mockResolvedValue('{}'), readHostTextFilesBatch: vi.fn().mockImplementation((paths: string[]) => Promise.resolve( diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 5e9cf18ed..ce1096cf1 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -15,6 +15,7 @@ import { ObservationsPage } from './pages/ObservationsPage'; import { SyncPage } from './pages/SyncPage'; import { ProfilesPage } from './pages/ProfilesPage'; import { ImportPage } from './pages/ImportPage'; +import { ExportPage } from './pages/ExportPage'; import { FormPreviewPage } from './pages/FormPreviewPage'; import { DeveloperModePanel } from './components/DeveloperModePanel'; import { ObservationIndexPrompt } from './components/ObservationIndexPrompt'; @@ -25,6 +26,7 @@ import { useSynkServerStatus } from './hooks/useSynkServerStatus'; import { selectActiveProfileState, selectBundleActivity, + selectExportActivity, selectSyncActivity, useCustodianStore, } from './store/useCustodianStore'; @@ -38,12 +40,14 @@ import { ensureBundleApplyEventPipeline, installGlobalIndexRebuildListener, } from './lib/bundleTauriEvents'; +import { tauriClient } from './lib/tauriClient'; import './App.css'; const DATA_NAV = [ { to: '/data/profiles', label: 'Profiles', icon: 'badge' }, { to: '/data/observations', label: 'Observations', icon: 'manage_search' }, { to: '/data/import', label: 'Import', icon: 'input' }, + { to: '/data/export', label: 'Export', icon: 'output' }, { to: '/data/sync', label: 'Sync', icon: 'sync_alt' }, { to: '/data/about', label: 'About', icon: 'info' }, ]; @@ -219,10 +223,12 @@ function Shell() { const isWorkbench = location.pathname.startsWith('/workbench'); const navItems = isWorkbench ? WORKBENCH_NAV : DATA_NAV; const syncMessage = useCustodianStore(s => s.syncMessage); + const syncDetailReport = useCustodianStore(s => s.syncDetailReport); const clearSyncMessage = useCustodianStore(s => s.clearSyncMessage); const pushToast = useToastStore(s => s.pushToast); const syncActivity = useCustodianStore(selectSyncActivity); const bundleActivity = useCustodianStore(selectBundleActivity); + const exportActivity = useCustodianStore(selectExportActivity); const importActivity = useImportStagingStore(selectImportActivity); const setBundleActivity = useCustodianStore(s => s.setBundleActivity); const clearBundleActivity = useCustodianStore(s => s.clearBundleActivity); @@ -239,6 +245,7 @@ function Shell() { syncActivity?.statusText ?? bundleActivity?.statusText ?? importActivity?.statusText ?? + exportActivity?.statusText ?? null; const activityProgress = @@ -247,10 +254,18 @@ function Shell() { 100, Math.round((bundleActivity.done / bundleActivity.total) * 100), ) - : null; + : exportActivity && exportActivity.total > 0 + ? Math.min( + 100, + Math.round((exportActivity.done / exportActivity.total) * 100), + ) + : null; const activityPresent = - syncActivity !== null || bundleActivity !== null || importActivity !== null; + syncActivity !== null || + bundleActivity !== null || + importActivity !== null || + exportActivity !== null; const [activityBannerDismissed, setActivityBannerDismissed] = useState(false); useEffect(() => { @@ -263,13 +278,18 @@ function Shell() { if (!syncMessage) { return; } - const isLong = syncMessage.includes('\n') || syncMessage.length > 120; - if (isLong) { + // Keep the banner when a downloadable detail report is attached, or when + // the message is long (multi-line / verbose). + const keepBanner = + Boolean(syncDetailReport) || + syncMessage.includes('\n') || + syncMessage.length > 120; + if (keepBanner) { return; } pushToast({ message: syncMessage, variant: 'success' }); clearSyncMessage(); - }, [syncMessage, pushToast, clearSyncMessage]); + }, [syncMessage, syncDetailReport, pushToast, clearSyncMessage]); const showActivityBanner = Boolean(activityText) && activityPresent && !activityBannerDismissed; @@ -277,169 +297,216 @@ function Shell() { const showSyncMessageBanner = Boolean(syncMessage) && syncMessage !== null && - (syncMessage.includes('\n') || syncMessage.length > 120); + (Boolean(syncDetailReport) || + syncMessage.includes('\n') || + syncMessage.length > 120); + + async function saveSyncDetailReport() { + if (!syncDetailReport) { + return; + } + try { + const { save } = await import('@tauri-apps/plugin-dialog'); + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + const path = await save({ + defaultPath: `ode-push-attachment-report-${stamp}.txt`, + filters: [{ name: 'Text', extensions: ['txt'] }], + }); + if (path == null) { + return; + } + await tauriClient.writeTextFile(path, syncDetailReport); + pushToast({ message: 'Report saved.', variant: 'success' }); + } catch (e) { + pushToast({ + message: e instanceof Error ? e.message : String(e), + variant: 'error', + }); + } + } return ( -
+ <> - - -
- - {isWorkbench ? : null} -
- {showActivityBanner ? ( -
-
- - {activityText} - {activityProgress !== null ? ( -
+ + +
+ + Open Data Ensemble {year} +
+ + +
+ + {isWorkbench ? : null} +
+ {showActivityBanner ? ( +
+
+ + {activityText} + {activityProgress !== null ? (
-
- ) : null} + className="activity-progress" + role="progressbar" + aria-valuenow={activityProgress} + aria-valuemin={0} + aria-valuemax={100}> +
+
+ ) : null} +
+
- -
- ) : null} - {showSyncMessageBanner ? ( -
-
{syncMessage}
- -
- ) : null} - - } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } /> - } /> - } /> - } /> - } /> - - } - /> - } - /> - } - /> - } - /> - -
-
-
+ ) : null} + {showSyncMessageBanner ? ( +
+
+ {syncMessage} + {syncDetailReport ? ( + + ) : null} +
+ +
+ ) : null} + + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + } + /> + } + /> + } + /> + + + + + ); } diff --git a/desktop/src/components/ForcePushMissingAttachmentsDialog.tsx b/desktop/src/components/ForcePushMissingAttachmentsDialog.tsx index 474dc843a..27d5e47e0 100644 --- a/desktop/src/components/ForcePushMissingAttachmentsDialog.tsx +++ b/desktop/src/components/ForcePushMissingAttachmentsDialog.tsx @@ -24,9 +24,9 @@ export function ForcePushMissingAttachmentsDialog({ } return ( -
+
diff --git a/desktop/src/components/ResetLocalDataDialog.tsx b/desktop/src/components/ResetLocalDataDialog.tsx new file mode 100644 index 000000000..10f45547b --- /dev/null +++ b/desktop/src/components/ResetLocalDataDialog.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react'; + +export type ResetLocalDataChoice = { + pendingOnly: boolean; +}; + +export function ResetLocalDataDialog({ + open, + dirtyCount, + conflictCount, + onChoice, +}: { + open: boolean; + dirtyCount: number; + conflictCount: number; + onChoice: (choice: ResetLocalDataChoice | null) => void; +}) { + const [pendingOnly, setPendingOnly] = useState(false); + + useEffect(() => { + if (open) { + setPendingOnly(false); + } + }, [open]); + + if (!open) { + return null; + } + + const pendingLabel = + dirtyCount + conflictCount > 0 + ? ` (${dirtyCount} pending push · ${conflictCount} conflict${conflictCount === 1 ? '' : 's'})` + : ''; + + return ( +
+
+

Reset local data

+

+ By default this removes all observations and + attachment files from this device and resets sync offsets. +

+ + {pendingOnly ? ( +

+ Deletes pending-push and conflict rows only. Synced observations and + sync offsets stay so the next pull can continue without + re-downloading everything. +

+ ) : ( +

+ Full reset: clears the local repository and attachment files. The + next pull starts from empty offsets. +

+ )} +
+ + +
+
+
+ ); +} diff --git a/desktop/src/components/ToastHost.tsx b/desktop/src/components/ToastHost.tsx index 653af6b7e..5c302bbbd 100644 --- a/desktop/src/components/ToastHost.tsx +++ b/desktop/src/components/ToastHost.tsx @@ -11,8 +11,20 @@ export function ToastHost() { return (
{toasts.map(t => ( -
- {t.message} +
+
+ {t.message} + {t.detailLines && t.detailLines.length > 0 ? ( +
    + {t.detailLines.map(line => ( +
  • {line}
  • + ))} +
+ ) : null} +
+
+
+ +
+ + {lastResult?.exportDir ? ( + + ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ +
+

Load in analysis tools

+

+ {lastResult?.parquetFiles && + Object.keys(lastResult.parquetFiles).length > 0 + ? 'Paths match the last export. Scripts are also written under snippets/ in that folder.' + : 'Replace [INSERT PATH TO PARQUET FILE] after exporting, or copy again once paths are known.'} +

+
+ {EXPORT_SNIPPET_LANGS.map(lang => ( + + ))} +
+ +
+
+
+          {snippetCode}
+        
+
+ + ); +} diff --git a/desktop/src/pages/ImportPage.tsx b/desktop/src/pages/ImportPage.tsx index 12e3963df..714364a38 100644 --- a/desktop/src/pages/ImportPage.tsx +++ b/desktop/src/pages/ImportPage.tsx @@ -5,16 +5,17 @@ import { tauriClient } from '../lib/tauriClient'; import { groupIssuesBySeverityAndCategory, normalizeBasename, - runImportValidation, + referencedNamesForObservation, type ImportIssue, type ImportIssueCategory, type ImportValidationReport, } from '../lib/importValidation'; -import type { BundleFormSpec } from '../types/domain'; +import type { ApiObservation, BundleFormSpec } from '../types/domain'; import { flattenObservations, mapPool, - parseObservationJsonPathsViaRust, + parseAndValidateImportJsonViaRust, + partitionImportObservationsBySyncAppearance, summarizeImportFiles, } from '../lib/importSummary'; import { @@ -31,6 +32,9 @@ import { const MAX_INDIVIDUAL_FILES = 20; +/** Cap DOM rows — rendering tens of thousands of staging rows freezes the UI. */ +const STAGING_LIST_PREVIEW = 50; + /** Host copy batch size — keeps IPC payloads and UI updates manageable. */ const ATTACHMENT_COPY_CHUNK_SIZE = 400; @@ -55,6 +59,22 @@ function formatBytes(n: number) { return `${(n / (1024 * 1024)).toFixed(1)} MB`; } +/** Attachment basenames referenced by the observations actually being written. */ +function referencedAttachmentNamesForObservations( + observations: readonly ApiObservation[], + formSpecsByType: Map, +): string[] { + const refs = new Set(); + for (const obs of observations) { + const ft = obs.formType?.trim(); + const schema = ft ? formSpecsByType.get(ft)?.formSchema : undefined; + for (const name of referencedNamesForObservation(schema, obs.data)) { + refs.add(name); + } + } + return [...refs]; +} + function normalizeDialogPaths( selected: string | string[] | null | undefined, ): string[] { @@ -124,6 +144,8 @@ const CATEGORY_LABELS: Record = { function ValidationAccordion({ issues }: { issues: ImportIssue[] }) { const grouped = groupIssuesBySeverityAndCategory(issues); const [openKeys, setOpenKeys] = useState>(new Set()); + const errorCount = issues.filter(i => i.severity === 'error').length; + const warningCount = issues.filter(i => i.severity === 'warning').length; function toggle(key: string) { setOpenKeys(prev => { @@ -148,38 +170,54 @@ function ValidationAccordion({ issues }: { issues: ImportIssue[] }) { } } - if (sections.length === 0) { - return

No issues reported.

; - } - return ( -
- {sections.map(sec => ( -
- - {openKeys.has(sec.key) ? ( -
-
    - {sec.items.slice(0, 30).map((issue, i) => ( -
  • {issue.message}
  • - ))} -
- {sec.items.length > 30 ? ( -

… and {sec.items.length - 30} more

+
+ {errorCount === 0 ? ( +

No validation errors were found.

+ ) : ( +

+ {errorCount} validation error{errorCount === 1 ? '' : 's'} + {warningCount > 0 + ? ` · ${warningCount} warning${warningCount === 1 ? '' : 's'}` + : ''} + . Review below — you can still import. +

+ )} + {errorCount === 0 && warningCount > 0 ? ( +

+ {warningCount} warning{warningCount === 1 ? '' : 's'} reported. +

+ ) : null} + {sections.length > 0 ? ( +
+ {sections.map(sec => ( +
+ + {openKeys.has(sec.key) ? ( +
+
    + {sec.items.slice(0, 30).map((issue, i) => ( +
  • {issue.message}
  • + ))} +
+ {sec.items.length > 30 ? ( +

… and {sec.items.length - 30} more

+ ) : null} +
) : null}
- ) : null} + ))}
- ))} + ) : null}
); } @@ -198,6 +236,9 @@ export function ImportPage() { const busy = importActivity !== null; const addScanEntries = useImportStagingStore(s => s.addScanEntries); + const retainStagedJsonPaths = useImportStagingStore( + s => s.retainStagedJsonPaths, + ); const removeStagedJson = useImportStagingStore(s => s.removeStagedJson); const removeStagedAttachment = useImportStagingStore( s => s.removeStagedAttachment, @@ -208,6 +249,10 @@ export function ImportPage() { const setError = useImportStagingStore(s => s.setError); const setImportActivity = useImportStagingStore(s => s.setImportActivity); + /** After staging skip dialog: silently drop remaining synced rows at import. */ + const [preferSkipSynced, setPreferSkipSynced] = useState(false); + const [skippedSyncedAtStaging, setSkippedSyncedAtStaging] = useState(0); + const stagingSummary = useMemo(() => { const jsonCount = stagedJson.length; const attCount = stagedAttachments.length; @@ -217,6 +262,70 @@ export function ImportPage() { return { jsonCount, attCount, bytes }; }, [stagedJson, stagedAttachments]); + /** + * After JSON lands in staging, scan Formulus `syncedAt` metadata and optionally + * drop already-synced files before the heavy parse/validate pass. + */ + const offerSkipAlreadySynced = useCallback(async () => { + const jsonPaths = useImportStagingStore + .getState() + .stagedJson.map(s => s.nativePath); + if (jsonPaths.length === 0) { + return; + } + setImportActivity({ + statusText: `Checking sync status (${jsonPaths.length} JSON files)…`, + }); + try { + const scan = await tauriClient.scanImportJsonSyncAppearance(jsonPaths); + if (scan.apparentlySyncedCount <= 0) { + return; + } + const skipSynced = await confirm( + `${scan.observationCount} observations were found. ${scan.apparentlySyncedCount} already appear to be synced — skip those and import only the ${scan.unsyncedCount} new observations?`, + { + title: 'Skip already-synced observations?', + kind: 'info', + okLabel: 'Skip synced', + cancelLabel: 'Keep all', + }, + ); + if (skipSynced) { + retainStagedJsonPaths(scan.unsyncedPaths); + setPreferSkipSynced(true); + setSkippedSyncedAtStaging(scan.apparentlySyncedCount); + setMessage( + `Staging updated — kept ${scan.unsyncedPaths.length} JSON file(s); skipped ${scan.apparentlySyncedCount} already-synced observation(s).`, + ); + } else { + setPreferSkipSynced(false); + setSkippedSyncedAtStaging(0); + } + } catch (e) { + setError( + messageFromUnknown(e, 'Could not check already-synced observations'), + ); + } + }, [retainStagedJsonPaths, setError, setImportActivity, setMessage]); + + const stageExpandedEntries = useCallback( + async ( + expanded: Awaited< + ReturnType + >, + ) => { + if (!expanded.length) { + return; + } + setPreviewReport(null); + addScanEntries(expanded); + if (expanded.some(e => e.isJson)) { + await offerSkipAlreadySynced(); + } + }, + [addScanEntries, offerSkipAlreadySynced], + ); + useEffect(() => { if (!isTauri()) { return undefined; @@ -249,9 +358,7 @@ export function ImportPage() { paths, MAX_INDIVIDUAL_FILES, ); - if (expanded.length) { - addScanEntries(expanded); - } + await stageExpandedEntries(expanded); } catch (e) { setError( messageFromUnknown(e, 'Could not stage dropped files'), @@ -270,7 +377,7 @@ export function ImportPage() { alive = false; unlisten?.(); }; - }, [addScanEntries, setError, setImportActivity]); + }, [setError, setImportActivity, stageExpandedEntries]); const pickImportFolder = useCallback(async () => { try { @@ -289,15 +396,13 @@ export function ImportPage() { selected, null, ); - if (expanded.length) { - addScanEntries(expanded); - } + await stageExpandedEntries(expanded); } catch (e) { setError(messageFromUnknown(e, 'Folder selection failed')); } finally { setImportActivity(null); } - }, [addScanEntries, setError, setImportActivity]); + }, [setError, setImportActivity, stageExpandedEntries]); const pickJsonFiles = useCallback(async () => { try { @@ -315,15 +420,13 @@ export function ImportPage() { paths, MAX_INDIVIDUAL_FILES, ); - if (expanded.length) { - addScanEntries(expanded); - } + await stageExpandedEntries(expanded); } catch (e) { setError(messageFromUnknown(e, 'File selection failed')); } finally { setImportActivity(null); } - }, [addScanEntries, setError, setImportActivity]); + }, [setError, setImportActivity, stageExpandedEntries]); const pickAttachmentFiles = useCallback(async () => { try { @@ -341,6 +444,7 @@ export function ImportPage() { MAX_INDIVIDUAL_FILES, ); if (expanded.length) { + setPreviewReport(null); addScanEntries(expanded); } } catch (e) { @@ -350,45 +454,104 @@ export function ImportPage() { } }, [addScanEntries, setError, setImportActivity]); - const runFullImport = useCallback(async () => { + const runValidate = useCallback(async () => { if (stagedJson.length === 0) { return; } const statusCtl = createThrottledImportStatus(setImportActivity); - setPreviewReport(null); setMessage(null); setError(null); - statusCtl.push('Reading observation JSON…'); try { await ensureBundleApplyEventPipeline(); - const parsed = await parseObservationJsonPathsViaRust( + statusCtl.push( + `Reading and validating JSON (${stagedJson.length} files)…`, + ); + const hostReport = await parseAndValidateImportJsonViaRust( stagedJson.map(s => ({ name: s.name, nativePath: s.nativePath })), - (done, tot) => statusCtl.push(`Reading JSON (${done}/${tot})…`), + stagedAttachments.map(s => s.name), ); + const issues: ImportIssue[] = hostReport.issues.map(i => ({ + severity: i.severity === 'warning' ? 'warning' : 'error', + code: i.code, + message: i.message, + fileName: i.fileName, + observationId: i.observationId, + formType: i.formType ?? null, + })); + + setPreviewReport({ + issues, + parsedFiles: hostReport.parsedFiles, + observationCount: hostReport.observationCount, + formTypeCount: hostReport.formTypeCount, + stagedAttachmentBasenames: stagedAttachments.map(s => s.name), + referencedAttachmentNames: hostReport.referencedAttachmentNames, + missingAttachmentNames: hostReport.missingAttachmentNames, + orphanAttachmentNames: hostReport.orphanAttachmentNames, + }); + } catch (e) { + setPreviewReport(null); + setError(messageFromUnknown(e, 'Validation failed')); + } finally { + statusCtl.dispose(); + setImportActivity(null); + } + }, [stagedJson, stagedAttachments, setMessage, setError, setImportActivity]); + + const runImportFromReport = useCallback(async () => { + if (!previewReport) { + return; + } + const statusCtl = createThrottledImportStatus(setImportActivity); + setMessage(null); + setError(null); + + try { + await ensureBundleApplyEventPipeline(); + + const allObservations = flattenObservations(previewReport.parsedFiles); + const syncPartition = + partitionImportObservationsBySyncAppearance(allObservations); + // Staging already offered skip/keep; when skip was chosen, drop any + // remaining apparently-synced rows (e.g. mixed files) without re-prompting. + const observations = preferSkipSynced + ? syncPartition.unsynced + : allObservations; + const skippedSyncedCount = preferSkipSynced + ? Math.max( + skippedSyncedAtStaging, + syncPartition.apparentlySynced.length, + ) + : 0; + + if (observations.length === 0) { + setMessage( + skippedSyncedCount > 0 + ? `Nothing to import — all ${skippedSyncedCount} observations already appear to be synced.` + : 'Nothing to import — no observations found in staged JSON.', + ); + return; + } + + // Form schemas only needed for attachment refs on the rows we actually write. const formTypes = new Set(); - for (const p of parsed) { - if (p.error) { - continue; - } - for (const obs of p.observations) { - if (obs.formType?.trim()) { - formTypes.add(obs.formType.trim()); - } + for (const obs of observations) { + if (obs.formType?.trim()) { + formTypes.add(obs.formType.trim()); } } - const formSpecsByType = new Map(); const ftArr = [...formTypes].sort(); - if (ftArr.length > 0) { + if (ftArr.length > 0 && stagedAttachments.length > 0) { let schemaDone = 0; await mapPool(ftArr, 8, async ft => { try { const spec = await tauriClient.readBundleFormSpec(ft); formSpecsByType.set(ft, spec); } catch { - /* missing schema reported inside runImportValidation */ + /* attachment refs fall back to heuristics */ } finally { schemaDone += 1; statusCtl.push( @@ -398,38 +561,9 @@ export function ImportPage() { }); } - statusCtl.push('Validating…'); - const basenames = stagedAttachments.map(s => s.name); - const report = runImportValidation({ - parsedFiles: parsed, - formSpecsByType, - stagedAttachmentBasenames: basenames, - onFileValidated: (fi, tot, name) => - statusCtl.push(`Validating (${fi + 1}/${tot}) ${name}…`), - }); - - if (report.issues.length > 0) { - const errCount = report.issues.filter( - i => i.severity === 'error', - ).length; - const warnCount = report.issues.filter( - i => i.severity === 'warning', - ).length; - const ok = await confirm( - `${errCount} error(s), ${warnCount} warning(s). Import anyway?`, - { title: 'Validation issues', kind: 'warning' }, - ); - if (!ok) { - setPreviewReport(report); - return; - } - } - - const observations = flattenObservations(report.parsedFiles); const writeTotal = observations.length; let imported = 0; let conflicts = 0; - let indexRebuildScheduled = false; for ( let offset = 0; @@ -441,103 +575,107 @@ export function ImportPage() { offset + IMPORT_WRITE_CHUNK_SIZE, ); const written = Math.min(offset + chunk.length, writeTotal); - const isLast = written >= writeTotal; statusCtl.push(`Writing observations (${written}/${writeTotal})…`); + // Indexes are updated incrementally inside import (same as sync pull). const chunkResult = await tauriClient.importObservations(chunk, { markPending: true, - scheduleIndexRebuild: isLast, + scheduleIndexRebuild: false, }); imported += chunkResult.imported; conflicts += chunkResult.conflicts; - indexRebuildScheduled = - indexRebuildScheduled || !!chunkResult.indexRebuildScheduled; } - const result = { imported, conflicts, indexRebuildScheduled }; + const result = { imported, conflicts }; - const stagedNorm = new Map(); - for (const s of stagedAttachments) { - const k = normalizeBasename(s.name); - if (k && !stagedNorm.has(k)) { - stagedNorm.set(k, s.name); + const copyItems: { sourcePath: string; attachmentId: string }[] = []; + const copyErrors: string[] = []; + let attachmentsCopied = 0; + if (stagedAttachments.length > 0) { + statusCtl.push('Resolving attachment references…'); + const stagedNorm = new Map(); + for (const s of stagedAttachments) { + const k = normalizeBasename(s.name); + if (k && !stagedNorm.has(k)) { + stagedNorm.set(k, s.name); + } } - } - const refNorm = new Set( - report.referencedAttachmentNames - .map(n => normalizeBasename(n)) - .filter(Boolean), - ); + const refNorm = new Set( + referencedAttachmentNamesForObservations( + observations, + formSpecsByType, + ) + .map(n => normalizeBasename(n)) + .filter(Boolean), + ); - const copyItems: { sourcePath: string; attachmentId: string }[] = []; - for (const s of stagedAttachments) { - const kn = normalizeBasename(s.name); - if (!kn || !refNorm.has(kn)) { - continue; + for (const s of stagedAttachments) { + const kn = normalizeBasename(s.name); + if (!kn || !refNorm.has(kn)) { + continue; + } + const attachmentId = stagedNorm.get(kn) ?? s.name; + copyItems.push({ + sourcePath: s.nativePath, + attachmentId, + }); } - const attachmentId = stagedNorm.get(kn) ?? s.name; - copyItems.push({ - sourcePath: s.nativePath, - attachmentId, - }); - } - const copyErrors: string[] = []; - let attachmentsCopied = 0; - if (copyItems.length > 0) { - const copyTotal = copyItems.length; - const copyStatusCtl = createThrottledImportStatus( - setImportActivity, - 250, - ); - copyStatusCtl.push(formatAttachmentCopyProgress(0, copyTotal)); - const { listen } = await import('@tauri-apps/api/event'); - let unlisten: (() => void) | undefined; - try { - for ( - let offset = 0; - offset < copyItems.length; - offset += ATTACHMENT_COPY_CHUNK_SIZE - ) { - const chunk = copyItems.slice( - offset, - offset + ATTACHMENT_COPY_CHUNK_SIZE, - ); - const chunkIndex = - Math.floor(offset / ATTACHMENT_COPY_CHUNK_SIZE) + 1; - const chunkCount = Math.ceil( - copyItems.length / ATTACHMENT_COPY_CHUNK_SIZE, - ); - if (chunkCount > 1) { + if (copyItems.length > 0) { + const copyTotal = copyItems.length; + const copyStatusCtl = createThrottledImportStatus( + setImportActivity, + 250, + ); + copyStatusCtl.push(formatAttachmentCopyProgress(0, copyTotal)); + const { listen } = await import('@tauri-apps/api/event'); + let unlisten: (() => void) | undefined; + try { + for ( + let offset = 0; + offset < copyItems.length; + offset += ATTACHMENT_COPY_CHUNK_SIZE + ) { + const chunk = copyItems.slice( + offset, + offset + ATTACHMENT_COPY_CHUNK_SIZE, + ); + const chunkIndex = + Math.floor(offset / ATTACHMENT_COPY_CHUNK_SIZE) + 1; + const chunkCount = Math.ceil( + copyItems.length / ATTACHMENT_COPY_CHUNK_SIZE, + ); + if (chunkCount > 1) { + copyStatusCtl.push( + `Copying attachments batch ${chunkIndex}/${chunkCount}…`, + ); + } + unlisten?.(); + unlisten = await listen<{ + done: number; + total: number; + attachmentId: string; + }>('import/attachment-copy-progress', e => { + const globalDone = offset + e.payload.done; + copyStatusCtl.push( + formatAttachmentCopyProgress(globalDone, copyTotal), + ); + }); + const batchResult = + await tauriClient.copyWorkspaceAttachmentsBatch(chunk); + copyErrors.push(...batchResult.errors); + attachmentsCopied += batchResult.copied; copyStatusCtl.push( - `Copying attachments batch ${chunkIndex}/${chunkCount}…`, + formatAttachmentCopyProgress( + Math.min(offset + chunk.length, copyTotal), + copyTotal, + ), ); } + } finally { unlisten?.(); - unlisten = await listen<{ - done: number; - total: number; - attachmentId: string; - }>('import/attachment-copy-progress', e => { - const globalDone = offset + e.payload.done; - copyStatusCtl.push( - formatAttachmentCopyProgress(globalDone, copyTotal), - ); - }); - const batchResult = - await tauriClient.copyWorkspaceAttachmentsBatch(chunk); - copyErrors.push(...batchResult.errors); - attachmentsCopied += batchResult.copied; - copyStatusCtl.push( - formatAttachmentCopyProgress( - Math.min(offset + chunk.length, copyTotal), - copyTotal, - ), - ); + copyStatusCtl.dispose(); } - } finally { - unlisten?.(); - copyStatusCtl.dispose(); } } @@ -545,16 +683,19 @@ export function ImportPage() { await loadObservations(); await loadHealth(); - const baseMsg = `Imported ${result.imported} observations (${result.conflicts} conflicts).`; - const indexMsg = result.indexRebuildScheduled - ? ' Rebuilding observation indexes in the background (see activity banner).' - : ''; + const skipMsg = + skippedSyncedCount > 0 + ? ` Skipped ${skippedSyncedCount} already-synced observation(s).` + : ''; + const baseMsg = `Imported ${result.imported} observations (${result.conflicts} conflicts).${skipMsg}`; const attMsg = copyItems.length > 0 ? ` Copied ${attachmentsCopied}/${copyItems.length} referenced attachment(s) to queue.${copyErrors.length ? ` Errors: ${copyErrors.slice(0, 5).join('; ')}${copyErrors.length > 5 ? '…' : ''}` : ''}` : ''; - setMessage(`${baseMsg}${indexMsg}${attMsg}`); + setMessage(`${baseMsg}${attMsg}`); clearStagedFiles(); + setPreferSkipSynced(false); + setSkippedSyncedAtStaging(0); setPreviewReport(null); } catch (e) { setError(messageFromUnknown(e, 'Import failed')); @@ -563,8 +704,10 @@ export function ImportPage() { setImportActivity(null); } }, [ - stagedJson, + previewReport, stagedAttachments, + preferSkipSynced, + skippedSyncedAtStaging, setMessage, setError, setImportActivity, @@ -637,7 +780,12 @@ export function ImportPage() { type="button" className="linkish" disabled={busy} - onClick={() => clearStagingLists()}> + onClick={() => { + clearStagingLists(); + setPreferSkipSynced(false); + setSkippedSyncedAtStaging(0); + setPreviewReport(null); + }}> Clear staging ) : null} @@ -648,14 +796,17 @@ export function ImportPage() { {stagedJson.length > 0 ? (

JSON ({stagedJson.length})

- {stagedJson.map(s => ( + {stagedJson.slice(0, STAGING_LIST_PREVIEW).map(s => (
@@ -665,20 +816,29 @@ export function ImportPage() { {formatBytes(s.size)}
))} + {stagedJson.length > STAGING_LIST_PREVIEW ? ( +

+ … and {stagedJson.length - STAGING_LIST_PREVIEW} more (list + truncated for performance) +

+ ) : null}
) : null} {stagedAttachments.length > 0 ? (

Attachments ({stagedAttachments.length})

- {stagedAttachments.map(s => ( + {stagedAttachments.slice(0, STAGING_LIST_PREVIEW).map(s => (
@@ -688,6 +848,12 @@ export function ImportPage() { {formatBytes(s.size)}
))} + {stagedAttachments.length > STAGING_LIST_PREVIEW ? ( +

+ … and {stagedAttachments.length - STAGING_LIST_PREVIEW} more + (list truncated for performance) +

+ ) : null}
) : null}
@@ -698,23 +864,35 @@ export function ImportPage() { type="button" className="btn-icon" disabled={busy || stagedJson.length === 0} - onClick={() => void runFullImport()}> + onClick={() => void runValidate()}> - download + fact_check - {busy ? 'Working…' : 'Import into local store'} + {busy ? 'Working…' : 'Validate'}
{previewReport && preflightSummary ? (
-

Validation summary

+

Validation results

{preflightSummary.observationCount} observations ·{' '} {preflightSummary.formTypeCount} form types

+
+ +
) : null} diff --git a/desktop/src/pages/ProfilesPage.tsx b/desktop/src/pages/ProfilesPage.tsx index 0dcc2c495..ad985de1d 100644 --- a/desktop/src/pages/ProfilesPage.tsx +++ b/desktop/src/pages/ProfilesPage.tsx @@ -19,9 +19,9 @@ import { import type { ServerProfile } from '../types/domain'; import { selectActiveProfileState, - selectAuthSessionForActiveProfile, useCustodianStore, } from '../store/useCustodianStore'; +import { useProfileAutoSynkAuth } from '../hooks/useProfileAutoSynkAuth'; import { useProfileDraftGuardStore } from '../store/useProfileDraftGuardStore'; import { confirmDestructiveAction } from '../lib/destructivePolicy'; @@ -89,7 +89,8 @@ export function ProfilesPage() { error, } = useCustodianStore(); const active = useCustodianStore(selectActiveProfileState); - const authSession = useCustodianStore(selectAuthSessionForActiveProfile); + const { authSession, authBlocked, authReady, refreshAuth } = + useProfileAutoSynkAuth(active?.id); const [label, setLabel] = useState(''); const [serverUrl, setServerUrl] = useState(''); @@ -406,6 +407,7 @@ export function ProfilesPage() { username: username.trim(), password: pwd, }); + await refreshAuth(); } catch { // synkLogin reports via store `error` } @@ -415,8 +417,18 @@ export function ProfilesPage() { const derivedDb = ws ? workspaceSqlitePath(ws) : ''; const derivedAttachments = ws ? workspaceAttachmentsDir(ws) : ''; - const authIcon = authSession ? 'verified_user' : 'lock_open'; - const authLabel = authSession ? 'Authenticated' : 'Authenticate'; + const isAuthenticated = authReady && Boolean(authSession) && !authBlocked; + const authChecking = Boolean(active) && !authReady; + const authIcon = isAuthenticated + ? 'verified_user' + : authChecking + ? 'hourglass_empty' + : 'lock_open'; + const authLabel = isAuthenticated + ? 'Authenticated' + : authChecking + ? 'Checking…' + : 'Authenticate'; return (
@@ -524,8 +536,8 @@ export function ProfilesPage() {
); } diff --git a/desktop/src/pages/WorkbenchCustomAppPage.tsx b/desktop/src/pages/WorkbenchCustomAppPage.tsx index 3966e558c..1cc9405e7 100644 --- a/desktop/src/pages/WorkbenchCustomAppPage.tsx +++ b/desktop/src/pages/WorkbenchCustomAppPage.tsx @@ -25,9 +25,7 @@ import type { AppBundleState } from '../types/domain'; export function WorkbenchCustomAppPage() { const navigate = useNavigate(); const activeProfile = useCustodianStore(selectActiveProfileState); - const { authSession, authBlocked, ensureAuth } = useProfileAutoSynkAuth( - activeProfile?.id, - ); + const { authBlocked, ensureAuth } = useProfileAutoSynkAuth(activeProfile?.id); const recoverActiveProfileAuth = useCustodianStore( s => s.recoverActiveProfileAuth, ); @@ -178,6 +176,10 @@ export function WorkbenchCustomAppPage() { setUploading(true); try { + // Refresh from the local source folder, then zip+push the mirror. + // `pushDevMirrorAppBundle` strips the ODE Desktop embed inject that + // CustomAppEmbed writes into `app/index.html` for iframe preview — + // that stub must not ship to Synkronus / Formulus. await refreshDevApp(); if (!(await ensureAuth())) { return; @@ -257,7 +259,7 @@ export function WorkbenchCustomAppPage() { {devError ?

{devError}

: null} {bundleError ?

{bundleError}

: null} {uploadError ?

{uploadError}

: null} - {developerMode && baseUrl && authBlocked && !authSession ? ( + {developerMode && baseUrl && authBlocked ? (

Not authenticated. Open Profiles to sign in. diff --git a/desktop/src/store/useCustodianStore.ts b/desktop/src/store/useCustodianStore.ts index 7a31fcd58..a013f7bb4 100644 --- a/desktop/src/store/useCustodianStore.ts +++ b/desktop/src/store/useCustodianStore.ts @@ -7,8 +7,13 @@ import { setCustodianSyncProgressHandler, SyncPausedError, } from '../lib/syncTauriEvents'; -import { partitionPendingPushObservations } from '../lib/pushAttachmentAudit'; +import { + formatMissingAttachmentHighlight, + formatMissingAttachmentReport, + partitionPendingPushObservations, +} from '../lib/pushAttachmentAudit'; import { getOrCreateClientId, syncGateway } from '../services/synk'; +import { isSyncHttpUnauthorized } from '../services/synk/syncErrors'; import type { AppHealth, AuthSession, @@ -117,7 +122,14 @@ async function reauthenticateActiveProfile( persistAuthMap(merged); set({ authSessionsByProfileId: merged }); return; - } catch { + } catch (refreshError) { + const cred = await tauriClient.credentialGet(id); + const password = cred.password ?? ''; + if (!password.trim()) { + // No password fallback: surface the refresh failure (401 clears session + // in recover; network errors keep the stored refresh token). + throw refreshError; + } // Fall through to password login. } } @@ -139,6 +151,20 @@ async function reauthenticateActiveProfile( set({ authSessionsByProfileId: merged }); } +function clearActiveProfileAuthSession( + set: (partial: Partial) => void, + get: () => CustodianState, +): void { + const id = get().activeProfileId; + if (!id || !get().authSessionsByProfileId[id]) { + return; + } + const auth = { ...get().authSessionsByProfileId }; + delete auth[id]; + persistAuthMap(auth); + set({ authSessionsByProfileId: auth }); +} + async function awaitSyncJobTerminal( jobId: string, set: (partial: Partial) => void, @@ -207,6 +233,8 @@ interface CustodianState { loading: boolean; error: string | null; syncMessage: string | null; + /** Full text for optional “Save report” (e.g. missing-attachment push details). */ + syncDetailReport: string | null; syncActivity: { op: 'pull' | 'push' | 'reset'; statusText: string; @@ -217,6 +245,11 @@ interface CustodianState { done: number; total: number; } | null; + exportActivity: { + statusText: string; + done: number; + total: number; + } | null; /** Persisted Rust sync job awaiting resume (transient stall, auth, or cold start). */ syncPausedJob: SyncJobRowOut | null; /** Bumped after each successful `refresh_custom_app_dev_mirror` (Workbench embeds). */ @@ -230,6 +263,8 @@ interface CustodianState { setDevError: (message: string | null) => void; setBundleActivity: (activity: CustodianState['bundleActivity']) => void; clearBundleActivity: () => void; + setExportActivity: (activity: CustodianState['exportActivity']) => void; + clearExportActivity: () => void; dismissObservationIndexPrompt: () => void; createPendingObservationIndexes: () => Promise; refreshSettings: () => Promise; @@ -266,7 +301,9 @@ interface CustodianState { syncPauseInFlight: () => Promise; syncContinueInFlight: () => Promise; syncCancelJob: (jobId?: string | null) => Promise; - resetLocalWorkspaceData: () => Promise; + resetLocalWorkspaceData: (options?: { + pendingOnly?: boolean; + }) => Promise; } /** Tauri invoke often rejects with a string; preserve the real message for the UI. */ @@ -363,8 +400,10 @@ export const useCustodianStore = create((set, get) => ({ loading: false, error: null, syncMessage: null, + syncDetailReport: null, syncActivity: null, bundleActivity: null, + exportActivity: null, syncPausedJob: null, devMirrorGeneration: 0, devBusy: false, @@ -377,15 +416,17 @@ export const useCustodianStore = create((set, get) => ({ setBundleActivity: activity => set({ bundleActivity: activity }), clearBundleActivity: () => set({ bundleActivity: null }), + setExportActivity: activity => set({ exportActivity: activity }), + clearExportActivity: () => set({ exportActivity: null }), ensureActiveProfileAuth: async () => { - if (selectAuthSessionForActiveProfile(get())?.token) { - return true; - } try { await reauthenticateActiveProfile(set, get); return true; - } catch { + } catch (e) { + if (isSyncHttpUnauthorized(e)) { + clearActiveProfileAuthSession(set, get); + } return false; } }, @@ -394,7 +435,10 @@ export const useCustodianStore = create((set, get) => ({ try { await reauthenticateActiveProfile(set, get); return true; - } catch { + } catch (e) { + if (isSyncHttpUnauthorized(e)) { + clearActiveProfileAuthSession(set, get); + } return false; } }, @@ -505,6 +549,7 @@ export const useCustodianStore = create((set, get) => ({ activeProfileId: s.activeProfileId, profiles: s.profiles, syncMessage: null, + syncDetailReport: null, }); await reloadProfileScopedData(set, get); await get().refreshPausedSyncJob(); @@ -541,7 +586,7 @@ export const useCustodianStore = create((set, get) => ({ setSelectedObservationId: id => set({ selectedObservationId: id }), clearError: () => set({ error: null }), - clearSyncMessage: () => set({ syncMessage: null }), + clearSyncMessage: () => set({ syncMessage: null, syncDetailReport: null }), loadWorkspace: async () => withErrorHandling(set, async () => { @@ -614,7 +659,10 @@ export const useCustodianStore = create((set, get) => ({ await tauriClient.saveObservation(request); await get().loadObservations(); await get().loadHealth(); - set({ syncMessage: 'Saved locally. Observation is now pending push.' }); + set({ + syncMessage: 'Saved locally. Observation is now pending push.', + syncDetailReport: null, + }); }), synkLogin: async request => @@ -626,6 +674,7 @@ export const useCustodianStore = create((set, get) => ({ set({ authSessionsByProfileId: next, syncMessage: 'Authenticated with Synkronus.', + syncDetailReport: null, }); }), @@ -638,6 +687,7 @@ export const useCustodianStore = create((set, get) => ({ ); set({ syncMessage: null, + syncDetailReport: null, syncActivity: { op: 'pull', statusText: 'Pulling…' }, }); try { @@ -668,6 +718,7 @@ export const useCustodianStore = create((set, get) => ({ await get().loadHealth(); set({ syncMessage: capture.current.trim() || 'Pull finished.', + syncDetailReport: null, }); } finally { setCustodianSyncProgressHandler(null); @@ -685,6 +736,7 @@ export const useCustodianStore = create((set, get) => ({ ); set({ syncMessage: null, + syncDetailReport: null, syncActivity: { op: 'push', statusText: 'Preparing push…' }, }); try { @@ -698,7 +750,10 @@ export const useCustodianStore = create((set, get) => ({ const pendingPushObservations = await tauriClient.listDirtyObservations(); if (pendingPushObservations.length === 0) { - set({ syncMessage: 'No pending observations to push.' }); + set({ + syncMessage: 'No pending observations to push.', + syncDetailReport: null, + }); return 0; } @@ -709,25 +764,21 @@ export const useCustodianStore = create((set, get) => ({ forceMissing, ); - const skipSummary = - missingAttachmentIssues.length > 0 && !forceMissing - ? ` Skipped ${missingAttachmentIssues.length} observation(s) with missing attachment file(s): ${missingAttachmentIssues - .map( - s => - `${s.id} (form: ${s.formType}; missing: ${s.missing.map(n => `"${n}"`).join(', ')})`, - ) - .join('; ')}.` - : ''; - - const forceMissingSummary = - missingAttachmentIssues.length > 0 && forceMissing - ? ` Included ${missingAttachmentIssues.length} observation(s) with missing attachment(s) (forced): ${missingAttachmentIssues - .map( - s => - `${s.id} (form: ${s.formType}; missing: ${s.missing.map(n => `"${n}"`).join(', ')})`, - ) - .join('; ')}.` + const attachmentMode = forceMissing ? 'forced' : 'skipped'; + const attachmentHighlight = + missingAttachmentIssues.length > 0 + ? formatMissingAttachmentHighlight( + missingAttachmentIssues, + attachmentMode, + ) : ''; + const attachmentReport = + missingAttachmentIssues.length > 0 + ? formatMissingAttachmentReport( + missingAttachmentIssues, + attachmentMode, + ) + : null; if ( missingAttachmentIssues.length > 0 && @@ -744,7 +795,8 @@ export const useCustodianStore = create((set, get) => ({ if (readyToPush.length === 0) { set({ - syncMessage: `Nothing pushed.${skipSummary}`.trim(), + syncMessage: `Nothing pushed.${attachmentHighlight}`.trim(), + syncDetailReport: attachmentReport, }); return 0; } @@ -758,7 +810,9 @@ export const useCustodianStore = create((set, get) => ({ xOdeVersion: SYNKRONUS_CLIENT_VERSION, pushPrepare: { readyObservationIds: readyToPush.map(o => o.id), - skipSummary: skipSummary.trim() ? skipSummary : undefined, + skipSummary: attachmentHighlight.trim() + ? attachmentHighlight.trim() + : undefined, }, }); const resumePayloadBound = (): SyncResumeJobRequest => ({ @@ -777,8 +831,8 @@ export const useCustodianStore = create((set, get) => ({ ? Number(acceptedMatch[1]) : readyToPush.length; set({ - syncMessage: - `${capture.current.trim()}${skipSummary}${forceMissingSummary}`.trim(), + syncMessage: `${capture.current.trim()}${attachmentHighlight}`.trim(), + syncDetailReport: attachmentReport, }); return accepted; } finally { @@ -797,6 +851,7 @@ export const useCustodianStore = create((set, get) => ({ ); set({ syncMessage: null, + syncDetailReport: null, syncActivity: { op: 'reset', statusText: 'Resetting server repository…', @@ -835,6 +890,7 @@ export const useCustodianStore = create((set, get) => ({ syncMessage: capture.current.trim() || 'Server repository reset and pull finished.', + syncDetailReport: null, }); } finally { setCustodianSyncProgressHandler(null); @@ -900,7 +956,10 @@ export const useCustodianStore = create((set, get) => ({ await get().loadObservations(); await get().loadHealth(); if (capture.current.trim()) { - set({ syncMessage: capture.current.trim() }); + set({ + syncMessage: capture.current.trim(), + syncDetailReport: null, + }); } } finally { setCustodianSyncProgressHandler(null); @@ -924,14 +983,17 @@ export const useCustodianStore = create((set, get) => ({ await get().loadHealth(); }, - resetLocalWorkspaceData: async () => + resetLocalWorkspaceData: async options => withErrorHandling(set, async () => { - await tauriClient.resetLocalWorkspaceData(); + const pendingOnly = options?.pendingOnly === true; + await tauriClient.resetLocalWorkspaceData({ pendingOnly }); await reloadProfileScopedData(set, get); set({ selectedObservationId: null, - syncMessage: - 'Local data reset: observations cleared, attachments removed, sync offsets reset.', + syncMessage: pendingOnly + ? 'Pending observations cleared. Synced data and sync offsets kept.' + : 'Local data reset: observations cleared, attachments removed, sync offsets reset.', + syncDetailReport: null, }); }), })); @@ -952,6 +1014,10 @@ export function selectBundleActivity(state: CustodianState) { return state.bundleActivity; } +export function selectExportActivity(state: CustodianState) { + return state.exportActivity; +} + export function selectPausedSyncJob(state: CustodianState) { return state.syncPausedJob; } diff --git a/desktop/src/store/useExportPageStore.ts b/desktop/src/store/useExportPageStore.ts new file mode 100644 index 000000000..49da97448 --- /dev/null +++ b/desktop/src/store/useExportPageStore.ts @@ -0,0 +1,85 @@ +import { create } from 'zustand'; +import type { ExportParquetResult, ServerProfile } from '../types/domain'; +import type { ExportSnippetLang } from '../lib/exportLoadSnippets'; + +interface ExportPageState { + /** Profile whose export prefs are currently loaded into this store. */ + hydratedProfileId: string | null; + /** Parent folder chosen for export (`YYYYMMDD` leaf is created underneath). */ + destinationParent: string | null; + includePending: boolean; + includeAttachments: boolean; + snippetLang: ExportSnippetLang; + lastResult: ExportParquetResult | null; + /** ISO timestamp of last successful export (persisted on the profile). */ + lastExportAt: string | null; + error: string | null; + busy: boolean; + hydrateFromProfile: (profile: ServerProfile) => void; + clearHydration: () => void; + setDestinationParent: (path: string | null) => void; + setIncludePending: (v: boolean) => void; + setIncludeAttachments: (v: boolean) => void; + setSnippetLang: (lang: ExportSnippetLang) => void; + setLastResult: (result: ExportParquetResult | null) => void; + setLastExportAt: (iso: string | null) => void; + setError: (error: string | null) => void; + setBusy: (busy: boolean) => void; + recordSuccessfulExport: (result: ExportParquetResult) => string; +} + +function trimPath(path: string | null | undefined): string | null { + const t = path?.trim(); + return t ? t : null; +} + +export const useExportPageStore = create(set => ({ + hydratedProfileId: null, + destinationParent: null, + includePending: false, + includeAttachments: false, + snippetLang: 'r', + lastResult: null, + lastExportAt: null, + error: null, + busy: false, + + hydrateFromProfile: profile => + set({ + hydratedProfileId: profile.id, + destinationParent: trimPath(profile.exportDestinationParent), + lastExportAt: trimPath(profile.lastExportAt), + lastResult: profile.lastExport ?? null, + error: null, + busy: false, + }), + clearHydration: () => + set({ + hydratedProfileId: null, + destinationParent: null, + lastResult: null, + lastExportAt: null, + error: null, + busy: false, + }), + setDestinationParent: path => + set({ + destinationParent: trimPath(path), + }), + setIncludePending: includePending => set({ includePending }), + setIncludeAttachments: includeAttachments => set({ includeAttachments }), + setSnippetLang: snippetLang => set({ snippetLang }), + setLastResult: lastResult => set({ lastResult }), + setLastExportAt: lastExportAt => set({ lastExportAt }), + setError: error => set({ error }), + setBusy: busy => set({ busy }), + recordSuccessfulExport: result => { + const lastExportAt = new Date().toISOString(); + set({ + lastResult: result, + lastExportAt, + error: null, + }); + return lastExportAt; + }, +})); diff --git a/desktop/src/store/useImportStagingStore.ts b/desktop/src/store/useImportStagingStore.ts index ed2951cff..796c4308b 100644 --- a/desktop/src/store/useImportStagingStore.ts +++ b/desktop/src/store/useImportStagingStore.ts @@ -18,6 +18,8 @@ interface ImportStagingState { error: string | null; importActivity: { statusText: string } | null; addScanEntries: (entries: ImportStagingScanEntry[]) => void; + /** Keep only JSON files whose absolute path is in `nativePaths`. */ + retainStagedJsonPaths: (nativePaths: readonly string[]) => void; removeStagedJson: (nativePath: string) => void; removeStagedAttachment: (nativePath: string) => void; /** Clears staged JSON + attachment lists (keeps message/error). */ @@ -86,6 +88,14 @@ export const useImportStagingStore = create(set => ({ }; }), + retainStagedJsonPaths: nativePaths => + set(s => { + const keep = new Set(nativePaths); + return { + stagedJson: s.stagedJson.filter(f => keep.has(f.nativePath)), + }; + }), + removeStagedJson: nativePath => set(s => ({ stagedJson: s.stagedJson.filter(f => f.nativePath !== nativePath), diff --git a/desktop/src/store/useToastStore.ts b/desktop/src/store/useToastStore.ts index 3cdfb952d..804ffe75a 100644 --- a/desktop/src/store/useToastStore.ts +++ b/desktop/src/store/useToastStore.ts @@ -6,35 +6,52 @@ export interface ToastItem { id: string; message: string; variant: ToastVariant; + /** Optional secondary lines (e.g. per–form-type counts); shown in a scrollable list. */ + detailLines?: string[]; } const MAX_TOASTS = 3; let nextId = 0; -function autoDismissMs(variant: ToastVariant): number { +function autoDismissMs(variant: ToastVariant, hasDetails: boolean): number { + if (hasDetails) { + return 14000; + } if (variant === 'error' || variant === 'warn') { return 8000; } return 5000; } +export type PushToastInput = { + message: string; + variant?: ToastVariant; + detailLines?: string[]; +}; + interface ToastState { toasts: ToastItem[]; - pushToast: (input: { message: string; variant?: ToastVariant }) => void; + pushToast: (input: PushToastInput) => void; dismissToast: (id: string) => void; } export const useToastStore = create((set, get) => ({ toasts: [], - pushToast: ({ message, variant = 'info' }) => { + pushToast: ({ message, variant = 'info', detailLines }) => { const id = `toast-${++nextId}`; - const item: ToastItem = { id, message, variant }; + const lines = detailLines?.filter(l => l.trim().length > 0); + const item: ToastItem = { + id, + message, + variant, + ...(lines && lines.length > 0 ? { detailLines: lines } : {}), + }; set(state => ({ toasts: [item, ...state.toasts].slice(0, MAX_TOASTS), })); - const ms = autoDismissMs(variant); + const ms = autoDismissMs(variant, Boolean(item.detailLines?.length)); window.setTimeout(() => { if (get().toasts.some(t => t.id === id)) { get().dismissToast(id); diff --git a/desktop/src/types/domain.ts b/desktop/src/types/domain.ts index 4f29a5eaa..ac3fca803 100644 --- a/desktop/src/types/domain.ts +++ b/desktop/src/types/domain.ts @@ -84,6 +84,38 @@ export interface ParsedImportFileResult { error?: string; } +/** Lightweight sync-appearance scan over staged Formulus export JSON. */ +export interface ImportSyncAppearanceScanResult { + fileCount: number; + observationCount: number; + apparentlySyncedCount: number; + unsyncedCount: number; + parseErrorCount: number; + /** Absolute paths to retain when skipping already-synced observations. */ + unsyncedPaths: string[]; +} + +/** One issue from host-side import validation ({@link parseAndValidateImportJsonPaths}). */ +export interface ImportHostIssue { + severity: 'error' | 'warning' | string; + code: string; + message: string; + fileName?: string; + observationId?: string; + formType?: string | null; +} + +/** Result of parallel Rust parse + schema/attachment validation. */ +export interface ImportValidateBatchResult { + files: ParsedImportFileResult[]; + issues: ImportHostIssue[]; + observationCount: number; + formTypeCount: number; + referencedAttachmentNames: string[]; + missingAttachmentNames: string[]; + orphanAttachmentNames: string[]; +} + export interface AttachmentCopyBatchResult { copied: number; failed: number; @@ -230,6 +262,12 @@ export interface ServerProfile { customAppDeveloperMode?: boolean | null; /** Absolute path to a folder containing `index.html` (e.g. custom app `dist/`). */ customAppLocalFolder?: string | null; + /** Parent folder last chosen on the Export page (survives restart). */ + exportDestinationParent?: string | null; + /** ISO timestamp of the last successful Parquet export for this profile. */ + lastExportAt?: string | null; + /** Summary of the last successful export (folder, counts, parquet paths). */ + lastExport?: ExportParquetResult | null; } /** Result of mirroring a local custom app folder into the profile workspace. */ @@ -435,3 +473,27 @@ export interface WorkspaceDomainItem { path: string; kind: 'directory' | 'file'; } + +/** Result of local Parquet export (`export_observations_parquet`). */ +export interface ExportParquetResult { + exportDir: string; + formTypeCounts: Record; + /** Absolute `.parquet` paths keyed by form type. */ + parquetFiles: Record; + totalRows: number; + attachmentsCopied: number; + attachmentsMissing: number; + includePending: boolean; + includeAttachments: boolean; + workspaceAttachmentsPath: string; + exportAttachmentsPath?: string | null; + manifestPath: string; +} + +export interface ExportParquetRequest { + parentDir: string; + includePending?: boolean; + includeAttachments?: boolean; + overwrite?: boolean; + profileLabel?: string | null; +} diff --git a/formulus-formplayer/AGENTS.md b/formulus-formplayer/AGENTS.md index f0b3896e8..aa163c3aa 100644 --- a/formulus-formplayer/AGENTS.md +++ b/formulus-formplayer/AGENTS.md @@ -51,6 +51,10 @@ This file gives AI assistants and developers enough context to work effectively | `src/mocks/` | `webview-mock.ts` and `DevTestbed` for local dev without RN. | | `scripts/` | `sync-interface.js`, `copy-to-rn.js`, `clean-rn-assets.js`. | +## Clear-on-hide (`SHOW` / `HIDE`) + +When a Control becomes not visible (`visible: false` from JSON Forms rules), Formplayer clears that path via `src/jsonforms/useClearOnHide.ts` (ODK-style `relevant`). Authoring implication: **do not** put visibility rules on fields that must survive (especially custom-app `defaultData` stamps). Keep those properties in the schema only; display with `headerFields` or a separate computed/`lbl_*` Control. Docs: [form-design — Conditional Logic](https://opendataensemble.org/docs/guides/form-design#conditional-logic-in-ode-forms). + ## Key technical constraints 1. **WebView environment**: No real `window.ReactNativeWebView` in browser dev; use the mock. Assume **file://** and **single JS bundle** when changing Vite/build. diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index 11b3b38e2..01089b790 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -84,6 +84,7 @@ The React Native host passes `FormInitData` into the WebView (including `params` - **Reserved top-level `params` keys** (not treated as observation fields): `defaultData`, `theme`, `darkMode`, `themeColors`. If the host adds more non-data parameters later, the formplayer’s `FORMPARAMS_NON_DATA_KEYS` in `src/utils/formObservationData.ts` should be extended in lockstep. - **Legacy prefills**: If `defaultData` is missing, the formplayer copies other top-level `params` keys except the reserved keys above. - **Sanitization**: When the schema defines non-empty root `properties`, loaded and submitted data are filtered to those keys plus `locale` (so older polluted rows are cleaned on edit/save). Schemas with missing or empty root `properties` pass data through unchanged. +- **Clear-on-hide**: A Control with a `SHOW`/`HIDE` rule that starts (or becomes) hidden has its value **deleted**. Injected stamps must stay schema + `defaultData` only—never a hidden Control on the real field. Prefer `headerFields` or a computed/`lbl_*` display proxy. See [form-design](https://opendataensemble.org/docs/guides/form-design#conditional-logic-in-ode-forms). ## Custom validators that mutate data diff --git a/formulus-formplayer/pnpm-lock.yaml b/formulus-formplayer/pnpm-lock.yaml index ed5a3b119..c673a543f 100644 --- a/formulus-formplayer/pnpm-lock.yaml +++ b/formulus-formplayer/pnpm-lock.yaml @@ -95,19 +95,19 @@ importers: version: 9.39.4 '@storybook/addon-docs': specifier: ^10.2.19 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0)) '@storybook/react': specifier: ^10.2.19 version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3) '@storybook/react-vite': specifier: ^10.2.19 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0)) '@types/node': specifier: ^24.10.9 version: 24.12.4 '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0)) + version: 4.7.0(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0)) eslint: specifier: ^9.39.2 version: 9.39.4 @@ -143,13 +143,13 @@ importers: version: 10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) typescript-eslint: specifier: ^8.54.0 - version: 8.59.3(eslint@9.39.4)(typescript@5.9.3) + version: 8.66.0(eslint@9.39.4)(typescript@5.9.3) vite: specifier: ^6.4.1 - version: 6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) + version: 6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.4)(jsdom@26.1.0)(terser@5.48.0)(yaml@2.9.0) + version: 3.2.4(@types/node@24.12.4)(jsdom@26.1.0)(terser@5.49.1)(yaml@2.9.0) packages: @@ -179,8 +179,8 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.28.6': @@ -238,8 +238,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true @@ -275,16 +275,16 @@ packages: resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@csstools/color-helpers@5.1.0': @@ -701,6 +701,12 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1459,8 +1465,8 @@ packages: cpu: [x64] os: [win32] - '@sinclair/typebox@0.27.10': - resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} '@sinclair/typebox@0.34.49': resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} @@ -1629,8 +1635,8 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} - '@types/node@24.13.2': - resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -1663,16 +1669,16 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.59.3': - resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} + '@typescript-eslint/eslint-plugin@8.66.0': + resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.3 + '@typescript-eslint/parser': ^8.66.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.3': - resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==} + '@typescript-eslint/parser@8.66.0': + resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1684,18 +1690,34 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.66.0': + resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.59.3': resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.66.0': + resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.59.3': resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.3': - resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==} + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.66.0': + resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1705,12 +1727,22 @@ packages: resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.59.3': resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.66.0': + resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.59.3': resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1718,10 +1750,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.66.0': + resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.59.3': resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.66.0': + resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1778,8 +1821,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -1923,6 +1966,10 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2595,8 +2642,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} image-size@1.2.1: @@ -3016,6 +3063,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -3191,6 +3242,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3273,6 +3328,9 @@ packages: react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-native-svg@15.15.5: resolution: {integrity: sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==} peerDependencies: @@ -3447,8 +3505,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} side-channel-list@1.0.1: @@ -3601,8 +3659,8 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - terser@5.48.0: - resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + terser@5.49.1: + resolution: {integrity: sha512-7A2xlQ5EnGT8KPA92dUh6RbRYTVw8hEaEN9L1K68l4UOXFuV511NnAqObGoRqGOQofQcMypisu1s3xawCEHrvA==} engines: {node: '>=10'} hasBin: true @@ -3705,8 +3763,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.59.3: - resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==} + typescript-eslint@8.66.0: + resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3896,8 +3954,8 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - ws@7.5.11: - resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} engines: {node: '>=8.3.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4022,10 +4080,10 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -4079,9 +4137,9 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/parser@7.29.7': + '@babel/parser@7.29.8': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: @@ -4106,8 +4164,8 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@babel/traverse@7.29.0': dependencies: @@ -4121,14 +4179,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -4138,7 +4196,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -4426,6 +4484,11 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -4505,7 +4568,7 @@ snapshots: '@jest/schemas@29.6.3': dependencies: - '@sinclair/typebox': 0.27.10 + '@sinclair/typebox': 0.27.12 '@jest/schemas@30.4.1': dependencies: @@ -4516,7 +4579,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.13.2 + '@types/node': 24.13.3 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -4530,11 +4593,11 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) optionalDependencies: typescript: 5.9.3 @@ -4881,7 +4944,7 @@ snapshots: '@react-native/codegen@0.85.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 hermes-parser: 0.33.3 invariant: 2.2.4 nullthrows: 1.1.1 @@ -4925,7 +4988,7 @@ snapshots: nullthrows: 1.1.1 open: 7.4.2 serve-static: 1.16.3 - ws: 7.5.11 + ws: 7.5.13 transitivePeerDependencies: - bufferutil - supports-color @@ -5031,14 +5094,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true - '@sinclair/typebox@0.27.10': {} + '@sinclair/typebox@0.27.12': {} '@sinclair/typebox@0.34.49': {} - '@storybook/addon-docs@10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0))': + '@storybook/addon-docs@10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.6) - '@storybook/csf-plugin': 10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0)) '@storybook/icons': 2.0.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@storybook/react-dom-shim': 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) react: 19.2.6 @@ -5054,25 +5117,25 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0))': + '@storybook/builder-vite@10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0)) storybook: 10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) ts-dedent: 2.2.0 - vite: 6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0))': + '@storybook/csf-plugin@10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0))': dependencies: storybook: 10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.7 rollup: 4.60.4 - vite: 6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -5090,11 +5153,11 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@storybook/react-vite@10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0))': + '@storybook/react-vite@10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0)) '@rollup/pluginutils': 5.3.0(rollup@4.60.4) - '@storybook/builder-vite': 10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0)) + '@storybook/builder-vite': 10.4.0(esbuild@0.27.7)(rollup@4.60.4)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0)) '@storybook/react': 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -5104,7 +5167,7 @@ snapshots: resolve: 1.22.12 storybook: 10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.14)(prettier@3.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tsconfig-paths: 4.2.0 - vite: 6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -5228,7 +5291,7 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@24.13.2': + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 @@ -5258,28 +5321,28 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/type-utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/parser': 8.66.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/type-utils': 8.66.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.66.0 eslint: 9.39.4 - ignore: 7.0.5 + ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.66.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.3 - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3 eslint: 9.39.4 typescript: 5.9.3 @@ -5295,20 +5358,38 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.66.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3) + '@typescript-eslint/types': 8.66.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.59.3': dependencies: '@typescript-eslint/types': 8.59.3 '@typescript-eslint/visitor-keys': 8.59.3 + '@typescript-eslint/scope-manager@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.59.3 - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.66.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.4)(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.4 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5318,6 +5399,8 @@ snapshots: '@typescript-eslint/types@8.59.3': {} + '@typescript-eslint/types@8.66.0': {} + '@typescript-eslint/typescript-estree@8.59.3(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.59.3(typescript@5.9.3) @@ -5325,9 +5408,24 @@ snapshots: '@typescript-eslint/types': 8.59.3 '@typescript-eslint/visitor-keys': 8.59.3 debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.0 - tinyglobby: 0.2.16 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.66.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.66.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -5344,12 +5442,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.66.0(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.59.3': dependencies: '@typescript-eslint/types': 8.59.3 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0))': + '@typescript-eslint/visitor-keys@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -5357,7 +5471,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -5369,13 +5483,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0))': + '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -5420,7 +5534,7 @@ snapshots: acorn@8.16.0: {} - acorn@8.17.0: {} + acorn@8.18.0: {} agent-base@7.1.4: {} @@ -5572,6 +5686,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -5636,7 +5754,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -5645,7 +5763,7 @@ snapshots: chromium-edge-launcher@0.3.0: dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -6198,6 +6316,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6387,7 +6509,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.6: {} image-size@1.2.1: dependencies: @@ -6597,7 +6719,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.13.2 + '@types/node': 24.13.3 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -6623,7 +6745,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 24.13.2 + '@types/node': 24.13.3 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -6811,7 +6933,7 @@ snapshots: metro-minify-terser@0.84.4: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.48.0 + terser: 5.49.1 metro-resolver@0.84.4: dependencies: @@ -6824,8 +6946,8 @@ snapshots: metro-source-map@0.84.4: dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 metro-symbolicate: 0.84.4 @@ -6850,9 +6972,9 @@ snapshots: metro-transform-plugins@0.84.4: dependencies: '@babel/core': 7.29.0 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 flow-enums-runtime: 0.0.6 nullthrows: 1.1.1 transitivePeerDependencies: @@ -6861,9 +6983,9 @@ snapshots: metro-transform-worker@0.84.4: dependencies: '@babel/core': 7.29.0 - '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 metro: 0.84.4 metro-babel-transformer: 0.84.4 @@ -6882,11 +7004,11 @@ snapshots: dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.0 - '@babel/generator': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 accepts: 2.0.0 ci-info: 2.0.0 connect: 3.7.0 @@ -6917,7 +7039,7 @@ snapshots: serialize-error: 2.1.0 source-map: 0.5.7 throat: 5.0.0 - ws: 7.5.11 + ws: 7.5.13 yargs: 17.7.3 transitivePeerDependencies: - bufferutil @@ -6943,6 +7065,10 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.14 @@ -7156,6 +7282,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.5: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.14: @@ -7189,7 +7317,7 @@ snapshots: '@jest/schemas': 30.4.1 ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 - react-is-19: react-is@19.2.7 + react-is-19: react-is@19.2.8 promise@8.3.0: dependencies: @@ -7211,8 +7339,8 @@ snapshots: react-devtools-core@6.1.5: dependencies: - shell-quote: 1.8.4 - ws: 7.5.11 + shell-quote: 1.10.0 + ws: 7.5.13 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -7251,6 +7379,8 @@ snapshots: react-is@19.2.7: {} + react-is@19.2.8: {} + react-native-svg@15.15.5(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: css-select: 5.2.2 @@ -7291,7 +7421,7 @@ snapshots: stacktrace-parser: 0.1.11 tinyglobby: 0.2.17 whatwg-fetch: 3.6.20 - ws: 7.5.11 + ws: 7.5.13 yargs: 17.7.3 optionalDependencies: '@types/react': 19.2.14 @@ -7510,7 +7640,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.10.0: {} side-channel-list@1.0.1: dependencies: @@ -7693,10 +7823,10 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - terser@5.48.0: + terser@5.49.1: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -7715,8 +7845,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@1.1.1: {} @@ -7799,12 +7929,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.59.3(eslint@9.39.4)(typescript@5.9.3): + typescript-eslint@8.66.0(eslint@9.39.4)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.66.0(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: @@ -7848,13 +7978,13 @@ snapshots: utils-merge@1.0.1: {} - vite-node@3.2.4(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0): + vite-node@3.2.4(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -7869,7 +7999,7 @@ snapshots: - tsx - yaml - vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0): + vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -7880,14 +8010,14 @@ snapshots: optionalDependencies: '@types/node': 24.12.4 fsevents: 2.3.3 - terser: 5.48.0 + terser: 5.49.1 yaml: 2.9.0 - vitest@3.2.4(@types/node@24.12.4)(jsdom@26.1.0)(terser@5.48.0)(yaml@2.9.0): + vitest@3.2.4(@types/node@24.12.4)(jsdom@26.1.0)(terser@5.49.1)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -7905,8 +8035,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@24.12.4)(terser@5.48.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.12.4)(terser@5.49.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 @@ -8012,7 +8142,7 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - ws@7.5.11: {} + ws@7.5.13: {} ws@8.20.1: {} diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 140894fd1..df5ee50ba 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -113,7 +113,9 @@ import DynamicEnumControl, { dynamicEnumTester } from './DynamicEnumControl'; import ShellInputControl, { shellInputControlTester, } from './jsonforms/ShellInputControl'; +import { applyClearOnHideToRenderers } from './jsonforms/applyClearOnHideToRenderers'; import type { KeyboardPrimaryEnterKeyHint } from './utils/keyboardEnterKeyHint'; +import { additionalErrorsForDisplay } from './utils/additionalErrorsForDisplay'; import ErrorBoundary from './components/ErrorBoundary'; import { draftService } from './services/DraftService'; @@ -128,10 +130,8 @@ import { runCustomValidatorsAndRefreshData } from './services/customValidatorDat import { resolveErrorPageIndex } from './utils/errorPageNavigation'; import { applyAutoSequences } from './utils/autoSequence'; import { newDraftSessionKey } from './utils/draftSessionKey'; -import { - formDataJsonEqual, - mergePreservingSubObsArrays, -} from './renderers/subObservationHelpers'; +import { formDataJsonEqual } from './renderers/subObservationHelpers'; +import { mergeIncomingFormData } from './jsonforms/clearHiddenControlData'; /** Embedded sub-observation session (also accepts legacy `returnOnly` from older hosts). */ function isSubObservationSession(init: FormInitData): boolean { @@ -461,9 +461,10 @@ function App() { // Deferred validation: new forms start hidden (no red errors on first paint), // then switch to ValidateAndShow on first forward navigation / finalize. Edits // and draft resumes start shown. Host can override via params.validationMode. + // Default Hide so pre-init flash cannot paint Show before FormInitData arrives. const [validationMode, setValidationMode] = useState< 'ValidateAndShow' | 'ValidateAndHide' | 'NoValidation' - >('ValidateAndShow'); + >('ValidateAndHide'); const [uiLocale, setUiLocale] = useState('en'); const uiLocaleRef = useRef(uiLocale); uiLocaleRef.current = uiLocale; @@ -473,6 +474,20 @@ function App() { const odeI18n = useMemo(() => createOdeI18n(uiLocale), [uiLocale]); + // Clear-on-hide is Formplayer's sole SHOW/HIDE data policy for Controls. + // Memoize so registry HOCs are stable across App re-renders. + const formRenderers = useMemo( + () => + applyClearOnHideToRenderers([ + ...shellMaterialRenderers, + ...materialRenderers, + ...customRenderers, + ...customTypeRenderers, + ...extensionRenderers, + ]), + [customTypeRenderers, extensionRenderers], + ); + // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); const isLoadingRef = useRef( @@ -1149,7 +1164,12 @@ function App() { ({ data: newData }: { data: FormData }) => { const incoming = newData as Record; const baseline = dataRef.current as Record; - const merged = mergePreservingSubObsArrays(baseline, incoming); + // Preserve off-page SwipeLayout fields, then omit answers that are not + // relevant (key deletion = unanswered for AJV — not null). + const merged = mergeIncomingFormData(baseline, incoming, { + uischema, + ajv, + }); void (async () => { const refreshedData = await refreshFormData(merged); if (formDataJsonEqual(refreshedData, baseline)) { @@ -1160,7 +1180,7 @@ function App() { persistDraftIfRootSession(refreshedData); })(); }, - [refreshFormData, persistDraftIfRootSession], + [refreshFormData, persistDraftIfRootSession, uischema, ajv], ); const commitFormData = useCallback( @@ -1549,18 +1569,15 @@ function App() { uischema={uischema} data={data} i18n={odeI18n} - renderers={[ - ...shellMaterialRenderers, - ...materialRenderers, - ...customRenderers, - ...customTypeRenderers, // Custom question types from custom_app - ...extensionRenderers, // Extension renderers (highest priority) - ]} + renderers={formRenderers} cells={materialCells} onChange={handleDataChange} validationMode={validationMode} ajv={ajv} - additionalErrors={customValidatorErrors} + additionalErrors={additionalErrorsForDisplay( + validationMode, + customValidatorErrors, + )} /> {/* Success Snackbar */} diff --git a/formulus-formplayer/src/DynamicEnumControl.tsx b/formulus-formplayer/src/DynamicEnumControl.tsx index 4c0ca6358..67edf287c 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -20,6 +20,8 @@ import { CircularProgress, } from '@mui/material'; import QuestionShell from './components/QuestionShell'; +import { useClearOnHide } from './jsonforms/useClearOnHide'; +import { useOdeT } from './i18n/useOdeT'; /** * Interface for x-dynamicEnum configuration @@ -128,6 +130,9 @@ const DynamicEnumControl: React.FC = ({ }) => { const { functions } = useFormEvaluation(); const ctx = useJsonForms(); + const t = useOdeT(); + + useClearOnHide({ visible, path, data, handleChange }); const [choices, setChoices] = useState>( [], @@ -155,9 +160,11 @@ const DynamicEnumControl: React.FC = ({ [handleChange, path], ); - // Find selected option based on current data value - must be before early returns + // Find selected option based on current data value - must be before early returns. + // Coerce so number/string mismatches from saved observations still resolve. const selectedOption = useMemo(() => { - return choices.find(opt => opt.const === data) || null; + if (data == null || data === '') return null; + return choices.find(opt => String(opt.const) === String(data)) || null; }, [choices, data]); // Get display label from schema or uischema - computed before early returns @@ -184,13 +191,20 @@ const DynamicEnumControl: React.FC = ({ // Load choices when component mounts or params change const loadChoices = useCallback(async () => { if (!dynamicConfig) { - setError('x-dynamicEnum configuration is missing'); + setError( + t( + 'dynamicEnum.configMissing', + 'x-dynamicEnum configuration is missing', + ), + ); return; } // Validate configuration if (!dynamicConfig.query) { - setError('x-dynamicEnum: query is required'); + setError( + t('dynamicEnum.queryRequired', 'x-dynamicEnum: query is required'), + ); return; } @@ -200,7 +214,14 @@ const DynamicEnumControl: React.FC = ({ if (!func) { const availableFunctions = Array.from(functions.keys()).join(', '); setError( - `Function "${functionName}" not found. Available: ${availableFunctions || 'none'}.`, + t( + 'dynamicEnum.functionNotFound', + 'Function "{{functionName}}" not found. Available: {{available}}.', + { + functionName, + available: availableFunctions || 'none', + }, + ), ); return; } @@ -248,13 +269,15 @@ const DynamicEnumControl: React.FC = ({ }; setLocalSchema(updatedSchema); } catch (err: any) { - const errorMessage = err?.message || 'Failed to load dynamic choices'; + const errorMessage = + err?.message || + t('dynamicEnum.loadFailed', 'Failed to load dynamic choices'); setError(`${errorMessage}`); console.error(`Error loading dynamic choices for ${path}:`, err); } finally { setLoading(false); } - }, [dynamicConfig, functions, path, localSchema, currentFormData]); // Use currentFormData instead + }, [dynamicConfig, functions, path, localSchema, currentFormData, t]); // Use currentFormData instead // Load choices on mount, when config changes, and when form data changes (for cascading filters) // currentFormData must be in deps so fields that use {{data.field}} templates reload @@ -308,7 +331,12 @@ const DynamicEnumControl: React.FC = ({ {description} )} - x-dynamicEnum configuration is missing + + {t( + 'dynamicEnum.configMissing', + 'x-dynamicEnum configuration is missing', + )} + ); } @@ -323,7 +351,7 @@ const DynamicEnumControl: React.FC = ({ - Loading choices... + {t('dynamicEnum.loading', 'Loading choices...')} ) : error ? ( @@ -336,12 +364,12 @@ const DynamicEnumControl: React.FC = ({ color="primary" sx={{ cursor: 'pointer', textDecoration: 'underline' }} onClick={loadChoices}> - Retry + {t('dynamicEnum.retry', 'Retry')} ) : choices.length === 0 ? ( - No options available + {t('dynamicEnum.noOptions', 'No options available')} ) : ( = ({ onChange={handleValueChange} options={choices} getOptionLabel={option => option.title || String(option.const)} - isOptionEqualToValue={(option, value) => option.const === value.const} + isOptionEqualToValue={(option, value) => + String(option.const) === String(value.const) + } disabled={!enabled} sx={{ mt: 1 }} renderInput={params => ( )} /> diff --git a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx index 7fde889d1..98f4a201d 100644 --- a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx +++ b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx @@ -13,6 +13,7 @@ import { resolveControlDescription, resolveControlLabel, } from '../utils/controlDisplayText'; +import { useClearOnHide } from './useClearOnHide'; /** * String control that renders the stock MuiInputText cell inside QuestionShell @@ -21,8 +22,18 @@ import { */ const ShellInputControl = (props: ControlProps) => { const { keyboardEnterKeyHint } = useFormContext(); - const { uischema, schema, errors, visible, required } = props; + const { + uischema, + schema, + errors, + visible, + required, + path, + data, + handleChange, + } = props; + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; const title = resolveControlLabel(props); diff --git a/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.test.tsx b/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.test.tsx new file mode 100644 index 000000000..cadf23728 --- /dev/null +++ b/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import React, { useState } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { + render, + screen, + cleanup, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import Ajv from 'ajv'; +import { theme } from '../theme/theme'; +import { applyClearOnHideToRenderers } from './applyClearOnHideToRenderers'; +import { shellMaterialRenderers } from '../theme/material-wrappers'; + +const ajv = new Ajv({ allErrors: true, strict: false }); + +afterEach(() => cleanup()); + +describe('applyClearOnHideToRenderers', () => { + it('clears a Control value via the registry wrapper when hidden', async () => { + const schema: JsonSchema7 = { + type: 'object', + properties: { + gate: { type: 'string', title: 'Gate', enum: ['yes', 'no'] }, + detail: { type: 'string', title: 'Detail' }, + }, + }; + const uischema: UISchemaElement = { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/gate', + options: { display: 'buttons' }, + }, + { + type: 'Control', + scope: '#/properties/detail', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/gate', + schema: { const: 'yes' }, + }, + }, + }, + ], + } as UISchemaElement; + + function Harness() { + const [data, setData] = useState>({ + gate: 'yes', + detail: 'keep-me', + }); + return ( + +

{JSON.stringify(data)}
+ + setData(next as Record) + } + /> + + ); + } + + render(); + expect(screen.getByDisplayValue('keep-me')).toBeTruthy(); + + fireEvent.click(screen.getAllByRole('button', { name: 'no' })[0]); + + await waitFor(() => { + const data = JSON.parse( + screen.getByTestId('data-json').textContent || '{}', + ) as Record; + expect(data.gate).toBe('no'); + expect(data.detail).toBeUndefined(); + }); + expect(screen.queryByDisplayValue('keep-me')).toBeNull(); + }); +}); diff --git a/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.tsx b/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.tsx new file mode 100644 index 000000000..89b218e9e --- /dev/null +++ b/formulus-formplayer/src/jsonforms/applyClearOnHideToRenderers.tsx @@ -0,0 +1,106 @@ +import React, { useCallback } from 'react'; +import { + Actions, + Resolve, + Runtime, + composeWithUi, + getAjv, + type JsonFormsRendererRegistryEntry, + type UISchemaElement, +} from '@jsonforms/core'; +import { useJsonForms } from '@jsonforms/react'; +import { useClearOnHide, type ClearOnHideHandleChange } from './useClearOnHide'; + +type RegistryRendererProps = { + visible?: boolean; + path?: string; + data?: unknown; + handleChange?: ClearOnHideHandleChange; + uischema?: UISchemaElement & { type?: string; scope?: string }; +}; + +/** + * Registry HOC: clear Control values on SHOW/HIDE hide. + * + * JsonForms does **not** pass `visible` or the composed control `path` into + * registry renderers — those are injected later by `withJsonFormsControlProps`. + * We therefore: + * - compose the data path via {@link composeWithUi} + * - evaluate visibility via {@link Runtime.isVisible} + * + * Layouts and Labels are skipped (`uischema.type !== 'Control'`). + */ +export function withRegistryClearOnHide

( + Component: React.ComponentType

, +): React.FC

{ + const Wrapped: React.FC

= props => { + const isControl = props.uischema?.type === 'Control'; + const ctx = useJsonForms(); + const rootData = ctx.core?.data; + const dispatch = ctx.dispatch; + const ajv = getAjv({ jsonforms: { ...ctx } } as never); + + const parentPath = typeof props.path === 'string' ? props.path : ''; + const path = + isControl && props.uischema + ? composeWithUi(props.uischema, parentPath) + : undefined; + + // Prefer an explicit `visible` when present (inner ControlProps / tests); + // otherwise mirror JsonForms rule evaluation for registry-level wraps. + const visible = + props.visible !== undefined + ? props.visible + : isControl && props.uischema && ajv + ? Runtime.isVisible(props.uischema, rootData, ajv, ctx.config) + : true; + + const dataFromStore = + isControl && path && rootData != null + ? Resolve.data(rootData, path) + : undefined; + // Only trust props.data when ControlProps were injected (has handleChange). + // Registry dispatch never passes data — undefined means "use store". + const data = + props.handleChange !== undefined && props.data !== undefined + ? props.data + : dataFromStore; + + const injectedHandleChange = props.handleChange; + const handleChange = useCallback( + (p, value) => { + if (typeof injectedHandleChange === 'function') { + injectedHandleChange(p, value); + return; + } + dispatch?.(Actions.update(p, () => value)); + }, + [injectedHandleChange, dispatch], + ); + + useClearOnHide({ + visible: isControl ? visible : true, + path: isControl ? path : undefined, + data: isControl ? data : undefined, + handleChange: isControl ? handleChange : undefined, + }); + + return ; + }; + Wrapped.displayName = `WithRegistryClearOnHide(${ + Component.displayName || Component.name || 'Component' + })`; + return Wrapped; +} + +/** Apply clear-on-hide to every renderer registry entry (Formplayer default). */ +export function applyClearOnHideToRenderers( + renderers: JsonFormsRendererRegistryEntry[], +): JsonFormsRendererRegistryEntry[] { + return renderers.map(entry => ({ + ...entry, + renderer: withRegistryClearOnHide( + entry.renderer as React.ComponentType, + ), + })); +} diff --git a/formulus-formplayer/src/jsonforms/clearHiddenControlData.test.ts b/formulus-formplayer/src/jsonforms/clearHiddenControlData.test.ts new file mode 100644 index 000000000..4a58f295c --- /dev/null +++ b/formulus-formplayer/src/jsonforms/clearHiddenControlData.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { createAjv } from '@jsonforms/core'; +import type { UISchemaElement } from '@jsonforms/core'; +import { + clearHiddenControlData, + mergeIncomingFormData, +} from './clearHiddenControlData'; +import { mergePreservingSubObsArrays } from '../renderers/subObservationHelpers'; + +const ajv = createAjv(); + +/** Minimal cap_ses-style consent page + following group. */ +const uischema = { + type: 'SwipeLayout', + elements: [ + { + type: 'Group', + label: 'Consentimento', + elements: [ + { type: 'Control', scope: '#/properties/consent' }, + { + type: 'Control', + scope: '#/properties/reason_no_consent', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/consent', + schema: { const: '2' }, + }, + }, + }, + ], + }, + { + type: 'Group', + label: 'Principal respondente', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/consent', + schema: { const: '1' }, + }, + }, + elements: [ + { + type: 'Control', + scope: '#/properties/m1_sexo', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/consent', + schema: { const: '1' }, + }, + }, + }, + ], + }, + ], +} as UISchemaElement; + +describe('clearHiddenControlData', () => { + it('omits reason_no_consent when consent flips to Sim (1)', () => { + const data = { + consent: '1', + reason_no_consent: 'Não tenho tempo', + m1_sexo: '1', + }; + const next = clearHiddenControlData(data, uischema, ajv); + expect(next.consent).toBe('1'); + expect(next).not.toHaveProperty('reason_no_consent'); + expect(next.m1_sexo).toBe('1'); + }); + + it('omits Sim-path fields when consent is Não (2), including under a hidden Group', () => { + const data = { + consent: '2', + reason_no_consent: 'Não tenho tempo', + m1_sexo: '2', + }; + const next = clearHiddenControlData(data, uischema, ajv); + expect(next.consent).toBe('2'); + expect(next.reason_no_consent).toBe('Não tenho tempo'); + expect(next).not.toHaveProperty('m1_sexo'); + }); + + it('strips stale null clears so AJV sees unanswered, not Invalid value', () => { + const data = { + consent: '1', + reason_no_consent: null, + }; + const next = clearHiddenControlData(data, uischema, ajv); + expect(next).not.toHaveProperty('reason_no_consent'); + }); +}); + +describe('mergeIncomingFormData', () => { + it('keeps off-page answers but drops ones made irrelevant by SHOW/HIDE', () => { + const baseline = { + consent: '2', + reason_no_consent: 'Não tenho tempo', + m1_sexo: '1', + obsdate: '2026-06-19', + }; + // Partial SwipeLayout payload after flipping consent to Sim; reason key deleted. + const incoming = { consent: '1', m1_sexo: '1' }; + + const restoredOnly = mergePreservingSubObsArrays(baseline, incoming); + expect(restoredOnly.reason_no_consent).toBe('Não tenho tempo'); + + const next = mergeIncomingFormData(baseline, incoming, { uischema, ajv }); + expect(next.consent).toBe('1'); + expect(next.obsdate).toBe('2026-06-19'); // off-page prefill kept + expect(next).not.toHaveProperty('reason_no_consent'); // irrelevant omitted + expect(next.m1_sexo).toBe('1'); + }); +}); diff --git a/formulus-formplayer/src/jsonforms/clearHiddenControlData.ts b/formulus-formplayer/src/jsonforms/clearHiddenControlData.ts new file mode 100644 index 000000000..8c138007d --- /dev/null +++ b/formulus-formplayer/src/jsonforms/clearHiddenControlData.ts @@ -0,0 +1,109 @@ +import { + composeWithUi, + isControl, + isVisible, + Resolve, + type UISchemaElement, +} from '@jsonforms/core'; +import type Ajv from 'ajv'; +import { + mergePreservingSubObsArrays, + omitDataPath, +} from '../renderers/subObservationHelpers'; + +type WalkEl = UISchemaElement & { + type?: string; + elements?: UISchemaElement[]; + scope?: string; +}; + +/** True when a value (including stale `null` clears) should be stripped for a hidden Control. */ +function hasPresentHiddenValue(data: unknown): boolean { + // Missing key → undefined. Stale null/'' from older clear-on-hide builds still strip. + return data !== undefined; +} + +/** + * Omit every Control that is not visible under current data (including Controls + * nested under a hidden Group/page), even if those Controls are not currently + * mounted (SwipeLayout / FlatGroup early-return). + * + * Deletes keys (unanswered) — do not write `null`, which AJV reports as + * "Invalid value" for typed schemas. + */ +export function clearHiddenControlData( + data: Record, + uischema: UISchemaElement | null | undefined, + ajv: Ajv | undefined, + config: unknown = {}, + dispatchPath = '', +): Record { + if (!uischema || !ajv) return data; + + let next = data; + let changed = false; + + const omitHidden = (controlPath: string, current: unknown) => { + if (!hasPresentHiddenValue(current)) return; + if (!changed) { + next = { ...data }; + changed = true; + } + next = omitDataPath(next, controlPath); + }; + + const visit = (element: WalkEl, path: string, ancestorVisible: boolean) => { + const selfVisible = + ancestorVisible && isVisible(element, data, path, ajv, config); + + if (isControl(element)) { + if (!selfVisible) { + const controlPath = composeWithUi(element, path); + if (controlPath) { + omitHidden(controlPath, Resolve.data(next, controlPath)); + } + } + return; + } + + const children = element.elements; + if (!Array.isArray(children) || children.length === 0) { + return; + } + + for (const child of children) { + visit(child as WalkEl, path, selfVisible); + } + }; + + visit(uischema as WalkEl, dispatchPath, true); + return next; +} + +export type MergeIncomingFormDataOptions = { + uischema?: UISchemaElement | null; + ajv?: Ajv; + config?: unknown; +}; + +/** + * SwipeLayout-safe data update: preserve off-page baseline fields, then omit + * answers that SHOW/HIDE says are not relevant. + * + * This is the App `onChange` entry point — keeps clear-on-hide (key deletion / + * unanswered) compatible with {@link mergePreservingSubObsArrays} without a + * `null` sentinel that breaks AJV. + */ +export function mergeIncomingFormData( + baseline: Record, + incoming: Record, + options: MergeIncomingFormDataOptions = {}, +): Record { + const merged = mergePreservingSubObsArrays(baseline, incoming); + return clearHiddenControlData( + merged, + options.uischema, + options.ajv, + options.config, + ); +} diff --git a/formulus-formplayer/src/jsonforms/clearOnHide.cascade.test.tsx b/formulus-formplayer/src/jsonforms/clearOnHide.cascade.test.tsx new file mode 100644 index 000000000..e7322e530 --- /dev/null +++ b/formulus-formplayer/src/jsonforms/clearOnHide.cascade.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +/** + * Parent SHOW → child SHOW → grandchild: hiding the parent must clear the + * child's value so the grandchild's SHOW rule fails (ODK-style relevant). + */ +import React, { useState } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { + render, + screen, + cleanup, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import Ajv from 'ajv'; +import { theme } from '../theme/theme'; +import { FormContext } from '../App'; +import { shellMaterialRenderers } from '../theme/material-wrappers'; + +const ajv = new Ajv({ allErrors: true, strict: false }); + +const schema: JsonSchema7 = { + type: 'object', + properties: { + parent: { + type: 'string', + title: 'Parent', + enum: ['1', '2'], + }, + child: { + type: 'string', + title: 'Child', + enum: ['1', '2'], + }, + grandchild: { + type: 'string', + title: 'Grandchild', + enum: ['1', '2'], + }, + }, +}; + +const uischema: UISchemaElement = { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/parent', + options: { display: 'buttons' }, + }, + { + type: 'Control', + scope: '#/properties/child', + options: { display: 'buttons' }, + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/parent', + schema: { const: '1' }, + }, + }, + }, + { + type: 'Control', + scope: '#/properties/grandchild', + options: { display: 'buttons' }, + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/child', + schema: { const: '1' }, + }, + }, + }, + ], +} as UISchemaElement; + +const renderers = [...shellMaterialRenderers, ...materialRenderers]; + +function CascadeHarness({ + initialData, +}: { + initialData: Record; +}) { + const [data, setData] = useState(initialData); + return ( + + +

{JSON.stringify(data)}
+ + setData(next as Record) + } + /> + + + ); +} + +afterEach(() => cleanup()); + +describe('clear-on-hide cascade (SHOW/HIDE)', () => { + it('clears child when parent hides it, which hides grandchild', async () => { + render( + , + ); + + expect(screen.getByText('Child')).toBeTruthy(); + expect(screen.getByText('Grandchild')).toBeTruthy(); + + // Three fields each expose a "2" toggle; first is Parent. + const parentTwo = screen.getAllByRole('button', { name: '2' })[0]; + fireEvent.click(parentTwo); + + await waitFor(() => { + const raw = screen.getByTestId('data-json').textContent || '{}'; + const data = JSON.parse(raw) as Record; + expect(data.parent).toBe('2'); + expect(data.child).toBeUndefined(); + expect(data.grandchild).toBeUndefined(); + }); + + expect(screen.queryByText('Child')).toBeNull(); + expect(screen.queryByText('Grandchild')).toBeNull(); + }); +}); diff --git a/formulus-formplayer/src/jsonforms/useClearOnHide.test.tsx b/formulus-formplayer/src/jsonforms/useClearOnHide.test.tsx new file mode 100644 index 000000000..c6f96af23 --- /dev/null +++ b/formulus-formplayer/src/jsonforms/useClearOnHide.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { hasClearableValue, useClearOnHide } from './useClearOnHide'; + +afterEach(() => cleanup()); + +function Probe(props: { + visible?: boolean; + path?: string; + data?: unknown; + handleChange?: (path: string, value: unknown) => void; +}) { + useClearOnHide(props); + return null; +} + +describe('hasClearableValue', () => { + it('is false for empty sentinels', () => { + expect(hasClearableValue(undefined)).toBe(false); + expect(hasClearableValue(null)).toBe(false); + expect(hasClearableValue('')).toBe(false); + expect(hasClearableValue([])).toBe(false); + }); + + it('is true for scalars and non-empty arrays', () => { + expect(hasClearableValue('1')).toBe(true); + expect(hasClearableValue(0)).toBe(true); + expect(hasClearableValue(false)).toBe(true); + expect(hasClearableValue(['x'])).toBe(true); + }); +}); + +describe('useClearOnHide', () => { + it('clears once when visible flips to false with a value', async () => { + const handleChange = vi.fn(); + const { rerender } = render( + , + ); + expect(handleChange).not.toHaveBeenCalled(); + + rerender( + , + ); + await waitFor(() => { + expect(handleChange).toHaveBeenCalledWith('child', undefined); + }); + expect(handleChange).toHaveBeenCalledTimes(1); + + // Already empty → no further clears + rerender( + , + ); + await waitFor(() => { + expect(handleChange).toHaveBeenCalledTimes(1); + }); + }); + + it('does not clear when already empty or when visible stays true', async () => { + const handleChange = vi.fn(); + const { rerender } = render( + , + ); + await waitFor(() => { + expect(handleChange).not.toHaveBeenCalled(); + }); + + rerender( + , + ); + await waitFor(() => { + expect(handleChange).not.toHaveBeenCalled(); + }); + }); + + it('no-ops without path or handleChange', async () => { + const handleChange = vi.fn(); + render(); + render(); + await waitFor(() => { + expect(handleChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/formulus-formplayer/src/jsonforms/useClearOnHide.ts b/formulus-formplayer/src/jsonforms/useClearOnHide.ts new file mode 100644 index 000000000..679b63022 --- /dev/null +++ b/formulus-formplayer/src/jsonforms/useClearOnHide.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; + +export type ClearOnHideHandleChange = (path: string, value: unknown) => void; + +export type UseClearOnHideArgs = { + visible?: boolean; + path?: string; + data?: unknown; + handleChange?: ClearOnHideHandleChange; +}; + +/** True when the control holds a value worth clearing on hide. */ +export function hasClearableValue(data: unknown): boolean { + if (data === undefined || data === null || data === '') return false; + if (Array.isArray(data) && data.length === 0) return false; + return true; +} + +/** + * When JsonForms marks a control `visible: false` (SHOW/HIDE), clear its value + * so dependent relevance rules re-evaluate (ODK-style `relevant` behaviour). + * + * Clears with `undefined` (JsonForms deletes the key = unanswered). App must + * apply {@link mergeIncomingFormData} on `onChange` so SwipeLayout's baseline + * merge cannot resurrect those keys. + */ +export function useClearOnHide({ + visible, + path, + data, + handleChange, +}: UseClearOnHideArgs): void { + useEffect(() => { + if (visible !== false) return; + if (!path || typeof handleChange !== 'function') return; + if (!hasClearableValue(data)) return; + handleChange(path, undefined); + }, [visible, path, data, handleChange]); +} diff --git a/formulus-formplayer/src/jsonforms/visibleGuard.test.tsx b/formulus-formplayer/src/jsonforms/visibleGuard.test.tsx index 3c736b833..0c76c73f8 100644 --- a/formulus-formplayer/src/jsonforms/visibleGuard.test.tsx +++ b/formulus-formplayer/src/jsonforms/visibleGuard.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; import { isControlHidden, withVisibleGuard } from './visibleGuard'; describe('visibleGuard', () => { @@ -20,4 +20,34 @@ describe('visibleGuard', () => { rerender(); expect(screen.queryByText('shown')).toBeNull(); }); + + it('withVisibleGuard clears control data when visible becomes false', async () => { + const handleChange = vi.fn(); + const Inner = ({ label }: { label: string; visible?: boolean }) => ( + {label} + ); + const Guarded = withVisibleGuard(Inner); + const { rerender } = render( + , + ); + expect(handleChange).not.toHaveBeenCalled(); + rerender( + , + ); + await waitFor(() => { + expect(handleChange).toHaveBeenCalledWith('fez', undefined); + }); + }); }); diff --git a/formulus-formplayer/src/jsonforms/visibleGuard.tsx b/formulus-formplayer/src/jsonforms/visibleGuard.tsx index 143fb1fde..fd83e283f 100644 --- a/formulus-formplayer/src/jsonforms/visibleGuard.tsx +++ b/formulus-formplayer/src/jsonforms/visibleGuard.tsx @@ -1,17 +1,40 @@ import React from 'react'; +import { useClearOnHide, type ClearOnHideHandleChange } from './useClearOnHide'; +import { + applyClearOnHideToRenderers, + withRegistryClearOnHide, +} from './applyClearOnHideToRenderers'; /** JsonForms passes `visible: false` when SHOW/HIDE rules hide a control. */ export function isControlHidden(visible?: boolean): boolean { return visible === false; } +type VisibleGuardProps = { + visible?: boolean; + path?: string; + data?: unknown; + handleChange?: ClearOnHideHandleChange; +}; + /** - * HOC: return null when JsonForms marks the control hidden (SHOW/HIDE rules). + * HOC: clear the control value when hidden, then return null (SHOW/HIDE rules). + * Clearing requires JsonForms ControlProps (`path`, `data`, `handleChange`); + * without them it only hides (same as before). + * + * Prefer {@link applyClearOnHideToRenderers} for the Formplayer registry so + * stock Material controls are covered too; this HOC remains for local wrappers. */ -export function withVisibleGuard

( +export function withVisibleGuard

( Component: React.ComponentType

, ): React.FC

{ const Guarded = (props: P) => { + useClearOnHide({ + visible: props.visible, + path: props.path, + data: props.data, + handleChange: props.handleChange, + }); if (isControlHidden(props.visible)) return null; return ; }; @@ -20,3 +43,6 @@ export function withVisibleGuard

( })`; return Guarded; } + +export { useClearOnHide, hasClearableValue } from './useClearOnHide'; +export { applyClearOnHideToRenderers, withRegistryClearOnHide }; diff --git a/formulus-formplayer/src/locales/en.json b/formulus-formplayer/src/locales/en.json index cacb2df77..99b4e5924 100644 --- a/formulus-formplayer/src/locales/en.json +++ b/formulus-formplayer/src/locales/en.json @@ -82,7 +82,24 @@ "media.qrCode": "QR Code", "media.approximateDate": "Approximate Date", "media.takePhoto": "Take photo", + "media.openingCamera": "Opening camera...", + "media.retakePhoto": "Retake photo", + "media.deletePhoto": "Delete photo", + "media.captureHint": "Capture a clear photo.", + "media.capturedPhotoAlt": "Captured photo", + "media.invalidFilename": "Invalid photo filename from camera.", + "media.cameraError": "Camera error occurred", + "media.unknownCameraError": "Unknown camera error", + "media.captureFailed": "Failed to capture photo. Please try again.", "media.recording": "Recording...", + "dynamicEnum.selectOption": "Select an option...", + "dynamicEnum.loading": "Loading choices...", + "dynamicEnum.retry": "Retry", + "dynamicEnum.noOptions": "No options available", + "dynamicEnum.configMissing": "x-dynamicEnum configuration is missing", + "dynamicEnum.queryRequired": "x-dynamicEnum: query is required", + "dynamicEnum.functionNotFound": "Function \"{{functionName}}\" not found. Available: {{available}}.", + "dynamicEnum.loadFailed": "Failed to load dynamic choices", "cqt.errorTitle": "Custom Question Type Error", "cqt.errorBody": "The custom question type \"{{format}}\" encountered an error and could not be rendered.", "cqt.errorDetails": "Error Details (click to expand)", diff --git a/formulus-formplayer/src/locales/fr.json b/formulus-formplayer/src/locales/fr.json index 44afd1d1f..b5369225f 100644 --- a/formulus-formplayer/src/locales/fr.json +++ b/formulus-formplayer/src/locales/fr.json @@ -82,7 +82,24 @@ "media.qrCode": "Code QR", "media.approximateDate": "Date approximative", "media.takePhoto": "Prendre une photo", + "media.openingCamera": "Ouverture de l'appareil photo...", + "media.retakePhoto": "Reprendre la photo", + "media.deletePhoto": "Supprimer la photo", + "media.captureHint": "Prenez une photo nette.", + "media.capturedPhotoAlt": "Photo capturée", + "media.invalidFilename": "Nom de fichier photo invalide provenant de l'appareil photo.", + "media.cameraError": "Une erreur de l'appareil photo s'est produite", + "media.unknownCameraError": "Erreur inconnue de l'appareil photo", + "media.captureFailed": "Échec de la capture de la photo. Veuillez réessayer.", "media.recording": "Enregistrement...", + "dynamicEnum.selectOption": "Sélectionnez une option...", + "dynamicEnum.loading": "Chargement des options...", + "dynamicEnum.retry": "Réessayer", + "dynamicEnum.noOptions": "Aucune option disponible", + "dynamicEnum.configMissing": "Configuration x-dynamicEnum manquante", + "dynamicEnum.queryRequired": "x-dynamicEnum : la requête est obligatoire", + "dynamicEnum.functionNotFound": "Fonction « {{functionName}} » introuvable. Disponibles : {{available}}.", + "dynamicEnum.loadFailed": "Échec du chargement des options dynamiques", "cqt.errorTitle": "Erreur du type de question personnalisé", "cqt.errorBody": "Le type de question personnalisé « {{format}} » a rencontré une erreur et n'a pas pu être affiché.", "cqt.errorDetails": "Détails de l'erreur (cliquer pour développer)", diff --git a/formulus-formplayer/src/locales/pt.json b/formulus-formplayer/src/locales/pt.json index f81439b8e..0b09f968a 100644 --- a/formulus-formplayer/src/locales/pt.json +++ b/formulus-formplayer/src/locales/pt.json @@ -82,7 +82,24 @@ "media.qrCode": "Código QR", "media.approximateDate": "Data aproximada", "media.takePhoto": "Tirar fotografia", + "media.openingCamera": "A abrir a câmara...", + "media.retakePhoto": "Repetir fotografia", + "media.deletePhoto": "Eliminar fotografia", + "media.captureHint": "Capture uma fotografia nítida.", + "media.capturedPhotoAlt": "Fotografia capturada", + "media.invalidFilename": "Nome de ficheiro de fotografia inválido da câmara.", + "media.cameraError": "Ocorreu um erro da câmara", + "media.unknownCameraError": "Erro desconhecido da câmara", + "media.captureFailed": "Falha ao capturar a fotografia. Tente novamente.", "media.recording": "A gravar...", + "dynamicEnum.selectOption": "Selecione uma opção...", + "dynamicEnum.loading": "A carregar opções...", + "dynamicEnum.retry": "Tentar novamente", + "dynamicEnum.noOptions": "Nenhuma opção disponível", + "dynamicEnum.configMissing": "Configuração x-dynamicEnum em falta", + "dynamicEnum.queryRequired": "x-dynamicEnum: a consulta é obrigatória", + "dynamicEnum.functionNotFound": "Função \"{{functionName}}\" não encontrada. Disponíveis: {{available}}.", + "dynamicEnum.loadFailed": "Falha ao carregar opções dinâmicas", "cqt.errorTitle": "Erro no tipo de pergunta personalizado", "cqt.errorBody": "O tipo de pergunta personalizado \"{{format}}\" encontrou um erro e não pôde ser apresentado.", "cqt.errorDetails": "Detalhes do erro (clique para expandir)", diff --git a/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx b/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx index d431ce46c..d2a6d503a 100644 --- a/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/AdateQuestionRenderer.tsx @@ -18,6 +18,7 @@ import { } from '@mui/material'; import { CalendarToday } from '@mui/icons-material'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { adateToStorageFormat, storageFormatToAdate, @@ -179,6 +180,7 @@ const AdateQuestionRenderer: React.FC = ({ setYearUnknown(false); }, []); + useClearOnHide({ visible, path, data, handleChange }); // Don't render if not visible if (!visible) { return null; diff --git a/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx b/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx index 50fbb4faf..251a9ff6a 100644 --- a/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx @@ -36,6 +36,7 @@ import { } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; import { tokens } from '../theme/tokens-adapter'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { attachmentBasenameFromFilename, attachmentBasenameFromObservation, @@ -376,6 +377,7 @@ const AudioQuestionRenderer: React.FC = ({ !!currentAudioData && typeof meta?.duration === 'number'; + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; return ( diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx index 51d641653..d3122c7ba 100644 --- a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -17,6 +17,7 @@ import { resolveControlLabel, } from '../utils/controlDisplayText'; import { useOdeT } from '../i18n/useOdeT'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; interface ErrorBoundaryLabels { title: string; @@ -139,6 +140,7 @@ export function createCustomQuestionTypeRenderer( visible, } = props; const t = useOdeT(); + useClearOnHide({ visible, path, data, handleChange }); const errorBoundaryLabels = { title: t('cqt.errorTitle', 'Custom Question Type Error'), body: t( diff --git a/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx index 21b5fdf9d..594799914 100644 --- a/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { withJsonFormsControlProps } from '@jsonforms/react'; import { ControlProps, rankWith, schemaMatches } from '@jsonforms/core'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import DurationControl from '../components/duration/DurationControl'; import { formatControlErrors } from '../utils/formatControlErrors'; @@ -42,6 +43,7 @@ const DurationQuestionRenderer: React.FC = ({ : null, ); + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) { return null; } diff --git a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx index f8802ba10..56465b64b 100644 --- a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx @@ -19,11 +19,13 @@ import { FileResultData, } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { attachmentBasenameFromFilename, attachmentBasenameFromObservation, } from '../utils/attachmentBasename'; import FormulusClient from '../services/FormulusInterface'; +import { formatControlErrors } from '../utils/formatControlErrors'; const parsePx = (value: string): number => parseInt(value.replace('px', ''), 10); @@ -174,6 +176,7 @@ const FileQuestionRenderer: React.FC = ({ setSafeError(null); }, [handleChange, path, setSafeError]); + useClearOnHide({ visible, path, data, handleChange }); if (!visible) { return null; } @@ -181,8 +184,7 @@ const FileQuestionRenderer: React.FC = ({ const obs = fileObservationRecord(data); const hasData = obs !== null; const displayName = displayFilenameForFileObservation(obs); - const validationError = - errors && errors.length > 0 ? String(errors[0]) : null; + const validationError = formatControlErrors(errors); const label = (uischema as { label?: string }).label ?? schema.title; const description = schema.description; diff --git a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx index ba02193a6..320f88af4 100644 --- a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx +++ b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx @@ -13,7 +13,8 @@ import { formatDurationHuman } from '../components/duration/durationFormat'; import { useOdeT } from '../i18n/useOdeT'; import { translateAjvError } from '../i18n/createOdeI18n'; import { FormplayerLocaleContext } from '../i18n/FormplayerLocaleContext'; -import { titleForErrorPath } from '../utils/errorPageNavigation'; +import { titleForAjvError } from '../utils/errorPageNavigation'; +import { instancePathForAjvError } from '../utils/validationNavigation'; import { resolveFieldLabel } from '../utils/controlDisplayText'; import type { JsonSchema7 } from '@jsonforms/core'; @@ -287,8 +288,8 @@ const FinalizeRenderer = ({ data }: ControlProps) => { }, [fullSchema, data, findFieldPageMemo, getFieldLabel]); const formatErrorMessage = (error: ErrorObject) => { - const title = titleForErrorPath( - error.instancePath, + const title = titleForAjvError( + error, fullSchema as JsonSchema7 | undefined, localizedUiSchema, ); @@ -298,12 +299,18 @@ const FinalizeRenderer = ({ data }: ControlProps) => { const hasErrors = Array.isArray(errors) && errors.length > 0; - const handleErrorClick = (path: string) => { - // Dispatch a custom event that SwipeLayoutRenderer will listen for - const event = new CustomEvent('navigateToError', { - detail: { path }, - }); - window.dispatchEvent(event); + const navigateToPath = (path: string) => { + if (!path) return; + window.dispatchEvent( + new CustomEvent('navigateToError', { + detail: { path }, + }), + ); + }; + + const handleErrorClick = (error: ErrorObject) => { + const path = instancePathForAjvError(error); + if (path) navigateToPath(path); }; const handleFieldEdit = (item: SummaryItem) => { @@ -315,7 +322,7 @@ const FinalizeRenderer = ({ data }: ControlProps) => { window.dispatchEvent(navigateEvent); } else { // Fallback: try to navigate using the field path - handleErrorClick(item.path); + navigateToPath(item.path); } }; @@ -364,7 +371,7 @@ const FinalizeRenderer = ({ data }: ControlProps) => { key={index} variant="danger" size="medium" - onPress={() => handleErrorClick(error.instancePath)} + onPress={() => handleErrorClick(error)} style={{ width: '100%', whiteSpace: 'normal', diff --git a/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx b/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx index 572589bc9..79093350b 100644 --- a/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx @@ -19,6 +19,7 @@ import { MyLocation as MyLocationIcon, } from '@mui/icons-material'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { tokens } from '../theme/tokens-adapter'; import FormulusClient from '../services/FormulusInterface'; import type { LocationResult } from '../types/FormulusInterfaceDefinition'; @@ -155,6 +156,7 @@ const GPSQuestionRenderer: React.FC = props => { const hasValidationErrors = errors && errors.length > 0; const isDisabled = !enabled || isCapturing; + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; return ( diff --git a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx index ca5b7067e..0712e3d57 100644 --- a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx @@ -5,6 +5,7 @@ import QuestionShell from '../components/QuestionShell'; import LikertScaleControl from '../components/likert/LikertScaleControl'; import { resolveLikertOptions } from '../components/likert/likertConfig'; import { formatControlErrors } from '../utils/formatControlErrors'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; export const likertScaleQuestionTester = rankWith( 12, @@ -47,6 +48,7 @@ const LikertScaleQuestionRenderer: React.FC = ({ : null, ); + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) { return null; } diff --git a/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx index a0ffab3d1..212724a53 100644 --- a/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx +++ b/formulus-formplayer/src/renderers/NumberStepperRenderer.tsx @@ -25,6 +25,7 @@ import { useNumericDraftInput, type NumericSchemaKind, } from '../hooks/useNumericDraftInput'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; const isNumberControl: RankedTester = rankWith( 5, @@ -73,6 +74,7 @@ const NumberStepperRenderer = ({ enabled, }); + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; const min = schema.minimum ?? (schema as { minimum?: number }).minimum; diff --git a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx index 9a1624460..39658a864 100644 --- a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx @@ -23,10 +23,17 @@ import { } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; import { tokens } from '../theme/tokens-adapter'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { attachmentBasenameFromFilename, attachmentBasenameFromObservation, } from '../utils/attachmentBasename'; +import { formatControlErrors } from '../utils/formatControlErrors'; +import { + resolveControlDescription, + resolveControlLabel, +} from '../utils/controlDisplayText'; +import { useOdeT } from '../i18n/useOdeT'; // Helper to parse pixel values from tokens const parsePx = (value: string): number => { @@ -67,36 +74,41 @@ interface PhotoQuestionProps extends ControlProps { // Additional props specific to photo questions can be added here } -const PhotoQuestionRenderer: React.FC = ({ - data, - handleChange, - path, - errors, - schema, - uischema, - enabled = true, - visible = true, -}) => { +const PhotoQuestionRenderer: React.FC = props => { + const { + data, + handleChange, + path, + errors, + schema, + enabled = true, + visible = true, + } = props; + + const t = useOdeT(); const [isLoading, setIsLoading] = useState(false); const [photoUrl, setPhotoUrl] = useState(null); const [error, setError] = useState(null); // Safe error setter to prevent corruption - const setSafeError = useCallback((errorMessage: string | null) => { - if (errorMessage === null || errorMessage === undefined) { - setError(null); - } else if (typeof errorMessage === 'string' && errorMessage.length > 0) { - setError(errorMessage); - } else { - console.warn( - 'Invalid error message detected:', - errorMessage, - 'Type:', - typeof errorMessage, - ); - setError('An unknown error occurred'); - } - }, []); + const setSafeError = useCallback( + (errorMessage: string | null) => { + if (errorMessage === null || errorMessage === undefined) { + setError(null); + } else if (typeof errorMessage === 'string' && errorMessage.length > 0) { + setError(errorMessage); + } else { + console.warn( + 'Invalid error message detected:', + errorMessage, + 'Type:', + typeof errorMessage, + ); + setError(t('cqt.unknownError', 'Unknown error')); + } + }, + [t], + ); const formulusClient = useRef(FormulusClient.getInstance()); // Extract field ID from the path for use with the camera interface @@ -151,7 +163,9 @@ const PhotoQuestionRenderer: React.FC = ({ cameraResult.data.filename, ); if (!storedBasename) { - setSafeError('Invalid photo filename from camera.'); + setSafeError( + t('media.invalidFilename', 'Invalid photo filename from camera.'), + ); return; } @@ -195,24 +209,29 @@ const PhotoQuestionRenderer: React.FC = ({ console.log('Camera operation cancelled by user'); setSafeError(null); } else if (cameraError.status === 'error') { - const errorMessage = cameraError.message || 'Camera error occurred'; + const errorMessage = + cameraError.message || + t('media.cameraError', 'Camera error occurred'); console.log('Setting camera error message:', errorMessage); setSafeError(errorMessage); } else { - setSafeError('Unknown camera error'); + setSafeError(t('media.unknownCameraError', 'Unknown camera error')); } } else { const errorMessage = err?.message || err?.toString() || - 'Failed to capture photo. Please try again.'; + t( + 'media.captureFailed', + 'Failed to capture photo. Please try again.', + ); console.log('Setting error message:', errorMessage); setSafeError(errorMessage); } } finally { setIsLoading(false); } - }, [fieldId, enabled, handleChange, path, setSafeError]); + }, [fieldId, enabled, handleChange, path, setSafeError, t]); // Handle photo deletion const handleDeletePhoto = useCallback(() => { @@ -224,22 +243,21 @@ const PhotoQuestionRenderer: React.FC = ({ console.log('Photo deleted for field:', fieldId); }, [fieldId, handleChange, path, enabled, setSafeError]); - // Get display label from schema or uischema - const label = (uischema as any)?.label || schema.title || 'Photo'; - const description = schema.description; + const label = resolveControlLabel(props) || t('media.photo', 'Photo'); + const description = resolveControlDescription(props) ?? schema.description; const isRequired = Boolean( - (uischema as any)?.options?.required ?? + (props.uischema as any)?.options?.required ?? (schema as any)?.options?.required ?? false, ); - const validationError = - errors && errors.length > 0 ? String(errors[0]) : null; + const validationError = formatControlErrors(errors); const displayBasename = attachmentBasenameFromObservation( currentPhotoData as Record | null, ); + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; return ( @@ -250,7 +268,9 @@ const PhotoQuestionRenderer: React.FC = ({ required={isRequired} error={error || validationError} helperText={ - displayBasename ? `File: ${displayBasename}` : 'Capture a clear photo.' + displayBasename + ? undefined + : t('media.captureHint', 'Capture a clear photo.') } metadata={ process.env.NODE_ENV === 'development' ? ( @@ -299,7 +319,7 @@ const PhotoQuestionRenderer: React.FC = ({ component="img" height="200" image={photoUrl} - alt="Captured photo" + alt={t('media.capturedPhotoAlt', 'Captured photo')} sx={{ objectFit: 'cover' }} /> @@ -321,7 +341,7 @@ const PhotoQuestionRenderer: React.FC = ({ disabled={!enabled || isLoading} color="primary" size="small" - aria-label="Retake photo"> + aria-label={t('media.retakePhoto', 'Retake photo')}> = ({ disabled={!enabled} color="error" size="small" - aria-label="Delete photo"> + aria-label={t('media.deletePhoto', 'Delete photo')}> @@ -364,14 +384,16 @@ const PhotoQuestionRenderer: React.FC = ({ color: 'action.disabled', }, }} - aria-label="Take photo"> + aria-label={t('media.takePhoto', 'Take photo')}> - {isLoading ? 'Opening camera...' : 'Tap to capture photo'} + {isLoading + ? t('media.openingCamera', 'Opening camera...') + : t('media.photoTap', 'Tap to capture photo')} )} diff --git a/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx index 5c3bfcc3e..58bbf9544 100644 --- a/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx @@ -22,6 +22,7 @@ import { ControlProps, rankWith, formatIs } from '@jsonforms/core'; import FormulusClient from '../services/FormulusInterface'; import { QrcodeResult } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { resolveControlDescription, resolveControlLabel, @@ -135,6 +136,7 @@ const QrcodeQuestionRenderer: React.FC = props => { console.log('QR code value cleared for field:', fieldId); }, [fieldId, handleChange, path]); + useClearOnHide({ visible, path, data, handleChange }); // Don't render if not visible if (!visible) { return null; diff --git a/formulus-formplayer/src/renderers/SignatureQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SignatureQuestionRenderer.tsx index 81a696759..e4ef06a51 100644 --- a/formulus-formplayer/src/renderers/SignatureQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SignatureQuestionRenderer.tsx @@ -9,6 +9,7 @@ import { withJsonFormsControlProps } from '@jsonforms/react'; import { ControlProps, rankWith, formatIs } from '@jsonforms/core'; import QuestionShell from '../components/QuestionShell'; import { tokens } from '../theme/tokens-adapter'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; // Helper to parse pixel values from tokens const parsePx = (value: string): number => { @@ -212,6 +213,7 @@ const SignatureQuestionRenderer: React.FC = ({ } }, [hasData]); + useClearOnHide({ visible, path, data, handleChange }); // Don't render if not visible if (!visible) { return null; diff --git a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx index 962112270..ae179157f 100644 --- a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx @@ -11,6 +11,7 @@ import { Box, Typography, Button, IconButton, Tooltip } from '@mui/material'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import FormulusClient from '../services/FormulusInterface'; import type { FormCompletionResult } from '../types/FormulusInterfaceDefinition'; import { useFormContext } from '../App'; @@ -481,6 +482,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ ], ); + useClearOnHide({ visible, path, data, handleChange }); if (!visible) return null; const shellError = diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index bb966f1c8..825e7f63d 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -103,6 +103,7 @@ const SwipeLayoutRenderer = ({ const t = useOdeT(); const parentFormContext = useFormContext(); const { formInitData } = parentFormContext; + const showValidationErrors = core?.validationMode === 'ValidateAndShow'; const fallbackAjv = useMemo(() => createAjv(), []); const ajv = core?.ajv ?? fallbackAjv; @@ -683,15 +684,18 @@ const SwipeLayoutRenderer = ({ )}

- {skipFinalize && isLastContentPage && validationErrorCount > 0 && ( - - {validationAlertMessage} - - )} + {skipFinalize && + isLastContentPage && + showValidationErrors && + validationErrorCount > 0 && ( + + {validationAlertMessage} + + )} {snackbarOpen && typeof document !== 'undefined' && diff --git a/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx index 95711b11c..f85c4a264 100644 --- a/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/VideoQuestionRenderer.tsx @@ -33,6 +33,7 @@ import { VideoFile as VideoFileIcon, } from '@mui/icons-material'; import QuestionShell from '../components/QuestionShell'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import FormulusClient from '../services/FormulusInterface'; import { VideoResult, @@ -331,6 +332,7 @@ const VideoQuestionRenderer: React.FC = ({ !!currentVideoData && typeof meta?.duration === 'number'; + useClearOnHide({ visible, path, data, handleChange }); if (visible === false) return null; return ( diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.test.ts b/formulus-formplayer/src/renderers/subObservationHelpers.test.ts index 83b677356..c4a727759 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.test.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { formDataJsonEqual, mergePreservingSubObsArrays, + omitDataPath, readDataPath, writeDataPath, resolveTemplateValue, @@ -70,6 +71,13 @@ describe('subObservationHelpers', () => { expect(merged.num).toBe(7); }); + it('omitDataPath removes top-level and nested keys', () => { + expect(omitDataPath({ a: 1, b: 2 }, 'a')).toEqual({ b: 2 }); + expect(omitDataPath({ nest: { x: 1, y: 2 } }, 'nest.x')).toEqual({ + nest: { y: 2 }, + }); + }); + it('formDataJsonEqual compares stable JSON snapshots', () => { expect(formDataJsonEqual({ a: 1 }, { a: 1 })).toBe(true); expect(formDataJsonEqual({ a: 1 }, { a: 2 })).toBe(false); @@ -146,6 +154,12 @@ describe('subObservationHelpers', () => { expect(asc.map(r => r.z)).toEqual(['a', 'b']); }); + it('sortRows compares numeric-looking values numerically', () => { + const rows = [{ nopessoa: '10' }, { nopessoa: '2' }, { nopessoa: 1 }]; + const asc = sortRows(rows, { key: 'nopessoa', direction: 'asc' }); + expect(asc.map(r => r.nopessoa)).toEqual([1, '2', '10']); + }); + it('coerceSubObservationRows normalizes values', () => { expect(coerceSubObservationRows(null)).toEqual([]); expect(coerceSubObservationRows([{ x: 1 }])).toEqual([{ x: 1 }]); diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.ts b/formulus-formplayer/src/renderers/subObservationHelpers.ts index 6a7f27e81..0c8022f1e 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.ts @@ -130,6 +130,37 @@ export function writeDataPath( return root; } +/** Immutable omit of a leaf at `dotPath` (top-level or nested object paths). */ +export function omitDataPath( + data: Record, + dotPath: string, +): Record { + if (!dotPath) return data; + const keys = dotPath.split('.'); + if (keys.length === 1) { + if (!(dotPath in data)) return data; + const { [dotPath]: _removed, ...rest } = data; + return rest; + } + + const root = { ...data }; + let cur: Record = root; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + const next = cur[key]; + if (next == null || typeof next !== 'object' || Array.isArray(next)) { + return data; + } + const cloned = { ...(next as Record) }; + cur[key] = cloned; + cur = cloned; + } + const leaf = keys[keys.length - 1]; + if (!(leaf in cur)) return data; + delete cur[leaf]; + return root; +} + /** Embedded arrays and derived indexes preserved when JsonForms omits unmounted fields. */ const PRESERVED_ARRAY_KEYS = [ 'quartos', @@ -156,8 +187,12 @@ export function formDataJsonEqual( * absent from `incoming` even though they still live in our baseline draft * state. Starting from `{ ...baseline, ...incoming }` preserves off-page * scalars (host prefills like cluster stamps / obsdate, and directly-used - * `x-autoSequence` fields) so they are not dropped and re-allocated. On-page - * edits (including clearing a field) still override baseline via `incoming`. + * `x-autoSequence` fields) so they are not dropped and re-allocated. + * + * Clear-on-hide deletes keys (unanswered). That is indistinguishable from an + * off-page omit here, so App must use {@link mergeIncomingFormData} which + * re-applies SHOW/HIDE after this merge. Do not clear to `null` — AJV treats + * null as an invalid typed value ("Invalid value") rather than unanswered. * Sub-observation arrays get extra protection below against shorter/partial * payloads. */ @@ -589,6 +624,21 @@ export function sortRows( : b; const av = readDataPath(aData, key); const bv = readDataPath(bData, key); + + const an = + av === null || av === undefined || av === '' + ? NaN + : Number(typeof av === 'string' ? av.trim() : av); + const bn = + bv === null || bv === undefined || bv === '' + ? NaN + : Number(typeof bv === 'string' ? bv.trim() : bv); + if (Number.isFinite(an) && Number.isFinite(bn)) { + if (an < bn) return -1 * sign; + if (an > bn) return 1 * sign; + return 0; + } + const as = av == null ? '' : String(av); const bs = bv == null ? '' : String(bv); if (as < bs) return -1 * sign; diff --git a/formulus-formplayer/src/theme/material-wrappers.tsx b/formulus-formplayer/src/theme/material-wrappers.tsx index b9b0df3f5..5935c35ce 100644 --- a/formulus-formplayer/src/theme/material-wrappers.tsx +++ b/formulus-formplayer/src/theme/material-wrappers.tsx @@ -37,6 +37,7 @@ import { } from '@mui/material'; import QuestionShell from '../components/QuestionShell'; import { isControlHidden } from '../jsonforms/visibleGuard'; +import { useClearOnHide } from '../jsonforms/useClearOnHide'; import { tokens } from './tokens-adapter'; import { parseChoiceLayout, @@ -66,6 +67,7 @@ const CardEnumControl = (props: AnyControlProps) => { visible, } = props; + useClearOnHide({ visible, path, data, handleChange }); if (isControlHidden(visible)) return null; const label = (uischema as any)?.label || schema.title; const description = schema.description; @@ -207,6 +209,7 @@ const SelectOneOfEnumControl = (props: ControlProps & OwnPropsOfEnum) => { visible, } = props; + useClearOnHide({ visible, path, data, handleChange }); if (isControlHidden(visible)) return null; const label = (uischema as any)?.label || schema.title; const description = schema.description; @@ -218,6 +221,8 @@ const SelectOneOfEnumControl = (props: ControlProps & OwnPropsOfEnum) => { typeof (uischema as any)?.options?.placeholder === 'string' ? (uischema as any).options.placeholder : '—'; + // Coerce so option values (always strings) match saved data (number/boolean). + const selectValue = data == null || data === '' ? '' : String(data); return ( { disabled={!enabled}>