mirror of
https://github.com/nextcloud/all-in-one.git
synced 2026-06-01 08:20:10 +00:00
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:
62
.github/workflows/pull-translations.yml
vendored
Normal file
62
.github/workflows/pull-translations.yml
vendored
Normal 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
|
||||
@@ -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');
|
||||
|
||||
104
php/public/language-switcher.js
Normal file
104
php/public/language-switcher.js
Normal 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();
|
||||
}
|
||||
}());
|
||||
@@ -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 */
|
||||
|
||||
38
php/src/Controller/LanguageController.php
Normal file
38
php/src/Controller/LanguageController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
264
php/src/Translation/TranslationManager.php
Normal file
264
php/src/Translation/TranslationManager.php
Normal 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);
|
||||
}
|
||||
}
|
||||
48
php/src/Twig/TranslationExtension.php
Normal file
48
php/src/Twig/TranslationExtension.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
0
php/translations/.gitkeep
Normal file
0
php/translations/.gitkeep
Normal file
223
php/translations/pull.sh
Executable file
223
php/translations/pull.sh
Executable 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."
|
||||
Reference in New Issue
Block a user