Skip to main content

Internationalisation (i18n)

OpenRegister ships first-class internationalisation across the object model, the REST API, and the rendered HTTP responses. This document describes the two pillars introduced by hydra ADR-025:

  1. Source-of-truth — declaring which language is canonical for a translatable property, projecting that into the sidecar, and flipping derived translations to outdated whenever the source value changes.
  2. API language negotiation — explicit per-request language overrides via query parameters and the write-side X-Translation-Target-Language header.

See also: openspec/specs/register-i18n/spec.md, openspec/changes/i18n-source-of-truth/, openspec/changes/i18n-api-language-negotiation/.

Schema declaration

Translatable properties carry translatable: true. They may also declare a canonical source language via the sourceLanguage modifier:

{
"type": "object",
"properties": {
"title": {
"type": "string",
"translatable": true,
"sourceLanguage": "nl"
},
"categoryCode": {
"type": "string"
}
}
}
  • sourceLanguage is OPTIONAL. If omitted, OR uses Register.defaultLanguage (the first element of Register.languages, falling back to 'nl').
  • sourceLanguage MUST NOT appear on properties without translatable: true; the validator rejects this with a 400.
  • The value MUST match basic BCP-47 syntax (/^[a-z]{2,3}(-[a-zA-Z0-9]{2,8})*$/).

Object-level source override

Individual objects may override the schema's source-language for a single property by writing to _translationMeta.<property>.sourceLanguage:

{
"id": "obj-uuid",
"title": {"nl": "Welkom", "en": "Welcome"},
"_translationMeta": {
"title": {
"sourceLanguage": "en"
}
}
}

Override precedence (highest first):

  1. object body _translationMeta.<property>.sourceLanguage
  2. schema property sourceLanguage
  3. Register.defaultLanguage
  4. 'nl' (hardcoded final fallback)

Translation projection + status

The openregister_translations sidecar carries one row per (object_uuid, property, language) tuple and now also tracks source_language. The projection service writes that column on every upsert; the Translation entity exposes it via getSourceLanguage() and embeds it (alongside an isSource flag) in jsonSerialize().

When the canonical source-language value for a property changes, SaveObject invokes TranslationStatusService::markDerivedTranslationsOutdated($uuid, $property, $sourceLanguage), which flips every other-language row for that property from approved / human_reviewed / machine_translated to outdated. Already-outdated and already-draft rows are not re-flipped.

To back-fill source_language on data created before the migration ran:

docker exec nextcloud php occ openregister:translations:backfill-source-language
# optional flags
docker exec nextcloud php occ openregister:translations:backfill-source-language \
--batch-size=500 --dry-run

The command is idempotent: re-running it once every row has a non-empty source_language reports 0 rows updated.

Request-side language negotiation

The middleware honours, in priority order:

PrioritySourceNotes
1?_lang=<bcp47>Canonical per-request override
2?language=<bcp47>Alias for ?_lang=
3Accept-Language: <RFC 9110>Existing browser-default fallback
4Register.defaultLanguageResolved against the register's languages list
5'nl'Hardcoded final fallback

Invalid BCP-47 tags at any step are logged and fall through; no 400 is ever emitted for a malformed language parameter.

LanguageService::getRequestedLanguageSource() returns 'query' | 'header' | 'default', useful for telemetry and the X-Source-Language response setter.

Write-side X-Translation-Target-Language

Translators can edit a single language without sending the full language-keyed body by adding the header on POST/PUT/PATCH:

PATCH /api/objects/r/s/uuid HTTP/1.1
Content-Type: application/json
X-Translation-Target-Language: en

{"title": "Welcome"}

The handler wraps the scalar value under the target language, persisting title.en = "Welcome" without touching title.nl. Sending a full language-keyed body PLUS the header is a conflict and returns 400 Bad Request with body:

{
"error": {
"code": "TRANSLATION_TARGET_CONFLICT",
"property": "title",
"targetLanguage": "en"
}
}

Translation metadata envelope

When ?_translationMeta=true is on the request, every translatable property in the response gets a sibling entry under _meta.languageMeta.<property>:

{
"id": "obj-uuid",
"title": "Welcome",
"_meta": {
"languageMeta": {
"title": {
"served": "en",
"sourceLanguage": "nl",
"isSource": false,
"status": "approved"
}
}
}
}

Use cases:

  • A translation UI rendering "Original (Dutch)" vs "Translation (out of date)" badges.
  • A reviewer dashboard summarising freshness ratios.

The envelope is OFF by default (no payload bloat for clients that don't need it).

Response headers

HeaderWhenExample
Content-LanguageAlwaysen
X-Content-Language-FallbackFallback chain usedtrue
X-Source-LanguageSingle-object contentnl

X-Source-Language reflects the dominant source language across the object's translatable properties (or the only source language when there is exactly one).

Translation search filters

GET /api/translations/search accepts the standard ?query=, ?language=, ?status=, ?objectUuid= filters plus the new:

  • ?sourceLanguage=<bcp47> — restrict to rows whose canonical source language matches the given tag.
  • ?isOutOfDate=true — restrict to rows in outdated status.
  • ?compareToSource=true — return both value and sourceValue side-by-side per matched row.

Client snippets

curl

# Read in English; declare the explicit override even though the browser sends Dutch.
curl -H "Accept-Language: nl" \
"https://or.example/api/objects/r/s/uuid?_lang=en"

# Edit only the German translation of `title` on a single object.
curl -X PATCH \
-H "Content-Type: application/json" \
-H "X-Translation-Target-Language: de" \
-d '{"title": "Willkommen"}' \
"https://or.example/api/objects/r/s/uuid"

axios

// Read.
await axios.get(`/api/objects/${r}/${s}/${id}`, { params: { _lang: 'en' } });

// Write the French translation.
await axios.patch(
`/api/objects/${r}/${s}/${id}`,
{ title: 'Bienvenue' },
{ headers: { 'X-Translation-Target-Language': 'fr' } }
);

Postman

Add _lang to the Params tab for reads. For writes, add a header X-Translation-Target-Language: <bcp47> and send a scalar body.

Hydra references

  • ADR-025 (parent decision) — hydra/openspec/architecture/adr-025-i18n-source-of-truth.md.
  • openspec/changes/i18n-source-of-truth/ — source-of-truth half.
  • openspec/changes/i18n-api-language-negotiation/ — API negotiation half.