diff --git a/.github/CICD.md b/.github/CICD.md index fa616c528..2c59bb533 100644 --- a/.github/CICD.md +++ b/.github/CICD.md @@ -187,6 +187,29 @@ This ensures: - Each APK is built against the exact assets produced in the same run - Formplayer build outputs do not pollute git history +### Documentation Site + +**Workflow**: `.github/workflows/docs.yml` + +Builds and publishes the Docusaurus site in [`docs/`](../docs/) to [opendataensemble.org](https://opendataensemble.org/) via GitHub Pages. + +#### Triggers + +| Event | Result | +|-------|--------| +| Pull request to `main` or `dev` touching `docs/**` | Validate and build only | +| Push to `main` touching `docs/**` | Validate, build, and deploy | +| `workflow_dispatch` | Validate and build | + +#### Notes + +- The deployed branch is main, so the published docs track the released version of ODE. Changes on dev and pull requests are validated by CI but are not deployed. +- The site is an independent **npm** project inside the pnpm monorepo: `cache-dependency-path` points at `docs/package-lock.json`, which is force-included in the root `.gitignore` so `npm ci` is reproducible. +- `docs/docs/` is the Docusaurus content root. URLs are unchanged from the previous standalone `OpenDataEnsemble/docs` repository — `routeBasePath` is still `/docs`. +- `actions/upload-pages-artifact@v4` is used deliberately: v5 excludes dotfiles by default, which would drop `static/.nojekyll` and `static/CNAME` from the artifact. +- The upload path is `docs/build` (repo-root relative). `defaults.run.working-directory` does not apply to `uses:` steps. +- No secrets are required. The deploy job needs `contents: read`, `pages: write`, and `id-token: write`. + ### SBOM (CycloneDX) on releases **Workflow**: `.github/workflows/sbom-release.yml` diff --git a/.github/QUICK_REFERENCE.md b/.github/QUICK_REFERENCE.md index 299fec7a5..3cb2e8d80 100644 --- a/.github/QUICK_REFERENCE.md +++ b/.github/QUICK_REFERENCE.md @@ -2,7 +2,7 @@ Quick reference for common CI/CD operations in the Open Data Ensemble monorepo. -## 🚀 Synkronus Docker Images +## Synkronus Docker Images ### Pull Images @@ -50,7 +50,7 @@ docker run -d \ `dev` is not the pre-release channel. Release pointer tags move only when a GitHub Release is published; `latest-pre-release` requires the release to be marked as a pre-release. -## 🔄 Triggering Builds +## Triggering Builds ### Automatic Triggers @@ -70,7 +70,7 @@ Relevant paths include `synkronus/`, `synkronus-portal/`, shared `packages/`, th Manual runs publish only an immutable `sha-{short}` tag; they do not move `latest`, `latest-pre-release`, `main`, or `dev`. -## 📦 Creating Releases +## Creating Releases ### Quick Release (Latest) ```bash @@ -91,7 +91,7 @@ Creates: - `v1.0.0` - `v1.0` -## 🔍 Monitoring +## Monitoring ### View Workflow Runs ``` @@ -112,7 +112,7 @@ gh run list --workflow=synkronus-docker.yml gh run view ``` -## 🐛 Troubleshooting +## Troubleshooting ### Build Failed @@ -144,7 +144,7 @@ echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin docker manifest inspect ghcr.io/opendataensemble/synkronus:latest ``` -## 🔐 Authentication +## Authentication ### GitHub CLI ```bash @@ -160,7 +160,7 @@ echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin gh auth token | docker login ghcr.io -u USERNAME --password-stdin ``` -## 📊 Image Information +## Image Information ### View Image Details ```bash @@ -180,7 +180,7 @@ docker images ghcr.io/opendataensemble/synkronus dive ghcr.io/opendataensemble/synkronus:latest ``` -## 🚢 Deployment +## Deployment ### Coolify @@ -216,7 +216,7 @@ spec: image: ghcr.io/opendataensemble/synkronus:latest ``` -## 🔄 Rollback +## Rollback ### Quick Rollback ```bash @@ -232,7 +232,7 @@ docker run -d [same options] ghcr.io/opendataensemble/synkronus:v1.0.0 2. Select previous version 3. Click "Redeploy" -## 📝 Best Practices +## Best Practices ### Production - ✅ Pin specific versions: `v1.0.0` @@ -253,7 +253,7 @@ docker run -d [same options] ghcr.io/opendataensemble/synkronus:v1.0.0 - ✅ Keep workflows updated - ✅ Document changes -## 🔗 Quick Links +## Quick Links - [Full CI/CD Documentation](CICD.md) - [Synkronus Docker Guide](../synkronus/DOCKER.md) @@ -261,7 +261,7 @@ docker run -d [same options] ghcr.io/opendataensemble/synkronus:v1.0.0 - [GitHub Actions Docs](https://docs.github.com/en/actions) - [GHCR Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) -## 💡 Tips +## Tips ### Speed Up Local Development ```bash diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7fb3834ba..cc1d2b7bb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,75 +1,36 @@ -# Pull Request Title + - +## What & Why -## Description - - +Short description of what changed and why. ## Type of Change -- [ ] Bug Fix -- [ ] New Feature / Enhancement -- [ ] Refactor / Code Cleanup -- [ ] Documentation Update -- [ ] Maintenance / Chore -- [ ] Other (please specify): - ---- - -## Component(s) Affected - -- [ ] **formulus** (React Native mobile app) -- [ ] **formulus-formplayer** (React web app) -- [ ] **synkronus** (Go backend server) -- [ ] **synkronus-cli** (Command-line utility) -- [ ] **Documentation** -- [ ] **DevOps / CI/CD** -- [ ] **Other:** - ---- +- [ ] Bug fix +- [ ] New feature / enhancement +- [ ] Refactor / cleanup +- [ ] Documentation +- [ ] Chore / other -## Related Issue(s) +## Component(s) Changed - - -**Closes/Fixes/Resolves:** - ---- +- [ ] formulus (React Native mobile app) +- [ ] formulus-formplayer (form WebView UI) +- [ ] synkronus (Go backend) +- [ ] synkronus-cli (command-line client) +- [ ] synkronus-portal (web admin UI) +- [ ] desktop (Tauri app) +- [ ] packages (tokens / components) +- [ ] Documentation / DevOps / CI ## Testing -- [ ] Unit tests added/updated -- [ ] Integration tests added/updated +- [ ] Added/updated tests - [ ] Manually tested -- [ ] Tested on multiple platforms (if applicable) - [ ] Not applicable ---- - -## Breaking Changes - -- [ ] This PR introduces breaking changes -- [ ] This PR does NOT introduce breaking changes - -**If breaking changes, please describe migration steps:** - ---- - -## Documentation Updates - -- [ ] Documentation has been updated -- [ ] Documentation update is not required - ---- - ## Checklist -- [ ] Code follows project style guidelines -- [ ] All existing tests pass -- [ ] New tests added for new functionality -- [ ] PR title follows Conventional Commits format - ---- - -**Thank you for contributing to Open Data Ensemble (ODE)!** +- [ ] Pre-flight checks for changed packages passed (lint / format / tests) +- [ ] Breaking changes (if any) have migration steps +- [ ] Related issue linked, e.g. `Closes #123` \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..4d1bc8ed1 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,102 @@ +name: Docs + +on: + pull_request: + branches: [main, dev] + paths: + - 'docs/**' + - '.github/workflows/docs.yml' + push: + branches: [main, dev] + paths: + - 'docs/**' + - '.github/workflows/docs.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Do not cancel an in-flight production deploy when another push lands. + cancel-in-progress: ${{ github.event_name != 'push' }} + +permissions: + contents: read + +env: + NODE_VERSION: '24' + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: docs/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Check links and doc IDs + run: npm run test + + - name: Build site + env: + CI: true + run: npm run build + + deploy: + name: Deploy to GitHub Pages + needs: validate + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + permissions: + contents: read # checkout (job permissions replace the workflow defaults) + pages: write # to deploy to Pages + id-token: write # to verify the deployment originates from an appropriate source + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + defaults: + run: + working-directory: docs + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: docs/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build site + run: npm run build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + with: + # Action paths are relative to the repo root, not defaults.run.working-directory. + path: docs/build + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/formulus-android.yml b/.github/workflows/formulus-android.yml index 062462edc..821f317c8 100644 --- a/.github/workflows/formulus-android.yml +++ b/.github/workflows/formulus-android.yml @@ -19,7 +19,6 @@ on: - 'packages/tokens/**' - 'packages/components/**' - 'packages/observation-query/**' - - '.github/workflows/formulus-android.yml' release: types: [published] @@ -83,6 +82,7 @@ jobs: needs: build-formplayer-assets permissions: contents: write + pull-requests: write steps: - name: Checkout repository @@ -250,3 +250,16 @@ jobs: formulus/android/app/build/outputs/bundle/release/*.aab env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + prepare-next-native-build-code: + name: Prepare next Formulus build code + if: github.event_name == 'release' && needs.build-android.result == 'success' + needs: build-android + permissions: + contents: write + pull-requests: write + uses: ./.github/workflows/prepare-next-formulus-build-code.yml + with: + target_branch: ${{ github.event.release.target_commitish }} + dry_run: false + secrets: inherit diff --git a/.github/workflows/prepare-next-formulus-build-code.yml b/.github/workflows/prepare-next-formulus-build-code.yml new file mode 100644 index 000000000..a2d38f1fd --- /dev/null +++ b/.github/workflows/prepare-next-formulus-build-code.yml @@ -0,0 +1,111 @@ +name: Prepare Next Formulus Build Code + +on: + workflow_dispatch: + inputs: + target_branch: + description: Branch that the release was prepared from and that should receive the next-code PR + required: true + default: dev + type: string + dry_run: + description: Validate and show the proposed update without creating a branch or pull request + required: true + default: true + type: boolean + workflow_call: + inputs: + target_branch: + description: Branch that the release was prepared from and that should receive the next-code PR + required: true + type: string + dry_run: + description: Validate and show the proposed update without creating a branch or pull request + required: false + default: false + type: boolean + +concurrency: + group: prepare-next-formulus-build-code-${{ inputs.target_branch }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + name: Prepare next native build-code block + runs-on: ubuntu-latest + + steps: + - name: Verify target branch + env: + TARGET_BRANCH: ${{ inputs.target_branch }} + run: | + case "$TARGET_BRANCH" in + dev|main) ;; + *) + echo "::error::Target branch must be dev or main; received '$TARGET_BRANCH'." + echo "Create releases from one of those branches so the next-code PR has a safe target." + exit 1 + ;; + esac + + - name: Checkout target branch + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + ref: ${{ inputs.target_branch }} + + - name: Set up Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: "24" + + - name: Allocate the next four-code block + working-directory: formulus + run: node scripts/bumpNativeBuildCode.js + + - name: Validate native versions + working-directory: formulus + run: node scripts/validateNativeVersions.js + + - name: Check and display proposed change + run: | + git diff --check + git --no-pager diff -- formulus/android/app/build.gradle formulus/ios/Formulus.xcodeproj/project.pbxproj + + - name: Check for an existing preparation pull request + if: inputs.dry_run == false + id: existing-pr + env: + GH_TOKEN: ${{ github.token }} + TARGET_BRANCH: ${{ inputs.target_branch }} + run: | + pr_url="$(gh pr list --base "$TARGET_BRANCH" --head "automation/prepare-next-formulus-build-code" --state open --json url --jq '.[0].url')" + echo "url=$pr_url" >> "$GITHUB_OUTPUT" + + - name: Create preparation pull request + if: inputs.dry_run == false && steps.existing-pr.outputs.url == '' + env: + GH_TOKEN: ${{ github.token }} + TARGET_BRANCH: ${{ inputs.target_branch }} + run: | + set -eu + branch="automation/prepare-next-formulus-build-code" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add formulus/android/app/build.gradle formulus/ios/Formulus.xcodeproj/project.pbxproj + git commit -m "chore(release): reserve next Formulus build code" + git push --force origin "$branch" + gh pr create \ + --base "$TARGET_BRANCH" \ + --head "$branch" \ + --title "chore(release): reserve next Formulus build code" \ + --body "Automated preparation of the next four-code Android/iOS build-number block. Generated by [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." + + - name: Report existing preparation pull request + if: inputs.dry_run == false && steps.existing-pr.outputs.url != '' + run: | + echo "A next-code preparation pull request is already open: ${{ steps.existing-pr.outputs.url }}" diff --git a/.gitignore b/.gitignore index c241ba558..923b5b760 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ package-lock.json **/package-lock.json # F-Droid builds use npm ci / install --build-from-source in formulus/ !formulus/package-lock.json +# Documentation site deploys with npm ci, so its lockfile must be committed +!docs/package-lock.json # Generated credentials **/credentials.txt diff --git a/AGENTS.md b/AGENTS.md index 419a3eda4..546d3fbb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ Do not assume custom app authors have local checkouts of **ODE** or internal exa | [packages/tokens](packages/tokens/) | Design tokens (`@ode/tokens`) | Style Dictionary | [packages/tokens/AGENTS.md](packages/tokens/AGENTS.md) | | [packages/components](packages/components/) | Shared UI (`@ode/components`) | React | [packages/components/AGENTS.md](packages/components/AGENTS.md) | | [desktop](desktop/) | Data management + Forms / app workbench (Tauri) | React, Rust | [desktop/AGENTS.md](desktop/AGENTS.md) | +| [docs](docs/) | Documentation site (published at [opendataensemble.org](https://opendataensemble.org/)) | Docusaurus, MDX | [docs/README.md](docs/README.md) | --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..ccd3d7190 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,121 @@ +# Contributing to ODE + +Thanks for wanting to help out. ODE is a young project and the community keeps it moving, so whether you're here to write code, fix docs, test the platform, or just share how you use it, you're welcome. + +This guide covers the practical stuff: how to report problems, how to set up a local environment, and what we expect from pull requests. If you haven't already, read the [README](README.md) for an overview of the project and what the ensemble (formulus, formplayer, synkronus, and friends) actually is. + +## What you can help with + +There is more than one way to contribute: + +- **Code** - fix bugs, build features, or start with the [issue tracker](https://github.com/OpenDataEnsemble/ode/issues) and look for `good first issue` or `help wanted` labels. +- **Documentation** - the docs always need polish, both here and on [opendataensemble.org](https://opendataensemble.org/). If a section is confusing or outdated, improve it. +- **Testing and feedback** - run the apps, sync data, and report what breaks or what feels rough. +- **Community** - help other users on the [forum](https://forum.opendataensemble.org/), answer questions, and share how ODE works in your own way. + +## Before you start + +- **Questions go to the forum, not issues.** If you're trying to understand how something works or how to set it up, ask on the [community forum](https://forum.opendataensemble.org/). Issues are for bugs and concrete change requests. +- **Talk first for anything significant.** If you want to build a new feature, do a big refactor, or change behavior, open an issue first and describe your idea before writing code. A short conversation up front saves you from building the wrong thing. +- **Be patient with people.** ODE welcomes contributors of all skill levels. Be kind, explain things, and assume good intent. + +## Setting up a development environment + +This is a monorepo, so the setup depends on the project you're touching. The full instructions live in each project's README and `AGENTS.md`; here's the short version. + +### Prerequisites + +- [git](https://git-scm.com/) +- [Node.js](https://nodejs.org/) with [Corepack](https://nodejs.org/api/corepack.html) enabled (we use `pnpm`) +- [Go](https://go.dev/) for the Synkronus backend and CLI +- [Rust](https://www.rust-lang.org/) if you're working on ODE Desktop + +### Get the code + +1. [Fork the ode monorepo on GitHub](https://github.com/OpenDataEnsemble/ode/fork). +2. Clone your fork and add the upstream remote: + + ```bash + git clone https://github.com//ode.git + cd ode + git remote add upstream https://github.com/OpenDataEnsemble/ode.git + ``` + +3. Create a branch for your work, based on the latest `dev` (the default branch): + + ```bash + git fetch upstream + git switch -c my-change upstream/dev + ``` + +4. When ready, push the changes to your for and create a PR from GitHub + + ```bash + git push -u origin my-change + ``` + +### Per-project setup + +**Tip:** in a fresh clone, install the design tokens first, then formplayer and the shared components because they depend on tokens being built. + +- **formulus** - React Native mobile app. See `formulus/README.md` (and the `formulus/AGENTS.md` pre-flight checklist). Lint/format: `cd formulus && pnpm run lint`, `pnpm run format:check`. +- **formulus-formplayer** - form UI in a WebView (React). See `formulus-formplayer/README.md`. Run it with `cd formulus-formplayer && pnpm start`. +- **synkronus** - Go backend. See `synkronus/README.md` (Development Setup) and `synkronus/DOCKER.md`. +- **synkronus-cli** - Go command-line client. See `synkronus-cli/README.md`; pre-flight is `cd synkronus-cli && go build ./cmd/synkronus`. +- **synkronus-portal** - web admin UI (React). See `synkronus-portal/README.md` for both Docker and Dockerless setups. +- **desktop** - Tauri app. See `desktop/README.md` (Quick start). +- **docs** - public Docusaurus site under `docs/`. It is an independent **npm** project (`cd docs && npm install && npm start`); do not use pnpm there. See `docs/README.md`. +- **packages/tokens** and **packages/components** - see `packages/tokens/CONTRIBUTING.md` and `packages/components/CONTRIBUTING.md`. + +The quickest cheat sheet for any package is its `AGENTS.md` file - it lists the day-to-day commands and exactly what to run before a pull request. + +## Making changes + +- **Keep a pull request focused.** One logical change per PR. If you notice something unrelated, open a separate issue instead of folding it in. +- **Follow the conventions of the project you're in.** Match the style of the surrounding code, use the existing helpers and libraries, and reach for shared tokens and components where they already exist. +- **Run the checks before you push.** Every package has lint, format, and test commands — run them locally so CI isn't the first to catch problems. Each package's `README.md` / `AGENTS.md` lists the exact commands. +- **Add or update tests** for new behavior, and update the docs if your change alters how something works. + +### Commit messages + +We use [Conventional Commits](https://www.conventionalcommits.org/). Keep messages short and specific: + +``` +feat(formulus): add draft delete confirmation +fix(synkronus): handle missing user on refresh +docs(portal): clarify env var setup +``` + +The scope is usually the package or area you touched (`formulus`, `formplayer`, `synkronus`, `portal`, `tokens`, `docs`, and so on), and the message should say what changed and why. + +## Opening a pull request + +1. **Run the pre-flight checks** for every package you changed. The per-package `AGENTS.md` files document these (typically lint, format, format:check, and tests - sometimes typecheck or build too). +2. **Use the [pull request template](.github/pull_request_template.md).** The PR title must be a valid Conventional Commit, and the description should explain how the change works and why it's needed. +3. **Link the issue** you're working on with `Closes #123` so it closes automatically when the PR is merged. +4. **Open a draft PR** if the work is still in progress, then mark it ready for review when it's complete. +5. **Watch the CI status.** Check that the workflows for your change pass; a failing check is usually quick to fix. + +After you open the PR, a maintainer will review it. Expect feedback and a round or two of changes - that's a normal part of the process, not a rejection. Stick around to answer questions and push updates to the same branch. + +## Reporting bugs and security issues + +### Bugs and feature requests + +Search the [issue tracker](https://github.com/OpenDataEnsemble/ode/issues) for existing reports first, then use the [issue templates](.github/ISSUE_TEMPLATE/) — they describe exactly what to include. For a bug report that means what you did, what you expected, and what happened instead, plus which component and version. + +### Security vulnerabilities + +**Do not report security issues through public issues.** Follow [SECURITY.md](SECURITY.md) and email `security@opendataensemble.org` instead. Reporting privately means the problem isn't exposed publicly before a fix exists, and we respond quickly. + +## License + +The repository is [MIT](LICENSE) licensed, with one exception: `synkronus-cli` is currently **GPL-2.0-or-later** while it depends on a GPL-classified QR library - see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for details. Contributions land under the license of the project they're in. + +## Getting help + +- [Community forum](https://forum.opendataensemble.org/) — questions, ideas, and general discussion. +- [opendataensemble.org](https://opendataensemble.org/) — project site and documentation. +- [Repository on GitHub](https://github.com/OpenDataEnsemble/ode) — issues, discussions, and releases. + +Every contribution, big or small, is what keeps ODE moving. Thanks for joining the ensemble. diff --git a/DYNAMIC_CHOICE_LISTS.md b/DYNAMIC_CHOICE_LISTS.md index d458c3dc0..a108df310 100644 --- a/DYNAMIC_CHOICE_LISTS.md +++ b/DYNAMIC_CHOICE_LISTS.md @@ -174,7 +174,7 @@ Dynamic Choice Lists enable: ## Real-World Examples -### 📍 Location Selection (Static Filters) +### Location Selection (Static Filters) ```json { @@ -209,7 +209,7 @@ Dynamic Choice Lists enable: **Note:** Template parameters (`{{data.field}}`) are not supported. Use static filter values. -### 👥 Select Person (ODK-X Pattern) +### Select Person (ODK-X Pattern) **Basic - All Persons:** ```json @@ -292,7 +292,7 @@ Dynamic Choice Lists enable: } ``` -### 🏆 Ranking Survey +### Ranking Survey ```json { @@ -329,7 +329,7 @@ Dynamic Choice Lists enable: **Note:** Template parameters are not supported. Use static filters only. -### 👨‍👩‍👧‍👦 Kinship Survey +### Kinship Survey ```json { @@ -368,7 +368,7 @@ Dynamic Choice Lists enable: } ``` -### 🔢 Age-Based Filtering +### Age-Based Filtering **Adults Only (18+) - Using age_from_dob():** ```json diff --git a/FORM_LOCALIZATION_GUIDE.md b/FORM_LOCALIZATION_GUIDE.md index 00acaa419..bbd7f9921 100644 --- a/FORM_LOCALIZATION_GUIDE.md +++ b/FORM_LOCALIZATION_GUIDE.md @@ -233,7 +233,7 @@ Behavioral config (`maxStars`, filters, …) stays in **`schema.json`** (`config | 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**. +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 **`docs/docs/guides/form-translations.md`**. --- diff --git a/README.md b/README.md index d5487640f..d671cf1e6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![Formulus Android](https://github.com/OpenDataEnsemble/ode/actions/workflows/formulus-android.yml/badge.svg?branch=main)](https://github.com/OpenDataEnsemble/ode/actions/workflows/formulus-android.yml) [![ODE Desktop](https://github.com/OpenDataEnsemble/ode/actions/workflows/ode-desktop.yml/badge.svg?branch=main)](https://github.com/OpenDataEnsemble/ode/actions/workflows/ode-desktop.yml) [![E2E attachments](https://github.com/OpenDataEnsemble/ode/actions/workflows/e2e-attachments.yml/badge.svg?branch=main)](https://github.com/OpenDataEnsemble/ode/actions/workflows/e2e-attachments.yml) +[![Docs](https://github.com/OpenDataEnsemble/ode/actions/workflows/docs.yml/badge.svg?branch=dev)](https://github.com/OpenDataEnsemble/ode/actions/workflows/docs.yml) [![Latest release](https://img.shields.io/github/v/release/OpenDataEnsemble/ode?include_prereleases&sort=semver)](https://github.com/OpenDataEnsemble/ode/releases) @@ -26,7 +27,7 @@ ODE is a monorepo containing all the core components in the ODE universe - the e ## Architecture -This repository houses four main components: +This repository houses the core components plus the public documentation site: ### **formulus** A React Native project containing the code for Android and iOS apps. This is your mobile data collection companion, designed for field work and offline-first data gathering. @@ -58,6 +59,9 @@ curl -fsSL https://raw.githubusercontent.com/OpenDataEnsemble/ode/main/scripts/i ### **synkronus-portal** A web-based version of the the **synkronus-cli** +### **docs** +The public documentation site (Docusaurus), published at [opendataensemble.org](https://opendataensemble.org/). It is an independent npm project inside this monorepo — see [docs/README.md](docs/README.md). + ## We're Young & Fresh! 🌱🌱🌱 ODE is a **young and vibrant open-source project**, and we're incredibly welcoming to contributors of all experience levels and interests! Whether you're passionate about: @@ -71,7 +75,9 @@ ODE is a **young and vibrant open-source project**, and we're incredibly welcomi ...we'd love to have you join our ensemble! -## 🤝 Contributing +## Contributing + +For the full contribution workflow — setup, commit rules, and PR checks — read [CONTRIBUTING.md](CONTRIBUTING.md). We believe that diverse perspectives and varied skill sets make our project stronger. Don't worry if you're new to open source or if you think your skills might not be "technical enough" - there's a place for everyone here. @@ -87,6 +93,7 @@ This monorepo uses GitHub Actions for CI/CD. For details (trigger conditions, ta - Synkronus Docker build & publish: `.github/workflows/synkronus-docker.yml` - Formulus Android build (includes Formplayer asset build): `.github/workflows/formulus-android.yml` +- Documentation site: `.github/workflows/docs.yml` (validates PRs; deploys from `dev`) - Synkronus deployment docs: `synkronus/DOCKER.md`, `synkronus/DEPLOYMENT.md` ## Code Quality: Linting & Formatting @@ -131,4 +138,4 @@ Ready to join the ensemble? We're excited to meet you and see what unique perspe --- -*Building the future of open data collection, one contribution at a time.* ✨ \ No newline at end of file +*Building the future of open data collection, one contribution at a time.* diff --git a/desktop/package.json b/desktop/package.json index f6bd0f49b..bac8daaba 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "ode-desktop", "private": true, - "version": "1.3.2", + "version": "1.3.3", "packageManager": "pnpm@11.22.0", "type": "module", "scripts": { diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index 7207daf0d..c6ad1ddc7 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -1,8 +1,57 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-06-19T12:32:54.430Z +// Last generated: 2026-09-18T16:52:28.301Z (function () { + const profileId = globalThis.__odeProfileId; + if (typeof profileId !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(profileId)) { + throw new Error( + 'Formulus requires a host profile ID before bridge initialization', + ); + } + const deletedIds = globalThis.__odeDeletedProfileIds || []; + if ( + !Array.isArray(deletedIds) || + deletedIds.some( + id => typeof id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(id), + ) + ) { + throw new Error('Invalid deleted profile IDs'); + } + // Tombstones are permanent: each origin is cleaned when it is next loaded. + // Never clear the whole origin; unrelated third-party storage is not ours. + function removePrefix(storage, prefix) { + for (let i = storage.length - 1; i >= 0; i--) { + const key = storage.key(i); + if (key !== null && key.startsWith(prefix)) storage.removeItem(key); + } + } + if (deletedIds.length) { + const storage = globalThis.localStorage; + deletedIds.forEach(id => removePrefix(storage, 'ode:' + id + ':')); + if (deletedIds.includes(globalThis.__odeLegacyWebStorageProfileId)) { + storage.removeItem('formulus_drafts'); + storage.removeItem('formulus_sticky_fields'); + } + } + if (deletedIds.includes(profileId)) + throw new Error('Active profile has been deleted'); + const appPrefix = 'ode:' + profileId + ':app:'; + const profileLocalStorage = Object.freeze({ + getItem: function (key) { + return globalThis.localStorage.getItem(appPrefix + String(key)); + }, + setItem: function (key, value) { + globalThis.localStorage.setItem(appPrefix + String(key), String(value)); + }, + removeItem: function (key) { + globalThis.localStorage.removeItem(appPrefix + String(key)); + }, + clear: function () { + removePrefix(globalThis.localStorage, appPrefix); + }, + }); + // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist @@ -14,8 +63,21 @@ function isFormulusAvailable() { const api = getFormulus(); + if ( + api && + typeof api.getProfileId === 'function' && + api.getProfileId() !== profileId + ) { + throw new Error( + 'A Formulus browser context cannot change profiles; remount it', + ); + } return ( - api && typeof api === 'object' && typeof api.getVersion === 'function' + api && + typeof api === 'object' && + typeof api.getVersion === 'function' && + typeof api.getProfileId === 'function' && + typeof api.getLocalStorageRef === 'function' ); } @@ -72,7 +134,7 @@ data = event.data; // Already an object } else { // console.warn('Global handleMessage: Received message with unexpected data type:', typeof event.data, event.data); - return; // Or handle error, but for now, just return to avoid breaking others. + return; // Or handle as an error, but for now, just return to avoid breaking others. } // Handle callbacks @@ -109,6 +171,13 @@ // Initialize the formulus interface globalThis.formulus = { + getProfileId: function () { + return profileId; + }, + getLocalStorageRef: function () { + return profileLocalStorage; + }, + // getVersion: => Promise getVersion: function () { return new Promise((resolve, reject) => { @@ -229,7 +298,7 @@ }); }, - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; } => Promise + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; observationId?: string; } => Promise openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = @@ -356,7 +425,7 @@ }); }, - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: any; whereClause?: string; } => Promise getObservationsByQuery: function (options) { return new Promise((resolve, reject) => { const messageId = diff --git a/desktop/scripts/copy-formplayer-to-desktop.mjs b/desktop/scripts/copy-formplayer-to-desktop.mjs index 935f7a589..fc4cff9ff 100644 --- a/desktop/scripts/copy-formplayer-to-desktop.mjs +++ b/desktop/scripts/copy-formplayer-to-desktop.mjs @@ -49,5 +49,16 @@ if (!fs.existsSync(buildDir)) { fs.mkdirSync(targetDir, { recursive: true }); cleanDirectory(targetDir); copyRecursive(buildDir, targetDir); +fs.copyFileSync( + path.join( + formplayerRoot, + '..', + 'formulus', + 'assets', + 'webview', + 'FormulusInjectionScript.js', + ), + path.join(__dirname, '..', 'public', 'formulus-injection.js'), +); console.log(`✓ Copied formplayer build → ${targetDir}`); console.log(' Served by Vite as /formplayer_dist/ (base URL in dev).'); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 8c7f46799..0a246eb79 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -3086,7 +3086,7 @@ dependencies = [ [[package]] name = "odedesktop" -version = "1.3.2" +version = "1.3.3" dependencies = [ "arrow", "chrono", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6cf4c6eaa..bbb04dcc3 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "odedesktop" -version = "1.3.2" +version = "1.3.3" description = "ODE Desktop" authors = ["OpenDataEnsemble.org"] edition = "2024" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index da6baac78..78e03f11c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -145,6 +145,9 @@ struct ServerProfile { struct AppConfigFile { #[serde(default = "schema_version_default")] schema_version: u32, + /// Permanent tombstones for cleanup when a browser origin is next loaded. + #[serde(default)] + deleted_profile_ids: Vec, active_profile_id: String, profiles: Vec, } @@ -487,6 +490,7 @@ struct AuthSession { #[serde(rename_all = "camelCase")] struct SettingsResponse { active_profile_id: String, + deleted_profile_ids: Vec, profiles: Vec, /// App data dir for constructing default per-profile DB paths in the UI. data_directory: String, @@ -837,6 +841,7 @@ fn default_app_config(data_dir: &Path) -> AppConfigFile { let db_path = sqlite_path_for_workspace(&workspace_dir); AppConfigFile { schema_version: 3, + deleted_profile_ids: Vec::new(), active_profile_id: id.clone(), profiles: vec![ServerProfile { id, @@ -862,6 +867,7 @@ fn migrate_legacy_workspace(workspace_path: &str, _data_dir: &Path) -> AppConfig let db = sqlite_path_for_workspace(&ws); AppConfigFile { schema_version: 3, + deleted_profile_ids: Vec::new(), active_profile_id: id.clone(), profiles: vec![ServerProfile { id, @@ -2300,6 +2306,7 @@ fn get_settings(ctx: tauri::State<'_, AppCtxHandle>) -> Result) -> Re if cfg.profiles.len() <= 1 { return Err("cannot delete the last profile".to_string()); } + if !cfg.profiles.iter().any(|p| p.id == profile_id) { + return Err("profile not found".to_string()); + } + if !cfg.deleted_profile_ids.contains(&profile_id) { + cfg.deleted_profile_ids.push(profile_id.clone()); + } cfg.profiles.retain(|p| p.id != profile_id); if cfg.active_profile_id == profile_id { cfg.active_profile_id = cfg.profiles[0].id.clone(); @@ -5866,6 +5882,19 @@ mod tests { use std::io::Read; use std::time::Instant; + #[test] + fn profile_browser_storage_tombstones_survive_config_round_trip() { + let mut cfg = super::default_app_config(Path::new("/tmp/ode-profile-test")); + cfg.deleted_profile_ids.push("deleted-id".to_string()); + let json = serde_json::to_string(&cfg).unwrap(); + let restored: super::AppConfigFile = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.deleted_profile_ids, vec!["deleted-id"]); + let mut legacy = serde_json::to_value(&cfg).unwrap(); + legacy.as_object_mut().unwrap().remove("deletedProfileIds"); + let restored: super::AppConfigFile = serde_json::from_value(legacy).unwrap(); + assert!(restored.deleted_profile_ids.is_empty()); + } + #[test] fn attachment_copy_progress_step_scales_with_batch_size() { assert_eq!(attachment_copy_progress_step(5), 1); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 1c7a993b2..e5c38a2ae 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.3.2", + "version": "1.3.3", "identifier": "org.opendataensemble.custodian", "build": { "beforeDevCommand": "pnpm dev", diff --git a/desktop/src/App.css b/desktop/src/App.css index 7b2831b5a..219c43c23 100644 --- a/desktop/src/App.css +++ b/desktop/src/App.css @@ -3023,6 +3023,39 @@ input:not([type]) { box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15); } +.observations-overview-map-cluster { + display: grid; + place-items: center; + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + box-shadow: + 0 1px 4px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(13, 20, 36, 0.55); +} + +.observations-overview-map-cluster span { + display: grid; + width: calc(100% - 6px); + height: calc(100% - 6px); + place-items: center; + border-radius: inherit; + background: #243a73; + color: #fff; + font-size: 0.75rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.45); +} + +.observations-overview-map-cluster--medium span { + font-size: 0.8rem; +} + +.observations-overview-map-cluster--large span { + font-size: 0.85rem; +} + .observations-overview-totals-row th, .observations-overview-totals-row td { font-weight: 600; diff --git a/desktop/src/components/CustomAppEmbed.tsx b/desktop/src/components/CustomAppEmbed.tsx index 9672958fd..48ec79a00 100644 --- a/desktop/src/components/CustomAppEmbed.tsx +++ b/desktop/src/components/CustomAppEmbed.tsx @@ -16,6 +16,8 @@ import { } from '../lib/rewriteEmbeddedBundleHtml'; import { tauriClient } from '../lib/tauriClient'; import { WORKSPACE_BUNDLE_DEV_APP_INDEX } from '../lib/workspacePaths'; +import { buildProfileStorageInjection } from '../lib/profileStorageInjection'; +import { useCustodianStore } from '../store/useCustodianStore'; /** Matches Formulus: custom app entry under the extracted bundle (see `HomeScreen.tsx`). */ export const CUSTOM_APP_BUNDLE_INDEX_REL = 'bundles/active/app/index.html'; @@ -114,6 +116,8 @@ export const CustomAppEmbed = forwardRef< ref, ) { const innerRef = useRef(null); + const profileId = useCustodianStore(s => s.activeProfileId); + const mountGeneration = useRef(0); const onContentWindowReadyRef = useRef(onContentWindowReady); onContentWindowReadyRef.current = onContentWindowReady; const setRefs = useCallback( @@ -138,9 +142,16 @@ export const CustomAppEmbed = forwardRef< if (!el) { return; } + const generation = ++mountGeneration.current; + const isCurrent = () => generation === mountGeneration.current; setLoading(true); setError(null); try { + const settings = await tauriClient.getSettings(); + if (!isCurrent()) return; + if (settings.activeProfileId !== profileId) + throw new Error('Active profile changed while loading custom app'); + const profileStub = buildProfileStorageInjection(settings); const workspace = await tauriClient.getWorkspace(); if (!workspace) { throw new Error('No workspace configured for the active profile.'); @@ -160,10 +171,12 @@ export const CustomAppEmbed = forwardRef< const baseHref = appDirAssetUrl.endsWith('/') ? appDirAssetUrl : `${appDirAssetUrl}/`; - const stub = buildHostStub(devicePixelRatio); + if (!isCurrent()) return; + const stub = profileStub + buildHostStub(devicePixelRatio); const doc = injectIntoHead(html, stub, baseHref); const enc = new TextEncoder(); await tauriClient.writeWorkspaceFile(indexRel, enc.encode(doc)); + if (!isCurrent()) return; // Query busts document cache. Do not use a `#fragment` here: many SPAs use the // hash for routing (HashRouter or path), so `#ode-…` would break the initial route. const url = `${indexAssetUrl}?ode=${Date.now()}`; @@ -173,13 +186,17 @@ export const CustomAppEmbed = forwardRef< }; el.src = url; } catch (e) { + if (!isCurrent()) return; setError(e instanceof Error ? e.message : String(e)); setLoading(false); } - }, [indexRel, mode, devicePixelRatio]); + }, [indexRel, mode, devicePixelRatio, profileId]); useEffect(() => { void mountBlob(); + return () => { + mountGeneration.current++; + }; }, [mountKey, mountBlob]); const defaultLoadingLabel = @@ -195,6 +212,7 @@ export const CustomAppEmbed = forwardRef<

{loadingLabel ?? defaultLoadingLabel}

) : null}