# UbuntuPlay Translation Exchange Standard — `uplay-translations` v1

**Status:** MANDATORY platform standard (since v10.40.0)
**Applies to:** every official game, the shared UI catalog, and any tool that
moves translatable strings in or out of an UbuntuPlay classroom server.

This document defines the ONE canonical way translation work travels:

- **Download** the strings that need translation (or all of them) as **CSV**
  (for Excel / Google Sheets / human translators) or **JSON** (for scripts
  and tools).
- **Upload** translated pairs back. Every row is **auto-mapped to its game
  and exact string instance by its catalog key** — no manual mapping, no
  extra steps, no post-processing.

---

## 1. The mapping contract (why no extra steps are needed)

Every translatable string on the platform lives in the string catalog
(`/locales/catalog.json`, built by `scripts/build-locale-packs.mjs`) under a
**catalog key**:

```
<area>.<instance>
```

- `<area>` is the game id (`assegai`, `chenjela`, `madzi`, `mankwala`,
  `nzelu`, `rangi`) or `common` for shared UI strings.
- `<instance>` is the stable slot inside that area, and may itself be
  dotted (`rangi.hud.score`, `nzelu.ai.theme_cancel`).
- Keys are lowercase `[a-z0-9_.]` and ALWAYS carry the area prefix.

The key is the whole mapping. A file that says
`"key": "rangi.hud.score", "translation": "Amaki"` needs nothing else —
the system knows the game (`rangi`), the instance (`hud.score`), and the
English source it belongs to.

### The standard for all games (string registration)

A game's content is translatable **by construction** when it follows the
platform i18n convention (see `/docs/dev/i18n` on the Hub):

1. Register every user-facing string in `UbuntuI18n.defaultStrings` under
   `<gameId>.<instance>` keys — either in `classroom/public/js/i18n.js`
   (shared strings) or in the game HTML via
   `Object.assign(UbuntuI18n.defaultStrings, { ... })`.
2. Render through `data-i18n` attributes or `UbuntuI18n.t('<key>')`.

Nothing else is required. The pack builder extracts registered strings into
the catalog automatically; the Translation Editor, the exchange
export/import endpoints, the locale packs and the Hub community pipeline
all pick them up from there. `tests/locale_packs_test.js` fails when the
shipped catalog drifts from the registered strings, and
`tests/translation_exchange_test.js` fails when a catalog key has no valid
area prefix — so a string that skips the convention cannot silently ship.

---

## 2. Endpoints

Both endpoints live on the classroom server and use teacher auth
(`Authorization: Bearer <teacher JWT>`).

| Method | Route | Purpose |
|---|---|---|
| GET | `/api/teacher/translations/:lang/export` | Download strings as JSON or CSV |
| POST | `/api/teacher/translations/:lang/import` | Upload translated pairs |

`:lang` is one of `bem`, `nya`, `ton`, `loz`.

### Export query parameters

| Param | Values | Default | Meaning |
|---|---|---|---|
| `format` | `json` \| `csv` | `json` | File format |
| `scope` | `untranslated` \| `machine` \| `fixes` \| `all` | `untranslated` | Which strings to include |
| `game` | `common`, `rangi`, … | *(all)* | Limit to one area prefix |

Scope meanings:

- `untranslated` — strings with NO translation at all (not in the shipped
  pack, no community entry, no teacher fix). **This is the "needs
  translation" download.**
- `machine` — strings whose current translation is machine-made (may need
  human review).
- `fixes` — this school's saved teacher overrides only.
- `all` — the full catalog, whatever the status.

### Import request body

`Content-Type: application/json`. The body is EITHER a standard JSON
document (§3), a legacy document (§6), or a CSV file wrapped as
`{ "csv": "<raw csv text>" }`. Add `"dryRun": true` (or `?dryRun=1`) to
validate without saving — the response reports exactly what WOULD happen.

Uploaded translations are treated as **human translations**: they are saved
as teacher overrides, they beat machine text immediately for every learner
on this server, and (when community sharing is enabled) they join the Hub
corpus. Only structural validation applies — empty values, changed
`{placeholder}` sets, values over 1000 chars and mojibake are refused
per-row; everything else is the human's call.

---

## 3. JSON document shape

```json
{
  "format": "uplay-translations",
  "v": 1,
  "lang": "bem",
  "langName": "Bemba",
  "generator": "UbuntuPlay Classroom v10.40.0",
  "exportedAt": "2026-08-09T12:00:00.000Z",
  "scope": "untranslated",
  "catalog": 342,
  "count": 2,
  "strings": [
    {
      "key": "rangi.hud.score",
      "game": "rangi",
      "en": "Score",
      "translation": "",
      "status": "untranslated"
    },
    {
      "key": "nzelu.ai.progress",
      "game": "nzelu",
      "en": "Creating puzzle {n} of {total}...",
      "translation": "",
      "status": "untranslated",
      "placeholders": ["{n}", "{total}"]
    }
  ]
}
```

Field semantics:

