Skip to content
Draft
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ Please check our [development documentation](https://thm-health.github.io/PILOS/

## Localization

The localization is managed in our [POEditor](https://poeditor.com/join/project/gWkaFBI8OH) project.
Feel free to join and help us translate PILOS into your language or improve the existing translations.
Please check our [localization documentation](https://thm-health.github.io/PILOS/docs/administration/customisation/locales) to
learn how to contribute and add custom locales.

## License

Expand Down
54 changes: 51 additions & 3 deletions docs/docs/administration/09-customisation/02-locales.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ title: Locales
description: Contribute to the translation of PILOS and add custom locales
---

PILOS is available in multiple languages. By default, PILOS comes with English and German translations.
Other locales are maintained by the community using [PoEditor](https://poeditor.com/join/project/gWkaFBI8OH).
PILOS supports multiple languages, with English and German officially maintained.

English serves as the reference language and can only be changed in the source code.

All translations are managed in the [POEditor Project](https://poeditor.com/projects/view?id=700042).
To contribute, [join the project](https://poeditor.com/join/project/gWkaFBI8OH).

## Locale structure

Expand All @@ -15,10 +19,54 @@ For example, the string `auth.ldap.username_help` would be stored in the file `a

Within the file, the keys are organized in nested php arrays.

### Placeholders

Placeholders in localization strings are defined using the `:placeholder` syntax.

**DO NOT** use `:n` and `:count` as these are reserved for pluralization.

For example:

```
"Welcome, :name!"
```

Both frontend and backend code can replace `:name` with a dynamic value, producing:

```
"Welcome, John!"
```

When translating, ensure that placeholders remain unchanged and are correctly positioned within the sentence structure of the target language.

### Pluralization

Pluralization can be complex, and we currently support the **flexible pluralization format used by [Laravel](https://laravel.com/docs/12.x/localization#pluralization)**.

#### Format

```text
{0} No items | {1} One item | [2,*] :count items
```

Each plural form is separated by a pipe (`|`):

- **Curly braces `{}`** define exact numbers.
- **Square brackets `[]`** define numeric ranges.
- The **asterisk (`*`)** denotes an open upper range.

Pluralization forms must be listed in ascending order, and you can define as many as required for a given locale.

#### Placeholders

- Singular strings support arbitrary placeholders using the `:placeholder` syntax.
- Pluralization strings only support the `:count` placeholder to represent the number of items.
Additional placeholders are not supported.

## Overriding locales

You can override the default locales by creating custom locale files in the `resources/custom/lang` directory.
This directory need to be mounted to the container by adjusting the docker-compose file.
This directory needs to be mounted to the container by adjusting the docker-compose file.

```yaml
- "./resources/custom:/var/www/html/resources/custom"
Expand Down
2 changes: 1 addition & 1 deletion lang/en/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
'message' => ':message',
],
'too_large' => 'The transmitted data was too large!',
'too_many_requests' => 'Too many requests. Please try again later.',
'too_many_requests' => '{1} Too many requests. Please try again in 1 minute. | [2,*] Too many requests. Please try again in :count minutes.',
'unauthenticated' => 'You must be authenticated to execute the request!',
'unauthorized' => 'You don\'t have the necessary rights to access the called route!',
],
Expand Down
36 changes: 34 additions & 2 deletions resources/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createI18n } from "vue-i18n";
* Custom message compiler for vue-i18n to use Laravel locale file syntax
*/
function messageCompiler(message) {
// Check if message is missing in the locales (!!missing!! injected by missingHandler)
// Check if a message is missing in the locales (!!missing!! injected by missingHandler)
const isMissing = message.startsWith("!!missing!!");
// Remove "!!missing!!" from message
if (isMissing) {
Expand All @@ -17,12 +17,18 @@ function messageCompiler(message) {
if (!ctx.values) {
return message;
}

// If ctx.values has n property, we have to handle pluralization
if (ctx.values["n"] !== undefined) {
message = getPluralization(message, ctx.values["n"]);
}

Object.keys(ctx.values).forEach((key) => {
// Use Laravel syntax :placeholder instead of {placeholder}
message = message.replace(`:${key}`, ctx.values[key]);
});

// If message is missing and values are present, append values to message for debugging
// If a message is missing and values are present, append values to the message for debugging
if (isMissing && Object.keys(ctx.values).length > 0) {
return message + "_" + JSON.stringify(ctx.values);
}
Expand All @@ -32,6 +38,32 @@ function messageCompiler(message) {
}
}

function getPluralization(message, count) {
const messageParts = message.split("|");
const regex = /^(?:(?:\{(\d+)\})|(?:\[(\d+),(\d+|\*)\])) (.*)$/;

for (const part of messageParts) {
const match = part.trim().match(regex);
if (match) {
// Match {n}
if (match[1] !== undefined && Number(match[1]) === Number(count)) {
return match[4];
}
if (match[1] === undefined) {
// Match [n,m] or [n,*]
const n = Number(match[2]);
const m = match[3] === "*" ? Infinity : Number(match[3]);
if (Number(count) >= n && Number(count) <= m) {
return match[4];
}
}
}
}

// Fallback; should not happen if the syntax is correct
return message;
}

function missingHandler(locale, key) {
return "!!missing!!" + key;
}
Expand Down
10 changes: 8 additions & 2 deletions resources/js/services/Api.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,14 @@ export class Api {
this.toast.error(this.t("app.flash.too_large"));
}

handleTooManyRequests() {
this.toast.error(this.t("app.flash.too_many_requests"));
handleTooManyRequests(error) {
// Retry-After Header is in seconds
// Minutes are always rounded up to avoid user retrying again too early
const tryAgainMinutes = Math.ceil(
error.response.headers["retry-after"] / 60,
);

this.toast.error(this.t("app.flash.too_many_requests", tryAgainMinutes));
}

handleMaintenance() {
Expand Down
Loading