Internationalization (i18n) =========================== This chapter covers how CARE localizes UI text: frontend and backend patterns, and how to add a new language. Where files live ---------------- All translation keys live under ``utils/modules/i18n/``: * ``en/*.json``, ``de/*.json`` — key catalogs per UI language (one file = one namespace, e.g. ``users.json``) * ``en/index.js``, ``de/index.js`` — import JSON files and bundle namespaces for the frontend * ``i18n-bundles.js`` — bundles ``en`` / ``de`` namespaces for ``vue-i18n`` Related code: * ``frontend/src/assets/utils.js`` — ``resolveApiMessage``, ``translateMaybeKey``, ``formatLocalized*`` * ``frontend/src/assets/locale.js`` — language switcher, ``app.locale`` * ``backend/utils/i18n.js`` — ``translateMaybeKey``, ``hasKey`` (backend runtime and migrations) Translation guidelines ---------------------- Do **not** translate ~~~~~~~~~~~~~~~~~~~~ * **Table content** — study titles, document names, workflow names, etc. * **Server logs** — dashboard log table and log files are **always English** (see :ref:`logging-i18n` below) * Developer ``console.log`` / debug output * **Template body content** — multi-language content, not UI strings How to translate ---------------- .. code-block:: text ┌─────────────────────────┐ │ What do you │ │ need? │ └───────────┬─────────────┘ ┌─────────────────┴─────────────────┐ │ │ New UI language UI text │ │ ▼ ▼ ┌──────────────────────┐ ┌──────────────────┐ │ 5. Adding a new UI │ │ 1. Adding a key │ │ language │ │ & │ │ │ │ 2. Adding a │ └──────────┬───────────┘ │ namespace │ │ └────────┬─────────┘ │ │ │ ┌──────────────────┘ │ ▼ │ ┌─────────────────────────┐ │ │ Frontend or │ │ │ backend? │ │ └───────────┬─────────────┘ │ ┌───────┴───────┐ │ ▼ ▼ │ 3. Frontend 4. Backend │ │ │ └─────────┴───────┬───────┘ ▼ ┌─────────────────────────┐ │ 6. Test it out │ └─────────────────────────┘ Sections in this chapter : | :ref:`i18n-add-key` — new key in an existing JSON file | :ref:`i18n-add-namespace` — new ``*.json`` namespace file | :ref:`i18n-frontend` — ``$t``, ``resolveApiMessage``, table columns, … | :ref:`i18n-backend` — ``TranslatableError``, backend ``translateMaybeKey``, … | :ref:`i18n-add-language` — new UI language | :ref:`i18n-test` — verify in the UI and logs | :ref:`i18n-linter` — automated i18n checks .. _i18n-add-key: 1. Adding a key --------------- .. note:: Need a **new JSON file** for a dashboard or feature? See :ref:`i18n-add-namespace`. **Example:** you have a short hint for users in ``Users.vue`` — hardcoded English in a ``
``: .. code-block:: html
Manage team members from this page.
To show it in the user's UI language, replace the literal text with ``$t(...)``. That requires a **key** in the locale JSON files first. **1. Choose a key name** Use dot notation: ``namespace.rest.of.key``. The first segment is the JSON filename (namespace), e.g. ``users.labels.manageHint`` → file ``users.json``, nested path ``labels.manageHint``. **2. Add the key in every active locale** ``utils/modules/i18n/en/users.json``: .. code-block:: json { "labels": { "manageHint": "Manage team members from this page." } } ``utils/modules/i18n/de/users.json`` — same key, translated value: .. code-block:: json { "labels": { "manageHint": "Verwalten Sie Teammitglieder auf dieser Seite." } } Repeat for every locale folder (``en/``, ``de/``, …). **Keys must match** across locales; only the text values differ. **3. Use the key in the template** .. code-block:: html{{ $t('users.labels.manageHint') }}
When the user switches language in **Preferences**, the paragraph updates automatically. More frontend patterns (placeholders, toasts, table headers, …): :ref:`i18n-frontend`. Backend errors and logging use keys too: :ref:`i18n-backend`. Key layout inside a JSON file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Organize keys by **screen or feature**. Group related strings under clear subsections: .. code-block:: json { "workflows": { "title": "Workflows", "labels": { "addWorkflow": "Add Workflow" }, "errors": { "importFailed": "Import failed" } } } Useful subsections: * ``title`` — page or modal title * ``labels`` — field labels, short UI phrases, hints * ``errors`` — validation or action errors for that screen * ``columns`` — table header labels * ``toasts`` — toast title/message pairs * ``messages`` — confirm dialogs, longer text Reuse ``common`` for words that appear everywhere. Before adding a new key, check ``utils/modules/i18n/en/common.json`` — shared labels such as ``id``, ``email``, ``save``, ``cancel``. Backend error keys usually go in ``errors.json`` (see :ref:`i18n-backend`). .. _i18n-add-namespace: 2. Adding a new namespace ------------------------- Each ``*.json`` file in a locale folder is one **namespace**. The filename (without ``.json``) becomes the first part of every key inside it. Example: ``reports.json`` with ``"title": "Reports"`` → use ``$t('reports.title')``. **1. Create the file in every active locale** (same keys, translated values): .. code-block:: text utils/modules/i18n/en/reports.json utils/modules/i18n/de/reports.json .. code-block:: json { "title": "Reports", "labels": { "export": "Export" } } **2. Register in** ``index.js`` **for each locale** (``en/index.js``, ``de/index.js``, …) — required for the frontend: .. code-block:: javascript import reports from './reports.json' export default { // ...existing namespaces... reports, } **3. Use the keys** in Vue components: .. code-block:: htmlManage team members from this page.
{{ $t('users.labels.manageHint') }}
Button or attribute label ~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: htmlHello, {{ userName }}!
{{ $t('greeting.hello', { name: userName }) }}
Lists of strings ($tm) ~~~~~~~~~~~~~~~~~~~~~~ When a key holds an **array** of messages (e.g. rotating status text), use ``$tm``: .. code-block:: json { "loading": { "messages": [ "Thinking through your request...", "Almost there..." ] } } .. code-block:: javascript data() { return { messages: this.$tm('loading.messages'), index: 0, }; } .. _i18n-t-rich-text: Rich text in translations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When a sentence needs **inline markup** (:code:``, :code:`…
` * - :code:`tag="span"` - inline wrapper → :code:`…` * - :code:`tag="div"` - block wrapper → :code:`Please check the format or download the template here.
Named slots ~~~~~~~~~~~ Use when placeholders in the JSON have **names** (:code:`{newCount}`, :code:`{br}`, :code:`{dupCount}`). Each name maps to a :code:`` block with the same name (without :code:`#` in the JSON). **JSON:** .. code-block:: json { "bulkImportConfirm": "Are you sure you want to bulk create {newCount} users {br} and overwrite {dupCount} users?" } **Vue** — slot name :code:`newCount` fills :code:`{newCount}`, :code:`br` fills :code:`{br}`, etc.: .. code-block:: vue
Are you sure you want to bulk create 3 users
and overwrite 1 users?
Please check the format or download the template here.
**Two slots** — same idea with :code:`{0}` and :code:`{1}`: **JSON:** .. code-block:: json { "openDocs": "Read the {0} or watch the {1}." } **Vue** — first child → :code:`{0}`, second child → :code:`{1}`: .. code-block:: vueRead the documentation or watch the tutorial video.
**Named vs indexed — quick pick:** .. list-table:: :header-rows: 1 :widths: 30 35 35 * - JSON placeholder - Vue slot - Typical use * - :code:`{userName}`, :code:`{br}` - :code:``, :code:`` - Many mixed parts; clear names for translators * - :code:`{0}`, :code:`{1}` - 1st / 2nd default child (or :code:``) - One or two inline links in a short hint HTML inside a translation (v-html) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Use :code:`v-html` **only when** :code:`i18n-t` is impractical and the string is **fully trusted** — markup comes from your locale JSON, and interpolated values are **not** raw user input, API text, or database names. If a phrase needs both markup and dynamic parts, **prefer** :ref:`i18n-t-rich-text` above. .. code-block:: json { "declinedSharingWarning": "You have selected students who didn't accept data sharing." } .. code-block:: html .. danger:: Use :code:`v-html` **only** for trusted translation strings from your locale JSON — **never** for raw user input, API data, or database values. Socket/API errors (resolveApiMessage) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **File:** ``frontend/src/assets/utils.js`` Use for every toast, modal, or inline message that comes from a socket callback or HTTP response. .. code-block:: javascript import { resolveApiMessage } from "@/assets/utils"; // Before — shows English from the server, ignores user locale: this.$socket.emit("userCreate", payload, (response) => { if (!response.success) { this.eventBus.emit("toast", { title: "Error", message: response.message, variant: "danger", }); } }); // After — translates response.key (+ params) in the user's locale: this.$socket.emit("userCreate", payload, (response) => { if (!response.success) { this.eventBus.emit("toast", { title: this.$t("errors.users.userCreationFailed"), message: resolveApiMessage(response), variant: "danger", }); } }); ``resolveApiMessage`` checks ``response.key`` first, then falls back to ``response.message`` (plain text or a key string). Optional second argument — fallback key if the response is empty: .. code-block:: javascript resolveApiMessage(response, "errors.server.unknownError"); DB values that may be a key or plain text (translateMaybeKey) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. _i18n-translate-maybe-key: **File:** ``frontend/src/assets/utils.js`` Some values from the database are stored as an **i18n key** (e.g. from a migration), others as **plain text** the user typed (e.g. a custom workflow name). Use ``translateMaybeKey`` when you do not know which case you have: * Known key → translated with ``$t`` * Not a key → shown as-is **1. Import** from ``@/assets/utils``: .. code-block:: javascript import { translateMaybeKey } from "@/assets/utils"; **2. Expose in the component** (so the template can call it): .. code-block:: javascript export default { methods: { translateMaybeKey, }, }; **3. Use in the template or script:** .. code-block:: html{{ translateMaybeKey(workflow.name) }}
.. code-block:: javascript computed: { displayName() { return translateMaybeKey(this.workflow.name); }, }, Example values for ``workflow.name``: .. code-block:: text workflow.dashboard.title → translated (key in JSON) My custom workflow → shown unchanged (user text) Table columns ~~~~~~~~~~~~~~~~~~~~~~~~ Define ``columns`` in a ``computed`` property so headers update when the user switches language. .. code-block:: javascript // Before — headers stay English after locale switch: data() { return { columns: [ { name: "ID", key: "id", sortable: true }, { name: "Email", key: "email" }, { name: "Roles", key: "roles" }, ], }; } // After — headers follow the active locale: computed: { columns() { return [ { name: this.$t("common.id"), key: "id", sortable: true }, { name: this.$t("common.email"), key: "email" }, { name: this.$t("users.columns.roles"), key: "roles" }, ]; }, }, Prefer ``common.*`` for generic column names; use feature keys (e.g. ``users.columns.*``) when the label is specific to that screen. Locale helpers ~~~~~~~~~~~~~~ **File:** ``frontend/src/assets/locale.js`` .. list-table:: :header-rows: 1 * - Export / constant - Purpose * - ``SUPPORTED_LOCALES`` - Languages shown in the language switcher * - ``LOCALE_SETTING_KEY`` (``"app.locale"``) - DB setting / user_setting key * - ``getLocaleFromSettings(settings)`` - Read locale from merged ``appSettings`` * - ``getStoredLocale`` / ``setStoredLocale`` - Browser storage * - ``applyLocale(i18n, code)`` - Set active ``vue-i18n`` locale Date and time formatting (formatLocalized*) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **File:** ``frontend/src/assets/utils.js`` Format **dates and times** in the user's **active UI locale** picked in **Preferences**. .. list-table:: :header-rows: 1 * - Function - Output * - ``formatLocalizedDate(value, options?)`` - Date only (e.g. ``29/05/2026`` in DE, ``5/29/2026`` in EN) * - ``formatLocalizedTime(value, options?)`` - Time only (e.g. ``14:30:00``) * - ``formatLocalizedDateTime(value, options?)`` - Date and time together **How to use** **1. Import** from ``@/assets/utils``: .. code-block:: javascript import { formatLocalizedDate, formatLocalizedDateTime } from "@/assets/utils"; **2. Expose in the component**: .. code-block:: javascript export default { methods: { formatLocalizedDate, formatLocalizedDateTime, }, }; **3. Use in the template:** .. code-block:: html {{ new Date(user.lastLoginAt).toLocaleDateString() }} {{ formatLocalizedDate(user.lastLoginAt) }} **Format while preparing table rows** (common pattern — no need to expose in :code:`methods`): .. code-block:: javascript import { formatLocalizedDate } from "@/assets/utils"; computed: { usersForTable() { return this.users.map((user) => ({ ...user, lastLoginAt: user.lastLoginAt ? formatLocalizedDate(user.lastLoginAt) : "-", })); }, }, Optional ``Intl.DateTimeFormat`` options as the second argument (:code:`options`) — same shape as for :code:`Date.prototype.toLocaleDateString`, :code:`toLocaleTimeString`, and :code:`toLocaleString`: .. code-block:: javascript formatLocalizedDate(session.start, { year: "numeric", month: "short", day: "numeric" }); formatLocalizedTime(edit.createdAt, { hour: "2-digit", minute: "2-digit" }); .. _i18n-backend: 4. Backend ---------- Throwing errors ~~~~~~~~~~~~~~~ Backend responses support **two formats** — both are valid: * **Preferred:** ``{ success: false, key: "errors.auth.invalidCredentials", params: { ... } }`` * **Legacy:** ``{ success: false, message: "Plain text or an i18n key string" }`` The frontend ``resolveApiMessage`` handles both. New code should prefer ``key`` + optional ``params``. User-facing error in a handler ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: javascript const TranslatableError = require("../utils/TranslatableError"); // Before — hardcoded English, wrong language in the UI: if (!user) { throw new Error("User not found."); } // After — key + params; frontend shows the translated message: if (!user) { throw new TranslatableError("errors.users.notFound", { id: userId }); } // JSON (errors.json): "notFound": "User with id {id} was not found." TranslatableError ~~~~~~~~~~~~~~~~~ **File:** ``backend/utils/TranslatableError.js`` When something goes wrong in a way the **user** should see (wrong password, missing permission, validation failed), throw a **TranslatableError** instead of a hardcoded English sentence. **Goal:** pass an i18n **key** (and optional **params** / **code**) from the backend to the frontend. The browser translates with ``resolveApiMessage``. Logging and legacy English ``message`` in socket callbacks are handled in ``Socket.js`` — see :ref:`logging-i18n`. **Why a dedicated class**: * **Structured payload** — carries the i18n **key**, optional **params** for placeholders (e.g. ``{ validationLimit: 10 }``), and an optional machine-readable **code``. A plain ``Error`` only has ``message``; bolting ``params`` / ``code`` onto arbitrary errors is fragile in catch handlers. * **Security boundary** — in ``Socket.js``, only errors with a known i18n key (``TranslatableError``, ``generateError``, or legacy ``Error("errors.*")``) become ``{ key, params, code }`` in the client callback. Anything else (DB failures, bugs, plain English ``Error``) is logged in full and mapped to ``errors.server.unexpectedError`` for the user. **Rule of thumb:** user-facing → ``TranslatableError`` or ``generateError``; internal / unexpected → plain ``Error`` (not for UI text). Patterns: .. list-table:: :header-rows: 1 :widths: 35 65 * - Pattern - When to use * - ``throw new TranslatableError("errors.namespace.key")`` - error, key only * - ``throw new TranslatableError("errors.namespace.key", { name })`` - error with ``{placeholder}`` values * - ``throw new TranslatableError("errors.namespace.key", { count: 5 }, "SOME_CODE")`` - key + params + machine-readable ``code`` * - ``throw generateError("SOME_CODE", "errors.namespace.key")`` - machine ``code`` + key, **no** params * - ``throw new Error("...")`` - **Internal only** — not for user-facing UI text **Files:** ``backend/utils/TranslatableError.js``, ``backend/utils/generic.js`` (``generateError``) Simple examples: .. code-block:: javascript // 1. Key only throw new TranslatableError("errors.auth.invalidCredentials"); // 2. Key + params throw new TranslatableError("errors.users.notFound", { id: userId }); // 3. Key + params + code (frontend may check response.code) throw new TranslatableError( "errors.assignment.unableToAssignEnoughDocuments", { roleName: "Reviewer", count: 5 }, "ASSIGNMENT_FAILED" ); // 4. Code + key, no params throw generateError("DOCUMENT_NOT_FOUND", "errors.documents.doesNotExistOrDeleted"); Socket callback payload ~~~~~~~~~~~~~~~~~~~~~~~ Most socket handlers are registered with ``createSocket`` in ``Socket.js``. On **success**, the callback receives ``{ success: true, data: result }``. On **failure**, prefer **throwing** an error — the shared catch block builds the callback for you: .. code-block:: javascript // Handler (backend) — throw; do not call callback yourself if (!user) { throw new TranslatableError("errors.users.notFound", { id: userId }); } // What the frontend receives in (response) => { ... }: { success: false, key: "errors.users.notFound", params: { id: userId }, message: "User with id 123 was not found.", // English fallback (translateMaybeKey) code: "..." // optional, when set on the error } The frontend should display errors with ``resolveApiMessage(response)`` — it translates ``key`` + ``params`` to the user's locale. The ``message`` field is a legacy English fallback; new UI code should not read it directly. **Manual responses** (validation results, per-row errors in ``data``, direct ``socket.emit``) may still use the legacy shape without a separate ``key`` field — put the i18n key in ``message`` instead: .. code-block:: javascript // Legacy — still works; resolveApiMessage treats message as a key when it exists in JSON { success: false, message: "errors.validation.additionalFilesNotAllowed", params: { files: "foo.txt" } } // Preferred when you build the object yourself — same as the hub format { success: false, key: "errors.validation.additionalFilesNotAllowed", params: { files: "foo.txt" } } **Frontend:** .. code-block:: javascript this.$socket.emit("userCreate", payload, (response) => { if (!response.success) { this.eventBus.emit("toast", { title: this.$t("errors.users.userCreationFailed"), message: resolveApiMessage(response), variant: "danger", }); } }); .. _logging-i18n: Backend English output (translateMaybeKey) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **File:** ``backend/utils/i18n.js`` Same idea as frontend :ref:`translateMaybeKey