Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ node_modules/
# local scratch / generated artifacts
/graphify-out/
PLAN_*.md
_*
test-results/
.claude/
.opencode/
Expand Down
39 changes: 27 additions & 12 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ environment:
| `SECRET_KEY` | — | **yes**¹ | Cookie signing key |
| `DATABASE_ARGS` | `{}` | no | Extra args for the psycopg3 driver |
| `SESSION_LIFETIME` | `1` | no | Session duration in days |
| `LANGUAGE_FILE` | `i18n/en.json` | no | UI translation file |
| `LANGUAGES` | `["en","de","fr","es","pl"]` | no | Locale codes offered in the picker (ships all five; narrow via this list) |
| `DEFAULT_LANGUAGE` | `en` | no | Fallback language (NULL user pref + no cookie). Must be listed in `LANGUAGES` |
| `THEME_FILE` | `theme.css` | no | Colour theme stylesheet (`static/`-relative name, or absolute path/URL) |
| `BASE_PATH` | *(empty)* | no | URL prefix WARP is mounted under, e.g. `/warp` (see [Mounting under a URL prefix](#mounting-under-a-url-prefix)) |
| `WEEKS_IN_ADVANCE` | `1` | no | Weeks after current week available for booking |
Expand Down Expand Up @@ -163,22 +164,36 @@ python -c 'from subprocess import run; print(run(["openssl","rand","16"],capture

## Language

The UI language is set globally for the instance — all users see the same language.
The UI language is a **per-user** choice: a language picker on the login
screen and in **Preferences** lets each user pick their own, stored in
`user_prefs.language` and carried across login/logout by the `warp_lang`
cookie. A deployment configures which languages are offered and the fallback:

| Language | `LANGUAGE_FILE` value |
| ----------------- | --------------------- |
| English (default) | `i18n/en.json` |
| German | `i18n/de.json` |
| French | `i18n/fr.json` |
| Spanish | `i18n/es.json` |
| Polish | `i18n/pl.json` |
| Setting | Default | Meaning |
| ------------------- | ------------ | ------- |
| `WARP_LANGUAGES` | `["en","de","fr","es","pl"]` | JSON array of locale codes offered in the picker (ships all five). Renders only when more than one is listed. |
| `WARP_DEFAULT_LANGUAGE` | `en` | Fallback language for users with no pref and no cookie. Must be listed in `WARP_LANGUAGES`. |

```
WARP_LANGUAGE_FILE=i18n/de.json
WARP_LANGUAGES='["en","de","pl"]'
WARP_DEFAULT_LANGUAGE=en
```

The iCal feed and action pages use the same language file for event summaries and
button labels.
Resolution precedence: **logged-in** users — `user_prefs.language` → `warp_lang`
cookie → `DEFAULT_LANGUAGE` (a stale cookie left by another user on a shared
device does not override your pref). **Login screen** (not logged in) —
`warp_lang` cookie → `DEFAULT_LANGUAGE`. Preferences lists each offered language
by name; there is no `Default` entry — a user with no stored preference follows
`DEFAULT_LANGUAGE` (shown applied, not selectable), so a later `DEFAULT_LANGUAGE`
change still reaches them. Picking any language pins it.

> **Breaking change:** the former `WARP_LANGUAGE_FILE` (single deployment-wide
> file) is removed. If still set, it is **silently ignored** (a startup warning
> on stderr only) and the UI falls back to `DEFAULT_LANGUAGE` (`en`). Migrate by
> setting `WARP_LANGUAGES` (a JSON array) and `WARP_DEFAULT_LANGUAGE` instead.

The iCal feed and action pages render in the owner's resolved language (a NULL
pref falls back to `DEFAULT_LANGUAGE`).

---

Expand Down
10 changes: 6 additions & 4 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ When a user clicks a link from their calendar, they are taken to a simple WARP p
2. On confirm: shows **"Seat released"** with the seat name.
3. On cancel: shows **"Action cancelled"**.

All text on these pages is translated according to the deployment-wide language setting.
All text on these pages is translated in the owner’s language (a user with no language preference falls back to the deployment default).

---

Expand All @@ -668,9 +668,10 @@ All text on these pages is translated according to the deployment-wide language
## 20. Multi-Language Support

- WARP supports **English, German, French, Spanish, and Polish**.
- The language is configured globally per instance via `LANGUAGE_FILE` (e.g., `i18n/de.json`).
- The language is a **per-user choice**: a flag picker on the login screen and a **Language** row in **Preferences** let each user pick their own. The choice is stored in `user_prefs.language` and carried across login/logout by the `warp_lang` cookie. A user with no stored preference follows the deployment's `DEFAULT_LANGUAGE` (shown applied in Preferences, not selectable); picking any language pins it.
- Which languages a deployment offers is configured via `WARP_LANGUAGES` (JSON array); the fallback via `WARP_DEFAULT_LANGUAGE`.
- All UI strings (buttons, labels, error messages, modal text) are translated.
- The iCal feed uses the same language for event summaries and action page text.
- The iCal feed and action pages render in the owner's resolved language.
- Date pickers adapt to the locale (first day of week, month names, etc.).

---
Expand Down Expand Up @@ -830,7 +831,8 @@ A zone admin can **release another user's booking** from the plan map by clickin
| `MAX_MAP_SIZE` | 2 MB | Maximum zone map image size |
| `MAX_CONTENT_LENGTH` | 5 MB | Maximum request body size |
| `TIMEZONE` | auto-detect | Timezone label for iCal DTSTART/DTEND |
| `LANGUAGE_FILE` | `i18n/en.json` | UI language file |
| `LANGUAGES` | `["en","de","fr","es","pl"]` | JSON array of locale codes offered in the picker (renders by default) |
| `DEFAULT_LANGUAGE` | `en` | Fallback language (NULL user pref / no cookie) |
| `SECRET_KEY` | — (required) | Key for signing session cookies |
| `DATABASE_ADDRESS` | — (required) | Database host or `host:port` (port defaults to 5432) |
| `DATABASE_NAME` | — (required) | Database name |
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ for writing new tests.

# Other

## Third-party assets

- **Material Icons** (Google) — `warp/static/material_icons/`, Apache 2.0.
- **Flag SVGs** — `warp/static/images/flags/`, from [lipis/flag-icons](https://github.com/lipis/flag-icons) (MIT). Used for the per-user language picker.

## How can I support you

Oh.. I was not expecting that, but you can send a beer via PayPal: https://paypal.me/sebo271
Expand Down
6 changes: 4 additions & 2 deletions containers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ LDAP, …) or any other feature, add the relevant `WARP_*` variables under
| `warp_secret_key` secret | `mysecretkey` | A random secret — see [CONFIGURATION.md](../CONFIGURATION.md#secret-key) |
| `warp_db_password` secret | `postgres_password` | A strong database password (used by both the DB and the app) |
| `warp-app` image tag | `:latest` | A pinned version, e.g. `:v1.2.3` |
| `WARP_LANGUAGE_FILE` | `i18n/en.json` | Your preferred language (`de`/`fr`/`es`/`pl`) |
| `WARP_LANGUAGES` | `["en","de","fr","es","pl"]` | JSON array of locale codes offered in the picker (`en`/`de`/`fr`/`es`/`pl`) |
| `WARP_DEFAULT_LANGUAGE` | `en` | Fallback language (must be listed in `WARP_LANGUAGES`) |

---

Expand Down Expand Up @@ -261,7 +262,8 @@ install the unit files under `~/.config/containers/systemd/` instead.
4. **Review the unit files** and adjust paths if you changed any of the
directories above (`Volume=` lines in `warp-db.container` and
`warp-revproxy.container`), the image reference in `warp-app.container`, and
`WARP_LANGUAGE_FILE` (`i18n/en.json`, also `de`/`fr`/`es`/`pl`).
`WARP_LANGUAGES` / `WARP_DEFAULT_LANGUAGE` (per-user language picker; see
CONFIGURATION.md).

5. **Install the unit files** into the Quadlet drop-in directory and reload:

Expand Down
1 change: 0 additions & 1 deletion containers/compose/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ services:
WARP_DATABASE_USER: "postgres"
WARP_DATABASE_PASSWORD_FILE: /run/secrets/warp_db_password
WARP_SECRET_KEY_FILE: /run/secrets/warp_secret_key
WARP_LANGUAGE_FILE: "i18n/en.json"
# uWSGI endpoints, mirroring the Quadlet setup: disable the binary uWSGI
# socket (empty value) so only the HTTP unix socket that Caddy proxies to
# over the shared /run/warp volume remains. The HTTP socket already defaults
Expand Down
3 changes: 0 additions & 3 deletions containers/quadlet/warp-app.container
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ Environment=WARP_DATABASE_PASSWORD_FILE=/run/secrets/warp-db-password
Secret=warp-secret-key
Environment=WARP_SECRET_KEY_FILE=/run/secrets/warp-secret-key

# UI language. Available: en, de, fr, es, pl.
Environment=WARP_LANGUAGE_FILE=i18n/en.json

# uWSGI endpoints. The in-pod Caddy proxies over the HTTP unix socket on the
# shared /run/warp volume, so the binary uWSGI socket is disabled (empty value).
# The HTTP socket already defaults to /run/warp/uwsgi-http.sock in the app, so it
Expand Down
5 changes: 5 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ npm run report # open last HTML report
test, so no manual cleanup is needed. Mind that jumping forward by a day or
more expires every login session (`SESSION_LIFETIME`): log in again after
advancing the clock, or `/xhr/*` calls silently redirect to `/login`.
- **Language**: the e2e container is started with
`WARP_LANGUAGES='["en","de"]'` and `WARP_DEFAULT_LANGUAGE=en`, so the
per-user language picker renders with two entries. `setLanguage(page, lang)`
(debug only) switches the deployment fallback `DEFAULT_LANGUAGE` at runtime
and clears the iCal feed cache; the fixture resets it to `en` between tests.
- Because all tests share one database, the config pins `workers: 1` and
`fullyParallel: false`. Do not turn parallelism on without giving each
worker its own database.
Expand Down
9 changes: 5 additions & 4 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ async function resetServerClock(baseURL: string): Promise<void> {
}

/**
* Reset the deployment language to English (see helpers/debug.ts setLanguage).
* LANGUAGE_FILE is process-global state; a test that switches language would
* otherwise poison every test after it. Tolerates 404 for a non-debug server.
* Reset the deployment fallback language to English (see helpers/debug.ts
* setLanguage). DEFAULT_LANGUAGE is process-global state; a test that switches
* it would otherwise poison every test after it. Tolerates 404 for a non-debug
* server.
*/
async function resetServerLanguage(baseURL: string): Promise<void> {
const resp = await fetch(`${baseURL}/debug/set_language`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language_file: 'i18n/en.json' }),
body: JSON.stringify({ language: 'en' }),
});
if (!resp.ok && resp.status !== 404) {
throw new Error(`resetting language failed: HTTP ${resp.status}`);
Expand Down
4 changes: 4 additions & 0 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export default async function globalSetup() {
// The suite resets the database directly over TCP, so Postgres must bind
// all interfaces inside the container (off by default — see Dockerfile_debug).
'-e', 'EXPOSE_POSTGRES=1',
// Per-user language picker needs >1 configured language to render the
// dropdown; the ical-language spec also switches DEFAULT_LANGUAGE to 'de'.
'-e', "WARP_LANGUAGES=[\"en\",\"de\"]",
'-e', 'WARP_DEFAULT_LANGUAGE=en',
IMAGE_TAG,
]);

