diff --git a/AGENTS.md b/AGENTS.md index b7cdca582..da86e598e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,71 @@ Do not assume custom app authors have local checkouts of **ODE** or internal exa --- +## Release version bump checklist + +Use this when preparing a new ODE release (pre-release or stable). Full tagging and CI behaviour: [RELEASE.md](RELEASE.md). Android Play/F-Droid `versionCode` rules: [formulus/android/ANDROID_RELEASE.md](formulus/android/ANDROID_RELEASE.md). + +### Pre-release vs stable + +| Layer | Pre-release (e.g. `v1.1.1-alpha.3`) | Stable (e.g. `v1.1.1`) | +|-------|--------------------------------------|-------------------------| +| Client manifests (`package.json`, `versionName`, CLI, Desktop, Portal) | Target semver **without** suffix (`1.1.1`) | Same (`1.1.1`) | +| Git tag + GitHub release | `v1.1.1-alpha.3` (mark **pre-release**) | `v1.1.1` | +| Synkronus Docker / server `BuildVersion()` | From release tag via CI ldflags | From release tag | + +For stable, you usually **do not** re-bump client manifests if they already match the target version; bump Android `versionCode` only when shipping a new Play build. + +### What to edit + +| File | Field | Purpose | +|------|-------|---------| +| `formulus/package.json` | `version` | Source for `ODE_VERSION` / `x-ode-version` ([`formulus/src/version.ts`](formulus/src/version.ts)) | +| `formulus/android/app/build.gradle` | `versionCode`, `versionName` | Google Play; run `pnpm run sync:version` from `formulus/` after `package.json` bump for `versionName` | +| `formulus/ios/Formulus.xcodeproj/project.pbxproj` | `MARKETING_VERSION`, `CURRENT_PROJECT_VERSION` | iOS display + build number (align `CURRENT_PROJECT_VERSION` with Android `versionCode`) | +| `synkronus-cli/internal/cmd/version.go` | `Version` | `synk version` output | +| `synkronus-cli/versioninfo.json` | Windows file/product version | Windows binary metadata | +| `desktop/package.json`, `desktop/src-tauri/tauri.conf.json`, `desktop/src-tauri/Cargo.toml` | `version` | ODE Desktop app version (keep all three in sync) | +| `desktop/src/lib/synkConstants.ts` | `SYNKRONUS_CLIENT_VERSION` | Desktop `x-ode-version` header | +| `synkronus-portal/package.json` | `version` | Portal `x-ode-version` ([`synkronus-portal/src/version.ts`](synkronus-portal/src/version.ts)) | + +**Synkronus server** version is **not** edited in source for releases — CI injects it from the git tag ([`.github/workflows/synkronus-docker.yml`](.github/workflows/synkronus-docker.yml)). + +### Increment rules + +- **Semver:** bump `MAJOR.MINOR.PATCH` in client manifests to match the release line (e.g. `1.1.1`). +- **Android `versionCode`:** must increase monotonically for Google Play (+10 per release is a common convention; +1 per shipped alpha build is also fine). +- **In-app version display:** Formulus About/Settings use native `versionName` + `versionCode` via [`AppVersionService`](formulus/src/services/AppVersionService.ts); Desktop About uses Tauri `getVersion()`. + +### Commands + +```bash +# After bumping formulus/package.json +cd formulus && pnpm run sync:version + +# Pre-flight on touched JS packages +cd formulus-formplayer && pnpm run lint && pnpm run format:check +cd formulus && pnpm run lint && pnpm run format:check +cd desktop && pnpm run lint && pnpm run format:check && pnpm run typecheck && pnpm test +cd synkronus-cli && go build ./cmd/synkronus && ./synk version # or synkronus-cli.exe on Windows +``` + +### Do not bump + +- `FORMULUS_INTERFACE_VERSION` in [`formulus/src/webview/FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts) — WebView bridge API contract, not app release version +- `formulus-formplayer/package.json` — embedded library semver +- OpenAPI document version comments in generated API clients +- `synkronus-cli/internal/config/config.go` default `api.version` — Synkronus **API contract** major version for compatibility checks, not CLI display version + +### Tag and publish + +```bash +git tag v1.1.1-alpha.3 # or v1.1.1 for stable +git push origin v1.1.1-alpha.3 +# GitHub → Releases → publish (pre-release checkbox for alpha/rc tags) +``` + +--- + ## Cross-cutting contracts - **Formulus ↔ WebView (custom apps + formplayer):** [`formulus/src/webview/FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts) is the **source of truth** for the injected JavaScript API. Formplayer copies a synced TypeScript snapshot via `pnpm run sync-interface` in `formulus-formplayer` (see [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md)). diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index d2598914a..039a5e07f 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -4,6 +4,8 @@ Published docs: [ODE Desktop developer mode](https://opendataensemble.org/docs/guides/ode-desktop-developer-mode) (local custom app iteration). +**Release version bumps:** see [../AGENTS.md#release-version-bump-checklist](../AGENTS.md#release-version-bump-checklist) (`package.json`, `tauri.conf.json`, `Cargo.toml`, `synkConstants.ts` — keep in sync). + --- ## Layout diff --git a/desktop/package.json b/desktop/package.json index a969a6444..e27af5081 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "ode-desktop", "private": true, - "version": "1.0.1", + "version": "1.1.1", "packageManager": "pnpm@10.33.2", "type": "module", "scripts": { diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index 5e5f43381..0c528198c 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -169,6 +169,64 @@ }); }, + // allocateSequence: scopeKey, options?: { startAt?, peek? } => Promise + allocateSequence: function (scopeKey, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; + } else { + window.removeEventListener('message', callback); + reject( + new Error( + 'allocateSequence callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'allocateSequence_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'allocateSequence' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); + reject(e); + } + }; + window.addEventListener('message', callback); + + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'allocateSequence', + messageId, + scopeKey, + options, + }), + ); + }); + }, + // getAvailableForms: => Promise getAvailableForms: function () { return new Promise((resolve, reject) => { diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0a8ec74fb..60fbc37b9 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "odedesktop" -version = "1.0.1" +version = "1.1.1" description = "ODE Desktop" authors = ["OpenDataEnsemble.org"] edition = "2024" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index f12d1d540..b9e978466 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.0.1", + "version": "1.1.1", "identifier": "org.opendataensemble.custodian", "build": { "beforeDevCommand": "pnpm dev", diff --git a/desktop/src/lib/formPreviewBridge.ts b/desktop/src/lib/formPreviewBridge.ts index b5602003c..f8b905963 100644 --- a/desktop/src/lib/formPreviewBridge.ts +++ b/desktop/src/lib/formPreviewBridge.ts @@ -32,6 +32,23 @@ import { dirname, join } from '@tauri-apps/api/path'; import { tauriClient } from './tauriClient'; import type { ObservationRecord } from '../types/domain'; +/** Preview-local sequence counters (device id stub + app scope suffix). */ +const previewSequenceCounters = new Map(); + +function previewAllocateSequence( + scopeKey: string, + options?: { startAt?: number; peek?: boolean }, +): number { + const key = `device:desktop-preview:${scopeKey.trim()}`; + const startAt = options?.startAt ?? 1; + const last = previewSequenceCounters.get(key) ?? startAt - 1; + const next = last + 1; + if (!options?.peek) { + previewSequenceCounters.set(key, next); + } + return next; +} + /** Matches `FORMULUS_INTERFACE_VERSION` in formplayer (`FormulusInterfaceDefinition.ts`). */ export const FORM_PREVIEW_FORMULUS_INTERFACE_VERSION = '1.2.1'; @@ -72,6 +89,7 @@ export const FORMULUS_INJECTION_REQUEST_TYPES = [ 'getAttachmentsUri', 'getCustomAppUri', 'getFormSpecsUri', + 'allocateSequence', ] as const; export type FinalizeRequest = @@ -359,6 +377,21 @@ export async function handleFormPreviewBridgeMessage( }); return; + case 'allocateSequence': { + const scopeKey = + typeof data.scopeKey === 'string' + ? data.scopeKey + : typeof data.payload === 'string' + ? data.payload + : ''; + const startAt = + typeof data.startAt === 'number' ? data.startAt : undefined; + const peek = Boolean(data.peek); + const result = previewAllocateSequence(scopeKey, { startAt, peek }); + reply('allocateSequence', { result }); + return; + } + case 'getAvailableForms': { const rows = await tauriClient.listActiveBundleForms(); const result = rows.map(r => ({ diff --git a/desktop/src/lib/synkConstants.ts b/desktop/src/lib/synkConstants.ts index 690620027..c76620a44 100644 --- a/desktop/src/lib/synkConstants.ts +++ b/desktop/src/lib/synkConstants.ts @@ -1,2 +1,2 @@ /** Must match Synkronus OpenAPI `x-ode-version` (semver). */ -export const SYNKRONUS_CLIENT_VERSION = '1.0.0'; +export const SYNKRONUS_CLIENT_VERSION = '1.1.1'; diff --git a/desktop/src/pages/AboutPage.tsx b/desktop/src/pages/AboutPage.tsx index ca3b4d2ee..28b33d182 100644 --- a/desktop/src/pages/AboutPage.tsx +++ b/desktop/src/pages/AboutPage.tsx @@ -1,4 +1,8 @@ +import { useEffect, useState } from 'react'; +import { getVersion } from '@tauri-apps/api/app'; +import { isTauri } from '@tauri-apps/api/core'; import brandMarkUrl from '../assets/custodian.png'; +import packageJson from '../../package.json'; const LINKS = [ { href: 'https://opendataensemble.org', label: 'Website' }, @@ -11,6 +15,23 @@ const LINKS = [ ] as const; export function AboutPage() { + const [version, setVersion] = useState(''); + + useEffect(() => { + const load = async () => { + try { + if (isTauri()) { + setVersion(await getVersion()); + } else { + setVersion(packageJson.version?.trim() ?? ''); + } + } catch { + setVersion(''); + } + }; + void load(); + }, []); + return (

