diff --git a/README.md b/README.md index 0ec684956..74fd206e7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/docs/administration/09-customisation/02-locales.md b/docs/docs/administration/09-customisation/02-locales.md index 68d61e20d..458dc4a77 100644 --- a/docs/docs/administration/09-customisation/02-locales.md +++ b/docs/docs/administration/09-customisation/02-locales.md @@ -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 @@ -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" diff --git a/lang/en/app.php b/lang/en/app.php index f50528ac5..e7f34f971 100644 --- a/lang/en/app.php +++ b/lang/en/app.php @@ -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!', ], diff --git a/resources/js/i18n.js b/resources/js/i18n.js index 5cc2f33c6..df047a971 100644 --- a/resources/js/i18n.js +++ b/resources/js/i18n.js @@ -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) { @@ -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); } @@ -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; } diff --git a/resources/js/services/Api.js b/resources/js/services/Api.js index d70623a9e..b3f0dd7b8 100644 --- a/resources/js/services/Api.js +++ b/resources/js/services/Api.js @@ -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() {