Expand Down
13 changes: 6 additions & 7 deletions e2e/helpers/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,14 @@ export async function advanceDays(page: Page, days: number): Promise<void> {
}

/**
* Switch the deployment language (debug only). Sets LANGUAGE_FILE and clears
* the iCal feed cache so the feed regenerates in the new language.
* `lang` is a short code ('de','en',...) resolved to `i18n/<lang>.json`,
* or pass a full path via the second arg. Reset between tests with 'en'.
* Switch the deployment fallback language (debug only). Sets DEFAULT_LANGUAGE
* (the per-user resolver falls back to it when a user has no pref) and clears
* the iCal feed cache so the feed regenerates in the new language. `lang` is a
* short code ('de','en',...); reset between tests with 'en'.
*/
export async function setLanguage(page: Page, lang: string, fullPath?: string): Promise<void> {
const languageFile = fullPath ?? `i18n/${lang}.json`;
export async function setLanguage(page: Page, lang: string): Promise<void> {
const resp = await page.request.post('/debug/set_language', {
data: { language_file: languageFile },
data: { language: lang },
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok()) {
Expand Down
11 changes: 6 additions & 5 deletions e2e/tests/settings/ical-language.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
*
* Feed event summaries (phrases: booking / missing / release) and the
* action-page titles + button labels (Release seat? / Seat released /
* Confirm / Cancel …) are rendered from the deployment language file
* (LANGUAGE_FILE). Switched at runtime via the debug-only /debug/set_language
* endpoint; the per-test fixture resets LANGUAGE_FILE to English afterwards.
* Confirm / Cancel ...) are rendered from the resolved language. For a user
* with no language pref, that falls back to the deployment DEFAULT_LANGUAGE,
* switched at runtime via the debug-only /debug/set_language endpoint; the
* per-test fixture resets DEFAULT_LANGUAGE to English afterwards.
*/

import { test, expect } from '../../fixtures';
Expand Down Expand Up @@ -37,7 +38,7 @@ async function fetchIcal(page: Page, token: string): Promise<ICalEvent[]> {
return parseIcal(await resp.text());
}

// ─── Feed summaries follow LANGUAGE_FILE ────────────────────────────────────
// ─── Feed summaries follow the resolved language ────────────────────────

test.describe('iCal feed text follows the deployment language', () => {
test('German: booking summary uses "Platz {name}" and reminders "Platz in … buchen"', async ({ page }) => {
Expand Down Expand Up @@ -76,7 +77,7 @@ test.describe('iCal feed text follows the deployment language', () => {
});
});

// ─── Action-page text follows LANGUAGE_FILE ─────────────────────────────────
// ─── Action-page text follows the resolved language ───────────────────────

test.describe('iCal action-page text follows the deployment language', () => {
test('German: release confirm page shows "Platz freigeben?" and Bestätigen/Abbrechen', async ({ page }) => {
Expand Down
Loading