Translate UI via transifex

AI-assistant: Copilot v1.0.7 (Claude Sonnet 4.6)

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>
This commit is contained in:
Pablo Zmdl
2026-03-13 17:41:30 +01:00
parent 7c8cabdb2d
commit 228f785987
11 changed files with 791 additions and 6 deletions

62
.github/workflows/pull-translations.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: Pull Translations from Transifex
on:
schedule:
# Run every day at 02:00 UTC
- cron: '0 2 * * *'
workflow_dispatch:
inputs:
org:
description: 'Transifex organisation slug'
required: false
default: 'nextcloud'
project:
description: 'Transifex project slug'
required: false
default: 'nextcloud-all-in-one'
permissions:
contents: write
pull-requests: write
jobs:
pull-translations:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install jq
run: sudo apt-get install -y jq
- name: Make pull script executable
run: chmod +x php/translations/pull.sh
- name: Pull translations from Transifex
env:
TRANSIFEX_TOKEN: ${{ secrets.TRANSIFEX_TOKEN }}
TRANSIFEX_ORG: ${{ inputs.org || 'nextcloud' }}
TRANSIFEX_PROJECT: ${{ inputs.project || 'nextcloud-all-in-one' }}
run: php/translations/pull.sh
- name: Check for changes
id: changes
run: |
git diff --quiet php/translations/ && echo "changed=false" >> "$GITHUB_OUTPUT" || echo "changed=true" >> "$GITHUB_OUTPUT"
- name: Create Pull Request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore(i18n): update translations from Transifex'
branch: chore/update-translations
delete-branch: true
title: 'chore(i18n): update translations from Transifex'
body: |
Automated pull of the latest translations from Transifex.
This PR was created automatically by the **Pull Translations** workflow.
Please review the changes and merge when ready.
labels: translations

View File

@@ -51,11 +51,13 @@ $app->add(Guard::class);
$twig = Twig::create(__DIR__ . '/../templates/', ['cache' => false]);
$app->add(TwigMiddleware::create($app, $twig));
$twig->addExtension(new \AIO\Twig\CsrfExtension($container->get(Guard::class)));
$twig->addExtension(new \AIO\Twig\TranslationExtension($container->get(\AIO\Translation\TranslationManager::class)));
// Auth Middleware
$app->add(new \AIO\Middleware\AuthMiddleware($container->get(\AIO\Auth\AuthManager::class)));
// API
$app->post('/api/language', AIO\Controller\LanguageController::class . ':SetLanguage');
$app->post('/api/docker/watchtower', AIO\Controller\DockerController::class . ':StartWatchtowerContainer');
$app->get('/api/docker/getwatchtower', AIO\Controller\DockerController::class . ':StartWatchtowerContainer');
$app->post('/api/docker/start', AIO\Controller\DockerController::class . ':StartContainer');

View File

@@ -0,0 +1,104 @@
(function () {
'use strict';
var STORAGE_KEY = 'aio_language';
var API_ENDPOINT = 'api/language';
/**
* Read the CSRF token fields that CsrfExtension injects into every page as
* hidden inputs inside the logout form. We reuse them for the JSON POST so
* that Slim's CSRF guard accepts our request.
*/
function getCsrfFields() {
var nameInput = document.querySelector('input[name$="__token_name"]') // fallback selector
|| document.querySelector('input[name^="csrf_name"]');
var valueInput = document.querySelector('input[name$="__token_value"]')
|| document.querySelector('input[name^="csrf_value"]');
// The Slim CSRF guard stores two hidden fields; their *name* attributes
// are themselves dynamic (csrf_name / csrf_value carry the key names,
// and csrf.name / csrf.value carry the actual token strings).
// The simplest reliable approach: grab all hidden inputs from the logout
// form and forward them all.
var logoutForm = document.querySelector('form[action*="api/auth/logout"]');
if (!logoutForm) {
return {};
}
var fields = {};
var hiddenInputs = logoutForm.querySelectorAll('input[type="hidden"]');
hiddenInputs.forEach(function (input) {
fields[input.name] = input.value;
});
return fields;
}
/**
* POST the chosen language to the server, then reload on success.
* Returns a Promise that resolves to true on success, false on failure.
*/
function postLanguage(lang) {
var csrfFields = getCsrfFields();
var body = Object.assign({ language: lang }, csrfFields);
return fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(body).toString(),
}).then(function (response) {
return response.ok || response.status === 204;
}).catch(function () {
return false;
});
}
/**
* Persist the language choice to localStorage and reload the page so the
* server can render in the new language.
*/
function applyLanguage(lang, reload) {
localStorage.setItem(STORAGE_KEY, lang);
postLanguage(lang).then(function (ok) {
if (ok && reload) {
window.location.reload();
}
});
}
/**
* Wire up the <select> drop-down once the DOM is ready.
*/
function initSwitcher() {
var select = document.getElementById('language-switcher');
if (!select) {
return;
}
select.addEventListener('change', function () {
var chosen = select.value;
if (chosen) {
applyLanguage(chosen, true);
}
});
// On page load: if localStorage holds a preference that differs from
// the current server-side language, silently sync once and reload.
var saved = localStorage.getItem(STORAGE_KEY);
var current = select.dataset.current || select.value;
if (saved && saved !== current) {
// Update the select to match the stored preference before posting,
// so the UI doesn't flicker if the reload is slow.
select.value = saved;
applyLanguage(saved, true);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSwitcher);
} else {
initSwitcher();
}
}());

