diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..1a4608179 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# EditorConfig — https://editorconfig.org +# Aligns with .gitattributes (LF in repo). Cursor/VS Code apply this on save when +# the EditorConfig extension is enabled (built into Cursor/VS Code by default). + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.go] +indent_style = tab +indent_size = 4 + +[*.{bat,cmd}] +end_of_line = crlf + +[Makefile] +indent_style = tab diff --git a/AGENTS.md b/AGENTS.md index a0f08d97a..9647d7db5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ flowchart LR | Profile | Typical focus | Where to work | |--------|----------------|---------------| | **Platform developer** | You are editing **this repo**: RN, Go, React, shared packages, CI. | Package `AGENTS.md` below. | -| **Custom app author** | You ship an **HTML/JS/CSS** app bundle and JSON forms for Formulus; you may **not** clone this monorepo. | [Custom app template (AI + author context)](https://github.com/OpenDataEnsemble/custom_app) and [documentation](https://opendataensemble.org/docs/). | +| **Custom app author** | You ship an **HTML/JS/CSS** app bundle and JSON forms for Formulus; you may **not** clone this monorepo. | [Custom app template (AI + author context)](https://github.com/OpenDataEnsemble/custom_app), [documentation](https://opendataensemble.org/docs/), and [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md) for form i18n. | Do not assume custom app authors have local checkouts of **ODE** or internal example repos. @@ -135,13 +135,14 @@ git push origin v1.1.1-alpha.3 - **Custom app bridge (v1.1.0+):** `persistObservation` (headless write), `sync`, `getConnectivityStatus`, `getCurrentDataRevisionCount`, and `openFormplayer` options `skipFinalize` / `skipDraftSelection` — contract in [`FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts); run `pnpm run sync-interface` in formplayer after changes. - **Sub-observations:** Each nested Formplayer session validates its own schema; `skipFinalize` only skips the Finalize page. Custom validators are per-session — see [Custom Extensions — nested sessions](https://opendataensemble.org/docs/guides/custom-extensions#nested-sessions-and-custom-validators) (docs site). - **Shared UI tokens:** Install **tokens** before **components** / **formplayer** where the docs require it (see package READMEs and formplayer AGENTS). +- **i18n (two layers):** ODE-owned locales (`en`/`pt`/`fr`) for Formulus + Formplayer chrome via Settings → Language; form-owned copy via optional `translations` on `ui.json` elements (preprocessed at form init). See [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md) (monorepo) and [form translations guide](https://opendataensemble.org/docs/guides/form-translations) (published). --- ## CI and code quality - **Pipelines:** [.github/CICD.md](.github/CICD.md). -- **Lint/format:** Run the relevant scripts in the **package you touch** (see root [README.md](README.md) and each package). +- **Lint/format:** Run the relevant scripts in the **package you touch** (see root [README.md](README.md) and each package). On Windows, use LF line endings — root [`.editorconfig`](.editorconfig) and [`.gitattributes`](.gitattributes) match Prettier/CI; run `pnpm run format` in the package if you see `Delete ␍` lint errors. - **Pre-flight before opening a PR:** each package `AGENTS.md` lists the local `lint` / `format` / `format:check` / `test` / `build` commands that match CI — run them in every package you changed (e.g. [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md#pre-flight-before-a-pr)). - **Commits/PRs:** Conventional Commits and PR expectations are documented in [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md) (project-wide convention). @@ -157,4 +158,6 @@ ODE Desktop ships in [`desktop/`](desktop/) (see [desktop/AGENTS.md](desktop/AGE Authoritative **public** documentation: [opendataensemble.org](https://opendataensemble.org/docs/). +Form localization (embedded `ui.json` translations): [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md). + Optional **AI-focused** context (no ODE clone required): [custom_app](https://github.com/OpenDataEnsemble/custom_app) on GitHub (`README.md`, `AGENTS.md`, `CONTEXT_*.md`). diff --git a/FORM_LOCALIZATION_GUIDE.md b/FORM_LOCALIZATION_GUIDE.md new file mode 100644 index 000000000..00acaa419 --- /dev/null +++ b/FORM_LOCALIZATION_GUIDE.md @@ -0,0 +1,247 @@ +# Form localization guide + +How **user-defined forms** (JSON Schema + `ui.json` in custom app bundles) support multiple languages in ODE. + +**Audience:** platform developers editing Formplayer/Formulus, and custom app authors shipping forms in app bundles. + +**Published user guide:** [Form translations](https://opendataensemble.org/docs/guides/form-translations) on opendataensemble.org (kept in sync conceptually; this file is the monorepo implementation reference). + +--- + +## Two i18n layers (do not mix them up) + +| Layer | Who owns it | Where strings live | When it applies | +|-------|-------------|-------------------|-----------------| +| **ODE chrome** | ODE platform | `formulus-formplayer/src/locales/{en,pt,fr}.json`, Formulus i18n catalogs | Next/Back, validation errors, sub-obs “+ Add …” template, loading text | +| **Form copy** | Form / app author | `ui.json` base fields + optional `translations` blocks | Field labels, descriptions, SwipeLayout headers, custom widget copy | + +Form authors **do not** edit ODE locale JSON. They embed translations in each form’s `ui.json`. ODE developers extend chrome strings when adding new platform UI. + +--- + +## How form locale is chosen + +1. User setting in Formulus **Settings → Language** (`auto`, `en`, `pt`, `fr`) +2. **Auto** → device language; if unsupported, bundle `defaultLocale` from `app.config.json` +3. Fallback `en` + +The host passes the resolved tag as **`params.locale`** when opening Formplayer. Formplayer merges form translations **once at init** (`applyFormUiTranslations()` in `formulus-formplayer/src/i18n/applyFormUiTranslations.ts`). + +```json +// app/public/app.config.json (custom app bundle) +{ + "defaultLocale": "pt" +} +``` + +Use `defaultLocale` when the study’s primary language is not English so **Auto** prefers the right default on devices whose language ODE does not ship. + +--- + +## Authoring pattern: base string + `translations` + +Put the **default** copy on the element (`label`, `description`, `text`, …). Add **`translations.`** only for locales that differ. + +```json +{ + "type": "Control", + "scope": "#/properties/participant_name", + "label": "Participant name", + "description": "Full legal name", + "translations": { + "pt": { + "label": "Nome do participante", + "description": "Nome legal completo" + }, + "fr": { + "label": "Nom du participant" + } + } +} +``` + +**Rules:** + +- Base `label` is required for every user-visible control — do not rely on `schema.json` `title` for display text in multi-locale forms. +- `translations` keys are **partial**; missing keys fall back to the base string. +- Locale lookup tries the full BCP-47 tag then the language subtag (`pt-BR` → `pt`). +- `translations..title` is accepted as an alias for `label` in that block (legacy convenience). + +### Portuguese-primary study with English override + +Base language can be anything; merge logic is symmetric. + +```json +{ + "type": "Control", + "scope": "#/properties/codigo", + "label": "Digitalizar código do envelope", + "translations": { + "en": { "label": "Scan the envelope code" } + } +} +``` + +| Active UI locale | Shown label | +|------------------|-------------| +| `pt` | Base (`label`) | +| `en` | `translations.en.label` | +| `fr` (no block) | Base (`label`) | + +--- + +## `schema.title` vs `ui.json` `label` + +| | `schema.json` → `properties.*.title` | `ui.json` → `Control.label` | +|---|--------------------------------------|-----------------------------| +| Translated at runtime? | No | Yes | +| Primary on-screen label? | Fallback only | Yes | + +```json +// schema.json — keep for validation / exports; not your i18n source of truth +{ + "properties": { + "codigo": { "type": "string", "title": "Código" } + } +} +``` + +```json +// ui.json — what the user actually reads +{ + "type": "Control", + "scope": "#/properties/codigo", + "label": "Digitalizar código do envelope", + "translations": { "en": { "label": "Scan the envelope code" } } +} +``` + +Formplayer resolves labels in order: **`Control.label`** (after translation merge) → JsonForms default → **`schema.title`** → field key. All built-in renderers, SwipeLayout header chips, Finalize summaries, and sub-observation column headers use this path (`controlDisplayText.ts`). + +--- + +## SwipeLayout chrome + +Put button and header copy in **`options`**. Override per locale via top-level keys in the translation block (merged into `options`) or nested `translations..options`: + +```json +{ + "type": "SwipeLayout", + "elements": [], + "options": { + "headerTitle": "Household interview", + "nextButtonLabel": "Next", + "finalizeButtonLabel": "Finish" + }, + "translations": { + "pt": { + "headerTitle": "Entrevista ao agregado", + "nextButtonLabel": "Seguinte", + "finalizeButtonLabel": "Concluir" + } + } +} +``` + +--- + +## Sub-observations + +**Add button:** if `options.addButtonLabel` is omitted, Formplayer composes `+ Add {itemLabel}` from schema `itemLabel` using ODE chrome strings (`subObservation.addItem`). Override when you need custom wording: + +```json +{ + "type": "Control", + "scope": "#/properties/quartos", + "label": "Quartos", + "options": { + "addButtonLabel": "+ Adicionar quarto" + }, + "translations": { + "en": { + "addButtonLabel": "+ Add room" + } + } +} +``` + +**Table columns:** list columns by **`key` only** in `schema.json`. Headers come from the **linked child form**’s `ui.json` labels (after translation), not from hardcoded schema column titles: + +```json +"x-subObservation": { + "formType": "censo_milda_quarto", + "itemLabel": "quarto", + "columns": [{ "key": "quarto_num" }, { "key": "quarto_display" }] +} +``` + +Child forms need their own `Control.label` + `translations` for those fields. Optional static `column.label` / `options.columns[].label` overrides all locales. + +--- + +## Custom question types + +Widget-specific copy belongs in **`Control.options`**, not hardcoded in `question_types/.../renderer.js`: + +```json +{ + "type": "Control", + "scope": "#/properties/confidence", + "label": "How confident are you?", + "options": { + "lowLabel": "Not at all", + "highLabel": "Very", + "oneOf": [{ "const": "yes", "title": "Yes" }] + }, + "translations": { + "pt": { + "label": "Qual é a sua confiança?", + "options": { + "lowLabel": "Nada", + "highLabel": "Muito", + "oneOf": [{ "const": "yes", "title": "Sim" }] + } + } + } +} +``` + +Behavioral config (`maxStars`, filters, …) stays in **`schema.json`** (`config` / validation), not in translated UI copy. + +--- + +## Translatable properties (v1) + +| Element | Properties | +|---------|------------| +| `Control` | `label`, `description`; nested `options.*` | +| `Group`, `Category` | `label` | +| `Label` | `text` | +| `SwipeLayout` (root) | `options.headerTitle`, `options.nextButtonLabel`, `options.finalizeButtonLabel` | +| Sub-observation | `options.addButtonLabel`; optional `options.columns[].label` | + +--- + +## Platform implementation map + +| Concern | Location | +|---------|----------| +| Merge `translations` into UI schema at init | `formulus-formplayer/src/i18n/applyFormUiTranslations.ts` | +| Label resolution at render time | `formulus-formplayer/src/utils/controlDisplayText.ts` | +| Form init + `params.locale` | `formulus-formplayer/src/App.tsx` | +| ODE chrome catalogs | `formulus-formplayer/src/locales/*.json` | +| UI locale preference (host) | `formulus/src/lib/locale.ts`, Settings → Language | +| Linked child specs for sub-obs columns | `FormInitData.linkedFormSpecs` (built in Formulus / ODE Desktop) | + +When changing merge rules or label resolution, update **`applyFormUiTranslations.test.ts`**, affected renderers, and the [published form translations guide](https://opendataensemble.org/docs/guides/form-translations) in **ode-docs**. + +--- + +## Author checklist + +- [ ] Every visible field has `Control.label` in `ui.json` (not only `schema.title`) +- [ ] Base `label` set; `translations` added for other ODE UI locales you ship (`en`, `pt`, `fr`) +- [ ] SwipeLayout headers/buttons translated where needed +- [ ] Sub-obs columns use `key` only; labels live on linked child forms +- [ ] Custom question type strings in `options`, not in renderer JS +- [ ] `defaultLocale` in `app.config.json` when the study default is not English diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 039a5e07f..02f2fe803 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -41,7 +41,9 @@ Persisted per profile via `upsertProfileRemote` / Rust `ServerProfile`. | Off | `bundles/active/app/` | `bundles/active/forms/` | | On | `bundles/dev-local/app/` (mirror) | `bundles/dev-local/forms/` (mirror if `/forms` exists) | -Synk downloads and **Refresh from server** on the Bundles page only touch `bundles/active/`. The source folder on disk is **never** modified. +Synk downloads and **Download & apply** on the Bundles page only touch `bundles/active/`. The source folder on disk is **never** modified. + +**Bundle download:** `download_and_apply_app_bundle` (TS: `tauriClient.downloadAndApplyAppBundle()`) — native Rust HTTP download; no zip bytes over IPC. Progress events: `bundle/apply-progress`, `bundle/index-rebuild`. Observation index rebuild runs in the background after apply. ### UI @@ -64,13 +66,15 @@ On success, Zustand bumps `devMirrorGeneration` so embeds and form lists reload. - `src/hooks/useDeveloperMode.ts` — profile read/write, refresh, generation counter from store. - `src/components/DeveloperModePanel.tsx` — full vs banner UI; auto-mirror `useEffect` only on `variant="full"`. - `src/lib/bundleLayout.ts` — `bundleSegment()`, `bundleFormsRel()`. -- `src/store/useCustodianStore.ts` — `devMirrorGeneration`, `devBusy`, `devError`, `refreshDevMirror`. +- `src/store/useCustodianStore.ts` — `devMirrorGeneration`, `devBusy`, `devError`, `refreshDevMirror`, `bundleActivity`. +- `src/lib/bundleTauriEvents.ts` — bundle apply progress listeners. ### Key Rust - `profile_developer_mode`, `bundle_segment`, `bundle_form_roots_for_ctx` - Dev-aware: `list_active_bundle_forms`, `read_bundle_form_spec`, `get_active_bundle_forms_file_base_url`, `scan_bundle_custom_question_types`, `bundle_app_config_path` -- Tests: `validate_custom_app_dev_source_requires_index_html`, `mirror_custom_app_dev_folder_copies_tree` +- `download_and_apply_app_bundle` — streamed Synkronus zip download + apply (never pass large binaries through WebView IPC as `number[]`) +- Tests: `validate_custom_app_dev_source_requires_index_html`, `mirror_custom_app_dev_folder_copies_tree`, `apply_app_bundle_zip_at_workspace_writes_state_and_active` ### Errors @@ -81,7 +85,7 @@ Developer mode on with missing/invalid folder → blocking error in UI; no silen ## Bridge and bundles - **Contract source of truth:** [`formulus/src/webview/FormulusInterfaceDefinition.ts`](../formulus/src/webview/FormulusInterfaceDefinition.ts). -- **Form preview:** `formPreviewBridge.ts` handles injection `postMessage` types; device APIs stubbed; observations/attachments use Tauri. +- **Form preview:** `formPreviewBridge.ts` handles injection `postMessage` types; device APIs stubbed; observations/attachments use Tauri. **`linkedFormSpecs`** on `FormInitData` is populated via `buildLinkedFormSpecs` (see `buildFormPreviewInit.ts`) so sub-observation column headers match Formulus. - **Extensions:** `bundleExtensionLoader.ts` merges `forms/ext.json` like Formulus `ExtensionService`; pass `developerMode` for path prefix. --- diff --git a/desktop/docs/UI_FEEDBACK.md b/desktop/docs/UI_FEEDBACK.md index 92ccb7b52..69ef43ad9 100644 --- a/desktop/docs/UI_FEEDBACK.md +++ b/desktop/docs/UI_FEEDBACK.md @@ -4,11 +4,13 @@ Single source of truth for how the shell surfaces status, errors, and confirmati ## Progress banner (full-width) -**Use for:** long-running sync and import jobs only. +**Use for:** long-running sync, import, and **app bundle download/apply** jobs. - Renders below the mode switch / dev bar in `Shell`. -- Shows spinner + status text; dismissible but reappears on next activity. -- Do not use for quick actions (save, auth, bundle download). +- 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. +- Do not use for quick actions (save, auth, dev mirror refresh). ## Toasts (bottom-right stack) @@ -32,7 +34,7 @@ Single source of truth for how the shell surfaces status, errors, and confirmati **Use for:** destructive actions, import-with-issues, closing unsaved observation tabs. -- `confirmDestructiveAction()` for destructive flows (always strict production-tier wording). +- `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). ## Retired patterns diff --git a/desktop/docs/screens/import.md b/desktop/docs/screens/import.md index 8f235a954..5fe1ff55e 100644 --- a/desktop/docs/screens/import.md +++ b/desktop/docs/screens/import.md @@ -29,3 +29,11 @@ Bring external JSON observation files into the active profile’s local reposito - Store: `import` flow via `tauriClient.importObservations`, refresh `loadObservations` / `loadHealth` after import - Active profile determines target SQLite repository + +## Observation indexes + +When the active app bundle declares `observationIndexes` in `app.config.json`: + +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). diff --git a/desktop/package.json b/desktop/package.json index e27af5081..cd523288f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "ode-desktop", "private": true, - "version": "1.1.1", + "version": "1.2.0", "packageManager": "pnpm@10.33.2", "type": "module", "scripts": { @@ -28,9 +28,13 @@ "@tauri-apps/plugin-opener": "~2.5.0", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "react-router-dom": "^7.8.2", + "recharts": "^3.9.2", "zustand": "^5.0.8" }, "devDependencies": { @@ -40,6 +44,8 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/leaflet": "^1.9.21", + "@types/leaflet.markercluster": "^1.5.6", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 5bb222b32..3270b51ea 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -23,18 +23,30 @@ importers: ajv-formats: specifier: ^3.0.1 version: 3.0.1(ajv@8.20.0) + leaflet: + specifier: ^1.9.4 + version: 1.9.4 + leaflet.markercluster: + specifier: ^1.5.3 + version: 1.5.3(leaflet@1.9.4) react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-leaflet: + specifier: ^4.2.1 + version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: ^7.8.2 version: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts: + specifier: ^3.9.2 + version: 3.9.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1) zustand: specifier: ^5.0.8 - version: 5.0.13(@types/react@18.3.28)(react@18.3.1) + version: 5.0.13(@types/react@18.3.28)(immer@11.1.11)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: '@eslint/js': specifier: ^9.39.2 @@ -54,6 +66,12 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/leaflet': + specifier: ^1.9.21 + version: 1.9.21 + '@types/leaflet.markercluster': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^18.3.1 version: 18.3.28 @@ -522,6 +540,24 @@ packages: engines: {node: '>=20.19.0'} hasBin: true + '@react-leaflet/core@2.1.0': + resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==} + peerDependencies: + leaflet: ^1.9.0 + react: ^18.0.0 + react-dom: ^18.0.0 + + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -663,6 +699,12 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} @@ -808,6 +850,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -817,9 +886,18 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/leaflet.markercluster@1.5.6': + resolution: {integrity: sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==} + + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/mute-stream@0.0.4': resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} @@ -837,6 +915,9 @@ packages: '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} @@ -1121,6 +1202,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1179,6 +1264,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -1208,6 +1337,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1303,6 +1435,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1397,6 +1532,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1603,6 +1741,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@11.1.11: + resolution: {integrity: sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1619,6 +1760,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -1791,6 +1936,14 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + leaflet.markercluster@1.5.3: + resolution: {integrity: sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==} + peerDependencies: + leaflet: ^1.3.1 + + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2040,6 +2193,25 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-leaflet@4.2.1: + resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==} + peerDependencies: + leaflet: ^1.9.0 + react: ^18.0.0 + react-dom: ^18.0.0 + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2065,10 +2237,26 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + recharts@3.9.2: + resolution: {integrity: sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -2088,6 +2276,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2280,6 +2471,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2401,6 +2595,14 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3016,6 +3218,24 @@ snapshots: - encoding - supports-color + '@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + leaflet: 1.9.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.11 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 18.3.1 + react-redux: 9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.60.4': @@ -3093,6 +3313,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@tauri-apps/api@2.10.1': {} '@tauri-apps/api@2.11.0': {} @@ -3225,14 +3449,48 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/json-schema@7.0.15': {} + '@types/leaflet.markercluster@1.5.6': + dependencies: + '@types/leaflet': 1.9.21 + + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + '@types/mute-stream@0.0.4': dependencies: '@types/node': 20.19.41 @@ -3252,6 +3510,8 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@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)': @@ -3608,6 +3868,8 @@ snapshots: clone@1.0.4: optional: true + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3660,6 +3922,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-uri-to-buffer@6.0.2: {} data-urls@5.0.0: @@ -3689,6 +3989,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} deep-eql@5.0.2: {} @@ -3849,6 +4151,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.49.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -4003,6 +4307,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} @@ -4208,6 +4514,8 @@ snapshots: ignore@7.0.5: {} + immer@11.1.11: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -4223,6 +4531,8 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + internmap@2.0.3: {} + ip-address@10.2.0: {} is-array-buffer@3.0.5: @@ -4414,6 +4724,12 @@ snapshots: dependencies: json-buffer: 3.0.1 + leaflet.markercluster@1.5.3(leaflet@1.9.4): + dependencies: + leaflet: 1.9.4 + + leaflet@1.9.4: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4653,6 +4969,22 @@ snapshots: react-is@17.0.2: {} + react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + leaflet: 1.9.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react-redux@9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + redux: 5.0.1 + react-refresh@0.17.0: {} react-router-dom@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -4673,11 +5005,37 @@ snapshots: dependencies: loose-envify: 1.4.0 + recharts@3.9.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.49.0 + eventemitter3: 5.0.4 + immer: 11.1.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 17.0.2 + react-redux: 9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@18.3.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -4704,6 +5062,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve@2.0.0-next.7: @@ -4960,6 +5320,8 @@ snapshots: symbol-tree@3.2.4: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -5084,6 +5446,27 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@20.19.41): dependencies: cac: 6.7.14 @@ -5282,7 +5665,9 @@ snapshots: zod@4.4.3: {} - zustand@5.0.13(@types/react@18.3.28)(react@18.3.1): + zustand@5.0.13(@types/react@18.3.28)(immer@11.1.11)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.28 + immer: 11.1.11 react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 93b92c6ba..ba6862051 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2600,9 +2600,10 @@ dependencies = [ [[package]] name = "odedesktop" -version = "1.1.1" +version = "1.2.0" dependencies = [ "chrono", + "futures-util", "keyring", "rayon", "reqwest 0.12.28", @@ -3456,12 +3457,14 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -3496,7 +3499,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -5299,6 +5302,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 60fbc37b9..b78a4c426 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "odedesktop" -version = "1.1.1" +version = "1.2.0" description = "ODE Desktop" authors = ["OpenDataEnsemble.org"] edition = "2024" @@ -25,7 +25,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } rusqlite = { version = "0.32", features = ["bundled", "chrono", "serde_json"] } -reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart", "stream"] } +futures-util = "0.3" rayon = "1" uuid = { version = "1", features = ["v4", "serde"] } thiserror = "2" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c0abc3a63..ce155a0db 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -6,10 +6,12 @@ use std::{ path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}, sync::{Arc, Mutex}, - time::{Instant, UNIX_EPOCH}, + time::{Duration, Instant, UNIX_EPOCH}, }; -use chrono::{DateTime, Utc}; +use futures_util::StreamExt; + +use chrono::{DateTime, Datelike, NaiveDate, Utc}; use keyring::Entry; use rayon::prelude::*; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; @@ -67,6 +69,26 @@ pub struct AppBundleState { pub archived_versions: Vec, } +/// Emitted on `bundle/apply-progress` and `bundle/index-rebuild`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BundleApplyProgressEvent { + job_id: String, + phase: String, + done: i64, + total: i64, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DownloadAndApplyAppBundleResult { + state: AppBundleState, + index_rebuild_scheduled: bool, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ActiveBundleFormEntry { @@ -81,16 +103,6 @@ pub struct BundleFormSpec { pub ui_schema: Value, } -/// Client-side guardrail for confirmations (not interpreted by Synkronus). -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -enum ProfileEnvironment { - #[default] - Production, - Staging, - Development, -} - #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum DefaultAppMode { @@ -110,8 +122,6 @@ struct ServerProfile { database_path: String, attachments_path: Option, #[serde(default)] - environment: ProfileEnvironment, - #[serde(default)] default_app_mode: DefaultAppMode, #[serde(default)] custom_app_developer_mode: bool, @@ -481,6 +491,15 @@ struct AppCtx { workspace_sqlite_lock: Mutex<()>, /// In-memory pause/cancel handles for the active sync worker (job row persists checkpoints). active_sync: Mutex>, + /// Coalesces overlapping observation-index rebuild jobs into one run (+ optional follow-up). + index_rebuild_gate: Mutex, +} + +#[derive(Default)] +struct IndexRebuildGate { + running: bool, + pending: bool, + current_job_id: Option, } pub(crate) type AppCtxHandle = Arc; @@ -600,6 +619,8 @@ struct ImportResult { attachments_downloaded: usize, #[serde(default)] attachments_failed: usize, + #[serde(default)] + index_rebuild_scheduled: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -814,7 +835,6 @@ fn default_app_config(data_dir: &Path) -> AppConfigFile { workspace_path: Some(workspace_dir.to_string_lossy().to_string()), database_path: db_path.to_string_lossy().to_string(), attachments_path: None, - environment: ProfileEnvironment::default(), default_app_mode: DefaultAppMode::default(), custom_app_developer_mode: false, custom_app_local_folder: None, @@ -837,7 +857,6 @@ fn migrate_legacy_workspace(workspace_path: &str, _data_dir: &Path) -> AppConfig workspace_path: Some(workspace_path.to_string()), database_path: db.to_string_lossy().to_string(), attachments_path: None, - environment: ProfileEnvironment::default(), default_app_mode: DefaultAppMode::default(), custom_app_developer_mode: false, custom_app_local_folder: None, @@ -1183,10 +1202,78 @@ fn sanitize_version_for_filename(version: &str) -> String { } } +fn format_byte_progress_mb(done: i64, total: i64) -> String { + const MB: f64 = 1024.0 * 1024.0; + if total > 0 { + format!("{:.1} / {:.1} MB", done as f64 / MB, total as f64 / MB) + } else { + format!("{:.1} MB", done as f64 / MB) + } +} + +fn emit_bundle_apply_progress( + app: &tauri::AppHandle, + job_id: &str, + phase: &str, + done: i64, + total: i64, + message: &str, + detail: Option<&str>, +) { + let _ = app.emit( + "bundle/apply-progress", + BundleApplyProgressEvent { + job_id: job_id.to_string(), + phase: phase.to_string(), + done, + total, + message: message.to_string(), + detail: detail.map(|s| s.to_string()), + }, + ); +} + +fn emit_bundle_index_rebuild_progress( + app: &tauri::AppHandle, + job_id: &str, + phase: &str, + done: i64, + total: i64, + message: &str, + detail: Option<&str>, +) { + let _ = app.emit( + "bundle/index-rebuild", + BundleApplyProgressEvent { + job_id: job_id.to_string(), + phase: phase.to_string(), + done, + total, + message: message.to_string(), + detail: detail.map(|s| s.to_string()), + }, + ); +} + +type BundleExtractProgress<'a> = dyn FnMut(i64, i64, Option<&str>) + 'a; + +#[allow(dead_code)] fn extract_zip_to_dir(zip_bytes: &[u8], dest: &Path) -> Result<(), CustodianError> { + extract_zip_to_dir_with_progress(zip_bytes, dest, None) +} + +fn extract_zip_to_dir_with_progress( + zip_bytes: &[u8], + dest: &Path, + mut on_progress: Option<&mut BundleExtractProgress<'_>>, +) -> Result<(), CustodianError> { let reader = Cursor::new(zip_bytes); let mut archive = ZipArchive::new(reader) .map_err(|e| CustodianError::Message(format!("invalid zip: {}", e)))?; + let total = archive.len() as i64; + if let Some(ref mut cb) = on_progress { + cb(0, total, None); + } for i in 0..archive.len() { let mut file = archive .by_index(i) @@ -1195,7 +1282,7 @@ fn extract_zip_to_dir(zip_bytes: &[u8], dest: &Path) -> Result<(), CustodianErro Some(p) => p.to_owned(), None => continue, }; - let outpath = dest.join(rel); + let outpath = dest.join(&rel); if file.is_dir() || file.name().ends_with('/') { fs::create_dir_all(&outpath)?; } else { @@ -1206,10 +1293,680 @@ fn extract_zip_to_dir(zip_bytes: &[u8], dest: &Path) -> Result<(), CustodianErro std::io::copy(&mut file, &mut outfile) .map_err(|e| CustodianError::Message(e.to_string()))?; } + let done = (i + 1) as i64; + if let Some(ref mut cb) = on_progress { + let emit = done == total || done % 10 == 0; + if emit { + let detail = rel.to_string_lossy(); + cb(done, total, Some(detail.as_ref())); + } + } + } + Ok(()) +} + +fn apply_app_bundle_zip_at_workspace( + ws: &Path, + version: &str, + hash: &str, + zip_bytes: &[u8], + on_extract_progress: Option<&mut BundleExtractProgress<'_>>, +) -> Result { + let ver = version.trim(); + if ver.is_empty() { + return Err(CustodianError::Message("version is required".to_string())); + } + let hash = hash.trim(); + if hash.is_empty() { + return Err(CustodianError::Message("hash is required".to_string())); + } + if zip_bytes.is_empty() { + return Err(CustodianError::Message("zip is empty".to_string())); + } + let bundles = ws.join("bundles"); + let archives_dir = bundles.join("archives"); + let active_dir = bundles.join("active"); + fs::create_dir_all(&archives_dir)?; + let prev = read_app_bundle_state_unlocked(&bundles)?; + let mut archived = prev + .as_ref() + .map(|s| s.archived_versions.clone()) + .unwrap_or_default(); + if !archived.iter().any(|v| v == ver) { + archived.push(ver.to_string()); + } + archived.sort(); + let sanit = sanitize_version_for_filename(ver); + let archive_zip = archives_dir.join(format!("{sanit}.zip")); + fs::write(&archive_zip, zip_bytes)?; + if active_dir.exists() { + fs::remove_dir_all(&active_dir)?; + } + fs::create_dir_all(&active_dir)?; + if let Some(cb) = on_extract_progress { + extract_zip_to_dir_with_progress(zip_bytes, &active_dir, Some(cb))?; + } else { + extract_zip_to_dir_with_progress(zip_bytes, &active_dir, None)?; + } + let state = AppBundleState { + schema_version: 1, + active_version: ver.to_string(), + active_hash: hash.to_string(), + downloaded_at: Utc::now().to_rfc3339(), + archived_versions: archived, + }; + let state_path = bundles.join("state.json"); + if let Some(parent) = state_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + &state_path, + serde_json::to_string_pretty(&state).map_err(|e| CustodianError::Message(e.to_string()))?, + )?; + let legacy = bundles.join("app-bundle.zip"); + if legacy.exists() { + let _ = fs::remove_file(&legacy); + } + Ok(state) +} + +fn apply_app_bundle_zip_bytes( + app: &tauri::AppHandle, + job_id: &str, + ctx: &AppCtxHandle, + version: &str, + hash: &str, + zip_bytes: &[u8], +) -> Result { + emit_bundle_apply_progress(app, job_id, "archiving", 0, 1, "Saving archive…", None); + let app_c = app.clone(); + let job = job_id.to_string(); + let state = with_workspace_fs_exclusive(ctx, |ctx| { + let ws = get_workspace_path(ctx)?; + emit_bundle_apply_progress(&app_c, &job, "archiving", 1, 1, "Saving archive…", None); + emit_bundle_apply_progress(&app_c, &job, "extracting", 0, 0, "Extracting bundle…", None); + let mut extract_cb = |done: i64, total: i64, detail: Option<&str>| { + emit_bundle_apply_progress( + &app_c, + &job, + "extracting", + done, + total, + "Extracting bundle…", + detail, + ); + }; + apply_app_bundle_zip_at_workspace(&ws, version, hash, zip_bytes, Some(&mut extract_cb)) + })?; + Ok(state) +} + +async fn download_synkronus_app_bundle_zip( + app: &tauri::AppHandle, + job_id: &str, + base_url: &str, + bearer_token: &str, + x_ode_version: &str, +) -> Result, CustodianError> { + let url = format!( + "{}/api/app-bundle/download-zip", + base_url.trim().trim_end_matches('/') + ); + let parsed = + Url::parse(&url).map_err(|e| CustodianError::Message(format!("invalid URL: {e}")))?; + let client = reqwest::Client::new(); + let res = client + .get(parsed) + .header(AUTHORIZATION, format!("Bearer {}", bearer_token.trim())) + .header("x-ode-version", x_ode_version.trim()) + .send() + .await?; + if !res.status().is_success() { + return Err(CustodianError::Message(format!( + "bundle download failed: HTTP {}", + res.status() + ))); + } + let total_bytes = res.content_length().map(|n| n as i64).unwrap_or(0); + emit_bundle_apply_progress( + app, + job_id, + "downloading", + 0, + total_bytes, + "Downloading bundle from server…", + None, + ); + let mut buf = Vec::new(); + let mut received: i64 = 0; + let mut last_emit = Instant::now(); + let mut last_emit_bytes: i64 = 0; + let mut stream = res.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + received += chunk.len() as i64; + buf.extend_from_slice(&chunk); + let elapsed = last_emit.elapsed(); + let bytes_since = received - last_emit_bytes; + if elapsed >= Duration::from_millis(250) || bytes_since >= 512 * 1024 { + emit_bundle_apply_progress( + app, + job_id, + "downloading", + received, + total_bytes, + "Downloading bundle from server…", + Some(&format_byte_progress_mb(received, total_bytes)), + ); + last_emit = Instant::now(); + last_emit_bytes = received; + } + } + emit_bundle_apply_progress( + app, + job_id, + "downloading", + received, + total_bytes.max(received), + "Downloading bundle from server…", + Some(&format_byte_progress_mb( + received, + total_bytes.max(received), + )), + ); + if buf.is_empty() { + return Err(CustodianError::Message("zip is empty".to_string())); + } + 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 { + 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 +} + +/// 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() { + 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); + } + let top = rel.split('/').next().unwrap_or(""); + DEV_MIRROR_BUNDLE_TOP_DIRS.contains(&top) +} + +fn forms_root_for_publish_schema(dev_local: &Path, rel: &str) -> Option { + if rel.starts_with("app/forms/") { + Some(dev_local.join("app/forms")) + } else if rel.starts_with("forms/") { + Some(dev_local.join("forms")) + } else { + None + } +} + +fn load_shared_choice_defs(forms_root: &Path) -> Option { + let path = forms_root.join("shared-choice-defs.schema.json"); + let raw = fs::read_to_string(&path).ok()?; + let doc: Value = serde_json::from_str(&raw).ok()?; + if doc.get("$defs").and_then(|d| d.as_object()).is_some() { + Some(doc) + } else { + None + } +} + +fn extract_shared_choice_def_name(ref_str: &str) -> Option { + ref_str + .trim() + .strip_prefix(SHARED_CHOICE_REF_PREFIX) + .map(|s| s.to_string()) +} + +fn collapse_shared_choice_allof(prop: &mut Value) { + let Some(obj) = prop.as_object_mut() else { + return; + }; + let Some(all_of) = obj.get("allOf").and_then(|v| v.as_array()) else { + return; + }; + let ref_branch = all_of.iter().find_map(|b| { + b.as_object() + .and_then(|o| o.get("$ref")) + .and_then(|r| r.as_str()) + }); + let Some(ref_str) = ref_branch else { + return; + }; + let mut next = serde_json::Map::new(); + for (k, v) in obj.iter() { + if k == "allOf" || k == "format" || k == "type" { + continue; + } + next.insert(k.clone(), v.clone()); + } + next.insert("$ref".to_string(), Value::String(ref_str.to_string())); + *prop = Value::Object(next); +} + +fn normalize_shared_choice_properties(schema: &mut Value) { + let Some(props) = schema + .as_object_mut() + .and_then(|o| o.get_mut("properties")) + .and_then(|p| p.as_object_mut()) + else { + return; + }; + for prop in props.values_mut() { + collapse_shared_choice_allof(prop); + } +} + +fn collect_shared_choice_refs(node: &mut Value, needed: &mut HashSet) { + match node { + Value::Object(map) => { + if let Some(Value::String(r)) = map.get_mut("$ref") + && let Some(name) = extract_shared_choice_def_name(r) + { + needed.insert(name.clone()); + *r = format!("#/$defs/{name}"); + } + for v in map.values_mut() { + collect_shared_choice_refs(v, needed); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + collect_shared_choice_refs(v, needed); + } + } + _ => {} + } +} + +fn resolve_shared_choice_refs( + mut schema: Value, + shared_doc: &Value, +) -> Result { + let shared_defs = shared_doc + .get("$defs") + .and_then(|d| d.as_object()) + .ok_or_else(|| { + CustodianError::Message( + "shared-choice-defs.schema.json must contain a $defs object".to_string(), + ) + })?; + + if !schema.is_object() { + return Ok(schema); + } + + let obj = schema.as_object_mut().unwrap(); + if !obj.contains_key("$defs") { + obj.insert("$defs".to_string(), Value::Object(serde_json::Map::new())); + } + normalize_shared_choice_properties(&mut schema); + + let mut needed = HashSet::new(); + collect_shared_choice_refs(&mut schema, &mut needed); + + let defs = schema + .as_object_mut() + .and_then(|o| o.get_mut("$defs")) + .and_then(|d| d.as_object_mut()) + .ok_or_else(|| CustodianError::Message("schema $defs missing".to_string()))?; + + for name in needed { + let Some(def) = shared_defs.get(&name) else { + return Err(CustodianError::Message(format!( + "missing shared choice def \"{name}\" in shared-choice-defs.schema.json" + ))); + }; + defs.insert(name, def.clone()); + } + + Ok(schema) +} + +fn read_publish_schema_bytes( + schema_path: &Path, + forms_root: &Path, + shared_cache: &mut HashMap>, +) -> Result, CustodianError> { + let raw = fs::read_to_string(schema_path)?; + let mut schema: Value = serde_json::from_str(&raw)?; + let shared = shared_cache + .entry(forms_root.to_path_buf()) + .or_insert_with(|| load_shared_choice_defs(forms_root)); + if let Some(shared_doc) = shared.clone() { + schema = resolve_shared_choice_refs(schema, &shared_doc)?; + } + let out = serde_json::to_vec_pretty(&schema)?; + Ok(out) +} + +/// Zips `bundles/dev-local/` into a temp file with Synkronus-compatible paths (`app/`, `forms/`, …). +fn zip_dev_mirror_bundle(ws: &Path) -> Result { + let dev_local = ws.join("bundles/dev-local"); + let index = dev_local.join("app/index.html"); + if !index.is_file() { + return Err(CustodianError::Message( + "developer mirror missing app/index.html — use Refresh app first".to_string(), + )); + } + 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)); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + let mut wrote_file = false; + let mut shared_cache: HashMap> = HashMap::new(); + + for entry in WalkDir::new(&dev_local).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + continue; + } + 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) { + continue; + } + let bytes = if name.ends_with("schema.json") { + if let Some(forms_root) = forms_root_for_publish_schema(&dev_local, &name) { + read_publish_schema_bytes(path, &forms_root, &mut shared_cache)? + } else { + fs::read(path)? + } + } else { + fs::read(path)? + }; + zip.start_file(name.as_ref(), options) + .map_err(|e| CustodianError::Message(e.to_string()))?; + zip.write_all(&bytes) + .map_err(|e| CustodianError::Message(e.to_string()))?; + wrote_file = true; + } + zip.finish() + .map_err(|e| CustodianError::Message(e.to_string()))?; + if !wrote_file { + let _ = fs::remove_file(&zip_path); + return Err(CustodianError::Message( + "developer mirror zip is empty".to_string(), + )); + } + Ok(zip_path) +} + +#[derive(Debug, Deserialize)] +struct SynkAppBundleManifest { + version: String, + hash: String, +} + +#[derive(Debug, Deserialize)] +struct SynkAppBundlePushResponse { + message: String, + manifest: SynkAppBundleManifest, +} + +async fn push_app_bundle_zip( + base_url: &str, + bearer_token: &str, + x_ode_version: &str, + zip_path: &Path, +) -> Result { + let url = format!( + "{}/api/app-bundle/push", + base_url.trim().trim_end_matches('/') + ); + let bytes = fs::read(zip_path)?; + if bytes.is_empty() { + return Err(CustodianError::Message("zip is empty".to_string())); + } + let part = multipart::Part::bytes(bytes) + .file_name("bundle.zip") + .mime_str("application/zip") + .map_err(|e| CustodianError::Message(e.to_string()))?; + let form = multipart::Form::new().part("bundle", part); + let client = reqwest::Client::new(); + let res = client + .post(url) + .header(AUTHORIZATION, format!("Bearer {}", bearer_token.trim())) + .header("x-ode-version", x_ode_version.trim()) + .multipart(form) + .send() + .await?; + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + let detail = if body.trim().is_empty() { + String::new() + } else { + format!(": {body}") + }; + return Err(CustodianError::Message(format!( + "bundle push failed: HTTP {status}{detail}" + ))); + } + Ok(res.json().await?) +} + +async fn switch_app_bundle_version( + base_url: &str, + bearer_token: &str, + x_ode_version: &str, + version: &str, +) -> Result<(), CustodianError> { + let version = version.trim(); + if version.is_empty() { + return Err(CustodianError::Message( + "bundle push response missing version".to_string(), + )); + } + let url = format!( + "{}/api/app-bundle/switch/{}", + base_url.trim().trim_end_matches('/'), + urlencoding::encode(version) + ); + let client = reqwest::Client::new(); + let res = client + .post(url) + .header(AUTHORIZATION, format!("Bearer {}", bearer_token.trim())) + .header("x-ode-version", x_ode_version.trim()) + .send() + .await?; + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + let detail = if body.trim().is_empty() { + String::new() + } else { + format!(": {body}") + }; + return Err(CustodianError::Message(format!( + "bundle switch failed: HTTP {status}{detail}" + ))); } Ok(()) } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PushDevMirrorAppBundleResult { + version: String, + hash: String, + message: String, +} + +fn spawn_observation_index_rebuild(app: tauri::AppHandle, ctx: AppCtxHandle, job_id: String) { + { + let gate = ctx + .index_rebuild_gate + .lock() + .unwrap_or_else(|e| e.into_inner()); + let is_active = gate.running && gate.current_job_id.as_deref() == Some(job_id.as_str()); + if !is_active { + eprintln!("[observation_index] skip rebuild spawn for inactive job_id={job_id}"); + return; + } + } + + tauri::async_runtime::spawn_blocking(move || { + struct IndexRebuildRunGuard { + ctx: AppCtxHandle, + app: tauri::AppHandle, + } + + impl Drop for IndexRebuildRunGuard { + fn drop(&mut self) { + let _ = finish_index_rebuild_gate(&self.ctx, &self.app); + } + } + + let _run_guard = IndexRebuildRunGuard { + ctx: ctx.clone(), + app: app.clone(), + }; + + let app_config = match bundle_app_config_path(&ctx) { + Ok(p) if p.exists() => p, + _ => return, + }; + let defs = observation_index::load_index_config(&app_config); + if defs.is_empty() { + return; + } + let conn = match open_db(&ctx) { + Ok(c) => c, + Err(err) => { + emit_bundle_index_rebuild_progress( + &app, + &job_id, + "failed", + 0, + 0, + "Rebuilding observation indexes…", + Some(&err.to_string()), + ); + return; + } + }; + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM observations", [], |r| r.get(0)) + .unwrap_or(0); + emit_bundle_index_rebuild_progress( + &app, + &job_id, + "indexing", + 0, + total.max(1), + "Indexing observations…", + None, + ); + let mut progress_cb = |done: i64, tot: i64, phase: Option<&str>| { + emit_bundle_index_rebuild_progress( + &app, + &job_id, + "indexing", + done, + tot, + phase.unwrap_or("Indexing observations…"), + None, + ); + }; + match observation_index::rebuild_all_indexes(&conn, &defs, Some(&mut progress_cb)) { + Ok(_) => emit_bundle_index_rebuild_progress( + &app, + &job_id, + "completed", + total, + total.max(1), + "Observation indexes rebuilt.", + None, + ), + Err(err) => emit_bundle_index_rebuild_progress( + &app, + &job_id, + "failed", + 0, + 0, + "Rebuilding observation indexes…", + Some(&err.to_string()), + ), + } + }); +} + +/// Marks the active rebuild finished; runs one coalesced follow-up if requests arrived mid-flight. +fn finish_index_rebuild_gate(ctx: &AppCtxHandle, app: &tauri::AppHandle) -> Option { + let follow_up = { + let mut gate = ctx + .index_rebuild_gate + .lock() + .unwrap_or_else(|e| e.into_inner()); + gate.running = false; + if gate.pending { + gate.pending = false; + let job_id = Uuid::new_v4().to_string(); + gate.running = true; + gate.current_job_id = Some(job_id.clone()); + Some(job_id) + } else { + gate.current_job_id = None; + None + } + }; + if let Some(job_id) = follow_up { + spawn_observation_index_rebuild(app.clone(), ctx.clone(), job_id.clone()); + Some(job_id) + } else { + None + } +} + +/// Starts a background full index rebuild when the active bundle declares indexes. +fn schedule_observation_index_rebuild( + app: &tauri::AppHandle, + ctx: &AppCtxHandle, +) -> Option { + let app_config = bundle_app_config_path(ctx).ok().filter(|p| p.exists())?; + let defs = observation_index::load_index_config(&app_config); + if defs.is_empty() { + return None; + } + let mut gate = ctx + .index_rebuild_gate + .lock() + .unwrap_or_else(|e| e.into_inner()); + if gate.running { + gate.pending = true; + return gate.current_job_id.clone(); + } + let job_id = Uuid::new_v4().to_string(); + gate.running = true; + gate.pending = false; + gate.current_job_id = Some(job_id.clone()); + drop(gate); + spawn_observation_index_rebuild(app.clone(), ctx.clone(), job_id.clone()); + Some(job_id) +} + fn read_app_bundle_state_unlocked( bundles_root: &Path, ) -> Result, CustodianError> { @@ -1413,6 +2170,7 @@ fn upsert_observation_from_local_import( .clone() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| timestamp.clone()); + let extras_json = serialize_observation_extras(&incoming.extras)?; if existing.is_some() { conn.execute( @@ -1423,13 +2181,15 @@ fn upsert_observation_from_local_import( dirty = 1, sync_status = 'dirty', conflict_payload = NULL, - last_saved_at = ?4 - WHERE id = ?5", + last_saved_at = ?4, + observation_extras = COALESCE(?5, observation_extras) + WHERE id = ?6", params![ payload, incoming.form_type, updated, timestamp, + extras_json, incoming.observation_id ], )?; @@ -1437,14 +2197,15 @@ fn upsert_observation_from_local_import( conn.execute( "INSERT INTO observations ( id, payload, form_type, updated_at, remote_updated_at, - dirty, sync_status, conflict_payload, last_saved_at, last_pushed_at - ) VALUES (?1, ?2, ?3, ?4, NULL, 1, 'dirty', NULL, ?5, NULL)", + dirty, sync_status, conflict_payload, last_saved_at, last_pushed_at, observation_extras + ) VALUES (?1, ?2, ?3, ?4, NULL, 1, 'dirty', NULL, ?5, NULL, ?6)", params![ incoming.observation_id, payload, incoming.form_type, updated, - timestamp + timestamp, + extras_json ], )?; } @@ -2075,9 +2836,9 @@ fn query_param_summary(params: &[observation_query::SqlParam]) -> String { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct RebuildObservationIndexesResult { - generation: i64, - last_rebuild_at: Option, +struct StartObservationIndexRebuildResult { + job_id: String, + scheduled: bool, } #[derive(Debug, Clone, Serialize)] @@ -2194,29 +2955,29 @@ fn query_observations( } #[tauri::command] -async fn rebuild_observation_indexes( +fn start_observation_index_rebuild( + app: tauri::AppHandle, ctx: tauri::State<'_, AppCtxHandle>, -) -> Result { +) -> Result { let ctx = ctx.inner().clone(); - tauri::async_runtime::spawn_blocking(move || { - let defs = load_active_index_defs(&ctx); - let conn = open_db(&ctx).map_err(|err| err.to_string())?; - let generation = - observation_index::rebuild_all_indexes(&conn, &defs).map_err(|err| err.to_string())?; - let last_rebuild_at: Option = conn - .query_row( - "SELECT last_rebuild_at FROM observation_index_meta WHERE id = 1", - [], - |r| r.get(0), - ) - .ok(); - Ok(RebuildObservationIndexesResult { - generation, - last_rebuild_at, - }) - }) - .await - .map_err(|e| e.to_string())? + match schedule_observation_index_rebuild(&app, &ctx) { + Some(job_id) => Ok(StartObservationIndexRebuildResult { + job_id, + scheduled: true, + }), + None => Ok(StartObservationIndexRebuildResult { + job_id: String::new(), + scheduled: false, + }), + } +} + +#[tauri::command] +fn rebuild_observation_indexes( + app: tauri::AppHandle, + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result { + start_observation_index_rebuild(app, ctx) } #[tauri::command] @@ -2330,33 +3091,327 @@ pub(crate) fn load_dirty_observations_by_ids( found.insert(rec.id.clone(), rec); } } - let mut ordered = Vec::new(); - for id in ids { - if let Some(r) = found.remove(id.as_str()) { - ordered.push(r); + let mut ordered = Vec::new(); + for id in ids { + if let Some(r) = found.remove(id.as_str()) { + ordered.push(r); + } + } + Ok(ordered) +} + +#[tauri::command] +fn list_form_types(ctx: tauri::State<'_, AppCtxHandle>) -> Result, String> { + let conn = open_db(&ctx).map_err(|err| err.to_string())?; + let mut stmt = conn + .prepare( + "SELECT DISTINCT form_type FROM observations + WHERE form_type IS NOT NULL AND TRIM(form_type) != '' + ORDER BY form_type COLLATE NOCASE", + ) + .map_err(|err| err.to_string())?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|err| err.to_string())?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|err| err.to_string())?); + } + Ok(out) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ObservationTimelineBucket { + bucket_start: String, + label: String, + count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ObservationOverviewTimeline { + bucket_unit: String, + range_start: String, + range_end: String, + buckets: Vec, + observations_without_date: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ObservationGeolocationSummary { + with_location: i64, + without_location: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ObservationMapPoint { + id: String, + form_type: String, + latitude: f64, + longitude: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ObservationOverviewMap { + points: Vec, + truncated: bool, + cap: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ObservationOverviewRow { + form_type: String, + observation_count: i64, + pending_sync_count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ObservationOverviewResult { + rows: Vec, + totals: ObservationOverviewRow, + timeline: ObservationOverviewTimeline, + geolocation_summary: ObservationGeolocationSummary, + map: ObservationOverviewMap, + computed_at: String, +} + +const OVERVIEW_MAP_POINT_CAP: i64 = 5000; + +fn parse_iso_to_naive_date(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) { + return Some(dt.date_naive()); + } + if let Ok(dt) = trimmed.parse::>() { + return Some(dt.date_naive()); + } + NaiveDate::parse_from_str(&trimmed[..trimmed.len().min(10)], "%Y-%m-%d").ok() +} + +fn resolve_observation_created_at( + extras_raw: Option<&str>, + updated_at: Option<&str>, + last_saved_at: &str, +) -> Option { + if let Some(raw) = extras_raw + && let Ok(extras) = serde_json::from_str::(raw) + && let Some(ref created) = extras.created_at + && let Some(d) = parse_iso_to_naive_date(created) + { + return Some(d); + } + updated_at + .and_then(parse_iso_to_naive_date) + .or_else(|| parse_iso_to_naive_date(last_saved_at)) +} + +fn geolocation_from_extras_raw(extras_raw: Option<&str>) -> Option<(f64, f64)> { + let raw = extras_raw?; + let extras: ObservationExtras = serde_json::from_str(raw).ok()?; + let geo = extras.geolocation?; + let lat = geo.get("latitude")?.as_f64()?; + let lng = geo.get("longitude")?.as_f64()?; + if lat.is_finite() && lng.is_finite() && lat.abs() <= 90.0 && lng.abs() <= 180.0 { + Some((lat, lng)) + } else { + None + } +} + +fn week_start(date: NaiveDate) -> NaiveDate { + date - chrono::Duration::days(date.weekday().num_days_from_monday() as i64) +} + +fn format_day_label(date: NaiveDate) -> String { + format!("{} {}", date.format("%b"), date.day()) +} + +fn format_week_label(date: NaiveDate) -> String { + format!("{} {}", date.format("%b"), date.day()) +} + +fn build_observation_timeline( + dates: &[NaiveDate], + without_date: i64, +) -> ObservationOverviewTimeline { + if dates.is_empty() { + return ObservationOverviewTimeline { + bucket_unit: "day".to_string(), + range_start: String::new(), + range_end: String::new(), + buckets: Vec::new(), + observations_without_date: without_date, + }; + } + + let min_date = *dates.iter().min().unwrap(); + let max_date = *dates.iter().max().unwrap(); + let span_days = (max_date - min_date).num_days(); + let use_weeks = span_days >= 365; + + let mut counts: HashMap = HashMap::new(); + for date in dates { + let bucket = if use_weeks { week_start(*date) } else { *date }; + *counts.entry(bucket).or_insert(0) += 1; + } + + let (range_start, range_end) = if use_weeks { + (week_start(min_date), week_start(max_date)) + } else { + (min_date, max_date) + }; + + let step = if use_weeks { 7 } else { 1 }; + let mut buckets = Vec::new(); + let mut cursor = range_start; + while cursor <= range_end { + let count = counts.get(&cursor).copied().unwrap_or(0); + buckets.push(ObservationTimelineBucket { + bucket_start: cursor.format("%Y-%m-%d").to_string(), + label: if use_weeks { + format_week_label(cursor) + } else { + format_day_label(cursor) + }, + count, + }); + cursor += chrono::Duration::days(step); + } + + ObservationOverviewTimeline { + bucket_unit: if use_weeks { + "week".to_string() + } else { + "day".to_string() + }, + range_start: range_start.format("%Y-%m-%d").to_string(), + range_end: range_end.format("%Y-%m-%d").to_string(), + buckets, + observations_without_date: without_date, + } +} + +fn build_observation_overview( + conn: &Connection, +) -> Result { + let mut stmt = conn.prepare( + "SELECT COALESCE(NULLIF(TRIM(form_type), ''), '(no form type)') AS form_type, + COUNT(*) AS observation_count, + SUM(CASE WHEN dirty = 1 AND sync_status = 'dirty' THEN 1 ELSE 0 END) AS pending_sync_count + FROM observations + GROUP BY 1 + ORDER BY 1 COLLATE NOCASE", + )?; + let rows = stmt + .query_map([], |row| { + Ok(ObservationOverviewRow { + form_type: row.get(0)?, + observation_count: row.get(1)?, + pending_sync_count: row.get(2)?, + }) + })? + .collect::, _>>()?; + + let mut total_observations: i64 = 0; + let mut total_pending: i64 = 0; + for row in &rows { + total_observations += row.observation_count; + total_pending += row.pending_sync_count; + } + + let mut scan_stmt = conn.prepare( + "SELECT id, + COALESCE(NULLIF(TRIM(form_type), ''), '(no form type)') AS form_type, + observation_extras, + updated_at, + last_saved_at + FROM observations", + )?; + + let mut dates: Vec = Vec::new(); + let mut without_date: i64 = 0; + let mut with_location: i64 = 0; + let mut without_location: i64 = 0; + let mut map_points: Vec = Vec::new(); + let mut map_truncated = false; + + let scan_rows = scan_stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + )) + })?; + + for row in scan_rows { + let (id, form_type, extras_raw, updated_at, last_saved_at) = row?; + if let Some(d) = resolve_observation_created_at( + extras_raw.as_deref(), + updated_at.as_deref(), + &last_saved_at, + ) { + dates.push(d); + } else { + without_date += 1; + } + + if let Some((lat, lng)) = geolocation_from_extras_raw(extras_raw.as_deref()) { + with_location += 1; + if (map_points.len() as i64) < OVERVIEW_MAP_POINT_CAP { + map_points.push(ObservationMapPoint { + id, + form_type, + latitude: lat, + longitude: lng, + }); + } else { + map_truncated = true; + } + } else { + without_location += 1; } } - Ok(ordered) + + let timeline = build_observation_timeline(&dates, without_date); + + Ok(ObservationOverviewResult { + rows, + totals: ObservationOverviewRow { + form_type: String::new(), + observation_count: total_observations, + pending_sync_count: total_pending, + }, + timeline, + geolocation_summary: ObservationGeolocationSummary { + with_location, + without_location, + }, + map: ObservationOverviewMap { + points: map_points, + truncated: map_truncated, + cap: OVERVIEW_MAP_POINT_CAP, + }, + computed_at: now_iso(), + }) } #[tauri::command] -fn list_form_types(ctx: tauri::State<'_, AppCtxHandle>) -> Result, String> { +fn get_observation_overview( + ctx: tauri::State<'_, AppCtxHandle>, +) -> Result { let conn = open_db(&ctx).map_err(|err| err.to_string())?; - let mut stmt = conn - .prepare( - "SELECT DISTINCT form_type FROM observations - WHERE form_type IS NOT NULL AND TRIM(form_type) != '' - ORDER BY form_type COLLATE NOCASE", - ) - .map_err(|err| err.to_string())?; - let rows = stmt - .query_map([], |row| row.get::<_, String>(0)) - .map_err(|err| err.to_string())?; - let mut out = Vec::new(); - for r in rows { - out.push(r.map_err(|err| err.to_string())?); - } - Ok(out) + build_observation_overview(&conn).map_err(|err| err.to_string()) } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -2809,6 +3864,12 @@ fn read_host_text_file(path: String) -> Result { read_host_text_file_inner(p) } +/// True when `path` exists and is a directory (for session folder dialog defaults). +#[tauri::command] +fn host_path_is_directory(path: String) -> bool { + Path::new(path.trim()).is_dir() +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ParsedImportFileResult { @@ -2830,6 +3891,55 @@ fn observation_id_from_obj(obj: &serde_json::Map) -> Option, + snake: &str, + camel: &str, +) -> Option { + obj.get(snake) + .or_else(|| obj.get(camel)) + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn optional_import_tags(obj: &serde_json::Map) -> Option> { + let tags = obj.get("tags").and_then(|t| { + t.as_array().map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect::>() + }) + })?; + if tags.is_empty() { None } else { Some(tags) } +} + +/// Synkronus envelope fields outside `data` / `payload` (snake_case or camelCase). +fn observation_extras_from_import_obj( + obj: &serde_json::Map, +) -> Option { + let geolocation = obj.get("geolocation").filter(|v| !v.is_null()).cloned(); + let extras = ObservationExtras { + form_version: optional_import_str(obj, "form_version", "formVersion"), + created_at: optional_import_str(obj, "created_at", "createdAt"), + deleted: obj.get("deleted").and_then(|v| v.as_bool()), + synced_at: optional_import_str(obj, "synced_at", "syncedAt"), + geolocation, + author: optional_import_str(obj, "author", "author"), + device_id: optional_import_str(obj, "device_id", "deviceId"), + tags: optional_import_tags(obj), + }; + let has_any = extras.form_version.is_some() + || extras.created_at.is_some() + || extras.deleted.is_some() + || extras.synced_at.is_some() + || extras.geolocation.is_some() + || extras.author.is_some() + || extras.device_id.is_some() + || extras.tags.is_some(); + if has_any { Some(extras) } else { None } +} + fn extract_observations_from_json_value( root: &Value, _file_name: &str, @@ -2880,7 +3990,7 @@ fn extract_observations_from_json_value( data, form_type, updated_at, - extras: None, + extras: observation_extras_from_import_obj(obj), }); } @@ -3002,7 +4112,68 @@ fn copy_one_attachment_to_pending( Ok(()) } -/// Copy many attachments into `attachments/pending/` with `import/attachment-copy-progress` events (fine-grained). +/// Cap parallel host copies so large imports do not saturate disk I/O. +const ATTACHMENT_COPY_MAX_PARALLEL: usize = 8; + +/// Minimum wall time between progress events forwarded to the WebView. +const ATTACHMENT_COPY_PROGRESS_MIN_INTERVAL: Duration = Duration::from_millis(250); + +/// Emit a progress event every N files (scaled to batch size). +fn attachment_copy_progress_step(total: usize) -> usize { + if total <= 20 { + 1 + } else if total <= 200 { + 10 + } else if total <= 2000 { + 25 + } else { + 50 + } +} + +fn should_emit_attachment_copy_progress( + done: usize, + total: usize, + step: usize, + last_emit: Instant, +) -> bool { + if done == 0 { + return false; + } + if done == 1 || done >= total { + return true; + } + if step > 0 && done.is_multiple_of(step) { + return true; + } + last_emit.elapsed() >= ATTACHMENT_COPY_PROGRESS_MIN_INTERVAL +} + +fn emit_attachment_copy_progress( + app: &tauri::AppHandle, + done: usize, + total: usize, + attachment_id: &str, + last_emit: &Mutex, + step: usize, +) { + let mut last = last_emit.lock().unwrap(); + if !should_emit_attachment_copy_progress(done, total, step, *last) { + return; + } + *last = Instant::now(); + drop(last); + let _ = app.emit( + "import/attachment-copy-progress", + AttachmentCopyProgressEvent { + done, + total, + attachment_id: attachment_id.trim().to_string(), + }, + ); +} + +/// Copy many attachments into `attachments/pending/` with throttled progress events. #[tauri::command] fn copy_workspace_attachments_batch( app: tauri::AppHandle, @@ -3021,26 +4192,38 @@ fn copy_workspace_attachments_batch( let done = AtomicUsize::new(0); let errors = Mutex::new(Vec::::new()); - - items.par_iter().for_each(|it| { - let r = copy_one_attachment_to_pending(&ws, &it.source_path, &it.attachment_id); - let n = done.fetch_add(1, Ordering::Relaxed) + 1; - if let Err(e) = r { - errors - .lock() - .unwrap() - .push(format!("{}: {e}", it.attachment_id.trim())); - } - let _ = app.emit( - "import/attachment-copy-progress", - AttachmentCopyProgressEvent { - done: n, + let progress_step = attachment_copy_progress_step(total); + let last_emit = Mutex::new(Instant::now() - ATTACHMENT_COPY_PROGRESS_MIN_INTERVAL); + + let workers = ATTACHMENT_COPY_MAX_PARALLEL.min(total.max(1)); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(workers) + .build() + .map_err(|e| e.to_string())?; + + pool.install(|| { + items.par_iter().for_each(|it| { + let r = copy_one_attachment_to_pending(&ws, &it.source_path, &it.attachment_id); + let n = done.fetch_add(1, Ordering::Relaxed) + 1; + if let Err(e) = r { + errors + .lock() + .unwrap() + .push(format!("{}: {e}", it.attachment_id.trim())); + } + emit_attachment_copy_progress( + &app, + n, total, - attachment_id: it.attachment_id.trim().to_string(), - }, - ); + &it.attachment_id, + &last_emit, + progress_step, + ); + }); }); + emit_attachment_copy_progress(&app, total, total, "", &last_emit, progress_step); + let errs = errors.into_inner().unwrap(); let failed = errs.len(); Ok(AttachmentCopyBatchResult { @@ -3424,15 +4607,30 @@ fn get_app_bundle_state( read_app_bundle_state_unlocked(&bundles).map_err(|e| e.to_string()) } -/// Writes `bundles/archives/{version}.zip`, replaces `bundles/active/` with extracted contents, -/// and updates `bundles/state.json`. Removes legacy `bundles/app-bundle.zip` if present. +/// Downloads the active app bundle from Synkronus and applies it under `bundles/active/`. +/// Progress: `bundle/apply-progress` (download/archive/extract) and `bundle/index-rebuild` (background). #[tauri::command] -fn apply_app_bundle_download( +async fn download_and_apply_app_bundle( + app: tauri::AppHandle, + base_url: String, + bearer_token: String, + x_ode_version: String, version: String, hash: String, - zip_bytes: Vec, ctx: tauri::State<'_, AppCtxHandle>, -) -> Result { +) -> Result { + let base = base_url.trim(); + if base.is_empty() { + return Err("base_url is required".to_string()); + } + let token = bearer_token.trim(); + if token.is_empty() { + return Err("bearer token is required".to_string()); + } + let ode_ver = x_ode_version.trim(); + if ode_ver.is_empty() { + return Err("x_ode_version is required".to_string()); + } let ver = version.trim(); if ver.is_empty() { return Err("version is required".to_string()); @@ -3441,61 +4639,112 @@ fn apply_app_bundle_download( if hash.is_empty() { return Err("hash is required".to_string()); } - if zip_bytes.is_empty() { - return Err("zip is empty".to_string()); - } - with_workspace_fs_exclusive(&ctx, |ctx| { - let ws = get_workspace_path(ctx)?; - let bundles = ws.join("bundles"); - let archives_dir = bundles.join("archives"); - let active_dir = bundles.join("active"); - fs::create_dir_all(&archives_dir)?; - let prev = read_app_bundle_state_unlocked(&bundles)?; - let mut archived = prev - .as_ref() - .map(|s| s.archived_versions.clone()) - .unwrap_or_default(); - if !archived.iter().any(|v| v == ver) { - archived.push(ver.to_string()); - } - archived.sort(); - let sanit = sanitize_version_for_filename(ver); - let archive_zip = archives_dir.join(format!("{sanit}.zip")); - fs::write(&archive_zip, &zip_bytes)?; - if active_dir.exists() { - fs::remove_dir_all(&active_dir)?; - } - fs::create_dir_all(&active_dir)?; - extract_zip_to_dir(&zip_bytes, &active_dir)?; - let state = AppBundleState { - schema_version: 1, - active_version: ver.to_string(), - active_hash: hash.to_string(), - downloaded_at: Utc::now().to_rfc3339(), - archived_versions: archived, + + let job_id = Uuid::new_v4().to_string(); + emit_bundle_apply_progress( + &app, + &job_id, + "downloading", + 0, + 0, + "Downloading bundle from server…", + None, + ); + + let zip_bytes = + match download_synkronus_app_bundle_zip(&app, &job_id, base, token, ode_ver).await { + Ok(b) => b, + Err(e) => { + let msg = e.to_string(); + emit_bundle_apply_progress( + &app, + &job_id, + "failed", + 0, + 0, + "Bundle download failed.", + Some(&msg), + ); + return Err(msg); + } }; - let state_path = bundles.join("state.json"); - if let Some(parent) = state_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write( - &state_path, - serde_json::to_string_pretty(&state) - .map_err(|e| CustodianError::Message(e.to_string()))?, - )?; - let legacy = bundles.join("app-bundle.zip"); - if legacy.exists() { - let _ = fs::remove_file(&legacy); - } - let app_config = active_dir.join("app/app.config.json"); - if app_config.exists() { - let defs = observation_index::load_index_config(&app_config); - let conn = open_db(ctx)?; - let _ = observation_index::rebuild_all_indexes(&conn, &defs); + + let ctx_inner = ctx.inner().clone(); + let state = match apply_app_bundle_zip_bytes(&app, &job_id, &ctx_inner, ver, hash, &zip_bytes) { + Ok(s) => s, + Err(e) => { + let msg = e.to_string(); + emit_bundle_apply_progress( + &app, + &job_id, + "failed", + 0, + 0, + "Applying bundle failed.", + Some(&msg), + ); + return Err(msg); } - Ok(state) + }; + + emit_bundle_apply_progress(&app, &job_id, "completed", 1, 1, "Bundle applied.", None); + + let needs_index = bundle_app_config_path(&ctx_inner) + .ok() + .filter(|p| p.exists()) + .is_some(); + let index_rebuild_scheduled = if needs_index { + schedule_observation_index_rebuild(&app, &ctx_inner).is_some() + } else { + false + }; + + Ok(DownloadAndApplyAppBundleResult { + state, + index_rebuild_scheduled, + }) +} + +/// Zips the developer mirror, uploads it to Synkronus, and activates the new version. +#[tauri::command] +async fn push_dev_mirror_app_bundle( + ctx: tauri::State<'_, AppCtxHandle>, + base_url: String, + bearer_token: String, + x_ode_version: String, +) -> Result { + let base = base_url.trim(); + if base.is_empty() { + return Err("base_url is required".to_string()); + } + let token = bearer_token.trim(); + if token.is_empty() { + return Err("bearer token is required".to_string()); + } + let ode_ver = x_ode_version.trim(); + if ode_ver.is_empty() { + return Err("x_ode_version is required".to_string()); + } + if !profile_developer_mode(&ctx)? { + return Err("developer mode is not enabled for the active profile".to_string()); + } + + let ws = get_workspace_path(&ctx).map_err(|e| e.to_string())?; + let zip_path = zip_dev_mirror_bundle(&ws).map_err(|e| e.to_string())?; + + let push_result = push_app_bundle_zip(base, token, ode_ver, &zip_path).await; + let _ = fs::remove_file(&zip_path); + + let push_res = push_result.map_err(|e| e.to_string())?; + switch_app_bundle_version(base, token, ode_ver, &push_res.manifest.version) + .await + .map_err(|e| e.to_string())?; + + Ok(PushDevMirrorAppBundleResult { + version: push_res.manifest.version, + hash: push_res.manifest.hash, + message: push_res.message, }) - .map_err(|e: CustodianError| e.to_string()) } fn reserved_form_dir_name(name: &str) -> bool { @@ -3815,16 +5064,20 @@ fn import_observations_run( conflicts += 1; } } - if !index_defs.is_empty() { + 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). let payload = serde_json::to_string(&observation.data).map_err(|e| e.to_string())?; let form_type = observation.form_type.as_deref().unwrap_or(""); - let _ = observation_index::incremental_reindex( + observation_index::incremental_reindex( &tx, &observation.observation_id, form_type, &payload, &index_defs, - ); + ) + .map_err(|err| err.to_string())?; } imported += 1; } @@ -3842,16 +5095,27 @@ fn import_observations_run( conflicts, attachments_downloaded: 0, attachments_failed: 0, + index_rebuild_scheduled: false, }) } #[tauri::command] fn import_observations( + app: tauri::AppHandle, observations: Vec, mark_pending: Option, + schedule_index_rebuild: Option, ctx: tauri::State<'_, AppCtxHandle>, ) -> Result { - import_observations_run(observations, mark_pending.unwrap_or(false), &ctx) + 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; + if should_schedule { + result.index_rebuild_scheduled = + schedule_observation_index_rebuild(&app, &ctx_inner).is_some(); + } + Ok(result) } #[tauri::command] @@ -4066,6 +5330,7 @@ pub fn run() { auth: Mutex::new(None), workspace_sqlite_lock: Mutex::new(()), active_sync: Mutex::new(None), + index_rebuild_gate: Mutex::new(IndexRebuildGate::default()), }); persist_config(&ctx).map_err(|err| err.to_string())?; ensure_active_workspace_dirs(&ctx).map_err(|err| err.to_string())?; @@ -4093,11 +5358,13 @@ pub fn run() { list_observations, list_observations_page, query_observations, + start_observation_index_rebuild, rebuild_observation_indexes, create_observation_sqlite_indexes, get_observation_index_status, list_dirty_observations, list_form_types, + get_observation_overview, get_sync_state, set_sync_state, archive_workspace_for_repository_generation, @@ -4109,6 +5376,7 @@ pub fn run() { parse_import_observation_json_paths, copy_workspace_attachments_batch, read_host_text_file, + host_path_is_directory, read_host_text_files_batch, download_workspace_attachment_from_url, upload_outbound_attachments, @@ -4116,7 +5384,8 @@ pub fn run() { write_workspace_file, get_app_bundle_state, refresh_custom_app_dev_mirror, - apply_app_bundle_download, + download_and_apply_app_bundle, + push_dev_mirror_app_bundle, list_active_bundle_forms, read_bundle_form_spec, read_workspace_text_file, @@ -4151,11 +5420,52 @@ mod tests { use std::path::Path; use super::{ - bind_query_params, mirror_custom_app_dev_folder, parse_time, resolve_attachment_path, - should_mark_conflict, validate_custom_app_dev_source_folder, + 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, + upsert_observation_from_local_import, validate_custom_app_dev_source_folder, + zip_dev_mirror_bundle, }; use crate::observation_query::SqlParam; - use rusqlite::Connection; + use rusqlite::{Connection, params}; + use serde_json::Value; + use std::io::Read; + use std::time::Instant; + + #[test] + fn attachment_copy_progress_step_scales_with_batch_size() { + assert_eq!(attachment_copy_progress_step(5), 1); + assert_eq!(attachment_copy_progress_step(100), 10); + assert_eq!(attachment_copy_progress_step(1500), 25); + assert_eq!(attachment_copy_progress_step(5000), 50); + } + + #[test] + fn attachment_copy_progress_emit_first_last_and_interval() { + let total = 100; + let step = attachment_copy_progress_step(total); + let old = Instant::now() - ATTACHMENT_COPY_PROGRESS_MIN_INTERVAL; + assert!(should_emit_attachment_copy_progress(1, total, step, old)); + assert!(should_emit_attachment_copy_progress( + total, total, step, old + )); + assert!(!should_emit_attachment_copy_progress( + 2, + total, + step, + Instant::now() + )); + assert!(should_emit_attachment_copy_progress( + step, + total, + step, + Instant::now() + )); + } #[test] fn parse_time_handles_valid_timestamp() { @@ -4253,6 +5563,149 @@ mod tests { let _ = fs::remove_dir_all(&base); } + #[test] + fn build_observation_overview_groups_by_form_type_and_pending() { + let conn = Connection::open_in_memory().unwrap(); + init_db(&conn).unwrap(); + let insert = |id: &str, form_type: &str, dirty: i64, sync_status: &str| { + conn.execute( + "INSERT INTO observations (id, payload, form_type, updated_at, dirty, sync_status, last_saved_at) + VALUES (?1, '{}', ?2, '2026-01-01T00:00:00Z', ?3, ?4, '2026-01-01T00:00:00Z')", + params![id, form_type, dirty, sync_status], + ) + .unwrap(); + }; + insert("a1", "hh_hut", 1, "dirty"); + insert("a2", "hh_hut", 0, "clean"); + insert("b1", "hh_person", 1, "dirty"); + insert("c1", "", 0, "clean"); + + let result = build_observation_overview(&conn).unwrap(); + assert_eq!(result.rows.len(), 3); + + let hut = result + .rows + .iter() + .find(|r| r.form_type == "hh_hut") + .unwrap(); + assert_eq!(hut.observation_count, 2); + assert_eq!(hut.pending_sync_count, 1); + + let no_type = result + .rows + .iter() + .find(|r| r.form_type == "(no form type)") + .unwrap(); + assert_eq!(no_type.observation_count, 1); + + assert_eq!(result.totals.observation_count, 4); + assert_eq!(result.totals.pending_sync_count, 2); + assert_eq!(result.geolocation_summary.with_location, 0); + assert_eq!(result.geolocation_summary.without_location, 4); + assert_eq!(result.map.points.len(), 0); + } + + #[test] + fn build_observation_overview_timeline_uses_day_buckets_under_one_year() { + let conn = Connection::open_in_memory().unwrap(); + init_db(&conn).unwrap(); + let insert = |id: &str, created_at: &str| { + let extras = format!(r#"{{"createdAt":"{created_at}"}}"#); + conn.execute( + "INSERT INTO observations (id, payload, form_type, updated_at, dirty, sync_status, last_saved_at, observation_extras) + VALUES (?1, '{}', 'hh_hut', ?2, 0, 'clean', ?2, ?3)", + params![id, created_at, extras], + ) + .unwrap(); + }; + insert("a", "2026-01-01T10:00:00Z"); + insert("b", "2026-01-01T12:00:00Z"); + insert("c", "2026-01-03T12:00:00Z"); + + let result = build_observation_overview(&conn).unwrap(); + assert_eq!(result.timeline.bucket_unit, "day"); + assert_eq!(result.timeline.buckets.len(), 3); + assert_eq!(result.timeline.buckets[0].count, 2); + assert_eq!(result.timeline.buckets[1].count, 0); + assert_eq!(result.timeline.buckets[2].count, 1); + assert_eq!(result.timeline.observations_without_date, 0); + } + + #[test] + fn build_observation_overview_extracts_geolocation_and_map_points() { + let conn = Connection::open_in_memory().unwrap(); + init_db(&conn).unwrap(); + let extras = r#"{"createdAt":"2026-01-01T10:00:00Z","geolocation":{"latitude":1.23,"longitude":4.56}}"#; + conn.execute( + "INSERT INTO observations (id, payload, form_type, updated_at, dirty, sync_status, last_saved_at, observation_extras) + VALUES ('obs-1', '{}', 'hh_hut', '2026-01-01T10:00:00Z', 0, 'clean', '2026-01-01T10:00:00Z', ?1)", + params![extras], + ) + .unwrap(); + + let result = build_observation_overview(&conn).unwrap(); + assert_eq!(result.geolocation_summary.with_location, 1); + assert_eq!(result.geolocation_summary.without_location, 0); + assert_eq!(result.map.points.len(), 1); + assert!((result.map.points[0].latitude - 1.23).abs() < f64::EPSILON); + assert_eq!(result.map.points[0].form_type, "hh_hut"); + } + + #[test] + fn extract_observations_from_json_value_preserves_envelope_extras() { + let root: Value = serde_json::from_str( + r#"{ + "observation_id": "uuid:test", + "form_type": "hh_hut", + "data": { "hh_hut_gps": "{\"latitude\":1}" }, + "updated_at": "2024-07-03T14:39:06.407Z", + "geolocation": { "latitude": 5.33, "longitude": 36.07 }, + "author": "username:device02", + "tags": ["migrated"] + }"#, + ) + .unwrap(); + let obs = extract_observations_from_json_value(&root, "f.json").unwrap(); + assert_eq!(obs.len(), 1); + let extras = obs[0].extras.as_ref().unwrap(); + assert_eq!(extras.author.as_deref(), Some("username:device02")); + assert_eq!(extras.tags.as_deref(), Some(&["migrated".to_string()][..])); + assert!(extras.geolocation.is_some()); + } + + #[test] + fn upsert_observation_from_local_import_persists_extras() { + let conn = Connection::open_in_memory().unwrap(); + init_db(&conn).unwrap(); + let incoming = ApiObservation { + observation_id: "uuid:import-1".to_string(), + data: serde_json::json!({ "x": 1 }), + form_type: Some("hh_hut".to_string()), + updated_at: Some("2024-07-03T14:39:06.407Z".to_string()), + extras: Some(ObservationExtras { + author: Some("username:device02".to_string()), + tags: Some(vec!["migrated".to_string()]), + geolocation: Some(serde_json::json!({ + "latitude": 5.33, + "longitude": 36.07 + })), + ..Default::default() + }), + }; + upsert_observation_from_local_import(&conn, &incoming).unwrap(); + let extras_raw: Option = conn + .query_row( + "SELECT observation_extras FROM observations WHERE id = ?1", + params!["uuid:import-1"], + |row| row.get(0), + ) + .unwrap(); + let parsed = parse_observation_extras(extras_raw).unwrap(); + assert_eq!(parsed.author.as_deref(), Some("username:device02")); + assert_eq!(parsed.tags.as_deref(), Some(&["migrated".to_string()][..])); + assert!(parsed.geolocation.is_some()); + } + #[test] fn bound_query_params_execute_with_raw_query() { let conn = Connection::open_in_memory().unwrap(); @@ -4272,4 +5725,147 @@ mod tests { assert_eq!(b, 7); assert!(rows.next().unwrap().is_none()); } + + fn minimal_bundle_zip_bytes() -> Vec { + use std::io::Write; + let base = + std::env::temp_dir().join(format!("ode_bundle_zip_fixture_{}", std::process::id())); + let zip_path = base.join("fixture.zip"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + { + let file = fs::File::create(&zip_path).unwrap(); + let mut zip = ZipWriter::new(file); + let options = + SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + zip.start_file("app/index.html", options).unwrap(); + zip.write_all(b"").unwrap(); + zip.finish().unwrap(); + } + let bytes = fs::read(&zip_path).unwrap(); + let _ = fs::remove_dir_all(&base); + bytes + } + + #[test] + fn apply_app_bundle_zip_at_workspace_writes_state_and_active() { + let base = + std::env::temp_dir().join(format!("ode_bundle_apply_test_{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + let zip_bytes = minimal_bundle_zip_bytes(); + let state = apply_app_bundle_zip_at_workspace( + Path::new(&base), + "1.0.0", + "abc123", + &zip_bytes, + None, + ) + .unwrap(); + assert_eq!(state.active_version, "1.0.0"); + assert_eq!(state.active_hash, "abc123"); + assert!(base.join("bundles/active/app/index.html").is_file()); + assert!(base.join("bundles/archives/1.0.0.zip").is_file()); + assert!(base.join("bundles/state.json").is_file()); + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn zip_dev_mirror_bundle_produces_valid_layout() { + 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/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::create_dir_all(base.join("bundles/dev-local/forms/extensions/helpers")).unwrap(); + fs::write( + base.join("bundles/dev-local/forms/extensions/helpers/queryHelpers.js"), + b"export {}", + ) + .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(&"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/"))); + let _ = fs::remove_file(&zip_path); + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn zip_dev_mirror_bundle_inlines_shared_choice_refs() { + let base = + std::env::temp_dir().join(format!("ode_dev_zip_shared_choice_{}", 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/shared-choice-defs.schema.json"), + br#"{"$defs":{"yesno":{"type":"string","enum":["yes","no"]}}}"#, + ) + .unwrap(); + fs::write( + base.join("bundles/dev-local/forms/demo/schema.json"), + br#"{"type":"object","properties":{"ok":{"$ref":"forms/shared-choice-defs.schema.json#/$defs/yesno"}}}"#, + ) + .unwrap(); + fs::write(base.join("bundles/dev-local/forms/demo/ui.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(); + let mut schema_entry = archive.by_name("forms/demo/schema.json").unwrap(); + let mut schema_raw = String::new(); + schema_entry.read_to_string(&mut schema_raw).unwrap(); + let schema: Value = serde_json::from_str(&schema_raw).unwrap(); + assert_eq!( + schema["properties"]["ok"]["$ref"], + Value::String("#/$defs/yesno".to_string()) + ); + assert!(schema["$defs"]["yesno"].is_object()); + 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")); + assert!(publish_bundle_zip_entry_allowed( + "forms/household/schema.json" + )); + assert!(publish_bundle_zip_entry_allowed( + "app/forms/household/ui.json" + )); + assert!(!publish_bundle_zip_entry_allowed( + "forms/shared-choice-defs.schema.json" + )); + assert!(!publish_bundle_zip_entry_allowed( + "forms/extensions/helpers/queryHelpers.js" + )); + 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 02adb7a2e..62c0edc18 100644 --- a/desktop/src-tauri/src/observation_index.rs +++ b/desktop/src-tauri/src/observation_index.rs @@ -145,9 +145,12 @@ fn scalar_to_columns(val: &Value, value_type: Option<&str>) -> (Option, (Some(val.to_string()), None) } +type RebuildProgressCallback<'a> = dyn FnMut(i64, i64, Option<&str>) + 'a; + pub fn rebuild_all_indexes( conn: &Connection, defs: &[ObservationIndexDef], + mut progress: Option<&mut RebuildProgressCallback<'_>>, ) -> rusqlite::Result { let active: i64 = conn.query_row( "SELECT active_generation FROM observation_index_meta WHERE id = 1", @@ -166,6 +169,11 @@ pub fn rebuild_all_indexes( params![new_gen], )?; + let total: i64 = conn.query_row("SELECT COUNT(*) FROM observations", [], |r| r.get(0))?; + if let Some(ref mut cb) = progress { + 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(( @@ -175,10 +183,29 @@ pub fn rebuild_all_indexes( )) })?; + let progress_interval = if total < 50 { + 1 + } else if total < 500 { + 10 + } else { + 50 + }; + + 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; + if let Some(ref mut cb) = progress + && (total == 0 || done == total || done % progress_interval == 0) + { + cb(done, total, Some("Indexing observations…")); + } + } + + if let Some(ref mut cb) = progress { + cb(done, total, Some("Creating SQLite indexes…")); } recreate_sqlite_indexes(conn, defs)?; @@ -429,7 +456,7 @@ mod tests { [], ) .unwrap(); - let gen1 = rebuild_all_indexes(&conn, &defs).unwrap(); + let gen1 = rebuild_all_indexes(&conn, &defs, None).unwrap(); assert_eq!(gen1, 2); let active: i64 = active_generation(&conn).unwrap(); assert_eq!(active, 2); diff --git a/desktop/src-tauri/src/observation_query.rs b/desktop/src-tauri/src/observation_query.rs index 0007ec46d..c2b15aadd 100644 --- a/desktop/src-tauri/src/observation_query.rs +++ b/desktop/src-tauri/src/observation_query.rs @@ -51,10 +51,8 @@ pub fn compile_observation_query( } if let Some(f) = filter { - match compile_filter_node(f, index_keys, &mut params, &mut warnings) { - Ok(sql) => where_parts.push(sql), - Err(e) => return Err(e), - } + let sql = compile_filter_node(f, index_keys, &mut params, &mut warnings)?; + where_parts.push(sql); } let sql = format!( @@ -359,7 +357,7 @@ mod tests { assert!(result.is_err(), "expected error for {name}"); continue; } - let compiled = result.expect(&name); + let compiled = result.expect(name); let fragments = fixture["expectedSqlFragmentsByDialect"]["desktop"] .as_array() .or_else(|| fixture["expectedSqlFragments"].as_array()); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b9e978466..a82c8b562 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.1.1", + "version": "1.2.0", "identifier": "org.opendataensemble.custodian", "build": { "beforeDevCommand": "pnpm dev", diff --git a/desktop/src/App.css b/desktop/src/App.css index 1799bd41c..9e91b7be8 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -243,6 +243,13 @@ body { flex: 1; min-height: 0; overflow: hidden; + display: flex; + flex-direction: column; +} + +.page.page-form-preview > .notice { + flex-shrink: 0; + margin: 0 var(--space-md) var(--space-sm); } .page.page-custom-app { @@ -257,6 +264,21 @@ body { flex-shrink: 0; } +.page.page-custom-app .custom-app-bundle-row { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: 0 var(--space-md) var(--space-sm); +} + +.page.page-custom-app .custom-app-bundle-row .custom-app-bundle-line { + margin: 0; + flex: 1; + min-width: 0; +} + .page.page-custom-app > .notice, .page.page-custom-app > .muted { flex-shrink: 0; @@ -517,6 +539,217 @@ body { overflow: hidden; } +.custom-app-device-shell { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.custom-app-device-toolbar { + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--space-md); + flex-wrap: wrap; + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--color-border); + background: #101828; +} + +.custom-app-device-toolbar--split { + justify-content: space-between; +} + +.custom-app-device-toolbar-start { + display: inline-flex; + align-items: center; + gap: var(--space-md); + flex-wrap: wrap; + min-width: 0; + flex: 1; +} + +.custom-app-device-toolbar-end { + display: inline-flex; + align-items: center; + gap: var(--space-md); + flex-wrap: wrap; + flex-shrink: 0; + margin-left: auto; +} + +.custom-app-device-toolbar-label { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + font-size: var(--font-size-sm); + color: #90a3cb; +} + +.custom-app-device-toolbar-controls { + display: inline-flex; + align-items: center; + gap: var(--space-sm); +} + +.custom-app-device-orientation-btn { + min-height: var(--control-height); + min-width: var(--control-height); + padding: 0; +} + +.custom-app-device-orientation-btn .material-symbols-outlined { + font-size: 1.25rem; +} + +.custom-app-device-toolbar-label select, +.custom-app-device-toolbar-controls select { + min-width: 14rem; +} + +.form-preview-toolbar-fields { + display: inline-flex; + align-items: center; + gap: var(--space-md); + flex-wrap: wrap; + min-width: 0; +} + +.form-preview-toolbar-fields select { + min-width: 10rem; +} + +.form-preview-toolbar-status { + font-size: var(--font-size-sm); + white-space: nowrap; +} + +.form-preview-advanced-btn { + min-height: var(--control-height); + min-width: var(--control-height); + padding: 0; +} + +.page.page-form-preview .form-preview-embed-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.form-preview-advanced-dialog { + width: min(36rem, 92vw); +} + +.form-preview-advanced-dialog-fields { + display: flex; + flex-direction: column; + gap: var(--space-md); + margin-bottom: var(--space-md); +} + +.form-preview-advanced-field { + flex-direction: column; + align-items: stretch; + width: 100%; +} + +.form-preview-advanced-field span { + flex-shrink: 0; +} + +.custom-app-device-toolbar-meta { + font-size: var(--font-size-sm); +} + +.custom-app-device-viewport { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + align-items: flex-start; + justify-content: center; + padding: var(--space-md); + background: + radial-gradient( + circle at 50% 0%, + rgba(107, 138, 232, 0.06), + transparent 55% + ), + #0b1326; +} + +.custom-app-device-viewport--responsive { + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; + align-items: stretch; + justify-content: flex-start; +} + +.custom-app-device-viewport--responsive .formplayer-embed-wrap, +.custom-app-device-viewport--responsive .custom-app-embed-wrap { + flex: 1; + min-height: 0; + min-width: 0; + width: 100%; +} + +.custom-app-device-viewport--responsive .formplayer-embed-frame { + flex: 1; + min-height: 0; + width: 100%; +} + +.custom-app-device-scaler { + flex-shrink: 0; + position: relative; +} + +.custom-app-device-frame { + transform-origin: top left; + overflow: hidden; + border: 1px solid #455070; + border-radius: 0.75rem; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.35), + 0 12px 40px rgba(0, 0, 0, 0.45); + background: #0b1326; +} + +.custom-app-embed-wrap--fill { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + gap: 0; +} + +.custom-app-embed-wrap--fill > .notice, +.custom-app-embed-wrap--fill > .muted { + position: absolute; + z-index: 2; + left: var(--space-md); + right: var(--space-md); + top: var(--space-md); + margin: 0; +} + +.custom-app-embed-wrap--fill .formplayer-embed-frame { + position: absolute; + inset: 0; + flex: none; + width: 100%; + height: 100%; + min-height: 0; + border: none; + border-radius: 0; +} + .split.split-form-preview { flex: 1; min-height: 0; @@ -527,6 +760,7 @@ body { align-content: stretch; } +/* legacy sidebar layout — kept for reference if nested elsewhere */ .panel.panel-form-preview-sidebar { display: flex; flex-direction: column; @@ -685,7 +919,7 @@ body { .panel.panel-observations-editor .observation-form { flex: 1; min-height: 0; - overflow-y: auto; + overflow: hidden; } .form-type-filter-label { @@ -1041,6 +1275,21 @@ textarea { gap: 0.5rem; flex: 1; min-width: 0; + flex-wrap: wrap; +} + +.activity-progress { + flex: 1 1 100%; + height: 4px; + border-radius: 2px; + background: color-mix(in srgb, var(--color-text) 12%, transparent); + overflow: hidden; +} + +.activity-progress-fill { + height: 100%; + background: var(--color-accent, #2563eb); + transition: width 0.2s ease; } .app-sync-banner-dismiss { @@ -1943,6 +2192,11 @@ input:not([type]) { padding-right: var(--space-sm); } +.observation-form.observation-form-editor { + overflow: hidden; + padding-right: 0; +} + .section-heading { display: flex; align-items: center; @@ -2182,8 +2436,20 @@ input:not([type]) { font-size: var(--font-size-sm); } -.import-staging-section { +.import-staging-lists { margin-top: var(--space-lg); + max-height: min(16rem, 40vh); + overflow-y: auto; + border: 1px solid #2a3a5c; + border-radius: var(--panel-radius); + padding: var(--space-sm) var(--space-md); + background: #0d1424; +} + +.import-staging-lists .import-staging-section + .import-staging-section { + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid rgba(45, 52, 73, 0.5); } .import-staging-section h4 { @@ -2191,6 +2457,11 @@ input:not([type]) { font-size: var(--font-size-sm); font-weight: 600; color: var(--color-text-muted); + position: sticky; + top: 0; + z-index: 1; + padding: var(--space-xs) 0; + background: #0d1424; } .import-staging-row { @@ -2375,6 +2646,54 @@ input:not([type]) { flex-direction: column; } +.page-observations-tabs .tab-content--overview { + overflow: hidden; +} + +.page-observations-tabs .tab-content--editor { + overflow: hidden; +} + +.observation-form-editor { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.observation-form-editor .editor-header, +.observation-form-editor .notice { + flex-shrink: 0; +} + +.observation-editor-accordion { + flex: 1; + min-height: 0; +} + +.observation-editor-subheading { + margin-top: 0; +} + +.observation-editor-subheading + .form-table { + margin-bottom: var(--space-md); +} + +.observation-editor-data-body { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + padding-bottom: var(--space-md); +} + +.observation-editor-data-textarea { + flex: 1; + min-height: 12rem; + width: 100%; + resize: none; + box-sizing: border-box; +} + .observations-list-full { width: 100%; flex: 1; @@ -2383,6 +2702,292 @@ input:not([type]) { flex-direction: column; } +.observations-overview { + flex: 1; + min-height: 0; + min-width: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.observations-overview-toolbar { + display: flex; + align-items: center; + gap: var(--space-md); + flex-shrink: 0; +} + +.observations-overview-updated { + font-size: var(--font-size-sm); +} + +.observations-overview-table { + width: 100%; + max-width: 48rem; +} + +.observations-overview-table--accordion { + max-width: none; +} + +.observations-overview-accordion { + display: flex; + flex-direction: column; + gap: var(--space-sm); + flex: 1; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.observations-overview-accordion-item { + flex: 0 0 auto; + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: var(--panel-radius); + overflow: hidden; + background: #131b2e; +} + +.observations-overview-accordion-item.is-open { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + border-color: #455070; +} + +.observations-overview-accordion-header { + display: flex; + align-items: center; + gap: var(--space-sm); + width: 100%; + padding: var(--space-md) var(--space-lg); + border: none; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + flex: 0 0 auto; + min-height: 3rem; + box-sizing: border-box; + font-weight: 600; + font-size: var(--font-size-sm); +} + +.observations-overview-accordion-header:hover { + background: rgba(255, 255, 255, 0.03); +} + +.observations-overview-accordion-item.is-open + .observations-overview-accordion-header { + border-bottom: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.02); +} + +.observations-overview-accordion-icon { + font-size: 1.15rem; + color: #90a3cb; +} + +.observations-overview-accordion-title { + flex: 0 0 auto; +} + +.observations-overview-accordion-meta { + margin-left: auto; + font-size: var(--font-size-sm); + font-weight: 400; +} + +.observations-overview-accordion-body { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: auto; + padding: var(--space-md) var(--space-lg) var(--space-lg); +} + +.observations-overview-charts-row--accordion { + gap: var(--space-lg); +} + +.observations-overview-chart-embedded { + display: flex; + flex-direction: column; + min-height: 0; +} + +.observations-overview-chart-embedded .observations-overview-chart-header h4 { + margin: 0; + font-size: var(--font-size-sm); + font-weight: 600; + color: #b7c8eb; +} + +.observations-overview-charts { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.observations-overview-charts-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-md); + align-items: stretch; +} + +@media (max-width: 900px) { + .observations-overview-charts-row { + grid-template-columns: 1fr; + } +} + +.observations-overview-chart { + display: flex; + flex-direction: column; + min-height: 0; + margin: 0; +} + +.observations-overview-chart-map { + width: 100%; +} + +.observations-overview-chart-header h3 { + margin: 0; + font-size: var(--font-size-sm); + color: #b7c8eb; +} + +.observations-overview-chart-subtitle { + margin: 0.35rem 0 0; + font-size: var(--font-size-sm); +} + +.observations-overview-chart-body { + margin-top: var(--space-md); + min-height: 0; +} + +.observations-overview-chart-empty { + margin: 0; + padding: var(--space-lg) 0; + text-align: center; +} + +.observations-overview-donut-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--space-md); + align-items: center; +} + +@media (max-width: 640px) { + .observations-overview-donut-layout { + grid-template-columns: 1fr; + } +} + +.observations-overview-donut-wrap { + position: relative; + min-height: 200px; +} + +.observations-overview-donut-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.observations-overview-donut-total { + font-family: 'Space Grotesk', sans-serif; + font-size: 1.35rem; + font-weight: 700; + color: #e8edf8; +} + +.observations-overview-legend { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; + max-height: 200px; + overflow-y: auto; +} + +.observations-overview-legend li { + display: grid; + grid-template-columns: 0.75rem 1fr auto; + gap: 0.5rem; + align-items: center; + font-size: var(--font-size-sm); +} + +.observations-overview-legend-swatch { + width: 0.75rem; + height: 0.75rem; + border-radius: 999px; + flex-shrink: 0; +} + +.observations-overview-legend-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.observations-overview-legend-count { + font-variant-numeric: tabular-nums; +} + +.observations-overview-map-wrap { + height: 320px; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid #2a3a5c; +} + +.observations-overview-map-wrap--tall { + height: 640px; +} + +.observations-overview-map { + width: 100%; + height: 100%; + background: #0d1424; +} + +.observations-overview-map-marker { + background: transparent; + border: none; +} + +.observations-overview-map-marker span { + display: block; + width: 14px; + height: 14px; + border-radius: 999px; + border: 2px solid #131b2e; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15); +} + +.observations-overview-totals-row th, +.observations-overview-totals-row td { + font-weight: 600; + border-top: 2px solid var(--color-border); +} + .observations-list-scroll { flex: 1; min-height: 0; diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index da421a2a9..73d7a120b 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -6,6 +6,12 @@ vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), })); +vi.mock('./lib/bundleTauriEvents', () => ({ + ensureBundleApplyEventPipeline: vi.fn().mockResolvedValue(undefined), + installGlobalIndexRebuildListener: vi.fn(() => () => {}), + bundleBannerLineFromProgress: vi.fn((p: { message: string }) => p.message), +})); + import App from './App'; const { defaultSettings } = vi.hoisted(() => ({ @@ -75,7 +81,7 @@ vi.mock('./lib/tauriClient', () => ({ .fn() .mockResolvedValue('/tmp/custodian-ws/bundles/app-bundle.zip'), getAppBundleState: vi.fn().mockResolvedValue(null), - applyAppBundleDownload: vi.fn(), + downloadAndApplyAppBundle: vi.fn(), listActiveBundleForms: vi.fn().mockResolvedValue([]), readBundleFormSpec: vi.fn(), removeWorkspaceAttachment: vi.fn(), diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 9594cc4af..5e9cf18ed 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -24,6 +24,7 @@ import { WorkbenchCustomAppPage } from './pages/WorkbenchCustomAppPage'; import { useSynkServerStatus } from './hooks/useSynkServerStatus'; import { selectActiveProfileState, + selectBundleActivity, selectSyncActivity, useCustodianStore, } from './store/useCustodianStore'; @@ -31,7 +32,12 @@ import { selectImportActivity, useImportStagingStore, } from './store/useImportStagingStore'; +import { guardedProfileNavigation } from './store/useProfileDraftGuardStore'; import { useToastStore } from './store/useToastStore'; +import { + ensureBundleApplyEventPipeline, + installGlobalIndexRebuildListener, +} from './lib/bundleTauriEvents'; import './App.css'; const DATA_NAV = [ @@ -82,7 +88,11 @@ function ModeSwitch() { const upsertProfileRemote = useCustodianStore(s => s.upsertProfileRemote); async function goData() { - navigate('/data/profiles'); + await guardedProfileNavigation( + navigate, + '/data/profiles', + location.pathname, + ); if (active) { await upsertProfileRemote({ ...active, @@ -92,7 +102,11 @@ function ModeSwitch() { } async function goWorkbench() { - navigate('/workbench/bundles'); + await guardedProfileNavigation( + navigate, + '/workbench/bundles', + location.pathname, + ); if (active) { await upsertProfileRemote({ ...active, @@ -159,6 +173,46 @@ function RootRedirect() { return ; } +function SidebarNavLink({ + to, + end, + icon, + label, +}: { + to: string; + end?: boolean; + icon: string; + label: string; +}) { + const location = useLocation(); + const navigate = useNavigate(); + + return ( + `nav-link${isActive ? ' active' : ''}`} + onClick={e => { + if ( + e.metaKey || + e.ctrlKey || + e.shiftKey || + e.altKey || + e.button !== 0 + ) { + return; + } + if (location.pathname === '/data/profiles' && to !== '/data/profiles') { + e.preventDefault(); + void guardedProfileNavigation(navigate, to, location.pathname); + } + }}> + {icon} + {label} + + ); +} + function Shell() { const year = useMemo(() => new Date().getFullYear(), []); const location = useLocation(); @@ -168,11 +222,35 @@ function Shell() { const clearSyncMessage = useCustodianStore(s => s.clearSyncMessage); const pushToast = useToastStore(s => s.pushToast); const syncActivity = useCustodianStore(selectSyncActivity); + const bundleActivity = useCustodianStore(selectBundleActivity); const importActivity = useImportStagingStore(selectImportActivity); + const setBundleActivity = useCustodianStore(s => s.setBundleActivity); + const clearBundleActivity = useCustodianStore(s => s.clearBundleActivity); + + useEffect(() => { + void ensureBundleApplyEventPipeline(); + return installGlobalIndexRebuildListener({ + setBundleActivity, + clearBundleActivity, + }); + }, [setBundleActivity, clearBundleActivity]); + const activityText: string | null = - syncActivity?.statusText ?? importActivity?.statusText ?? null; + syncActivity?.statusText ?? + bundleActivity?.statusText ?? + importActivity?.statusText ?? + null; + + const activityProgress = + bundleActivity && bundleActivity.total > 0 + ? Math.min( + 100, + Math.round((bundleActivity.done / bundleActivity.total) * 100), + ) + : null; - const activityPresent = syncActivity !== null || importActivity !== null; + const activityPresent = + syncActivity !== null || bundleActivity !== null || importActivity !== null; const [activityBannerDismissed, setActivityBannerDismissed] = useState(false); useEffect(() => { @@ -225,18 +303,15 @@ function Shell() {