| Field | Export | Import |
|---|---|---|
| `key` | always present | **primary mapping** — must match a catalog key |
| `game` | derived from the key prefix | ignored (derived) |
| `en` | English source text | optional **drift guard** — when present it must equal the live catalog source, else the row is skipped as `stale` |
| `translation` (alias `t`) | current value or `""` | the translated text; empty rows are skipped (imports never delete) |
| `status` | `untranslated` \| `machine` \| `human` \| `community` \| `override` \| `verbatim` | ignored |
| `placeholders` | `{token}` list when the source has any | ignored (validated against the source) |

`verbatim` rows are deliberate proper nouns (game/brand names such as
RANGI or GOGO) served as-is — **do not translate them**; they are excluded
from the `untranslated` scope. `human` rows are human translations folded
into the shipped pack itself (translator deliveries applied at build time —
§7); they never appear in the `machine` scope, so a review download only
ever contains text that is genuinely machine-made.

Rows with **no `key`** but a matching `en` are mapped by exact English
source text and fan out to EVERY catalog key sharing that source (the
runtime itself resolves translations by source text, so one translation of
"Try Again!" legitimately covers all its instances). Rows with neither a
known key nor a matching source are skipped as `unknown`.

## 4. CSV document shape

UTF-8 (BOM optional on upload, always present on download), RFC 4180
quoting (quotes doubled; fields with commas/quotes/newlines are quoted).
**The header row is required** on upload; columns are matched by name,
extra columns are ignored, order does not matter.

```csv
key,game,en,translation,status,placeholders
rangi.hud.score,rangi,Score,,untranslated,
nzelu.ai.progress,nzelu,"Creating puzzle {n} of {total}...",,untranslated,{n} {total}
```

Recognised header names (case-insensitive): `key`; `en` (aliases
`english`, `source`); `translation` (aliases `t`, `target`, `value`, or
the language code/name of the file's language). A minimal upload needs
only `key,translation` — or even `en,translation` (source-text mapping,
§3).

**Formula safety:** downloaded cells that would start with `=`, `+`, `-`,
`@` or a tab are prefixed with a single space so spreadsheet apps never
execute them. All imported values are trimmed, so the guard round-trips
losslessly. Newlines inside uploaded values (wrapped spreadsheet cells)
normalize to single spaces — UI strings are single-line, and the community
corpus must stay line-safe.

## 5. Import response

```json
{
  "ok": true,
  "lang": "bem",
  "dryRun": false,
  "detected": "uplay-translations",
  "totalRows": 342,
  "imported": 38,
  "unchanged": 2,
  "empty": 300,
  "skipped": [
    { "row": 7, "key": "rangi.hud.scor", "reason": "unknown" },
    { "row": 19, "key": "madzi.quiz_title", "reason": "stale" }
  ],
  "overrides": 51,
  "sharing": true
}
```

Rows left untranslated are the NORM in a translator's working file — they
are counted in `empty`, never treated as errors, and never deleted
anything (imports never remove existing translations). Skip reasons:
`unknown` (no such key / source), `stale` (English source drifted),
`invalid` (placeholder set changed, mojibake, or over 1000 chars),
`duplicate` (same key appears again later in the file — first occurrence
wins).

## 6. Legacy formats (accepted on import forever)

- `uplay-translation-fixes` v1 — the pre-v10.40.0 Translation Editor
  export: `{ "kind": "uplay-translation-fixes", "lang": "bem",
  "fixes": { "<key>": { "en": "...", "t": "..." } } }`.
- A bare key→translation JSON map under `strings`:
  `{ "strings": { "rangi.hud.score": "Amaki" } }`.

## 7. Where the files flow after import

Imported pairs are saved to this school's override file
(`data/locale-overrides/<lang>.json`) exactly as if typed in the
Translation Editor: they render for every learner immediately (override >
community > shipped pack), they are pushed to the Hub community corpus on
the next sync (opt-out: Settings → Community Translation Sharing), and the
next locale-pack build can fold them into the packs every school ships.

Completed deliveries can also be mapped straight into the **built-in packs**
at authoring time:

```
node scripts/build-locale-packs.mjs --from-exchange <fileOrDir> [--no-mt]
```

`--from-exchange` (repeatable) accepts any file in this standard (CSV or
JSON, language detected from the document's `lang` field or the exported
filename) and applies exactly the import semantics above — unknown / stale /
invalid / duplicate rows are skipped per-row with reasons, keyless rows fan
out by English source, values are trimmed and newline-normalized. Applied
rows are **human translations**: they are emitted into the shipped pack
with `src: "override"`, outrank every machine source, survive later
rebuilds via the carry path, are exempt from the machine junk screens, and
export as status `human`. This is how bulk translator deliveries become
the packs every school receives.

## 8. Version history

- **v1 (platform v10.40.0)** — initial standard: JSON + CSV shapes, key /
  source-text mapping, scopes, dry-run, legacy format acceptance.
- **v1 clarification (2026-08-10)** — export `status` gains the `human`
  value for human translations shipped inside the built-in packs
  (`--from-exchange` deliveries); `status` remains ignored on import, so
  every v1 consumer keeps working unchanged.