View File

@@ -375,9 +375,38 @@ header {
z-index: 1000;
}
header > form {
.header-controls {
margin-left: auto;
margin-right: 30px;
display: flex;
align-items: center;
gap: 12px;
}
.header-controls > form {
margin: 0;
}
.language-switcher {
height: 34px;
padding: 0 8px;
font-size: var(--default-font-size);
font-family: inherit;
color: var(--color-main-text);
background-color: var(--color-main-background);
border: var(--border) solid var(--color-main-border);
border-radius: var(--border-radius);
cursor: pointer;
outline: none;
}
.language-switcher:hover {
border-width: var(--border-hover);
border-color: var(--color-main-border-hover);
}
.language-switcher:focus {
outline: 2px solid var(--color-main-border);
}
/* Standard styling for enabled checkboxes */

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace AIO\Controller;
use AIO\Translation\TranslationManager;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
readonly class LanguageController
{
public function __construct(
private TranslationManager $translationManager,
) {
}
public function SetLanguage(Request $request, Response $response, array $args): Response
{
/** @var array<string, mixed>|null $body */
$body = $request->getParsedBody();
$language = '';
if (is_array($body) && isset($body['language']) && is_string($body['language'])) {
$language = $body['language'];
}
$supported = $this->translationManager->getSupportedLanguages();
if ($language === '' || !in_array($language, $supported, true)) {
$response->getBody()->write('Unsupported language.');
return $response->withStatus(422);
}
$_SESSION['aio_user_language'] = $language;
return $response->withStatus(204);
}
}

View File

@@ -6,6 +6,7 @@ namespace AIO;
use AIO\Docker\DockerHubManager;
use DI\Container;
use AIO\Docker\GitHubContainerRegistryManager;
use AIO\Translation\TranslationManager;
class DependencyInjection
{
@@ -50,6 +51,10 @@ class DependencyInjection
$container->get(\AIO\Data\ConfigurationManager::class)
)
);
$container->set(
TranslationManager::class,
new TranslationManager()
);
return $container;
}

View File

