Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
2 changes: 2 additions & 0 deletions desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
58 changes: 58 additions & 0 deletions desktop/public/formulus-injection.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,64 @@
});
},

// allocateSequence: scopeKey, options?: { startAt?, peek? } => Promise<number>
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<FormInfo[]>
getAvailableForms: function () {
return new Promise((resolve, reject) => {
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "odedesktop"
version = "1.0.1"
version = "1.1.1"
description = "ODE Desktop"
authors = ["OpenDataEnsemble.org"]
edition = "2024"
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
33 changes: 33 additions & 0 deletions desktop/src/lib/formPreviewBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();

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';

Expand Down Expand Up @@ -72,6 +89,7 @@ export const FORMULUS_INJECTION_REQUEST_TYPES = [
'getAttachmentsUri',
'getCustomAppUri',
'getFormSpecsUri',
'allocateSequence',
] as const;

export type FinalizeRequest =
Expand Down Expand Up @@ -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 => ({
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/lib/synkConstants.ts
Original file line number Diff line number Diff line change
@@ -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';
22 changes: 22 additions & 0 deletions desktop/src/pages/AboutPage.tsx
Original file line number Diff line number Diff line change
@@ -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' },
Expand All @@ -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 (
<section className="page page-about">
<img
Expand All @@ -21,6 +42,7 @@ export function AboutPage() {
height={256}
/>
<h2>ODE Desktop</h2>
{version ? <p className="about-version">v{version}</p> : null}
<p>
Open Data Ensemble is an offline-first ecosystem for field data
collection, synchronization, and stewardship. ODE Desktop helps you
Expand Down
52 changes: 28 additions & 24 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
formSchema: unknown,
): Record<string, unknown> {
): Promise<Record<string, unknown>> {
const root = prepareRootObservationData(raw, formSchema);
const { data } = applyAutoSequences(
const { data } = await applyAutoSequences(
root,
formSchema as JsonSchema7 | undefined,
readAutoSequenceRuntime(),
Expand Down Expand Up @@ -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<string, unknown>,
formSchemaTyped,
),
Expand All @@ -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);
Expand Down Expand Up @@ -1011,9 +1011,9 @@ function App() {
}, [pendingFormInit, initializeForm]);

const refreshFormData = useCallback(
(newData: Record<string, unknown>) => {
async (newData: Record<string, unknown>) => {
const autoRuntime = readAutoSequenceRuntime();
const { data: sequencedData } = applyAutoSequences(
const { data: sequencedData } = await applyAutoSequences(
newData,
schema ?? undefined,
autoRuntime,
Expand Down Expand Up @@ -1049,25 +1049,27 @@ function App() {
const incoming = newData as Record<string, unknown>;
const baseline = dataRef.current as Record<string, unknown>;
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<string, unknown>) => {
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],
);
Expand Down Expand Up @@ -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 = () => {
Expand Down
Loading
Loading