From 228f785987156b22eb3663053e6d190c56f632e0 Mon Sep 17 00:00:00 2001 From: Pablo Zmdl Date: Fri, 13 Mar 2026 17:41:30 +0100 Subject: [PATCH] Translate UI via transifex AI-assistant: Copilot v1.0.7 (Claude Sonnet 4.6) Signed-off-by: Pablo Zmdl --- .github/workflows/pull-translations.yml | 62 +++++ php/public/index.php | 2 + php/public/language-switcher.js | 104 ++++++++ php/public/style.css | 31 ++- php/src/Controller/LanguageController.php | 38 +++ php/src/DependencyInjection.php | 5 + php/src/Translation/TranslationManager.php | 264 +++++++++++++++++++++ php/src/Twig/TranslationExtension.php | 48 ++++ php/templates/containers.twig | 20 +- php/translations/.gitkeep | 0 php/translations/pull.sh | 223 +++++++++++++++++ 11 files changed, 791 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/pull-translations.yml create mode 100644 php/public/language-switcher.js create mode 100644 php/src/Controller/LanguageController.php create mode 100644 php/src/Translation/TranslationManager.php create mode 100644 php/src/Twig/TranslationExtension.php create mode 100644 php/translations/.gitkeep create mode 100755 php/translations/pull.sh diff --git a/.github/workflows/pull-translations.yml b/.github/workflows/pull-translations.yml new file mode 100644 index 00000000..4e0f5b7b --- /dev/null +++ b/.github/workflows/pull-translations.yml @@ -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 \ No newline at end of file diff --git a/php/public/index.php b/php/public/index.php index fb4f6117..a3b012f4 100644 --- a/php/public/index.php +++ b/php/public/index.php @@ -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'); diff --git a/php/public/language-switcher.js b/php/public/language-switcher.js new file mode 100644 index 00000000..fe4f112c --- /dev/null +++ b/php/public/language-switcher.js @@ -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 - - - +
+ {% if supportedLanguages|length > 1 %} + + {% endif %} +
+ + + +
+
+
diff --git a/php/translations/.gitkeep b/php/translations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/php/translations/pull.sh b/php/translations/pull.sh new file mode 100755 index 00000000..4883e2e6 --- /dev/null +++ b/php/translations/pull.sh @@ -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 — 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." \ No newline at end of file