@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace AIO\Translation;
/**
* Resolves the active language and loads translations from a flat JSON file.
*
* Language resolution order:
* 1. PHP session ($_SESSION['aio_user_language'])
* 2. Accept-Language HTTP header (first matching tag that has a JSON file)
* 3. Hardcoded fallback: "en"
*
* English is the implicit source language — the key itself is the English
* string, so no en.json is required.
*
* Translation files live at:
* <project-root>/php/translations/{lang}.json
* Each file is a flat JSON object: {"some_key": "Translated string", ...}
*/
final class TranslationManager
{
private const TRANSLATIONS_DIR = __DIR__ . '/../../translations';
private const FALLBACK_LANGUAGE = 'en';
private const SESSION_KEY = 'aio_user_language';
/** @var array<string, string> */
private array $strings = [];
private string $currentLanguage;
/** @var list<string>|null Lazily populated from the filesystem. */
private ?array $supportedLanguages = null;
public function __construct()
{
$this->currentLanguage = $this->resolveLanguage();
$this->loadStrings($this->currentLanguage);
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* Return the translated string for $key, or $key itself when no
* translation is available (English pass-through behaviour).
*/
public function translate(string $key): string
{
return $this->strings[$key] ?? $key;
}
/**
* The language code that is currently active (e.g. "de", "fr", "en").
*/
public function getCurrentLanguage(): string
{
return $this->currentLanguage;
}
/**
* All language codes for which a translations/*.json file exists.
* The list is sorted alphabetically and always includes "en".
*
* @return list<string>
*/
public function getSupportedLanguages(): array
{
if ($this->supportedLanguages !== null) {
return $this->supportedLanguages;
}
$languages = ['en'];
$pattern = self::TRANSLATIONS_DIR . '/*.json';
$files = glob($pattern);
foreach ($files !== false ? $files : [] as $file) {
$code = basename($file, '.json');
if ($code !== 'en' && $this->isValidLanguageCode($code)) {
$languages[] = $code;
}
}
sort($languages);
$this->supportedLanguages = $languages;
return $this->supportedLanguages;
}
// -------------------------------------------------------------------------
// Language resolution
// -------------------------------------------------------------------------
private function resolveLanguage(): string
{
// 1. Session preference set by the user via the language switcher.
if (
isset($_SESSION[self::SESSION_KEY])
&& is_string($_SESSION[self::SESSION_KEY])
&& $this->isValidLanguageCode($_SESSION[self::SESSION_KEY])
) {
$lang = $this->normalise($_SESSION[self::SESSION_KEY]);
if ($this->hasTranslationFile($lang) || $lang === self::FALLBACK_LANGUAGE) {
return $lang;
}
}
// 2. Accept-Language header — try each tag in quality order.
$acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
if ($acceptLanguage !== '') {
$candidate = $this->resolveFromAcceptLanguage($acceptLanguage);
if ($candidate !== null) {
return $candidate;
}
}
// 3. Hardcoded fallback.
return self::FALLBACK_LANGUAGE;
}
/**
* Parse an Accept-Language header value and return the best matching
* language code for which we have a translation file, or null.
*
* Example header: "de-AT,de;q=0.9,en-US;q=0.8,en;q=0.7"
*/
private function resolveFromAcceptLanguage(string $header): ?string
{
// Split on comma, sort by quality weight (highest first).
$tags = [];
foreach (explode(',', $header) as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
$quality = 1.0;
if (str_contains($part, ';q=')) {
$segments = explode(';q=', $part, 2);
$quality = (float) ($segments[1] ?? '1');
$part = trim($segments[0]);
}
$tags[] = ['tag' => $part, 'q' => $quality];
}
usort($tags, static fn(array $a, array $b): int => $b['q'] <=> $a['q']);
foreach ($tags as $entry) {
$tag = $entry['tag'];
// Try the exact tag first (e.g. "de-AT"), then the primary subtag
// (e.g. "de"), then a case-insensitive match against known files.
foreach ($this->candidatesFor($tag) as $candidate) {
if ($candidate === self::FALLBACK_LANGUAGE) {
return self::FALLBACK_LANGUAGE;
}
if ($this->hasTranslationFile($candidate)) {
return $candidate;
}
}
}
return null;
}
/**
* Return the normalised language code candidates to try for a given
* Accept-Language tag, from most specific to least specific.
*
* @return list<string>
*/
private function candidatesFor(string $tag): array
{
$candidates = [];
$normalised = $this->normalise($tag);
if ($this->isValidLanguageCode($normalised)) {
$candidates[] = $normalised;
}
// If the tag contains a region/script subtag, also try just the
// primary language subtag (e.g. "de-AT" → "de").
if (str_contains($normalised, '-')) {
$primary = explode('-', $normalised, 2)[0];
if ($this->isValidLanguageCode($primary)) {
$candidates[] = $primary;
}
}
return $candidates;
}
// -------------------------------------------------------------------------
// Translation file loading
// -------------------------------------------------------------------------
private function loadStrings(string $language): void
{
if ($language === self::FALLBACK_LANGUAGE) {
// English: key == translation, nothing to load.
$this->strings = [];
return;
}
$path = $this->translationFilePath($language);
if (!file_exists($path)) {
$this->strings = [];
return;
}
$contents = file_get_contents($path);
if ($contents === false) {
$this->strings = [];
return;
}
/** @var mixed $decoded */
$decoded = json_decode($contents, true);
if (!is_array($decoded)) {
$this->strings = [];
return;
}
/** @var array<string, string> $strings */
$strings = [];
foreach ($decoded as $key => $value) {
if (is_string($key) && is_string($value)) {
$strings[$key] = $value;
}
}
$this->strings = $strings;
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private function hasTranslationFile(string $language): bool
{
return file_exists($this->translationFilePath($language));
}
private function translationFilePath(string $language): string
{
return self::TRANSLATIONS_DIR . '/' . $language . '.json';
}
/**
* Normalise a language tag to lowercase with hyphens
* (e.g. "de_AT" → "de-at", "ZH-Hans" → "zh-hans").
*/
private function normalise(string $tag): string
{
return strtolower(str_replace('_', '-', $tag));
}
/**
* Sanity-check that the string looks like a BCP-47 language tag and
* cannot be used for path traversal.
*/
private function isValidLanguageCode(string $code): bool
{
return (bool) preg_match('/^[a-zA-Z]{2,8}(?:-[a-zA-Z0-9]{1,8})*$/', $code);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace AIO\Twig;
use AIO\Translation\TranslationManager;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;
final class TranslationExtension extends AbstractExtension implements GlobalsInterface
{
public function __construct(
private readonly TranslationManager $translationManager,
) {
}
#[\Override]
public function getFunctions(): array
{
return [
new TwigFunction('t', $this->translate(...)),
];
}
#[\Override]
public function getFilters(): array
{
return [
new TwigFilter('t', $this->translate(...)),
];
}
#[\Override]
public function getGlobals(): array
{
return [
'currentLanguage' => $this->translationManager->getCurrentLanguage(),
'supportedLanguages' => $this->translationManager->getSupportedLanguages(),
];
}
public function translate(string $key): string
{
return $this->translationManager->translate($key);
}
}

View File

@@ -8,11 +8,21 @@
<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 142 71" width="62" height="50">
<use href="img/nextcloud-logo.svg#logo"></use>
</svg>
<form method="POST" action="api/auth/logout">
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input type="submit" value="Log out" />
</form>
<div class="header-controls">
{% if supportedLanguages|length > 1 %}
<select id="language-switcher" class="language-switcher" data-current="{{ currentLanguage }}" aria-label="Select language">
{% for lang in supportedLanguages %}
<option value="{{ lang }}"{% if lang == currentLanguage %} selected{% endif %}>{{ lang }}</option>
{% endfor %}
</select>
{% endif %}
<form method="POST" action="api/auth/logout">
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input type="submit" value="Log out" />
</form>
</div>
<script type="text/javascript" src="language-switcher.js"></script>
</header>
<div class="container">

View File

223
php/translations/pull.sh Executable file
View File

@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# pull.sh — Fetch all translations from Transifex API v3 and write them to
# php/translations/{lang}.json.
#
# Usage:
# TRANSIFEX_TOKEN=your_token ./pull.sh
#
# Optional env vars:
# TRANSIFEX_ORG — Transifex organisation slug (default: nextcloud)
# TRANSIFEX_PROJECT — Transifex project slug (default: nextcloud-all-in-one)
#
# Requirements: bash, curl, jq
#
# Never called at runtime — run manually or from CI before a release.
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
TOKEN="${TRANSIFEX_TOKEN:?'TRANSIFEX_TOKEN env var must be set'}"
ORG="${TRANSIFEX_ORG:-nextcloud}"
PROJECT="${TRANSIFEX_PROJECT:-nextcloud-all-in-one}"
API="https://rest.api.transifex.com"
AUTH_HEADER="Authorization: Bearer ${TOKEN}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
require_cmd() {
if ! command -v "$1" &>/dev/null; then
echo "ERROR: required command '$1' not found." >&2
exit 1
fi
}
require_cmd curl
require_cmd jq
log() { echo "[pull.sh] $*"; }
# ---------------------------------------------------------------------------
# 1. Fetch the list of languages for the project
# ---------------------------------------------------------------------------
log "Fetching language list for ${ORG}/${PROJECT}"
languages_response=$(curl --silent --fail --show-error \
-H "${AUTH_HEADER}" \
-H "Content-Type: application/vnd.api+json" \
"${API}/projects/o:${ORG}:p:${PROJECT}/languages")
mapfile -t lang_codes < <(echo "${languages_response}" \
| jq -r '.data[].attributes.code')
if [[ ${#lang_codes[@]} -eq 0 ]]; then
log "No languages found — nothing to do."
exit 0
fi
log "Found ${#lang_codes[@]} language(s): ${lang_codes[*]}"
# ---------------------------------------------------------------------------
# 2. Fetch the list of resources for the project (we need the resource slug)
# ---------------------------------------------------------------------------
log "Fetching resource list …"
resources_response=$(curl --silent --fail --show-error \
-H "${AUTH_HEADER}" \
-H "Content-Type: application/vnd.api+json" \
"${API}/resources?filter[project]=o:${ORG}:p:${PROJECT}")
mapfile -t resource_slugs < <(echo "${resources_response}" \
| jq -r '.data[].attributes.slug')
if [[ ${#resource_slugs[@]} -eq 0 ]]; then
log "No resources found — nothing to do."
exit 0
fi
log "Found ${#resource_slugs[@]} resource(s): ${resource_slugs[*]}"
# ---------------------------------------------------------------------------
# 3. For each language, merge translations from all resources and write JSON
# ---------------------------------------------------------------------------
# poll_until_ready <url> — keeps polling an async download URL until the
# Transifex job finishes, then prints the redirect/content URL.
poll_until_ready() {
local url="$1"
local max_attempts=30
local attempt=0
local delay=2
while (( attempt < max_attempts )); do
response=$(curl --silent --fail --show-error \
-H "${AUTH_HEADER}" \
-H "Content-Type: application/vnd.api+json" \
"${url}")
status=$(echo "${response}" | jq -r '.data.attributes.status // empty')
case "${status}" in
succeeded)
echo "${response}" | jq -r '.data.attributes.download_url'
return 0
;;
failed)
echo "ERROR: Transifex async job failed: $(echo "${response}" | jq -r '.data.attributes.errors // empty')" >&2
return 1
;;
*)
# pending / processing — wait and retry
sleep "${delay}"
(( attempt++ )) || true
;;
esac
done
echo "ERROR: Timed out waiting for Transifex download." >&2
return 1
}
for lang in "${lang_codes[@]}"; do
# Skip English — the key itself IS the English string.
if [[ "${lang}" == "en" ]]; then
log "Skipping English (source language)."
continue
fi
log "Processing language: ${lang}"
# Collect merged translations from all resources into one flat map.
declare -A merged_translations=()
for resource in "${resource_slugs[@]}"; do
log " Requesting download for resource '${resource}' / language '${lang}' …"
# Request an async resource translation download (KEYVALUEJSON format).
job_response=$(curl --silent --fail --show-error \
-X POST \
-H "${AUTH_HEADER}" \
-H "Content-Type: application/vnd.api+json" \
-d "{
\"data\": {
\"attributes\": {
\"callback_url\": null,
\"content_encoding\": \"text\",
\"file_type\": \"default\",
\"language\": \"l:${lang}\",
\"mode\": \"translator\"
},
\"relationships\": {
\"resource\": {
\"data\": {
\"id\": \"o:${ORG}:p:${PROJECT}:r:${resource}\",
\"type\": \"resources\"
}
}
},
\"type\": \"resource_translations_async_downloads\"
}
}" \
"${API}/resource_translations_async_downloads")
job_id=$(echo "${job_response}" | jq -r '.data.id')
if [[ -z "${job_id}" || "${job_id}" == "null" ]]; then
log " WARNING: Could not start async download for ${resource}/${lang} — skipping."
continue
fi
# Poll until ready and get the download URL.
download_url=$(poll_until_ready "${API}/resource_translations_async_downloads/${job_id}")
# Download the raw file content (KEYVALUEJSON = flat JSON object).
raw=$(curl --silent --fail --show-error -L \
-H "${AUTH_HEADER}" \
"${download_url}")
# Merge the flat key-value pairs from this resource.
while IFS= read -r line; do
key=$(echo "${line}" | jq -r '.key')
value=$(echo "${line}" | jq -r '.value')
if [[ -n "${key}" && -n "${value}" && "${value}" != "null" ]]; then
merged_translations["${key}"]="${value}"
fi
done < <(echo "${raw}" | jq -c 'to_entries[] | {key: .key, value: .value}')
done
# Build the output JSON object from the merged map.
output_file="${SCRIPT_DIR}/${lang}.json"
tmp_file="${output_file}.tmp"
{
echo "{"
first=true
for key in "${!merged_translations[@]}"; do
value="${merged_translations[${key}]}"
if [[ "${first}" == true ]]; then
first=false
else
echo ","
fi
# Use jq to safely encode both key and value as JSON strings.
printf '%s: %s' \
"$(echo -n "${key}" | jq -Rs '.')" \
"$(echo -n "${value}" | jq -Rs '.')"
done
echo ""
echo "}"
} > "${tmp_file}"
# Validate & pretty-print the JSON before writing it out.
jq '.' "${tmp_file}" > "${output_file}"
rm -f "${tmp_file}"
log " Written ${output_file}"
unset merged_translations
done
log "Done."