ODE Desktop

+ {version ?

v{version}

: null}

Open Data Ensemble is an offline-first ecosystem for field data collection, synchronization, and stewardship. ODE Desktop helps you diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index e1ac2dfbb..7ea7fdb27 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -130,12 +130,12 @@ function readAutoSequenceRuntime() { } /** Root observation data aligned to schema, with platform `x-autoSequence` applied on open. */ -function prepareInitialFormData( +async function prepareInitialFormData( raw: Record, formSchema: unknown, -): Record { +): Promise> { const root = prepareRootObservationData(raw, formSchema); - const { data } = applyAutoSequences( + const { data } = await applyAutoSequences( root, formSchema as JsonSchema7 | undefined, readAutoSequenceRuntime(), @@ -636,7 +636,7 @@ function App() { if (savedData && Object.keys(savedData).length > 0) { console.log('Preloading saved data:', savedData); setData( - prepareInitialFormData( + await prepareInitialFormData( savedData as Record, formSchemaTyped, ), @@ -660,14 +660,14 @@ function App() { ); const withSticky = applyStickyDefaults(withTokens, relevantSticky); console.log('Preloading initialization form values:', withSticky); - setData(prepareInitialFormData(withSticky, formSchemaTyped)); + setData(await prepareInitialFormData(withSticky, formSchemaTyped)); } else { const defaultData = applySchemaDefaultTokens( initialFormDataFromParams(params), formSchemaTyped, ); console.log('Preloading initialization form values:', defaultData); - setData(prepareInitialFormData(defaultData, formSchemaTyped)); + setData(await prepareInitialFormData(defaultData, formSchemaTyped)); } console.log('Form params (if any, beyond schemas/data):', params); @@ -1011,9 +1011,9 @@ function App() { }, [pendingFormInit, initializeForm]); const refreshFormData = useCallback( - (newData: Record) => { + async (newData: Record) => { const autoRuntime = readAutoSequenceRuntime(); - const { data: sequencedData } = applyAutoSequences( + const { data: sequencedData } = await applyAutoSequences( newData, schema ?? undefined, autoRuntime, @@ -1049,25 +1049,27 @@ function App() { const incoming = newData as Record; const baseline = dataRef.current as Record; const merged = mergePreservingSubObsArrays(baseline, incoming); - const refreshedData = refreshFormData(merged); - // JsonForms re-emits when we push merged sub-obs arrays back; skip when - // nothing actually changed to break the render / draft persistence loop. - if (formDataJsonEqual(refreshedData, baseline)) { - return; - } - dataRef.current = refreshedData; - setData(refreshedData); - persistDraftIfRootSession(refreshedData); + void (async () => { + const refreshedData = await refreshFormData(merged); + if (formDataJsonEqual(refreshedData, baseline)) { + return; + } + dataRef.current = refreshedData; + setData(refreshedData); + persistDraftIfRootSession(refreshedData); + })(); }, [refreshFormData, persistDraftIfRootSession], ); const commitFormData = useCallback( (newData: Record) => { - const refreshedData = refreshFormData(newData); - dataRef.current = refreshedData; - setData(refreshedData); - persistDraftIfRootSession(refreshedData); + void (async () => { + const refreshedData = await refreshFormData(newData); + dataRef.current = refreshedData; + setData(refreshedData); + persistDraftIfRootSession(refreshedData); + })(); }, [refreshFormData, persistDraftIfRootSession], ); @@ -1099,9 +1101,11 @@ function App() { if (!current || Object.keys(current).length === 0) { return; } - const refreshedData = refreshFormData(current); - dataRef.current = refreshedData; - setData(refreshedData); + void (async () => { + const refreshedData = await refreshFormData(current); + dataRef.current = refreshedData; + setData(refreshedData); + })(); }; const handleShowValidation = () => { diff --git a/formulus-formplayer/src/mocks/webview-mock.ts b/formulus-formplayer/src/mocks/webview-mock.ts index 87c3744b4..b92807759 100644 --- a/formulus-formplayer/src/mocks/webview-mock.ts +++ b/formulus-formplayer/src/mocks/webview-mock.ts @@ -47,6 +47,7 @@ type MockGlobalThis = typeof globalThis & { class WebViewMock { private messageListeners: ((message: any) => void)[] = []; private isActive = false; + private sequenceCounters = new Map(); private pendingCameraPromises = new Map< string, { @@ -237,6 +238,19 @@ class WebViewMock { // getVersion is required by formulus-load.js so it accepts this as a valid API. mockGlobal.formulus = { getVersion: (): Promise => Promise.resolve('mock-dev'), + allocateSequence: ( + scopeKey: string, + options?: { startAt?: number; peek?: boolean }, + ): Promise => { + const key = `device:mock-dev:${scopeKey.trim()}`; + const startAt = options?.startAt ?? 1; + const last = this.sequenceCounters.get(key) ?? startAt - 1; + const next = last + 1; + if (!options?.peek) { + this.sequenceCounters.set(key, next); + } + return Promise.resolve(next); + }, submitObservation: ( formType: string, data: Record, diff --git a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx index 577499556..d8a4b18f1 100644 --- a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx @@ -28,7 +28,10 @@ import { resolveEmptyLabel, resolveDeleteFallbackLabel, buildSubObservationOpenParams, + refreshSubObservationContextFromFormData, + writeSubObservationContextToWindow, type OrderBySpec, + type SubObservationContextMergeConfig, } from './subObservationHelpers'; const RESERVED_SCHEMA_KEYS = new Set([ @@ -246,9 +249,43 @@ const SubObservationQuestionRendererInner: React.FC = ({ const row = result.formData as Record; const next = [...getCurrentRows(), row]; pushSorted(next); + + const root = jsonForms.core?.data; + if (root && typeof root === 'object' && path) { + const parentData = writeDataPath( + root as Record, + path, + next, + ); + const pv = resolveParentValue(formData, parentValuePath); + const contextTemplate = optionalRecordMap(config.subObservationContext); + const mergeConfigRaw = config.subObservationContextMerge; + const mergeConfig = + mergeConfigRaw && typeof mergeConfigRaw === 'object' + ? mergeConfigRaw + : undefined; + if (contextTemplate || mergeConfig) { + const refreshed = refreshSubObservationContextFromFormData( + parentData, + contextTemplate, + pv, + mergeConfig as SubObservationContextMergeConfig, + ); + writeSubObservationContextToWindow(refreshed); + } + } return true; }, - [getCurrentRows, pushSorted], + [ + getCurrentRows, + pushSorted, + jsonForms.core?.data, + path, + formData, + parentValuePath, + config.subObservationContext, + config.subObservationContextMerge, + ], ); const handleAdd = useCallback(async () => { @@ -334,6 +371,31 @@ const SubObservationQuestionRendererInner: React.FC = ({ i === index ? (result.formData as Record) : r, ); pushSorted(updated); + const root = jsonForms.core?.data; + if (root && typeof root === 'object' && path) { + const parentData = writeDataPath( + root as Record, + path, + updated, + ); + const contextTemplate = optionalRecordMap( + config.subObservationContext, + ); + const mergeConfigRaw = config.subObservationContextMerge; + const mergeConfig = + mergeConfigRaw && typeof mergeConfigRaw === 'object' + ? mergeConfigRaw + : undefined; + if (contextTemplate || mergeConfig) { + const refreshed = refreshSubObservationContextFromFormData( + parentData, + contextTemplate, + pv, + mergeConfig as SubObservationContextMergeConfig, + ); + writeSubObservationContextToWindow(refreshed); + } + } } } catch (e) { setError( diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.test.ts b/formulus-formplayer/src/renderers/subObservationHelpers.test.ts index 2443b8071..f7a4a428b 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.test.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.test.ts @@ -15,6 +15,7 @@ import { resolveAddButtonLabel, resolveEmptyLabel, resolveDeleteFallbackLabel, + mergeSessionIntoSubObservationContext, } from './subObservationHelpers'; describe('subObservationHelpers', () => { @@ -174,4 +175,29 @@ describe('subObservationHelpers', () => { expect(resolveDeleteFallbackLabel(null)).toBe('this sub-observation'); expect(resolveDeleteFallbackLabel('room')).toBe('this room'); }); + + it('mergeSessionIntoSubObservationContext upserts quarto row', () => { + const merged = mergeSessionIntoSubObservationContext( + { quartos: [] }, + { quarto_num: 1, camas: [] }, + { contextKey: 'quartos', matchField: 'quarto_num' }, + ); + expect(merged.quartos).toEqual([{ quarto_num: 1, camas: [] }]); + }); + + it('mergeSessionIntoSubObservationContext patches nested cama row', () => { + const merged = mergeSessionIntoSubObservationContext( + { + quartos: [{ quarto_num: 1, camas: [{ cama_num: 1, pessoas: [] }] }], + }, + { quarto_num: 1, cama_num: 1, pessoas: [{ nopessoa: '1' }] }, + { + contextKey: 'quartos', + matchField: 'quarto_num', + nestedArrayField: 'camas', + nestedMatchField: 'cama_num', + }, + ); + expect(merged.quartos[0].camas[0].pessoas).toEqual([{ nopessoa: '1' }]); + }); }); diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.ts b/formulus-formplayer/src/renderers/subObservationHelpers.ts index 34dd3b417..a936df1e1 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.ts @@ -224,6 +224,154 @@ export function resolveSubObservationContext( return resolveInitialValues(mapObj, formData, parentValue); } +export type SubObservationContextMergeConfig = { + contextKey?: string; + matchField: string; + /** Nested array on matched row (e.g. `camas` under a quarto). */ + nestedArrayField?: string; + nestedMatchField?: string; +}; + +function toPosIntForMerge(value: unknown): number | null { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : null; +} + +/** Upsert the opening session row into an inherited sub-observation context tree. */ +export function mergeSessionIntoSubObservationContext( + inherited: Record, + sessionData: Record, + mergeConfig: SubObservationContextMergeConfig | undefined, +): Record { + if (!mergeConfig || typeof mergeConfig !== 'object') { + return inherited; + } + + const contextKey = mergeConfig.contextKey ?? 'quartos'; + const matchField = mergeConfig.matchField; + const matchVal = toPosIntForMerge(sessionData[matchField]); + if (matchVal == null) return inherited; + + const next = { ...inherited }; + const snapshot = Array.isArray(next[contextKey]) + ? [...(next[contextKey] as unknown[])] + : []; + + const patch = { ...sessionData }; + delete patch.household_quartos; + + if (mergeConfig.nestedArrayField && mergeConfig.nestedMatchField) { + const nestedField = mergeConfig.nestedArrayField; + const nestedMatch = mergeConfig.nestedMatchField; + const nestedVal = toPosIntForMerge(sessionData[nestedMatch]); + if (nestedVal == null) return inherited; + + let quartoFound = false; + const mergedSnapshot = snapshot.map(item => { + if (!item || typeof item !== 'object') return item; + const row = item as Record; + if (toPosIntForMerge(row[matchField]) !== matchVal) return item; + quartoFound = true; + const camas = Array.isArray(row[nestedField]) + ? [...(row[nestedField] as unknown[])] + : []; + let camaFound = false; + const nextCamas = camas.map(cama => { + if (!cama || typeof cama !== 'object') return cama; + const c = cama as Record; + if (toPosIntForMerge(c[nestedMatch]) !== nestedVal) return cama; + camaFound = true; + return { ...c, ...patch }; + }); + if (!camaFound) { + nextCamas.push(patch); + } + return { ...row, [nestedField]: nextCamas }; + }); + if (!quartoFound) { + mergedSnapshot.push({ + [matchField]: matchVal, + [nestedField]: [patch], + }); + } + next[contextKey] = mergedSnapshot; + return next; + } + + let found = false; + const mergedSnapshot = snapshot.map(item => { + if (!item || typeof item !== 'object') return item; + const row = item as Record; + if (toPosIntForMerge(row[matchField]) !== matchVal) return item; + found = true; + return { ...row, ...patch }; + }); + if (!found) { + mergedSnapshot.push(patch); + } + next[contextKey] = mergedSnapshot; + return next; +} + +export function writeSubObservationContextToWindow( + subObservation: Record, +): void { + if (typeof window === 'undefined') return; + const w = window as unknown as { + formulusSessionContext?: Record | null; + formulusSubObservationContext?: Record | null; + }; + const sessionContext = + w.formulusSessionContext && typeof w.formulusSessionContext === 'object' + ? { ...w.formulusSessionContext } + : {}; + sessionContext.subObservation = subObservation; + w.formulusSessionContext = sessionContext; + w.formulusSubObservationContext = subObservation; +} + +/** Re-resolve `subObservationContext` templates from live parent form data. */ +export function refreshSubObservationContextFromFormData( + formData: Record, + contextTemplate: Record | null | undefined, + parentValue: string | null, + mergeConfig?: SubObservationContextMergeConfig, +): Record { + const w = + typeof window !== 'undefined' + ? (window as unknown as { + formulusSessionContext?: Record | null; + }) + : null; + const inherited = + w?.formulusSessionContext && + typeof w.formulusSessionContext === 'object' && + w.formulusSessionContext.subObservation && + typeof w.formulusSessionContext.subObservation === 'object' + ? (w.formulusSessionContext.subObservation as Record) + : {}; + + const mergedInherited = mergeSessionIntoSubObservationContext( + inherited, + formData, + mergeConfig, + ); + + const resolved = resolveSubObservationContext( + contextTemplate, + formData, + parentValue, + ); + + const subObservation: Record = { ...mergedInherited }; + for (const [key, value] of Object.entries(resolved)) { + if (value !== '' && value != null) { + subObservation[key] = value; + } + } + return subObservation; +} + export function buildSubObservationOpenParams( formData: Record, config: Record, @@ -252,8 +400,20 @@ export function buildSubObservationOpenParams( ? (parentSessionContext.subObservation as Record) : {}; + const mergeConfigRaw = config.subObservationContextMerge; + const mergeConfig = + mergeConfigRaw && typeof mergeConfigRaw === 'object' + ? (mergeConfigRaw as SubObservationContextMergeConfig) + : undefined; + + const mergedInherited = mergeSessionIntoSubObservationContext( + inheritedSubObservation, + formData, + mergeConfig, + ); + const subObservation: Record = { - ...inheritedSubObservation, + ...mergedInherited, }; for (const [key, value] of Object.entries(resolved)) { if (value !== '' && value != null) { diff --git a/formulus-formplayer/src/services/FormulusInterface.ts b/formulus-formplayer/src/services/FormulusInterface.ts index 1df1707e7..50e48cf4b 100644 --- a/formulus-formplayer/src/services/FormulusInterface.ts +++ b/formulus-formplayer/src/services/FormulusInterface.ts @@ -359,6 +359,23 @@ class FormulusClient { ); } + /** + * Allocate the next value in a device-local sequence scope (Formulus prepends device id). + */ + public async allocateSequence( + scopeKey: string, + options?: { startAt?: number; peek?: boolean }, + ): Promise { + await this.tryEnsureFormulus(); + if (this.formulus?.allocateSequence) { + return this.formulus.allocateSequence(scopeKey, options); + } + console.warn('Formulus interface not available for allocateSequence'); + return Promise.reject( + new Error('Formulus interface not available for allocateSequence'), + ); + } + /** * Open Formplayer from within the formplayer WebView (e.g. sub-observation rows). * Forwards to the injected Formulus API. diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index 5dfa73e94..a4028f8f5 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -484,6 +484,17 @@ export interface FormulusInterface { */ getCachedLocation(fieldId?: string): Promise; + /** + * Allocate the next monotonic integer in a device-local scope. + * Formulus prepends `device:{deviceId}:` to the app-authored `scopeKey` suffix. + * @param scopeKey App suffix, e.g. `af:A12:person` (no device prefix). + * @param options `startAt` default 1; `peek` returns next without consuming. + */ + allocateSequence( + scopeKey: string, + options?: { startAt?: number; peek?: boolean }, + ): Promise; + /** * Subscribe to location updates while the custom app WebView is active. * Updates arrive as `locationWatchUpdate` window messages. @@ -711,7 +722,7 @@ export interface FormulusCallbacks { /** * Current version of the interface */ -export const FORMULUS_INTERFACE_VERSION = '1.4.0'; +export const FORMULUS_INTERFACE_VERSION = '1.5.0'; /** Parses major.minor.patch from the start of a version string (ignores prerelease after `-`). */ function semverSegments(version: string): [number, number, number] { diff --git a/formulus-formplayer/src/utils/autoSequence.test.ts b/formulus-formplayer/src/utils/autoSequence.test.ts index 3b5c3bf5b..cffb3f919 100644 --- a/formulus-formplayer/src/utils/autoSequence.test.ts +++ b/formulus-formplayer/src/utils/autoSequence.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { applyAutoSequences } from './autoSequence'; describe('applyAutoSequences', () => { - it('assigns sibling max+1 in an array', () => { + it('assigns sibling max+1 in an array', async () => { const schema = { properties: { camas: { @@ -24,13 +24,13 @@ describe('applyAutoSequences', () => { camas: [{ cama_num: 1 }, { cama_num: null }, {}], }; - const { data: next, mutated } = applyAutoSequences(data, schema); + const { data: next, mutated } = await applyAutoSequences(data, schema); expect(mutated).toBe(true); expect(next.camas[1].cama_num).toBe(2); expect(next.camas[2].cama_num).toBe(3); }); - it('does not overwrite existing values when immutable', () => { + it('does not overwrite existing values when immutable', async () => { const schema = { properties: { quarto_num: { @@ -41,12 +41,12 @@ describe('applyAutoSequences', () => { }; const data = { quarto_num: 5 }; - const { data: next, mutated } = applyAutoSequences(data, schema); + const { data: next, mutated } = await applyAutoSequences(data, schema); expect(mutated).toBe(false); expect(next.quarto_num).toBe(5); }); - it('uses contextTree for household numbering', () => { + it('uses contextTree for household numbering', async () => { const schema = { properties: { nopessoa: { @@ -61,7 +61,7 @@ describe('applyAutoSequences', () => { }; const data = { nopessoa: '' }; - const { data: next } = applyAutoSequences(data, schema, { + const { data: next } = await applyAutoSequences(data, schema, { subObservationContext: { quartos: [ { @@ -72,6 +72,41 @@ describe('applyAutoSequences', () => { }, }); - expect(next.nopessoa).toBe(4); + expect(next.nopessoa).toBe('4'); + }); + + it('matches contextFilter with numeric coercion', async () => { + const schema = { + properties: { + cama_num: { + type: 'integer', + 'x-autoSequence': { + scope: 'contextTree', + contextKey: 'quartos', + field: 'cama_num', + contextFilter: { quarto_num: '$data.quarto_num' }, + }, + }, + quarto_num: { type: 'integer' }, + }, + }; + + const data = { quarto_num: 2, cama_num: null }; + const { data: next } = await applyAutoSequences(data, schema, { + subObservationContext: { + quartos: [ + { + quarto_num: 1, + camas: [{ cama_num: 1 }, { cama_num: 2 }], + }, + { + quarto_num: '2', + camas: [{ cama_num: 1 }], + }, + ], + }, + }); + + expect(next.cama_num).toBe(2); }); }); diff --git a/formulus-formplayer/src/utils/autoSequence.ts b/formulus-formplayer/src/utils/autoSequence.ts index 2a333aeda..695ce5f89 100644 --- a/formulus-formplayer/src/utils/autoSequence.ts +++ b/formulus-formplayer/src/utils/autoSequence.ts @@ -3,14 +3,19 @@ */ import type { JsonSchema7 } from '@jsonforms/core'; +import { resolveTemplateValue } from '../renderers/subObservationHelpers'; +import FormulusClient from '../services/FormulusInterface'; export type AutoSequenceConfig = { assign?: 'max+1'; + allocator?: 'native' | 'declarative'; immutable?: boolean; scope?: 'sibling' | 'tree' | 'contextTree'; contextKey?: string; contextFilter?: Record; field?: string; + /** App-authored suffix; Formulus prepends `device:{deviceId}:`. */ + scopeKey?: string; }; export type AutoSequenceRuntimeContext = { @@ -23,6 +28,7 @@ type Binding = { config: AutoSequenceConfig; /** Path to parent object; `*` = each array index */ parentSegments: string[]; + valueKind: 'string' | 'number'; }; function isBlankSequenceValue(value: unknown): boolean { @@ -46,6 +52,14 @@ function resolveFilterToken( return data[token]; } +function filterValuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const aNum = toPosInt(a); + const bNum = toPosInt(b); + if (aNum != null && bNum != null) return aNum === bNum; + return String(a ?? '') === String(b ?? ''); +} + function nodeMatchesFilter( node: Record, filter: Record | undefined, @@ -53,7 +67,9 @@ function nodeMatchesFilter( ): boolean { if (!filter) return true; for (const [nodeKey, token] of Object.entries(filter)) { - if (node[nodeKey] !== resolveFilterToken(token, data)) return false; + if (!filterValuesEqual(node[nodeKey], resolveFilterToken(token, data))) { + return false; + } } return true; } @@ -165,6 +181,7 @@ function collectBindings( bindings.push({ field: name, parentSegments, + valueKind: child.type === 'string' ? 'string' : 'number', config: { assign: 'max+1', immutable: true, @@ -228,12 +245,48 @@ function enumerateParents( return enumerateParents(next as Record, tail); } -/** Apply all `x-autoSequence` rules in `schema` to a shallow-cloned `data` when needed. */ -export function applyAutoSequences( +function usesNativeAllocator(config: AutoSequenceConfig): boolean { + return config.allocator === 'native' && Boolean(config.scopeKey?.trim()); +} + +function resolveScopeKeyTemplate( + template: string, + data: Record, +): string { + const resolved = resolveTemplateValue(template, data, null); + return typeof resolved === 'string' + ? resolved.trim() + : String(resolved ?? '').trim(); +} + +async function computeNextValueAsync( + binding: Binding, + data: Record, + runtime: AutoSequenceRuntimeContext, +): Promise { + if (usesNativeAllocator(binding.config)) { + const scopeKey = resolveScopeKeyTemplate( + binding.config.scopeKey!.trim(), + data, + ); + if (!scopeKey) { + throw new Error( + `x-autoSequence scopeKey resolved empty for field "${binding.field}"`, + ); + } + const client = FormulusClient.getInstance(); + return client.allocateSequence(scopeKey); + } + const next = computeNextValue(binding, data, runtime); + return binding.valueKind === 'string' ? String(next) : next; +} + +/** Apply all `x-autoSequence` rules in `schema` when fields are blank. */ +export async function applyAutoSequences( data: Record, schema: JsonSchema7 | undefined, runtime: AutoSequenceRuntimeContext = {}, -): { data: Record; mutated: boolean } { +): Promise<{ data: Record; mutated: boolean }> { if (!schema) return { data, mutated: false }; const bindings = collectBindings(schema, []); @@ -252,7 +305,11 @@ export function applyAutoSequences( if (immutable && !isBlankSequenceValue(current)) continue; if (!isBlankSequenceValue(current)) continue; - parent[fieldName] = computeNextValue(binding, working, runtime); + parent[fieldName] = await computeNextValueAsync( + binding, + working, + runtime, + ); mutated = true; } } diff --git a/formulus/AGENTS.md b/formulus/AGENTS.md index 85af14920..f6fee055c 100644 --- a/formulus/AGENTS.md +++ b/formulus/AGENTS.md @@ -6,6 +6,8 @@ **User-facing docs:** [Formulus](https://opendataensemble.org/docs/reference/formulus) on [opendataensemble.org](https://opendataensemble.org/). +**Release version bumps:** see [../AGENTS.md#release-version-bump-checklist](../AGENTS.md#release-version-bump-checklist) (Formulus `package.json`, Android `versionCode`, iOS, `pnpm run sync:version`). + --- ## What this package is diff --git a/formulus/android/app/build.gradle b/formulus/android/app/build.gradle index b4f6cda43..1903aca30 100644 --- a/formulus/android/app/build.gradle +++ b/formulus/android/app/build.gradle @@ -103,8 +103,8 @@ android { applicationId = "org.opendataensemble.formulus" minSdk = rootProject.ext.minSdkVersion targetSdk = rootProject.ext.targetSdkVersion - versionCode = 4 - versionName = "1.1.0" + versionCode = 14 + versionName = "1.1.1" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", (findProperty("newArchEnabled") ?: "false").toString() diff --git a/formulus/assets/webview/FormulusInjectionScript.js b/formulus/assets/webview/FormulusInjectionScript.js index 295b421a1..7207daf0d 100644 --- a/formulus/assets/webview/FormulusInjectionScript.js +++ b/formulus/assets/webview/FormulusInjectionScript.js @@ -1,6 +1,6 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-05-16T12:40:22.983Z +// Last generated: 2026-06-19T12:32:54.430Z (function () { // Enhanced API availability detection and recovery @@ -229,7 +229,7 @@ }); }, - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; } => Promise openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = @@ -664,22 +664,30 @@ }); }, - // getCachedLocation: fieldId?: string => Promise + // getCachedLocation: fieldId: string => Promise getCachedLocation: function (fieldId) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + // Add response handler for methods that return values + const callback = event => { try { let data; if (typeof event.data === 'string') { data = JSON.parse(event.data); } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; + data = event.data; // Already an object } else { - window.removeEventListener('message', callback); - reject(new Error('getCachedLocation: unexpected response')); + // console.warn('getCachedLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCachedLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); return; } if ( @@ -694,12 +702,19 @@ } } } catch (e) { - window.removeEventListener('message', callback); + console.error( + "'getCachedLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too reject(e); } }; window.addEventListener('message', callback); + // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ type: 'getCachedLocation', @@ -710,41 +725,75 @@ }); }, - // watchLocation: fieldId => Promise<{ status, message? }> - watchLocation: function (fieldId) { + // allocateSequence: scopeKey: string, options: { startAt?: number; peek?: boolean; } => Promise + allocateSequence: function (scopeKey, options) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - const updateHandler = event => { + // Add response handler for methods that return values + + const callback = event => { try { let data; if (typeof event.data === 'string') { data = JSON.parse(event.data); } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; + data = event.data; // Already an object } else { + // console.warn('allocateSequence callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'allocateSequence callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); return; } if ( - data.type === 'locationWatchUpdate' && - data.fieldId === fieldId + data.type === 'allocateSequence_response' && + data.messageId === messageId ) { - window.dispatchEvent( - new CustomEvent('formulusLocationUpdate', { - detail: { fieldId, result: data.result }, - }), - ); + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } } - } catch { - /* ignore malformed push */ + } catch (e) { + console.error( + "'allocateSequence' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); } }; - window.addEventListener('message', updateHandler); - if (!globalThis.__formulusLocationWatchHandlers) { - globalThis.__formulusLocationWatchHandlers = {}; - } - globalThis.__formulusLocationWatchHandlers[fieldId] = updateHandler; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'allocateSequence', + messageId, + scopeKey: scopeKey, + options: options, + }), + ); + }); + }, + + // watchLocation: fieldId: string => Promise<{ status: "started" | "error"; message?: string; }> + watchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values const callback = event => { try { @@ -752,10 +801,16 @@ if (typeof event.data === 'string') { data = JSON.parse(event.data); } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; + data = event.data; // Already an object } else { - window.removeEventListener('message', callback); - reject(new Error('watchLocation: unexpected response')); + // console.warn('watchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'watchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); return; } if ( @@ -764,20 +819,25 @@ ) { window.removeEventListener('message', callback); if (data.error) { - window.removeEventListener('message', updateHandler); - delete globalThis.__formulusLocationWatchHandlers[fieldId]; reject(new Error(data.error)); } else { resolve(data.result); } } } catch (e) { - window.removeEventListener('message', callback); + console.error( + "'watchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too reject(e); } }; window.addEventListener('message', callback); + // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ type: 'watchLocation', @@ -788,22 +848,30 @@ }); }, - // stopWatchLocation: fieldId => Promise + // stopWatchLocation: fieldId: string => Promise stopWatchLocation: function (fieldId) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + // Add response handler for methods that return values + const callback = event => { try { let data; if (typeof event.data === 'string') { data = JSON.parse(event.data); } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; + data = event.data; // Already an object } else { - window.removeEventListener('message', callback); - reject(new Error('stopWatchLocation: unexpected response')); + // console.warn('stopWatchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'stopWatchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); return; } if ( @@ -811,12 +879,6 @@ data.messageId === messageId ) { window.removeEventListener('message', callback); - const handler = - globalThis.__formulusLocationWatchHandlers?.[fieldId]; - if (handler) { - window.removeEventListener('message', handler); - delete globalThis.__formulusLocationWatchHandlers[fieldId]; - } if (data.error) { reject(new Error(data.error)); } else { @@ -824,12 +886,19 @@ } } } catch (e) { - window.removeEventListener('message', callback); + console.error( + "'stopWatchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too reject(e); } }; window.addEventListener('message', callback); + // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ type: 'stopWatchLocation', @@ -1820,6 +1889,8 @@ const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + // Add response handler for methods that return values + const callback = event => { try { let data; @@ -1828,6 +1899,7 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { + // console.warn('persistObservation callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( @@ -1872,12 +1944,14 @@ }); }, - // sync: options: { includeAttachments?: boolean } => Promise + // sync: options: { includeAttachments?: boolean; } => Promise sync: function (options) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + // Add response handler for methods that return values + const callback = event => { try { let data; @@ -1886,6 +1960,7 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { + // console.warn('sync callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( @@ -1933,6 +2008,8 @@ const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + // Add response handler for methods that return values + const callback = event => { try { let data; @@ -1941,6 +2018,7 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { + // console.warn('getConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( @@ -1990,15 +2068,18 @@ const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + // Add response handler for methods that return values + const callback = event => { try { let data; if (typeof event.data === 'string') { data = JSON.parse(event.data); } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; + data = event.data; // Already an object } else { - window.removeEventListener('message', callback); + // console.warn('getCurrentDataRevisionCount callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener reject( new Error( 'getCurrentDataRevisionCount callback: Received response with unexpected data type. Raw: ' + @@ -2025,12 +2106,13 @@ 'Raw event.data:', event.data, ); - window.removeEventListener('message', callback); + window.removeEventListener('message', callback); // Ensure listener is removed on error too reject(e); } }; window.addEventListener('message', callback); + // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ type: 'getCurrentDataRevisionCount', diff --git a/formulus/assets/webview/formulus-api.js b/formulus/assets/webview/formulus-api.js index 8306c78d3..71dec8160 100644 --- a/formulus/assets/webview/formulus-api.js +++ b/formulus/assets/webview/formulus-api.js @@ -1,16 +1,16 @@ /** * Formulus API Interface (JavaScript Version) - * + * * This file provides type information and documentation for the Formulus API * that's available in the WebView context as `globalThis.formulus`. - * + * * This file is auto-generated from FormulusInterfaceDefinition.ts - * Last generated: 2026-05-16T12:40:24.252Z - * + * Last generated: 2026-06-19T12:32:54.875Z + * * @example * // In your JavaScript file: * /// - * + * * // Now you'll get autocompletion and type hints in IDEs that support JSDoc * globalThis.formulus.getVersion().then(version => { * console.log('Formulus version:', version); @@ -29,241 +29,299 @@ */ const FormulusAPI = { /** - * Get the current version of the Formulus API - * / - * @returns {Promise} The API version - */ - getVersion: function () {}, - - /** - * Get a list of available forms - * / - * @returns {Promise} Array of form information objects - */ - getAvailableForms: function () {}, - - /** - * Open Formplayer with the specified form - * / - * @param {string} formType - The identifier of the formtype to open - * @param {Object} params - Additional parameters for form initialization - * @param {Object} savedData - Previously saved form data (for editing) - * @returns {Promise} Promise that resolves when the form is completed/closed with result details - */ - openFormplayer: function (formType, params, savedData, options) {}, - - /** - * Get observations for a specific form - * / - * @param {string} formType - The identifier of the formtype - * @returns {Promise} Array of form observations - */ - getObservations: function (formType, isDraft, includeDeleted) {}, - - /** - * Get observations with optional WHERE clause filtering (for dynamic choice lists). - * Supports format: data.field = 'value' AND data.other = 'value' - * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. - * / - * @returns {Promise} Array of filtered observations - */ - getObservationsByQuery: function (options) {}, - - /** - * Submit a completed form - * / - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to submit - * @returns {Promise} The observationId of the submitted form - */ - submitObservation: function (formType, finalData) {}, + * Get the current version of the Formulus bridge API (the interface contract + * version, e.g. for {@link isCompatibleVersion} checks). + * / + * @returns {Promise} The API version (semver) + */ + getVersion: function() {}, + + /** + * Get a list of available forms + * / + * @returns {Promise} Array of form information objects + */ + getAvailableForms: function() {}, + + /** + * Open Formplayer with the specified form + * Reserved keys are not treated as observation data: + * `defaultData` (prefill), `theme`/`darkMode`/`themeColors` (theming), and + * `context` — a read-only **session context** object (device role, selected cluster, + * ...) that Formplayer never persists and exposes to extensions as + * `window.formulusSessionContext`. Draft bypass is not a param key — use + * `options.skipDraftSelection` on {@link openFormplayer} (same as `skipFinalize`). + * / + * @param {string} formType - The identifier of the formtype to open + * @param {Object} params - Additional parameters for form initialization. + * @param {Object} savedData - Previously saved form data (for editing) + * @returns {Promise} Promise that resolves when the form is completed/closed with result details + */ + openFormplayer: function(formType, params, savedData, options) {}, + + /** + * Get observations for a specific form + * / + * @param {string} formType - The identifier of the formtype + * @returns {Promise} Array of form observations + */ + getObservations: function(formType, isDraft, includeDeleted) {}, + + /** + * Get observations with optional WHERE clause filtering (for dynamic choice lists). + * Supports format: data.field = 'value' AND data.other = 'value' + * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. + * / + * @returns {Promise} Array of filtered observations + */ + getObservationsByQuery: function(options) {}, + + /** + * Submit a completed form + * / + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to submit + * @returns {Promise} The observationId of the submitted form + */ + submitObservation: function(formType, finalData) {}, + + /** + * Update an existing form + * / + * @param {string} observationId - The identifier of the observation + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to update + * @returns {Promise} The observationId of the updated form + */ + updateObservation: function(observationId, formType, finalData) {}, + + /** + * Request camera access for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation + */ + requestCamera: function(fieldId) {}, + + /** + * Request location for a field (captures into the form GPS field). + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with location result or rejects on error + */ + requestLocation: function(fieldId) {}, + + /** + * Return the best cached device fix (if any) without forcing a new GPS read. + * / + */ + getCachedLocation: function(fieldId) {}, + + /** + * Allocate the next monotonic integer in a device-local scope. + * Formulus prepends `device:{deviceId}:` to the app-authored `scopeKey` suffix. + * / + */ + allocateSequence: function(scopeKey, options) {}, + + /** + * Subscribe to location updates while the custom app WebView is active. + * Updates arrive as `locationWatchUpdate` window messages. + * / + */ + watchLocation: function(fieldId) {}, + + /** + * /** Stop a prior `watchLocation` subscription for `fieldId`. */ + */ + stopWatchLocation: function(fieldId) {}, + + /** + * Request file selection for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation + */ + requestFile: function(fieldId) {}, + + /** + * Launch an external intent + * / + * @param {string} fieldId - The ID of the field + * @param {Object} intentSpec - The intent specification + * @returns {Promise} + */ + launchIntent: function(fieldId, intentSpec) {}, + + /** + * Call a subform + * / + * @param {string} fieldId - The ID of the field + * @param {string} formType - The ID of the subform + * @param {Object} options - Additional options for the subform + * @returns {Promise} + */ + callSubform: function(fieldId, formType, options) {}, + + /** + * Request audio recording for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation + */ + requestAudio: function(fieldId) {}, + + /** + * Request video recording for a field (camera / picker — host-defined). + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation + */ + requestVideo: function(fieldId) {}, + + /** + * Request QR code scanning for a field + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation + */ + requestQrcode: function(fieldId) {}, + + /** + * Request biometric authentication + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} + */ + requestBiometric: function(fieldId) {}, + + /** + * Request the current connectivity status + * / + * @returns {Promise} + */ + requestConnectivityStatus: function() {}, + + /** + * Request the current sync status + * / + * @returns {Promise} + */ + requestSyncStatus: function() {}, + + /** + * Run a local ML model + * / + * @param {string} fieldId - The ID of the field + * @param {string} modelId - The ID of the model to run + * @param {Object} input - The input data for the model + * @returns {Promise} + */ + runLocalModel: function(fieldId, modelId, input) {}, + + /** + * Get information about the currently authenticated user. + * When no one is logged in, resolves with `{ username: '' }` (does not reject). + * / + * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} + */ + getCurrentUser: function() {}, + + /** + * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. + * / + * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. + */ + getThemeMode: function() {}, + + /** + * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). + * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: + * 1. `attachments/draft/` — unsaved capture (formplayer preview) + * 2. `attachments/synced/` — canonical committed / downloaded copy + * 3. `attachments/pending/` — queued for upload (fallback only) + * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. + * Path segments and ".." are rejected. + * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). + * / + * @returns {Promise} Display URL, or `null` if none + */ + getAttachmentUri: function(fileName) {}, + + /** + * Base `file://` URL for the canonical attachments directory (trailing slash). + * Returns the `synced/` subfolder — only committed/downloaded files are + * iterable from here. Drafts and the upload queue are excluded by design so + * custom apps can safely list this directory. + * **Breaking change (v2 layout):** this used to return the `attachments/` + * parent directory, which mixed committed files with `draft/` and + * `pending_upload/` subfolders. Custom apps that iterate this URL will now + * see only fully-committed attachments. + * / + * @returns {Promise} e.g. `file:///.../attachments/synced/` + */ + getAttachmentsUri: function() {}, + + /** + * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). + * / + * @returns {Promise} App directory URL for extensions, question_types, etc. + */ + getCustomAppUri: function() {}, + + /** + * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). + * Some bundles also use files under the custom app `forms/` subdirectory. + * / + * @returns {Promise} Forms directory URL + */ + getFormSpecsUri: function() {}, + + /** + * Persist an observation **without opening Formplayer** (headless write). + * Intended for custom apps that create or update records programmatically. + * Uses the same persistence path as a Formplayer submit, including promotion + * of any referenced draft attachments. Provide `observationId` to update an + * existing row; omit it to create a new one. + * / + * @since 1.3.0 + * @param {PersistObservationInput} input - The observation to persist + * @returns {Promise} The stored observation id and data + */ + persistObservation: function(input) {}, + + /** + * Trigger a synchronization with Synkronus (pull + push). + * Resolves when the sync completes; rejects if a sync is already in progress + * or the sync fails. Attachments are excluded by default for speed. + * / + * @since 1.3.0 + * @returns {Promise} The final data version after sync + */ + sync: function(options) {}, + + /** + * Probe connectivity to the configured Synkronus server (`GET /health`). + * Never rejects for an offline device — returns `{ online: false }` so callers + * can branch on connectivity without try/catch. Suitable for the + * "verify subject details when online, fall back when offline" pattern. + * / + * @since 1.3.0 + * @returns {Promise} The current connectivity status + */ + getConnectivityStatus: function() {}, /** - * Update an existing form - * / - * @param {string} observationId - The identifier of the observation - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to update - * @returns {Promise} The observationId of the updated form - */ - updateObservation: function (observationId, formType, finalData) {}, - - /** - * Request camera access for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation - */ - requestCamera: function (fieldId) {}, - - /** - * Request location for a field (captures into the form GPS field). - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with location result or rejects on error - */ - requestLocation: function (fieldId) {}, - - /** - * Request file selection for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation - */ - requestFile: function (fieldId) {}, - - /** - * Launch an external intent - * / - * @param {string} fieldId - The ID of the field - * @param {Object} intentSpec - The intent specification - * @returns {Promise} - */ - launchIntent: function (fieldId, intentSpec) {}, - - /** - * Call a subform - * / - * @param {string} fieldId - The ID of the field - * @param {string} formType - The ID of the subform - * @param {Object} options - Additional options for the subform - * @returns {Promise} - */ - callSubform: function (fieldId, formType, options) {}, - - /** - * Request audio recording for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation - */ - requestAudio: function (fieldId) {}, - - /** - * Request video recording for a field (camera / picker — host-defined). - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation - */ - requestVideo: function (fieldId) {}, - - /** - * Request QR code scanning for a field - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation - */ - requestQrcode: function (fieldId) {}, - - /** - * Request biometric authentication - * / - * @param {string} fieldId - The ID of the field - * @returns {Promise} - */ - requestBiometric: function (fieldId) {}, - - /** - * Request the current connectivity status - * / - * @returns {Promise} - */ - requestConnectivityStatus: function () {}, - - /** - * Request the current sync status - * / - * @returns {Promise} - */ - requestSyncStatus: function () {}, - - /** - * Run a local ML model - * / - * @param {string} fieldId - The ID of the field - * @param {string} modelId - The ID of the model to run - * @param {Object} input - The input data for the model - * @returns {Promise} - */ - runLocalModel: function (fieldId, modelId, input) {}, - - /** - * Get information about the currently authenticated user. - * When no one is logged in, resolves with `{ username: '' }` (does not reject). - * / - * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} - */ - getCurrentUser: function () {}, - - /** - * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. - * / - * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. - */ - getThemeMode: function () {}, - - /** - * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). - * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: - * 1. `attachments/draft/` — unsaved capture (formplayer preview) - * 2. `attachments/synced/` — canonical committed / downloaded copy - * 3. `attachments/pending/` — queued for upload (fallback only) - * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. - * Path segments and ".." are rejected. - * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). - * / - * @returns {Promise} Display URL, or `null` if none - */ - getAttachmentUri: function (fileName) {}, - - /** - * Base `file://` URL for the canonical attachments directory (trailing slash). - * Returns the `synced/` subfolder — only committed/downloaded files are - * iterable from here. Drafts and the upload queue are excluded by design so - * custom apps can safely list this directory. - * **Breaking change (v2 layout):** this used to return the `attachments/` - * parent directory, which mixed committed files with `draft/` and - * `pending_upload/` subfolders. Custom apps that iterate this URL will now - * see only fully-committed attachments. - * / - * @returns {Promise} e.g. `file:///.../attachments/synced/` - */ - getAttachmentsUri: function () {}, - - /** - * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). - * / - * @returns {Promise} App directory URL for extensions, question_types, etc. - */ - getCustomAppUri: function () {}, - - /** - * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). - * Some bundles also use files under the custom app `forms/` subdirectory. - * / - * @returns {Promise} Forms directory URL - */ - getFormSpecsUri: function () {}, - - /** - * Persist an observation without opening Formplayer (headless write). - * @param {PersistObservationInput} input - The observation to persist - * @returns {Promise} The stored observation id and data - */ - persistObservation: function (input) {}, - - /** - * Trigger a synchronization with Synkronus (pull + push). - * @param {{ includeAttachments?: boolean }} options - Sync options - * @returns {Promise} The final data version after sync - */ - sync: function (options) {}, - - /** - * Probe connectivity to the configured Synkronus server (GET /health). - * @returns {Promise} The current connectivity status - */ - getConnectivityStatus: function () {}, + * Read the device's last-known Synkronus data revision (`current_version` + * from the most recent successful sync). Reflects server-stream alignment + * only — not unsynced local edits. Use with periodic polling or after + * {@link sync} to detect when another device has pushed changes. + * / + * @since 1.4.0 + * @returns {Promise} Non-negative revision count (0 if never synced) + */ + getCurrentDataRevisionCount: function() {}, + }; // Make the API available globally in browser environments diff --git a/formulus/ios/Formulus.xcodeproj/project.pbxproj b/formulus/ios/Formulus.xcodeproj/project.pbxproj index 96e384d2b..661d49e0c 100644 --- a/formulus/ios/Formulus.xcodeproj/project.pbxproj +++ b/formulus/ios/Formulus.xcodeproj/project.pbxproj @@ -295,7 +295,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = ZZ9482ZVMJ; ENABLE_BITCODE = NO; INFOPLIST_FILE = Formulus/Info.plist; @@ -304,7 +304,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -329,7 +329,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = ZZ9482ZVMJ; INFOPLIST_FILE = Formulus/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -337,7 +337,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/formulus/package.json b/formulus/package.json index 88b0fd84f..3f9dad702 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -1,6 +1,6 @@ { "name": "formulus-app", - "version": "1.0.0", + "version": "1.1.1", "packageManager": "pnpm@10.33.2", "type": "module", "private": true, diff --git a/formulus/src/screens/SettingsScreen.tsx b/formulus/src/screens/SettingsScreen.tsx index e39e29398..d82e6ec50 100644 --- a/formulus/src/screens/SettingsScreen.tsx +++ b/formulus/src/screens/SettingsScreen.tsx @@ -30,6 +30,7 @@ import { import QRScannerModal, { ScannerModalResults, } from '../components/QRScannerModal'; +import { appVersionService } from '../services/AppVersionService'; import { QRSettingsService } from '../services/QRSettingsService'; import { MainTabParamList } from '../types/NavigationTypes'; import Icon from '@react-native-vector-icons/material-design-icons'; @@ -89,6 +90,7 @@ const SettingsScreen = () => { const [isHydrating, setIsHydrating] = useState(() => !initialSnap.ready); const [isLoggingIn, setIsLoggingIn] = useState(false); const [showQRScanner, setShowQRScanner] = useState(false); + const [version, setVersion] = useState(''); const mountedRef = useRef(true); useEffect(() => { @@ -98,6 +100,17 @@ const SettingsScreen = () => { }; }, []); + useEffect(() => { + const load = async () => { + try { + setVersion(await appVersionService.getFullVersion()); + } catch { + setVersion(''); + } + }; + load(); + }, []); + useEffect(() => { let cancelled = false; @@ -574,16 +587,20 @@ const SettingsScreen = () => { - - v1.0.0 - - - Young Ossicone - + {!!version && ( + + v{version} + + )} + {!!version && ( + + Young Ossicone + + )} diff --git a/formulus/src/services/SequenceCounterService.ts b/formulus/src/services/SequenceCounterService.ts new file mode 100644 index 000000000..54887fb84 --- /dev/null +++ b/formulus/src/services/SequenceCounterService.ts @@ -0,0 +1,62 @@ +/** + * Device-local monotonic sequence counters for scoped auto-numbering. + * Keys are fully qualified (including device prefix) before storage. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { clientIdService } from './ClientIdService'; + +const STORAGE_PREFIX = '@ode_sequence:'; + +export type AllocateSequenceOptions = { + startAt?: number; + peek?: boolean; +}; + +export class SequenceCounterService { + private static instance: SequenceCounterService; + + private constructor() {} + + public static getInstance(): SequenceCounterService { + if (!SequenceCounterService.instance) { + SequenceCounterService.instance = new SequenceCounterService(); + } + return SequenceCounterService.instance; + } + + /** Prefix app-authored scope suffix with stable device id. */ + public async buildFullScopeKey(appScopeKey: string): Promise { + const trimmed = appScopeKey.trim(); + if (!trimmed) { + throw new Error('scopeKey is required'); + } + const deviceId = await clientIdService.getClientId(); + return `device:${deviceId}:${trimmed}`; + } + + public async allocate( + appScopeKey: string, + options: AllocateSequenceOptions = {}, + ): Promise { + const fullKey = await this.buildFullScopeKey(appScopeKey); + const startAt = options.startAt ?? 1; + const storageKey = STORAGE_PREFIX + fullKey; + + const raw = await AsyncStorage.getItem(storageKey); + const current = raw != null ? Number(raw) : startAt - 1; + const last = + Number.isFinite(current) && current >= startAt - 1 + ? current + : startAt - 1; + const next = last + 1; + + if (!options.peek) { + await AsyncStorage.setItem(storageKey, String(next)); + } + + return next; + } +} + +export const sequenceCounterService = SequenceCounterService.getInstance(); diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index 5dfa73e94..a4028f8f5 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -484,6 +484,17 @@ export interface FormulusInterface { */ getCachedLocation(fieldId?: string): Promise; + /** + * Allocate the next monotonic integer in a device-local scope. + * Formulus prepends `device:{deviceId}:` to the app-authored `scopeKey` suffix. + * @param scopeKey App suffix, e.g. `af:A12:person` (no device prefix). + * @param options `startAt` default 1; `peek` returns next without consuming. + */ + allocateSequence( + scopeKey: string, + options?: { startAt?: number; peek?: boolean }, + ): Promise; + /** * Subscribe to location updates while the custom app WebView is active. * Updates arrive as `locationWatchUpdate` window messages. @@ -711,7 +722,7 @@ export interface FormulusCallbacks { /** * Current version of the interface */ -export const FORMULUS_INTERFACE_VERSION = '1.4.0'; +export const FORMULUS_INTERFACE_VERSION = '1.5.0'; /** Parses major.minor.patch from the start of a version string (ignores prerelease after `-`). */ function semverSegments(version: string): [number, number, number] { diff --git a/formulus/src/webview/FormulusMessageHandlers.ts b/formulus/src/webview/FormulusMessageHandlers.ts index d70eeaf00..a75244d88 100644 --- a/formulus/src/webview/FormulusMessageHandlers.ts +++ b/formulus/src/webview/FormulusMessageHandlers.ts @@ -3,6 +3,7 @@ This is where the actual implementation of the methods happens on the React Nati It handles the messages received from the WebView and executes the corresponding native functionality. */ import { GeolocationService } from '../services/GeolocationService'; +import { sequenceCounterService } from '../services/SequenceCounterService'; import { qrcodeRequestCoordinator } from '../services/QrcodeRequestCoordinator'; import { WebViewMessageEvent, WebView } from 'react-native-webview'; import RNFS from 'react-native-fs'; @@ -716,6 +717,32 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { }; }, + onAllocateSequence: async ( + payload: + | string + | { + scopeKey?: string; + startAt?: number; + peek?: boolean; + }, + ): Promise => { + const scopeKey = + typeof payload === 'string' + ? payload + : typeof payload?.scopeKey === 'string' + ? payload.scopeKey + : ''; + const startAt = + typeof payload === 'object' && payload != null + ? payload.startAt + : undefined; + const peek = + typeof payload === 'object' && payload != null + ? payload.peek + : undefined; + return sequenceCounterService.allocate(scopeKey, { startAt, peek }); + }, + onWatchLocation: async ( payload: string | { fieldId?: string }, ): Promise => { diff --git a/synkronus-cli/internal/cmd/version.go b/synkronus-cli/internal/cmd/version.go index 827e09ad3..eb1bbff7f 100644 --- a/synkronus-cli/internal/cmd/version.go +++ b/synkronus-cli/internal/cmd/version.go @@ -11,7 +11,7 @@ import ( var ( // Version is the CLI version, set during build - Version = "0.2.0" + Version = "1.1.1" // BuildDate is the date when the CLI was built BuildDate = "unknown" // CommitHash is the git commit hash diff --git a/synkronus-cli/versioninfo.json b/synkronus-cli/versioninfo.json index 4ec950653..0416da8ba 100644 --- a/synkronus-cli/versioninfo.json +++ b/synkronus-cli/versioninfo.json @@ -2,14 +2,14 @@ "FixedFileInfo": { "FileVersion": { "Major": 1, - "Minor": 0, - "Patch": 0, + "Minor": 1, + "Patch": 1, "Build": 0 }, "ProductVersion": { "Major": 1, - "Minor": 0, - "Patch": 0, + "Minor": 1, + "Patch": 1, "Build": 0 }, "FileFlagsMask": "3f", @@ -22,14 +22,14 @@ "Comments": "Synkronus CLI Tool", "CompanyName": "OpenDataEnsemble", "FileDescription": "Synkronus CLI - A command-line interface for the Synkronus API", - "FileVersion": "1.0.0.0", + "FileVersion": "1.1.1.0", "InternalName": "synk", "LegalCopyright": "© 2025 OpenDataEnsemble", "LegalTrademarks": "", "OriginalFilename": "synk.exe", "PrivateBuild": "", "ProductName": "Synkronus CLI", - "ProductVersion": "1.0.0.0", + "ProductVersion": "1.1.1.0", "SpecialBuild": "" }, "VarFileInfo": { diff --git a/synkronus-portal/package.json b/synkronus-portal/package.json index 543d63694..3057b9986 100644 --- a/synkronus-portal/package.json +++ b/synkronus-portal/package.json @@ -1,7 +1,7 @@ { "name": "synkronus-portal", "private": true, - "version": "1.0.0", + "version": "1.1.1", "packageManager": "pnpm@10.33.2", "license": "MIT", "type": "module",