Skip to content

Commit 4e8ea7a

Browse files
Fidesnoellairumvanselme
authored andcommitted
feat(i18n): centralize translations in Google Sheets and document workflow
1 parent 30ba13e commit 4e8ea7a

25 files changed

Lines changed: 449 additions & 176 deletions

File tree

add-a-new-language.md

Lines changed: 51 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -2,103 +2,78 @@
22

33
This guide is divided into two main parts:
44

5-
1. **[Adding a New Language](#adding-a-new-language)**: Instructions for introducing a new locale across the backend and frontend.
5+
1. **[Adding a New Language](#adding-a-new-language)**: Instructions for introducing a new locale using the automated Google Sheets workflow.
66
2. **[Switching or Using Supported Languages](#switching-or-using-supported-languages)** : Guidance for configuring or switching between languages that are already supported.
77

88
## Adding a New Language
99

10-
This section shows how to introduce a new locale end-to-end across the backend and the frontend. It includes what files to add, the expected templates, which configs to update, and how to verify everything.
10+
Compass uses an automated translation workflow based on Google Sheets. This allows non-technical users to add translations from a familiar spreadsheet, while developers simply run an import script to automatically apply them to the codebase.
1111

12-
Locale identifiers follow [IETF BCP 47](https://www.ietf.org/rfc/bcp/bcp47.txt) (e.g., `en-GB`, `es-AR`). Keep language in lowercase and region in uppercase. Keep codes consistent across config and folders.
12+
Locale identifiers must follow [IETF BCP 47](https://www.ietf.org/rfc/bcp/bcp47.txt) (e.g., `en-GB`, `es-AR`, `fr-FR`). Keep language codes in lowercase and region codes in uppercase.
1313

14-
Notes
14+
### 1. For Translators (Non-Technical)
1515

16-
* Use BCP‑47 language tags (e.g., `en-GB`, `en-US`, `es-ES`, `es-AR`). Keep codes consistent across config and folders.
17-
* Backend uses locale directories under `backend/app/i18n/locales/<locale>/` with domain JSON files (e.g., `messages.json`).
18-
* Frontend-new uses `frontend-new/src/i18n/locales/<locale>/translation.json` and maps them in [`frontend-new/src/i18n/i18n.ts`](frontend-new/src/i18n/i18n.ts).
19-
* Supported languages are enabled via environment variables on both backend and frontend.
16+
1. **Open the Compass Translation Sheet:** Access the shared Google Sheet provided by the development team.
17+
2. **Add a New Language Column:** Find the first empty column header after the existing language codes. Type the BCP-47 language code for your new language (e.g., `fr-FR` for French) and press Enter.
18+
> **Note:** Do NOT modify the `Platform` or `Key` columns, as these are strict technical identifiers required for the application to function and **must only be edited by developers or other authorised maintainers**.
19+
3. **Fill in the Translations:** Scroll through the rows. Use the English (`en-GB` or `en-US`) columns as a reference. Type your translated text into your new column. If you leave a cell blank, the application will automatically fall back to English for that specific text.
20+
4. **Notify the Team:** Once translations are complete, notify the development team to run the import script.
2021

21-
### Prerequisites
22+
### 2. For Developers (Technical)
2223

23-
* Decide the language code(s) you want to support (e.g., `es-AR`).
24-
* Pick a reference language to copy from (English is recommended):
25-
- Backend reference: [`backend/app/i18n/locales/en-US/messages.json`](backend/app/i18n/locales/en-US/messages.json)
26-
- Frontend-new reference: [`frontend-new/src/i18n/locales/en-GB/translation.json`](frontend-new/src/i18n/locales/en-GB/translation.json)
24+
Once translations or new keys have been added to the Google Sheet, follow these steps to pull them into the codebase.
2725

28-
### 1. Backend
29-
30-
The backend uses `LocaleProvider` to determine the current locale. The default locale in `BACKEND_LANGUAGE_CONFIG` configuration is used as the default.
31-
32-
#### 1.1 Create Locale directory and messages file
33-
34-
* Create a folder for your new locale under `backend/app/i18n/locales/<locale>/` and add a `messages.json` file with the same keys as English.
35-
* Translate backend-facing strings in `backend/app/i18n/locales/<locale>/messages.json`. Use [`en-US/messages.json`](backend/app/i18n/locales/en-US/messages.json) as the reference.
36-
* Key consistency is enforced by [`backend/app/i18n/test_i18n.py`](backend/app/i18n/test_i18n.py).
37-
38-
Example
39-
```
40-
backend/app/i18n/locales/en-US/messages.json # reference
41-
backend/app/i18n/locales/es-AR/messages.json # new
42-
```
43-
For the message structure and keys, please refer to the reference file: [`backend/app/i18n/locales/en-US/messages.json`](backend/app/i18n/locales/en-US/messages.json).
26+
> **Supported platforms**
27+
>
28+
> Currently, only three platforms are powered by this workflow:
29+
> - **Frontend** (React app)
30+
> - **Backend** (Python services)
31+
> - **Feedback** (survey/questions content)
32+
>
33+
> Only rows/keys that belong to one of these platforms will be generated. If a *new* platform is ever introduced, the import script must be extended to support it before any keys for that platform will have an effect.
4434
45-
#### 1.2 Update Supported Constants and Environment Variables
35+
#### 2.1 Run the Import Script
4636

47-
- Add the locale to [`backend/app/i18n/types.py`](backend/app/i18n/types.py) (`Locale` enum and `SUPPORTED_LOCALES`).
48-
- Update [`backend/app/i18n/constants.py`](backend/app/i18n/constants.py) only if the default fallback changes.
49-
- Enable the new locale in your backend environment via `BACKEND_LANGUAGE_CONFIG`.
50-
51-
#### 1.3 Verify Backend Key Consistency
52-
53-
`I18nManager` can verify that all locales contain the same keys per domain; [`backend/app/i18n/test_i18n.py`](backend/app/i18n/test_i18n.py) enforces key parity.
54-
55-
Optional commands
37+
Navigate to the `backend` directory and run the automated import script:
5638

5739
```bash
5840
cd backend
59-
poetry run python scripts/verify_i18n_keys.py --verify
60-
```
61-
62-
### 2. Frontend
63-
64-
The frontend uses `i18next` with resources defined in locale files and mapped in [`frontend-new/src/i18n/i18n.ts`](frontend-new/src/i18n/i18n.ts).
65-
66-
#### 2.1 Create the locale folder and translation file
67-
68-
Create a folder for your new locale under `frontend-new/src/locales/<locale>/` and copy the reference `translation.json` from English (`en-GB`). Translate values, keeping all keys identical.
69-
70-
Example
41+
poetry run python scripts/import_translations_from_sheets.py
7142
```
72-
frontend-new/src/i18n/locales/en-GB/translation.json # reference
73-
frontend-new/src/i18n/locales/es-AR/translation.json # new
74-
```
75-
76-
For the translation structure and keys, please refer to the reference file: [`frontend-new/src/i18n/locales/en-GB/translation.json`](frontend-new/src/i18n/locales/en-GB/translation.json).
77-
78-
* **Add other locale files:**
79-
- Add `frontend-new/src/feedback/overallFeedback/feedbackForm/questions-<locale>.json` mirroring the existing files, e.g., [`questions-en-GB.json`](frontend-new/src/feedback/overallFeedback/feedbackForm/questions-en-GB.json).
80-
- Add `frontend-new/public/data/config/fields-<locale>.yaml`; keep the same structure/keys as [`fields-en-GB.yaml`](frontend-new/public/data/config/fields-en-GB.yaml).
8143

82-
#### 2.2 Register Locale Resources
44+
**What this script does:**
45+
* Connects to the Google Sheet and downloads the translation matrices.
46+
* Generates or updates the corresponding JSON files in `frontend-new/src/i18n/locales/`, `backend/app/i18n/locales/`, and `frontend-new/src/feedback/overallFeedback/feedbackForm/`.
47+
* Ensures that **new translation keys** defined in the sheet are created for the supported platforms, and keeps existing keys in sync.
48+
* **Auto-registers the new locale:** It automatically patches the codebase to recognize the new language by modifying:
49+
* `config/default.json` (Adds locale to `supportedLocales` and injects English fallbacks into `sensitiveData`)
50+
* `backend/app/i18n/types.py` (Locale enum and `SUPPORTED_LOCALES`)
51+
* `frontend-new/src/i18n/constants.ts` (Locale enum, `LocalesLabels`, `SupportedLocales`)
52+
* `frontend-new/src/i18n/i18n.ts` (Import statements and internal resource mapping)
8353

84-
* Update [`frontend-new/src/i18n/constants.ts`](frontend-new/src/i18n/constants.ts) (add to `Locale`, `LocalesLabels`, `SupportedLocales`, and adjust `FALL_BACK_LOCALE` only if the fallback changes).
85-
* Import and register the translation and feedback JSON files in [`frontend-new/src/i18n/i18n.ts`](frontend-new/src/i18n/i18n.ts).
54+
> **When to re-run the script**
55+
>
56+
> Any time the shared translation sheet is updated (new language, updated strings, or new keys), a developer must:
57+
> 1. Re-run `scripts/import_translations_from_sheets.py`.
58+
> 2. Commit the regenerated locale files and configuration changes.
59+
> 3. Deploy the updated services so the changes are visible in all environments.
8660
87-
#### 2.3 Environment Config
61+
#### 2.2 Manual Steps Required
8862

89-
Enable the new locale via environment variables:
90-
* `FRONTEND_SUPPORTED_LOCALES`: a JSON array of enabled locale codes (see [`parseEnvSupportedLocales.ts`](frontend-new/src/i18n/languageContextMenu/parseEnvSupportedLocales.ts) for validation rules).
91-
* `FRONTEND_DEFAULT_LOCALE`: default language if user preference not set.
63+
While the script handles JSON translations and locale registration, one manual step remains:
9264

93-
Environment variables are Base64-encoded and read from [`frontend-new/public/data/env.example.js`](frontend-new/public/data/env.example.js).
65+
1. **Embeddings:** Generate taxonomy embeddings for the new language using the appropriate model ID (see [Generate Embeddings](./deployment-procedure.md#step-43-generate-embeddings)).
9466

95-
#### 2.4 Verify Frontend Key Consistency
67+
#### 2.3 Verify Key Consistency
9668

97-
There is an automated test that ensures all locales share the same keys as English (`en-GB`): [`frontend-new/src/i18n/locales/locales.test.ts`](frontend-new/src/i18n/locales/locales.test.ts).
98-
99-
Optional commands
69+
You can optionally run the automated tests to ensure exact key consistency across platforms:
10070

10171
```bash
72+
# Backend verification
73+
cd backend
74+
poetry run python scripts/verify_i18n_keys.py --verify
75+
76+
# Frontend verification
10277
cd frontend-new
10378
yarn test -- src/i18n/locales/locales.test.ts
10479
```
@@ -107,30 +82,13 @@ yarn test -- src/i18n/locales/locales.test.ts
10782

10883
Supported languages are enabled via environment variables on both backend and frontend.
10984

110-
* **Add environment variables for the frontend** ([see deployment procedure](./deployment-procedure.md) and [upload to secret manager](./deployment-procedure.md):
85+
* **Add environment variables for the frontend** ([see deployment procedure](./deployment-procedure.md) and [upload to secret manager](./deployment-procedure.md)):
11186
- `FRONTEND_SUPPORTED_LOCALES`: JSON string array of supported locales, e.g., `["en-GB","es-ES"]`.
11287
- `FRONTEND_DEFAULT_LOCALE`: Default locale string.
11388

11489
* **Add environment variables for the backend** ([see deployment procedure](./deployment-procedure.md) and [upload to secret manager](./deployment-procedure.md)):
11590
- `BACKEND_LANGUAGE_CONFIG`: Default backend locale (BCP 47).
11691

117-
### 4. Quick checklist (summary)
118-
119-
* **Backend**
120-
* [ ] Create `backend/app/i18n/locales/<locale>/messages.json` with the same keys as English
121-
* [ ] Add language configurations to `BACKEND_LANGUAGE_CONFIG`
122-
* [ ] Add the locale to `backend/app/i18n/types.py` (`Locale` enum and `SUPPORTED_LOCALES`)
123-
* [ ] Generate embeddings for the new language using the new taxonomy model ID \([Generate Embeddings](./deployment-procedure.md#step-43-generate-embeddings)\)
124-
* [ ] (Optional) Run backend i18n verify script
125-
126-
* **Frontend-new**
127-
* [ ] Create `frontend-new/src/i18n/locales/<locale>/translation.json` with the same keys as English (`en-GB`)
128-
* [ ] Import and register resources in `frontend-new/src/i18n/i18n.ts`
129-
* [ ] Update `frontend-new/src/i18n/constants.ts` (`Locale`, `LocalesLabels`, `SupportedLocales`)
130-
* [ ] Add `frontend-new/src/feedback/overallFeedback/feedbackForm/questions-<locale>.json`
131-
* [ ] Add `frontend-new/public/data/config/fields-<locale>.yaml`
132-
* [ ] Update `env` config: `FRONTEND_SUPPORTED_LOCALES` and `FRONTEND_DEFAULT_LOCALE`
133-
* [ ] (Optional) Run the locales consistency test
13492

13593
## Switching or Using Supported Languages
13694

@@ -144,12 +102,12 @@ This section explains how to enable or switch between languages that are already
144102
### 1. Backend
145103

146104
* **Language Config:** `BACKEND_LANGUAGE_CONFIG` determines the default backend language and other configurations.
147-
* **Supported languages:** Listed in the `SUPPORTED_LOCALES` enum in [`backend/app/i18n/types.py`](backend/app/i18n/types.py).
105+
* **Supported languages:** Listed in the `SUPPORTED_LOCALES` list in [`backend/app/i18n/types.py`](backend/app/i18n/types.py).
148106

149107
### 2. Frontend
150108

151109
* **Registered languages:** Defined in [`frontend-new/src/i18n/i18n.ts`](frontend-new/src/i18n/i18n.ts) (translation resources) and [`frontend-new/src/i18n/constants.ts`](frontend-new/src/i18n/constants.ts) (`Locale`, `LocalesLabels`, `SupportedLocales`).
152-
* **Default language:** `FRONTEND_DEFAULT_LOCALE` in `public/data/env.js` sets the default UI language.
110+
* **Default language:** `FRONTEND_DEFAULT_LOCALE` in `public/data/env.js` sets the default UI language if no user preference is set.
153111
* **Switching languages at runtime:** Users can select a language from the UI menu. Only locales listed in `FRONTEND_SUPPORTED_LOCALES` are available. To change the default language in the frontend, update `FRONTEND_DEFAULT_LOCALE` in the environment configuration.
154112

155-
> **Note:** Backend and frontend are not automatically synchronized. To ensure a consistent language across the application, configure both layers to support and use the same language.
113+
> **Note:** Backend and frontend active languages are not automatically synchronized. To ensure a completely consistent language across the application, configure both layers to support and default to the same language.

backend/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,7 @@ BACKEND_LANGUAGE_CONFIG='{"default_locale":"en-US","available_locales":[{"locale
6363

6464
# Branding settings
6565
GLOBAL_PRODUCT_NAME=Compass
66+
67+
# Google Sheets translations
68+
GOOGLE_SHEETS_CREDENTIALS=keys/credentials-sheets.json
69+
GOOGLE_SHEET_ID=<GOOGLE_SHEET_ID>

backend/app/i18n/locales/en-GB/messages.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,4 @@
6262
"unseenUnpaid": "What do you think is most important when helping out in the community or caring for others?"
6363
}
6464
}
65-
}
65+
}

backend/app/i18n/locales/en-US/messages.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,4 @@
6262
"unseenUnpaid": "What do you think is most important when helping out in the community or caring for others?"
6363
}
6464
}
65-
}
65+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Script to download the latest translations from the Google Sheet and automatically
3+
update the frontend and backend JSON language files.
4+
"""
5+
6+
import json
7+
import os
8+
import sys
9+
from pathlib import Path
10+
11+
from dotenv import load_dotenv
12+
from google.oauth2 import service_account
13+
from googleapiclient.discovery import build
14+
15+
from locale_registration import register_locales
16+
17+
# Configuration
18+
load_dotenv()
19+
GOOGLE_SHEETS_CREDENTIALS = os.getenv("GOOGLE_SHEETS_CREDENTIALS")
20+
GOOGLE_SHEET_ID = os.getenv("GOOGLE_SHEET_ID")
21+
SCOPES = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
22+
23+
PLATFORM_MAPPINGS = {
24+
"Frontend": "frontend-new/src/i18n/locales/{locale}/translation.json",
25+
"Backend": "backend/app/i18n/locales/{locale}/messages.json",
26+
"Feedback": "frontend-new/src/feedback/overallFeedback/feedbackForm/questions-{locale}.json",
27+
}
28+
29+
def build_service(credentials_path: str):
30+
"""Builds a Google Sheets service object."""
31+
creds = service_account.Credentials.from_service_account_file(
32+
credentials_path, scopes=SCOPES
33+
)
34+
return build("sheets", "v4", credentials=creds)
35+
36+
def fetch_rows(service, sheet_id: str, range_name: str = "A:ZZZ") -> list[list[str]]:
37+
"""Fetches all rows from the specified spreadsheet range."""
38+
result = service.spreadsheets().values().get(
39+
spreadsheetId=sheet_id, range=range_name
40+
).execute()
41+
return result.get("values", [])
42+
43+
def set_nested(d: dict, key_path: str, value):
44+
"""Sets a value in a nested dictionary using a dot-notated key path."""
45+
parts = key_path.split(".")
46+
for part in parts[:-1]:
47+
if part not in d or not isinstance(d[part], dict):
48+
d[part] = {}
49+
d = d[part]
50+
# Format value: restore newlines and handle nulls
51+
d[parts[-1]] = value.replace("\\n", "\n") if isinstance(value, str) else value
52+
53+
def _get_translations_data(header: list[str], rows: list[list[str]], locales: list[str]) -> dict:
54+
"""Groups spreadsheet rows by target (platform, locale)."""
55+
all_data = {}
56+
for row in rows[1:]:
57+
padded = row + [""] * (len(header) - len(row))
58+
platform, key_path = padded[0].strip(), padded[1].strip()
59+
60+
if platform not in PLATFORM_MAPPINGS or not key_path:
61+
continue
62+
63+
for i, locale in enumerate(locales):
64+
value = padded[2 + i]
65+
# If value is empty, use None so it becomes 'null' in JSON
66+
val_to_set = value if value != "" else None
67+
68+
target = (platform, locale)
69+
all_data.setdefault(target, {})[key_path] = val_to_set
70+
return all_data
71+
72+
def _sync_dict(existing: dict, incoming: dict) -> dict:
73+
"""Order-preserving sync of two nested dicts"""
74+
result: dict = {}
75+
76+
for key, old_val in existing.items():
77+
if key not in incoming:
78+
continue
79+
new_val = incoming[key]
80+
if isinstance(old_val, dict) and isinstance(new_val, dict):
81+
result[key] = _sync_dict(old_val, new_val)
82+
else:
83+
result[key] = new_val
84+
85+
for key, new_val in incoming.items():
86+
if key not in result:
87+
result[key] = new_val
88+
89+
return result
90+
91+
92+
def _write_json_files(root_dir: Path, all_data: dict):
93+
"""Writes translation data to JSON files using an order-preserving sync"""
94+
for (platform, locale), updates in all_data.items():
95+
file_path = root_dir / PLATFORM_MAPPINGS[platform].format(locale=locale)
96+
file_path.parent.mkdir(parents=True, exist_ok=True)
97+
98+
# Build the incoming dict from sheet data (dot-notation → nested)
99+
incoming: dict = {}
100+
for key, val in updates.items():
101+
set_nested(incoming, key, val)
102+
103+
# Load an existing file (empty dict for new locales)
104+
existing: dict = {}
105+
if file_path.exists():
106+
try:
107+
existing = json.loads(file_path.read_text(encoding="utf-8") or "{}")
108+
except json.JSONDecodeError:
109+
pass
110+
111+
synced = _sync_dict(existing, incoming)
112+
113+
with open(file_path, "w", encoding="utf-8") as f:
114+
json.dump(synced, f, indent=2, ensure_ascii=False)
115+
f.write("\n")
116+
117+
def main():
118+
if not GOOGLE_SHEETS_CREDENTIALS or not GOOGLE_SHEET_ID:
119+
print("ERROR: GOOGLE_SHEETS_CREDENTIALS or GOOGLE_SHEET_ID not set in .env")
120+
sys.exit(1)
121+
122+
root_dir = Path(__file__).resolve().parent.parent.parent
123+
service = build_service(GOOGLE_SHEETS_CREDENTIALS)
124+
rows = fetch_rows(service, GOOGLE_SHEET_ID)
125+
126+
if not rows:
127+
print("ERROR: Sheet is empty.")
128+
sys.exit(1)
129+
130+
header = rows[0]
131+
if len(header) < 3 or header[0] != "Platform" or header[1] != "Key":
132+
print("ERROR: Invalid header. Expected ['Platform', 'Key', <locale>, ...]")
133+
sys.exit(1)
134+
135+
locales = header[2:]
136+
all_data = _get_translations_data(header, rows, locales)
137+
_write_json_files(root_dir, all_data)
138+
139+
print(f"Import complete. Processed {len(all_data)} translation files.")
140+
141+
# Auto-register any new locales found in the sheet
142+
new_locs = register_locales(root_dir, locales)
143+
if new_locs:
144+
print(f"Successfully registered {len(new_locs)} new languages: {', '.join(new_locs)}")
145+
146+
if __name__ == "__main__":
147+
main()

0 commit comments

Comments
 (0)