Socle complet de TQ-Slator : éditeur, API, CLI, administration

Système de gestion de traductions pensé en CMS headless : un back-office
pour ceux qui traduisent, une API pour ce qui consomme.

Architecture
- Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en
  même origine — ce qui rend viable le cookie de session plutôt qu'un
  jeton en localStorage.
- Deux APIs séparées : Management (session ou clé) et Delivery
  (stateless, clé seule). Les fusionner ferait porter à chaque lecture de
  bundle le coût de la session.
- Stockage canonique en ICU MessageFormat, sérialisation par plateforme.
  Le format d'une plateforme ne contamine pas la base.
- Publication par releases immuables ; le déploiement est un déplacement
  de pointeur, donc le rollback aussi.
- Isolation multi-organisation par filtre Doctrine, avec un test
  d'architecture qui casse la CI si une entité échappe à l'invariant.

Éditeur, deux vues
- Par langue : source et cible, jamais douze colonnes. Grille virtualisée,
  saisie sans bouton « Enregistrer », panneau de contexte permanent.
- Par clé : une clé, toutes ses langues empilées et repliées. Répond à
  « ce libellé est-il prêt partout ? ».
- Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner.
- Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ
  par catégorie CLDR de la langue cible.

Administration
- Deux niveaux : projet (membres, clés API, plateformes) et organisation
  (annuaire des comptes, création de projets).
- Invitations par e-mail, jeton 256 bits stocké haché.
- Désactiver un compte coupe les sessions en cours, pas seulement les
  connexions suivantes.
- Les plateformes s'archivent ; ni elles ni les environnements ne se
  suppriment — la trace explique pourquoi telle clé existe.

CLI tqs
- PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend
  ni de Composer ni de la disponibilité de TQ-Slator.
- init / push / pull / status ; le sync est non destructif par défaut et
  son prune est scopé plateforme.

126 tests, PHPStan niveau 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stephan Morand 2026-08-21 08:16:05 +02:00
commit 9025c64c0b
223 changed files with 43891 additions and 0 deletions

17
.editorconfig Normal file
View file

@ -0,0 +1,17 @@
# editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

79
.env Normal file
View file

@ -0,0 +1,79 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
###> symfony/mailer ###
# Mailpit en local : toutes les invitations sont capturées sur http://localhost:8025
MAILER_DSN=smtp://mailer:1025
###< symfony/mailer ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
DATABASE_URL="mysql://tqslator:tqslator@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4"
###< doctrine/doctrine-bundle ###
###> tq-slator ###
# Organisation unique en v1 (décision 1 : interne, schéma SaaS-ready).
# Le Doctrine filter s'appuie dessus tant qu'aucun contexte utilisateur n'est établi (CLI, workers).
DEFAULT_ORGANIZATION_SLUG=tranquilys
# Racine de stockage des captures d'écran de contexte (décision 7 : stockage local).
# Monté sur un volume Docker ; passer à un adaptateur S3 se fait derrière AssetStorageInterface.
ASSET_STORAGE_PATH=%kernel.project_dir%/var/storage
# Rétention des releases : on conserve toujours celles référencées par un environnement,
# plus les N plus récentes non référencées (décision 6 : « une version de test »).
RELEASE_RETENTION_UNREFERENCED=1
# URL publique du back-office, utilisée dans les liens d'invitation. Elle ne peut
# pas être déduite de la requête : les courriels partent depuis un worker, hors
# contexte HTTP.
BACK_OFFICE_URL=http://localhost:8080
# Durée de validité d'une invitation. Les prestataires externes ont une fenêtre
# plus courte : leur accès porte sur des libellés produit avant annonce, et une
# invitation qui traîne dans une boîte mail est une porte ouverte.
INVITATION_TTL=P7D
INVITATION_TTL_EXTERNAL=P2D
###< tq-slator ###
###> snc/redis ###
REDIS_URL=redis://cache:6379
###< snc/redis ###

4
.env.dev Normal file
View file

@ -0,0 +1,4 @@
###> symfony/framework-bundle ###
APP_SECRET=8a86933946a19a17bf08506aa550a011
###< symfony/framework-bundle ###

3
.env.test Normal file
View file

@ -0,0 +1,3 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###
###> tq-slator ###
/var/storage/
/.phpstan.cache/
/phpstan.neon.local
###< tq-slator ###
.gstack/
###> front ###
/node_modules/
/public/build/
/public/index.html
/.vite/
###< front ###
###> cli ###
/build/

17
.php-cs-fixer.dist.php Normal file
View file

@ -0,0 +1,17 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
->notPath([
'config/bundles.php',
'config/reference.php',
])
;
return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
])
->setFinder($finder)
;

104
Dockerfile Normal file
View file

@ -0,0 +1,104 @@
# syntax=docker/dockerfile:1
# PHP 8.4 et non 8.5 : c'est la version contre laquelle Composer résout les
# dépendances (voir config.platform dans composer.json). L'hôte de dev peut être
# en 8.5 sans que cela change ce qui sera déployé.
ARG PHP_VERSION=8.4
ARG FRANKENPHP_VERSION=1
# ─────────────────────────────────────────────────────────────────────────────
# base — runtime commun dev/prod
# ─────────────────────────────────────────────────────────────────────────────
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS app_base
WORKDIR /app
# `acl` sert au setfacl sur var/ ; `gettext` fournit envsubst utilisé par l'entrypoint.
RUN apt-get update && apt-get install -y --no-install-recommends \
acl \
file \
gettext \
git \
&& rm -rf /var/lib/apt/lists/*
RUN install-php-extensions \
@composer \
apcu \
intl \
opcache \
pdo_mysql \
redis \
zip
ENV COMPOSER_ALLOW_SUPERUSER=1
ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/app.conf.d/
COPY --link frankenphp/Caddyfile /etc/frankenphp/Caddyfile
ENTRYPOINT ["docker-php-entrypoint"]
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
HEALTHCHECK --interval=10s --timeout=3s --start-period=45s --retries=5 \
CMD curl -fsS http://localhost:2019/metrics || exit 1
# ─────────────────────────────────────────────────────────────────────────────
# dev — sources montées en volume, pas copiées
# ─────────────────────────────────────────────────────────────────────────────
FROM app_base AS app_dev
# APP_ENV n'est volontairement PAS défini ici. Le fichier .env fournit déjà
# `dev` par défaut, et une variable d'environnement réelle prendrait le pas sur
# le réglage de phpunit.dist.xml : `bin/phpunit` tournerait alors en dev, où le
# service test.service_container n'existe pas.
ENV XDEBUG_MODE=off
COPY --link frankenphp/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/
RUN install-php-extensions xdebug
# Pas de composer install ici : le volume monté par Compose écraserait vendor/.
# L'entrypoint s'en charge au démarrage du conteneur.
COPY --link docker/entrypoint.dev.sh /usr/local/bin/entrypoint.dev.sh
RUN chmod +x /usr/local/bin/entrypoint.dev.sh
# CMD doit être redéclaré : Docker le remet à zéro dès qu'un étage redéfinit
# ENTRYPOINT, même si l'étage de base en fournissait un.
ENTRYPOINT ["docker-php-entrypoint", "entrypoint.dev.sh"]
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
# ─────────────────────────────────────────────────────────────────────────────
# vendor — couche de dépendances isolée pour maximiser le cache de build
# ─────────────────────────────────────────────────────────────────────────────
FROM app_base AS app_vendor
COPY --link composer.json composer.lock symfony.lock ./
RUN --mount=type=cache,target=/tmp/composer \
composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress
# ─────────────────────────────────────────────────────────────────────────────
# prod — image finale, worker mode activé
# ─────────────────────────────────────────────────────────────────────────────
FROM app_base AS app_prod
ENV APP_ENV=prod
ENV APP_RUNTIME="Runtime\FrankenPhpSymfony\Runtime"
# Active le worker mode : le kernel Symfony reste en mémoire entre les requêtes.
# C'est ce qui met la Delivery API sous les 20 ms.
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
COPY --from=app_vendor --link /app/vendor ./vendor
COPY --link . ./
RUN set -eux; \
mkdir -p var/cache var/log var/storage; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; \
setfacl -R -m u:www-data:rwX -m u:root:rwX var; \
setfacl -dR -m u:www-data:rwX -m u:root:rwX var
VOLUME /app/var/storage

424
README.md Normal file
View file

@ -0,0 +1,424 @@
# TQ-Slator
Système de gestion de traductions (TMS) headless — API-First, spécialisé i18n.
- **Management API** : Symfony 7.4 LTS + API Platform 4, OpenAPI généré depuis les attributs PHP.
- **Delivery API** : contrôleur dédié, bundles figés et immuables, ETag.
- **Base** : MariaDB 11.4.
- **Runtime** : FrankenPHP (worker mode en production).
L'architecture, les décisions et leurs justifications : [`docs/01-architecture-proposal.md`](docs/01-architecture-proposal.md).
---
## Démarrage
```bash
docker compose up --build -d
docker compose exec php bin/console doctrine:fixtures:load --no-interaction
```
| Service | URL |
|---|---|
| API | http://localhost:8080 |
| Documentation OpenAPI | http://localhost:8080/api/docs |
| Sonde de santé | http://localhost:8080/api/v1/health |
| Mailpit (invitations) | http://localhost:8025 |
Les migrations s'appliquent automatiquement au démarrage du conteneur `php`
(voir `docker/entrypoint.dev.sh`). Seul ce service migre ; le `worker` attend
que le schéma soit à jour avant de consommer.
## Comptes de démonstration
Mot de passe commun : `tqslator`.
| Compte | Rôle projet | Périmètre |
|---|---|---|
| `admin@tranquilys.test` | owner + super-admin | tout |
| `dev@tranquilys.test` | developer | clés, API, releases — **pas** les traductions |
| `maria@agence-lingua.test` | translator (externe) | es-ES, pt-BR **uniquement** |
| `jonas@agence-lingua.test` | translator (externe) | de-DE, nl-NL **uniquement** |
| `claire@tranquilys.test` | reviewer | es-ES, de-DE |
| `obs@tranquilys.test` | viewer | lecture seule |
Les deux comptes `agence-lingua` illustrent le RBAC à deux axes : María ne peut
pas écrire une ligne d'allemand, Jonas pas une ligne d'espagnol.
## Clés API de démonstration
```
tqs_test_development000000000000000000000 # translations:read, keys:read, keys:write, releases:read
tqs_test_staging0000000000000000000000000 # idem
tqs_live_production0000000000000000000000 # translations:read, releases:read — lecture seule
```
Déterministes en fixtures uniquement. La génération réelle passe par
`random_bytes` et le secret n'est affiché qu'une fois.
Vérifier une clé :
```bash
curl http://localhost:8080/delivery/v1/whoami \
-H 'Authorization: Bearer tqs_live_production0000000000000000000000'
```
## Jeu de données
Volontairement imparfait, pour que l'ergonomie soit jugeable dès le premier
écran : clés manquantes, sources modifiées après traduction (statut
`needs_review`), pluriels ICU, arabe en RTL avec six formes plurielles,
placeholders, et une clé rattachée à aucune plateforme (bac « Non assignées »).
## Back-office
Interface React servie par FrankenPHP en **même origine** que l'API — c'est ce
qui rend viable le cookie de session plutôt qu'un jeton en localStorage.
```bash
npm ci && npm run build # construit vers public/
npm run dev # Vite sur :5173, relaie /api vers :8080
```
Puis http://localhost:8080/login
Sept écrans, et un principe pour chacun :
- **Projets** — pas un tableau de bord. Répond à « où reste-t-il du travail, et
pour quelle langue ? », puis s'efface. Les langues sur lesquelles vous êtes
habilité passent en premier ; les autres sont grisées et marquées
« consultation ».
- **Éditeur** — trois zones et **deux façons de regarder le même contenu**
(voir plus bas). Grille virtualisée, saisie en ligne sans bouton
« Enregistrer », panneau de contexte permanent. Tous les filtres vivent dans
l'URL, y compris le choix de vue, donc un lien Slack amène la traductrice
exactement sur le bon travail.
- **Mode Focus** — une clé plein écran, `⌘↵` pour enregistrer et passer à la
suivante. Un traducteur ne vient pas explorer, il vient vider une file. Il
existe en deux variantes, une par vue : *une langue à la fois*, ou *toutes
celles qu'on a le droit d'écrire*.
- **Membres** *(administrateurs)* — invitation en trois champs (adresse, rôle,
langues), membres et invitations en attente dans la même liste. « Qui a
accès ? » doit se répondre d'un coup d'œil, accès accordés comme accès en
cours d'octroi.
- **Plateformes** *(administrateurs)* — les surfaces du projet et ses cibles de
déploiement, réunies parce qu'on s'y rend pour la même raison : rendre le
projet livrable. Une plateforme dit quelles clés entrent dans un fichier et
dans quelle syntaxe ; un environnement dit quelle version est servie.
- **Intégration** *(administrateurs)* — création et révocation des clés API.
Le secret n'apparaît **qu'une fois**. Les permissions d'écriture sont en
orange, les lectures en bleu : une clé de production n'a besoin que de
lecture, et la couleur le dit avant la documentation.
- **Administration** *(super-administrateurs, `/admin`)* — hors projet.
Deux onglets : tous les comptes de l'organisation avec leurs appartenances,
et tous les projets avec leur inventaire. C'est le seul endroit d'où l'on
crée un projet, désactive un compte ou nomme un super-administrateur.
Les entrées réservées aux administrateurs ne sont pas grisées, elles sont
**absentes** du menu. Un menu plein d'options inaccessibles apprend à
l'utilisateur que l'interface ne le concerne pas.
### Deux niveaux d'administration, pas un
| | Question posée | Qui répond | Où |
|---|---|---|---|
| **Projet** | Qui a accès à *ce* projet, et sur quelles langues ? | `owner` ou `admin` du projet | Onglets Membres et Intégration |
| **Organisation** | Qui existe dans l'outil, et où va-t-il ? | super-administrateur | `/admin` |
La seconde question traverse les projets, donc traverse aussi les
administrateurs de projet. Un administrateur du projet A n'a pas à savoir qui
travaille sur le projet B.
**Désactiver un compte coupe les sessions en cours**, pas seulement les
connexions suivantes : l'utilisateur est rechargé depuis la base à chaque
requête, et un compte inactif n'est plus trouvé. C'est ce qu'on attend d'un
bouton pressé au moment où un accès doit cesser.
Deux garde-fous : on ne modifie ni ne désactive son propre compte depuis cet
écran, et il doit rester au moins un super-administrateur actif.
### Archiver une plateforme, ne jamais la supprimer
Une plateforme **s'archive**. La supprimer effacerait son rattachement aux clés
via `translation_key_platform`, donc la seule trace expliquant pourquoi telle clé
existe.
Une fois archivée, elle sort des releases suivantes, refuse les `sync` (409, avec
un message qui dit que la décision est humaine et se défait depuis le
back-office), et disparaît des filtres de l'éditeur comme de `tqs init`. Elle
reste visible dans l'écran, avec sa date de retrait et son nombre de clés — le
chiffre qui rend la décision prenable. Les releases **déjà publiées** la
conservent : un instantané immuable ne se réécrit pas rétroactivement, sinon les
ETags déjà servis mentiraient.
La dernière plateforme active ne peut pas être archivée : le bouton est désactivé
avec l'explication, et le serveur refuse aussi.
Un environnement, lui, ne se supprime ni ne s'archive : les applications qui
l'interrogent cesseraient d'être servies, sans qu'aucun signal ne parte au moment
du geste. Le slug d'une plateforme comme d'un environnement est figé après
création — il vit dans les URL de livraison et dans les `tqs.config.json` des
dépôts clients.
Seuls les formats disposant réellement d'un sérialiseur (`icu`, `i18next`) sont
proposés à la création. Offrir `symfony` créerait une plateforme incapable de
recevoir un `sync` comme de produire un bundle : un choix sans issue, découvert
bien plus tard.
**Le traducteur ne voit jamais de syntaxe ICU.** Les variables s'affichent en
pastilles, les pluriels se saisissent dans un champ par catégorie CLDR de la
langue cible — trois en espagnol, six en arabe — et la recomposition en ICU
canonique se fait à l'enregistrement.
### Deux vues, deux questions
Une bascule en haut de l'éditeur, et le choix vit dans l'URL (`?view=`).
| | Question posée | Pour qui |
|---|---|---|
| **Par langue** *(défaut)* | Où en est mon travail en espagnol ? | La traductrice qui vide sa file |
| **Par clé** | Ce libellé est-il prêt partout ? | Avant une mise en production ; après avoir ajouté une clé |
En vue par clé, chaque ligne porte la source puis **une ligne par langue**,
repliée : code de langue, pastille de statut, valeur tronquée. On ouvre la seule
langue sur laquelle on veut agir. Sept champs de saisie ouverts d'emblée
produiraient un mur illisible — et le principe « deux langues à l'écran, jamais
douze » reste tenu, autrement : les langues sont **empilées et repliées**, pas
juxtaposées en colonnes.
Deux différences à connaître :
- **Le filtre de statut change de sens.** Par langue, « à traduire » désigne une
clé non traduite *dans cette langue*. Par clé, il désigne une clé non traduite
dans **au moins une** langue. Les compteurs de l'arbre des namespaces suivent
la vue, sinon ils contrediraient la liste qu'ils surplombent.
- **La recherche porte sur toutes les langues.** Chercher `Cancel` en vue par clé
trouve la clé même si le mot n'existe qu'en anglais.
Un traducteur voit **toutes** les langues en vue par clé — c'est même l'intérêt,
disposer des autres comme contexte — mais les langues hors de son habilitation
sont marquées « consultation » et n'offrent aucun champ de saisie. Le serveur
refuse également, langue par langue.
### Le mode Focus, en deux variantes
| | La file contient | Sur l'écran |
|---|---|---|
| **Par langue** | Les clés à traiter dans la langue affichée | Un champ |
| **Par clé** | Les clés à traiter dans **au moins une des langues qu'on peut écrire** | Un champ par langue restante |
Le gain de la seconde est précis, et c'est le seul qui la justifie : **la source
et son contexte se lisent une fois pour plusieurs langues**. María, habilitée en
espagnol et en portugais, rencontrait la même clé dans deux files séparées et
relisait deux fois « Annuler la séance » avant d'écrire deux phrases voisines.
`⌘↵` descend d'un champ, puis passe à la clé suivante — le geste reste celui du
mode Focus par langue, il traverse simplement plusieurs champs avant de changer
de clé. Les langues **déjà traduites** de la clé restent affichées en dessous,
en lecture : une traduction voisine tranche souvent mieux un registre que la
source elle-même.
La file ne contient que du faisable. Une clé qui ne manque qu'en allemand n'entre
pas dans la file d'une traductrice espagnole. Le sous-ensemble de langues est
calculé **côté serveur** à partir de l'identité courante (`?focus=1`), jamais
reçu du client : une file qu'on pourrait demander pour les langues d'un autre
serait une fuite d'information déguisée en confort.
### Inviter quelqu'un
```
Membres → Inviter → adresse, rôle, langues
```
L'e-mail part de façon asynchrone (Messenger). En développement il atterrit
dans **Mailpit : http://localhost:8025**. Le lien vaut 2 jours.
La page d'acceptation montre le projet, le rôle et les langues **avant** de
demander quoi que ce soit — on ne demande pas de créer un compte sans dire
pour quoi. Le mot de passe (12 caractères minimum) n'est défini que si le
compte n'existe pas encore : inviter une adresse déjà connue ne permet jamais
d'en réinitialiser le mot de passe. La connexion est automatique à
l'acceptation.
## Management API
Documentation OpenAPI générée depuis les attributs PHP : http://localhost:8080/api/docs
| | |
|---|---|
| `GET /api/v1/projects` | Projets visibles — filtrés au niveau SQL |
| `GET /api/v1/projects/{uuid}/grid` | **Grille de l'éditeur** : clé + source + cible en une requête |
| `GET /api/v1/projects/{uuid}/keys` | **Vue par clé** : clé + source + toutes les langues en une requête |
| `GET/PATCH /api/v1/keys/{uuid}/translations/{locale}` | Écriture validée par le moteur de format |
| `POST /api/v1/projects/{uuid}/platforms/{slug}/sync` | **Endpoint du CLI** : inventaire des clés, diff en retour |
| `GET /api/v1/locales` | Référentiel de langues (lecture seule) |
| `GET/POST /api/v1/projects/{uuid}/platforms` | Plateformes |
| `GET/POST/PATCH /api/v1/projects/{uuid}/platforms-admin` | Plateformes : création, réglages, archivage |
| `GET/POST/PATCH /api/v1/projects/{uuid}/environments` | Environnements : création et renommage |
| `GET/PATCH /api/v1/admin/users` | Annuaire des comptes — super-admin uniquement |
| `GET/POST /api/v1/admin/projects` | Inventaire et création de projets — super-admin uniquement |
Deux identités authentifient la Management API : **session cookie** pour le
back-office, **clé API** pour le CLI. Les Voters savent raisonner sur les deux —
une clé ne vaut que pour son projet, et ne peut jamais administrer ni créer
d'autres clés.
`/api/v1/admin/` est hors de portée d'une clé API quelles que soient ses
permissions, et la règle est posée deux fois : dans `access_control` et par un
`#[IsGranted]` sur chaque contrôleur. Une clé vit dans un dépôt et une chaîne
d'intégration continue ; elle n'a rien à faire près des comptes.
Trois garanties tenues par les tests :
- **Un non-membre reçoit 404, pas 403.** L'absence et l'interdiction sont
indiscernables : le nom d'un produit avant son annonce est une information.
- **Le `sync` est non destructif par défaut** et son `prune` est scopé
plateforme : retirer une clé du code web ne peut pas archiver une clé iOS.
- **Une traduction qui perd ou invente une variable est refusée** (422), de même
qu'un pluriel arabe incomplet. Un dépassement de longueur passe, en
avertissement.
## CLI `tqs`
Le binaire que les développeurs d'applications clientes installent. C'est lui
qui décide si l'outil est adopté ou contourné.
```bash
# Construire le binaire (un fichier, ~3 Mo, aucune dépendance)
php -d phar.readonly=0 bin/build-tqs-phar.php
cp build/tqs.phar /usr/local/bin/tqs
# Dans le dépôt de l'application cliente
export TQS_API_KEY=tqs_test_…
tqs init # déduit projet, environnement, plateformes et langues de la clé
tqs push --dry-run # ce qu'un envoi changerait
tqs push # applique
tqs pull # récupère les traductions publiées
tqs status # avancement par langue
```
En intégration continue :
```bash
tqs push --prune # code de sortie 2 si des clés sont refusées
tqs pull --check # échoue si des fichiers ne sont pas commités
tqs status --fail-under=80 # échoue si une langue passe sous 80 %
```
**La clé API n'est jamais dans `tqs.config.json`.** Ce fichier est commité — il
décrit le lien entre un dépôt et un projet, ce qui est une information d'équipe.
Le secret vient de `TQS_API_KEY`, et le CLI refuse explicitement un champ
`apiKey` dans la configuration : une clé commitée reste dans l'historique Git.
Le CLI ne connaît **rien** aux formats de messages : il aplatit du JSON et parle
HTTP. Tout le reste — parsing ICU, regroupement des pluriels i18next, validation
— vit côté serveur. Dupliquer le moteur dans le CLI garantirait qu'il diverge, et
un CLI qui valide différemment du serveur est pire que pas de validation.
## Releases et Delivery API
Publier fige le contenu en fichiers **immuables**, un par (plateforme, langue).
Déployer déplace un pointeur. Revenir en arrière déplace le même pointeur dans
l'autre sens — il n'y a pas d'endpoint de rollback, parce qu'il n'y a rien de
particulier à faire.
```bash
P=<uuid-projet>
E=<uuid-environnement>
# Publier, et déployer dans la foulée sur la recette
curl -X POST localhost:8080/api/v1/projects/$P/releases/publish \
-H 'Content-Type: application/ld+json' \
-d '{"notes":"Corrections espagnoles","deployTo":["staging"]}'
# Comparer deux versions
curl localhost:8080/api/v1/releases/<uuid-v1>/diff/<uuid-v2>
# Déployer — ou revenir en arrière, c'est le même appel
curl -X PUT localhost:8080/api/v1/environments/$E/release \
-H 'Content-Type: application/ld+json' -d '{"version":42}'
# Ce que lit l'application en production
curl localhost:8080/delivery/v1/tranquilys/production/web/es-ES.json \
-H 'Authorization: Bearer tqs_live_…'
```
Quatre propriétés que la publication continue ne peut pas offrir :
- **Un brouillon ne peut pas atteindre la production.** La Delivery API ne sait
lire que `release_bundle`, et un bundle ne contient que du publiable.
- **Rollback instantané.** Aucun fichier n'est régénéré.
- **Cache agressif sans invalidation.** Le contenu d'une release ne change
jamais ; seul le pointeur d'environnement bouge, et il n'est pas caché.
- **Diff entre versions**, calculé sur les fichiers réellement produits — donc
un changement de repli y apparaît, puisque l'utilisateur final le verra.
La publication est **tout ou rien** : si un seul bundle ne peut pas être
construit (`select` de genre vers i18next, conflit de structure imbriquée…),
aucun n'est enregistré et la réponse liste tout ce qu'il faut corriger.
## Moteur de format
Le stockage est **toujours** en ICU MessageFormat canonique ; les formats des
plateformes ne sont que des projections produites à la publication.
```
src/Translation/Format/
├── Ast/ nœuds : littéral, argument, plural, selectordinal, select, dièse
├── Parser/ IcuParser (récursif descendant), I18nextParser
├── Serializer/ IcuSerializer (canonique déterministe), I18nextSerializer
├── PlaceholderExtractor.php alimente translation_key.placeholders
├── MessageValidator.php erreurs bloquantes vs avertissements
└── MessageFormatRegistry.php aiguillage par MessageFormat
```
Trois invariants tenus par les tests :
- **`parse(serialize(ast)) == ast`** sur tout le corpus ICU.
- **La forme canonique est un point fixe** — condition de stabilité de
`source_checksum`, donc de l'absence de bascules `needs_review` fantômes.
- **Une construction non exprimable fait échouer la publication**, avec un
message qui dit quoi faire (`select` de genre, `offset:`, variable de pluriel
autre que `count`, sélecteur exact autre que `=0`).
Transformations lossy documentées et testées : un pluriel en milieu de phrase est
redistribué dans chaque clé i18next (rendu identique, structure différente), et
le type d'un argument ICU est perdu (le nom survit). Voir
`docs/01-architecture-proposal.md` §4.3.
## Développement
```bash
docker compose exec php composer install
docker compose exec php vendor/bin/phpstan analyse --memory-limit=1G # niveau 8, doit rester à zéro
docker compose exec php bin/phpunit
docker compose exec php bin/console doctrine:migrations:migrate
```
### Points d'attention
- **`doctrine:migrations:diff` proposera de supprimer `idx_tkp_platform_key`.**
Ne pas accepter : Doctrine ne sait pas exprimer un index sur une table de
jointement ManyToMany, cet index est déclaré à la main dans la migration
initiale et sert la requête de grille.
- **Le dépôt est configuré en `core.ignorecase false`.** Les clés de traduction
sont sensibles à la casse, et macOS ne l'est pas par défaut.
- **Composer résout contre PHP 8.4**, la version du conteneur, pas celle de la
machine de dev. Voir `config.platform` dans `composer.json`.
## Structure
```
src/
├── Controller/ Auth, santé — les ressources métier passent par API Platform
├── DataFixtures/ Jeu de démonstration
├── Doctrine/ Filtre d'isolation multi-organisation
├── Entity/ 21 entités
├── Enum/ Statuts, rôles, formats, scopes
├── EventListener/ Établissement du contexte d'organisation
├── Message/ Messages asynchrones (Messenger)
├── Repository/ Requêtes — dont la requête de grille de l'éditeur
├── Security/ Authenticator par clé API, Voters
└── Tenant/ Contexte d'organisation
```

417
assets/api/client.ts Normal file
View file

@ -0,0 +1,417 @@
import type {
AdminEnvironment,
AdminPlatform,
AdminProject,
AdminProjectList,
AdminUser,
AdminUserList,
ApiKeyList,
EnvironmentList,
PlatformList,
CreatedApiKey,
CurrentUser,
InvitationPreview,
MemberList,
ProjectRole,
GridRow,
KeyRow,
NamespaceTree,
Page,
Project,
ProjectStats,
TranslationEntry,
TranslationStatus,
} from './types';
/**
* Client HTTP de la Management API.
*
* Aucune bibliothèque : `fetch` suffit, et l'API étant servie en même origine,
* le cookie de session part tout seul. La seule règle à ne pas oublier est
* `credentials: 'same-origin'`, sans quoi le navigateur n'envoie rien.
*/
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly detail?: string,
) {
super(message);
this.name = 'ApiError';
}
/** L'utilisateur n'est plus authentifié : sa session a expiré. */
get isUnauthenticated(): boolean {
return this.status === 401;
}
/** Rejet de validation — le message est destiné à être lu tel quel. */
get isValidation(): boolean {
return this.status === 422;
}
/** Quelqu'un d'autre a modifié la traduction entre-temps. */
get isConflict(): boolean {
return this.status === 409;
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...init,
credentials: 'same-origin',
headers: {
Accept: 'application/ld+json',
...init.headers,
},
});
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
const payload = text ? safeParse(text) : null;
if (!response.ok) {
// API Platform renvoie `detail` ; nos contrôleurs aussi. C'est le champ
// rédigé pour un humain, celui qu'on affiche sans le reformuler.
const detail =
(payload && typeof payload === 'object' && 'detail' in payload
? String((payload as { detail: unknown }).detail)
: undefined) ?? response.statusText;
throw new ApiError(response.status, detail, detail);
}
return payload as T;
}
function safeParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Déplie une collection Hydra.
*
* JSON-LD est retenu précisément pour `totalItems` : la grille virtualisée en a
* besoin pour dimensionner sa zone de défilement sans avoir tout chargé.
*/
function toPage<T>(payload: { member?: T[]; totalItems?: number }): Page<T> {
return { items: payload.member ?? [], total: payload.totalItems ?? 0 };
}
export interface GridQuery {
projectUuid: string;
locale: string;
platform?: string;
status?: TranslationStatus;
namespace?: string;
q?: string;
unassigned?: boolean;
page?: number;
itemsPerPage?: number;
}
export const api = {
async me(): Promise<CurrentUser> {
return request<CurrentUser>('/api/v1/auth/me', { headers: { Accept: 'application/json' } });
},
async login(email: string, password: string): Promise<CurrentUser> {
return request<CurrentUser>('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email, password }),
});
},
async logout(): Promise<void> {
await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'same-origin' });
},
async projects(): Promise<Project[]> {
const payload = await request<{ member?: Project[] }>('/api/v1/projects');
return payload.member ?? [];
},
async stats(projectUuid: string): Promise<ProjectStats> {
return request<ProjectStats>(`/api/v1/projects/${projectUuid}/stats`, {
headers: { Accept: 'application/json' },
});
},
async namespaces(projectUuid: string, locale: string): Promise<NamespaceTree> {
return request<NamespaceTree>(
`/api/v1/projects/${projectUuid}/namespaces?locale=${encodeURIComponent(locale)}`,
{ headers: { Accept: 'application/json' } },
);
},
async grid(query: GridQuery): Promise<Page<GridRow>> {
const params = new URLSearchParams({ locale: query.locale });
if (query.platform) params.set('platform', query.platform);
if (query.status) params.set('status', query.status);
if (query.namespace) params.set('namespace', query.namespace);
if (query.q) params.set('q', query.q);
if (query.unassigned) params.set('unassigned', '1');
params.set('page', String(query.page ?? 1));
params.set('itemsPerPage', String(query.itemsPerPage ?? 100));
const payload = await request<{ member?: GridRow[]; totalItems?: number }>(
`/api/v1/projects/${query.projectUuid}/grid?${params}`,
);
return toPage(payload);
},
// ── Administration ───────────────────────────────────────────────────
async members(project: string): Promise<MemberList> {
return request<MemberList>(`/api/v1/projects/${project}/members`, {
headers: { Accept: 'application/json' },
});
},
async invite(
project: string,
body: { email: string; role: ProjectRole; locales: string[]; isExternal: boolean },
): Promise<{ email: string; expiresAt: string; message: string }> {
return request(`/api/v1/projects/${project}/members/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async updateMember(
project: string,
memberUuid: string,
body: { role?: ProjectRole; locales?: string[] },
): Promise<void> {
await request(`/api/v1/projects/${project}/members/${memberUuid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async removeMember(project: string, memberUuid: string): Promise<void> {
await request(`/api/v1/projects/${project}/members/${memberUuid}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
},
async apiKeys(project: string): Promise<ApiKeyList> {
return request<ApiKeyList>(`/api/v1/projects/${project}/api-keys`, {
headers: { Accept: 'application/json' },
});
},
async createApiKey(
project: string,
body: { name: string; environment: string; scopes: string[] },
): Promise<CreatedApiKey> {
return request<CreatedApiKey>(`/api/v1/projects/${project}/api-keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async revokeApiKey(project: string, keyUuid: string): Promise<void> {
await request(`/api/v1/projects/${project}/api-keys/${keyUuid}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
},
/**
* Vue par clé : une clé, toutes ses langues.
*
* Pas de paramètre `locale` la réponse les contient toutes. `status`
* change de sens au passage : « au moins une langue cible est dans cet
* état », et non « la langue affichée l'est ».
*/
async keys(query: Omit<GridQuery, 'locale'> & { focus?: boolean }): Promise<Page<KeyRow>> {
const params = new URLSearchParams();
if (query.platform) params.set('platform', query.platform);
if (query.status) params.set('status', query.status);
if (query.namespace) params.set('namespace', query.namespace);
if (query.q) params.set('q', query.q);
if (query.unassigned) params.set('unassigned', '1');
// Le serveur déduit LUI-MÊME les langues concernées de l'identité
// courante : ce drapeau demande une file, il ne la décrit pas.
if (query.focus) params.set('focus', '1');
params.set('page', String(query.page ?? 1));
params.set('itemsPerPage', String(query.itemsPerPage ?? 50));
const payload = await request<{ member?: KeyRow[]; totalItems?: number }>(
`/api/v1/projects/${query.projectUuid}/keys?${params}`,
);
return toPage(payload);
},
// ── Plateformes et environnements ────────────────────────────────────
async platforms(project: string): Promise<PlatformList> {
return request<PlatformList>(`/api/v1/projects/${project}/platforms-admin`, {
headers: { Accept: 'application/json' },
});
},
async createPlatform(
project: string,
body: {
name: string;
slug: string;
kind: string;
messageFormat: string;
exportLayout: string;
defaultMaxLength: number | null;
},
): Promise<AdminPlatform> {
return request<AdminPlatform>(`/api/v1/projects/${project}/platforms-admin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async updatePlatform(
project: string,
platformUuid: string,
body: {
name?: string;
messageFormat?: string;
exportLayout?: string;
defaultMaxLength?: number | null;
isArchived?: boolean;
},
): Promise<AdminPlatform> {
return request<AdminPlatform>(`/api/v1/projects/${project}/platforms-admin/${platformUuid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async environments(project: string): Promise<EnvironmentList> {
return request<EnvironmentList>(`/api/v1/projects/${project}/environments`, {
headers: { Accept: 'application/json' },
});
},
async createEnvironment(
project: string,
body: { name: string; slug: string },
): Promise<AdminEnvironment> {
return request<AdminEnvironment>(`/api/v1/projects/${project}/environments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async updateEnvironment(
project: string,
environmentUuid: string,
body: { name?: string },
): Promise<AdminEnvironment> {
return request<AdminEnvironment>(
`/api/v1/projects/${project}/environments/${environmentUuid}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
},
);
},
// ── Administration globale (super-admin) ─────────────────────────────
async adminUsers(search?: string): Promise<AdminUserList> {
const query = search ? `?q=${encodeURIComponent(search)}` : '';
return request<AdminUserList>(`/api/v1/admin/users${query}`, {
headers: { Accept: 'application/json' },
});
},
async updateAdminUser(
uuid: string,
body: { isActive?: boolean; isSuperAdmin?: boolean },
): Promise<AdminUser> {
return request<AdminUser>(`/api/v1/admin/users/${uuid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async adminProjects(): Promise<AdminProjectList> {
return request<AdminProjectList>('/api/v1/admin/projects', {
headers: { Accept: 'application/json' },
});
},
async createProject(body: {
name: string;
slug: string;
description: string;
sourceLocale: string;
targetLocales: string[];
}): Promise<AdminProject> {
return request<AdminProject>('/api/v1/admin/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
// ── Invitation (endpoints publics) ───────────────────────────────────
async invitationPreview(token: string): Promise<InvitationPreview> {
return request<InvitationPreview>(`/api/v1/invitations/${encodeURIComponent(token)}`, {
headers: { Accept: 'application/json' },
});
},
async acceptInvitation(
token: string,
body: { name: string; password: string },
): Promise<{ email: string; name: string; message: string }> {
return request(`/api/v1/invitations/${encodeURIComponent(token)}/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
},
async writeTranslation(
keyUuid: string,
locale: string,
body: { value: string | null; status?: TranslationStatus; version?: number },
): Promise<TranslationEntry> {
return request<TranslationEntry>(
`/api/v1/keys/${keyUuid}/translations/${encodeURIComponent(locale)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
Accept: 'application/json',
},
body: JSON.stringify(body),
},
);
},
};

183
assets/api/icu.ts Normal file
View file

@ -0,0 +1,183 @@
/**
* Composition et décomposition des messages pluriels ICU, côté éditeur.
*
* Raison d'être : **le traducteur ne doit jamais voir de syntaxe ICU.**
* `{count, plural, one {# séance} other {# séances}}` est illisible pour un
* non-technicien, et lui demander de l'écrire à la main revient à lui confier
* un éditeur de code déguisé en outil de traduction.
*
* L'éditeur affiche donc un champ par catégorie CLDR et recompose la chaîne à
* l'enregistrement. Le serveur reste l'autorité : il reparse et revalide tout
* ce qui lui arrive. Ce module n'est qu'une commodité de saisie, pas un
* contrôle de sécurité.
*/
export interface PluralParts {
/** Texte précédant la construction plurielle. */
prefix: string;
/** Une entrée par sélecteur : catégorie CLDR, ou `=0`. */
branches: Record<string, string>;
/** Texte suivant la construction plurielle. */
suffix: string;
/** Nom de la variable de comptage. */
variable: string;
}
const PLURAL_PATTERN = /\{\s*(\w+)\s*,\s*plural\s*,/;
export function isPluralMessage(value: string | null): boolean {
return value !== null && PLURAL_PATTERN.test(value);
}
/**
* Décompose un message ICU pluriel en champs éditables.
*
* Renvoie `null` si le message n'est pas pluriel ou si sa structure dépasse ce
* que l'éditeur simple sait représenter (imbrication, `select`). Dans ce cas
* l'interface bascule sur un champ texte brut plutôt que de risquer une
* recomposition qui perdrait de l'information.
*/
export function decomposePlural(value: string | null): PluralParts | null {
if (value === null) return null;
const match = PLURAL_PATTERN.exec(value);
if (!match || match.index === undefined) return null;
const variable = match[1];
if (!variable) return null;
const openIndex = match.index;
const bodyStart = openIndex + match[0].length;
const closeIndex = findMatchingBrace(value, openIndex);
if (closeIndex === -1) return null;
const body = value.slice(bodyStart, closeIndex);
const branches = parseBranches(body);
if (branches === null) return null;
return {
prefix: value.slice(0, openIndex),
branches,
suffix: value.slice(closeIndex + 1),
variable,
};
}
/**
* Recompose un message ICU depuis les champs de l'éditeur.
*
* Les branches vides sont omises : une catégorie non renseignée doit remonter
* au serveur comme absente, pour qu'il la refuse si la langue l'exige. La
* masquer par une chaîne vide produirait une traduction silencieusement fausse.
*/
export function composePlural(parts: PluralParts, order: string[]): string {
const rendered = order
.filter((selector) => (parts.branches[selector] ?? '').trim() !== '')
.map((selector) => `${selector} {${parts.branches[selector]}}`)
.join(' ');
if (rendered === '') return '';
return `${parts.prefix}{${parts.variable}, plural, ${rendered}}${parts.suffix}`;
}
/**
* Ordre d'affichage des champs : sélecteurs exacts d'abord, puis catégories
* CLDR du plus spécifique au repli. `other` ferme la liste c'est le cas
* général, il se lit en dernier.
*/
export function selectorOrder(pluralCategories: string[], existing: string[]): string[] {
const canonical = ['zero', 'one', 'two', 'few', 'many', 'other'];
const exact = existing.filter((s) => s.startsWith('=')).sort();
const categories = canonical.filter(
(category) => pluralCategories.includes(category) || existing.includes(category),
);
return [...exact, ...categories];
}
/**
* Variables `{nom}` présentes dans un message, hors constructions plurielles.
*
* Sert au retour immédiat de l'éditeur pendant la frappe. Ce n'est PAS la
* validation : celle-ci s'appuie sur l'AST côté serveur, seul capable de
* distinguer un argument d'un sélecteur de branche.
*/
export function extractVariables(value: string): string[] {
const found = new Set<string>();
const pattern = /\{\s*(\w+)\s*(?:,\s*(\w+))?/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(value)) !== null) {
const name = match[1];
const type = match[2];
// `{count, plural,` déclare bien la variable count : on la garde.
// En revanche `one {`, `other {` sont des sélecteurs, jamais des
// variables — ils sont écartés par la liste ci-dessous.
if (name && !['zero', 'one', 'two', 'few', 'many', 'other'].includes(name)) {
found.add(name);
} else if (name && type) {
found.add(name);
}
}
return [...found];
}
function findMatchingBrace(value: string, openIndex: number): number {
let depth = 0;
for (let i = openIndex; i < value.length; i++) {
const char = value[i];
if (char === "'" && (value[i + 1] === '{' || value[i + 1] === '}')) {
// Accolade échappée : on saute jusqu'à l'apostrophe fermante.
const end = value.indexOf("'", i + 2);
i = end === -1 ? value.length : end;
continue;
}
if (char === '{') depth++;
else if (char === '}') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
function parseBranches(body: string): Record<string, string> | null {
const branches: Record<string, string> = {};
let index = 0;
while (index < body.length) {
while (index < body.length && /\s/.test(body[index] ?? '')) index++;
if (index >= body.length) break;
const selectorStart = index;
while (index < body.length && !/[\s{]/.test(body[index] ?? '')) index++;
const selector = body.slice(selectorStart, index);
if (selector === '') return null;
while (index < body.length && /\s/.test(body[index] ?? '')) index++;
if (body[index] !== '{') return null;
const end = findMatchingBrace(body, index);
if (end === -1) return null;
const content = body.slice(index + 1, end);
// Sous-message contenant lui-même une construction : hors du périmètre
// de l'éditeur simple, on renonce plutôt que de mal recomposer.
if (/\{\s*\w+\s*,\s*(plural|select|selectordinal)\s*,/.test(content)) return null;
branches[selector] = content;
index = end + 1;
}
return Object.keys(branches).length > 0 ? branches : null;
}

326
assets/api/types.ts Normal file
View file

@ -0,0 +1,326 @@
export type TranslationStatus =
| 'untranslated'
| 'draft'
| 'translated'
| 'needs_review'
| 'reviewed';
export interface CurrentUser {
id: string;
email: string;
name: string;
uiLocale: string;
roles: string[];
isSuperAdmin: boolean;
isExternal: boolean;
requiresTotpSetup: boolean;
}
// ── Plateformes et environnements ────────────────────────────────────────
export interface AdminPlatform {
uuid: string;
name: string;
slug: string;
kind: string;
messageFormat: string;
exportLayout: 'nested' | 'flat';
defaultMaxLength: number | null;
acceptsPush: boolean;
isArchived: boolean;
archivedAt: string | null;
keyCount: number;
}
export interface PlatformList {
platforms: AdminPlatform[];
kinds: { value: string; defaultMessageFormat: string }[];
formats: { value: string; fileExtension: string }[];
layouts: string[];
}
export interface AdminEnvironment {
uuid: string;
name: string;
slug: string;
currentRelease: {
uuid: string;
label: string;
version: number;
publishedAt: string | null;
keyCount: number;
} | null;
activeKeyCount: number;
totalKeyCount: number;
}
export interface EnvironmentList {
environments: AdminEnvironment[];
}
// ── Administration globale ───────────────────────────────────────────────
export interface AdminMembership {
project: string;
projectUuid: string;
role: ProjectRole;
locales: string[];
coversAllLocales: boolean;
}
export interface AdminUser {
uuid: string;
name: string;
email: string;
isActive: boolean;
isExternal: boolean;
isSuperAdmin: boolean;
authProvider: string;
lastLoginAt: string | null;
createdAt: string;
memberships: AdminMembership[];
}
export interface AdminUserList {
users: AdminUser[];
superAdminCount: number;
}
export interface AdminProject {
uuid: string;
name: string;
slug: string;
description: string | null;
sourceLocale: string;
targetLocales: string[];
platformCount: number;
environmentCount: number;
memberCount: number;
owners: string[];
}
export interface AdminProjectList {
projects: AdminProject[];
locales: { code: string; name: string; nativeName: string }[];
}
export interface Platform {
uuid: string;
name: string;
slug: string;
kind: string;
messageFormat: string;
acceptsPush: boolean;
}
export interface Project {
uuid: string;
name: string;
slug: string;
description: string | null;
sourceLocale: LocaleRef;
platforms: Platform[];
targetLocales: LocaleRef[];
}
export interface LocaleRef {
code: string;
name: string;
nativeName: string;
direction: 'ltr' | 'rtl';
pluralCategories: string[];
}
export interface LocaleStats extends LocaleRef {
isSource: boolean;
/** L'utilisateur courant peut-il écrire dans cette langue ? */
canWrite: boolean;
total: number;
untranslated: number;
draft: number;
translated: number;
needsReview: number;
reviewed: number;
completion: number;
}
export interface Viewer {
role: string | null;
canAdminister: boolean;
canPublish: boolean;
}
export interface ProjectStats {
project: string;
projectName: string;
viewer: Viewer;
sourceLocale: string;
locales: LocaleStats[];
platforms: { slug: string; name: string; kind: string; messageFormat: string }[];
}
export interface NamespaceNode {
path: string;
name: string;
depth: number;
actionable: number;
}
/** L'état d'une clé dans une langue, en vue par clé. */
export interface KeyRowTarget {
locale: string;
value: string | null;
status: TranslationStatus;
isStale: boolean;
isMachineTranslated: boolean;
updatedAt: string | null;
updatedBy: string | null;
version: number;
}
export interface KeyRow {
id: string;
keyPath: string;
namespace: string | null;
description: string | null;
maxLength: number | null;
placeholders: Placeholder[];
platforms: string[];
sourceValue: string | null;
targets: KeyRowTarget[];
/** Part des langues cibles dans un état publiable. */
completion: number;
}
export interface NamespaceTree {
namespaces: NamespaceNode[];
unassigned: number;
}
export interface Placeholder {
name: string;
type: string;
example?: string;
}
export interface GridRow {
id: string;
keyPath: string;
namespace: string | null;
description: string | null;
maxLength: number | null;
placeholders: Placeholder[];
platforms: string[];
sourceValue: string | null;
targetValue: string | null;
status: TranslationStatus;
isStale: boolean;
isMachineTranslated: boolean;
updatedAt: string | null;
updatedBy: string | null;
version: number;
}
export interface ValidationWarning {
severity: 'error' | 'warning';
code: string;
message: string;
}
export interface TranslationEntry {
id: string;
keyPath: string;
locale: string;
sourceValue: string | null;
value: string | null;
status: TranslationStatus;
currentVersion: number;
isStale: boolean;
isMachineTranslated: boolean;
updatedAt: string | null;
updatedBy: string | null;
pluralCategories: string[];
warnings: ValidationWarning[];
flaggedForReview: number;
}
export interface Page<T> {
items: T[];
total: number;
}
export type ProjectRole = 'owner' | 'admin' | 'developer' | 'translator' | 'reviewer' | 'viewer';
export interface Member {
uuid: string;
name: string;
email: string;
role: ProjectRole;
isExternal: boolean;
isActive: boolean;
lastLoginAt: string | null;
locales: string[];
coversAllLocales: boolean;
}
export interface PendingInvitation {
email: string;
role: ProjectRole | null;
locales: string[];
isExternal: boolean;
expiresAt: string;
invitedBy: string | null;
}
export interface RoleDescriptor {
value: ProjectRole;
canWriteTranslations: boolean;
canManageKeys: boolean;
canPublish: boolean;
canAdminister: boolean;
}
export interface MemberList {
members: Member[];
pending: PendingInvitation[];
roles: RoleDescriptor[];
}
export interface ApiKeySummary {
uuid: string;
name: string;
prefix: string;
environment: string;
scopes: string[];
createdAt: string;
createdBy: string | null;
lastUsedAt: string | null;
expiresAt: string | null;
revokedAt: string | null;
isUsable: boolean;
}
export interface ApiKeyList {
keys: ApiKeySummary[];
environments: { slug: string; name: string; currentRelease: string | null }[];
scopes: { value: string; isWrite: boolean }[];
}
/** Réponse de création : le secret n'apparaît qu'ici, une seule fois. */
export interface CreatedApiKey {
uuid: string;
name: string;
environment: string;
scopes: string[];
secret: string;
warning: string;
}
export interface InvitationPreview {
email: string;
project: string | null;
role: ProjectRole | null;
locales: string[];
isExternal: boolean;
expiresAt: string;
}

81
assets/app.css Normal file
View file

@ -0,0 +1,81 @@
@import 'tailwindcss';
/**
* Système visuel.
*
* Deux contraintes gouvernent ces choix, et aucune n'est esthétique :
*
* 1. Une traductrice passe deux heures d'affilée sur cet écran. Contraste
* franc mais fonds jamais purement blancs, aucune animation gratuite,
* aucune couleur saturée en aplat large.
*
* 2. Le statut d'une traduction doit se lire sans effort de mémoire. Cinq
* couleurs, toujours les mêmes, et le rouge réservé au seul état qui
* désigne un bug en production.
*/
@theme {
--font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
/* Gris légèrement bleutés : moins clinique qu'un gris neutre sur de longues sessions. */
--color-ink-50: #f8fafc;
--color-ink-100: #f1f5f9;
--color-ink-200: #e2e8f0;
--color-ink-300: #cbd5e1;
--color-ink-400: #94a3b8;
--color-ink-500: #64748b;
--color-ink-600: #475569;
--color-ink-700: #334155;
--color-ink-800: #1e293b;
--color-ink-900: #0f172a;
--color-accent: #4f46e5;
--color-accent-soft: #eef2ff;
/* Statuts — voir docs/01-architecture-proposal.md §5.5 */
--color-status-untranslated: #94a3b8;
--color-status-draft: #d97706;
--color-status-needs-review: #dc2626;
--color-status-translated: #2563eb;
--color-status-reviewed: #059669;
}
html,
body,
#root {
height: 100%;
}
body {
background: var(--color-ink-100);
color: var(--color-ink-800);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Les compteurs changent en permanence : sans chasse fixe, ils tressautent. */
.tabular {
font-variant-numeric: tabular-nums;
}
/* Anneau de focus visible partout : l'outil se pilote au clavier. */
*:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
border-radius: 2px;
}
/* Barres de défilement discrètes — trois panneaux défilent simultanément. */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-ink-300) transparent;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

View file

@ -0,0 +1,81 @@
import { createContext, use, useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { ApiError, api } from '@/api/client';
import type { CurrentUser } from '@/api/types';
interface AuthState {
user: CurrentUser | null;
/** Vrai tant que la session n'a pas été vérifiée au démarrage. */
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
// Première requête de l'application : « qui suis-je ? ». Le cookie de
// session est déjà là ou il ne l'est pas ; rien à lire dans localStorage,
// rien à rafraîchir. C'est le bénéfice concret du choix cookie sur JWT.
useEffect(() => {
let cancelled = false;
api.me()
.then((me) => {
if (!cancelled) setUser(me);
})
.catch(() => {
if (!cancelled) setUser(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (email: string, password: string) => {
setUser(await api.login(email, password));
}, []);
const logout = useCallback(async () => {
await api.logout();
setUser(null);
}, []);
const value = useMemo<AuthState>(
() => ({ user, loading, login, logout }),
[user, loading, login, logout],
);
return <AuthContext value={value}>{children}</AuthContext>;
}
export function useAuth(): AuthState {
const context = use(AuthContext);
if (context === null) {
throw new Error('useAuth doit être utilisé dans un AuthProvider.');
}
return context;
}
/**
* Message d'erreur destiné à l'utilisateur.
*
* Les messages de l'API sont déjà rédigés pour être lus : les reformuler ici
* ferait perdre leur précision (« la variable {prenom} est absente » devient
* « une erreur est survenue », ce qui n'aide personne).
*/
export function humanMessage(error: unknown): string {
if (error instanceof ApiError) return error.message;
if (error instanceof Error) return error.message;
return 'Une erreur inattendue est survenue.';
}

View file

@ -0,0 +1,53 @@
import { Link, NavLink } from 'react-router-dom';
import { useAuth } from '@/auth/AuthProvider';
/**
* Navigation d'un projet.
*
* Les entrées réservées aux administrateurs ne sont pas grisées : elles sont
* ABSENTES. Un menu plein d'options inaccessibles apprend à l'utilisateur que
* l'interface ne le concerne pas — c'est exactement l'inverse du but recherché.
*
* Le rôle n'est pas encore exposé par l'API de projet ; en attendant, on affiche
* l'administration aux comptes qui ont un rôle global élevé, et le serveur reste
* l'autorité — chaque endpoint refuse ce qu'il doit refuser.
*/
export function ProjectNav({ projectUuid, canAdminister }: { projectUuid: string; canAdminister: boolean }) {
const { user, logout } = useAuth();
const item = (to: string, label: string) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`rounded px-2.5 py-1 text-sm transition-colors ${
isActive ? 'bg-accent-soft font-medium text-accent' : 'text-ink-600 hover:bg-ink-100'
}`
}
>
{label}
</NavLink>
);
return (
<div className="flex items-center gap-1">
<Link to="/projects" className="mr-2 text-sm font-semibold text-ink-900 hover:text-accent">
TQ-Slator
</Link>
<div className="h-5 w-px bg-ink-200" />
{item(`/p/${projectUuid}/editor`, 'Éditeur')}
{canAdminister && item(`/p/${projectUuid}/members`, 'Membres')}
{canAdminister && item(`/p/${projectUuid}/platforms`, 'Plateformes')}
{canAdminister && item(`/p/${projectUuid}/integration`, 'Intégration')}
<span className="ml-auto flex items-center gap-3 text-xs text-ink-400">
<span>{user?.name}</span>
<button onClick={() => void logout()} className="hover:text-ink-700">
Déconnexion
</button>
</span>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { Link } from 'react-router-dom';
import { ProjectNav } from '@/components/ProjectNav';
/**
* Refus explicite plutôt que formulaire cassé.
*
* Le serveur refuse déjà chaque endpoint d'administration, mais un utilisateur
* qui arrive par URL directe voit alors un écran à moitié vide, un menu déroulant
* sans options et un « Access Denied » brut au moment de valider. Il croit à une
* panne, alors que le système fonctionne exactement comme prévu.
*
* La barre de navigation reste en place : un refus ne doit pas être un cul-de-sac.
* Sans elle, l'utilisateur perd le retour aux projets ET la déconnexion, et la
* seule sortie serait le bouton « Précédent » du navigateur.
*/
export function RequiresAdmin({ projectUuid }: { projectUuid: string }) {
return (
<div className="flex h-full flex-col bg-ink-100">
<header className="shrink-0 border-b border-ink-200 bg-white px-4 py-2.5">
<ProjectNav projectUuid={projectUuid} canAdminister={false} />
</header>
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-md text-center">
<p className="text-lg font-medium text-ink-800">Cette page est réservée aux administrateurs</p>
<p className="mt-2 text-sm text-ink-500">
La gestion des membres et des clés d'accès demande un rôle « admin » ou
« owner » sur ce projet. Demandez-le à un administrateur si vous en avez besoin.
</p>
<Link
to={`/p/${projectUuid}/editor`}
className="mt-6 inline-block rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
Revenir à l'éditeur
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,162 @@
import { Fragment, useMemo } from 'react';
/**
* Affiche une valeur source en masquant la syntaxe ICU.
*
* `Séance avec {praticien}, le {date, date, long}` se lit mal : le `, date, long`
* est une instruction de formatage destinée à la machine, pas au traducteur.
* Elle est ici réduite à une pastille portant le seul nom de la variable, ce qui
* rend la phrase lisible tout en signalant sans ambiguïté ce qui n'est pas du
* texte et donc ce qu'il ne faut pas traduire.
*
* La chaîne réelle reste intacte : ce composant ne fait que la RENDRE.
*/
export function SourceText({ value, className = '' }: { value: string; className?: string }) {
const parts = useMemo(() => tokenize(value), [value]);
const hasPlural = /\{\s*[\w.-]+\s*,\s*(?:plural|selectordinal)\s*,/.test(value);
return (
<span className={className}>
{parts.map((part, index) =>
part.kind === 'text' ? (
<Fragment key={index}>{part.text}</Fragment>
) : (
<span
key={index}
className="mx-0.5 inline-block rounded bg-indigo-50 px-1 font-mono text-[0.85em] text-indigo-700"
title={
part.detail === null
? `Variable « ${part.text} »`
: `Variable « ${part.text} », formatée en ${part.detail}`
}
>
{part.text}
</span>
),
)}
{/* Le message varie selon un nombre : le signaler sans montrer la
mécanique, que l'éditeur de pluriels prend en charge. */}
{hasPlural && (
<span
className="ml-1.5 inline-block rounded bg-ink-100 px-1 align-middle text-[0.7em] uppercase tracking-wide text-ink-500"
title="Ce message a plusieurs formes selon le nombre."
>
pluriel
</span>
)}
</span>
);
}
type Token =
| { kind: 'text'; text: string }
| { kind: 'var'; text: string; detail: string | null };
/**
* Réduit une construction plurielle à sa forme générale.
*
* `{count, plural, one {# séance} other {# séances}} à venir` devient
* « # séances à venir ». Le traducteur a besoin de comprendre la PHRASE ; la
* mécanique de sélection des formes, elle, est prise en charge par l'éditeur de
* pluriels au moment de la saisie. Lui montrer les deux à la fois, c'est lui
* demander de lire du code pour accéder à du texte.
*/
function flattenPlurals(value: string): string {
const pattern = /\{\s*[\w.-]+\s*,\s*(?:plural|selectordinal|select)\s*,/;
let result = value;
for (let guard = 0; guard < 5; guard++) {
const match = pattern.exec(result);
if (!match || match.index === undefined) break;
const close = matchingBrace(result, match.index);
if (close === -1) break;
const body = result.slice(match.index + match[0].length, close);
const fallback = lastBranch(body);
if (fallback === null) break;
result = result.slice(0, match.index) + fallback + result.slice(close + 1);
}
return result;
}
/** Contenu de la branche `other`, ou à défaut de la dernière branche. */
function lastBranch(body: string): string | null {
const branches: { selector: string; content: string }[] = [];
let index = 0;
while (index < body.length) {
while (index < body.length && /\s/.test(body[index] ?? '')) index++;
if (index >= body.length) break;
const start = index;
while (index < body.length && !/[\s{]/.test(body[index] ?? '')) index++;
const selector = body.slice(start, index);
while (index < body.length && /\s/.test(body[index] ?? '')) index++;
if (body[index] !== '{') return null;
const end = matchingBrace(body, index);
if (end === -1) return null;
branches.push({ selector, content: body.slice(index + 1, end) });
index = end + 1;
}
if (branches.length === 0) return null;
return (branches.find((b) => b.selector === 'other') ?? branches[branches.length - 1])!.content;
}
function matchingBrace(value: string, from: number): number {
let depth = 0;
for (let i = value.indexOf('{', from); i >= 0 && i < value.length; i++) {
if (value[i] === '{') depth++;
else if (value[i] === '}') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
/**
* Découpe volontairement simple : on ne cherche pas à parser l'ICU, seulement à
* repérer les arguments de premier niveau, une fois les pluriels aplatis.
*/
function tokenize(source: string): Token[] {
const value = flattenPlurals(source);
const tokens: Token[] = [];
const pattern = /\{\s*([\w.-]+)\s*(?:,\s*([^{}]*))?\}/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(value)) !== null) {
const name = match[1];
if (name === undefined) continue;
// Un sélecteur de branche (`one {`, `other {`) n'est pas un argument.
if (['zero', 'one', 'two', 'few', 'many', 'other'].includes(name) && match[2] === undefined) {
continue;
}
if (match.index > lastIndex) {
tokens.push({ kind: 'text', text: value.slice(lastIndex, match.index) });
}
tokens.push({ kind: 'var', text: name, detail: match[2]?.trim() || null });
lastIndex = match.index + match[0].length;
}
if (lastIndex < value.length) {
tokens.push({ kind: 'text', text: value.slice(lastIndex) });
}
return tokens;
}

View file

@ -0,0 +1,107 @@
import type { TranslationStatus } from '@/api/types';
/**
* Signalétique des statuts une seule définition, utilisée partout.
*
* Centralisée volontairement : cinq couleurs qui varieraient d'un écran à
* l'autre obligeraient l'utilisateur à réapprendre le code à chaque page, ce
* qui annule tout l'intérêt d'un code couleur.
*/
export const STATUS_META: Record<
TranslationStatus,
{ label: string; dot: string; text: string; bg: string }
> = {
untranslated: {
label: 'À traduire',
dot: 'bg-ink-300',
text: 'text-ink-500',
bg: 'bg-ink-100',
},
draft: {
label: 'Brouillon',
dot: 'bg-amber-500',
text: 'text-amber-700',
bg: 'bg-amber-50',
},
// Le seul statut qui désigne un bug en production : il doit être le plus
// visible de l'interface, d'où le rouge, réservé à lui seul.
needs_review: {
label: 'À revoir',
dot: 'bg-red-500',
text: 'text-red-700',
bg: 'bg-red-50',
},
translated: {
label: 'Traduite',
dot: 'bg-blue-500',
text: 'text-blue-700',
bg: 'bg-blue-50',
},
reviewed: {
label: 'Validée',
dot: 'bg-emerald-500',
text: 'text-emerald-700',
bg: 'bg-emerald-50',
},
};
export function StatusDot({ status, title }: { status: TranslationStatus; title?: string }) {
const meta = STATUS_META[status];
return (
<span
className={`inline-block size-2 shrink-0 rounded-full ${meta.dot}`}
title={title ?? meta.label}
aria-label={meta.label}
/>
);
}
export function StatusBadge({ status }: { status: TranslationStatus }) {
const meta = STATUS_META[status];
return (
<span
className={`inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-[11px] font-medium ${meta.bg} ${meta.text}`}
>
<span className={`size-1.5 rounded-full ${meta.dot}`} />
{meta.label}
</span>
);
}
/**
* Barre d'avancement segmentée.
*
* Une seule barre de pourcentage cacherait la répartition : « 60 % » ne dit pas
* s'il reste du travail de traduction ou de relecture, ni combien de traductions
* sont devenues obsolètes. Les segments répondent aux trois questions d'un coup.
*/
export function ProgressBar({
reviewed,
translated,
needsReview,
draft,
total,
}: {
reviewed: number;
translated: number;
needsReview: number;
draft: number;
total: number;
}) {
if (total === 0) {
return <div className="h-1.5 w-full rounded-full bg-ink-200" />;
}
const pct = (value: number) => `${(value / total) * 100}%`;
return (
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-ink-200">
<div className="bg-emerald-500" style={{ width: pct(reviewed) }} />
<div className="bg-blue-500" style={{ width: pct(translated) }} />
<div className="bg-red-500" style={{ width: pct(needsReview) }} />
<div className="bg-amber-500" style={{ width: pct(draft) }} />
</div>
);
}

View file

@ -0,0 +1,210 @@
import type { KeyRowTarget, Placeholder, TranslationStatus } from '@/api/types';
import { SourceText } from '@/components/SourceText';
import { StatusBadge, StatusDot } from '@/components/Status';
/**
* Ce qui appartient à la CLÉ, et vaut donc dans les deux vues.
*/
export interface KeyContext {
keyPath: string;
description: string | null;
maxLength: number | null;
placeholders: Placeholder[];
platforms: string[];
sourceValue: string | null;
}
/**
* Ce qui appartient à une LANGUE, et n'a de sens qu'en vue par langue.
*
* Séparé du reste plutôt que fondu dedans : le panneau servait initialement une
* seule vue, et les deux natures d'information s'y confondaient sans dommage.
* Avec deux vues, la confusion coûterait un panneau qui affiche « traduite »
* au-dessus d'une liste six langues ne le sont pas.
*/
export interface TargetContext {
localeCode: string;
status: TranslationStatus;
isStale: boolean;
isMachineTranslated: boolean;
updatedAt: string | null;
updatedBy: string | null;
}
/**
* Le contexte de la clé sélectionnée.
*
* Panneau PERMANENT, jamais une modale. Le contexte n'est pas une information
* secondaire qu'on consulte au besoin : c'est la matière première du traducteur.
* Une modale l'obligerait à choisir entre lire le contexte et voir sa saisie
* exactement ce qu'il ne faut pas lui demander.
*/
export function ContextPanel({
row,
sourceCode,
target,
targets,
}: {
row: KeyContext | null;
sourceCode: string;
/** Vue par langue : l'état de la langue affichée. */
target?: TargetContext | null;
/** Vue par clé : l'état de toutes les langues, en résumé. */
targets?: KeyRowTarget[] | null;
}) {
if (row === null) {
return (
<aside className="hidden w-72 shrink-0 border-l border-ink-200 bg-white p-4 lg:block">
<p className="text-xs text-ink-400">
Sélectionnez une clé pour afficher son contexte : description, contraintes,
variables attendues et historique.
</p>
</aside>
);
}
return (
<aside className="hidden w-72 shrink-0 overflow-y-auto border-l border-ink-200 bg-white lg:block">
<div className="border-b border-ink-200 p-4">
<p className="break-all font-mono text-xs text-ink-900">{row.keyPath}</p>
{target != null && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<StatusBadge status={target.status} />
{target.isStale && (
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[11px] font-medium text-red-700">
source modifiée
</span>
)}
{target.isMachineTranslated && (
<span className="rounded bg-ink-100 px-1.5 py-0.5 text-[11px] text-ink-600">
traduction automatique
</span>
)}
</div>
)}
</div>
{/* Vue par clé : le panneau résume l'état de chaque langue plutôt
que celui d'une seule, qu'il n'a aucune raison de privilégier. */}
{targets != null && targets.length > 0 && (
<Section title="Langues">
<ul className="space-y-1">
{targets.map((entry) => (
<li key={entry.locale} className="flex items-center gap-2 text-xs">
<StatusDot status={entry.status} />
<span className="w-12 shrink-0 font-mono text-ink-500">
{entry.locale}
</span>
<span className="truncate text-ink-500">
{entry.isStale ? 'source modifiée' : STATUS_LABEL[entry.status]}
</span>
</li>
))}
</ul>
</Section>
)}
<Section title="Contexte">
{row.description !== null && row.description !== '' ? (
<p className="text-sm leading-relaxed text-ink-700">{row.description}</p>
) : (
// L'absence de description n'est pas neutre : c'est la
// première cause de mauvaise traduction. On la signale au
// lieu de laisser un vide silencieux.
<p className="text-sm text-amber-700">
Aucune description. Demandez-en une à l'équipe de développement : sans
contexte, le choix de formulation est un pari.
</p>
)}
</Section>
{row.maxLength !== null && (
<Section title="Contrainte">
<p className="text-sm text-ink-700">
<span className="tabular font-medium">{row.maxLength}</span> caractères
maximum
</p>
<p className="mt-1 text-xs text-ink-400">
Au-delà, le texte risque d'être tronqué à l'affichage.
</p>
</Section>
)}
{row.placeholders.length > 0 && (
<Section title="Variables">
<ul className="space-y-1.5">
{row.placeholders.map((placeholder) => (
<li key={placeholder.name} className="text-sm">
<code className="rounded bg-ink-100 px-1 py-0.5 font-mono text-xs text-ink-800">
{'{'}
{placeholder.name}
{'}'}
</code>
<span className="ml-2 text-xs text-ink-500">{placeholder.type}</span>
</li>
))}
</ul>
<p className="mt-2 text-xs text-ink-400">
Toutes doivent figurer dans la traduction leur ordre, en revanche, est
libre.
</p>
</Section>
)}
<Section title="Plateformes">
{row.platforms.length > 0 ? (
<div className="flex flex-wrap gap-1">
{row.platforms.map((slug) => (
<span
key={slug}
className="rounded bg-ink-100 px-1.5 py-0.5 font-mono text-[11px] text-ink-600"
>
{slug}
</span>
))}
</div>
) : (
<p className="text-sm text-amber-700">
Rattachée à aucune plateforme : cette clé n'entre dans aucun fichier livré.
</p>
)}
</Section>
<Section title="Valeur source">
<p className="whitespace-pre-wrap rounded bg-ink-50 p-2 text-sm text-ink-700">
{row.sourceValue === null ? '—' : <SourceText value={row.sourceValue} />}
</p>
<p className="mt-1 font-mono text-[11px] text-ink-400">{sourceCode}</p>
</Section>
{target != null && target.updatedAt !== null && (
<Section title="Dernière modification">
<p className="text-xs text-ink-500">
{new Date(target.updatedAt).toLocaleString('fr-FR')}
{target.updatedBy !== null && <> · {target.updatedBy}</>}
</p>
<p className="mt-1 font-mono text-[11px] text-ink-400">{target.localeCode}</p>
</Section>
)}
</aside>
);
}
const STATUS_LABEL: Record<TranslationStatus, string> = {
untranslated: 'à traduire',
draft: 'brouillon',
needs_review: 'à revoir',
translated: 'traduite',
reviewed: 'validée',
};
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="border-b border-ink-100 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
{title}
</h3>
{children}
</section>
);
}

200
assets/editor/FocusMode.tsx Normal file
View file

@ -0,0 +1,200 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/api/client';
import type { GridRow } from '@/api/types';
import { SourceText } from '@/components/SourceText';
import { TranslationInput } from '@/editor/TranslationInput';
interface Props {
projectUuid: string;
locale: string;
direction: 'ltr' | 'rtl';
pluralCategories: string[];
platform?: string;
onClose: () => void;
}
/**
* Mode Focus la fonctionnalité qui différencie l'outil.
*
* Une traductrice n'explore pas un catalogue : elle **vide une file**. Lui
* proposer une grille et la laisser choisir par commencer, c'est lui
* transférer une décision qu'elle n'a pas envie de prendre quarante-sept fois.
*
* Ici : une clé, tout son contexte, et deux raccourcis. Pas de menu, pas de
* barre latérale, pas de souris. C'est ce qui fait passer « traduire 47 clés »
* de quarante-cinq minutes à douze.
*/
export function FocusMode({
projectUuid,
locale,
direction,
pluralCategories,
platform,
onClose,
}: Props) {
const [index, setIndex] = useState(0);
const [done, setDone] = useState<Set<string>>(new Set());
// La file est chargée UNE fois à l'ouverture. La rafraîchir à chaque
// enregistrement ferait disparaître la ligne courante sous les doigts —
// le pire défaut possible pour un mode de saisie en rafale.
const queue = useQuery({
queryKey: ['focus-queue', projectUuid, locale, platform],
queryFn: () =>
api.grid({
projectUuid,
locale,
platform,
status: 'untranslated',
itemsPerPage: 200,
}),
staleTime: Infinity,
refetchOnWindowFocus: false,
});
const rows = queue.data?.items ?? [];
const current: GridRow | undefined = rows[index];
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
function next() {
setIndex((value) => Math.min(value + 1, rows.length));
}
function previous() {
setIndex((value) => Math.max(value - 1, 0));
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-white">
<header className="flex shrink-0 items-center gap-4 border-b border-ink-200 px-5 py-3">
<span className="tabular text-sm font-medium text-ink-700">
{Math.min(index + 1, rows.length)} / {rows.length}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-ink-200">
<div
className="h-full bg-accent transition-[width] duration-200"
style={{
width: rows.length === 0 ? '0%' : `${(done.size / rows.length) * 100}%`,
}}
/>
</div>
<span className="tabular text-xs text-ink-400">{done.size} traitée{done.size > 1 ? 's' : ''}</span>
<button
onClick={onClose}
className="rounded px-2 py-1 text-sm text-ink-500 hover:bg-ink-100"
>
Quitter <kbd className="ml-1 font-mono text-[11px]">Échap</kbd>
</button>
</header>
<div className="flex min-h-0 flex-1 items-start justify-center overflow-y-auto px-6 py-10">
{queue.isLoading && <p className="text-sm text-ink-400">Constitution de la file</p>}
{!queue.isLoading && current === undefined && (
<div className="max-w-md text-center">
<p className="text-lg font-medium text-ink-800">File terminée.</p>
<p className="mt-2 text-sm text-ink-500">
{done.size > 0
? `${done.size} traduction${done.size > 1 ? 's' : ''} enregistrée${done.size > 1 ? 's' : ''}.`
: 'Aucune clé ne restait à traiter.'}
</p>
<button
onClick={onClose}
className="mt-6 rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
Revenir à la grille
</button>
</div>
)}
{current !== undefined && (
<div className="w-full max-w-2xl">
<div className="flex items-center gap-2">
<p className="break-all font-mono text-xs text-ink-500">
{current.keyPath}
</p>
{current.platforms.map((slug) => (
<span
key={slug}
className="rounded bg-ink-100 px-1 py-0.5 font-mono text-[10px] text-ink-500"
>
{slug}
</span>
))}
</div>
{current.description !== null && current.description !== '' && (
<p className="mt-4 rounded-md bg-ink-50 px-3 py-2 text-sm leading-relaxed text-ink-600">
{current.description}
</p>
)}
<p className="mt-6 whitespace-pre-wrap text-lg leading-relaxed text-ink-900">
{current.sourceValue !== null && <SourceText value={current.sourceValue} />}
</p>
<div className="mt-6">
<TranslationInput
key={current.id}
keyUuid={current.id}
locale={locale}
value={current.targetValue}
version={current.version}
direction={direction}
pluralCategories={pluralCategories}
maxLength={current.maxLength}
autoFocus
onSaved={() => setDone((prev) => new Set(prev).add(current.id))}
onRequestNext={next}
/>
</div>
<div className="mt-8 flex items-center justify-between text-xs text-ink-400">
<button
onClick={previous}
disabled={index === 0}
className="rounded px-2 py-1 hover:bg-ink-100 disabled:opacity-40"
>
Précédente
</button>
<p className="flex items-center gap-4">
<span>
<kbd className="rounded bg-ink-100 px-1.5 py-0.5 font-mono"></kbd>{' '}
enregistrer et suivante
</span>
<span>
<kbd className="rounded bg-ink-100 px-1.5 py-0.5 font-mono">Échap</kbd>{' '}
quitter
</span>
</p>
<button
onClick={next}
className="rounded px-2 py-1 hover:bg-ink-100"
>
Passer
</button>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,317 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/api/client';
import type { KeyRow, LocaleStats, TranslationStatus } from '@/api/types';
import { SourceText } from '@/components/SourceText';
import { StatusDot } from '@/components/Status';
import { TranslationInput } from '@/editor/TranslationInput';
interface Props {
projectUuid: string;
/** Langues cibles du projet, avec l'habilitation d'écriture de l'utilisateur. */
locales: LocaleStats[];
platform?: string;
namespace?: string;
onClose: () => void;
}
const ACTIONABLE: TranslationStatus[] = ['untranslated', 'draft', 'needs_review'];
/**
* Mode Focus toutes langues confondues.
*
* Le pendant du mode Focus par langue, et non son remplaçant. Celui-ci enchaîne
* les clés d'UNE langue ; celui-là enchaîne les clés en présentant d'un coup
* **toutes les langues sur lesquelles l'utilisateur a quelque chose à faire**.
*
* Le gain est précis, et c'est le seul qui justifie l'écran : la source et son
* contexte se lisent UNE fois pour plusieurs langues. María, habilitée en
* espagnol et en portugais, rencontrait jusqu'ici deux fois la même clé dans
* deux files séparées, et relisait deux fois « Annuler la séance » avant
* d'écrire deux phrases voisines. Ici elle la lit une fois.
*
* Trois conséquences de conception :
*
* 1. **La file ne contient que du faisable.** Une clé qui ne manque qu'en
* allemand n'entre pas dans la file d'une traductrice espagnole : elle la
* traverserait sans rien pouvoir y faire. Le filtrage est serveur, à partir
* de l'identité courante.
* 2. **Les langues déjà faites restent visibles, en lecture.** Une traduction
* voisine déjà écrite est la meilleure matière première qui soit souvent
* meilleure que la source elle-même pour trancher un registre.
* 3. ** descend d'un champ, puis passe à la clé suivante.** Le geste reste
* celui du mode Focus par langue ; il traverse simplement plusieurs champs
* avant de changer de clé.
*/
export function KeyFocusMode({ projectUuid, locales, platform, namespace, onClose }: Props) {
const [index, setIndex] = useState(0);
const [done, setDone] = useState<Set<string>>(new Set());
const fieldRefs = useRef<(HTMLDivElement | null)[]>([]);
// La file est constituée UNE fois à l'ouverture. La rafraîchir à chaque
// enregistrement ferait disparaître la clé courante sous les doigts — le
// pire défaut possible pour un mode de saisie en rafale.
const queue = useQuery({
queryKey: ['key-focus-queue', projectUuid, platform, namespace],
queryFn: () =>
api.keys({
projectUuid,
platform,
namespace,
focus: true,
itemsPerPage: 200,
}),
staleTime: Infinity,
refetchOnWindowFocus: false,
});
const rows = queue.data?.items ?? [];
const current: KeyRow | undefined = rows[index];
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const next = useCallback(() => {
fieldRefs.current = [];
setIndex((value) => Math.min(value + 1, rows.length));
}, [rows.length]);
function previous() {
fieldRefs.current = [];
setIndex((value) => Math.max(value - 1, 0));
}
// Les langues à traiter sur CETTE clé : écrivables et pas encore faites.
const writable = locales.filter((locale) => locale.canWrite);
const byCode = new Map((current?.targets ?? []).map((target) => [target.locale, target]));
const todo = writable.filter((locale) => {
const status = byCode.get(locale.code)?.status ?? 'untranslated';
return ACTIONABLE.includes(status);
});
const reference = (current?.targets ?? []).filter(
(target) => !todo.some((locale) => locale.code === target.locale) && target.value !== null,
);
/** ⌘↵ : champ suivant, ou clé suivante si c'était le dernier. */
function advanceFrom(position: number) {
const nextField = fieldRefs.current[position + 1];
if (nextField != null) {
nextField.querySelector('textarea')?.focus();
return;
}
next();
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-white">
<header className="flex shrink-0 items-center gap-4 border-b border-ink-200 px-5 py-3">
<span className="tabular text-sm font-medium text-ink-700">
{Math.min(index + 1, rows.length)} / {rows.length}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-ink-200">
<div
className="h-full bg-accent transition-[width] duration-200"
style={{
width: rows.length === 0 ? '0%' : `${(done.size / rows.length) * 100}%`,
}}
/>
</div>
<span className="tabular text-xs text-ink-400">
{done.size} traitée{done.size > 1 ? 's' : ''}
</span>
<span className="rounded bg-ink-100 px-2 py-0.5 font-mono text-[11px] text-ink-500">
{writable.map((l) => l.code).join(' · ')}
</span>
<button
onClick={onClose}
className="rounded px-2 py-1 text-sm text-ink-500 hover:bg-ink-100"
>
Quitter <kbd className="ml-1 font-mono text-[11px]">Échap</kbd>
</button>
</header>
<div className="flex min-h-0 flex-1 items-start justify-center overflow-y-auto px-6 py-10">
{queue.isLoading && <p className="text-sm text-ink-400">Constitution de la file</p>}
{!queue.isLoading && writable.length === 0 && (
<div className="max-w-md text-center">
<p className="text-lg font-medium text-ink-800">Aucune langue à traiter.</p>
<p className="mt-2 text-sm text-ink-500">
Vous n'êtes habilité à écrire dans aucune langue de ce projet. Le mode
Focus n'a rien à vous proposer ; la consultation reste ouverte.
</p>
<button
onClick={onClose}
className="mt-6 rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
Revenir à la liste
</button>
</div>
)}
{!queue.isLoading && writable.length > 0 && current === undefined && (
<div className="max-w-md text-center">
<p className="text-lg font-medium text-ink-800">File terminée.</p>
<p className="mt-2 text-sm text-ink-500">
{done.size > 0
? `${done.size} clé${done.size > 1 ? 's' : ''} traitée${done.size > 1 ? 's' : ''}.`
: 'Aucune clé ne restait à traiter dans vos langues.'}
</p>
<button
onClick={onClose}
className="mt-6 rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
Revenir à la liste
</button>
</div>
)}
{current !== undefined && writable.length > 0 && (
<div className="w-full max-w-3xl">
<div className="flex items-center gap-2">
<p className="break-all font-mono text-xs text-ink-500">
{current.keyPath}
</p>
{current.platforms.map((slug) => (
<span
key={slug}
className="rounded bg-ink-100 px-1 py-0.5 font-mono text-[10px] text-ink-500"
>
{slug}
</span>
))}
<span className="tabular ml-auto shrink-0 rounded bg-ink-100 px-1.5 py-0.5 text-[11px] text-ink-600">
{current.completion}%
</span>
</div>
{current.description !== null && current.description !== '' && (
<p className="mt-4 rounded-md bg-ink-50 px-3 py-2 text-sm leading-relaxed text-ink-600">
{current.description}
</p>
)}
<p className="mt-6 whitespace-pre-wrap text-lg leading-relaxed text-ink-900">
{current.sourceValue !== null && <SourceText value={current.sourceValue} />}
</p>
{/* Un champ par langue restant à faire. La clé de React
inclut l'identifiant de la clé de traduction : sans
cela, passer à la clé suivante réutiliserait les
composants et garderait le texte précédent à l'écran. */}
<div className="mt-6 space-y-5">
{todo.map((locale, position) => {
const target = byCode.get(locale.code);
return (
<div
key={`${current.id}:${locale.code}`}
ref={(element) => {
fieldRefs.current[position] = element;
}}
>
<div className="mb-1.5 flex items-center gap-2">
<StatusDot status={target?.status ?? 'untranslated'} />
<span className="font-mono text-xs font-medium text-ink-700">
{locale.code}
</span>
<span className="text-xs text-ink-400">
{locale.nativeName}
</span>
{target?.isStale === true && (
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-700">
source modifiée
</span>
)}
</div>
<TranslationInput
keyUuid={current.id}
locale={locale.code}
value={target?.value ?? null}
version={target?.version ?? 0}
direction={locale.direction}
pluralCategories={locale.pluralCategories}
maxLength={current.maxLength}
autoFocus={position === 0}
onSaved={() =>
setDone((prev) => new Set(prev).add(current.id))
}
onRequestNext={() => advanceFrom(position)}
/>
</div>
);
})}
</div>
{/* Les langues déjà faites, en lecture. Une traduction
voisine tranche souvent mieux un registre que la
source elle-même. */}
{reference.length > 0 && (
<div className="mt-8 rounded-md border border-ink-100 bg-ink-50/60 p-3">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Déjà traduites
</p>
<ul className="space-y-1">
{reference.map((target) => (
<li key={target.locale} className="flex gap-2 text-sm">
<span className="w-12 shrink-0 font-mono text-[11px] text-ink-400">
{target.locale}
</span>
<span className="text-ink-600">{target.value}</span>
</li>
))}
</ul>
</div>
)}
<div className="mt-8 flex items-center justify-between text-xs text-ink-400">
<button
onClick={previous}
disabled={index === 0}
className="rounded px-2 py-1 hover:bg-ink-100 disabled:opacity-40"
>
Précédente
</button>
<p className="flex items-center gap-4">
<span>
<kbd className="rounded bg-ink-100 px-1.5 py-0.5 font-mono"></kbd>{' '}
enregistrer et descendre
</span>
<span>
<kbd className="rounded bg-ink-100 px-1.5 py-0.5 font-mono">Échap</kbd>{' '}
quitter
</span>
</p>
<button onClick={next} className="rounded px-2 py-1 hover:bg-ink-100">
Passer
</button>
</div>
</div>
)}
</div>
</div>
);
}

304
assets/editor/KeyGrid.tsx Normal file
View file

@ -0,0 +1,304 @@
import { useEffect, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { KeyRow, KeyRowTarget, LocaleStats } from '@/api/types';
import { SourceText } from '@/components/SourceText';
import { STATUS_META, StatusDot } from '@/components/Status';
import { TranslationInput } from '@/editor/TranslationInput';
interface Props {
rows: KeyRow[];
total: number;
loading: boolean;
/** Langues cibles du projet, avec l'habilitation d'écriture de l'utilisateur. */
locales: LocaleStats[];
sourceCode: string;
selectedId: string | null;
onSelect: (id: string) => void;
}
/**
* La vue par clé : une clé, toutes ses langues.
*
* Elle ne remplace pas la grille par langue, elle répond à une autre question.
* La grille sert la traductrice qui vide sa file dans UNE langue ; celle-ci
* sert qui doit juger d'une clé « ce libellé est-il prêt partout ? » avant
* une mise en production, ou juste après avoir ajouté une clé.
*
* **Les langues sont repliées par défaut.** Sept champs de saisie ouverts par
* clé produiraient un mur illisible et une page de vingt mille pixels ; la
* bande de statuts donne la réponse d'un coup d'œil, et l'on ouvre la seule
* langue sur laquelle on veut agir. C'est aussi ce qui garde la virtualisation
* honnête : les hauteurs restent proches de l'estimation.
*/
export function KeyGrid({
rows,
total,
loading,
locales,
sourceCode,
selectedId,
onSelect,
}: Props) {
const parentRef = useRef<HTMLDivElement>(null);
// Même règle que dans la grille par langue : un panneau de contexte vide au
// moment où l'utilisateur découvre l'écran est une colonne perdue.
const first = rows[0]?.id;
useEffect(() => {
if (selectedId === null && first !== undefined) onSelect(first);
}, [first, selectedId, onSelect]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
overscan: 6,
getItemKey: (index) => rows[index]?.id ?? index,
});
if (loading && rows.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-ink-400">
Chargement des clés
</div>
);
}
if (rows.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-6 text-center">
<p className="text-sm font-medium text-ink-700">Aucune clé ne correspond.</p>
<p className="text-xs text-ink-400">
En vue par clé, le filtre de statut retient les clés dont{' '}
<strong>au moins une</strong> langue est dans cet état.
</p>
</div>
);
}
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex shrink-0 items-baseline justify-between border-b border-ink-200 bg-white px-4 py-1.5">
<span className="tabular text-xs text-ink-500">
{rows.length < total ? `${rows.length} sur ${total}` : `${total}`} clé
{total > 1 ? 's' : ''}
</span>
<span className="text-[11px] text-ink-400">
{locales.length} langue{locales.length > 1 ? 's' : ''} cible
{locales.length > 1 ? 's' : ''}
</span>
</div>
<div ref={parentRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const row = rows[item.index];
if (!row) return null;
return (
<div
key={item.key}
ref={virtualizer.measureElement}
data-index={item.index}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${item.start}px)` }}
>
<KeyCard
row={row}
locales={locales}
sourceCode={sourceCode}
selected={row.id === selectedId}
onSelect={() => onSelect(row.id)}
/>
</div>
);
})}
</div>
</div>
</div>
);
}
function KeyCard({
row,
locales,
sourceCode,
selected,
onSelect,
}: {
row: KeyRow;
locales: LocaleStats[];
sourceCode: string;
selected: boolean;
onSelect: () => void;
}) {
const [openLocale, setOpenLocale] = useState<string | null>(null);
const byCode = new Map(row.targets.map((target) => [target.locale, target]));
return (
<div
onClick={onSelect}
className={`border-b border-ink-100 px-4 py-3 transition-colors ${
selected ? 'bg-accent-soft/30' : 'bg-white'
}`}
>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs text-ink-700">{row.keyPath}</span>
<span className="ml-auto flex shrink-0 items-center gap-1">
{row.platforms.map((slug) => (
<span
key={slug}
className="rounded bg-ink-100 px-1 py-0.5 font-mono text-[10px] text-ink-500"
>
{slug}
</span>
))}
{row.platforms.length === 0 && (
<span
className="rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-800"
title="Rattachée à aucune plateforme : n'entre dans aucun fichier livré."
>
non assignée
</span>
)}
<span
className={`tabular ml-1 rounded px-1.5 py-0.5 text-[11px] font-medium ${
row.completion === 100
? 'bg-emerald-100 text-emerald-800'
: 'bg-ink-100 text-ink-600'
}`}
title="Part des langues cibles dans un état publiable."
>
{row.completion}%
</span>
</span>
</div>
<p className="mt-1.5 flex gap-2 text-sm leading-snug">
<span className="mt-0.5 shrink-0 font-mono text-[11px] text-ink-400">
{sourceCode}
</span>
<span className="whitespace-pre-wrap text-ink-600">
{row.sourceValue === null ? (
<em className="text-ink-300">source absente</em>
) : (
<SourceText value={row.sourceValue} />
)}
</span>
</p>
{/* La bande de langues : une ligne par langue, refermée. Elle répond
à « en est cette clé ? » sans rien ouvrir. */}
<div className="mt-2 divide-y divide-ink-50 rounded border border-ink-100">
{locales.map((locale) => {
const target = byCode.get(locale.code);
const open = openLocale === locale.code;
return (
<LocaleLine
key={locale.code}
keyUuid={row.id}
locale={locale}
target={target}
maxLength={row.maxLength}
open={open}
onToggle={() => setOpenLocale(open ? null : locale.code)}
/>
);
})}
</div>
</div>
);
}
function LocaleLine({
keyUuid,
locale,
target,
maxLength,
open,
onToggle,
}: {
keyUuid: string;
locale: LocaleStats;
target: KeyRowTarget | undefined;
maxLength: number | null;
open: boolean;
onToggle: () => void;
}) {
const status = target?.status ?? 'untranslated';
const value = target?.value ?? null;
const meta = STATUS_META[status];
return (
<div className={open ? 'bg-ink-50' : ''}>
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-2 px-2 py-1 text-left transition-colors hover:bg-ink-50"
>
<span className="w-12 shrink-0 font-mono text-[11px] text-ink-500">
{locale.code}
</span>
<StatusDot status={status} />
<span
className={`min-w-0 flex-1 truncate text-sm ${
value === null ? 'text-ink-300' : 'text-ink-800'
}`}
dir={locale.direction}
>
{value ?? meta.label}
</span>
{target?.isStale === true && (
<span className="shrink-0 rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-700">
source modifiée
</span>
)}
{/* Dit AVANT d'ouvrir le champ. Découvrir qu'on n'a pas le droit
d'écrire après avoir tapé sa traduction est le pire moment. */}
{!locale.canWrite && (
<span className="shrink-0 text-[10px] text-ink-400">consultation</span>
)}
<span className="shrink-0 text-[10px] text-ink-300">{open ? '▾' : '▸'}</span>
</button>
{open && (
<div className="px-2 pb-2 pl-16">
{locale.canWrite ? (
<TranslationInput
keyUuid={keyUuid}
locale={locale.code}
value={value}
version={target?.version ?? 0}
direction={locale.direction}
pluralCategories={locale.pluralCategories}
maxLength={maxLength}
autoFocus
dense
/>
) : (
<p className="rounded border border-dashed border-ink-200 px-2 py-1 text-sm text-ink-400">
{value ?? '—'}
</p>
)}
{target?.updatedBy != null && (
<p className="mt-1 text-[11px] text-ink-400">
{target.updatedBy}
{target.updatedAt !== null &&
` · ${new Date(target.updatedAt).toLocaleDateString('fr-FR')}`}
{target.isMachineTranslated && ' · traduction automatique'}
</p>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,143 @@
import { useMemo } from 'react';
import type { NamespaceTree as Tree } from '@/api/types';
import type { TranslationStatus } from '@/api/types';
import { STATUS_META } from '@/components/Status';
const FILTERS: { value: TranslationStatus | ''; label: string }[] = [
{ value: '', label: 'Toutes' },
{ value: 'untranslated', label: STATUS_META.untranslated.label },
{ value: 'needs_review', label: STATUS_META.needs_review.label },
{ value: 'draft', label: STATUS_META.draft.label },
{ value: 'translated', label: STATUS_META.translated.label },
{ value: 'reviewed', label: STATUS_META.reviewed.label },
];
interface Props {
tree: Tree | undefined;
selected: string;
status: TranslationStatus | '';
unassigned: boolean;
total: number;
onSelectNamespace: (path: string | null) => void;
// Statut et bac « non assignées » partent ENSEMBLE : ce sont deux facettes
// du même filtre, et les envoyer en deux appels séparés faisait que le
// second écrasait le premier.
onSelectFilter: (next: { status: TranslationStatus | ''; unassigned: boolean }) => void;
}
export function NamespaceTree({
tree,
selected,
status,
unassigned,
total,
onSelectNamespace,
onSelectFilter,
}: Props) {
// Le serveur renvoie un compteur PROPRE à chaque namespace. L'agrégation par
// branche se fait ici : le client possède déjà l'arbre entier, la calculer
// côté serveur coûterait une requête récursive pour rien.
const nodes = useMemo(() => {
const list = tree?.namespaces ?? [];
return list.map((node) => {
const subtotal = list
.filter((other) => other.path === node.path || other.path.startsWith(`${node.path}.`))
.reduce((sum, other) => sum + other.actionable, 0);
return { ...node, subtotal };
});
}, [tree]);
return (
<aside className="flex w-60 shrink-0 flex-col border-r border-ink-200 bg-white">
<div className="border-b border-ink-200 p-3">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Filtrer
</p>
<div className="flex flex-wrap gap-1">
{FILTERS.map((filter) => {
const active = status === filter.value && !unassigned;
return (
<button
key={filter.value || 'all'}
onClick={() =>
onSelectFilter({ status: filter.value, unassigned: false })
}
className={`rounded px-2 py-1 text-xs transition-colors ${
active
? 'bg-accent text-white'
: 'bg-ink-100 text-ink-600 hover:bg-ink-200'
}`}
>
{filter.label}
</button>
);
})}
</div>
{/* Bac « Non assignées » : les clés rattachées à aucune plateforme
n'entrent dans aucun bundle. Sans cette vue elles deviennent
invisibles et les compteurs d'avancement mentent. */}
{(tree?.unassigned ?? 0) > 0 && (
<button
onClick={() => onSelectFilter({ status: '', unassigned: !unassigned })}
className={`mt-2 flex w-full items-center justify-between rounded px-2 py-1.5 text-xs transition-colors ${
unassigned
? 'bg-amber-100 text-amber-900'
: 'text-ink-500 hover:bg-ink-100'
}`}
title="Clés rattachées à aucune plateforme : elles n'entrent dans aucun bundle."
>
<span> Non assignées</span>
<span className="tabular font-medium">{tree?.unassigned}</span>
</button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<button
onClick={() => onSelectNamespace(null)}
className={`flex w-full items-center justify-between rounded px-2 py-1.5 text-sm transition-colors ${
selected === '' ? 'bg-accent-soft font-medium text-accent' : 'hover:bg-ink-100'
}`}
>
<span>Toutes les clés</span>
<span className="tabular text-xs text-ink-400">{total}</span>
</button>
{nodes.map((node) => {
const active = selected === node.path;
return (
<button
key={node.path}
onClick={() => onSelectNamespace(active ? null : node.path)}
style={{ paddingLeft: `${8 + node.depth * 12}px` }}
className={`flex w-full items-center justify-between rounded py-1 pr-2 text-sm transition-colors ${
active
? 'bg-accent-soft font-medium text-accent'
: 'text-ink-700 hover:bg-ink-100'
}`}
title={node.path}
>
<span className="truncate">{node.name}</span>
{node.subtotal > 0 && (
<span className="tabular ml-2 shrink-0 rounded bg-ink-100 px-1.5 text-[11px] font-medium text-ink-600">
{node.subtotal}
</span>
)}
</button>
);
})}
{nodes.length === 0 && (
<p className="px-2 py-4 text-xs text-ink-400">Aucun namespace.</p>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,186 @@
import { useEffect, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { GridRow } from '@/api/types';
import { SourceText } from '@/components/SourceText';
import { StatusDot } from '@/components/Status';
import { TranslationInput } from '@/editor/TranslationInput';
interface Props {
rows: GridRow[];
total: number;
loading: boolean;
locale: string;
direction: 'ltr' | 'rtl';
pluralCategories: string[];
selectedId: string | null;
onSelect: (id: string) => void;
readOnly: boolean;
gridKey: unknown;
}
/**
* La grille de traduction.
*
* **Virtualisée** : un projet mûr compte des dizaines de milliers de clés, et
* une liste non virtualisée effondre le navigateur bien avant. Seules les lignes
* visibles existent dans le DOM.
*
* **Source et cible empilées, pas côte à côte.** Les colonnes obligent à couper
* le texte long ; l'empilement laisse respirer les deux valeurs et fonctionne
* sur un écran d'ordinateur portable, qui est la réalité d'une traductrice en
* déplacement.
*/
export function TranslationGrid({
rows,
total,
loading,
locale,
direction,
pluralCategories,
selectedId,
onSelect,
readOnly,
}: Props) {
const parentRef = useRef<HTMLDivElement>(null);
// Le panneau de contexte est inutile tant que rien n'est sélectionné, et
// demander un clic pour l'activer, c'est afficher une colonne vide au
// moment précis où l'utilisateur découvre l'écran.
const first = rows[0]?.id;
useEffect(() => {
if (selectedId === null && first !== undefined) onSelect(first);
}, [first, selectedId, onSelect]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
// Estimation volontairement basse : `measureElement` corrige au rendu.
// Surestimer produirait des sauts de défilement bien plus gênants.
estimateSize: () => 132,
overscan: 8,
getItemKey: (index) => rows[index]?.id ?? index,
});
if (loading && rows.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-ink-400">
Chargement des clés
</div>
);
}
if (rows.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-6 text-center">
<p className="text-sm font-medium text-ink-700">Aucune clé ne correspond.</p>
<p className="text-xs text-ink-400">
Élargissez les filtres, ou vérifiez que la plateforme sélectionnée porte bien
des clés.
</p>
</div>
);
}
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex shrink-0 items-baseline justify-between border-b border-ink-200 bg-white px-4 py-1.5">
<span className="tabular text-xs text-ink-500">
{rows.length < total ? `${rows.length} sur ${total}` : `${total}`} clé
{total > 1 ? 's' : ''}
</span>
<span className="font-mono text-[11px] text-ink-400">{locale}</span>
</div>
<div ref={parentRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const row = rows[item.index];
if (!row) return null;
const selected = row.id === selectedId;
return (
<div
key={item.key}
ref={virtualizer.measureElement}
data-index={item.index}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${item.start}px)` }}
>
<div
onFocusCapture={() => onSelect(row.id)}
onClick={() => onSelect(row.id)}
className={`border-b border-ink-100 px-4 py-3 transition-colors ${
selected ? 'bg-accent-soft/40' : 'bg-white hover:bg-ink-50'
}`}
>
<div className="flex items-center gap-2">
<StatusDot status={row.status} />
<span className="truncate font-mono text-xs text-ink-700">
{row.keyPath}
</span>
{/* La source modifiée est LE signal qui compte :
il désigne un texte peut-être faux en production. */}
{row.isStale && (
<span className="shrink-0 rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-700">
source modifiée
</span>
)}
<span className="ml-auto flex shrink-0 gap-1">
{row.platforms.map((slug) => (
<span
key={slug}
className="rounded bg-ink-100 px-1 py-0.5 font-mono text-[10px] text-ink-500"
>
{slug}
</span>
))}
{row.platforms.length === 0 && (
<span
className="rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-800"
title="Rattachée à aucune plateforme : n'entre dans aucun fichier livré."
>
non assignée
</span>
)}
</span>
</div>
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-snug text-ink-500">
{row.sourceValue === null ? (
<em className="text-ink-300">source absente</em>
) : (
<SourceText value={row.sourceValue} />
)}
</p>
<div className="mt-2">
{readOnly ? (
<p className="rounded border border-dashed border-ink-200 px-2 py-1 text-sm text-ink-400">
{row.targetValue ?? '—'}
</p>
) : (
<TranslationInput
keyUuid={row.id}
locale={locale}
value={row.targetValue}
version={row.version}
direction={direction}
pluralCategories={pluralCategories}
maxLength={row.maxLength}
dense
/>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,258 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ApiError, api } from '@/api/client';
import type { TranslationEntry, ValidationWarning } from '@/api/types';
import { composePlural, decomposePlural, isPluralMessage, selectorOrder } from '@/api/icu';
export type SaveState = 'idle' | 'saving' | 'saved' | 'error';
interface Props {
keyUuid: string;
locale: string;
value: string | null;
version: number;
direction: 'ltr' | 'rtl';
pluralCategories: string[];
maxLength: number | null;
autoFocus?: boolean;
/** Rendu compact pour la grille, aéré pour le mode Focus. */
dense?: boolean;
onSaved?: (entry: TranslationEntry) => void;
onRequestNext?: () => void;
}
/**
* Saisie d'une traduction le composant que le traducteur utilise toute la
* journée.
*
* Trois partis pris :
*
* 1. **Pas de bouton « Enregistrer ».** L'enregistrement se déclenche à la
* perte de focus. Un bouton par ligne sur une grille de trente mille lignes
* est une plaisanterie ; un bouton global oblige à se souvenir de cliquer.
*
* 2. **Le traducteur ne voit jamais d'ICU.** Un message pluriel se présente
* comme un champ par forme, avec le nom de la catégorie CLDR en clair. La
* recomposition se fait à l'enregistrement.
*
* 3. **Les erreurs s'affichent sous le champ, pas dans une alerte.** Elles
* concernent ce texte- ; les éloigner du texte oblige à faire le lien
* soi-même.
*/
export function TranslationInput({
keyUuid,
locale,
value,
version,
direction,
pluralCategories,
maxLength,
autoFocus = false,
dense = false,
onSaved,
onRequestNext,
}: Props) {
const queryClient = useQueryClient();
const decomposed = useMemo(() => decomposePlural(value), [value]);
const plural = decomposed !== null;
const [simple, setSimple] = useState(value ?? '');
const [branches, setBranches] = useState<Record<string, string>>(decomposed?.branches ?? {});
const [state, setState] = useState<SaveState>('idle');
const [error, setError] = useState<string | null>(null);
const [warnings, setWarnings] = useState<ValidationWarning[]>([]);
const currentVersion = useRef(version);
// Le contenu peut changer sous nos pieds — changement de ligne dans la
// grille, rafraîchissement au retour d'onglet. On ne resynchronise que si la
// valeur serveur diffère de ce qu'on a écrit, sinon la frappe serait écrasée.
useEffect(() => {
currentVersion.current = version;
setSimple(value ?? '');
setBranches(decomposePlural(value)?.branches ?? {});
setState('idle');
setError(null);
setWarnings([]);
}, [keyUuid, locale, value, version]);
const selectors = useMemo(
() => selectorOrder(pluralCategories, Object.keys(decomposed?.branches ?? {})),
[pluralCategories, decomposed],
);
const mutation = useMutation({
mutationFn: (next: string | null) =>
api.writeTranslation(keyUuid, locale, {
value: next,
version: currentVersion.current,
}),
onMutate: () => {
setState('saving');
setError(null);
},
onSuccess: (entry) => {
currentVersion.current = entry.currentVersion;
setWarnings(entry.warnings);
setState('saved');
onSaved?.(entry);
// La grille et les compteurs sont désormais périmés. On invalide
// plutôt que de patcher le cache à la main : le calcul d'avancement
// dépend de règles serveur qu'on ne veut pas dupliquer ici.
void queryClient.invalidateQueries({ queryKey: ['grid'] });
// La vue par clé montre la MÊME traduction sous un autre angle :
// l'oublier ici laisserait un onglet afficher une valeur périmée
// après une saisie faite dans l'autre.
void queryClient.invalidateQueries({ queryKey: ['keys'] });
void queryClient.invalidateQueries({ queryKey: ['stats'] });
void queryClient.invalidateQueries({ queryKey: ['namespaces'] });
window.setTimeout(() => setState('idle'), 1500);
},
onError: (caught) => {
setState('error');
if (caught instanceof ApiError && caught.isConflict) {
setError(
'Modifiée entre-temps par quelqu\'un d\'autre. Rechargez avant de réécrire.',
);
return;
}
setError(caught instanceof Error ? caught.message : 'Enregistrement impossible.');
},
});
function currentValue(): string | null {
if (!plural) return simple.trim() === '' ? null : simple;
const composed = composePlural(
{
prefix: decomposed.prefix,
suffix: decomposed.suffix,
variable: decomposed.variable,
branches,
},
selectors,
);
return composed === '' ? null : composed;
}
function save() {
const next = currentValue();
const unchanged = next === (value ?? null);
if (unchanged || mutation.isPending) return;
mutation.mutate(next);
}
function onKeyDown(event: React.KeyboardEvent) {
// ⌘↵ enregistre et passe à la suivante : c'est le geste du mode Focus,
// et il doit fonctionner à l'identique dans la grille.
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
save();
onRequestNext?.();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
setSimple(value ?? '');
setBranches(decomposePlural(value)?.branches ?? {});
(event.target as HTMLElement).blur();
}
}
const length = plural
? Math.max(0, ...Object.values(branches).map((b) => b.length))
: simple.length;
const tooLong = maxLength !== null && length > maxLength;
return (
<div className="w-full">
{plural ? (
<div className="space-y-1">
{selectors.map((selector) => (
<div key={selector} className="flex items-start gap-2">
<span
className="mt-1.5 w-12 shrink-0 text-right font-mono text-[11px] text-ink-400"
title={
selector.startsWith('=')
? `Valeur exacte ${selector.slice(1)}`
: `Forme plurielle « ${selector} »`
}
>
{selector}
</span>
<textarea
rows={1}
dir={direction}
value={branches[selector] ?? ''}
onChange={(e) =>
setBranches((prev) => ({ ...prev, [selector]: e.target.value }))
}
onBlur={save}
onKeyDown={onKeyDown}
className="min-h-8 flex-1 resize-y rounded border border-ink-200 bg-white px-2 py-1 text-sm outline-none focus:border-accent"
/>
</div>
))}
</div>
) : (
<textarea
rows={dense ? 1 : 3}
dir={direction}
autoFocus={autoFocus}
value={simple}
onChange={(e) => setSimple(e.target.value)}
onBlur={save}
onKeyDown={onKeyDown}
placeholder="Saisir la traduction…"
className={`w-full resize-y rounded border bg-white px-2 py-1 outline-none focus:border-accent ${
dense ? 'min-h-8 text-sm' : 'min-h-24 text-base'
} ${error !== null ? 'border-red-400' : 'border-ink-200'}`}
/>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
<SaveIndicator state={state} />
{maxLength !== null && (
<span className={`tabular ${tooLong ? 'font-medium text-amber-700' : 'text-ink-400'}`}>
{length} / {maxLength}
</span>
)}
{error !== null && (
<span role="alert" className="font-medium text-red-700">
{error}
</span>
)}
{warnings.map((warning) => (
<span key={warning.code} className="text-amber-700">
{warning.message}
</span>
))}
</div>
</div>
);
}
function SaveIndicator({ state }: { state: SaveState }) {
if (state === 'saving') return <span className="text-ink-400"> enregistrement</span>;
if (state === 'saved') return <span className="text-emerald-600"> enregistré</span>;
if (state === 'error') return <span className="text-red-600"> non enregistré</span>;
return null;
}
export { isPluralMessage };

14
assets/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>TQ-Slator</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗣️</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

117
assets/main.tsx Normal file
View file

@ -0,0 +1,117 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider, useAuth } from '@/auth/AuthProvider';
import { LoginPage } from '@/pages/LoginPage';
import { ProjectsPage } from '@/pages/ProjectsPage';
import { EditorPage } from '@/pages/EditorPage';
import { MembersPage } from '@/pages/MembersPage';
import { IntegrationPage } from '@/pages/IntegrationPage';
import { InvitationPage } from '@/pages/InvitationPage';
import { AdminPage } from '@/pages/AdminPage';
import { PlatformsPage } from '@/pages/PlatformsPage';
import './app.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Les données changent parce que d'AUTRES personnes travaillent
// dessus. Un rafraîchissement au retour sur l'onglet est le bon
// compromis entre fraîcheur et calme visuel.
refetchOnWindowFocus: true,
staleTime: 30_000,
retry: (failureCount, error) =>
failureCount < 2 && !(error instanceof Error && error.name === 'ApiError'),
},
},
});
function Protected({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="flex h-full items-center justify-center text-ink-400">
<span className="text-sm">Chargement</span>
</div>
);
}
return user ? <>{children}</> : <Navigate to="/login" replace />;
}
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* Page publique : celui qui clique n'a pas encore de compte. */}
<Route path="/invitation/:token" element={<InvitationPage />} />
<Route
path="/projects"
element={
<Protected>
<ProjectsPage />
</Protected>
}
/>
<Route
path="/admin"
element={
<Protected>
<AdminPage />
</Protected>
}
/>
<Route
path="/p/:projectUuid/editor"
element={
<Protected>
<EditorPage />
</Protected>
}
/>
<Route
path="/p/:projectUuid/members"
element={
<Protected>
<MembersPage />
</Protected>
}
/>
<Route
path="/p/:projectUuid/platforms"
element={
<Protected>
<PlatformsPage />
</Protected>
}
/>
<Route
path="/p/:projectUuid/integration"
element={
<Protected>
<IntegrationPage />
</Protected>
}
/>
<Route path="*" element={<Navigate to="/projects" replace />} />
</Routes>
);
}
const root = document.getElementById('root');
if (root) {
createRoot(root).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);
}

523
assets/pages/AdminPage.tsx Normal file
View file

@ -0,0 +1,523 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '@/api/client';
import type { AdminProject, AdminUser } from '@/api/types';
import { useAuth, humanMessage } from '@/auth/AuthProvider';
/**
* Administration hors projet.
*
* Volontairement séparée des écrans Membres et Intégration, qui répondent à
* « qui a accès à CE projet ? ». Ici la question est autre : « qui existe dans
* l'outil, et va-t-il ? ». Elle traverse les projets, donc elle traverse
* aussi les administrateurs de projet d' le rôle super-administrateur.
*
* Deux onglets, pas davantage. Un panneau d'administration qui grossit sans
* discipline devient l'endroit où l'on range ce qu'on ne sait pas classer.
*/
export function AdminPage() {
const { user } = useAuth();
const [params, setParams] = useSearchParams();
const tab = params.get('tab') === 'projects' ? 'projects' : 'users';
if (user && !user.isSuperAdmin) {
return <NotSuperAdmin />;
}
return (
<div className="flex h-full flex-col bg-ink-100">
<header className="shrink-0 border-b border-ink-200 bg-white px-4 py-2.5">
<div className="flex items-center gap-1">
<Link to="/projects" className="mr-2 text-sm font-semibold text-ink-900 hover:text-accent">
TQ-Slator
</Link>
<div className="h-5 w-px bg-ink-200" />
<Link
to="/projects"
className="rounded px-2.5 py-1 text-sm text-ink-600 transition-colors hover:bg-ink-100"
>
Projets
</Link>
<span className="rounded bg-accent-soft px-2.5 py-1 text-sm font-medium text-accent">
Administration
</span>
<span className="ml-auto text-xs text-ink-400">{user?.name}</span>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-xl font-semibold tracking-tight text-ink-900">Administration</h1>
<p className="mt-1 text-sm text-ink-500">
Comptes et projets de toute l'organisation.
</p>
<div className="mt-6 flex gap-1 border-b border-ink-200">
{(
[
['users', 'Utilisateurs'],
['projects', 'Projets'],
] as const
).map(([value, label]) => (
<button
key={value}
onClick={() => setParams({ tab: value }, { replace: true })}
className={`-mb-px border-b-2 px-3 py-2 text-sm transition-colors ${
tab === value
? 'border-accent font-medium text-accent'
: 'border-transparent text-ink-500 hover:text-ink-800'
}`}
>
{label}
</button>
))}
</div>
{tab === 'users' ? <UsersTab /> : <ProjectsTab />}
</div>
</div>
</div>
);
}
function NotSuperAdmin() {
return (
<div className="flex h-full items-center justify-center px-6">
<div className="max-w-md text-center">
<p className="text-lg font-medium text-ink-800">Réservé aux super-administrateurs</p>
<p className="mt-2 text-sm text-ink-500">
Cette page donne accès aux comptes de toute l'organisation. Pour gérer les accès
d'un projet dont vous êtes administrateur, ouvrez ce projet et allez dans
« Membres ».
</p>
<Link
to="/projects"
className="mt-6 inline-block rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
Revenir aux projets
</Link>
</div>
</div>
);
}
// ── Utilisateurs ─────────────────────────────────────────────────────────
function UsersTab() {
const [search, setSearch] = useState('');
const data = useQuery({
queryKey: ['admin-users', search],
queryFn: () => api.adminUsers(search || undefined),
});
const users = data.data?.users ?? [];
const orphans = users.filter((u) => u.memberships.length === 0).length;
return (
<div className="mt-6">
<div className="flex items-center gap-3">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un nom ou une adresse…"
className="min-w-64 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<span className="shrink-0 text-xs text-ink-400">
{users.length} compte{users.length > 1 ? 's' : ''}
</span>
</div>
{/* Un compte sans projet ne voit rien du tout. C'est soit une
invitation oubliée, soit un départ mal soldé dans les deux cas
quelque chose à traiter, pas une curiosité. */}
{orphans > 0 && (
<p className="mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-900">
{orphans} compte{orphans > 1 ? 's' : ''} sans aucun projet : ces personnes se
connectent mais ne voient rien.
</p>
)}
<div className="mt-4 divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{users.map((user) => (
<UserRow
key={user.uuid}
user={user}
superAdminCount={data.data?.superAdminCount ?? 0}
/>
))}
{data.isSuccess && users.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-ink-400">Aucun compte trouvé.</p>
)}
</div>
</div>
);
}
function UserRow({ user, superAdminCount }: { user: AdminUser; superAdminCount: number }) {
const { user: current } = useAuth();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const isSelf = current?.email === user.email;
const isLastSuperAdmin = user.isSuperAdmin && superAdminCount <= 1;
const update = useMutation({
mutationFn: (body: { isActive?: boolean; isSuperAdmin?: boolean }) =>
api.updateAdminUser(user.uuid, body),
onSuccess: () => {
setError(null);
void queryClient.invalidateQueries({ queryKey: ['admin-users'] });
},
onError: (caught) => setError(humanMessage(caught)),
});
return (
<div className={`px-4 py-3 ${user.isActive ? '' : 'bg-ink-50'}`}>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className={`text-sm ${user.isActive ? 'text-ink-800' : 'text-ink-400 line-through'}`}>
{user.name}
</span>
<span className="text-xs text-ink-400">{user.email}</span>
{user.isSuperAdmin && (
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[11px] font-medium text-accent">
super-admin
</span>
)}
{user.isExternal && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[11px] text-amber-900">
externe
</span>
)}
{!user.isActive && (
<span className="rounded bg-ink-200 px-1.5 py-0.5 text-[11px] text-ink-600">
désactivé
</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-2">
<button
onClick={() => update.mutate({ isSuperAdmin: !user.isSuperAdmin })}
disabled={update.isPending || isSelf || isLastSuperAdmin}
title={
isSelf
? 'On ne modifie pas son propre rôle depuis cet écran.'
: isLastSuperAdmin
? 'Il doit rester au moins un super-administrateur.'
: undefined
}
className="rounded border border-ink-300 px-2 py-1 text-xs text-ink-600 transition-colors hover:bg-ink-100 disabled:cursor-not-allowed disabled:opacity-40"
>
{user.isSuperAdmin ? 'Retirer super-admin' : 'Nommer super-admin'}
</button>
<button
onClick={() => update.mutate({ isActive: !user.isActive })}
disabled={update.isPending || isSelf || (!user.isActive ? false : isLastSuperAdmin)}
title={isSelf ? 'On ne désactive pas son propre compte.' : undefined}
className={`rounded px-2 py-1 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
user.isActive
? 'border border-red-200 text-red-700 hover:bg-red-50'
: 'border border-ink-300 text-ink-600 hover:bg-ink-100'
}`}
>
{user.isActive ? 'Désactiver' : 'Réactiver'}
</button>
</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-ink-400">
{user.memberships.length === 0 ? (
<span className="text-amber-700">aucun projet</span>
) : (
user.memberships.map((m) => (
<span key={m.projectUuid}>
<Link
to={`/p/${m.projectUuid}/members`}
className="text-ink-600 hover:text-accent"
>
{m.project}
</Link>
<span className="text-ink-300"> · </span>
{m.role}
<span className="text-ink-300"> · </span>
<span className="font-mono">
{m.coversAllLocales ? 'toutes langues' : m.locales.join(', ')}
</span>
</span>
))
)}
<span className="ml-auto">
{user.lastLoginAt
? `dernière connexion le ${new Date(user.lastLoginAt).toLocaleDateString('fr-FR')}`
: 'jamais connecté'}
</span>
</div>
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
</div>
);
}
// ── Projets ──────────────────────────────────────────────────────────────
function ProjectsTab() {
const data = useQuery({ queryKey: ['admin-projects'], queryFn: () => api.adminProjects() });
const queryClient = useQueryClient();
return (
<div className="mt-6">
<CreateProjectForm
locales={data.data?.locales ?? []}
onDone={() => void queryClient.invalidateQueries({ queryKey: ['admin-projects'] })}
/>
<div className="mt-8 divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{data.data?.projects.map((project) => (
<ProjectRow key={project.uuid} project={project} />
))}
</div>
</div>
);
}
function ProjectRow({ project }: { project: AdminProject }) {
return (
<div className="px-4 py-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Link
to={`/p/${project.uuid}/editor`}
className="text-sm font-medium text-ink-800 hover:text-accent"
>
{project.name}
</Link>
<span className="font-mono text-[11px] text-ink-400">{project.slug}</span>
{/* Un projet sans propriétaire n'est administrable par personne :
plus d'invitation possible, plus de publication. C'est la
seule anomalie que cet écran doit crier. */}
{project.owners.length === 0 && (
<span className="rounded bg-red-100 px-1.5 py-0.5 text-[11px] font-medium text-red-800">
aucun propriétaire
</span>
)}
{/* Sans plateforme, aucune clé ne peut être rattachée et aucun
bundle ne peut être construit : le projet existe mais ne
livre rien. Un projet fraîchement créé est dans cet état. */}
{project.platformCount === 0 && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-900">
aucune plateforme
</span>
)}
<span className="ml-auto flex shrink-0 gap-2 text-xs">
<Link
to={`/p/${project.uuid}/members`}
className="rounded border border-ink-300 px-2 py-1 text-ink-600 hover:bg-ink-100"
>
Membres
</Link>
<Link
to={`/p/${project.uuid}/integration`}
className="rounded border border-ink-300 px-2 py-1 text-ink-600 hover:bg-ink-100"
>
Intégration
</Link>
</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 text-[11px] text-ink-400">
<span className="font-mono">
{project.sourceLocale} {project.targetLocales.join(', ') || 'aucune cible'}
</span>
<span>{project.platformCount} plateformes</span>
<span>{project.environmentCount} environnements</span>
<span>{project.memberCount} membres</span>
{project.owners.length > 0 && <span>propriétaire : {project.owners.join(', ')}</span>}
</div>
</div>
);
}
function CreateProjectForm({
locales,
onDone,
}: {
locales: { code: string; name: string; nativeName: string }[];
onDone: () => void;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [description, setDescription] = useState('');
const [sourceLocale, setSourceLocale] = useState('fr-FR');
const [targets, setTargets] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const create = useMutation({
mutationFn: () =>
api.createProject({ name, slug, description, sourceLocale, targetLocales: targets }),
onSuccess: (project) => {
setMessage(
`Projet « ${project.name} » créé, avec ses trois environnements. Vous en êtes propriétaire.`,
);
setError(null);
setName('');
setSlug('');
setDescription('');
setTargets([]);
// On referme : un formulaire vidé sur place ressemble à un échec
// silencieux. Refermer fait apparaître la confirmation, et la
// nouvelle ligne juste en dessous.
setOpen(false);
onDone();
},
onError: (caught) => {
setError(humanMessage(caught));
setMessage(null);
},
});
if (!open) {
return (
<div>
<button
onClick={() => setOpen(true)}
className="rounded-md bg-accent px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Nouveau projet
</button>
{message && <p className="mt-3 text-xs text-emerald-700">{message}</p>}
</div>
);
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
className="rounded-lg border border-ink-200 bg-white p-5 shadow-sm"
>
<h2 className="mb-4 text-sm font-medium text-ink-800">Nouveau projet</h2>
<div className="flex flex-wrap gap-3">
<input
required
value={name}
onChange={(e) => {
setName(e.target.value);
// Le slug se déduit du nom tant qu'on n'y a pas touché.
// Il finit dans des URL de livraison et dans les dépôts
// clients : le laisser vide serait un piège.
setSlug(
e.target.value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
);
}}
placeholder="Nom du projet"
className="min-w-56 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<input
required
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="slug-du-projet"
className="min-w-44 rounded-md border border-ink-300 px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
<select
value={sourceLocale}
onChange={(e) => setSourceLocale(e.target.value)}
className="rounded-md border border-ink-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent"
>
{locales.map((l) => (
<option key={l.code} value={l.code}>
Source : {l.nativeName}
</option>
))}
</select>
</div>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (facultative)"
className="mt-3 w-full rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<p className="mb-2 mt-4 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Langues cibles
</p>
<div className="flex flex-wrap gap-1.5">
{locales
.filter((l) => l.code !== sourceLocale)
.map((l) => {
const on = targets.includes(l.code);
return (
<button
key={l.code}
type="button"
onClick={() =>
setTargets((prev) =>
on ? prev.filter((c) => c !== l.code) : [...prev, l.code],
)
}
className={`rounded px-2 py-1 text-xs transition-colors ${
on ? 'bg-accent text-white' : 'bg-ink-100 text-ink-600 hover:bg-ink-200'
}`}
>
{l.nativeName}
</button>
);
})}
</div>
<p className="mt-2 text-[11px] text-ink-400">
La langue source ne se change plus après la création : la modifier invaliderait
l'empreinte de chaque traduction et basculerait tout le contenu en « à revoir ».
</p>
{/* Hauteur bornée : une erreur serveur inattendue peut faire
plusieurs milliers de caractères, et pousserait sinon le bouton
de validation hors de l'écran. */}
{error && (
<p className="mt-3 max-h-24 overflow-y-auto rounded bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
</p>
)}
<div className="mt-4 flex items-center gap-3">
<button
type="submit"
disabled={create.isPending}
className="rounded-md bg-accent px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:bg-ink-300"
>
{create.isPending ? 'Création…' : 'Créer le projet'}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="text-sm text-ink-500 hover:text-ink-800"
>
Annuler
</button>
</div>
</form>
);
}

425
assets/pages/EditorPage.tsx Normal file
View file

@ -0,0 +1,425 @@
import { useCallback, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { api } from '@/api/client';
import type { GridRow, TranslationStatus } from '@/api/types';
import { NamespaceTree } from '@/editor/NamespaceTree';
import { TranslationGrid } from '@/editor/TranslationGrid';
import { KeyGrid } from '@/editor/KeyGrid';
import { ContextPanel } from '@/editor/ContextPanel';
import { FocusMode } from '@/editor/FocusMode';
import { KeyFocusMode } from '@/editor/KeyFocusMode';
const PAGE_SIZE = 200;
// Plus court en vue par clé : chaque ligne y porte autant d'entrées qu'il y a
// de langues. Charger 200 clés × 7 langues pour un écran qui en montre dix
// serait payer sept fois le prix d'un défilement qu'on ne fera pas.
const KEY_PAGE_SIZE = 50;
/**
* L'écran, et ses deux façons de regarder le même contenu.
*
* **Par langue** (par défaut) deux langues à l'écran, source et cible, jamais
* douze. Personne ne traduit vers douze langues simultanément, et une grille à
* douze colonnes est illisible. Le sélecteur « source cible » est ici l'objet
* de navigation principal.
*
* **Par clé** une clé, toutes ses langues, repliées. La question n'est plus
* « en est mon travail en espagnol ? » mais « ce libellé est-il prêt
* partout ? ». Elle se pose avant une mise en production, et juste après avoir
* ajouté une clé.
*
* Le principe des douze colonnes n'est pas abandonné, il est respecté
* autrement : la vue par clé empile les langues au lieu de les juxtaposer, et
* les garde repliées. Sept lignes de vingt pixels se lisent ; sept colonnes ne
* se lisent pas.
*
* Tout l'état de filtrage vit dans l'URL, y compris le choix de vue. Ce n'est
* pas une commodité technique : c'est ce qui permet à une responsable de
* localisation d'envoyer un lien dans Slack et à la traductrice d'atterrir
* exactement sur le travail concerné, dans la vue qui convient.
*/
export function EditorPage() {
const { projectUuid = '' } = useParams();
const [params, setParams] = useSearchParams();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [focusOpen, setFocusOpen] = useState(false);
const locale = params.get('locale') ?? '';
const platform = params.get('platform') ?? '';
const status = (params.get('status') ?? '') as TranslationStatus | '';
const namespace = params.get('namespace') ?? '';
const search = params.get('q') ?? '';
const unassigned = params.get('unassigned') === '1';
// Deux façons de regarder le même contenu, et deux publics :
//
// - `locale` (par défaut) — deux langues à l'écran, source et cible. La
// vue de la traductrice qui vide sa file dans UNE langue.
// - `key` — une clé, toutes ses langues. La vue de qui doit juger d'un
// libellé avant une mise en production, ou du développeur qui vient
// d'ajouter une clé et veut savoir où elle en est.
//
// Le choix vit dans l'URL comme le reste des filtres : un lien partagé
// ouvre la bonne vue sur le bon travail.
const view = params.get('view') === 'key' ? 'key' : 'locale';
const stats = useQuery({
queryKey: ['stats', projectUuid],
queryFn: () => api.stats(projectUuid),
});
// Sans langue explicite, on prend la première cible : arriver sur un écran
// vide en demandant de choisir est une étape de plus pour rien.
const effectiveLocale =
locale || stats.data?.locales.find((l) => !l.isSource)?.code || stats.data?.sourceLocale || '';
const localeMeta = stats.data?.locales.find((l) => l.code === effectiveLocale);
const sourceMeta = stats.data?.locales.find((l) => l.isSource);
const targetLocales = useMemo(
() => (stats.data?.locales ?? []).filter((l) => !l.isSource),
[stats.data],
);
// Les langues que l'utilisateur peut réellement écrire. Le serveur le dit
// dans /stats ; on ne le redéduit pas ici du rôle, ce serait une seconde
// implémentation de la même règle d'autorisation.
const writableLocales = useMemo(
() => targetLocales.filter((l) => l.canWrite),
[targetLocales],
);
// « * » = toutes langues. Sans cela l'arbre compterait le travail restant en
// espagnol au-dessus d'une liste qui montre les sept langues : deux chiffres
// qui ne parlent pas de la même chose, côte à côte.
const countingLocale = view === 'key' ? '*' : effectiveLocale;
const namespaces = useQuery({
queryKey: ['namespaces', projectUuid, countingLocale],
queryFn: () => api.namespaces(projectUuid, countingLocale),
enabled: countingLocale !== '',
});
const gridQuery = useMemo(
() => ({
projectUuid,
locale: effectiveLocale,
platform: platform || undefined,
status: status || undefined,
namespace: namespace || undefined,
q: search || undefined,
unassigned: unassigned || undefined,
itemsPerPage: PAGE_SIZE,
}),
[projectUuid, effectiveLocale, platform, status, namespace, search, unassigned],
);
const grid = useQuery({
queryKey: ['grid', gridQuery],
queryFn: () => api.grid(gridQuery),
enabled: view === 'locale' && effectiveLocale !== '',
placeholderData: (previous) => previous,
});
const keyQuery = useMemo(
() => ({
projectUuid,
platform: platform || undefined,
status: status || undefined,
namespace: namespace || undefined,
q: search || undefined,
unassigned: unassigned || undefined,
itemsPerPage: KEY_PAGE_SIZE,
}),
[projectUuid, platform, status, namespace, search, unassigned],
);
const keyRows = useQuery({
queryKey: ['keys', keyQuery],
queryFn: () => api.keys(keyQuery),
enabled: view === 'key',
placeholderData: (previous) => previous,
});
/**
* Modification atomique de l'URL.
*
* Prend un LOT de paramètres, et non un seul, parce que deux appels
* successifs dans le même gestionnaire d'événement s'écrasent : la fonction
* de mise à jour reçoit les paramètres du rendu courant, pas ceux de la
* navigation encore en attente. Le second appel repart donc de l'état
* d'avant le premier et gagne.
*
* C'est ce qui rendait muets tous les filtres de la colonne de gauche : ils
* touchent `status` et `unassigned` ensemble.
*/
const update = useCallback(
(patch: Record<string, string | null>) => {
setParams(
(current) => {
const next = new URLSearchParams(current);
for (const [key, value] of Object.entries(patch)) {
if (value === null || value === '') next.delete(key);
else next.set(key, value);
}
return next;
},
{ replace: true },
);
},
[setParams],
);
const rows = grid.data?.items ?? [];
const selected = rows.find((row) => row.id === selectedId) ?? null;
const selectedKey = keyRows.data?.items.find((row) => row.id === selectedId) ?? null;
const total = (view === 'locale' ? grid.data?.total : keyRows.data?.total) ?? 0;
// Le panneau de contexte ne parle que de la CLÉ ; les deux vues lui
// fournissent donc la même chose, chacune extraite de son propre DTO.
const context = view === 'locale' ? selected : selectedKey;
const actionable = localeMeta
? localeMeta.untranslated + localeMeta.draft + localeMeta.needsReview
: 0;
// Lecture seule : la langue existe et se consulte, mais l'utilisateur n'y a
// pas d'habilitation d'écriture. Le dire AVANT la saisie évite le pire
// enchaînement possible — traduire, puis se voir refuser l'enregistrement.
const readOnly = localeMeta !== undefined && !localeMeta.canWrite;
return (
<div className="flex h-full flex-col bg-ink-100">
<header className="flex shrink-0 items-center gap-4 border-b border-ink-200 bg-white px-4 py-2.5">
<Link
to="/projects"
className="shrink-0 text-sm font-semibold text-ink-900 hover:text-accent"
>
TQ-Slator
</Link>
<div className="h-5 w-px bg-ink-200" />
{/* La bascule précède le sélecteur de langue : elle décide de ce
que les contrôles suivants veulent dire. */}
<div className="flex shrink-0 rounded-md bg-ink-100 p-0.5">
{(
[
['locale', 'Par langue', 'Deux langues à l\'écran : la source et une cible.'],
['key', 'Par clé', 'Une clé, toutes ses langues.'],
] as const
).map(([value, label, hint]) => (
<button
key={value}
onClick={() => update({ view: value === 'locale' ? null : value })}
title={hint}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
view === value
? 'bg-white text-ink-900 shadow-sm'
: 'text-ink-500 hover:text-ink-800'
}`}
>
{label}
</button>
))}
</div>
{/* Le sélecteur de langue est l'objet de navigation principal de
la vue par langue et n'a aucun sens dans l'autre, toutes
les langues sont . Le laisser affiché grisé suggérerait
qu'il reste quelque chose à y choisir. */}
{view === 'locale' ? (
<div className="flex items-center gap-2 text-sm">
<span className="rounded bg-ink-100 px-2 py-1 font-mono text-xs text-ink-500">
{sourceMeta?.code ?? '…'}
</span>
<span className="text-ink-300"></span>
<select
value={effectiveLocale}
onChange={(e) => update({ locale: e.target.value })}
className="rounded border border-ink-300 bg-white px-2 py-1 text-sm font-medium outline-none focus:border-accent"
>
{stats.data?.locales
.filter((l) => !l.isSource)
.map((l) => (
<option key={l.code} value={l.code}>
{l.nativeName}
{!l.canWrite ? ' — lecture seule' : ''}
</option>
))}
</select>
</div>
) : (
<span className="shrink-0 text-sm text-ink-500">
<span className="rounded bg-ink-100 px-2 py-1 font-mono text-xs">
{sourceMeta?.code ?? '…'}
</span>
<span className="ml-2 text-ink-300"></span>
<span className="ml-2 text-xs">
{targetLocales.length} langue{targetLocales.length > 1 ? 's' : ''}
</span>
</span>
)}
<select
value={platform}
onChange={(e) => update({ platform: e.target.value })}
className="rounded border border-ink-300 bg-white px-2 py-1 text-sm outline-none focus:border-accent"
>
<option value="">Toutes plateformes</option>
{stats.data?.platforms.map((p) => (
<option key={p.slug} value={p.slug}>
{p.name}
</option>
))}
</select>
<input
type="search"
value={search}
onChange={(e) => update({ q: e.target.value })}
placeholder="Rechercher une clé ou un texte…"
className="min-w-0 flex-1 rounded border border-ink-300 px-2.5 py-1 text-sm outline-none focus:border-accent"
/>
{view === 'locale' && localeMeta && (
<div className="flex shrink-0 items-center gap-3 text-xs">
{localeMeta.needsReview > 0 && (
<span className="tabular font-medium text-red-600">
{localeMeta.needsReview} obsolète
{localeMeta.needsReview > 1 ? 's' : ''}
</span>
)}
<span className="tabular text-ink-500">
{localeMeta.completion}% traduit
</span>
</div>
)}
{/* Le bouton principal n'est pas « enregistrer » mais « traiter la
file » : un traducteur ne vient pas explorer, il vient vider
une liste. Chaque vue a la sienne celle d'une langue, ou
celle de toutes les langues qu'on a le droit d'écrire. */}
{view === 'locale' ? (
<button
onClick={() => setFocusOpen(true)}
disabled={actionable === 0 || readOnly}
className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:bg-ink-300"
>
{readOnly
? 'Lecture seule'
: actionable > 0
? `Traiter les ${actionable}`
: 'Rien à traiter'}
</button>
) : (
<button
onClick={() => setFocusOpen(true)}
disabled={writableLocales.length === 0}
title={
writableLocales.length === 0
? 'Vous n\'êtes habilité à écrire dans aucune langue de ce projet.'
: `Enchaîner les clés à traiter en ${writableLocales.map((l) => l.code).join(', ')}.`
}
className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:bg-ink-300"
>
{writableLocales.length === 0
? 'Lecture seule'
: `Traiter (${writableLocales.length} langue${writableLocales.length > 1 ? 's' : ''})`}
</button>
)}
</header>
{view === 'locale' && readOnly && (
<div className="shrink-0 border-b border-amber-200 bg-amber-50 px-4 py-1.5 text-xs text-amber-900">
Vous n'êtes pas habilité à écrire en {localeMeta?.nativeName}. Consultation
uniquement.
</div>
)}
<div className="flex min-h-0 flex-1">
<NamespaceTree
tree={namespaces.data}
selected={namespace}
status={status}
unassigned={unassigned}
total={total}
onSelectNamespace={(path) => update({ namespace: path })}
onSelectFilter={({ status: next, unassigned: on }) =>
update({ status: next || null, unassigned: on ? '1' : null })
}
/>
{view === 'locale' ? (
<TranslationGrid
rows={rows}
total={total}
loading={grid.isLoading}
locale={effectiveLocale}
direction={localeMeta?.direction ?? 'ltr'}
pluralCategories={localeMeta?.pluralCategories ?? ['other']}
selectedId={selectedId}
onSelect={setSelectedId}
readOnly={readOnly}
gridKey={gridQuery}
/>
) : (
<KeyGrid
rows={keyRows.data?.items ?? []}
total={total}
loading={keyRows.isLoading}
locales={targetLocales}
sourceCode={sourceMeta?.code ?? ''}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
<ContextPanel
row={context}
sourceCode={sourceMeta?.code ?? ''}
target={
view === 'locale' && selected !== null
? {
localeCode: effectiveLocale,
status: selected.status,
isStale: selected.isStale,
isMachineTranslated: selected.isMachineTranslated,
updatedAt: selected.updatedAt,
updatedBy: selected.updatedBy,
}
: null
}
targets={view === 'key' ? (selectedKey?.targets ?? null) : null}
/>
</div>
{focusOpen &&
(view === 'locale' ? (
<FocusMode
projectUuid={projectUuid}
locale={effectiveLocale}
direction={localeMeta?.direction ?? 'ltr'}
pluralCategories={localeMeta?.pluralCategories ?? ['other']}
platform={platform || undefined}
onClose={() => setFocusOpen(false)}
/>
) : (
<KeyFocusMode
projectUuid={projectUuid}
locales={targetLocales}
platform={platform || undefined}
namespace={namespace || undefined}
onClose={() => setFocusOpen(false)}
/>
))}
</div>
);
}
export type { GridRow };

View file

@ -0,0 +1,328 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { api } from '@/api/client';
import type { CreatedApiKey } from '@/api/types';
import { humanMessage } from '@/auth/AuthProvider';
import { ProjectNav } from '@/components/ProjectNav';
import { RequiresAdmin } from '@/components/RequiresAdmin';
/**
* Clés API et prise en main.
*
* Deux idées gouvernent cet écran :
*
* 1. **Le secret n'apparaît qu'une fois**, immédiatement après la création, dans
* un encart qu'on ne peut pas manquer. Il n'est pas stocké en clair : le
* réafficher est techniquement impossible, et le dire franchement évite de
* laisser croire qu'on pourra le retrouver plus tard.
*
* 2. **Le mode d'emploi est SOUS la clé**, pas dans une documentation à part.
* Le moment quelqu'un crée une clé est exactement celui il cherche
* quoi en faire.
*/
export function IntegrationPage() {
const { projectUuid = '' } = useParams();
const queryClient = useQueryClient();
const [created, setCreated] = useState<CreatedApiKey | null>(null);
const stats = useQuery({ queryKey: ['stats', projectUuid], queryFn: () => api.stats(projectUuid) });
const data = useQuery({ queryKey: ['api-keys', projectUuid], queryFn: () => api.apiKeys(projectUuid) });
const revoke = useMutation({
mutationFn: (uuid: string) => api.revokeApiKey(projectUuid, uuid),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['api-keys', projectUuid] }),
});
// Le serveur refuse déjà ; ici on refuse LISIBLEMENT.
if (stats.isSuccess && !stats.data.viewer.canAdminister) {
return <RequiresAdmin projectUuid={projectUuid} />;
}
return (
<div className="flex h-full flex-col bg-ink-100">
<header className="shrink-0 border-b border-ink-200 bg-white px-4 py-2.5">
<ProjectNav projectUuid={projectUuid} canAdminister={stats.data?.viewer.canAdminister ?? false} />
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="text-xl font-semibold tracking-tight text-ink-900">Intégration</h1>
<p className="mt-1 text-sm text-ink-500">
Clés d'accès et prise en main du CLI.
</p>
{created !== null && <SecretBanner created={created} onDismiss={() => setCreated(null)} />}
<CreateKeyForm
projectUuid={projectUuid}
environments={(data.data?.environments ?? []).map((e) => e.slug)}
scopes={(data.data?.scopes ?? []).map((s) => s.value)}
onCreated={(key) => {
setCreated(key);
void queryClient.invalidateQueries({ queryKey: ['api-keys', projectUuid] });
}}
/>
<section className="mt-8">
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Clés existantes
</h2>
<div className="divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{data.data?.keys.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-ink-400">
Aucune clé. Créez-en une pour brancher une application.
</p>
)}
{data.data?.keys.map((key) => (
<div
key={key.uuid}
className={`flex flex-wrap items-center gap-3 px-4 py-3 ${key.isUsable ? '' : 'opacity-50'}`}
>
<div className="min-w-40 flex-1">
<p className="text-sm font-medium text-ink-800">{key.name}</p>
<p className="font-mono text-[11px] text-ink-400">{key.prefix}</p>
</div>
<span className="rounded bg-ink-100 px-1.5 py-0.5 text-[11px] text-ink-600">
{key.environment}
</span>
<span className="font-mono text-[10px] text-ink-400">
{key.scopes.join(' · ')}
</span>
{/* Une clé jamais utilisée est soit oubliée, soit
remplacée sans avoir é révoquée. Dans les deux
cas elle devrait disparaître. */}
<span className="min-w-28 text-[11px] text-ink-400">
{key.revokedAt !== null
? 'révoquée'
: key.lastUsedAt === null
? 'jamais utilisée'
: `vue le ${new Date(key.lastUsedAt).toLocaleDateString('fr-FR')}`}
</span>
{key.isUsable && (
<button
onClick={() => revoke.mutate(key.uuid)}
className="rounded px-2 py-1 text-xs text-ink-400 hover:bg-red-50 hover:text-red-700"
>
Révoquer
</button>
)}
</div>
))}
</div>
</section>
<GettingStarted project={stats.data?.project ?? ''} />
</div>
</div>
</div>
);
}
function SecretBanner({ created, onDismiss }: { created: CreatedApiKey; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
return (
<div className="mt-6 rounded-lg border-2 border-amber-300 bg-amber-50 p-5">
<p className="text-sm font-medium text-amber-900">{created.warning}</p>
<div className="mt-3 flex items-center gap-2">
<code className="flex-1 select-all break-all rounded border border-amber-200 bg-white px-3 py-2 font-mono text-xs text-ink-900">
{created.secret}
</code>
<button
onClick={() => {
void navigator.clipboard?.writeText(created.secret);
setCopied(true);
}}
className="shrink-0 rounded-md bg-amber-600 px-3 py-2 text-xs font-medium text-white hover:bg-amber-700"
>
{copied ? 'Copié' : 'Copier'}
</button>
</div>
<p className="mt-3 text-xs text-amber-800">
Elle n'est pas stockée en clair : la réafficher est impossible. Si vous la perdez,
révoquez-la et créez-en une nouvelle.
</p>
<button onClick={onDismiss} className="mt-3 text-xs text-amber-700 underline-offset-4 hover:underline">
J'ai copié la clé
</button>
</div>
);
}
function CreateKeyForm({
projectUuid,
environments,
scopes,
onCreated,
}: {
projectUuid: string;
environments: string[];
scopes: string[];
onCreated: (key: CreatedApiKey) => void;
}) {
const [name, setName] = useState('');
const [environment, setEnvironment] = useState('');
const [selected, setSelected] = useState<string[]>(['translations:read']);
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: () =>
api.createApiKey(projectUuid, {
name,
environment: environment || (environments[0] ?? ''),
scopes: selected,
}),
onSuccess: (key) => {
setError(null);
setName('');
onCreated(key);
},
onError: (caught) => setError(humanMessage(caught)),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
className="mt-6 rounded-lg border border-ink-200 bg-white p-5 shadow-sm"
>
<h2 className="mb-4 text-sm font-medium text-ink-800">Créer une clé</h2>
<div className="flex flex-wrap gap-3">
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI application web"
className="min-w-56 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<select
value={environment || environments[0] || ''}
onChange={(e) => setEnvironment(e.target.value)}
className="rounded-md border border-ink-300 bg-white px-2 py-2 text-sm outline-none focus:border-accent"
>
{environments.map((slug) => (
<option key={slug} value={slug}>
{slug}
</option>
))}
</select>
<button
type="submit"
disabled={create.isPending || selected.length === 0}
className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
>
{create.isPending ? 'Création…' : 'Créer'}
</button>
</div>
<p className="mt-2 text-xs text-ink-400">
Le nom est ce qui permettra de savoir quoi révoquer dans six mois.
</p>
<div className="mt-4">
<p className="mb-1.5 text-xs font-medium text-ink-600">Permissions</p>
<div className="flex flex-wrap gap-1.5">
{scopes.map((scope) => {
const on = selected.includes(scope);
const isWrite = scope.includes('write') || scope.includes('publish');
return (
<button
key={scope}
type="button"
onClick={() =>
setSelected((prev) =>
on ? prev.filter((s) => s !== scope) : [...prev, scope],
)
}
className={`rounded px-2 py-1 font-mono text-[11px] transition-colors ${
on
? isWrite
? 'bg-amber-600 text-white'
: 'bg-accent text-white'
: 'bg-ink-100 text-ink-600 hover:bg-ink-200'
}`}
>
{scope}
</button>
);
})}
</div>
{/* Les scopes d'écriture sont d'une autre couleur : une clé de
production ne devrait en porter aucun, et l'écart visuel rend
l'erreur difficile à commettre sans la voir. */}
<p className="mt-2 text-xs text-ink-400">
Une clé de production n'a besoin que de lecture. Les permissions d'écriture
(en orange) sont réservées aux clés de développement et d'intégration continue.
</p>
</div>
{error !== null && (
<p role="alert" className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
)}
</form>
);
}
function GettingStarted({ project }: { project: string }) {
return (
<section className="mt-8 rounded-lg border border-ink-200 bg-white p-5">
<h2 className="mb-3 text-sm font-medium text-ink-800">Brancher une application</h2>
<ol className="space-y-4 text-sm text-ink-700">
<li>
<p className="font-medium">1. Installer le CLI</p>
<Snippet>{`curl -sSL https://tq-slator.internal/tqs.phar -o /usr/local/bin/tqs\nchmod +x /usr/local/bin/tqs`}</Snippet>
</li>
<li>
<p className="font-medium">2. Lier le dépôt</p>
<Snippet>{`export TQS_API_KEY=tqs_…\ntqs init`}</Snippet>
<p className="mt-1 text-xs text-ink-400">
Projet, environnement, plateformes et langues sont déduits de la clé.
Le fichier <code className="font-mono">tqs.config.json</code> produit se
committe il ne contient aucun secret.
</p>
</li>
<li>
<p className="font-medium">3. Envoyer les clés du code</p>
<Snippet>{`tqs push --dry-run # voir le diff\ntqs push # appliquer`}</Snippet>
</li>
<li>
<p className="font-medium">4. Récupérer les traductions en intégration continue</p>
<Snippet>{`tqs pull\ntqs pull --check # échoue si des fichiers ne sont pas commités\ntqs status --fail-under=80 # échoue si une langue passe sous 80 %`}</Snippet>
</li>
</ol>
<p className="mt-4 text-xs text-ink-400">
Les fichiers récupérés sont commités dans votre dépôt : votre production ne dépend
donc jamais de la disponibilité de TQ-Slator. Projet visé :{' '}
<code className="font-mono">{project}</code>
</p>
</section>
);
}
function Snippet({ children }: { children: string }) {
return (
<pre className="mt-1.5 overflow-x-auto rounded bg-ink-900 px-3 py-2 font-mono text-xs leading-relaxed text-ink-100">
{children}
</pre>
);
}

View file

@ -0,0 +1,181 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '@/api/client';
import { humanMessage, useAuth } from '@/auth/AuthProvider';
const MIN_PASSWORD_LENGTH = 12;
/**
* Acceptation d'une invitation — la première page qu'un traducteur voit.
*
* Elle annonce à quoi il est invité AVANT de lui demander quoi que ce soit :
* quel projet, quel rôle, quelles langues. Demander un mot de passe sans dire
* pour quoi est la façon la plus sûre de faire abandonner quelqu'un, ou pire, de
* lui faire croire à une tentative d'hameçonnage.
*/
export function InvitationPage() {
const { token = '' } = useParams();
const navigate = useNavigate();
const { login } = useAuth();
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const invitation = useQuery({
queryKey: ['invitation', token],
queryFn: () => api.invitationPreview(token),
retry: false,
});
async function submit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setPending(true);
try {
await api.acceptInvitation(token, { name, password });
// Connexion immédiate : demander de ressaisir des identifiants qu'on
// vient de définir est une étape gratuite, juste après l'effort.
await login(invitation.data?.email ?? '', password);
navigate('/projects', { replace: true });
} catch (caught) {
setError(humanMessage(caught));
} finally {
setPending(false);
}
}
if (invitation.isLoading) {
return <Centered>Vérification de l'invitation</Centered>;
}
if (invitation.isError) {
return (
<Centered>
<div className="max-w-md text-center">
<p className="text-lg font-medium text-ink-800">Invitation non valable</p>
<p className="mt-2 text-sm text-ink-500">{humanMessage(invitation.error)}</p>
<Link
to="/login"
className="mt-6 inline-block text-sm text-accent underline-offset-4 hover:underline"
>
Aller à la page de connexion
</Link>
</div>
</Centered>
);
}
const data = invitation.data;
const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH;
return (
<Centered>
<div className="w-full max-w-md">
<h1 className="text-2xl font-semibold tracking-tight text-ink-900">
Rejoindre {data?.project}
</h1>
<p className="mt-1 text-sm text-ink-500">
Invitation pour <span className="font-medium text-ink-700">{data?.email}</span>
</p>
<div className="mt-6 rounded-lg border border-ink-200 bg-white p-6 shadow-sm">
{/* Ce que l'invitation donne, énoncé avant toute saisie. */}
<dl className="mb-6 space-y-2 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-ink-500">Rôle</dt>
<dd className="font-medium text-ink-800">{data?.role}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-ink-500">Langues</dt>
<dd className="text-right font-medium text-ink-800">
{data?.locales.length === 0
? 'toutes les langues du projet'
: data?.locales.join(', ')}
</dd>
</div>
</dl>
<form onSubmit={submit}>
<label className="block">
<span className="text-sm font-medium text-ink-700">Votre nom</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
autoComplete="name"
className="mt-1.5 w-full rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<span className="mt-1 block text-xs text-ink-400">
Il apparaîtra sur les traductions que vous écrivez.
</span>
</label>
<label className="mt-4 block">
<span className="text-sm font-medium text-ink-700">Mot de passe</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={MIN_PASSWORD_LENGTH}
autoComplete="new-password"
className={`mt-1.5 w-full rounded-md border px-3 py-2 text-sm outline-none focus:border-accent ${
tooShort ? 'border-amber-400' : 'border-ink-300'
}`}
/>
{/* Une longueur plutôt qu'une exigence de composition :
les règles « majuscule + chiffre + symbole » produisent
des mots de passe courts, difficiles à retenir et faciles
à casser. */}
<span
className={`mt-1 block text-xs ${tooShort ? 'text-amber-700' : 'text-ink-400'}`}
>
{MIN_PASSWORD_LENGTH} caractères minimum. Une phrase entière fait un
excellent mot de passe.
</span>
</label>
{data?.isExternal && (
<p className="mt-4 rounded-md bg-ink-50 px-3 py-2 text-xs text-ink-600">
Vous rejoignez ce projet comme prestataire externe. Votre accès est
limité aux langues ci-dessus et vos connexions sont journalisées.
</p>
)}
{error !== null && (
<p
role="alert"
className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700"
>
{error}
</p>
)}
<button
type="submit"
disabled={pending || tooShort}
className="mt-6 w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:opacity-60"
>
{pending ? 'Création du compte…' : 'Créer mon compte'}
</button>
</form>
</div>
<p className="mt-6 text-center text-xs text-ink-400">
Ce lien ne fonctionne qu'une fois. Si vous n'attendiez pas cette invitation,
fermez simplement cette page : aucun compte ne sera créé.
</p>
</div>
</Centered>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return <div className="flex h-full items-center justify-center px-6">{children}</div>;
}

View file

@ -0,0 +1,93 @@
import { useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { humanMessage, useAuth } from '@/auth/AuthProvider';
export function LoginPage() {
const { user, loading, login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
if (loading) return null;
if (user) return <Navigate to="/projects" replace />;
async function submit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setPending(true);
try {
await login(email, password);
navigate('/projects', { replace: true });
} catch (caught) {
setError(humanMessage(caught));
} finally {
setPending(false);
}
}
return (
<div className="flex h-full items-center justify-center px-6">
<div className="w-full max-w-sm">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight text-ink-900">TQ-Slator</h1>
<p className="mt-1 text-sm text-ink-500">Gestion des traductions</p>
</div>
<form
onSubmit={submit}
className="rounded-lg border border-ink-200 bg-white p-6 shadow-sm"
>
<label className="block">
<span className="text-sm font-medium text-ink-700">Adresse e-mail</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
autoComplete="username"
className="mt-1.5 w-full rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="mt-4 block">
<span className="text-sm font-medium text-ink-700">Mot de passe</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="mt-1.5 w-full rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
{error !== null && (
<p
role="alert"
className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700"
>
{error}
</p>
)}
<button
type="submit"
disabled={pending}
className="mt-6 w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:opacity-60"
>
{pending ? 'Connexion…' : 'Se connecter'}
</button>
</form>
<p className="mt-6 text-center text-xs text-ink-400">
L'accès se fait sur invitation. Contactez un administrateur du projet.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,367 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { api } from '@/api/client';
import type { Member, ProjectRole } from '@/api/types';
import { humanMessage } from '@/auth/AuthProvider';
import { ProjectNav } from '@/components/ProjectNav';
import { RequiresAdmin } from '@/components/RequiresAdmin';
const ROLE_HELP: Record<ProjectRole, string> = {
owner: 'Tout, y compris la suppression du projet.',
admin: 'Membres, langues, plateformes, clés API, publication.',
developer: 'Clés, plateformes, releases. N\'écrit PAS de traductions.',
translator: 'Écrit les traductions de ses langues assignées.',
reviewer: 'Comme traducteur, plus la validation.',
viewer: 'Lecture seule.',
};
/**
* Administration des accès.
*
* L'écran est construit autour d'une question : « qui peut écrire quoi ? ».
* D' les langues affichées sur chaque ligne plutôt que cachées derrière une
* fiche c'est l'information qui décide si un prestataire est correctement
* cloisonné, et elle doit se vérifier d'un coup d'œil.
*/
export function MembersPage() {
const { projectUuid = '' } = useParams();
const queryClient = useQueryClient();
const stats = useQuery({ queryKey: ['stats', projectUuid], queryFn: () => api.stats(projectUuid) });
const data = useQuery({ queryKey: ['members', projectUuid], queryFn: () => api.members(projectUuid) });
const targetLocales = (stats.data?.locales ?? []).filter((l) => !l.isSource);
// Le serveur refuse déjà ; ici on refuse LISIBLEMENT.
if (stats.isSuccess && !stats.data.viewer.canAdminister) {
return <RequiresAdmin projectUuid={projectUuid} />;
}
return (
<div className="flex h-full flex-col bg-ink-100">
<header className="shrink-0 border-b border-ink-200 bg-white px-4 py-2.5">
<ProjectNav projectUuid={projectUuid} canAdminister={stats.data?.viewer.canAdminister ?? false} />
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="text-xl font-semibold tracking-tight text-ink-900">Membres</h1>
<p className="mt-1 text-sm text-ink-500">
Qui a accès à {stats.data?.projectName}, et sur quelles langues.
</p>
<InviteForm
projectUuid={projectUuid}
locales={targetLocales.map((l) => l.code)}
onDone={() => void queryClient.invalidateQueries({ queryKey: ['members', projectUuid] })}
/>
<section className="mt-8">
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Membres actifs
</h2>
<div className="divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{data.data?.members.map((member) => (
<MemberRow
key={member.uuid}
projectUuid={projectUuid}
member={member}
locales={targetLocales.map((l) => l.code)}
/>
))}
</div>
</section>
{(data.data?.pending.length ?? 0) > 0 && (
<section className="mt-8">
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Invitations en attente
</h2>
{/* Les invitations non acceptées comptent comme des accès
en cours d'octroi : les séparer dans un autre écran
laisserait un angle mort à qui audite les accès. */}
<div className="divide-y divide-ink-100 overflow-hidden rounded-lg border border-dashed border-ink-300 bg-white">
{data.data?.pending.map((invitation) => (
<div key={invitation.email} className="flex items-center gap-3 px-4 py-3">
<span className="flex-1 truncate text-sm text-ink-700">
{invitation.email}
</span>
<span className="rounded bg-ink-100 px-1.5 py-0.5 text-[11px] text-ink-600">
{invitation.role}
</span>
<span className="font-mono text-[11px] text-ink-400">
{invitation.locales.length === 0
? 'toutes langues'
: invitation.locales.join(', ')}
</span>
<span className="text-[11px] text-ink-400">
expire le{' '}
{new Date(invitation.expiresAt).toLocaleDateString('fr-FR')}
</span>
</div>
))}
</div>
</section>
)}
</div>
</div>
</div>
);
}
function InviteForm({
projectUuid,
locales,
onDone,
}: {
projectUuid: string;
locales: string[];
onDone: () => void;
}) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<ProjectRole>('translator');
const [selected, setSelected] = useState<string[]>([]);
const [isExternal, setIsExternal] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const invite = useMutation({
mutationFn: () => api.invite(projectUuid, { email, role, locales: selected, isExternal }),
onSuccess: (result) => {
setMessage(result.message);
setError(null);
setEmail('');
setSelected([]);
onDone();
},
onError: (caught) => {
setError(humanMessage(caught));
setMessage(null);
},
});
// Les langues ne concernent que les rôles qui écrivent : les proposer à un
// developer suggérerait une restriction qui n'existe pas pour lui.
const showLocales = role === 'translator' || role === 'reviewer';
return (
<form
onSubmit={(e) => {
e.preventDefault();
invite.mutate();
}}
className="mt-6 rounded-lg border border-ink-200 bg-white p-5 shadow-sm"
>
<h2 className="mb-4 text-sm font-medium text-ink-800">Inviter quelqu'un</h2>
<div className="flex flex-wrap gap-3">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="adresse@exemple.fr"
className="min-w-56 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ProjectRole)}
className="rounded-md border border-ink-300 bg-white px-2 py-2 text-sm outline-none focus:border-accent"
>
{(Object.keys(ROLE_HELP) as ProjectRole[]).map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
type="submit"
disabled={invite.isPending}
className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
>
{invite.isPending ? 'Envoi…' : 'Inviter'}
</button>
</div>
<p className="mt-2 text-xs text-ink-400">{ROLE_HELP[role]}</p>
{showLocales && (
<div className="mt-4">
<p className="mb-1.5 text-xs font-medium text-ink-600">
Langues autorisées{' '}
<span className="font-normal text-ink-400">
aucune sélection = toutes les langues du projet
</span>
</p>
<div className="flex flex-wrap gap-1.5">
{locales.map((code) => {
const on = selected.includes(code);
return (
<button
key={code}
type="button"
onClick={() =>
setSelected((prev) =>
on ? prev.filter((c) => c !== code) : [...prev, code],
)
}
className={`rounded px-2 py-1 font-mono text-xs transition-colors ${
on ? 'bg-accent text-white' : 'bg-ink-100 text-ink-600 hover:bg-ink-200'
}`}
>
{code}
</button>
);
})}
</div>
</div>
)}
<label className="mt-4 flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isExternal}
onChange={(e) => setIsExternal(e.target.checked)}
/>
Prestataire externe
<span className="text-ink-400">
fenêtre d'invitation raccourcie et connexions journalisées
</span>
</label>
{message !== null && (
<p className="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{message}</p>
)}
{error !== null && (
<p role="alert" className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
)}
</form>
);
}
function MemberRow({
projectUuid,
member,
locales,
}: {
projectUuid: string;
member: Member;
locales: string[];
}) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = () => void queryClient.invalidateQueries({ queryKey: ['members', projectUuid] });
const update = useMutation({
mutationFn: (body: { role?: ProjectRole; locales?: string[] }) =>
api.updateMember(projectUuid, member.uuid, body),
onSuccess: () => {
setError(null);
setEditing(false);
refresh();
},
onError: (caught) => setError(humanMessage(caught)),
});
const remove = useMutation({
mutationFn: () => api.removeMember(projectUuid, member.uuid),
onSuccess: refresh,
onError: (caught) => setError(humanMessage(caught)),
});
return (
<div className="px-4 py-3">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-40 flex-1">
<p className="text-sm font-medium text-ink-800">
{member.name}
{member.isExternal && (
<span className="ml-2 rounded bg-ink-200 px-1.5 py-0.5 text-[10px] font-normal text-ink-600">
externe
</span>
)}
</p>
<p className="text-xs text-ink-400">{member.email}</p>
</div>
<select
value={member.role}
onChange={(e) => update.mutate({ role: e.target.value as ProjectRole })}
className="rounded border border-ink-300 bg-white px-2 py-1 text-xs outline-none focus:border-accent"
>
{(Object.keys(ROLE_HELP) as ProjectRole[]).map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
onClick={() => setEditing((v) => !v)}
className="min-w-32 rounded px-2 py-1 text-left font-mono text-[11px] text-ink-500 hover:bg-ink-100"
title="Modifier les langues autorisées"
>
{member.coversAllLocales ? 'toutes langues' : member.locales.join(', ')}
</button>
<span className="text-[11px] text-ink-400">
{member.lastLoginAt === null
? 'jamais connecté'
: new Date(member.lastLoginAt).toLocaleDateString('fr-FR')}
</span>
<button
onClick={() => remove.mutate()}
className="rounded px-2 py-1 text-xs text-ink-400 hover:bg-red-50 hover:text-red-700"
>
Retirer
</button>
</div>
{editing && (
<div className="mt-3 flex flex-wrap gap-1.5 border-t border-ink-100 pt-3">
{locales.map((code) => {
const on = member.locales.includes(code);
return (
<button
key={code}
onClick={() =>
update.mutate({
locales: on
? member.locales.filter((c) => c !== code)
: [...member.locales, code],
})
}
className={`rounded px-2 py-1 font-mono text-xs transition-colors ${
on ? 'bg-accent text-white' : 'bg-ink-100 text-ink-600 hover:bg-ink-200'
}`}
>
{code}
</button>
);
})}
<span className="self-center text-xs text-ink-400">
aucune sélection = toutes les langues
</span>
</div>
)}
{error !== null && (
<p role="alert" className="mt-2 text-xs text-red-700">
{error}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,721 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { api } from '@/api/client';
import type { AdminEnvironment, AdminPlatform, PlatformList } from '@/api/types';
import { humanMessage } from '@/auth/AuthProvider';
import { ProjectNav } from '@/components/ProjectNav';
import { RequiresAdmin } from '@/components/RequiresAdmin';
const KIND_LABELS: Record<string, string> = {
web: 'Application web',
ios: 'iOS',
android: 'Android',
backend: 'Back-end',
email: 'E-mails',
other: 'Autre',
};
/**
* Les surfaces d'un projet, et ses cibles de déploiement.
*
* Deux notions distinctes réunies sur un écran parce qu'on s'y rend pour la
* même raison : rendre un projet livrable. Une **plateforme** dit quelles clés
* entrent dans un fichier et dans quelle syntaxe ; un **environnement** dit
* quelle version de ce fichier est servie. Il faut les deux, et un projet neuf
* n'a que le second.
*/
export function PlatformsPage() {
const { projectUuid = '' } = useParams();
const stats = useQuery({ queryKey: ['stats', projectUuid], queryFn: () => api.stats(projectUuid) });
const platforms = useQuery({
queryKey: ['platforms', projectUuid],
queryFn: () => api.platforms(projectUuid),
});
const environments = useQuery({
queryKey: ['environments', projectUuid],
queryFn: () => api.environments(projectUuid),
});
if (stats.isSuccess && !stats.data.viewer.canAdminister) {
return <RequiresAdmin projectUuid={projectUuid} />;
}
const active = platforms.data?.platforms.filter((p) => !p.isArchived) ?? [];
const archived = platforms.data?.platforms.filter((p) => p.isArchived) ?? [];
return (
<div className="flex h-full flex-col bg-ink-100">
<header className="shrink-0 border-b border-ink-200 bg-white px-4 py-2.5">
<ProjectNav
projectUuid={projectUuid}
canAdminister={stats.data?.viewer.canAdminister ?? false}
/>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="text-xl font-semibold tracking-tight text-ink-900">
Plateformes et environnements
</h1>
<p className="mt-1 text-sm text-ink-500">
Ce que {stats.data?.projectName} produit, et cela est servi.
</p>
{/* Le cas du projet neuf : il a ses environnements mais aucune
plateforme, donc rien à livrer. Le dire avant que
l'utilisateur ne découvre l'échec au moment de publier. */}
{platforms.isSuccess && active.length === 0 && (
<p className="mt-5 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Aucune plateforme active : ce projet ne peut produire aucun fichier de
traduction. Déclarez-en une pour rendre la publication possible.
</p>
)}
<section className="mt-6">
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Plateformes
</h2>
<div className="divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{active.map((platform) => (
<PlatformRow
key={platform.uuid}
projectUuid={projectUuid}
platform={platform}
options={platforms.data}
isLastActive={active.length === 1}
/>
))}
{active.length === 0 && platforms.isSuccess && (
<p className="px-4 py-6 text-center text-sm text-ink-400">
Aucune plateforme active.
</p>
)}
</div>
<CreatePlatformForm
projectUuid={projectUuid}
options={platforms.data}
onDone={() => void platforms.refetch()}
/>
</section>
{archived.length > 0 && (
<section className="mt-8">
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Archivées
</h2>
{/* Archivées et non supprimées : les clés leur restent
rattachées, et c'est cette trace qui explique
pourquoi telle clé existe. */}
<div className="divide-y divide-ink-100 overflow-hidden rounded-lg border border-dashed border-ink-300 bg-white opacity-75">
{archived.map((platform) => (
<PlatformRow
key={platform.uuid}
projectUuid={projectUuid}
platform={platform}
options={platforms.data}
isLastActive={false}
/>
))}
</div>
</section>
)}
<section className="mt-10">
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Environnements
</h2>
<div className="divide-y divide-ink-100 overflow-hidden rounded-lg border border-ink-200 bg-white">
{environments.data?.environments.map((environment) => (
<EnvironmentRow
key={environment.uuid}
projectUuid={projectUuid}
environment={environment}
/>
))}
</div>
<CreateEnvironmentForm
projectUuid={projectUuid}
onDone={() => void environments.refetch()}
/>
</section>
</div>
</div>
</div>
);
}
// ── Plateformes ──────────────────────────────────────────────────────────
function PlatformRow({
projectUuid,
platform,
options,
isLastActive,
}: {
projectUuid: string;
platform: AdminPlatform;
options: PlatformList | undefined;
isLastActive: boolean;
}) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(platform.name);
const [format, setFormat] = useState(platform.messageFormat);
const [layout, setLayout] = useState(platform.exportLayout);
const [maxLength, setMaxLength] = useState(platform.defaultMaxLength?.toString() ?? '');
const [error, setError] = useState<string | null>(null);
const refresh = () => void queryClient.invalidateQueries({ queryKey: ['platforms', projectUuid] });
const save = useMutation({
mutationFn: () =>
api.updatePlatform(projectUuid, platform.uuid, {
name,
messageFormat: format,
exportLayout: layout,
defaultMaxLength: maxLength === '' ? null : Number(maxLength),
}),
onSuccess: () => {
setError(null);
setEditing(false);
refresh();
},
onError: (caught) => setError(humanMessage(caught)),
});
const toggleArchive = useMutation({
mutationFn: () =>
api.updatePlatform(projectUuid, platform.uuid, { isArchived: !platform.isArchived }),
onSuccess: () => {
setError(null);
refresh();
},
onError: (caught) => setError(humanMessage(caught)),
});
if (editing) {
return (
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
className="space-y-3 bg-ink-50 px-4 py-4"
>
<div className="flex flex-wrap gap-3">
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="min-w-48 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
{/* Le slug est en lecture seule : il est dans les URL de
livraison, dans les tqs.config.json des dépôts clients et
dans les scripts d'intégration continue. Le changer
casserait tout cela sans qu'aucune erreur ne remonte. */}
<span
className="rounded-md border border-dashed border-ink-300 bg-ink-100 px-3 py-2 font-mono text-sm text-ink-400"
title="Le slug n'est pas modifiable : il apparaît dans les URL de livraison et dans la configuration des dépôts clients."
>
{platform.slug}
</span>
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className="rounded-md border border-ink-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent"
>
{options?.formats.map((f) => (
<option key={f.value} value={f.value}>
{f.value} (.{f.fileExtension})
</option>
))}
</select>
<select
value={layout}
onChange={(e) => setLayout(e.target.value as 'nested' | 'flat')}
className="rounded-md border border-ink-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent"
>
<option value="nested">imbriqué</option>
<option value="flat">à plat</option>
</select>
<input
type="number"
min={1}
value={maxLength}
onChange={(e) => setMaxLength(e.target.value)}
placeholder="longueur max"
className="w-32 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex items-center gap-3">
<button
type="submit"
disabled={save.isPending}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-ink-300"
>
Enregistrer
</button>
<button
type="button"
onClick={() => {
setEditing(false);
setError(null);
}}
className="text-sm text-ink-500 hover:text-ink-800"
>
Annuler
</button>
</div>
</form>
);
}
return (
<div className="px-4 py-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-ink-800">{platform.name}</span>
<span className="font-mono text-[11px] text-ink-400">{platform.slug}</span>
<span className="rounded bg-ink-100 px-1.5 py-0.5 text-[11px] text-ink-600">
{KIND_LABELS[platform.kind] ?? platform.kind}
</span>
{!platform.acceptsPush && !platform.isArchived && (
<span
className="rounded bg-amber-100 px-1.5 py-0.5 text-[11px] text-amber-900"
title="Le CLI ne peut pas pousser de clés dans ce format."
>
push impossible
</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-2 text-xs">
{!platform.isArchived && (
<button
onClick={() => setEditing(true)}
className="rounded border border-ink-300 px-2 py-1 text-ink-600 hover:bg-ink-100"
>
Modifier
</button>
)}
<button
onClick={() => toggleArchive.mutate()}
disabled={toggleArchive.isPending || (isLastActive && !platform.isArchived)}
title={
isLastActive && !platform.isArchived
? 'Dernière plateforme active : l\'archiver rendrait toute publication impossible.'
: undefined
}
className={`rounded border px-2 py-1 transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
platform.isArchived
? 'border-ink-300 text-ink-600 hover:bg-ink-100'
: 'border-amber-300 text-amber-800 hover:bg-amber-50'
}`}
>
{platform.isArchived ? 'Désarchiver' : 'Archiver'}
</button>
</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 text-[11px] text-ink-400">
<span className="font-mono">
{platform.messageFormat} · {platform.exportLayout === 'nested' ? 'imbriqué' : 'à plat'}
</span>
{/* Le chiffre qui rend l'archivage décidable : « 0 clé » se
retire sans réfléchir, « 340 clés » demande de vérifier. */}
<span>
{platform.keyCount} clé{platform.keyCount > 1 ? 's' : ''}
</span>
{platform.defaultMaxLength !== null && (
<span>longueur max {platform.defaultMaxLength}</span>
)}
{platform.isArchived && platform.archivedAt !== null && (
<span>archivée le {new Date(platform.archivedAt).toLocaleDateString('fr-FR')}</span>
)}
</div>
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
</div>
);
}
function CreatePlatformForm({
projectUuid,
options,
onDone,
}: {
projectUuid: string;
options: PlatformList | undefined;
onDone: () => void;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [kind, setKind] = useState('web');
const [format, setFormat] = useState('icu');
const [layout, setLayout] = useState('nested');
const [maxLength, setMaxLength] = useState('');
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: () =>
api.createPlatform(projectUuid, {
name,
slug,
kind,
messageFormat: format,
exportLayout: layout,
defaultMaxLength: maxLength === '' ? null : Number(maxLength),
}),
onSuccess: () => {
setError(null);
setName('');
setSlug('');
setMaxLength('');
setOpen(false);
onDone();
},
onError: (caught) => setError(humanMessage(caught)),
});
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="mt-3 rounded-md border border-ink-300 bg-white px-3 py-1.5 text-sm text-ink-700 transition-colors hover:bg-ink-50"
>
Ajouter une plateforme
</button>
);
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
className="mt-3 rounded-lg border border-ink-200 bg-white p-5 shadow-sm"
>
<h3 className="mb-4 text-sm font-medium text-ink-800">Nouvelle plateforme</h3>
<div className="flex flex-wrap gap-3">
<input
required
value={name}
onChange={(e) => {
setName(e.target.value);
setSlug(slugify(e.target.value));
}}
placeholder="Nom de la plateforme"
className="min-w-48 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<input
required
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="slug"
className="w-40 rounded-md border border-ink-300 px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
<select
value={kind}
onChange={(e) => {
setKind(e.target.value);
// Le format suit le type par défaut : une app web se
// traduit presque toujours en i18next, une app iOS en
// ICU. Le choix reste modifiable juste à côté.
const suggested = options?.kinds.find((k) => k.value === e.target.value);
if (suggested && options?.formats.some((f) => f.value === suggested.defaultMessageFormat)) {
setFormat(suggested.defaultMessageFormat);
}
}}
className="rounded-md border border-ink-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent"
>
{options?.kinds.map((k) => (
<option key={k.value} value={k.value}>
{KIND_LABELS[k.value] ?? k.value}
</option>
))}
</select>
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className="rounded-md border border-ink-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent"
>
{options?.formats.map((f) => (
<option key={f.value} value={f.value}>
{f.value} (.{f.fileExtension})
</option>
))}
</select>
<select
value={layout}
onChange={(e) => setLayout(e.target.value)}
className="rounded-md border border-ink-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent"
>
<option value="nested">imbriqué</option>
<option value="flat">à plat</option>
</select>
<input
type="number"
min={1}
value={maxLength}
onChange={(e) => setMaxLength(e.target.value)}
placeholder="longueur max"
className="w-32 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
</div>
<p className="mt-3 text-[11px] text-ink-400">
Seuls les formats disposant d'un sérialiseur sont proposés. Le slug n'est plus
modifiable ensuite : il apparaît dans les URL de livraison et dans la configuration
des dépôts clients.
</p>
{error && (
<p className="mt-3 max-h-24 overflow-y-auto rounded bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
</p>
)}
<div className="mt-4 flex items-center gap-3">
<button
type="submit"
disabled={create.isPending}
className="rounded-md bg-accent px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-ink-300"
>
{create.isPending ? 'Création…' : 'Créer la plateforme'}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="text-sm text-ink-500 hover:text-ink-800"
>
Annuler
</button>
</div>
</form>
);
}
// ── Environnements ───────────────────────────────────────────────────────
function EnvironmentRow({
projectUuid,
environment,
}: {
projectUuid: string;
environment: AdminEnvironment;
}) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(environment.name);
const [error, setError] = useState<string | null>(null);
const save = useMutation({
mutationFn: () => api.updateEnvironment(projectUuid, environment.uuid, { name }),
onSuccess: () => {
setError(null);
setEditing(false);
void queryClient.invalidateQueries({ queryKey: ['environments', projectUuid] });
},
onError: (caught) => setError(humanMessage(caught)),
});
return (
<div className="px-4 py-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{editing ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => save.mutate()}
onKeyDown={(e) => {
if (e.key === 'Enter') save.mutate();
if (e.key === 'Escape') {
setName(environment.name);
setEditing(false);
}
}}
className="rounded border border-accent px-2 py-1 text-sm outline-none"
/>
) : (
<button
onClick={() => setEditing(true)}
className="text-sm font-medium text-ink-800 hover:text-accent"
>
{environment.name}
</button>
)}
<span className="font-mono text-[11px] text-ink-400">{environment.slug}</span>
{/* Rien de déployé = LA cause d'un bundle introuvable côté
client. La voir ici évite de la chercher ailleurs. */}
{environment.currentRelease === null ? (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[11px] text-amber-900">
rien de déployé
</span>
) : (
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[11px] font-medium text-emerald-800">
{environment.currentRelease.label}
</span>
)}
<span className="ml-auto text-[11px] text-ink-400">
{environment.activeKeyCount} clé{environment.activeKeyCount > 1 ? 's' : ''} d'accès
{environment.totalKeyCount > environment.activeKeyCount &&
` (${environment.totalKeyCount - environment.activeKeyCount} révoquée${
environment.totalKeyCount - environment.activeKeyCount > 1 ? 's' : ''
})`}
</span>
</div>
{environment.currentRelease !== null && (
<p className="mt-1.5 text-[11px] text-ink-400">
{environment.currentRelease.keyCount} clés · déployée le{' '}
{environment.currentRelease.publishedAt !== null
? new Date(environment.currentRelease.publishedAt).toLocaleDateString('fr-FR')
: '—'}
</p>
)}
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
</div>
);
}
function CreateEnvironmentForm({
projectUuid,
onDone,
}: {
projectUuid: string;
onDone: () => void;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: () => api.createEnvironment(projectUuid, { name, slug }),
onSuccess: () => {
setError(null);
setName('');
setSlug('');
setOpen(false);
onDone();
},
onError: (caught) => setError(humanMessage(caught)),
});
if (!open) {
return (
<div className="mt-3">
<button
onClick={() => setOpen(true)}
className="rounded-md border border-ink-300 bg-white px-3 py-1.5 text-sm text-ink-700 transition-colors hover:bg-ink-50"
>
Ajouter un environnement
</button>
{/* Dit une fois, ici, plutôt que découvert au moment où l'on
cherche le bouton qui n'existe pas. */}
<p className="mt-2 text-[11px] text-ink-400">
Un environnement ne se supprime pas : les applications qui l'interrogent
cesseraient d'être servies, sans qu'aucun signal ne parte d'ici.
</p>
</div>
);
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
className="mt-3 rounded-lg border border-ink-200 bg-white p-5 shadow-sm"
>
<h3 className="mb-4 text-sm font-medium text-ink-800">Nouvel environnement</h3>
<div className="flex flex-wrap gap-3">
<input
required
value={name}
onChange={(e) => {
setName(e.target.value);
setSlug(slugify(e.target.value));
}}
placeholder="Nom (Recette client, Préproduction…)"
className="min-w-56 flex-1 rounded-md border border-ink-300 px-3 py-2 text-sm outline-none focus:border-accent"
/>
<input
required
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="slug"
className="w-40 rounded-md border border-ink-300 px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</div>
<p className="mt-3 text-[11px] text-ink-400">
Le slug entre dans l'URL de livraison :{' '}
<span className="font-mono">/delivery/v1/&#123;projet&#125;/{slug || 'slug'}/</span>{' '}
il n'est plus modifiable ensuite.
</p>
{error && (
<p className="mt-3 max-h-24 overflow-y-auto rounded bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
</p>
)}
<div className="mt-4 flex items-center gap-3">
<button
type="submit"
disabled={create.isPending}
className="rounded-md bg-accent px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-ink-300"
>
{create.isPending ? 'Création…' : 'Créer l\'environnement'}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="text-sm text-ink-500 hover:text-ink-800"
>
Annuler
</button>
</div>
</form>
);
}
function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}

View file

@ -0,0 +1,230 @@
import { useQueries, useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api } from '@/api/client';
import type { LocaleStats } from '@/api/types';
import { useAuth } from '@/auth/AuthProvider';
import { ProgressBar } from '@/components/Status';
/**
* Point d'entrée : les projets, et en est chaque langue.
*
* Ce n'est délibérément PAS un tableau de bord. Un tableau de bord générique
* affiche des chiffres que personne n'a demandés ; cet écran répond à la seule
* question qu'on se pose en arrivant « reste-t-il du travail, et pour quelle
* langue ? » puis s'efface.
*/
export function ProjectsPage() {
const { user, logout } = useAuth();
const projects = useQuery({ queryKey: ['projects'], queryFn: api.projects });
const stats = useQueries({
queries: (projects.data ?? []).map((project) => ({
queryKey: ['stats', project.uuid],
queryFn: () => api.stats(project.uuid),
})),
});
return (
<div className="mx-auto max-w-5xl px-6 py-10">
<header className="mb-10 flex items-baseline justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight text-ink-900">Projets</h1>
<p className="mt-0.5 text-sm text-ink-500">
{user?.name}
{user?.isExternal && (
<span className="ml-2 rounded bg-ink-200 px-1.5 py-0.5 text-[11px] font-medium text-ink-600">
prestataire externe
</span>
)}
</p>
</div>
<div className="flex items-center gap-4">
{/* Absente pour tous les autres, pas grisée : un menu plein
d'options inaccessibles apprend à l'utilisateur que
l'interface ne le concerne pas. */}
{user?.isSuperAdmin && (
<Link
to="/admin"
className="text-sm text-ink-500 underline-offset-4 hover:text-ink-800 hover:underline"
>
Administration
</Link>
)}
<button
onClick={() => void logout()}
className="text-sm text-ink-500 underline-offset-4 hover:text-ink-800 hover:underline"
>
Se déconnecter
</button>
</div>
</header>
{projects.isLoading && <p className="text-sm text-ink-400">Chargement</p>}
{projects.data?.length === 0 && (
<div className="rounded-lg border border-dashed border-ink-300 p-10 text-center">
<p className="text-sm text-ink-600">Aucun projet ne vous est accessible.</p>
<p className="mt-1 text-xs text-ink-400">
L'accès à un projet s'obtient auprès de son administrateur.
</p>
</div>
)}
<div className="space-y-4">
{projects.data?.map((project, index) => {
const projectStats = stats[index]?.data;
const targets = (projectStats?.locales ?? []).filter((l) => !l.isSource);
return (
<article
key={project.uuid}
className="rounded-lg border border-ink-200 bg-white p-5 shadow-sm"
>
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<h2 className="font-semibold text-ink-900">{project.name}</h2>
{project.description !== null && (
<p className="mt-1 text-sm text-ink-500">
{project.description}
</p>
)}
<p className="mt-2 flex flex-wrap gap-1.5">
{project.platforms.map((platform) => (
<span
key={platform.uuid}
className="rounded bg-ink-100 px-1.5 py-0.5 font-mono text-[11px] text-ink-600"
title={`Format ${platform.messageFormat}`}
>
{platform.slug}
</span>
))}
</p>
</div>
<span className="shrink-0 rounded bg-ink-100 px-2 py-1 font-mono text-[11px] text-ink-500">
source&nbsp;: {project.sourceLocale.code}
</span>
</div>
{/* Les langues de l'utilisateur d'abord. Présenter les
sept langues d'un projet à une traductrice habilitée
sur deux, c'est lui faire trier son propre travail. */}
{(() => {
const mine = targets.filter((l) => l.canWrite);
const others = targets.filter((l) => !l.canWrite);
const split = mine.length > 0 && others.length > 0;
return (
<>
{split && (
<p className="mt-5 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Vos langues
</p>
)}
<div
className={`grid gap-x-8 gap-y-3 sm:grid-cols-2 ${split ? 'mt-2' : 'mt-5'}`}
>
{(split ? mine : targets).map((locale) => (
<LocaleRow
key={locale.code}
locale={locale}
projectUuid={project.uuid}
/>
))}
</div>
{split && (
<>
<p className="mt-6 text-[11px] font-semibold uppercase tracking-wide text-ink-400">
Autres langues du projet · consultation
</p>
<div className="mt-2 grid gap-x-8 gap-y-3 opacity-60 sm:grid-cols-2">
{others.map((locale) => (
<LocaleRow
key={locale.code}
locale={locale}
projectUuid={project.uuid}
/>
))}
</div>
</>
)}
</>
);
})()}
{targets.length === 0 && stats[index]?.isSuccess && (
<p className="mt-4 text-xs text-ink-400">
Aucune langue cible activée sur ce projet.
</p>
)}
</article>
);
})}
</div>
</div>
);
}
/**
* Une langue, son avancement, et le raccourci vers le travail restant.
*
* Le lien mène directement à la grille filtrée sur ce qui reste à faire. C'est
* la différence entre « voici l'état des lieux » et « voici votre travail » :
* un clic de moins, mais surtout aucune décision à prendre en arrivant.
*/
function LocaleRow({ locale, projectUuid }: { locale: LocaleStats; projectUuid: string }) {
const actionable = locale.untranslated + locale.draft + locale.needsReview;
const readOnly = !locale.canWrite;
return (
<Link
to={`/p/${projectUuid}/editor?locale=${locale.code}${actionable > 0 ? '&status=untranslated' : ''}`}
className="group block rounded-md px-2 py-1.5 -mx-2 transition-colors hover:bg-ink-50"
>
<div className="flex items-baseline justify-between gap-3">
<span className="truncate text-sm font-medium text-ink-800">
{locale.nativeName}
{locale.direction === 'rtl' && (
<span className="ml-1.5 text-[10px] font-normal text-ink-400">RTL</span>
)}
</span>
<span className="tabular shrink-0 text-sm text-ink-500">{locale.completion}%</span>
</div>
<div className="mt-1.5">
<ProgressBar
reviewed={locale.reviewed}
translated={locale.translated}
needsReview={locale.needsReview}
draft={locale.draft}
total={locale.total}
/>
</div>
<div className="mt-1.5 flex items-center gap-3 text-[11px] text-ink-400">
{readOnly ? (
<span className="text-ink-400">consultation</span>
) : actionable > 0 ? (
<span className="tabular font-medium text-ink-600">
{actionable} à traiter
</span>
) : (
<span className="text-emerald-600">Rien à faire</span>
)}
{/* Les traductions obsolètes sont sorties du lot : c'est le seul
chiffre qui signale un texte potentiellement faux en production. */}
{locale.needsReview > 0 && (
<span className="tabular font-medium text-red-600">
{locale.needsReview} obsolète{locale.needsReview > 1 ? 's' : ''}
</span>
)}
</div>
</Link>
);
}

256
bin/build-tqs-phar.php Normal file
View file

@ -0,0 +1,256 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/*
* Construit `tqs.phar` un fichier unique, distribuable tel quel.
*
* Écrit à la main plutôt qu'avec Box : le besoin tient en cinquante lignes
* (empaqueter src/Cli et les quelques dépendances qu'il utilise), et ajouter un
* outil de build à maintenir pour cela serait disproportionné.
*
* Le PHAR n'embarque QUE ce dont le CLI a besoin : Console, HttpClient et leurs
* dépendances. Ni Doctrine, ni API Platform, ni le kernel le CLI ne parle
* qu'HTTP, et l'embarquer entier multiplierait sa taille par dix pour rien.
*
* Usage :
* php -d phar.readonly=0 bin/build-tqs-phar.php
*/
$root = \dirname(__DIR__);
$target = $root.'/build/tqs.phar';
if (\ini_get('phar.readonly')) {
fwrite(\STDERR, "phar.readonly est actif.\n\nRelancez :\n php -d phar.readonly=0 bin/build-tqs-phar.php\n");
exit(1);
}
@mkdir($root.'/build', 0o775, true);
@unlink($target);
$phar = new Phar($target, 0, 'tqs.phar');
$phar->startBuffering();
// Dépendances du CLI, calculées depuis le graphe réel de Composer.
//
// Une liste écrite à la main est fatalement incomplète : l'autoloader charge
// d'emblée des polyfills que personne ne pense à citer, et le PHAR échoue à
// l'exécution — après le build, donc chez l'utilisateur. Partir de
// `installed.json` rend l'ensemble correct par construction.
$roots = ['symfony/console', 'symfony/http-client'];
$installed = json_decode((string) file_get_contents($root.'/vendor/composer/installed.json'), true);
if (!\is_array($installed) || !isset($installed['packages'])) {
fwrite(\STDERR, "vendor/composer/installed.json illisible. Lancez « composer install ».\n");
exit(1);
}
$byName = [];
foreach ($installed['packages'] as $package) {
$byName[$package['name']] = $package;
}
/** Fermeture transitive des dépendances, hors extensions et PHP lui-même. */
$collect = static function (string $name, array &$seen) use (&$collect, $byName): void {
if (isset($seen[$name]) || !isset($byName[$name])) {
return;
}
$seen[$name] = true;
foreach (array_keys($byName[$name]['require'] ?? []) as $dependency) {
if (!str_contains($dependency, '/')) {
continue; // php, ext-*, lib-*
}
$collect($dependency, $seen);
}
};
$seen = [];
foreach ($roots as $rootPackage) {
$collect($rootPackage, $seen);
}
// L'autoloader requiert d'office tous les fichiers déclarés en `autoload_files`.
// On y prend les polyfills — indispensables — mais SURTOUT PAS les dépendances
// de développement : PHPStan pèse à lui seul 47 Mo, et se retrouverait embarqué
// dans un binaire qui n'en a aucun usage.
//
// `dev-package-names` est maintenu par Composer : s'appuyer dessus vaut mieux
// que deviner ce qui relève du développement.
$devPackages = array_flip((array) ($installed['dev-package-names'] ?? []));
$eager = require $root.'/vendor/composer/autoload_files.php';
$eagerKept = [];
foreach ($eager as $hash => $file) {
$normalised = str_replace('\\', '/', (string) $file);
if (1 !== preg_match('#/vendor/([^/]+/[^/]+)/#', $normalised, $matches)) {
continue;
}
if (isset($devPackages[$matches[1]])) {
continue;
}
$seen[$matches[1]] = true;
$eagerKept[$hash] = $matches[0].substr($normalised, strpos($normalised, $matches[0]) + \strlen($matches[0]));
$eagerKept[$hash] = substr($normalised, strpos($normalised, '/vendor/') + \strlen('/vendor/'));
}
$vendors = array_keys($seen);
sort($vendors);
$added = 0;
/**
* Filtre d'inclusion.
*
* On garde TOUS les types de fichiers, pas seulement le PHP : Console lit ses
* gabarits de complétion shell depuis un répertoire Resources/, et les omettre
* produit un PHAR qui plante au lancement pas au build. Ne sont écartés que
* les tests et la documentation, qui ne servent jamais à l'exécution.
*/
$keep = static function (SplFileInfo $file): bool {
$path = str_replace('\\', '/', $file->getPathname());
foreach (['/Tests/', '/tests/', '/docs/', '/.github/'] as $excluded) {
if (str_contains($path, $excluded)) {
return false;
}
}
return !\in_array(strtolower($file->getExtension()), ['md', 'dist', 'lock'], true);
};
// Uniquement src/Cli : l'autoloader de Composer n'est PAS embarqué. Il
// référence des paquets écartés du binaire, et sa seule présence suffirait à
// faire échouer le démarrage si quelque chose venait à le requérir.
foreach (['src/Cli'] as $relative) {
$base = $root.'/'.$relative;
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
foreach ($files as $file) {
if (!$file instanceof SplFileInfo || !$keep($file)) {
continue;
}
$phar->addFile($file->getPathname(), $relative.'/'.substr($file->getPathname(), \strlen($base) + 1));
++$added;
}
}
foreach ($vendors as $vendor) {
$base = $root.'/vendor/'.$vendor;
if (!is_dir($base)) {
// Un paquet « replace » (les polyfills remplacés par PHP lui-même) n'a
// pas de répertoire : c'est normal, on passe.
continue;
}
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
foreach ($files as $file) {
if (!$file instanceof SplFileInfo || !$keep($file)) {
continue;
}
$phar->addFile($file->getPathname(), 'vendor/'.$vendor.'/'.substr($file->getPathname(), \strlen($base) + 1));
++$added;
}
}
$phar->addFile($root.'/bin/tqs', 'bin/tqs');
++$added;
// Autoloader DÉDIÉ, généré ici.
//
// Réutiliser celui de l'application ne marche pas : Composer inline la liste des
// fichiers à charger dans `autoload_static.php`, si bien que réécrire
// `autoload_files.php` ne change rien — le PHAR tente alors de requérir des
// paquets de développement absents, et meurt au démarrage.
//
// Trente lignes générées valent mieux qu'un rafistolage du générateur d'un
// autre outil : ce qui est embarqué est exactement ce qui est déclaré.
$psr4 = ['App\\Cli\\' => 'src/Cli/'];
foreach ($vendors as $vendor) {
$package = $byName[$vendor] ?? null;
foreach (($package['autoload']['psr-4'] ?? []) as $namespace => $paths) {
foreach ((array) $paths as $path) {
$psr4[$namespace] = 'vendor/'.$vendor.('' === $path ? '/' : '/'.trim($path, '/').'/');
}
}
}
$eagerFiles = [];
foreach ($vendors as $vendor) {
foreach (($byName[$vendor]['autoload']['files'] ?? []) as $file) {
$eagerFiles[] = 'vendor/'.$vendor.'/'.ltrim($file, '/');
}
}
$phar->addFromString('autoload.php', sprintf(
<<<'PHP'
<?php
// Généré par bin/build-tqs-phar.php — ne pas modifier à la main.
$base = 'phar://tqs.phar/';
$psr4 = %s;
spl_autoload_register(static function (string $class) use ($base, $psr4): void {
foreach ($psr4 as $prefix => $directory) {
if (!str_starts_with($class, $prefix)) {
continue;
}
$relative = str_replace('\\', '/', substr($class, strlen($prefix)));
$file = $base . $directory . $relative . '.php';
if (is_file($file)) {
require $file;
return;
}
}
});
$files = %s;
foreach ($files as $file) {
require $base . $file;
}
PHP,
var_export($psr4, true),
var_export($eagerFiles, true),
));
$phar->setStub(<<<'STUB'
#!/usr/bin/env php
<?php
Phar::mapPhar('tqs.phar');
require 'phar://tqs.phar/autoload.php';
require 'phar://tqs.phar/bin/tqs';
__HALT_COMPILER();
STUB);
$phar->stopBuffering();
chmod($target, 0o755);
printf(
"tqs.phar construit : %s\n %d fichiers · %d dépendances · %s\n",
$target,
$added,
\count($vendors),
number_format(filesize($target) / 1024, 0, ',', ' ').' Ko',
);

21
bin/console Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

4
bin/phpunit Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

71
bin/tqs Executable file
View file

@ -0,0 +1,71 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use App\Cli\CliException;
use App\Cli\Command\InitCommand;
use App\Cli\Command\PullCommand;
use App\Cli\Command\PushCommand;
use App\Cli\Command\StatusCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
/*
* Point d'entrée du CLI.
*
* Volontairement INDÉPENDANT du kernel Symfony : ni conteneur, ni Doctrine, ni
* base de données. Ce binaire tourne sur la machine d'un développeur d'une
* application cliente, pas sur le serveur — il n'a rien à savoir de
* l'infrastructure de TQ-Slator, seulement parler HTTP.
*
* Bénéfice concret : démarrage en quelques millisecondes, et aucune raison
* d'échouer parce qu'une variable d'environnement du serveur manque.
*/
// Dans le PHAR, le stub a déjà chargé l'autoloader dédié — le test porte sur le
// contexte d'exécution, pas sur une classe : à ce stade rien n'est encore
// chargé, seul un autoloader est enregistré.
if ('' === Phar::running(false)) {
foreach ([__DIR__.'/../vendor/autoload.php', __DIR__.'/../../../autoload.php'] as $autoload) {
if (is_file($autoload)) {
require $autoload;
break;
}
}
}
$application = new Application('tqs', '1.0.0');
$application->setCatchExceptions(false);
$application->addCommands([
new InitCommand(),
new PushCommand(),
new PullCommand(),
new StatusCommand(),
]);
$output = new ConsoleOutput();
try {
exit($application->run(null, $output));
} catch (CliException $exception) {
// Erreur attendue : le message a été rédigé pour être lu. Pas de trace —
// une pile d'appels devant « exportez TQS_API_KEY » n'aide personne.
$output->getErrorOutput()->writeln(['', '<error> Erreur </error>', '', $exception->getMessage(), '']);
exit(1);
} catch (Throwable $exception) {
$errors = $output->getErrorOutput();
$errors->writeln(['', '<error> Erreur inattendue </error>', '', $exception->getMessage(), '']);
if ($errors->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
$errors->writeln($exception->getTraceAsString());
} else {
$errors->writeln('<comment>Relancez avec -v pour la trace complète.</comment>');
$errors->writeln('');
}
exit(1);
}

18
compose.override.yaml Normal file
View file

@ -0,0 +1,18 @@
services:
###> symfony/mailer ###
mailer:
image: axllent/mailpit
ports:
- "1025"
- "8025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ###
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###

119
compose.yaml Normal file
View file

@ -0,0 +1,119 @@
services:
php:
build:
context: .
target: app_dev
depends_on:
database:
condition: service_healthy
cache:
condition: service_started
environment:
SERVER_NAME: ':80'
# APP_ENV n'est PAS répété ici : le Dockerfile le positionne déjà, et le
# redéclarer dans Compose en ferait une variable que PHPUnit ne peut pas
# surcharger — `bin/phpunit` tournerait alors en environnement dev.
#
# Ce service est le seul à appliquer les migrations (voir entrypoint.dev.sh).
RUN_MIGRATIONS: '1'
DATABASE_URL: 'mysql://tqslator:tqslator@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4'
REDIS_URL: 'redis://cache:6379'
MAILER_DSN: 'smtp://mailer:1025'
BACK_OFFICE_URL: 'http://localhost:8080'
# Caddy tente sinon d'obtenir un certificat pour un domaine public.
CADDY_GLOBAL_OPTIONS: 'auto_https off'
ports:
- '8080:80'
volumes:
- .:/app
- app_storage:/app/var/storage
restart: unless-stopped
# Consommateur Messenger : statistiques de complétion, construction des bundles,
# envoi des invitations. Séparé du serveur web pour que la publication d'une
# release ne bloque jamais une requête HTTP.
worker:
build:
context: .
target: app_dev
depends_on:
database:
condition: service_healthy
environment:
DATABASE_URL: 'mysql://tqslator:tqslator@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4'
REDIS_URL: 'redis://cache:6379'
MAILER_DSN: 'smtp://mailer:1025'
BACK_OFFICE_URL: 'http://localhost:8080'
volumes:
- .:/app
- app_storage:/app/var/storage
command: ['php', 'bin/console', 'messenger:consume', 'async', '--time-limit=3600', '-vv']
# Le healthcheck hérité de l'image de base interroge l'endpoint admin de
# Caddy, que ce service ne fait pas tourner : il échouerait toujours.
#
# On ne le remplace pas. Le consommateur est PID 1 du conteneur : s'il meurt,
# le conteneur sort et `restart` le relance. Une sonde ne ferait que dupliquer
# ce que Docker sait déjà, au prix d'un paquet supplémentaire dans l'image
# (pgrep n'y est pas). Aucun service ne dépend de la santé du worker.
healthcheck:
disable: true
restart: unless-stopped
database:
image: mariadb:11.4
environment:
MARIADB_DATABASE: tqslator
MARIADB_USER: tqslator
MARIADB_PASSWORD: tqslator
MARIADB_ROOT_PASSWORD: root
command:
# Voir docs/01-architecture-proposal.md §3.6.
# La collation serveur doit correspondre à default_table_options de
# doctrine.yaml, sinon les tables créées hors migration divergent en silence.
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_uca1400_ai_ci
# Nécessaire pour les index UNIQUE larges (row format DYNAMIC).
- --innodb-default-row-format=dynamic
# Journalise les requêtes de plus de 200 ms sans index : c'est le filet qui
# attrapera la requête de grille avant qu'elle ne parte en production.
- --slow-query-log=1
- --long-query-time=0.2
- --log-queries-not-using-indexes=1
healthcheck:
test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
interval: 5s
timeout: 5s
retries: 20
start_period: 30s
ports:
- '3306:3306'
volumes:
- db_data:/var/lib/mysql
# Exécuté une seule fois, au premier démarrage sur un volume vierge.
- ./docker/mariadb/init.sql:/docker-entrypoint-initdb.d/10-test-database.sql:ro
restart: unless-stopped
cache:
image: redis:7-alpine
command: ['redis-server', '--save', '', '--appendonly', 'no']
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
# Capture tous les e-mails sortants (invitations, notifications).
# Interface sur http://localhost:8025
mailer:
image: axllent/mailpit:latest
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
ports:
- '8025:8025'
restart: unless-stopped
volumes:
db_data:
app_storage:

106
composer.json Normal file
View file

@ -0,0 +1,106 @@
{
"name": "tranquilys/tq-slator",
"description": "TQ-Slator — Translation Management System headless",
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.3",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"api-platform/doctrine-orm": "^4.3",
"api-platform/symfony": "^4.3",
"doctrine/doctrine-bundle": "^3.3",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"nelmio/cors-bundle": "^2.6",
"runtime/frankenphp-symfony": "^1.0",
"symfony/console": "7.4.*",
"symfony/doctrine-messenger": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/messenger": "7.4.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/rate-limiter": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/yaml": "7.4.*"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true,
"phpstan/extension-installer": true
},
"platform": {
"php": "8.4.0"
},
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-intl-grapheme": "*",
"symfony/polyfill-intl-normalizer": "*",
"symfony/polyfill-mbstring": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
}
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.95",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.2",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^12.5",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/maker-bundle": "^1.67"
}
}

11941
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

14
config/bundles.php Normal file
View file

@ -0,0 +1,14 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
];

View file

@ -0,0 +1,64 @@
api_platform:
title: 'TQ-Slator — Management API'
version: '1.0.0'
description: |
API de gestion des traductions.
Deux APIs cohabitent et ne doivent pas être confondues :
- **Management API** (`/api/v1`, cette documentation) — création de clés,
saisie de traductions, administration. Authentification par session
(back-office) ou par clé API scopée (CLI, intégrations).
- **Delivery API** (`/delivery/v1`) — lecture des bundles publiés par les
applications en production. Immuable, cachable, clé API en lecture seule.
Le stockage des valeurs est **toujours** en ICU MessageFormat canonique.
Les formats propres aux plateformes (i18next…) sont produits à la
publication, jamais stockés.
formats:
# JSON-LD par défaut : il apporte hydra:totalItems et hydra:view, dont la
# grille virtualisée du back-office a besoin pour dimensionner sa barre de
# défilement. Le JSON nu renvoie un tableau sans métadonnée de pagination.
jsonld: ['application/ld+json']
# Disponible via Accept: application/json — c'est ce que consomment le CLI
# et les intégrations simples, qui n'ont que faire d'Hydra.
json: ['application/json']
docs_formats:
jsonopenapi: ['application/vnd.openapi+json']
html: ['text/html']
defaults:
# Combiné au préfixe /api du routing d'API Platform, les ressources
# sortent sous /api/v1/... — versionné dès le premier jour, parce
# qu'ajouter une version à une API déjà consommée est un chantier.
route_prefix: /v1
# `false` est imposé par le choix d'authentification par session
# (cookie SameSite + CSRF) plutôt que par jeton porteur : Symfony refuse
# de lire la session sur une requête déclarée sans état.
#
# Conséquence : les réponses de la Management API ne sont pas cachables
# publiquement. Ce n'est pas un problème — c'est la Delivery API qui
# porte la charge de lecture, et elle est stateless de bout en bout.
stateless: false
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
pagination_client_items_per_page: true
pagination_maximum_items_per_page: 500
pagination_items_per_page: 50
swagger:
# Déclare le schéma d'authentification pour que le bouton « Authorize »
# de la page de documentation soit fonctionnel. Sans lui, les endpoints
# sont documentés mais impossibles à essayer depuis un navigateur, ce qui
# vide la doc interactive de son intérêt.
api_keys:
cleApi:
name: Authorization
type: header
exception_to_status:
App\Translation\Format\Exception\MessageParseException: 422
App\Translation\Format\Exception\UnsupportedMessageFeatureException: 422

View file

@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View file

@ -0,0 +1,63 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# Voir docs/01-architecture-proposal.md §3.6 « Pièges MariaDB ».
#
# utf8mb4_uca1400_ai_ci (MariaDB >= 10.10) donne une recherche naturelle et
# accent-insensible sur les VALEURS de traduction — ce que veut un traducteur
# qui cherche « eleve » et doit trouver « élève ».
#
# ATTENTION : cette collation est aussi case-insensible. La colonne
# translation_key.key_path la surcharge explicitement en utf8mb4_bin, sinon
# `Header.Login` et `header.login` seraient considérées comme une seule clé.
default_table_options:
charset: utf8mb4
collation: utf8mb4_uca1400_ai_ci
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
# Isolation multi-tenant (décision 1). Le filtre est déclaré ici mais son
# activation est pilotée par TenantContextListener : actif par défaut sur
# toute requête authentifiée, désactivable explicitement pour les commandes
# de maintenance. L'inverse — désactivé par défaut — est la recette d'une
# fuite inter-organisation.
filters:
organization:
class: App\Doctrine\Filter\OrganizationFilter
enabled: false
when@test:
doctrine:
dbal:
# "TEST_TOKEN" est positionné par ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View file

@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View file

@ -0,0 +1,15 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View file

@ -0,0 +1,3 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'

View file

@ -0,0 +1,32 @@
framework:
messenger:
failure_transport: failed
transports:
# Transport Doctrine plutôt que Redis : la mise en file participe alors
# à la transaction métier. Publier une release et déclencher la
# construction de ses bundles ne peuvent pas diverger — soit les deux
# sont validés, soit aucun.
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 2000
multiplier: 3
failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
App\Message\RecomputeCompletionStats: async
App\Message\BuildReleaseBundles: async
App\Message\SendInvitationEmail: async
App\Message\TouchApiKey: async
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
when@test:
framework:
messenger:
transports:
async: 'in-memory://'
failed: 'in-memory://'

View file

@ -0,0 +1,55 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

View file

@ -0,0 +1,10 @@
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null

View file

@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View file

@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
default_uri: '%env(DEFAULT_URI)%'
when@prod:
framework:
router:
strict_requirements: null

View file

@ -0,0 +1,93 @@
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_users:
entity:
class: App\Entity\User
# Pas de `property: email` : UserRepository implémente
# UserLoaderInterface pour maîtriser la levée du filtre
# multi-organisation au chargement comme au rechargement.
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
# ─── Delivery API ────────────────────────────────────────────────────
# Stateless, clé API uniquement, aucun cookie. Firewall distinct de la
# Management API : les deux n'ont ni le même mode d'authentification, ni
# la même surface, ni la même sensibilité à la latence. Les fusionner
# ferait porter à chaque lecture de bundle le coût de la session.
delivery:
pattern: ^/delivery
stateless: true
provider: app_users
entry_point: App\Security\ApiKeyAuthenticator
custom_authenticators:
- App\Security\ApiKeyAuthenticator
# ─── Management API + back-office ────────────────────────────────────
# Session cookie SameSite=Lax plutôt que JWT en localStorage : le SPA est
# servi en same-origin, le cookie HttpOnly est donc immunisé contre
# l'exfiltration par XSS, et une révocation est immédiate.
main:
lazy: true
provider: app_users
entry_point: App\Security\ApiEntryPoint
# Deux modes d'authentification cohabitent ici, et c'est nécessaire :
# le back-office ouvre une session, le CLI présente une clé API.
# ApiKeyAuthenticator::supports() ne répond que si un en-tête
# d'autorisation est présent, les deux ne se marchent donc pas dessus.
custom_authenticators:
- App\Security\ApiKeyAuthenticator
json_login:
check_path: api_auth_login
username_path: email
password_path: password
success_handler: App\Security\AuthenticationHandler
failure_handler: App\Security\AuthenticationHandler
logout:
path: api_auth_logout
login_throttling:
max_attempts: 5
interval: '15 minutes'
access_control:
# Endpoints publics : connexion et acceptation d'invitation. Un invité
# n'a par définition pas encore de compte.
- { path: ^/api/v1/auth/login$, roles: PUBLIC_ACCESS }
# Consultation ET acceptation : celui qui clique sur le lien n'a par
# définition pas encore de compte, et il doit pouvoir voir à quoi il est
# invité avant de choisir un mot de passe.
- { path: ^/api/v1/invitations/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/health$, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# ROLE_API_KEY et non PUBLIC_ACCESS : sans en-tête d'autorisation, le
# point d'entrée du firewall renvoie 401. Aucun endpoint de delivery ne
# peut ainsi oublier d'exiger une clé.
- { path: ^/delivery, roles: ROLE_API_KEY }
# L'administration globale est hors de portée d'une clé API, quelles que
# soient ses permissions : une clé vit dans un dépôt et une chaîne
# d'intégration continue, elle n'a rien à faire près des comptes. Les
# attributs #[IsGranted] des contrôleurs disent déjà la même chose ; cette
# ligne est la deuxième serrure, celle qui tient si un contrôleur futur
# oublie la première.
- { path: ^/api/v1/admin/, roles: ROLE_SUPER_ADMIN }
# IS_AUTHENTICATED_FULLY et non ROLE_USER : la Management API accepte deux
# identités, un utilisateur du back-office et une clé API. Exiger un rôle
# propre à l'une exclurait l'autre. Le tri fin appartient aux Voters, qui
# savent raisonner sur les deux.
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test:
security:
password_hashers:
# Réduit le coût du hachage dans les tests. Ne jamais reprendre ces
# valeurs ailleurs.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4
time_cost: 3
memory_cost: 10

View file

@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View file

@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

5
config/preload.php Normal file
View file

@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

1879
config/reference.php Normal file

File diff suppressed because it is too large Load diff

11
config/routes.yaml Normal file
View file

@ -0,0 +1,11 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
# bin/console debug:router
controllers:
resource: routing.controllers

View file

@ -0,0 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View file

@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error

View file

@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

54
config/services.yaml Normal file
View file

@ -0,0 +1,54 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
parameters:
# Organisation unique en v1 (décision 1). Sert de rattachement par défaut aux
# traitements sans contexte utilisateur : commandes CLI, workers, et
# journalisation des échecs de connexion (où aucun utilisateur n'est résolu).
app.default_organization_slug: '%env(DEFAULT_ORGANIZATION_SLUG)%'
app.asset_storage_path: '%env(resolve:ASSET_STORAGE_PATH)%'
app.release_retention_unreferenced: '%env(int:RELEASE_RETENTION_UNREFERENCED)%'
# URL publique du back-office, utilisée dans les liens d'invitation. Elle ne
# peut pas être déduite d'une requête : les courriels partent depuis un
# worker, hors contexte HTTP.
app.back_office_url: '%env(BACK_OFFICE_URL)%'
app.invitation_ttl: '%env(INVITATION_TTL)%'
app.invitation_ttl_external: '%env(INVITATION_TTL_EXTERNAL)%'
services:
_defaults:
autowire: true
autoconfigure: true
bind:
# Les autres paramètres seront liés ici au fur et à mesure que les
# services qui les consomment apparaîtront : Symfony rejette une
# liaison inutilisée, ce qui évite les paramètres fantômes.
string $defaultOrganizationSlug: '%app.default_organization_slug%'
string $projectDir: '%kernel.project_dir%'
int $releaseRetentionUnreferenced: '%app.release_retention_unreferenced%'
string $invitationTtl: '%app.invitation_ttl%'
string $invitationTtlExternal: '%app.invitation_ttl_external%'
string $backOfficeUrl: '%app.back_office_url%'
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Enum/'
- '../src/Message/'
# Le CLI est un binaire autonome exécuté chez le client : il n'a rien
# à faire dans le conteneur de services de l'application.
- '../src/Cli/'
- '../src/Kernel.php'
when@test:
services:
# `_defaults` ne traverse pas les blocs `when@` : sans le rappeler ici,
# la redéfinition ci-dessous perdrait l'autowiring et le service serait
# construit sans arguments.
_defaults:
autowire: true
autoconfigure: true

35
docker/entrypoint.dev.sh Normal file
View file

@ -0,0 +1,35 @@
#!/bin/sh
set -e
# Le volume Compose monte les sources par-dessus l'image, ce qui masque le vendor/
# construit au build. On le régénère au démarrage — d'où cet entrypoint séparé
# plutôt qu'un `composer install` dans le Dockerfile de dev.
if [ ! -f vendor/autoload_runtime.php ]; then
echo '[entrypoint] vendor/ absent — composer install…'
composer install --prefer-dist --no-progress --no-interaction
fi
mkdir -p var/cache var/log var/storage
# Attend que MariaDB accepte réellement des requêtes. Le healthcheck Compose
# couvre la disponibilité du serveur, pas celle du schéma applicatif.
until php bin/console dbal:run-sql 'SELECT 1' >/dev/null 2>&1; do
echo '[entrypoint] attente de MariaDB…'
sleep 2
done
# Un seul service migre. Sans ce garde-fou, `php` et `worker` démarrent en
# parallèle et se disputent la table de verrouillage des migrations.
if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
echo '[entrypoint] migrations…'
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
else
# Les workers attendent que le schéma soit à jour avant de consommer.
until php bin/console doctrine:migrations:up-to-date >/dev/null 2>&1; do
echo '[entrypoint] attente des migrations…'
sleep 2
done
fi
exec "$@"

11
docker/mariadb/init.sql Normal file
View file

@ -0,0 +1,11 @@
-- Base dédiée aux tests, créée au premier démarrage du conteneur.
--
-- Doctrine y ajoute le suffixe `_test` (voir doctrine.yaml, bloc when@test).
-- La créer ici plutôt que dans un script de test évite d'accorder au compte
-- applicatif le droit CREATE DATABASE, qu'il n'a aucune raison de posséder.
CREATE DATABASE IF NOT EXISTS `tqslator_test`
CHARACTER SET utf8mb4
COLLATE utf8mb4_uca1400_ai_ci;
GRANT ALL PRIVILEGES ON `tqslator_test`.* TO 'tqslator'@'%';
FLUSH PRIVILEGES;

View file

@ -0,0 +1,709 @@
# TQ-Slator — Architecture (v0.2)
> Statut : **décisions structurantes actées**. Reste à arbitrer : voir §7.
---
## 0. Le positionnement, avant la technique
Un TMS n'est pas un CRUD sur une table `translations`. Trois vérités métier structurent toute l'architecture :
1. **Le problème n°1 d'un traducteur n'est pas de saisir du texte, c'est de savoir *quoi* traduire et *dans quel contexte*.** Une clé `common.submit` sans description, sans capture d'écran, sans longueur max, produit une mauvaise traduction. L'outil doit rendre le contexte obligatoire côté développeur.
2. **Le problème n°1 d'un développeur, c'est la synchronisation.** Le code évolue, les clés apparaissent et disparaissent. Si le push depuis le CI n'est pas trivial et non destructif, l'outil sera contourné en trois semaines.
3. **Le problème n°1 d'un manager, c'est la dérive silencieuse.** Une clé source modifiée après traduction = 11 traductions devenues fausses, sans aucun signal. C'est le bug le plus coûteux d'un TMS, et celui que les outils génériques ne traitent pas.
Chaque décision ci-dessous découle de ces trois points.
---
## 1. Décisions actées (ADR condensés)
| # | Décision | Conséquence principale |
|---|---|---|
| **1** | **Interne mono-org, schéma SaaS-ready** | `organization_id` sur toutes les racines + Doctrine filter global dès la 1<sup>re</sup> migration. Une seule org en base. Pas d'onboarding self-serve, pas de facturation, pas de quotas. Ouverture externe = chantier d'écrans, jamais de migration. |
| **2** | **`project` + `platform` enfants** | Les clés appartiennent au projet ; une table pivot `translation_key_platform` marque leur appartenance (1..N). Le `sync` et les bundles deviennent scopés plateforme. |
| **3** | **Symfony 7.4 LTS + API Platform 4** | OpenAPI généré depuis les attributs PHP, REST + GraphQL depuis la même définition, RBAC en Voters testables unitairement. |
| **4** | **Releases versionnées** | Publication explicite → bundle figé + checksum. Aucun brouillon ne peut atteindre la production. Rollback instantané, cache immuable, diff entre versions. |
| **5** | **Valeur de traduction partagée entre plateformes** | `UNIQUE (translation_key_id, locale_id)`. Pas de surcharge par plateforme. Un libellé qui doit différer devient une clé distincte. |
| **6** | **Multi-format, canonique ICU MessageFormat** | `platform.message_format` pilote la sérialisation à la construction du bundle. La colonne `plural_forms` disparaît : le pluriel vit dans la valeur ICU. |
| **7** | **RBAC = projet × langue** | `project_member` × `member_locale`. La plateforme n'est pas un axe de permission. |
| **8** | **Consommation build-time (CLI + commit)** | Aucune dépendance runtime des apps clientes envers TQ-Slator. Delivery API à trafic faible, pas de CDN ni de SLA critique en v1. |
---
## 2. Architecture technique
### 2.1 Deux APIs, pas une
Le besoin « API-First » recouvre deux contraintes opposées qu'on ne peut pas servir avec le même code.
| | **Management API** | **Delivery API** |
|---|---|---|
| Consommateurs | Back-office, CLI `tqs` | Applications clientes (via CI) |
| Forme | Ressources REST/GraphQL normalisées | Bundle JSON figé par (release, plateforme, langue) |
| Latence cible | < 300 ms | < 20 ms |
| Auth | Session cookie + CSRF | Clé API scopée environnement |
| Données | Tout, brouillons compris | **Uniquement le contenu publié** |
| Évolution | Versionnée, peut casser | Immuable |
Les fusionner donne soit une API de management lente, soit une API de delivery qui fuit les brouillons en production. Firewalls, contrôleurs et stratégies de cache distincts dès le jour 1.
```
tqslator.internal/admin/* → SPA back-office (React)
tqslator.internal/api/v1/* → Management API (API Platform, OpenAPI auto)
tqslator.internal/delivery/v1/ → Delivery API (contrôleur dédié, ETag, immuable)
```
> Décision 8 : la Delivery API n'a pas besoin de CDN en v1, puisque les apps la consomment depuis leur CI et commitent les fichiers. Le contrat (`ETag`, `Cache-Control`, URL versionnée) est néanmoins respecté dès maintenant — passer un jour en OTA sera une décision d'exploitation, pas une réécriture.
### 2.2 Stack
| Couche | Choix | Justification |
|---|---|---|
| Runtime | PHP 8.5 + **FrankenPHP** (worker mode) | Kernel en mémoire : 35× sur le chemin de delivery. Binaire unique, HTTP/3 |
| Framework | Symfony 7.4 LTS | Support ~2030. API Platform 4 y est mature |
| API | API Platform 4 | OpenAPI/Swagger auto, REST + GraphQL, filtres, pagination |
| ORM | Doctrine ORM 3 + Migrations | Agnosticité base, migrations versionnées, Doctrine filter pour le tenant |
| BDD | MariaDB 11.4 LTS | Contrainte projet. Pièges spécifiques : §3.6 |
| Cache / Queue | Redis 7 | Cache des bundles, rate limiting, verrous d'édition concurrente |
| Async | Symfony Messenger | Stats de complétion, webhooks, imports volumineux, construction des bundles |
| Auth humain | Session cookie `SameSite=Lax` + CSRF | Same-origin → immunisé à l'exfiltration XSS, révocation immédiate. Pas de JWT en localStorage |
| Auth machine | Clés API SHA-256, scopées, préfixées | `tqs_live_…` / `tqs_test_…`. Secret affiché une seule fois |
| Front admin | React 19 + Vite + TanStack Query/Table/Virtual | Grille virtualisée + édition inline + sauvegarde optimiste |
| Design system | Tailwind + Radix UI | Composants accessibles non stylés, clavier-first natif |
| Recherche | MariaDB FULLTEXT → Meilisearch si > 500k traductions | Ne pas sur-architecturer au jour 1 |
| Docs API | Scalar (rendu de l'OpenAPI généré) | Plus lisible et essayable que Swagger UI |
| CLI | `tqs` — PHP packagé (Box/PHAR) | §2.4 |
| Qualité | PHPStan 8, PHP-CS-Fixer, Rector, PHPUnit | Le palier utile sur du Doctrine : il couvre les nullables, source réelle de bugs ici. Le niveau 9 n'ajoute que la traque des `mixed`, majoritairement issus des colonnes JSON |
| Local | Docker Compose (FrankenPHP + MariaDB + Redis + Mailpit) | `docker compose up` doit suffire |
### 2.3 Back-office : SPA React, pas Twig
Le cœur du produit est une grille de plusieurs milliers de lignes, éditable inline, avec sauvegarde optimiste, navigation clavier intégrale et filtres instantanés. C'est un cas d'usage d'application, pas de site.
- Twig + Turbo/Stimulus : chaque frappe implique un aller-retour ; la virtualisation de 30 000 lignes et l'undo optimiste s'y écrivent mal.
- EasyAdmin / Filament : générateurs de CRUD. Ils produisent exactement « l'énième outil » qu'on veut éviter — la grille générique ne connaît ni les catégories CLDR, ni les placeholders ICU, ni le RTL.
- React : **le back-office devient le premier consommateur de la Management API.** Si elle est pénible pour nous, elle le sera pour les intégrateurs. Dogfooding structurel.
Coût assumé : une seconde stack, un build front, un contrat d'API à tenir.
### 2.4 Le CLI `tqs`
Un TMS s'adopte ou se contourne au niveau du terminal du développeur. Avec la décision 8, le CLI **est** le chemin de production, pas un accessoire.
```bash
tqs init # crée tqs.config.json, lie projet + plateforme
tqs push --dry-run # montre le diff AVANT d'écrire (défaut : jamais destructif)
tqs push --prune # retire explicitement les clés orphelines de CETTE plateforme
tqs pull --env production --locale fr,es --out ./src/locales
tqs status # 47 manquantes en ES, 12 sources modifiées
tqs lint # placeholders incohérents, doublons, dépassements de longueur
```
`tqs pull` en CI + commit = **aucune dépendance runtime des apps clientes envers TQ-Slator**. Si le TMS tombe, les productions clientes ne tombent pas. C'est une propriété d'architecture, pas une commodité.
### 2.5 Publication par releases
```
édition (draft, review) ──▶ [Publier v44] ──▶ bundles figés (checksum SHA-256)
production → v43 staging → v44
```
- Un brouillon ne peut pas fuiter en production : garantie structurelle, pas procédurale.
- Rollback = repointer l'environnement sur la release N-1. Instantané.
- `Cache-Control: public, max-age=31536000, immutable` sur une URL contenant le checksum.
- Le diff entre deux releases est une donnée de premier ordre.
- **La construction des bundles exécute les sérialiseurs de format** (§4) : une transformation impossible bloque la publication avec un message actionnable, au lieu de produire une chaîne corrompue en production.
Cardinalité : `plateformes × langues` bundles par release. 3 × 12 = 36. Avec une publication hebdomadaire, ~1 900 bundles/an/projet → politique de rétention : conserver les N dernières releases **plus** toutes celles référencées par un environnement.
---
## 3. Modèle de données
### 3.1 Vue d'ensemble
```mermaid
erDiagram
ORGANIZATION ||--o{ PROJECT : contient
ORGANIZATION ||--o{ USER : membre
PROJECT ||--o{ PLATFORM : declare
PROJECT ||--o{ ENVIRONMENT : possede
PROJECT ||--o{ PROJECT_LOCALE : active
PROJECT ||--o{ NAMESPACE : structure
PROJECT ||--o{ TRANSLATION_KEY : contient
PROJECT ||--o{ PROJECT_MEMBER : autorise
PROJECT ||--o{ GLOSSARY_TERM : definit
PLATFORM ||--o{ TRANSLATION_KEY_PLATFORM : regroupe
TRANSLATION_KEY ||--o{ TRANSLATION_KEY_PLATFORM : appartient
LOCALE ||--o{ PROJECT_LOCALE : referencee
LOCALE ||--o{ TRANSLATION : cible
ENVIRONMENT ||--o{ API_KEY : porte
ENVIRONMENT }o--|| RELEASE : pointe
RELEASE ||--o{ RELEASE_BUNDLE : contient
NAMESPACE ||--o{ TRANSLATION_KEY : regroupe
TRANSLATION_KEY ||--o{ TRANSLATION : valeurs
TRANSLATION_KEY ||--o{ KEY_SCREENSHOT : illustre
TRANSLATION_KEY ||--o{ COMMENT : discute
TRANSLATION ||--o{ TRANSLATION_VERSION : historise
USER ||--o{ PROJECT_MEMBER : role
PROJECT_MEMBER ||--o{ MEMBER_LOCALE : restreint
USER ||--o{ AUDIT_LOG : trace
```
### 3.2 Entités
#### `organization`
`id`, `uuid`, `name`, `slug`, `created_at`. **Une seule ligne en v1.** Toutes les entités racines portent `organization_id`, filtré automatiquement par un Doctrine filter activé au boot depuis le contexte de sécurité. Le coût aujourd'hui est d'une colonne et d'une classe ; le gain est de ne jamais avoir à auditer 200 requêtes le jour d'une ouverture externe.
#### `project`
| Colonne | Type | Note |
|---|---|---|
| `id` | BIGINT UNSIGNED AI | PK interne |
| `uuid` | BINARY(16) | Exposé en API — jamais l'id auto-incrémenté |
| `organization_id` | FK | |
| `name`, `slug` | VARCHAR(120) | `slug` unique par organisation |
| `source_locale_id` | FK locale | Langue de référence, commune à toutes les plateformes |
| `key_naming_pattern` | VARCHAR(255) NULL | Regex optionnelle imposée aux clés (`^[a-z0-9_.]+$`) — qualité par construction |
| `settings` | JSON | Longueur max par défaut, etc. |
#### `platform`
| Colonne | Type | Note |
|---|---|---|
| `project_id` | FK | `UNIQUE (project_id, slug)` |
| `name`, `slug` | VARCHAR(80) | « App Web », `web` |
| `kind` | VARCHAR(20) | `web` \| `ios` \| `android` \| `backend` \| `email` — pilote l'icône et les valeurs par défaut |
| `message_format` | VARCHAR(20) | `icu` \| `i18next` \| `symfony` \| `laravel` \| `gettext` \| `apple` \| `android`**pilote la sérialisation du bundle** (§4) |
| `export_layout` | VARCHAR(20) | `nested` \| `flat` — structure du JSON produit |
#### `translation_key_platform` (pivot)
```sql
PRIMARY KEY (platform_id, translation_key_id) -- filtre grille = range scan
INDEX (translation_key_id, platform_id) -- « à quelles plateformes appartient cette clé ? »
```
L'ordre de la PK est délibéré : filtrer 30 000 clés sur une plateforme devient un balayage de plage, pas un hash join. C'est ce qui neutralise le surcoût du modèle à pivot.
Règles associées :
- Une clé peut appartenir à **zéro** plateforme (créée dans l'UI, ou retirée partout) → bac « Non assignées » obligatoire dans l'éditeur, sinon la clé devient invisible et les compteurs mentent.
- `tqs push --prune` depuis le CI web supprime la ligne pivot `(web, clé)`. La clé n'est archivée que lorsqu'elle n'appartient plus à **aucune** plateforme. Sans cette règle, le CI web archive les clés iOS.
#### `environment`
`(project_id, slug)``development` / `staging` / `production`. Porte les clés API et **pointe sur `current_release_id`**. Sans cette entité, impossible d'avoir staging sur v44 et production sur v43.
#### `locale`
Référentiel global, pas par projet.
| Colonne | Note |
|---|---|
| `code` | BCP-47 : `fr-FR`, `pt-BR`, `ar` |
| `name`, `native_name` | « French (France) » / « Français (France) » |
| `direction` | `ltr` \| `rtl` — pilote la preview de l'éditeur |
| `plural_categories` | JSON CLDR : `["one","other"]` (fr), `["zero","one","two","few","many","other"]` (ar) |
| `fallback_locale_id` | `fr-CA``fr` → langue source |
#### `translation_key`
Le cœur. Chaque champ ici est un champ que le traducteur réclamera.
| Colonne | Type | Justification métier |
|---|---|---|
| `project_id`, `namespace_id` | FK | |
| `key_path` | VARCHAR(500) **`utf8mb4_bin`** | `header.navigation.login_button`. Collation binaire : §3.6 |
| `key_hash` | BINARY(20) | SHA-1 de `key_path``UNIQUE (project_id, key_hash)` |
| `description` | TEXT | **Le champ le plus rentable de la base.** Contexte pour le traducteur |
| `placeholders` | JSON | Dérivé de l'AST ICU de la source : `[{"name":"nom","type":"string","example":"Marie"}]` |
| `max_length` | SMALLINT NULL | Contrainte UI (bouton, colonne). Warning temps réel |
| `is_archived` | BOOL | **Jamais de DELETE.** Une clé retirée du code peut revenir |
| `last_seen_at` | DATETIME | Mis à jour à chaque `tqs push`. Détecte les orphelines |
| `created_by`, `created_at` | | |
> `is_plural` disparaît : avec un canonique ICU, le pluriel est une propriété de la valeur (nœud `plural` dans l'AST), pas de la clé. Une clé peut devenir plurielle sans migration — et sans solliciter un développeur.
> **Index de la table pivot.** La clé primaire de `translation_key_platform` est `(translation_key_id, platform_id)`, ce qui sert parfaitement le `MEMBER OF` de la grille. La migration ajoute **en plus** `idx_tkp_platform_key (platform_id, translation_key_id)`, dans l'ordre inverse, pour que l'optimiseur puisse attaquer par la plateforme quand celle-ci ne porte que quelques centaines de clés. Doctrine ne sait pas exprimer un index sur une table de jointement ManyToMany : il est déclaré à la main et un `migrations:diff` proposera de le supprimer. Ne pas accepter.
#### `translation`
| Colonne | Type | Note |
|---|---|---|
| `translation_key_id`, `locale_id` | FK | `UNIQUE (translation_key_id, locale_id)` — décision 5 |
| `value` | TEXT | **Toujours en ICU MessageFormat canonique.** NULL si non traduit |
| `status` | VARCHAR(20) | `untranslated`, `draft`, `translated`, `needs_review`, `reviewed` |
| `source_checksum` | BINARY(20) | **Le champ anti-dérive.** Hash de la valeur source **canonique** au moment de la traduction |
| `is_machine_translated` | BOOL | Traçabilité, filtrable |
| `updated_by_id`, `updated_at` | | |
| `version` | INT | Verrouillage optimiste (deux traducteurs sur la même clé) |
> **Les lignes existent pour toutes les langues activées, y compris non traduites.** Créer les `translation` par anticipation plutôt qu'à la première saisie est un arbitrage assumé : le filtre par statut devient uniforme, les statistiques se calculent par un simple `GROUP BY`, et le marquage `needs_review` tient en un seul `UPDATE`. Le coût est un volume de lignes vides — 30 000 clés × 12 langues = 360 000 lignes — que MariaDB absorbe sans difficulté. L'alternative paresseuse imposerait un `LEFT JOIN` avec traitement des `NULL` sur chaque requête de grille, et un total calculé par soustraction.
> **`source_checksum` traite le problème métier n°3.** À chaque édition de la source, un `UPDATE … WHERE source_checksum != <nouveau hash>` bascule toutes les cibles concernées en `needs_review`. Le manager voit « 34 traductions obsolètes ». Le hash porte sur la forme **canonique** : sinon web qui pousse `{{nom}}` et iOS qui pousse `{nom}` déclencheraient une bascule à chaque push croisé.
#### `translation_version`
Append-only : `translation_id`, `value`, `status`, `author_id`, `created_at`, `change_source` (`ui` | `api` | `cli` | `import` | `mt`). Audit, rollback unitaire, attribution du travail. Partitionnable par mois si le volume l'exige.
#### `release` + `release_bundle`
- `release` : `project_id`, `version`, `created_by`, `published_at`, `notes`, `key_count`
- `release_bundle` : `release_id`, **`platform_id`**, `locale_id`, `checksum` (SHA-256), `payload` (LONGTEXT ou fichier), `size_bytes`, `format` (copie de `platform.message_format` à l'instant T)
La Delivery API ne lit **que** `release_bundle` : une requête sur un index unique, aucun JOIN sur le pivot. Le coût du modèle à plateformes est entièrement absorbé à la publication.
#### `completion_stat`
`project_id`, `platform_id` (NULL = toutes), `locale_id`, `total_keys`, `translated`, `reviewed`, `needs_review`, `computed_at`.
Les statistiques sont **bidimensionnelles** depuis la décision 2 : « ES à 94 % » ne veut plus rien dire si iOS est à 40 %. Rafraîchies en asynchrone via Messenger — surtout pas un `COUNT(*)` à chaque affichage de l'éditeur.
#### `api_key`
| Colonne | Note |
|---|---|
| `environment_id` | Une clé appartient à un environnement, jamais à un projet globalement |
| `key_prefix` | VARCHAR(16), affiché dans l'UI : `tqs_live_a4f2…` |
| `key_hash` | BINARY(32) SHA-256. **Le secret n'est montré qu'une fois** |
| `scopes` | JSON : `["translations:read"]`, `["keys:write","translations:read"]` |
| `last_used_at`, `expires_at`, `revoked_at` | Clés dormantes, rotation |
#### `glossary_term`
`project_id`, `term`, `locale_id`, `translation`, `is_forbidden`, `notes`.
« *checkout* se traduit toujours par *commande*, jamais *caisse*. » Faible coût, forte valeur perçue par les traducteurs professionnels — différenciateur réel.
### 3.3 Autorisation (décision 7)
```
user ──< project_member (project_id, role) < member_locale (locale_id)
```
| Rôle | Portée |
|---|---|
| `owner` | Tout sur le projet, y compris suppression |
| `admin` | Membres, langues, plateformes, réglages, publication |
| `developer` | Clés (CRUD), plateformes, clés API, releases, lecture des traductions. **Pas d'écriture sur les traductions** |
| `translator` | Écriture des traductions **sur ses langues assignées uniquement**. Lecture seule sur les clés |
| `reviewer` | Comme translator + transition `translated``reviewed` |
| `viewer` | Lecture seule |
La plateforme n'est pas un axe de permission : un traducteur habilité sur (Tranquilys, ES) voit les clés ES de toutes les plateformes. Cohérent avec le fait que les clés sont majoritairement partagées.
Implémentation : `ProjectVoter`, `TranslationVoter`, `ReleaseVoter`. Chaque décision d'accès est une méthode testée unitairement, pas une condition dispersée dans les contrôleurs. Rôles **fixes en v1** — un système de permissions dynamique multiplie la complexité par cinq pour un besoin non prouvé.
### 3.4 Volumétrie
| Scénario | Clés | Langues | Lignes `translation` |
|---|---|---|---|
| Projet typique | 5 000 | 8 | 40 000 |
| Gros projet | 30 000 | 15 | 450 000 |
| 50 projets mixtes | — | — | ~6 M |
Le point de vigilance n'est pas le volume mais **la requête de la grille** : « clés du namespace X, plateforme web, valeur source + valeur ES, statut = manquant, triées, paginées ».
```sql
-- pivot : range scan grâce à l'ordre de la PK
INDEX idx_grid (translation_key_id, locale_id, status)
INDEX idx_key_lookup (project_id, namespace_id, key_path(191))
INDEX idx_stale (locale_id, status, updated_at)
```
### 3.5 Multi-tenant : le Doctrine filter
Un filter Doctrine activé au boot injecte `organization_id = :current` sur toutes les entités marquées. Deux garde-fous :
- **Test d'architecture** : toute entité racine sans `organization_id` fait échouer la CI.
- **Le filter est actif par défaut, désactivable explicitement** (commandes de maintenance, migrations). L'inverse — désactivé par défaut — est la recette d'une fuite inter-tenant.
### 3.6 Pièges MariaDB ⚠️
1. **Collation et casse des clés.** En `utf8mb4_unicode_ci`, `Header.Login` et `header.login` sont **égales** → l'index UNIQUE rejette une clé légitime, ou pire, deux clés distinctes fusionnent. Les clés i18n sont sensibles à la casse. → `key_path` en `utf8mb4_bin`, reste de la base en `utf8mb4_uca1400_ai_ci` (MariaDB ≥ 10.10) pour une recherche naturelle sur les valeurs.
2. **Limite d'index InnoDB : 3072 octets** (row format DYNAMIC). Un `UNIQUE` sur `VARCHAR(500)` utf8mb4 = 2000 octets : ça passe, mais l'index est énorme et lent. → `UNIQUE (project_id, key_hash BINARY(20))` = 28 octets, + index préfixe `key_path(191)` pour les `LIKE 'header.%'`.
3. **`JSON` n'est pas un type natif en MariaDB** — alias de `LONGTEXT` + contrainte `CHECK (json_valid(…))`. Aucun index direct. → colonne générée persistante + index si un filtre est nécessaire. Acceptable ici : `placeholders` et `scopes` ne sont lus qu'à l'unité.
4. **`ENUM` et migrations.** Ajouter une valeur verrouille la table sur certaines versions. → `VARCHAR(20)` + validation applicative (choix retenu pour `status`, `message_format`, `kind`).
Doctrine gère tout ça, à condition de le déclarer explicitement — ce n'est pas le comportement par défaut.
---
## 4. Moteur de format (décision 6)
### 4.1 Canonique ICU
Une clé a une seule valeur partagée (décision 5), servie à des plateformes qui l'écrivent différemment (décision 6). Le stockage canonique est donc **obligatoire**, pas optionnel.
ICU MessageFormat est retenu parce qu'il est le seul sur-ensemble des trois familles : il porte pluriels, ordinaux, genre et sélections *dans* la chaîne. i18next et sprintf en sont des projections appauvries.
```
value canonique (ICU) ──┬─▶ platform web : i18next {{var}} + clés _one/_other
├─▶ platform ios : ICU natif (identité)
└─▶ platform backend : Symfony %name%
```
### 4.2 Trois règles non négociables
1. **Le traducteur ne voit jamais de syntaxe ICU.** `{count, plural, one {# artículo} other {# artículos}}` est illisible pour un non-technicien. L'éditeur parse l'AST et affiche **un champ par catégorie CLDR de la langue cible**, puis recompose. Sans ça, on a construit un éditeur de code déguisé en TMS.
2. **`source_checksum` porte sur la forme canonique**, jamais sur la chaîne poussée (§3.2).
3. **Une transformation impossible bloque la publication.** Un `select` de genre ICU n'a pas d'équivalent i18next : la construction du bundle web échoue avec un message actionnable, plutôt que d'émettre une chaîne corrompue en production.
### 4.3 Deux pertes assumées, mesurées à l'implémentation
Le moteur est écrit et testé (91 tests). Deux transformations vers i18next sont
**lossy**, et il vaut mieux les nommer que les découvrir en intégration :
1. **Un pluriel au milieu d'une phrase est redistribué.** ICU écrit
« Vous avez {count, plural, one {# message} other {# messages}} non lus » ;
i18next fait porter la variation à la clé entière, donc le texte alentour est
recopié dans chaque branche (`…_one` = « Vous avez {{count}} message non lus »).
L'AST ne revient donc pas à sa forme d'origine. Le **rendu**, lui, est
identique — le test confronte les deux formes au moteur ICU de PHP pour
quatre valeurs de `count`.
2. **Le type d'un argument est perdu.** `{date, date, long}` devient
`{{date}}` : i18next délègue le formatage à ses propres `formatters`,
déclarés côté application. Le **nom** de la variable survit, ce qui préserve
le contrat avec le code appelant.
Une troisième subtilité a été tranchée par la langue cible : le suffixe `_zero`
d'i18next peut venir d'un sélecteur exact ICU `=0` ou de la catégorie CLDR
`zero`, qui existe réellement en arabe. Si `zero` figure dans les catégories de
la langue lue, c'en est une ; sinon, ce ne peut être qu'un `=0`.
### 4.4 Composants et coût
Chaque format demande un **parseur** (import) et un **sérialiseur** (export), avec un test aller-retour `parse(serialize(ast)) == ast` sur un corpus de fixtures.
Il n'existe pas de parseur AST ICU mature en PHP — `intl`/`MessageFormatter` sait formater, pas produire un arbre. On écrit le nôtre sur un sous-ensemble borné (`argument`, `plural`, `selectordinal`, `select`, escaping) : ~400 lignes, entièrement testable, avec `@formatjs/icu-messageformat-parser` comme oracle pour générer les fixtures.
**Validation dans l'éditeur** : une regex client-side donne le retour instantané sur la présence des placeholders (95 % des cas), le parseur PHP fait autorité au blur. Une seule implémentation normative — pas de dérive entre deux parseurs.
### 4.5 Séquençage
| Phase | Import (parse) | Export (serialize) |
|---|---|---|
| v1 ✅ | ICU, i18next | ICU, i18next |
| v1.1 | — | Symfony/Laravel, gettext `.po` |
| v2 | Symfony, gettext | Apple `.strings`, Android `.xml`, XLIFF 2.1 |
**sprintf `%s` reste export-only.** Des arguments positionnels non nommés produisent `{arg0}`, `{arg1}` en canonique — inexploitable pour un traducteur. Une plateforme backend qui doit *pousser* des clés utilise la syntaxe nommée (`%name%` Symfony, `:name` Laravel).
---
## 5. UX du back-office
### 5.1 Personas
| | **Marie — traductrice** | **Sam — développeur** | **Léa — resp. localisation** |
|---|---|---|---|
| Fréquence | Sessions de 2 h, intensives | 5 min, deux fois par semaine | Coup d'œil quotidien |
| Objectif | Vider une file de clés manquantes | Pousser des clés, récupérer les fichiers | Savoir si on peut livrer |
| Outil concurrent | Un fichier Excel | `git grep` | Une relance Slack |
| Ce qui la/le fait fuir | La souris. Le contexte manquant | Un formulaire web pour créer une clé | Un chiffre de complétion faux |
**Les trois voient des applications différentes.** Marie n'a pas d'accès aux clés API. Sam n'a pas de champ de saisie de traduction. Léa a des graphiques que les deux autres n'ont pas. Une interface unique avec des boutons grisés est un échec d'UX.
### 5.2 Arborescence
```
/ Sélecteur de projets — complétion par langue
└── /p/{projet}
├── /editor ★ L'ÉCRAN. Défaut pour translator & reviewer
├── /keys Défaut pour developer — table technique, import/export
├── /platforms Plateformes, format de message, layout d'export
├── /locales Langues, fallbacks, avancement
├── /releases Historique, diff, rollback
├── /integration Clés API, snippets, doc, CLI [developer+]
├── /members Membres, rôles, langues assignées [admin+]
├── /glossary Terminologie
└── /settings [admin+]
```
L'entrée dans un projet **ne mène pas à un dashboard**. On atterrit sur l'écran correspondant à son rôle, avec une action déjà chargée.
### 5.3 L'Éditeur — trois zones
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Tranquilys FR → ES ▾ ⬡ web+ios ▾ [⌘K] ● 47 manquantes ⚠ 12 obsolètes│
├──────────────┬────────────────────────────────────────┬─────────────────────┤
│ NAMESPACES │ CLÉS │ CONTEXTE │
│ │ │ │
│ ▸ common 12 │ ● cart.empty_state ⬡ web ios │ cart.empty_state │
│ ▾ cart 8 │ « Votre panier est vide » │ │
│ ▸ items 3 │ [Tu carrito está vacío___________] │ 📝 Affiché au │
│ ▸ checkout 27│ │ centre de la page │
│ ▸ errors 0 │ ⚠ cart.item_count ⬡ web ios and │ panier quand elle │
│ ─────────────│ « 1 article | {n} articles » │ est vide. │
│ ⊘ Non assig 2│ one [{n} artículo______________] │ │
│ ─────────────│ other[{n} artículos_____________] │ 📐 max. 40 car. │
│ FILTRES │ │ 🔤 {n} : nombre │
│ ○ Toutes 312 │ ✓ cart.checkout_cta ⬡ web │ │
│ ● Manquantes │ « Passer commande » │ 📸 [capture] │
│ ⚠ Obsolètes │ [Realizar pedido________________] │ ⏱ Historique │
│ ○ À relire │ │ 💬 Commentaires 2 │
└──────────────┴────────────────────────────────────────┴─────────────────────┘
```
Décisions et leurs raisons :
- **Deux langues à l'écran, jamais douze.** Personne ne traduit vers 12 langues simultanément et une grille à 12 colonnes est illisible. Le sélecteur `FR → ES` est l'objet de navigation principal. *(Le principe vaut pour la vue par langue ; la vue par clé, §5.3 bis, le tient autrement.)*
- **La plateforme est un filtre, pas un espace.** Multi-select, « toutes » par défaut. Chaque ligne porte ses pastilles d'appartenance (`⬡ web ios`). Marie traduit une fois pour tout le monde ; Sam filtre sur `ios` avant un build.
- **Le bac « Non assignées » est permanent** dans l'arbre des namespaces. Une clé sans plateforme n'apparaît dans aucun bundle — elle doit être visible, pas silencieuse.
- **Les pluriels sont des champs, pas de l'ICU.** L'éditeur affiche une ligne par catégorie CLDR de la langue **cible** (l'arabe en a six, le français deux) et recompose l'ICU à l'enregistrement.
- **Panneau de contexte permanent, pas une modale.** Le contexte est la matière première du traducteur ; une modale l'oblige à choisir entre le lire et saisir.
- **Édition inline, pas de bouton « Enregistrer » global.** Autosave au blur, indicateur d'état par ligne (`⋯` → `✓`), rollback visible en cas d'échec réseau.
- **Liste virtualisée.** 30 000 clés à 60 fps. Non négociable.
- **Filtres dans l'URL** (`?status=missing&locale=es&platform=ios&ns=checkout`) : partageables dans Slack, restaurables, bookmarkables. Léa envoie un lien, Marie atterrit sur le bon travail.
### 5.3 bis La vue par clé — l'autre angle
Une bascule en tête de l'éditeur, dont le choix vit dans l'URL (`?view=key`).
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ [Par langue│Par clé] fr-FR → 7 langues ⬡ toutes ▾ [rechercher…] │
├──────────────┬────────────────────────────────────────┬─────────────────────┤
│ NAMESPACES │ booking.action.cancel ⬡ web ios 29% │ LANGUES │
│ (toutes │ fr-FR Annuler la séance │ ● en-GB traduite │
│ langues) │ ┌────────────────────────────────────┐│ ● es-ES traduite │
│ │ │ en-GB ● Cancel session ││ ○ de-DE à traduire│
│ booking 5 │ │ es-ES ● Cancelar sesión ▾ ││ ○ it-IT à traduire│
│ action 1 │ │ [Cancelar sesión__________] ││ … │
│ cancel 1 │ │ de-DE ○ À traduire ││ │
│ common 5 │ │ it-IT ○ À traduire consultation││ CONTEXTE │
│ … │ │ … ││ Action destructive…│
└──────────────┴────────────────────────────────────────┴─────────────────────┘
```
**Deux publics, deux questions.** La vue par langue sert la traductrice qui vide sa file dans *une* langue. La vue par clé sert qui doit juger d'un libellé : le responsable de localisation avant une mise en production, le développeur qui vient d'ajouter une clé et veut savoir où elle en est. Aucune ne remplace l'autre, et c'est pourquoi les deux existent plutôt qu'un compromis qui servirait mal les deux.
**Le principe des douze colonnes n'est pas abandonné, il est respecté autrement.** Les langues sont **empilées et repliées** — une ligne de vingt pixels chacune, code de langue, pastille de statut, valeur tronquée — et non juxtaposées en colonnes. On ouvre la seule langue sur laquelle on veut agir. Sept lignes se lisent ; sept colonnes ne se lisent pas. C'est aussi ce qui garde la virtualisation honnête : les hauteurs restent proches de l'estimation.
**Le filtre de statut change de sens, et c'est dit dans l'interface.** Par langue, « à traduire » désigne une clé non traduite *dans cette langue*. Par clé, il désigne une clé non traduite dans **au moins une** langue — la seule lecture qui ait un sens quand la ligne porte sept langues. Les compteurs de l'arbre des namespaces suivent la vue (`locale=*` côté API), sinon ils afficheraient le reste-à-faire espagnol au-dessus d'une liste qui montre tout : deux chiffres qui ne parlent pas de la même chose, côte à côte. Ce compte n'est pas la somme des autres : une clé incomplète en six langues compte une fois, d'où le `COUNT(DISTINCT)`.
**La recherche porte sur toutes les langues.** Chercher `Cancel` trouve la clé même si le mot n'existe qu'en anglais.
**Le cloisonnement par langue ne s'applique pas à la lecture, et c'est voulu.** Une traductrice espagnole voit l'allemand — c'est même l'intérêt de cette vue, disposer des autres langues comme contexte. L'écriture reste refusée langue par langue : les lignes hors habilitation sont marquées « consultation » et n'offrent aucun champ, et le serveur refuse également (403).
**Coût technique.** Un DTO `KeyRow` distinct plutôt qu'un `GridRow` élargi : les deux vues n'ont ni la même cardinalité ni les mêmes filtres, et un DTO à géométrie variable aurait forcé chaque appelant à deviner ce qu'il tient. Pagination plus courte (50 contre 200) puisque chaque ligne porte autant d'entrées qu'il y a de langues. Et pagination **en deux temps** — identifiants d'abord, hydratation ensuite : une jointure de collection combinée à `setMaxResults` fait porter la limite sur les lignes du produit cartésien, pas sur les clés, et rendrait sept fois moins de résultats que demandé, silencieusement.
### 5.4 Le mode Focus — la fonctionnalité signature
Marie n'explore pas, elle **vide une file**. Bouton principal : **« Traduire les 47 manquantes »**.
```
┌──────────────────────────────────────────────────────────┐
│ 14 / 47 ▓▓▓░░░░░░ │
│ checkout.payment.card_declined ⬡ web ios │
│ │
│ FR « Votre carte a été refusée. Vérifiez vos │
│ informations ou essayez une autre carte. » │
│ │
│ ES ┌────────────────────────────────────────────┐ │
│ └────────────────────────────────────────────┘ │
│ 0 / 120 caractères │
│ │
│ 💡 Glossaire : « card » → « tarjeta » │
│ 💡 Similaire : checkout.payment.card_expired │
│ │
│ ⌘↵ valider et suivant ⌘⇧↵ marquer à revoir │
│ ⌥↵ suggestion auto Échap quitter │
└──────────────────────────────────────────────────────────┘
```
Une clé, tout le contexte, zéro souris. Ce mode fait passer « traduire 47 clés » de 45 minutes à 12. **C'est là que se gagne la préférence utilisateur, pas dans le CRUD.**
#### 5.4 bis Le mode Focus toutes langues
Le pendant du précédent, ouvert depuis la vue par clé. Une clé, et **un champ par langue que l'utilisateur a le droit d'écrire et qui reste à faire**.
```
┌──────────────────────────────────────────────────────────┐
│ 2 / 16 ▓▓░░░░░░░░ 1 traitée es-ES · pt-BR Quitter│
│ │
│ booking.cancel.confirm ⬡ web ios 14%│
│ Texte de la boîte de confirmation. │
│ │
│ Annuler définitivement cette séance ? │
│ │
│ ● es-ES Español ┌──────────────────────────────┐ │
│ └──────────────────────────────┘ │
│ ● pt-BR Português ┌──────────────────────────────┐ │
│ └──────────────────────────────┘ │
│ │
│ DÉJÀ TRADUITES │
│ en-GB Are you sure you want to delete this seance? │
│ │
│ ⌘↵ enregistrer et descendre Échap quitter │
└──────────────────────────────────────────────────────────┘
```
**Le gain est précis, et c'est le seul qui justifie l'écran** : la source et son contexte se lisent UNE fois pour plusieurs langues. María, habilitée en espagnol et en portugais, rencontrait jusqu'ici la même clé dans deux files séparées et relisait deux fois « Annuler la séance » avant d'écrire deux phrases voisines.
Trois conséquences de conception :
1. **La file ne contient que du faisable.** Une clé qui ne manque qu'en allemand n'entre pas dans la file d'une traductrice espagnole : elle la traverserait sans rien pouvoir y faire, ce qui vide le mode de sa raison d'être. Le filtrage est serveur (`?focus=1`), et le sous-ensemble de langues est déduit de l'identité courante — **jamais reçu du client**. Une file qu'on pourrait demander pour les langues d'un autre serait une fuite d'information déguisée en confort.
2. **Les langues déjà faites restent visibles, en lecture.** Une traduction voisine déjà écrite est souvent une meilleure matière première que la source elle-même pour trancher un registre.
3. **`⌘↵` descend d'un champ, puis passe à la clé suivante.** Le geste reste celui du mode Focus par langue ; il traverse simplement plusieurs champs avant de changer de clé.
Un piège rencontré à l'implémentation, et qui vaut d'être noté : `[]` et `null` ne veulent pas dire la même chose côté langues écrivables. `null` = toutes, `[]` = aucune. Le `if (!empty($x))` réflexe aurait servi au développeur — qui n'écrit aucune traduction — la file de travail entière, sur laquelle il n'aurait rien pu faire.
### 5.5 Signalétique des statuts
Un code couleur unique, partout, sans exception :
| | Statut | Sens pour le traducteur |
|---|---|---|
| ○ gris | `untranslated` | À faire |
| ◐ ambre | `draft` | Commencé, pas fini |
| ⚠ rouge | `needs_review` | **La source a changé — cette traduction est peut-être fausse** |
| ● bleu | `translated` | Fait, en attente de relecture |
| ✓ vert | `reviewed` | Validé |
Le rouge `needs_review` doit être **le plus visible de l'interface**. C'est le seul état qui représente un bug en production.
### 5.6 Garde-fous de qualité, en temps réel
- **Placeholder manquant ou inventé** → erreur bloquante. `« Bonjour {nom} »` traduit en `« Hola »` casse l'application cliente.
- **Dépassement de `max_length`** → avertissement non bloquant + compteur.
- **Catégorie CLDR manquante** → l'éditeur exige toutes les formes de la langue cible.
- **Balises HTML déséquilibrées** → erreur.
- **Terme du glossaire non respecté** → suggestion douce, jamais bloquante.
C'est ce qui fait qu'un traducteur non technique ne peut pas casser la production. Gain de confiance majeur côté développeurs.
### 5.7 Vue développeur
- `/keys` : table dense, colonnes techniques (chemin, namespace, plateformes, dernière détection, langues traduites), sélection multiple, actions groupées, import/export.
- `/releases` : **diff lisible entre deux releases.** « v43 → v44 : 12 clés ajoutées, 3 modifiées, 1 archivée. »
- `/integration` : la clé API générée avec, juste en dessous, **le snippet prêt à coller dans la stack de la plateforme** (i18next, vue-i18n, react-intl, Symfony Translation). Le time-to-first-translation se compte en minutes.
### 5.8 Administration : faire entrer quelqu'un, et le cantonner
Deux écrans, réservés aux administrateurs du projet — absents de la navigation pour les autres, et refusés lisiblement s'ils sont atteints par l'URL.
**`/members` — invitations et habilitations.** L'invitation ne demande que trois choses : l'adresse, le rôle, et les langues. La liste des membres et la liste des invitations en attente sont **dans le même écran** : un administrateur qui se demande « qui a accès ? » doit voir d'un coup d'œil les accès accordés *et* ceux en cours d'octroi.
Trois décisions de sécurité s'y jouent :
- **L'écran est réservé aux administrateurs, pas ouvert à tout membre.** La réponse contient l'adresse e-mail de chaque personne du projet et de chaque invitation en cours ; un prestataire externe pourrait y énumérer l'équipe interne et observer les arrivées à venir. Le confort perdu (« qui relit mon travail ? ») est réel mais mineur.
- **Le jeton d'invitation fait 256 bits et n'est stocké que haché** (SHA-256). Le lien en clair n'existe que dans l'e-mail.
- **Accepter une invitation ne définit un mot de passe que sur un compte neuf.** Sinon, inviter une adresse existante permettrait de réinitialiser le mot de passe de son propriétaire — une prise de contrôle déguisée en invitation.
La page d'acceptation montre le projet, le rôle et les langues **avant** de demander quoi que ce soit. On ne demande pas à quelqu'un de créer un compte sans lui dire pour quoi.
Le dernier propriétaire ne peut être ni rétrogradé ni retiré : un projet sans propriétaire ne peut plus être administré du tout. Et retirer un membre retire l'appartenance, jamais le compte — il peut être membre d'autres projets, et son historique de traduction doit rester attribuable.
**`/integration` — cycle de vie des clés.** Le secret n'est affiché **qu'une fois**, à la création, avec la phrase qui va avec : *« Copiez cette clé maintenant : elle ne sera plus jamais affichée. »* Ensuite, seul un préfixe tronqué subsiste, assez pour reconnaître la clé dans la liste, inutile pour s'en servir.
Les permissions d'écriture sont **en orange** dans le sélecteur, les lectures en bleu. Une clé de production n'a besoin que de lecture ; la couleur le dit avant que la documentation ait à l'expliquer. Chaque clé porte son nom, son environnement, ses permissions et sa dernière utilisation — les quatre informations nécessaires pour répondre, dans six mois, à « celle-là, on peut la révoquer ? ».
### 5.9 Administration d'organisation
Un second niveau, distinct du précédent et réservé au **super-administrateur** : `/admin`.
La séparation n'est pas décorative. Les écrans Membres et Intégration répondent à « qui a accès à *ce* projet ? ». Celui-ci répond à « qui existe dans l'outil, et où va-t-il ? » — une question qui traverse les projets, donc qui traverse aussi les administrateurs de projet. L'administrateur du projet A n'a pas à savoir qui travaille sur le projet B.
**Onglet Utilisateurs.** Tous les comptes de l'organisation, avec pour chacun ses appartenances projet, ses langues et sa dernière connexion. Deux actions : activer/désactiver, nommer/retirer super-administrateur. L'écran signale en tête les **comptes sans aucun projet** — ils se connectent et ne voient rien, ce qui est toujours soit une invitation oubliée, soit un départ mal soldé.
Trois protections, toutes exprimées par un bouton désactivé plutôt que par un refus après clic :
- On ne modifie ni ne désactive **son propre** compte depuis cet écran.
- Il doit rester **au moins un super-administrateur actif**.
- La désactivation **coupe les sessions en cours**. Ce point a coûté une correction : sans `UserProviderInterface` sur le repository, Symfony recharge l'utilisateur par clé primaire et ne repasse jamais par la condition `isActive`. Désactiver n'aurait fermé que la porte d'entrée, laissant travailler qui était déjà entré jusqu'à l'expiration de son cookie — exactement l'inverse de ce qu'on attend d'un bouton pressé quand un accès doit cesser. Un test verrouille le comportement.
**Onglet Projets.** L'inventaire complet, y compris les projets où le super-administrateur n'a aucun rôle : `GET /api/v1/projects` ne renvoie que ce dont on est membre, et un projet **sans propriétaire** resterait sinon invisible à la seule personne capable d'y remédier. Deux anomalies structurelles sont donc criées dans la liste : « aucun propriétaire » (plus personne ne peut inviter ni publier) et « aucune plateforme » (le projet existe mais ne livre rien).
La création d'un projet pose d'un coup les trois environnements, la langue source comme langue du projet, et le créateur comme propriétaire. Chacun de ces trois oublis ne se manifesterait qu'au premier déploiement, loin de sa cause.
### 5.10 Plateformes et environnements
Un écran, deux notions, parce qu'on s'y rend pour une seule raison : rendre un projet livrable. Une **plateforme** dit quelles clés entrent dans un fichier et dans quelle syntaxe ; un **environnement** dit quelle version de ce fichier est servie. Il faut les deux, et un projet fraîchement créé n'a que le second — l'écran le dit en tête plutôt que de laisser le découvrir à la première publication.
**L'archivage remplace la suppression.** Effacer une plateforme effacerait son rattachement aux clés dans `translation_key_platform`, donc la seule trace expliquant pourquoi telle clé existe. Une plateforme archivée :
- sort des releases suivantes, mais reste dans celles **déjà publiées** — un instantané immuable ne se réécrit pas rétroactivement, sinon les ETags déjà servis mentiraient ;
- refuse les `sync` avec un **409** dont le message dit que la décision est humaine et se défait depuis le back-office. Un 404 aurait envoyé le développeur chercher une faute de frappe dans sa configuration ;
- disparaît des filtres de l'éditeur et de la réponse `whoami` que lit `tqs init` ;
- reste visible dans l'écran, avec sa date de retrait et son **nombre de clés** — le chiffre qui rend la décision prenable : « 0 clé » se retire sans réfléchir, « 340 clés » demande de vérifier d'abord.
L'invariant tient à une seule règle : tout ce qui **produit** passe par `getActivePlatforms()`, tout ce qui **affiche** peut passer par `getPlatforms()`. Rien dans le langage ne l'impose, et l'oubli serait silencieux — la publication réussirait, en livrant une plateforme retirée. Un test pose la frontière.
La dernière plateforme active ne peut pas être archivée : le bouton est désactivé avec son explication, et le serveur refuse également. Un projet sans plateforme active ne peut rien publier.
**Un environnement ne se supprime pas**, ni ne s'archive. Il porte le pointeur de release que la Delivery API sert, et les clés d'accès. L'effacer couperait la livraison des applications qui le consultent sans qu'aucun signal ne parte au moment du geste : la panne se manifesterait chez le client, plus tard, et personne ne ferait le lien.
**Les slugs sont figés après création**, pour les deux entités. Ils vivent dans les URL de livraison, dans les `tqs.config.json` des dépôts clients et dans les scripts d'intégration continue. Les changer casserait tout cela sans qu'aucune erreur ne remonte au moment du changement.
**Seuls les formats disposant d'un sérialiseur sont proposés** (`icu`, `i18next`). Offrir `symfony` ou `gettext` créerait une plateforme incapable de recevoir un `sync` comme de produire un bundle — un choix sans issue, découvert bien plus tard. Le jour où ces sérialiseurs existeront (§4.5), ils apparaîtront d'eux-mêmes dans la liste.
`autoPublish` n'est **pas** exposé. La colonne existe depuis la phase 0 mais aucun code ne la lit : un interrupteur qui ne fait rien est pire qu'un interrupteur absent — on le bascule, on constate que rien ne change, et on cesse de faire confiance au reste de l'écran.
### 5.11 Hors périmètre v1
Traduction automatique intégrée (DeepL/LLM), mémoire de traduction sémantique, workflows d'approbation configurables, collaboration temps réel, facturation. Tous légitimes, aucun bloquant. Le modèle de données ci-dessus les accueille sans migration destructive.
---
## 6. Contrat d'API
### Delivery API
```http
GET /delivery/v1/{project}/{env}/{platform}/{locale}.json
GET /delivery/v1/{project}/{env}/{platform}/{locale}/{namespace}.json
Authorization: Bearer tqs_live_xxx
If-None-Match: "sha256-a4f2…"
200 { "cart": { "empty_state": "Tu carrito está vacío" } }
304 Not Modified ← le cas nominal
ETag: "sha256-a4f2…"
Cache-Control: public, max-age=300, stale-while-revalidate=86400
```
### Management API (OpenAPI auto-généré)
```http
GET /api/v1/projects/{id}/keys?platform=ios&namespace=cart&status=missing&locale=es
POST /api/v1/projects/{id}/keys
PATCH /api/v1/translations/{id}
POST /api/v1/projects/{id}/platforms/{platform}/sync ← endpoint du CLI, idempotent
POST /api/v1/projects/{id}/releases
GET /api/v1/releases/{a}/diff/{b}
```
`POST /platforms/{platform}/sync` est le endpoint critique : il reçoit l'inventaire complet des clés détectées dans le code d'**une plateforme**, les parse depuis le format de cette plateforme vers le canonique ICU, et renvoie un **diff** (ajoutées / inchangées / orphelines). Il n'archive rien sans `prune: true` explicite, et le `prune` ne retire que l'appartenance à cette plateforme. Idempotent, rejouable, non destructif par défaut.
---
## 7. Trajectoire
| Phase | Contenu | Critère de sortie |
|---|---|---|
| **0 — Socle** ✅ | Docker, Symfony, schéma complet, migrations, Doctrine filter, auth, seed | `docker compose up` → API qui répond |
| **1 — Moteur de format** ✅ | Parseur/sérialiseur ICU + i18next, extracteur de placeholders, validateur | 91 tests verts, PHPStan 8 propre |
| **2 — Management API** ✅ | CRUD, extension de visibilité, grille, `sync`, écriture validée, OpenAPI | 102 tests verts, 9 chemins documentés |
| **3 — Éditeur** ✅ | SPA React, grille virtualisée, saisie en ligne, pluriels CLDR, mode Focus, lecture seule par habilitation | Parcours vérifié en navigateur headless |
| **4 — Releases & Delivery** ✅ | Publication tout-ou-rien, bundles par (plateforme, langue), ETag/304, diff, déploiement et rollback, rétention | 111 tests verts, boucle vérifiée jusqu'au 304 |
| **5 — CLI `tqs`** ✅ | init / push / pull / status, packagé en PHAR autonome de 3 Mo | Parcours complet vérifié depuis un dépôt vierge, hors du projet |
| **6 — Administration de projet** ✅ | Invitations par e-mail, gestion des membres, cycle de vie des clés API, écrans Membres et Intégration | Traductrice externe invitée, arrivée, cantonnée à sa langue — vérifié en navigateur |
| **7 — Administration d'organisation** ✅ | Annuaire des comptes, rôle super-admin, désactivation révocatoire, inventaire et création de projets | Projet créé de bout en bout depuis l'interface ; 115 tests verts |
| **8 — Plateformes et environnements** ✅ | Écran de gestion des plateformes (archivage, pas de suppression), gestion des environnements | Projet créé, plateforme déclarée, clés poussées, release publiée et fichier servi — sans jamais toucher au seed ; 120 tests verts |
| **9 — Vue par clé** ✅ | Bascule par langue / par clé, DTO et endpoint dédiés, compteurs toutes langues, mode Focus toutes langues | Saisie faite dans une vue et retrouvée dans l'autre ; file du Focus restreinte aux langues écrivables ; 126 tests verts |
| **10 — Qualité & terrain** | Glossaire, commentaires, captures d'écran, formats additionnels, import de l'existant | Retour terrain d'une traductrice réelle |
La phase 1 passe avant l'API : le moteur de format conditionne le schéma des valeurs, et le corriger après coup signifierait remigrer toutes les traductions. La phase 5 est le jalon d'adoption — c'est là qu'on saura si l'outil sera utilisé ou contourné. La phase 6 est celle qui rend l'outil transmissible : jusque-là, seul celui qui a lancé le seed pouvait s'en servir.
---
## 8. Questions ouvertes
1. ~~**Authentification**~~**tranché** : comptes locaux + invitations par e-mail (phase 6). Le point d'entrée SSO reste ouvert : `User` ne présume rien du mode d'authentification, et un `UserBadge` suffirait à brancher un fournisseur OIDC sans toucher au reste.
2. ~~**Traducteurs externes**~~**tranché, partiellement livré** : `isExternal` est porté par l'invitation et par le compte, les invitations expirent (2 jours par défaut), et les actions d'administration sont journalisées. La 2FA obligatoire pour les externes reste à implémenter — les colonnes existent, la contrainte non.
Restent ouvertes :
3. **Existant à migrer** — fichiers `.json` / `.po` / `.strings` déjà en production ? Volume, formats, et est-ce l'amorce de la base ou un import ponctuel ?
4. **Langues cibles réelles** et nombre de plateformes par projet — dimensionne les index et la cardinalité des bundles.
5. **Hébergement cible** — Docker sur VM, Kubernetes, PaaS ? Détermine la stratégie de stockage des `payload` de bundles (colonne LONGTEXT vs objet sur disque/S3).
6. **Rétention des releases** — combien de versions conserver au-delà de celles référencées par un environnement ?
7. **Captures d'écran de contexte** — stockage local, S3, ou URL externe ? Et qui les produit (dev au push, ou upload manuel) ?

35
frankenphp/Caddyfile Normal file
View file

@ -0,0 +1,35 @@
{
{$CADDY_GLOBAL_OPTIONS}
frankenphp {
# En prod, FRANKENPHP_CONFIG vaut "worker ./public/index.php" (voir Dockerfile).
# En dev il reste vide : chaque requête repart d'un kernel neuf, ce qui rend
# le debug prévisible. Le worker mode ne se teste que sur l'image prod.
{$FRANKENPHP_CONFIG}
}
}
{$CADDY_EXTRA_CONFIG}
{$SERVER_NAME:localhost} {
log {
{$CADDY_SERVER_LOG_OPTIONS}
# Les clés API circulent en Authorization: Bearer. Elles ne doivent jamais
# atterrir dans les logs d'accès c'est le premier endroit fuit un secret.
format filter {
wrap console
fields {
request>headers>Authorization delete
request>headers>Cookie delete
request>headers>X-Api-Key delete
}
}
}
root /app/public
encode zstd br gzip
php_server {
try_files {path} index.php
}
}

View file

@ -0,0 +1,27 @@
; Réglages communs dev/prod.
expose_php = Off
; Les valeurs de traduction peuvent être longues (paragraphes juridiques, e-mails
; transactionnels). Le sync CLI pousse des milliers de clés en une requête.
memory_limit = 512M
post_max_size = 32M
upload_max_filesize = 16M
max_execution_time = 120
date.timezone = UTC
; UTF-8 de bout en bout : c'est un outil de traduction, tout le reste est un bug.
; mbstring.internal_encoding est dépréciée depuis PHP 8.4 : default_charset la
; couvre entièrement.
default_charset = UTF-8
; La sérialisation ICU dépend de la version d'ICU embarquée. On la journalise au
; boot (voir IcuVersionCheck) pour que les écarts CLDR entre environnements soient
; détectables plutôt que mystérieux.
intl.default_locale = en
intl.error_level = 0
session.cookie_httponly = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1

View file

@ -0,0 +1,15 @@
; Dev uniquement.
opcache.enable = 1
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
display_errors = On
error_reporting = E_ALL
; Xdebug est installé mais désactivé par défaut (XDEBUG_MODE=off dans le Dockerfile).
; Pour l'activer ponctuellement :
; docker compose run --rm -e XDEBUG_MODE=debug php bin/console ...
xdebug.client_host = host.docker.internal
xdebug.start_with_request = trigger
xdebug.discover_client_host = 0

View file

@ -0,0 +1,21 @@
; Prod uniquement.
opcache.enable = 1
opcache.preload_user = root
opcache.preload = /app/config/preload.php
opcache.validate_timestamps = 0
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.interned_strings_buffer = 16
; JIT : gain réel sur le parseur ICU et la construction des bundles, qui sont des
; boucles CPU sur des chaînes. Neutre voire négatif sur du CRUD Doctrine — c'est
; le chemin de publication qu'on optimise ici.
opcache.jit = tracing
opcache.jit_buffer_size = 64M
display_errors = Off
error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED
realpath_cache_size = 4096K
realpath_cache_ttl = 600

0
migrations/.gitignore vendored Normal file
View file

View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Schéma initial de TQ-Slator.
*
* Relue à la main plutôt que générée telle quelle : trois points ne peuvent pas
* être exprimés par les métadonnées Doctrine et sont documentés ici.
*
* 1. translation_key.key_path est en utf8mb4_bin alors que le reste de la base
* est en utf8mb4_uca1400_ai_ci. Sans cela, `Header.Login` et `header.login`
* seraient la même clé pour l'index UNIQUE.
*
* 2. L'unicité des clés passe par key_hash BINARY(20) et non par key_path :
* 28 octets d'index au lieu de 2000.
*
* 3. L'index couvrant idx_tkp_platform_key est ajouté manuellement (voir
* commentaire dans up()).
*/
final class Version20260814090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Schéma initial : organisations, projets, plateformes, clés, traductions, releases, RBAC.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE organization (id INT AUTO_INCREMENT NOT NULL, uuid BINARY(16) NOT NULL, name VARCHAR(120) NOT NULL, slug VARCHAR(120) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_C1EE637CD17F50A6 (uuid), UNIQUE INDEX uniq_organization_slug (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE api_key (id INT AUTO_INCREMENT NOT NULL, uuid BINARY(16) NOT NULL, scopes JSON NOT NULL, last_used_at DATETIME DEFAULT NULL, expires_at DATETIME DEFAULT NULL, revoked_at DATETIME DEFAULT NULL, name VARCHAR(120) NOT NULL, key_prefix VARCHAR(24) NOT NULL, key_hash BINARY(32) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, created_by_id INT DEFAULT NULL, environment_id INT NOT NULL, UNIQUE INDEX UNIQ_C912ED9DD17F50A6 (uuid), INDEX IDX_C912ED9DB03A8386 (created_by_id), INDEX idx_api_key_environment (environment_id), UNIQUE INDEX uniq_api_key_hash (key_hash), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE audit_log (id INT AUTO_INCREMENT NOT NULL, created_at DATETIME NOT NULL, actor_label VARCHAR(180) DEFAULT NULL, entity_class VARCHAR(120) DEFAULT NULL, entity_id VARCHAR(64) DEFAULT NULL, context JSON NOT NULL, ip_address VARCHAR(45) DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, action VARCHAR(80) NOT NULL, actor_id INT DEFAULT NULL, organization_id INT NOT NULL, INDEX IDX_F6E1C0F510DAF24A (actor_id), INDEX IDX_F6E1C0F532C8A3DE (organization_id), INDEX idx_audit_org_date (organization_id, created_at), INDEX idx_audit_actor (actor_id, created_at), INDEX idx_audit_target (entity_class, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE completion_stat (id INT AUTO_INCREMENT NOT NULL, total_keys INT DEFAULT 0 NOT NULL, untranslated INT DEFAULT 0 NOT NULL, draft INT DEFAULT 0 NOT NULL, translated INT DEFAULT 0 NOT NULL, needs_review INT DEFAULT 0 NOT NULL, reviewed INT DEFAULT 0 NOT NULL, computed_at DATETIME NOT NULL, project_id INT NOT NULL, locale_id INT NOT NULL, platform_id INT DEFAULT NULL, INDEX IDX_C818E4F5166D1F9C (project_id), INDEX IDX_C818E4F5E559DFD1 (locale_id), INDEX IDX_C818E4F5FFE6496F (platform_id), UNIQUE INDEX uniq_stat_scope (project_id, platform_id, locale_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE environment (id INT AUTO_INCREMENT NOT NULL, uuid BINARY(16) NOT NULL, auto_publish TINYINT DEFAULT 0 NOT NULL, sort_order INT DEFAULT 0 NOT NULL, name VARCHAR(60) NOT NULL, slug VARCHAR(60) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, current_release_id INT DEFAULT NULL, project_id INT NOT NULL, UNIQUE INDEX UNIQ_4626DE22D17F50A6 (uuid), INDEX IDX_4626DE226E91BB0F (current_release_id), INDEX IDX_4626DE22166D1F9C (project_id), UNIQUE INDEX uniq_environment_project_slug (project_id, slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE glossary_term (id INT AUTO_INCREMENT NOT NULL, translation VARCHAR(255) DEFAULT NULL, is_forbidden TINYINT DEFAULT 0 NOT NULL, keep_as_is TINYINT DEFAULT 0 NOT NULL, notes LONGTEXT DEFAULT NULL, term VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, project_id INT NOT NULL, locale_id INT NOT NULL, INDEX IDX_334345DD166D1F9C (project_id), INDEX IDX_334345DDE559DFD1 (locale_id), INDEX idx_glossary_lookup (project_id, locale_id), UNIQUE INDEX uniq_glossary_term (project_id, locale_id, term), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE invitation (id INT AUTO_INCREMENT NOT NULL, role VARCHAR(16) DEFAULT NULL, locale_codes JSON NOT NULL, is_external TINYINT DEFAULT 0 NOT NULL, accepted_at DATETIME DEFAULT NULL, revoked_at DATETIME DEFAULT NULL, email VARCHAR(180) NOT NULL, token_hash BINARY(32) NOT NULL, expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, project_id INT DEFAULT NULL, invited_by_id INT DEFAULT NULL, organization_id INT NOT NULL, INDEX IDX_F11D61A2166D1F9C (project_id), INDEX IDX_F11D61A2A7B4A7E3 (invited_by_id), INDEX IDX_F11D61A232C8A3DE (organization_id), INDEX idx_invitation_email (organization_id, email), UNIQUE INDEX uniq_invitation_token (token_hash), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE key_comment (id INT AUTO_INCREMENT NOT NULL, resolved_at DATETIME DEFAULT NULL, body LONGTEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, locale_id INT DEFAULT NULL, resolved_by_id INT DEFAULT NULL, translation_key_id INT NOT NULL, author_id INT NOT NULL, INDEX IDX_F6A27652E559DFD1 (locale_id), INDEX IDX_F6A276526713A32B (resolved_by_id), INDEX IDX_F6A27652D07ED992 (translation_key_id), INDEX IDX_F6A27652F675F31B (author_id), INDEX idx_comment_key (translation_key_id, created_at), INDEX idx_comment_open (translation_key_id, resolved_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE key_screenshot (id INT AUTO_INCREMENT NOT NULL, caption VARCHAR(255) DEFAULT NULL, width INT DEFAULT NULL, height INT DEFAULT NULL, path VARCHAR(400) NOT NULL, mime_type VARCHAR(100) NOT NULL, size_bytes INT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, uploaded_by_id INT DEFAULT NULL, translation_key_id INT NOT NULL, INDEX IDX_E83440FEA2B28FE8 (uploaded_by_id), INDEX idx_screenshot_key (translation_key_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE locale (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(16) NOT NULL, name VARCHAR(120) NOT NULL, native_name VARCHAR(120) NOT NULL, direction VARCHAR(3) DEFAULT \'ltr\' NOT NULL, plural_categories JSON NOT NULL, fallback_locale_id INT DEFAULT NULL, INDEX IDX_4180C6983AE764FC (fallback_locale_id), UNIQUE INDEX uniq_locale_code (code), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE platform (id INT AUTO_INCREMENT NOT NULL, uuid BINARY(16) NOT NULL, sort_order INT DEFAULT 0 NOT NULL, export_layout VARCHAR(16) DEFAULT \'nested\' NOT NULL, default_max_length INT DEFAULT NULL, name VARCHAR(80) NOT NULL, slug VARCHAR(80) NOT NULL, kind VARCHAR(16) DEFAULT \'other\' NOT NULL, message_format VARCHAR(16) DEFAULT \'icu\' NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, project_id INT NOT NULL, UNIQUE INDEX UNIQ_3952D0CBD17F50A6 (uuid), INDEX IDX_3952D0CB166D1F9C (project_id), UNIQUE INDEX uniq_platform_project_slug (project_id, slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE project (id INT AUTO_INCREMENT NOT NULL, uuid BINARY(16) NOT NULL, description LONGTEXT DEFAULT NULL, key_naming_pattern VARCHAR(255) DEFAULT NULL, default_max_length INT DEFAULT NULL, name VARCHAR(120) NOT NULL, slug VARCHAR(120) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, organization_id INT NOT NULL, source_locale_id INT NOT NULL, UNIQUE INDEX UNIQ_2FB3D0EED17F50A6 (uuid), INDEX IDX_2FB3D0EE32C8A3DE (organization_id), INDEX IDX_2FB3D0EE8DC74C1F (source_locale_id), UNIQUE INDEX uniq_project_org_slug (organization_id, slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE project_locale (id INT AUTO_INCREMENT NOT NULL, is_enabled TINYINT DEFAULT 1 NOT NULL, sort_order INT DEFAULT 0 NOT NULL, project_id INT NOT NULL, locale_id INT NOT NULL, INDEX IDX_56242DD2166D1F9C (project_id), INDEX IDX_56242DD2E559DFD1 (locale_id), UNIQUE INDEX uniq_project_locale (project_id, locale_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE project_member (id INT AUTO_INCREMENT NOT NULL, uuid BINARY(16) NOT NULL, role VARCHAR(16) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, project_id INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_67401132D17F50A6 (uuid), INDEX IDX_67401132166D1F9C (project_id), INDEX idx_member_user (user_id), UNIQUE INDEX uniq_project_member (project_id, user_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE member_locale (project_member_id INT NOT NULL, locale_id INT NOT NULL, INDEX IDX_655644AB64AB9629 (project_member_id), INDEX IDX_655644ABE559DFD1 (locale_id), PRIMARY KEY (project_member_id, locale_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE project_release (id INT AUTO_INCREMENT NOT NULL, uuid BINARY(16) NOT NULL, notes LONGTEXT DEFAULT NULL, key_count INT DEFAULT 0 NOT NULL, published_at DATETIME DEFAULT NULL, version INT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, created_by_id INT DEFAULT NULL, project_id INT NOT NULL, UNIQUE INDEX UNIQ_8590F78D17F50A6 (uuid), INDEX IDX_8590F78B03A8386 (created_by_id), INDEX IDX_8590F78166D1F9C (project_id), INDEX idx_release_published (project_id, published_at), UNIQUE INDEX uniq_release_project_version (project_id, version), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE release_bundle (id INT AUTO_INCREMENT NOT NULL, key_count INT DEFAULT 0 NOT NULL, size_bytes INT DEFAULT 0 NOT NULL, fallback_count INT DEFAULT 0 NOT NULL, payload LONGTEXT NOT NULL, checksum BINARY(32) NOT NULL, format VARCHAR(16) NOT NULL, layout VARCHAR(16) NOT NULL, release_id INT NOT NULL, platform_id INT NOT NULL, locale_id INT NOT NULL, INDEX IDX_6B994810B12A727D (release_id), INDEX IDX_6B994810FFE6496F (platform_id), INDEX IDX_6B994810E559DFD1 (locale_id), INDEX idx_bundle_checksum (checksum), UNIQUE INDEX uniq_bundle_release_platform_locale (release_id, platform_id, locale_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE translation (id INT AUTO_INCREMENT NOT NULL, value LONGTEXT DEFAULT NULL, status VARCHAR(20) DEFAULT \'untranslated\' NOT NULL, source_checksum BINARY(20) DEFAULT NULL, is_machine_translated TINYINT DEFAULT 0 NOT NULL, updated_at DATETIME NOT NULL, version INT DEFAULT 1 NOT NULL, updated_by_id INT DEFAULT NULL, translation_key_id INT NOT NULL, locale_id INT NOT NULL, INDEX IDX_B469456F896DBBDE (updated_by_id), INDEX IDX_B469456FD07ED992 (translation_key_id), INDEX IDX_B469456FE559DFD1 (locale_id), INDEX idx_translation_grid (translation_key_id, locale_id, status), INDEX idx_translation_stale (locale_id, status, updated_at), UNIQUE INDEX uniq_translation_key_locale (translation_key_id, locale_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE translation_key (id INT AUTO_INCREMENT NOT NULL, uuid BINARY(16) NOT NULL, key_hash BINARY(20) NOT NULL, description LONGTEXT DEFAULT NULL, placeholders JSON NOT NULL, max_length INT DEFAULT NULL, is_archived TINYINT DEFAULT 0 NOT NULL, last_seen_at DATETIME DEFAULT NULL, key_path VARCHAR(500) NOT NULL COLLATE `utf8mb4_bin`, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, namespace_id INT DEFAULT NULL, created_by_id INT DEFAULT NULL, project_id INT NOT NULL, UNIQUE INDEX UNIQ_AADCBD56D17F50A6 (uuid), INDEX IDX_AADCBD565F74F783 (namespace_id), INDEX IDX_AADCBD56B03A8386 (created_by_id), INDEX IDX_AADCBD56166D1F9C (project_id), INDEX idx_key_lookup (project_id, namespace_id, key_path), INDEX idx_key_archived (project_id, is_archived), INDEX idx_key_last_seen (project_id, last_seen_at), UNIQUE INDEX uniq_key_project_hash (project_id, key_hash), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE translation_key_platform (translation_key_id INT NOT NULL, platform_id INT NOT NULL, INDEX IDX_8FCD3598D07ED992 (translation_key_id), INDEX IDX_8FCD3598FFE6496F (platform_id), PRIMARY KEY (translation_key_id, platform_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE translation_namespace (id INT AUTO_INCREMENT NOT NULL, depth INT DEFAULT 0 NOT NULL, path VARCHAR(400) NOT NULL COLLATE `utf8mb4_bin`, parent_id INT DEFAULT NULL, project_id INT NOT NULL, INDEX IDX_D10A03DD166D1F9C (project_id), INDEX idx_namespace_parent (parent_id), UNIQUE INDEX uniq_namespace_project_path (project_id, path), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE translation_version (id INT AUTO_INCREMENT NOT NULL, created_at DATETIME NOT NULL, value LONGTEXT DEFAULT NULL, status VARCHAR(20) NOT NULL, change_source VARCHAR(16) NOT NULL, author_id INT DEFAULT NULL, translation_id INT NOT NULL, INDEX IDX_1E102A31F675F31B (author_id), INDEX IDX_1E102A319CAA2B25 (translation_id), INDEX idx_version_translation (translation_id, created_at), INDEX idx_version_author (author_id, created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
$this->addSql('CREATE TABLE app_user (id INT AUTO_INCREMENT NOT NULL, uuid BINARY(16) NOT NULL, password VARCHAR(255) DEFAULT NULL, auth_provider VARCHAR(16) DEFAULT \'local\' NOT NULL, is_external TINYINT DEFAULT 0 NOT NULL, is_active TINYINT DEFAULT 1 NOT NULL, totp_secret VARCHAR(64) DEFAULT NULL, totp_confirmed_at DATETIME DEFAULT NULL, last_login_at DATETIME DEFAULT NULL, ui_locale VARCHAR(16) DEFAULT \'fr\' NOT NULL, roles JSON NOT NULL, email VARCHAR(180) NOT NULL, name VARCHAR(120) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, organization_id INT NOT NULL, UNIQUE INDEX UNIQ_88BDF3E9D17F50A6 (uuid), INDEX idx_user_organization (organization_id), UNIQUE INDEX uniq_user_email (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
// File Messenger. Créée ici plutôt que par auto_setup : un DDL implicite
// au premier message rendrait le premier démarrage non reproductible, et
// masquerait un défaut de droits sur la base jusqu'au pire moment.
$this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL, available_at DATETIME NOT NULL, delivered_at DATETIME DEFAULT NULL, INDEX IDX_75EA56E0FB7336F0 (queue_name), INDEX IDX_75EA56E0E3BD61CE (available_at), INDEX IDX_75EA56E016BA31DB (delivered_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_uca1400_ai_ci`');
// Index couvrant sur la table de jointement des plateformes.
//
// Doctrine ne sait pas exprimer un index sur une table ManyToMany : il est
// déclaré ici à la main. La clé primaire (translation_key_id, platform_id)
// sert le MEMBER OF de la requête de grille ; cet index en miroir permet à
// l'optimiseur d'attaquer PAR la plateforme quand celle-ci ne porte que
// quelques centaines de clés sur les trente mille du projet.
//
// ⚠ Un `doctrine:migrations:diff` proposera de le SUPPRIMER, puisqu'aucune
// métadonnée ne le décrit. Ne pas accepter cette suppression.
$this->addSql('CREATE INDEX idx_tkp_platform_key ON translation_key_platform (platform_id, translation_key_id)');
$this->addSql('ALTER TABLE api_key ADD CONSTRAINT FK_C912ED9DB03A8386 FOREIGN KEY (created_by_id) REFERENCES app_user (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE api_key ADD CONSTRAINT FK_C912ED9D903E3A94 FOREIGN KEY (environment_id) REFERENCES environment (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE audit_log ADD CONSTRAINT FK_F6E1C0F510DAF24A FOREIGN KEY (actor_id) REFERENCES app_user (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE audit_log ADD CONSTRAINT FK_F6E1C0F532C8A3DE FOREIGN KEY (organization_id) REFERENCES organization (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE completion_stat ADD CONSTRAINT FK_C818E4F5166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE completion_stat ADD CONSTRAINT FK_C818E4F5E559DFD1 FOREIGN KEY (locale_id) REFERENCES locale (id)');
$this->addSql('ALTER TABLE completion_stat ADD CONSTRAINT FK_C818E4F5FFE6496F FOREIGN KEY (platform_id) REFERENCES platform (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE environment ADD CONSTRAINT FK_4626DE226E91BB0F FOREIGN KEY (current_release_id) REFERENCES project_release (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE environment ADD CONSTRAINT FK_4626DE22166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE glossary_term ADD CONSTRAINT FK_334345DD166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE glossary_term ADD CONSTRAINT FK_334345DDE559DFD1 FOREIGN KEY (locale_id) REFERENCES locale (id)');
$this->addSql('ALTER TABLE invitation ADD CONSTRAINT FK_F11D61A2166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE invitation ADD CONSTRAINT FK_F11D61A2A7B4A7E3 FOREIGN KEY (invited_by_id) REFERENCES app_user (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE invitation ADD CONSTRAINT FK_F11D61A232C8A3DE FOREIGN KEY (organization_id) REFERENCES organization (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE key_comment ADD CONSTRAINT FK_F6A27652E559DFD1 FOREIGN KEY (locale_id) REFERENCES locale (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE key_comment ADD CONSTRAINT FK_F6A276526713A32B FOREIGN KEY (resolved_by_id) REFERENCES app_user (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE key_comment ADD CONSTRAINT FK_F6A27652D07ED992 FOREIGN KEY (translation_key_id) REFERENCES translation_key (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE key_comment ADD CONSTRAINT FK_F6A27652F675F31B FOREIGN KEY (author_id) REFERENCES app_user (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE key_screenshot ADD CONSTRAINT FK_E83440FEA2B28FE8 FOREIGN KEY (uploaded_by_id) REFERENCES app_user (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE key_screenshot ADD CONSTRAINT FK_E83440FED07ED992 FOREIGN KEY (translation_key_id) REFERENCES translation_key (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE locale ADD CONSTRAINT FK_4180C6983AE764FC FOREIGN KEY (fallback_locale_id) REFERENCES locale (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE platform ADD CONSTRAINT FK_3952D0CB166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_2FB3D0EE32C8A3DE FOREIGN KEY (organization_id) REFERENCES organization (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_2FB3D0EE8DC74C1F FOREIGN KEY (source_locale_id) REFERENCES locale (id)');
$this->addSql('ALTER TABLE project_locale ADD CONSTRAINT FK_56242DD2166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project_locale ADD CONSTRAINT FK_56242DD2E559DFD1 FOREIGN KEY (locale_id) REFERENCES locale (id)');
$this->addSql('ALTER TABLE project_member ADD CONSTRAINT FK_67401132166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project_member ADD CONSTRAINT FK_67401132A76ED395 FOREIGN KEY (user_id) REFERENCES app_user (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE member_locale ADD CONSTRAINT FK_655644AB64AB9629 FOREIGN KEY (project_member_id) REFERENCES project_member (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE member_locale ADD CONSTRAINT FK_655644ABE559DFD1 FOREIGN KEY (locale_id) REFERENCES locale (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project_release ADD CONSTRAINT FK_8590F78B03A8386 FOREIGN KEY (created_by_id) REFERENCES app_user (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE project_release ADD CONSTRAINT FK_8590F78166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE release_bundle ADD CONSTRAINT FK_6B994810B12A727D FOREIGN KEY (release_id) REFERENCES project_release (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE release_bundle ADD CONSTRAINT FK_6B994810FFE6496F FOREIGN KEY (platform_id) REFERENCES platform (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE release_bundle ADD CONSTRAINT FK_6B994810E559DFD1 FOREIGN KEY (locale_id) REFERENCES locale (id)');
$this->addSql('ALTER TABLE translation ADD CONSTRAINT FK_B469456F896DBBDE FOREIGN KEY (updated_by_id) REFERENCES app_user (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE translation ADD CONSTRAINT FK_B469456FD07ED992 FOREIGN KEY (translation_key_id) REFERENCES translation_key (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE translation ADD CONSTRAINT FK_B469456FE559DFD1 FOREIGN KEY (locale_id) REFERENCES locale (id)');
$this->addSql('ALTER TABLE translation_key ADD CONSTRAINT FK_AADCBD565F74F783 FOREIGN KEY (namespace_id) REFERENCES translation_namespace (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE translation_key ADD CONSTRAINT FK_AADCBD56B03A8386 FOREIGN KEY (created_by_id) REFERENCES app_user (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE translation_key ADD CONSTRAINT FK_AADCBD56166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE translation_key_platform ADD CONSTRAINT FK_8FCD3598D07ED992 FOREIGN KEY (translation_key_id) REFERENCES translation_key (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE translation_key_platform ADD CONSTRAINT FK_8FCD3598FFE6496F FOREIGN KEY (platform_id) REFERENCES platform (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE translation_namespace ADD CONSTRAINT FK_D10A03DD727ACA70 FOREIGN KEY (parent_id) REFERENCES translation_namespace (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE translation_namespace ADD CONSTRAINT FK_D10A03DD166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE translation_version ADD CONSTRAINT FK_1E102A31F675F31B FOREIGN KEY (author_id) REFERENCES app_user (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE translation_version ADD CONSTRAINT FK_1E102A319CAA2B25 FOREIGN KEY (translation_id) REFERENCES translation (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE app_user ADD CONSTRAINT FK_88BDF3E932C8A3DE FOREIGN KEY (organization_id) REFERENCES organization (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE api_key DROP FOREIGN KEY FK_C912ED9DB03A8386');
$this->addSql('ALTER TABLE api_key DROP FOREIGN KEY FK_C912ED9D903E3A94');
$this->addSql('ALTER TABLE audit_log DROP FOREIGN KEY FK_F6E1C0F510DAF24A');
$this->addSql('ALTER TABLE audit_log DROP FOREIGN KEY FK_F6E1C0F532C8A3DE');
$this->addSql('ALTER TABLE completion_stat DROP FOREIGN KEY FK_C818E4F5166D1F9C');
$this->addSql('ALTER TABLE completion_stat DROP FOREIGN KEY FK_C818E4F5E559DFD1');
$this->addSql('ALTER TABLE completion_stat DROP FOREIGN KEY FK_C818E4F5FFE6496F');
$this->addSql('ALTER TABLE environment DROP FOREIGN KEY FK_4626DE226E91BB0F');
$this->addSql('ALTER TABLE environment DROP FOREIGN KEY FK_4626DE22166D1F9C');
$this->addSql('ALTER TABLE glossary_term DROP FOREIGN KEY FK_334345DD166D1F9C');
$this->addSql('ALTER TABLE glossary_term DROP FOREIGN KEY FK_334345DDE559DFD1');
$this->addSql('ALTER TABLE invitation DROP FOREIGN KEY FK_F11D61A2166D1F9C');
$this->addSql('ALTER TABLE invitation DROP FOREIGN KEY FK_F11D61A2A7B4A7E3');
$this->addSql('ALTER TABLE invitation DROP FOREIGN KEY FK_F11D61A232C8A3DE');
$this->addSql('ALTER TABLE key_comment DROP FOREIGN KEY FK_F6A27652E559DFD1');
$this->addSql('ALTER TABLE key_comment DROP FOREIGN KEY FK_F6A276526713A32B');
$this->addSql('ALTER TABLE key_comment DROP FOREIGN KEY FK_F6A27652D07ED992');
$this->addSql('ALTER TABLE key_comment DROP FOREIGN KEY FK_F6A27652F675F31B');
$this->addSql('ALTER TABLE key_screenshot DROP FOREIGN KEY FK_E83440FEA2B28FE8');
$this->addSql('ALTER TABLE key_screenshot DROP FOREIGN KEY FK_E83440FED07ED992');
$this->addSql('ALTER TABLE locale DROP FOREIGN KEY FK_4180C6983AE764FC');
$this->addSql('ALTER TABLE platform DROP FOREIGN KEY FK_3952D0CB166D1F9C');
$this->addSql('ALTER TABLE project DROP FOREIGN KEY FK_2FB3D0EE32C8A3DE');
$this->addSql('ALTER TABLE project DROP FOREIGN KEY FK_2FB3D0EE8DC74C1F');
$this->addSql('ALTER TABLE project_locale DROP FOREIGN KEY FK_56242DD2166D1F9C');
$this->addSql('ALTER TABLE project_locale DROP FOREIGN KEY FK_56242DD2E559DFD1');
$this->addSql('ALTER TABLE project_member DROP FOREIGN KEY FK_67401132166D1F9C');
$this->addSql('ALTER TABLE project_member DROP FOREIGN KEY FK_67401132A76ED395');
$this->addSql('ALTER TABLE member_locale DROP FOREIGN KEY FK_655644AB64AB9629');
$this->addSql('ALTER TABLE member_locale DROP FOREIGN KEY FK_655644ABE559DFD1');
$this->addSql('ALTER TABLE project_release DROP FOREIGN KEY FK_8590F78B03A8386');
$this->addSql('ALTER TABLE project_release DROP FOREIGN KEY FK_8590F78166D1F9C');
$this->addSql('ALTER TABLE release_bundle DROP FOREIGN KEY FK_6B994810B12A727D');
$this->addSql('ALTER TABLE release_bundle DROP FOREIGN KEY FK_6B994810FFE6496F');
$this->addSql('ALTER TABLE release_bundle DROP FOREIGN KEY FK_6B994810E559DFD1');
$this->addSql('ALTER TABLE translation DROP FOREIGN KEY FK_B469456F896DBBDE');
$this->addSql('ALTER TABLE translation DROP FOREIGN KEY FK_B469456FD07ED992');
$this->addSql('ALTER TABLE translation DROP FOREIGN KEY FK_B469456FE559DFD1');
$this->addSql('ALTER TABLE translation_key DROP FOREIGN KEY FK_AADCBD565F74F783');
$this->addSql('ALTER TABLE translation_key DROP FOREIGN KEY FK_AADCBD56B03A8386');
$this->addSql('ALTER TABLE translation_key DROP FOREIGN KEY FK_AADCBD56166D1F9C');
$this->addSql('ALTER TABLE translation_key_platform DROP FOREIGN KEY FK_8FCD3598D07ED992');
$this->addSql('ALTER TABLE translation_key_platform DROP FOREIGN KEY FK_8FCD3598FFE6496F');
$this->addSql('ALTER TABLE translation_namespace DROP FOREIGN KEY FK_D10A03DD727ACA70');
$this->addSql('ALTER TABLE translation_namespace DROP FOREIGN KEY FK_D10A03DD166D1F9C');
$this->addSql('ALTER TABLE translation_version DROP FOREIGN KEY FK_1E102A31F675F31B');
$this->addSql('ALTER TABLE translation_version DROP FOREIGN KEY FK_1E102A319CAA2B25');
$this->addSql('ALTER TABLE app_user DROP FOREIGN KEY FK_88BDF3E932C8A3DE');
$this->addSql('DROP TABLE app_user');
$this->addSql('DROP TABLE translation_version');
$this->addSql('DROP TABLE translation_namespace');
$this->addSql('DROP TABLE translation_key_platform');
$this->addSql('DROP TABLE translation_key');
$this->addSql('DROP TABLE translation');
$this->addSql('DROP TABLE release_bundle');
$this->addSql('DROP TABLE project_release');
$this->addSql('DROP TABLE member_locale');
$this->addSql('DROP TABLE project_member');
$this->addSql('DROP TABLE project_locale');
$this->addSql('DROP TABLE project');
$this->addSql('DROP TABLE platform');
$this->addSql('DROP TABLE locale');
$this->addSql('DROP TABLE key_screenshot');
$this->addSql('DROP TABLE key_comment');
$this->addSql('DROP TABLE invitation');
$this->addSql('DROP TABLE glossary_term');
$this->addSql('DROP TABLE environment');
$this->addSql('DROP TABLE completion_stat');
$this->addSql('DROP TABLE audit_log');
$this->addSql('DROP TABLE api_key');
$this->addSql('DROP TABLE organization');
$this->addSql('DROP TABLE messenger_messages');
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Archivage d'une plateforme.
*
* `doctrine:migrations:diff` a proposé trois autres instructions, toutes
* écartées et pour la même raison il compare au schéma que les attributs PHP
* savent exprimer, pas à celui qu'on veut :
*
* - `DROP INDEX idx_tkp_platform_key` : cet index est déclaré à la main sur la
* table de jointure translation_key_platform, que Doctrine ne sait pas
* annoter. Le laisser passer coûterait un balayage complet à chaque `sync`.
* - la réécriture des index de `messenger_messages` : table gérée par le
* transport Doctrine, pas par nous.
*
* Le même diff les reproposera à la prochaine génération. Les retirer à la main
* fait partie du geste.
*/
final class Version20260817121733 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajoute platform.archived_at : sortie de scène réversible d\'une plateforme.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE platform ADD archived_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE platform DROP archived_at');
}
}

2577
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "tq-slator-admin",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-virtual": "^3.11.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^26.2.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

17
phpstan.neon Normal file
View file

@ -0,0 +1,17 @@
parameters:
level: 8
paths:
- src
- tests
excludePaths:
- src/Kernel.php
# Le niveau 8 est le palier utile sur un projet Doctrine : il couvre les
# types nullables, qui sont la source réelle de bugs ici (une traduction sans
# valeur, un namespace racine, un auteur supprimé). Le niveau 9 n'ajoute que
# la traque des `mixed`, dont l'essentiel provient des colonnes JSON — pour
# un bénéfice faible et beaucoup de bruit. À rehausser une fois le moteur de
# format en place, où la précision de type compte davantage.
doctrine:
objectManagerLoader: tests/object-manager.php

44
phpunit.dist.xml Normal file
View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
</extensions>
</phpunit>

9
public/index.php Normal file
View file

@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Environment;
use App\Entity\Platform;
use App\Entity\Project;
use App\Entity\ProjectMember;
use App\Entity\Release;
use App\Entity\TranslationKey;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Restreint toute collection à ce que l'utilisateur a le droit de voir.
*
* Le contrôle se fait ici, dans le SQL, et non dans chaque opération. La raison
* est simple : une règle de sécurité qu'il faut penser à répéter finit par être
* oubliée, et l'oubli ne se voit pas — la ressource s'affiche, simplement pour
* trop de monde. Placée dans l'extension, la restriction s'applique aux
* opérations existantes comme à celles qui n'ont pas encore été écrites.
*
* Le principe retenu est strict : un utilisateur non membre d'un projet n'en
* voit RIEN, pas même l'existence. Le nom d'un produit avant son annonce est en
* soi une information, et la liste des projets d'une organisation en dit long
* sur sa feuille de route.
*
* L'extension s'applique aussi aux requêtes UNITAIRES, et c'est important : sans
* cela, un non-membre reçoit 403 sur `/projects/{uuid}`, ce qui lui confirme que
* le projet existe. Avec, il reçoit 404 l'absence et l'interdiction deviennent
* indiscernables, ce qui est le seul comportement cohérent avec le principe
* ci-dessus.
*
* Cela ne remplace pas les Voters, qui restent le contrôle primaire sur les
* ACTIONS : ceci décide de ce qu'on peut voir, ceux-là de ce qu'on peut faire.
* Un viewer voit un projet (200) mais ne peut pas le modifier (403), et cette
* distinction- est légitime.
*/
final readonly class ProjectVisibilityExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
/**
* Chemin de propriété menant au projet, par classe de ressource.
*
* Une entité absente de cette table n'est PAS filtrée. C'est délibérément
* une liste explicite plutôt qu'une découverte automatique : exposer une
* nouvelle ressource doit obliger à se demander qui a le droit de la voir.
*/
private const PROJECT_PATH = [
Project::class => null,
Platform::class => 'project',
Environment::class => 'project',
TranslationKey::class => 'project',
ProjectMember::class => 'project',
Release::class => 'project',
];
public function __construct(private Security $security)
{
}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->restrict($queryBuilder, $queryNameGenerator, $resourceClass);
}
/**
* @param array<string, mixed> $identifiers
* @param array<string, mixed> $context
*/
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
$this->restrict($queryBuilder, $queryNameGenerator, $resourceClass);
}
private function restrict(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
): void {
if (!\array_key_exists($resourceClass, self::PROJECT_PATH)) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
// Ni utilisateur du back-office, ni contexte connu : on ne renvoie
// rien. Se taire est le comportement sûr par défaut ; laisser passer
// « au cas où » est la façon habituelle dont fuit une donnée.
$queryBuilder->andWhere('1 = 0');
return;
}
if ($user->isSuperAdmin()) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$path = self::PROJECT_PATH[$resourceClass];
if (null === $path) {
$projectAlias = $rootAlias;
} else {
$projectAlias = $queryNameGenerator->generateJoinAlias('project');
$queryBuilder->join(sprintf('%s.%s', $rootAlias, $path), $projectAlias);
}
$memberAlias = $queryNameGenerator->generateJoinAlias('member');
$userParameter = $queryNameGenerator->generateParameterName('currentUser');
// EXISTS plutôt qu'une jointure sur la collection des membres : une
// jointure dupliquerait les lignes du projet autant qu'il a de membres,
// ce qui fausserait la pagination de la grille.
$queryBuilder
->andWhere(sprintf(
'EXISTS (SELECT 1 FROM %s %s WHERE %s.project = %s AND %s.user = :%s)',
ProjectMember::class,
$memberAlias,
$memberAlias,
$projectAlias,
$memberAlias,
$userParameter,
))
->setParameter($userParameter, $user);
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Put;
use App\ApiPlatform\State\EnvironmentDeploymentProcessor;
use App\ApiPlatform\State\EnvironmentDeploymentProvider;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Pointe un environnement sur une release donnée.
*
* **C'est tout ce qu'est un déploiement, et tout ce qu'est un rollback.** Aucune
* donnée n'est réécrite, aucun fichier n'est régénéré : on déplace un pointeur.
* L'opération est instantanée, réversible, et symétrique revenir en arrière
* emprunte exactement le même chemin qu'avancer.
*
* C'est la propriété que la publication continue ne peut pas offrir : sans
* release immuable, « revenir à hier » signifie retrouver et ressaisir ce qui a
* changé.
*/
#[ApiResource(
shortName: 'EnvironmentDeployment',
operations: [
new Put(
uriTemplate: '/environments/{environmentUuid}/release',
uriVariables: ['environmentUuid'],
provider: EnvironmentDeploymentProvider::class,
processor: EnvironmentDeploymentProcessor::class,
status: 200,
openapi: new \ApiPlatform\OpenApi\Model\Operation(
summary: 'Déployer une release sur un environnement (ou revenir en arrière)',
description: <<<'TXT'
Fait servir la release indiquée par cet environnement.
Indiquez `version` (le numéro entier, ex. `42`). La release doit
appartenir au même projet et être publiée.
Le rollback n'a pas d'endpoint distinct : redéployer une version
antérieure EST le rollback. L'opération est instantanée aucun
fichier n'est régénéré, seul un pointeur change.
La propagation aux applications clientes prend au plus la durée du
`max-age` de la Delivery API (300 s), puisque le pointeur n'est pas
caché mais que les fichiers le sont.
TXT,
),
),
],
normalizationContext: ['groups' => ['deployment:read'], 'skip_null_values' => false],
denormalizationContext: ['groups' => ['deployment:write']],
)]
final class EnvironmentDeployment
{
#[ApiProperty(identifier: true, writable: false)]
#[Groups(['deployment:read'])]
public string $id = 'deployment';
/**
* Numéro de la release à servir.
*/
#[Groups(['deployment:write', 'deployment:read'])]
public ?int $version = null;
#[Groups(['deployment:read'])]
public ?string $environment = null;
/**
* Release servie avant l'opération. Renvoyée pour que l'appelant sache vers
* quoi revenir sans avoir à la retrouver.
*/
#[Groups(['deployment:read'])]
public ?int $previousVersion = null;
#[Groups(['deployment:read'])]
public ?string $summary = null;
}

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use App\Entity\Project;
use App\ApiPlatform\State\GridProvider;
use App\Enum\TranslationStatus;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Une ligne de l'éditeur : la clé, sa valeur source, sa valeur cible.
*
* DTO et non entité, pour une raison de fond : l'éditeur n'affiche pas des
* entités, il affiche une CONFRONTATION entre deux langues. Exposer
* TranslationKey obligerait le front à recharger les traductions séparément
* soit N+1 requêtes sur une liste virtualisée de trente mille lignes.
*
* Tout ce dont le panneau de contexte a besoin est déjà : description,
* capture, longueur max, placeholders, plateformes. Une seule requête suffit à
* peindre l'écran complet.
*/
#[ApiResource(
shortName: 'GridRow',
operations: [
new GetCollection(
uriTemplate: '/projects/{projectUuid}/grid',
// GridRow n'est pas une entité Doctrine : la variable d'URI doit être
// déclarée explicitement, sinon API Platform ne sait pas la résoudre
// et répond « Invalid uri variables ».
uriVariables: [
'projectUuid' => new Link(fromClass: Project::class, identifiers: ['uuid'], parameterName: 'projectUuid'),
],
provider: GridProvider::class,
paginationClientItemsPerPage: true,
openapi: new \ApiPlatform\OpenApi\Model\Operation(
summary: 'Grille de l\'éditeur : clés, valeur source et valeur cible',
description: <<<'TXT'
Requête centrale du back-office. Retourne en UNE fois la clé, sa valeur
dans la langue source du projet et sa valeur dans la langue demandée.
Filtres (tous cumulables et repris dans l'URL, donc partageables) :
- `locale` code BCP-47 de la langue cible. **Obligatoire.**
- `platform` slug de plateforme. Absent = toutes.
- `status` `untranslated`, `draft`, `translated`, `needs_review`, `reviewed`.
- `namespace` préfixe ; inclut les descendants (`cart` couvre `cart.items`).
- `q` recherche libre sur le chemin, la description et les deux valeurs.
- `unassigned` `true` pour n'obtenir que les clés rattachées à aucune
plateforme, celles qui n'entrent dans aucun bundle.
TXT,
),
),
],
// skip_null_values désactivé : le front doit distinguer « champ absent » de
// « valeur nulle ». Une ligne sans traduction cible doit porter
// targetValue: null, pas omettre la clé — sinon la grille interprète
// l'absence comme une erreur de chargement.
normalizationContext: ['groups' => ['grid:read'], 'skip_null_values' => false],
paginationItemsPerPage: 100,
)]
final readonly class GridRow
{
/**
* @param list<string> $platforms slugs
* @param list<array{name: string, type: string, example?: string}> $placeholders
*/
public function __construct(
#[ApiProperty(identifier: true)]
#[Groups(['grid:read'])]
public string $id,
#[Groups(['grid:read'])]
public string $keyPath,
#[Groups(['grid:read'])]
public ?string $namespace,
#[Groups(['grid:read'])]
public ?string $description,
#[Groups(['grid:read'])]
public ?int $maxLength,
#[Groups(['grid:read'])]
public array $placeholders,
#[Groups(['grid:read'])]
public array $platforms,
/**
* Valeur source, en ICU canonique. Lecture seule dans l'éditeur : elle se
* modifie depuis la langue source, jamais depuis une langue cible.
*/
#[Groups(['grid:read'])]
public ?string $sourceValue,
#[Groups(['grid:read'])]
public ?string $targetValue,
#[Groups(['grid:read'])]
public TranslationStatus $status,
/**
* La source a changé depuis l'écriture de cette traduction.
*
* Redondant avec `status === needs_review` aujourd'hui, et volontairement :
* le statut peut être remis à la main par un relecteur, alors que ce
* drapeau reste le fait objectif. L'éditeur affiche l'un, les tableaux de
* bord comptent l'autre.
*/
#[Groups(['grid:read'])]
public bool $isStale,
#[Groups(['grid:read'])]
public bool $isMachineTranslated,
#[Groups(['grid:read'])]
public ?string $updatedAt,
#[Groups(['grid:read'])]
public ?string $updatedBy,
/**
* Verrou optimiste. Le front doit le renvoyer à l'écriture : c'est ce qui
* empêche deux traducteurs travaillant la même clé d'écraser mutuellement
* leur travail sans s'en apercevoir.
*/
#[Groups(['grid:read'])]
public int $version,
) {
}
}

View file

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use App\ApiPlatform\State\KeyRowProvider;
use App\Entity\Project;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Une clé et l'état de TOUTES ses langues.
*
* Le pendant de GridRow, pour l'autre façon de regarder le même contenu.
* GridRow confronte deux langues et répond à « en est mon travail en
* espagnol ? ». KeyRow prend une clé et répond à « ce libellé est-il prêt
* partout ? » la question du responsable de localisation avant une mise en
* production, et celle du développeur qui vient d'ajouter une clé.
*
* Aucune des deux vues ne remplace l'autre, et c'est pourquoi les deux existent
* plutôt qu'un compromis qui servirait mal les deux publics.
*/
#[ApiResource(
shortName: 'KeyRow',
operations: [
new GetCollection(
uriTemplate: '/projects/{projectUuid}/keys',
uriVariables: [
'projectUuid' => new Link(fromClass: Project::class, identifiers: ['uuid'], parameterName: 'projectUuid'),
],
provider: KeyRowProvider::class,
paginationClientItemsPerPage: true,
openapi: new \ApiPlatform\OpenApi\Model\Operation(
summary: 'Vue par clé : une clé, toutes ses langues',
description: <<<'TXT'
Retourne chaque clé avec sa valeur source et l'état de chacune des langues
cibles du projet, en une seule requête.
Filtres, identiques à ceux de la grille à une exception près :
- `platform` slug de plateforme. Absent = toutes.
- `status` **« au moins une langue cible est dans cet état »**, et non
« la langue demandée est dans cet état ». C'est la seule lecture qui ait
un sens quand la ligne porte toutes les langues à la fois.
- `namespace` préfixe ; inclut les descendants.
- `q` recherche sur le chemin, la description et la valeur dans
**n'importe quelle** langue.
- `unassigned` clés rattachées à aucune plateforme.
Pas de paramètre `locale` : la réponse les contient toutes.
La pagination est plus courte que celle de la grille (50 par défaut) :
chaque ligne porte ici autant d'entrées qu'il y a de langues.
TXT,
),
),
],
normalizationContext: ['groups' => ['keyrow:read'], 'skip_null_values' => false],
paginationItemsPerPage: 50,
)]
final readonly class KeyRow
{
/**
* @param list<string> $platforms slugs
* @param list<array{name: string, type: string, example?: string}> $placeholders
* @param list<KeyRowTarget> $targets
*/
public function __construct(
#[ApiProperty(identifier: true)]
#[Groups(['keyrow:read'])]
public string $id,
#[Groups(['keyrow:read'])]
public string $keyPath,
#[Groups(['keyrow:read'])]
public ?string $namespace,
#[Groups(['keyrow:read'])]
public ?string $description,
#[Groups(['keyrow:read'])]
public ?int $maxLength,
#[Groups(['keyrow:read'])]
public array $placeholders,
#[Groups(['keyrow:read'])]
public array $platforms,
#[Groups(['keyrow:read'])]
public ?string $sourceValue,
/**
* Une entrée par langue cible ACTIVÉE sur le projet, dans l'ordre du
* projet. Les langues sans traduction y figurent avec le statut
* `untranslated` : les omettre ferait disparaître de l'écran
* précisément le travail qui reste à faire.
*/
#[Groups(['keyrow:read'])]
public array $targets,
/**
* Part des langues cibles dans un état publiable, en pourcentage.
*
* Calculée ici et non côté client : c'est la même règle que celle qui
* décide ce qui entre dans un bundle, et la dupliquer dans le SPA
* garantirait qu'elles divergent un jour.
*/
#[Groups(['keyrow:read'])]
public int $completion,
) {
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Resource;
use App\Enum\TranslationStatus;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* L'état d'une clé dans une langue, tel qu'affiché en vue par clé.
*
* Volontairement plus maigre que GridRow : la description, les placeholders et
* les plateformes appartiennent à la clé, pas à chaque langue. Les répéter sept
* fois par ligne multiplierait le poids de la réponse sans rien apprendre.
*/
final readonly class KeyRowTarget
{
public function __construct(
#[Groups(['keyrow:read'])]
public string $locale,
#[Groups(['keyrow:read'])]
public ?string $value,
#[Groups(['keyrow:read'])]
public TranslationStatus $status,
#[Groups(['keyrow:read'])]
public bool $isStale,
#[Groups(['keyrow:read'])]
public bool $isMachineTranslated,
#[Groups(['keyrow:read'])]
public ?string $updatedAt,
#[Groups(['keyrow:read'])]
public ?string $updatedBy,
/**
* Verrou optimiste, par langue.
*
* Chaque langue a le sien : deux personnes travaillant la même clé dans
* deux langues différentes ne se gênent pas, alors qu'un verrou porté
* par la clé les mettrait en conflit sans raison.
*/
#[Groups(['keyrow:read'])]
public int $version,
) {
}
}

View file

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\ApiPlatform\State\KeySyncProcessor;
use App\Translation\Sync\SyncReport;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Synchronisation de l'inventaire de clés d'une plateforme.
*
* C'est l'endpoint que le CI d'une application cliente appelle à chaque build.
* Il est conçu autour d'une seule idée : **on doit pouvoir l'appeler sans
* réfléchir**. Il est idempotent, non destructif par défaut, et une clé fautive
* n'empêche pas les autres d'aboutir.
*/
#[ApiResource(
shortName: 'KeySync',
operations: [
new Post(
uriTemplate: '/projects/{projectUuid}/platforms/{platformSlug}/sync',
uriVariables: ['projectUuid', 'platformSlug'],
processor: KeySyncProcessor::class,
// 200 et non le 201 par défaut d'un POST : cet endpoint ne crée pas
// de ressource adressable, il exécute une commande et renvoie un
// rapport. Un 201 sans en-tête Location désoriente les clients HTTP.
status: 200,
openapi: new \ApiPlatform\OpenApi\Model\Operation(
summary: 'Pousser l\'inventaire des clés d\'une plateforme',
description: <<<'TXT'
Envoie les clés détectées dans le code source d'une plateforme et
renvoie le diff résultant.
**Toujours commencer par `dryRun: true`.** La réponse a exactement la
même forme, sans rien écrire.
Les valeurs sont attendues dans le format natif de la plateforme
(`messageFormat`), pas en ICU : une application i18next envoie ses
clés suffixées `_one` / `_other`, elles seront réunies en un unique
message canonique.
**Suppression.** Sans `prune`, aucune clé n'est jamais retirée un
push depuis une branche incomplète ne peut pas amputer le projet. Avec
`prune: true`, les clés absentes du push perdent leur rattachement à
CETTE plateforme uniquement ; elles ne sont archivées que si plus
aucune plateforme ne les référence.
**Erreurs partielles.** Une clé de syntaxe invalide est rapportée dans
`errors` et ignorée ; les autres sont appliquées. Le code de réponse
reste 200.
TXT,
),
),
],
normalizationContext: ['groups' => ['sync:read'], 'skip_null_values' => false],
denormalizationContext: ['groups' => ['sync:write']],
)]
final class KeySync
{
#[ApiProperty(identifier: true, writable: false)]
#[Groups(['sync:read'])]
public string $id = 'sync';
/**
* Inventaire des clés, dans le format natif de la plateforme.
*
* Deux formes acceptées pour chaque valeur une chaîne nue quand il n'y a
* rien d'autre à dire, un objet quand le développeur peut fournir du
* contexte :
*
* ```json
* {
* "cart.empty_state": "Votre panier est vide",
* "cart.checkout_cta": {
* "value": "Passer commande",
* "description": "Bouton principal du panier, sous la liste.",
* "maxLength": 24
* }
* }
* ```
*
* La `description` est ce qui sépare une bonne traduction d'une mauvaise.
* L'omettre est possible, mais c'est reporter le coût sur la traductrice.
*
* @var array<string, string|array{value: string, description?: string|null, maxLength?: int|null}>
*/
#[ApiProperty(
openapiContext: [
'type' => 'object',
'example' => [
'cart.empty_state' => 'Votre panier est vide',
'cart.items_one' => '{{count}} article',
'cart.items_other' => '{{count}} articles',
'cart.checkout_cta' => [
'value' => 'Passer commande',
'description' => 'Bouton principal du panier.',
'maxLength' => 24,
],
],
],
)]
#[Groups(['sync:write'])]
public array $keys = [];
/**
* Retire de cette plateforme les clés absentes du push.
*
* Faux par défaut, et ce défaut n'est pas négociable : une suppression
* accidentelle de traductions coûte des jours de travail humain.
*/
#[Groups(['sync:write'])]
public bool $prune = false;
/**
* Calcule et renvoie le diff sans rien écrire.
*/
#[Groups(['sync:write'])]
public bool $dryRun = false;
// ── Résultat ──────────────────────────────────────────────────────────
#[Groups(['sync:read'])]
public ?string $platform = null;
#[Groups(['sync:read'])]
public ?bool $simulated = null;
/**
* Résumé en une ligne, tel que le CLI l'affiche.
*/
#[Groups(['sync:read'])]
public ?string $summary = null;
/**
* @var list<string>
*/
#[Groups(['sync:read'])]
public array $added = [];
/**
* @var list<string>
*/
#[Groups(['sync:read'])]
public array $updated = [];
#[Groups(['sync:read'])]
public int $unchanged = 0;
/**
* Clés présentes en base pour cette plateforme et absentes du push.
*
* Renvoyées même sans `prune` : c'est précisément ce qu'un développeur veut
* voir avant de décider de supprimer.
*
* @var list<string>
*/
#[Groups(['sync:read'])]
public array $orphaned = [];
/**
* @var list<string>
*/
#[Groups(['sync:read'])]
public array $pruned = [];
/**
* Nombre de traductions cibles devenues obsolètes du fait de ce push.
*
* Un chiffre élevé mérite un message à l'équipe de traduction : leur travail
* vient d'être invalidé.
*/
#[Groups(['sync:read'])]
public int $flagged = 0;
/**
* @var list<array{key: string, message: string}>
*/
#[Groups(['sync:read'])]
public array $errors = [];
public function fill(SyncReport $report): self
{
$this->platform = $report->platform;
$this->simulated = $report->dryRun;
$this->summary = $report->summary();
$this->added = $report->added;
$this->updated = $report->updated;
$this->unchanged = $report->unchanged;
$this->orphaned = $report->orphaned;
$this->pruned = $report->pruned;
$this->flagged = $report->flagged;
$this->errors = $report->errors;
return $this;
}
}

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\ApiPlatform\State\ReleaseDiffProvider;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Ce qui a changé entre deux releases.
*
* Répond à la question qu'un développeur se pose avant de livrer — « qu'est-ce
* qui bouge si je passe de v42 à v43 ? » et à laquelle un historique de
* commits ne répond pas : les traductions changent hors du dépôt.
*
* La comparaison porte sur les FICHIERS RÉELLEMENT PRODUITS, pas sur les clés en
* base. C'est ce qui compte : une clé modifiée en base mais non publiable
* n'apparaît pas, et un simple changement de repli la même clé qui bascule du
* français vers l'espagnol — apparaît, parce que l'utilisateur final le verra.
*/
#[ApiResource(
shortName: 'ReleaseDiff',
operations: [
new Get(
uriTemplate: '/releases/{fromUuid}/diff/{toUuid}',
uriVariables: ['fromUuid', 'toUuid'],
provider: ReleaseDiffProvider::class,
openapi: new \ApiPlatform\OpenApi\Model\Operation(
summary: 'Comparer deux releases',
description: <<<'TXT'
Compare les fichiers produits par deux releases, par couple
(plateforme, langue).
Trois catégories : `added` (clé absente de la précédente),
`changed` (valeur différente), `removed` (clé disparue). Un
changement de repli compte comme une modification : c'est un texte
différent pour l'utilisateur final.
TXT,
),
),
],
normalizationContext: ['groups' => ['diff:read'], 'skip_null_values' => false],
)]
final class ReleaseDiff
{
#[ApiProperty(identifier: true, writable: false)]
#[Groups(['diff:read'])]
public string $id = '';
#[Groups(['diff:read'])]
public string $from = '';
#[Groups(['diff:read'])]
public string $to = '';
/**
* Résumé global : nombre total de clés touchées, tous fichiers confondus.
*/
#[Groups(['diff:read'])]
public string $summary = '';
#[Groups(['diff:read'])]
public int $addedCount = 0;
#[Groups(['diff:read'])]
public int $changedCount = 0;
#[Groups(['diff:read'])]
public int $removedCount = 0;
/**
* Détail par fichier. Les fichiers inchangés sont omis : sur vingt-quatre
* bundles dont deux bougent, lister les vingt-deux autres noie le signal.
*
* @var list<array<string, mixed>>
*/
#[Groups(['diff:read'])]
public array $files = [];
/**
* Fichiers présents dans une release et pas dans l'autre une plateforme ou
* une langue ajoutée ou retirée entre les deux.
*
* @var list<string>
*/
#[Groups(['diff:read'])]
public array $addedFiles = [];
/**
* @var list<string>
*/
#[Groups(['diff:read'])]
public array $removedFiles = [];
}

View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\ApiPlatform\State\ReleasePublicationProcessor;
use App\Entity\Release;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Publication d'une release.
*
* Opération **tout ou rien** : si un seul bundle ne peut pas être construit,
* aucun n'est enregistré et la requête échoue avec la liste complète des
* problèmes. Une release partielle laisserait certaines plateformes à jour et
* d'autres figées, sans que rien ne le signale.
*/
#[ApiResource(
shortName: 'ReleasePublication',
operations: [
new Post(
uriTemplate: '/projects/{projectUuid}/releases/publish',
uriVariables: ['projectUuid'],
processor: ReleasePublicationProcessor::class,
status: 201,
openapi: new \ApiPlatform\OpenApi\Model\Operation(
summary: 'Publier une release',
description: <<<'TXT'
Fige le contenu actuel du projet en un jeu de fichiers immuables,
un par (plateforme, langue).
**Ce qui entre dans les fichiers.** Seules les clés rattachées à la
plateforme, et seules les traductions publiables une clé au statut
`untranslated` est simplement absente. C'est la garantie structurelle
qu'un brouillon ne peut pas atteindre la production.
**Replis.** Une clé sans traduction descend la chaîne de repli de sa
langue, puis retombe sur la langue source. Le compteur `fallbackCount`
de chaque fichier dit combien de clés sont dans ce cas : une langue
« livrée » avec deux cents replis affiche en réalité du français.
**Échec.** Une construction non exprimable dans le format d'une
plateforme un `select` de genre vers i18next, par exemple fait
échouer la publication entière avec un message indiquant quoi corriger.
Rien n'est enregistré.
Publier ne déploie pas : un environnement continue de servir sa release
courante jusqu'à ce qu'on l'y pointe explicitement.
TXT,
),
),
],
normalizationContext: ['groups' => ['publication:read'], 'skip_null_values' => false],
denormalizationContext: ['groups' => ['publication:write']],
)]
final class ReleasePublication
{
#[ApiProperty(identifier: true, writable: false)]
#[Groups(['publication:read'])]
public string $id = 'publication';
/**
* Note de version, libre. Sert au diff et à l'historique.
*/
#[Groups(['publication:write', 'publication:read'])]
public ?string $notes = null;
/**
* Déploie immédiatement la release sur ces environnements (slugs).
*
* Vide par défaut : publier et déployer sont deux gestes distincts. Les
* confondre retirerait la possibilité de préparer une livraison à l'avance,
* qui est la raison d'être des releases.
*
* @var list<string>
*/
#[Groups(['publication:write'])]
public array $deployTo = [];
#[Groups(['publication:read'])]
public ?int $version = null;
#[Groups(['publication:read'])]
public ?string $label = null;
#[Groups(['publication:read'])]
public ?string $publishedAt = null;
#[Groups(['publication:read'])]
public int $keyCount = 0;
/**
* @var list<array<string, mixed>>
*/
#[Groups(['publication:read'])]
public array $files = [];
/**
* Environnements effectivement repointés sur cette release.
*
* @var list<string>
*/
#[Groups(['publication:read'])]
public array $deployed = [];
/**
* Releases anciennes supprimées par la politique de rétention.
*/
#[Groups(['publication:read'])]
public int $purged = 0;
public function fill(Release $release): self
{
$this->version = $release->getVersion();
$this->label = $release->getLabel();
$this->publishedAt = $release->getPublishedAt()?->format(\DATE_ATOM);
$this->keyCount = $release->getKeyCount();
$this->files = $release->getFiles();
return $this;
}
}

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Patch;
use App\ApiPlatform\State\TranslationEntryProcessor;
use App\ApiPlatform\State\TranslationEntryProvider;
use App\Enum\TranslationStatus;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Une traduction, adressée par sa clé et sa langue.
*
* L'URI est `/keys/{keyUuid}/translations/{localeCode}` plutôt que
* `/translations/{id}`, et ce n'est pas cosmétique : l'éditeur connaît toujours
* la clé et la langue qu'il affiche, jamais un identifiant opaque de ligne. Une
* URI par identifiant l'obligerait à un aller-retour préalable pour chaque
* cellule modifiée sur une grille virtualisée, c'est rédhibitoire.
*
* Corollaire utile : l'URI est stable et devinable. Un lien vers une traduction
* se construit de tête, y compris pour une clé pas encore traduite, dont la
* ligne existe déjà avec le statut `untranslated`.
*/
#[ApiResource(
shortName: 'TranslationEntry',
operations: [
new Get(
uriTemplate: '/keys/{keyUuid}/translations/{localeCode}',
uriVariables: ['keyUuid', 'localeCode'],
provider: TranslationEntryProvider::class,
),
new Patch(
uriTemplate: '/keys/{keyUuid}/translations/{localeCode}',
uriVariables: ['keyUuid', 'localeCode'],
provider: TranslationEntryProvider::class,
processor: TranslationEntryProcessor::class,
openapi: new \ApiPlatform\OpenApi\Model\Operation(
summary: 'Écrire une traduction',
description: <<<'TXT'
La valeur est attendue en **ICU MessageFormat canonique**, y compris pour
les formes plurielles : c'est le format de stockage, indépendant de ce
que consomment les plateformes.
Elle est validée contre la source avant écriture :
- **variable manquante ou inventée** 422, rien n'est écrit. Une
traduction qui perd `{prenom}` ou en invente une casse l'application
cliente ;
- **catégorie CLDR manquante** dans la langue cible 422. L'arabe en
exige six ;
- **dépassement de `maxLength`** écrit, avec un avertissement dans
`warnings`. Un libellé trop long est tronqué à l'écran, il ne met rien
en panne.
`version` implémente le verrouillage optimiste. Renvoyez celle reçue à la
lecture ; une réponse **409** signifie que quelqu'un d'autre a modifié la
traduction entre-temps.
Écrire dans la LANGUE SOURCE bascule automatiquement en `needs_review`
toutes les traductions cibles qui en dérivaient.
TXT,
),
),
],
normalizationContext: ['groups' => ['translation:read'], 'skip_null_values' => false],
denormalizationContext: ['groups' => ['translation:write']],
)]
final class TranslationEntry
{
#[ApiProperty(identifier: true, writable: false)]
#[Groups(['translation:read'])]
public string $id = '';
#[Groups(['translation:read'])]
public string $keyPath = '';
#[Groups(['translation:read'])]
public string $locale = '';
/**
* Valeur source, en lecture seule : elle se modifie depuis la langue source.
*/
#[Groups(['translation:read'])]
public ?string $sourceValue = null;
/**
* Valeur canonique ICU. `null` remet la traduction à l'état « à faire ».
*/
#[Groups(['translation:read', 'translation:write'])]
public ?string $value = null;
#[Groups(['translation:read', 'translation:write'])]
public ?TranslationStatus $status = null;
/**
* Verrou optimiste. Omis, l'écriture passe en force pratique pour un
* script d'import, dangereux depuis une interface.
*/
#[Groups(['translation:write'])]
public ?int $version = null;
#[Groups(['translation:read'])]
public int $currentVersion = 0;
#[Groups(['translation:read'])]
public bool $isStale = false;
#[Groups(['translation:read'])]
public bool $isMachineTranslated = false;
#[Groups(['translation:read'])]
public ?string $updatedAt = null;
#[Groups(['translation:read'])]
public ?string $updatedBy = null;
/**
* Catégories CLDR attendues dans cette langue.
*
* Renvoyées avec la traduction pour que l'éditeur construise exactement les
* champs de formes plurielles requis, sans avoir à connaître le CLDR.
*
* @var list<string>
*/
#[Groups(['translation:read'])]
public array $pluralCategories = [];
/**
* Avertissements non bloquants : dépassement de longueur, forme plurielle
* inutile dans cette langue.
*
* @var list<array{severity: string, code: string, message: string}>
*/
#[Groups(['translation:read'])]
public array $warnings = [];
/**
* Nombre de traductions cibles basculées en `needs_review` par cette
* écriture. Non nul uniquement quand on écrit dans la langue source.
*/
#[Groups(['translation:read'])]
public int $flaggedForReview = 0;
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiPlatform\Resource\EnvironmentDeployment;
use App\Entity\Environment;
use App\Repository\ReleaseRepository;
use App\Security\Voter\ProjectVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @implements ProcessorInterface<EnvironmentDeployment, EnvironmentDeployment>
*/
final readonly class EnvironmentDeploymentProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private ReleaseRepository $releases,
private Security $security,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EnvironmentDeployment
{
$environment = $this->entityManager->getRepository(Environment::class)
->findOneBy(['uuid' => (string) ($uriVariables['environmentUuid'] ?? '')]);
if (!$environment instanceof Environment
|| !$this->security->isGranted(ProjectVoter::VIEW, $environment->getProject())) {
throw new NotFoundHttpException('Environnement introuvable.');
}
if (!$this->security->isGranted(ProjectVoter::PUBLISH, $environment->getProject())) {
throw new AccessDeniedHttpException(
'Déploiement non autorisé. Un rôle « developer », « admin » ou « owner » est requis, '
.'ou une clé API portant le scope « releases:publish ».',
);
}
if (null === $data->version) {
throw new UnprocessableEntityHttpException('Le champ « version » est obligatoire.');
}
$release = $this->releases->findOneBy([
'project' => $environment->getProject(),
'version' => $data->version,
]);
if (null === $release) {
throw new UnprocessableEntityHttpException(sprintf(
'Aucune release v%d sur ce projet.',
$data->version,
));
}
if (!$release->isPublished()) {
// Une release non scellée n'a pas de bundles complets : la déployer
// servirait des fichiers partiels.
throw new UnprocessableEntityHttpException(sprintf(
'La release v%d n\'est pas publiée : elle ne peut pas être déployée.',
$data->version,
));
}
$previous = $environment->getCurrentRelease();
$environment->setCurrentRelease($release);
$this->entityManager->flush();
$data->environment = $environment->getSlug();
$data->previousVersion = $previous?->getVersion();
$data->summary = sprintf(
'%s : %s → %s',
$environment->getSlug(),
null === $previous ? 'aucune release' : $previous->getLabel(),
$release->getLabel(),
);
return $data;
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiPlatform\Resource\EnvironmentDeployment;
/**
* Fournit l'objet vierge que le corps de la requête vient remplir.
*
* Nécessaire parce qu'un PUT est, pour API Platform, la mise à jour d'une
* ressource existante : sans provider, il tente de la charger, échoue, et
* répond 404 sans jamais atteindre le processeur. Or « déployer » n'est pas la
* modification d'un objet stocké — c'est une commande dont PUT exprime
* simplement l'idempotence.
*
* @implements ProviderInterface<EnvironmentDeployment>
*/
final readonly class EnvironmentDeploymentProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EnvironmentDeployment
{
return new EnvironmentDeployment();
}
}

View file

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\ApiPlatform\Resource\GridRow;
use App\Entity\Locale;
use App\Entity\Platform;
use App\Entity\Project;
use App\Entity\Translation;
use App\Entity\TranslationKey;
use App\Enum\TranslationStatus;
use App\Repository\LocaleRepository;
use App\Repository\ProjectRepository;
use App\Repository\TranslationKeyRepository;
use App\Security\Voter\ProjectVoter;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Alimente la grille de l'éditeur.
*
* Ce fournisseur contourne ProjectVisibilityExtension : elle ne s'applique
* qu'aux ressources Doctrine, et GridRow n'en est pas une. Le contrôle d'accès
* est donc EXPLICITE ci-dessous. Tout nouveau fournisseur sur un DTO doit faire
* de même c'est le prix de la souplesse des DTO.
*
* @implements ProviderInterface<GridRow>
*/
final readonly class GridProvider implements ProviderInterface
{
public function __construct(
private ProjectRepository $projects,
private LocaleRepository $locales,
private TranslationKeyRepository $keys,
private RequestStack $requestStack,
private Security $security,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new \LogicException('La grille ne peut être servie qu\'en contexte HTTP.');
}
$project = $this->resolveProject((string) ($uriVariables['projectUuid'] ?? ''));
$targetLocale = $this->resolveTargetLocale($request->query->getString('locale'), $project);
$platform = $this->resolvePlatform($request->query->getString('platform'), $project);
$status = $this->resolveStatus($request->query->getString('status'));
$namespaces = array_values(array_filter(array_map(
trim(...),
explode(',', $request->query->getString('namespace')),
)));
$search = $request->query->getString('q');
$unassignedOnly = $request->query->getBoolean('unassigned');
$includeArchived = $request->query->getBoolean('archived');
$itemsPerPage = min(500, max(1, $request->query->getInt('itemsPerPage', 100)));
$page = max(1, $request->query->getInt('page', 1));
$offset = ($page - 1) * $itemsPerPage;
$total = $this->keys->countForGrid(
$project, $targetLocale, $platform, $status, $namespaces, $search, $unassignedOnly, $includeArchived,
);
$keys = $this->keys->findForGrid(
$project, $targetLocale, $platform, $status, $namespaces, $search, $unassignedOnly, $includeArchived,
$offset, $itemsPerPage,
);
$rows = array_map(
fn (TranslationKey $key): GridRow => $this->toRow($key, $project, $targetLocale),
$keys,
);
return new TraversablePaginator(new \ArrayIterator($rows), $page, $itemsPerPage, $total);
}
private function resolveProject(string $uuid): Project
{
$project = $this->projects->findOneByUuidOrSlug($uuid);
// 404 et non 403 pour un projet existant mais non autorisé : voir
// ProjectVisibilityExtension, l'absence et l'interdiction doivent rester
// indiscernables.
if (null === $project || !$this->security->isGranted(ProjectVoter::VIEW, $project)) {
throw new NotFoundHttpException('Projet introuvable.');
}
return $project;
}
private function resolveTargetLocale(string $code, Project $project): Locale
{
if ('' === $code) {
throw new UnprocessableEntityHttpException(
'Le paramètre « locale » est obligatoire : la grille confronte toujours deux langues.',
);
}
$locale = $this->locales->findOneByCode($code);
if (null === $locale) {
throw new UnprocessableEntityHttpException(sprintf('Langue « %s » inconnue.', $code));
}
$enabled = array_map(
static fn (Locale $l): string => $l->getCode(),
[...$project->getTargetLocales(), $project->getSourceLocale()],
);
if (!\in_array($locale->getCode(), $enabled, true)) {
throw new UnprocessableEntityHttpException(sprintf(
'La langue « %s » n\'est pas activée sur ce projet. Langues actives : %s.',
$code,
implode(', ', $enabled),
));
}
return $locale;
}
private function resolvePlatform(string $slug, Project $project): ?Platform
{
if ('' === $slug) {
return null;
}
// Les archivées restent filtrables, contrairement au reste de
// l'application. Consulter ce qui était rattaché à une plateforme
// retirée est légitime — et un lien Slack envoyé la semaine dernière
// doit continuer d'ouvrir quelque chose plutôt qu'une erreur.
foreach ($project->getPlatforms() as $platform) {
if ($platform->getSlug() === $slug) {
return $platform;
}
}
throw new UnprocessableEntityHttpException(sprintf('Plateforme « %s » inconnue sur ce projet.', $slug));
}
private function resolveStatus(string $status): ?TranslationStatus
{
if ('' === $status) {
return null;
}
return TranslationStatus::tryFrom($status) ?? throw new UnprocessableEntityHttpException(sprintf(
'Statut « %s » inconnu. Valeurs possibles : %s.',
$status,
implode(', ', array_column(TranslationStatus::cases(), 'value')),
));
}
private function toRow(TranslationKey $key, Project $project, Locale $targetLocale): GridRow
{
$source = $key->getTranslation($project->getSourceLocale());
$target = $key->getTranslation($targetLocale);
$platforms = [];
foreach ($key->getPlatforms() as $platform) {
$platforms[] = $platform->getSlug();
}
return new GridRow(
id: (string) $key->getUuid(),
keyPath: $key->getKeyPath(),
namespace: $key->getNamespace()?->getPath(),
description: $key->getDescription(),
maxLength: $key->getMaxLength(),
placeholders: $key->getPlaceholders(),
platforms: $platforms,
sourceValue: $source?->getValue(),
targetValue: $target?->getValue(),
status: $target?->getStatus() ?? TranslationStatus::Untranslated,
isStale: $this->isStale($target, $source),
isMachineTranslated: $target?->isMachineTranslated() ?? false,
updatedAt: $target?->getUpdatedAt()->format(\DATE_ATOM),
updatedBy: $target?->getUpdatedBy()?->getName(),
version: $target?->getVersion() ?? 0,
);
}
private function isStale(?Translation $target, ?Translation $source): bool
{
return null !== $target && $target->isStaleAgainst($source?->getValue());
}
}

View file

@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\ApiPlatform\Resource\KeyRow;
use App\ApiPlatform\Resource\KeyRowTarget;
use App\Entity\Locale;
use App\Entity\Platform;
use App\Entity\Project;
use App\Entity\Translation;
use App\Entity\TranslationKey;
use App\Enum\TranslationStatus;
use App\Repository\ProjectRepository;
use App\Repository\TranslationKeyRepository;
use App\Security\Voter\ProjectVoter;
use App\Security\WritableLocaleResolver;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Alimente la vue par clé.
*
* Comme GridProvider, ce fournisseur contourne ProjectVisibilityExtension
* elle ne s'applique qu'aux ressources Doctrine, et KeyRow n'en est pas une. Le
* contrôle d'accès est donc EXPLICITE ci-dessous.
*
* Le cloisonnement par langue n'est en revanche PAS appliqué à la lecture, et
* c'est délibéré : une traductrice espagnole a le droit de voir l'allemand
* c'est même l'intérêt de cette vue, disposer des autres langues comme
* contexte. L'écriture reste refusée par TranslationVoter, langue par langue.
*
* @implements ProviderInterface<KeyRow>
*/
final readonly class KeyRowProvider implements ProviderInterface
{
public function __construct(
private ProjectRepository $projects,
private TranslationKeyRepository $keys,
private RequestStack $requestStack,
private Security $security,
private WritableLocaleResolver $writableLocales,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new \LogicException('La vue par clé ne peut être servie qu\'en contexte HTTP.');
}
$project = $this->resolveProject((string) ($uriVariables['projectUuid'] ?? ''));
$targetLocales = $project->getTargetLocales();
$platform = $this->resolvePlatform($request->query->getString('platform'), $project);
$status = $this->resolveStatus($request->query->getString('status'));
$namespaces = array_values(array_filter(array_map(
trim(...),
explode(',', $request->query->getString('namespace')),
)));
$search = $request->query->getString('q');
$unassignedOnly = $request->query->getBoolean('unassigned');
$includeArchived = $request->query->getBoolean('archived');
// File du mode Focus. Le sous-ensemble de langues est calculé SERVEUR à
// partir de l'identité courante, jamais reçu du client : une file qu'on
// pourrait demander pour les langues d'un autre serait une fuite
// d'information déguisée en confort.
$actionableIn = $request->query->getBoolean('focus')
? $this->writableLocales->localesFor($project)
: null;
// Plafond plus bas que celui de la grille : chaque ligne porte ici
// autant d'entrées qu'il y a de langues. À 200 clés et 7 langues, la
// réponse dépasserait le mégaoctet pour un écran qui en affiche dix.
$itemsPerPage = min(200, max(1, $request->query->getInt('itemsPerPage', 50)));
$page = max(1, $request->query->getInt('page', 1));
$offset = ($page - 1) * $itemsPerPage;
$total = $this->keys->countForKeyView(
$project, $targetLocales, $platform, $status, $namespaces, $search, $unassignedOnly, $includeArchived,
$actionableIn,
);
$keys = $this->keys->findForKeyView(
$project, $targetLocales, $platform, $status, $namespaces, $search, $unassignedOnly, $includeArchived,
$offset, $itemsPerPage, $actionableIn,
);
$rows = array_map(
fn (TranslationKey $key): KeyRow => $this->toRow($key, $project, $targetLocales),
$keys,
);
return new TraversablePaginator(new \ArrayIterator($rows), $page, $itemsPerPage, $total);
}
private function resolveProject(string $uuid): Project
{
$project = $this->projects->findOneByUuidOrSlug($uuid);
if (null === $project || !$this->security->isGranted(ProjectVoter::VIEW, $project)) {
throw new NotFoundHttpException('Projet introuvable.');
}
return $project;
}
private function resolvePlatform(string $slug, Project $project): ?Platform
{
if ('' === $slug) {
return null;
}
foreach ($project->getPlatforms() as $platform) {
if ($platform->getSlug() === $slug) {
return $platform;
}
}
throw new UnprocessableEntityHttpException(sprintf('Plateforme « %s » inconnue sur ce projet.', $slug));
}
private function resolveStatus(string $status): ?TranslationStatus
{
if ('' === $status) {
return null;
}
return TranslationStatus::tryFrom($status) ?? throw new UnprocessableEntityHttpException(sprintf(
'Statut « %s » inconnu. Valeurs possibles : %s.',
$status,
implode(', ', array_column(TranslationStatus::cases(), 'value')),
));
}
/**
* @param list<Locale> $targetLocales
*/
private function toRow(TranslationKey $key, Project $project, array $targetLocales): KeyRow
{
$source = $key->getTranslation($project->getSourceLocale());
$platforms = [];
foreach ($key->getPlatforms() as $platform) {
$platforms[] = $platform->getSlug();
}
$targets = [];
$publishable = 0;
foreach ($targetLocales as $locale) {
$translation = $key->getTranslation($locale);
$status = $translation?->getStatus() ?? TranslationStatus::Untranslated;
if ($status->isPublishable()) {
++$publishable;
}
$targets[] = new KeyRowTarget(
locale: $locale->getCode(),
value: $translation?->getValue(),
status: $status,
isStale: null !== $translation && $translation->isStaleAgainst($source?->getValue()),
isMachineTranslated: $translation?->isMachineTranslated() ?? false,
updatedAt: $translation?->getUpdatedAt()->format(\DATE_ATOM),
updatedBy: $translation?->getUpdatedBy()?->getName(),
version: $translation?->getVersion() ?? 0,
);
}
return new KeyRow(
id: (string) $key->getUuid(),
keyPath: $key->getKeyPath(),
namespace: $key->getNamespace()?->getPath(),
description: $key->getDescription(),
maxLength: $key->getMaxLength(),
placeholders: $key->getPlaceholders(),
platforms: $platforms,
sourceValue: $source?->getValue(),
targets: $targets,
completion: [] === $targetLocales ? 0 : (int) round(100 * $publishable / \count($targetLocales)),
);
}
}

View file

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiPlatform\Resource\KeySync;
use App\Entity\Platform;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\ProjectRepository;
use App\Security\Voter\ProjectVoter;
use App\Translation\Sync\KeySynchronizer;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Adapte la requête HTTP au service de synchronisation.
*
* Volontairement mince : toute la logique métier vit dans KeySynchronizer, qui
* se teste sans requête ni conteneur. Ce qui reste ici est de la traduction de
* protocole résolution des identifiants, contrôle d'accès, normalisation du
* corps de requête.
*
* @implements ProcessorInterface<KeySync, KeySync>
*/
final readonly class KeySyncProcessor implements ProcessorInterface
{
public function __construct(
private ProjectRepository $projects,
private KeySynchronizer $synchronizer,
private Security $security,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): KeySync
{
// Pas d'assertion de type : le générique @implements ProcessorInterface
// déclare déjà le contrat, et API Platform le respecte.
$project = $this->resolveProject((string) ($uriVariables['projectUuid'] ?? ''));
$platform = $this->resolvePlatform($project, (string) ($uriVariables['platformSlug'] ?? ''));
if (!$this->security->isGranted(ProjectVoter::MANAGE_KEYS, $project)) {
throw new AccessDeniedHttpException(
'Écriture de clés non autorisée. Un rôle « developer », « admin » ou « owner » '
.'est requis, ou une clé API portant le scope « keys:write ».',
);
}
if ([] === $data->keys) {
throw new UnprocessableEntityHttpException(
'Aucune clé fournie. Un push vide est refusé plutôt qu\'interprété comme '
.'« toutes les clés sont orphelines » — c\'est le scénario où un CI mal '
.'configuré efface un projet entier.',
);
}
$author = $this->security->getUser();
$report = $this->synchronizer->synchronize(
$platform,
$this->normalise($data->keys),
$data->prune,
$data->dryRun,
$author instanceof User ? $author : null,
);
return $data->fill($report);
}
private function resolveProject(string $uuid): Project
{
$project = $this->projects->findOneByUuidOrSlug($uuid);
if (null === $project || !$this->security->isGranted(ProjectVoter::VIEW, $project)) {
throw new NotFoundHttpException('Projet introuvable.');
}
return $project;
}
private function resolvePlatform(Project $project, string $slug): Platform
{
foreach ($project->getPlatforms() as $platform) {
if ($platform->getSlug() !== $slug) {
continue;
}
// Message distinct du 404 : la plateforme existe, elle a été mise
// hors service. Un développeur qui voit « introuvable » cherche une
// faute de frappe dans sa configuration ; il doit lire ici que la
// décision est humaine et se défait depuis le back-office.
if ($platform->isArchived()) {
throw new ConflictHttpException(sprintf(
'La plateforme « %s » est archivée : elle n\'accepte plus de synchronisation. '
.'Désarchivez-la depuis l\'écran Plateformes pour reprendre les push.',
$slug,
));
}
return $platform;
}
throw new NotFoundHttpException(sprintf(
'Plateforme « %s » introuvable sur ce projet. Plateformes actives : %s.',
$slug,
implode(', ', array_map(static fn (Platform $p): string => $p->getSlug(), $project->getActivePlatforms())) ?: 'aucune',
));
}
/**
* Accepte indifféremment une chaîne nue ou un objet détaillé.
*
* Imposer la forme longue pour toutes les clés rendrait le fichier de push
* trois fois plus verbeux sans rien apporter aux clés qui n'ont pas de
* contexte particulier.
*
* `array-key` et non `string` : le type vient du corps de la requête, pas
* d'un appelant interne. Un client qui envoie un tableau JSON produit des
* index entiers, et prétendre le contraire dans la signature ferait
* disparaître la vérification ci-dessous aux yeux de l'analyse statique
* c'est exactement ce qui a laissé passer un 500.
*
* @param array<array-key, mixed> $keys
*
* @return array<string, array{value: string, description?: string|null, maxLength?: int|null}>
*/
private function normalise(array $keys): array
{
$normalised = [];
foreach ($keys as $path => $entry) {
// `keys` est un OBJET chemin → valeur. Envoyer un tableau
// — `[{"key": "...", "value": "..."}]` — donne des index entiers,
// et le parseur explosait plus loin sur un str_ends_with(int).
// Un client qui se trompe de forme doit l'apprendre ici, avec le
// bon exemple sous les yeux, pas par une erreur 500 sans rapport.
if (!\is_string($path)) {
throw new UnprocessableEntityHttpException(
'Le champ « keys » doit être un objet associant chaque chemin de clé à sa valeur, '
.'par exemple {"accueil.titre": "Bienvenue"}. Un tableau a été reçu.',
);
}
if (\is_string($entry)) {
$normalised[$path] = ['value' => $entry];
continue;
}
if (!\is_array($entry) || !\is_string($entry['value'] ?? null)) {
throw new UnprocessableEntityHttpException(sprintf(
'Clé « %s » : attendu une chaîne, ou un objet avec au moins un champ « value ».',
$path,
));
}
$normalised[$path] = [
'value' => $entry['value'],
'description' => isset($entry['description']) && \is_string($entry['description']) ? $entry['description'] : null,
'maxLength' => isset($entry['maxLength']) && is_numeric($entry['maxLength']) ? (int) $entry['maxLength'] : null,
];
}
return $normalised;
}
}

View file

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiPlatform\Resource\ReleaseDiff;
use App\Entity\Release;
use App\Entity\ReleaseBundle;
use App\Repository\ReleaseRepository;
use App\Security\Voter\ProjectVoter;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @implements ProviderInterface<ReleaseDiff>
*/
final readonly class ReleaseDiffProvider implements ProviderInterface
{
/**
* Au-delà, on renvoie les compteurs sans énumérer : une réponse de dix mille
* chemins de clés n'est lue par personne.
*/
private const MAX_LISTED = 50;
public function __construct(
private ReleaseRepository $releases,
private Security $security,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ReleaseDiff
{
$from = $this->load((string) ($uriVariables['fromUuid'] ?? ''));
$to = $this->load((string) ($uriVariables['toUuid'] ?? ''));
if ($from->getProject() !== $to->getProject()) {
throw new UnprocessableEntityHttpException(
'Ces deux releases appartiennent à des projets différents : la comparaison n\'a pas de sens.',
);
}
$diff = new ReleaseDiff();
$diff->id = sprintf('%s..%s', $from->getUuid(), $to->getUuid());
$diff->from = $from->getLabel();
$diff->to = $to->getLabel();
$before = $this->index($from);
$after = $this->index($to);
$diff->addedFiles = array_values(array_diff(array_keys($after), array_keys($before)));
$diff->removedFiles = array_values(array_diff(array_keys($before), array_keys($after)));
sort($diff->addedFiles);
sort($diff->removedFiles);
foreach ($after as $file => $entries) {
$previous = $before[$file] ?? null;
if (null === $previous) {
continue;
}
$added = array_keys(array_diff_key($entries, $previous));
$removed = array_keys(array_diff_key($previous, $entries));
$changed = [];
foreach ($entries as $path => $value) {
if (\array_key_exists($path, $previous) && $previous[$path] !== $value) {
$changed[] = $path;
}
}
if ([] === $added && [] === $removed && [] === $changed) {
continue;
}
sort($added);
sort($removed);
sort($changed);
$diff->addedCount += \count($added);
$diff->changedCount += \count($changed);
$diff->removedCount += \count($removed);
$diff->files[] = [
'file' => $file,
'added' => \array_slice($added, 0, self::MAX_LISTED),
'changed' => \array_slice($changed, 0, self::MAX_LISTED),
'removed' => \array_slice($removed, 0, self::MAX_LISTED),
'truncated' => \count($added) + \count($changed) + \count($removed) > self::MAX_LISTED,
];
}
usort($diff->files, static fn (array $a, array $b): int => $a['file'] <=> $b['file']);
$diff->summary = sprintf(
'%s → %s : %d ajoutée(s), %d modifiée(s), %d retirée(s) sur %d fichier(s)',
$diff->from,
$diff->to,
$diff->addedCount,
$diff->changedCount,
$diff->removedCount,
\count($diff->files),
);
return $diff;
}
private function load(string $uuid): Release
{
$release = '' === $uuid ? null : $this->releases->findOneBy(['uuid' => $uuid]);
if (null === $release || !$this->security->isGranted(ProjectVoter::VIEW, $release->getProject())) {
throw new NotFoundHttpException('Release introuvable.');
}
return $release;
}
/**
* Aplatit les bundles d'une release en « plateforme/langue » => (clé => valeur).
*
* L'aplatissement est nécessaire parce que deux plateformes peuvent utiliser
* des layouts différents imbriqué ici, plat et qu'on ne peut comparer
* que des structures comparables.
*
* @return array<string, array<string, string>>
*/
private function index(Release $release): array
{
$index = [];
foreach ($release->getBundles() as $bundle) {
$file = sprintf('%s/%s', $bundle->getPlatform()->getSlug(), $bundle->getLocale()->getCode());
$decoded = json_decode($bundle->getPayload(), true);
$index[$file] = \is_array($decoded) ? $this->flatten($decoded) : [];
}
return $index;
}
/**
* @param array<string, mixed> $tree
*
* @return array<string, string>
*/
private function flatten(array $tree, string $prefix = ''): array
{
$flat = [];
foreach ($tree as $key => $value) {
$path = '' === $prefix ? (string) $key : $prefix.'.'.$key;
if (\is_array($value)) {
$flat = [...$flat, ...$this->flatten($value, $path)];
continue;
}
$flat[$path] = (string) $value;
}
return $flat;
}
}

View file

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiPlatform\Resource\ReleasePublication;
use App\Entity\Environment;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\ProjectRepository;
use App\Security\Voter\ProjectVoter;
use App\Translation\Release\ReleasePublisher;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @implements ProcessorInterface<ReleasePublication, ReleasePublication>
*/
final readonly class ReleasePublicationProcessor implements ProcessorInterface
{
public function __construct(
private ProjectRepository $projects,
private ReleasePublisher $publisher,
private EntityManagerInterface $entityManager,
private Security $security,
private int $releaseRetentionUnreferenced,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ReleasePublication
{
$project = $this->resolveProject((string) ($uriVariables['projectUuid'] ?? ''));
if (!$this->security->isGranted(ProjectVoter::PUBLISH, $project)) {
throw new AccessDeniedHttpException(
'Publication non autorisée. Un rôle « developer », « admin » ou « owner » est requis, '
.'ou une clé API portant le scope « releases:publish ».',
);
}
$author = $this->security->getUser();
try {
$release = $this->publisher->publish(
$project,
$author instanceof User ? $author : null,
$data->notes,
);
} catch (\RuntimeException $exception) {
// 422 et non 500 : la publication a échoué parce que le CONTENU ne
// le permet pas, pas parce que le serveur est en panne. Le message
// liste précisément ce qu'il faut corriger.
throw new UnprocessableEntityHttpException($exception->getMessage(), $exception);
}
$data->fill($release);
$data->deployed = $this->deploy($project, $release, $data->deployTo);
$data->purged = $this->publisher->purge($project, $this->releaseRetentionUnreferenced);
return $data;
}
/**
* @param list<string> $slugs
*
* @return list<string>
*/
private function deploy(Project $project, \App\Entity\Release $release, array $slugs): array
{
if ([] === $slugs) {
return [];
}
$environments = [];
foreach ($project->getEnvironments() as $environment) {
$environments[$environment->getSlug()] = $environment;
}
$deployed = [];
foreach ($slugs as $slug) {
$environment = $environments[$slug] ?? null;
if (null === $environment) {
throw new UnprocessableEntityHttpException(sprintf(
'Environnement « %s » inconnu sur ce projet. Environnements existants : %s.',
$slug,
implode(', ', array_keys($environments)),
));
}
$environment->setCurrentRelease($release);
$deployed[] = $slug;
}
$this->entityManager->flush();
return $deployed;
}
private function resolveProject(string $uuid): Project
{
$project = $this->projects->findOneByUuidOrSlug($uuid);
if (null === $project || !$this->security->isGranted(ProjectVoter::VIEW, $project)) {
throw new NotFoundHttpException('Projet introuvable.');
}
return $project;
}
}

View file

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiPlatform\Resource\TranslationEntry;
use App\Entity\Translation;
use App\Entity\TranslationVersion;
use App\Entity\User;
use App\Enum\ChangeSource;
use App\Enum\TranslationStatus;
use App\Repository\TranslationRepository;
use App\Security\Voter\TranslationVoter;
use App\Translation\Format\Ast\MessageAst;
use App\Translation\Format\MessageValidator;
use App\Translation\Format\Parser\IcuParser;
use App\Translation\Format\Serializer\IcuSerializer;
use App\Translation\Format\ValidationIssue;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Écrit une traduction, après l'avoir confrontée à sa source.
*
* C'est ici que le moteur de format cesse d'être une bibliothèque pour devenir
* une garantie : un traducteur non technique ne PEUT PAS enregistrer une valeur
* qui casserait l'application cliente. Sans ce point de contrôle, il faudrait
* relire chaque traduction avant publication ce que personne ne fait au-delà
* de la deuxième semaine.
*
* @implements ProcessorInterface<TranslationEntry, TranslationEntry>
*/
final readonly class TranslationEntryProcessor implements ProcessorInterface
{
public function __construct(
private TranslationEntryProvider $provider,
private TranslationRepository $translations,
private EntityManagerInterface $entityManager,
private IcuParser $parser,
private IcuSerializer $serializer,
private MessageValidator $validator,
private Security $security,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TranslationEntry
{
$translation = $this->provider->loadTranslation(
(string) ($uriVariables['keyUuid'] ?? ''),
(string) ($uriVariables['localeCode'] ?? ''),
);
$key = $translation->getTranslationKey();
$project = $key->getProject();
$isSourceLocale = $translation->getLocale() === $project->getSourceLocale();
$this->assertMayWrite($translation, $data->status);
$this->assertVersionMatches($translation, $data->version);
$canonical = null;
$warnings = [];
if (null !== $data->value && '' !== trim($data->value)) {
$ast = $this->parse($data->value);
$canonical = $this->serializer->serialize($ast);
// La valeur source n'est validée contre rien : elle EST la référence.
if (!$isSourceLocale) {
$warnings = $this->validate($ast, $key, $translation);
}
}
$sourceValue = $isSourceLocale
? $canonical
: $key->getTranslation($project->getSourceLocale())?->getValue();
$author = $this->security->getUser();
$author = $author instanceof User ? $author : null;
// L'historique capture l'état AVANT écriture : c'est ce qui permet de
// répondre à « qui a écrasé ma traduction ».
$this->entityManager->persist(TranslationVersion::snapshot($translation, ChangeSource::Ui, $author));
$status = $this->resolveStatus($data->status, $canonical, $translation->getStatus());
$translation->write($canonical, $status, $sourceValue, $author);
$translation->setIsMachineTranslated(false);
$this->entityManager->flush();
$flagged = 0;
if ($isSourceLocale && null !== $canonical) {
// Modifier la source invalide potentiellement toutes les cibles :
// c'est le mécanisme anti-dérive, déclenché en un seul UPDATE.
$flagged = $this->translations->flagStaleAfterSourceChange($key, $canonical);
}
return $this->provider->toResource($translation, $warnings, $flagged);
}
private function assertMayWrite(Translation $translation, ?TranslationStatus $requestedStatus): void
{
if (!$this->security->isGranted(TranslationVoter::EDIT, $translation)) {
throw new AccessDeniedHttpException(sprintf(
'Écriture non autorisée sur la langue « %s ». Un traducteur n\'écrit que dans les langues qui lui sont assignées.',
$translation->getLocale()->getCode(),
));
}
// Valider est un acte distinct d'écrire : un traducteur peut proposer,
// seul un relecteur peut approuver son propre travail.
if (TranslationStatus::Reviewed === $requestedStatus
&& !$this->security->isGranted(TranslationVoter::REVIEW, $translation)) {
throw new AccessDeniedHttpException(
'Passage au statut « reviewed » non autorisé : il demande un rôle de relecteur sur cette langue.',
);
}
}
private function assertVersionMatches(Translation $translation, ?int $expected): void
{
if (null !== $expected && $expected !== $translation->getVersion()) {
throw new ConflictHttpException(sprintf(
'Cette traduction a été modifiée entre-temps (version %d en base, %d envoyée). '
.'Rechargez-la avant de réécrire — sans quoi vous écraseriez le travail de quelqu\'un d\'autre.',
$translation->getVersion(),
$expected,
));
}
}
private function parse(string $value): MessageAst
{
try {
return $this->parser->parse($value);
} catch (\App\Translation\Format\Exception\MessageParseException $exception) {
throw new UnprocessableEntityHttpException($exception->getMessage(), $exception);
}
}
/**
* @return list<array{severity: string, code: string, message: string}> avertissements non bloquants
*/
private function validate(MessageAst $ast, \App\Entity\TranslationKey $key, Translation $translation): array
{
$source = $key->getTranslation($key->getProject()->getSourceLocale());
$issues = $this->validator->validate(
null === $source?->getValue() ? new MessageAst() : $this->parser->parse($source->getValue()),
$ast,
$translation->getLocale()->getPluralCategories(),
$key->getMaxLength(),
);
$blocking = array_values(array_filter($issues, static fn (ValidationIssue $i): bool => $i->isBlocking()));
if ([] !== $blocking) {
throw new UnprocessableEntityHttpException(implode(' ', array_map(
static fn (ValidationIssue $i): string => $i->message,
$blocking,
)));
}
return array_map(
static fn (ValidationIssue $i): array => $i->toArray(),
array_values(array_filter($issues, static fn (ValidationIssue $i): bool => !$i->isBlocking())),
);
}
/**
* Détermine le statut à écrire.
*
* Piège de la sémantique PATCH : API Platform fusionne le corps de la
* requête dans l'objet produit par le provider. Un champ non envoyé n'arrive
* donc PAS à null il arrive avec sa valeur actuelle. Un simple
* `$data->status ?? …` ne se déclencherait jamais, et une traduction saisie
* resterait éternellement « untranslated ».
*
* On considère donc qu'un statut identique à celui déjà en base vaut
* « le client ne demande rien » et on déduit. C'est aussi le comportement
* attendu quand il le renvoie explicitement à l'identique.
*/
private function resolveStatus(?TranslationStatus $requested, ?string $canonical, TranslationStatus $current): TranslationStatus
{
if (null !== $requested && $requested !== $current) {
return $requested;
}
// Vider une traduction la remet à « à faire » plutôt que de la laisser en
// brouillon vide, état qui fausserait les compteurs d'avancement.
if (null === $canonical) {
return TranslationStatus::Untranslated;
}
// Une saisie sur une traduction jusque-là absente ou périmée vaut
// proposition : elle passe à « traduite », en attente de relecture.
return match ($current) {
TranslationStatus::Untranslated, TranslationStatus::NeedsReview => TranslationStatus::Translated,
default => $current,
};
}
}

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiPlatform\Resource\TranslationEntry;
use App\Entity\Translation;
use App\Repository\LocaleRepository;
use App\Repository\TranslationKeyRepository;
use App\Security\Voter\ProjectVoter;
use App\Security\Voter\TranslationVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Charge une traduction depuis (clé, langue).
*
* Crée la ligne si elle n'existe pas encore. Ce cas ne devrait pas se produire
* les lignes sont créées d'avance pour toutes les langues activées mais il
* survient dès qu'une langue est ajoutée au projet après coup. Créer à la volée
* évite une erreur 404 incompréhensible pour un traducteur qui voit pourtant la
* clé dans sa grille.
*
* @implements ProviderInterface<TranslationEntry>
*/
final readonly class TranslationEntryProvider implements ProviderInterface
{
public function __construct(
private TranslationKeyRepository $keys,
private LocaleRepository $locales,
private EntityManagerInterface $entityManager,
private Security $security,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TranslationEntry
{
$translation = $this->loadTranslation(
(string) ($uriVariables['keyUuid'] ?? ''),
(string) ($uriVariables['localeCode'] ?? ''),
);
// Sur un PATCH, le processeur recevra cette entité via le contexte : la
// recharger serait une requête de plus sur le chemin le plus sollicité de
// l'éditeur.
$context['previous_data'] = $translation;
return $this->toResource($translation);
}
public function loadTranslation(string $keyUuid, string $localeCode): Translation
{
$key = '' === $keyUuid ? null : $this->keys->findOneBy(['uuid' => $keyUuid]);
if (null === $key || !$this->security->isGranted(ProjectVoter::VIEW, $key->getProject())) {
throw new NotFoundHttpException('Clé introuvable.');
}
$locale = $this->locales->findOneByCode($localeCode);
if (null === $locale) {
throw new NotFoundHttpException(sprintf('Langue « %s » inconnue.', $localeCode));
}
$translation = $key->getTranslation($locale);
if (null === $translation) {
$translation = new Translation($key, $locale);
$this->entityManager->persist($translation);
$this->entityManager->flush();
}
if (!$this->security->isGranted(TranslationVoter::VIEW, $translation)) {
throw new NotFoundHttpException('Traduction introuvable.');
}
return $translation;
}
/**
* @param list<array{severity: string, code: string, message: string}> $warnings
*/
public function toResource(Translation $translation, array $warnings = [], int $flagged = 0): TranslationEntry
{
$key = $translation->getTranslationKey();
$source = $key->getTranslation($key->getProject()->getSourceLocale());
$entry = new TranslationEntry();
$entry->id = sprintf('%s:%s', $key->getUuid(), $translation->getLocale()->getCode());
$entry->keyPath = $key->getKeyPath();
$entry->locale = $translation->getLocale()->getCode();
$entry->sourceValue = $source?->getValue();
$entry->value = $translation->getValue();
$entry->status = $translation->getStatus();
$entry->currentVersion = $translation->getVersion();
$entry->isStale = $translation->isStaleAgainst($source?->getValue());
$entry->isMachineTranslated = $translation->isMachineTranslated();
$entry->updatedAt = $translation->getUpdatedAt()->format(\DATE_ATOM);
$entry->updatedBy = $translation->getUpdatedBy()?->getName();
$entry->pluralCategories = $translation->getLocale()->getPluralCategories();
$entry->warnings = $warnings;
$entry->flaggedForReview = $flagged;
return $entry;
}
}

0
src/ApiResource/.gitignore vendored Normal file
View file

168
src/Cli/ApiClient.php Normal file
View file

@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Cli;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Client HTTP du CLI.
*
* Toutes les erreurs sont converties en CliException porteuse du message rédigé
* par le serveur. C'est un choix délibéré : l'API prend soin d'écrire des
* messages actionnables (« la variable {prenom} est absente », « cette clé donne
* accès à development »), et les reformuler ici les dégraderait.
*/
final class ApiClient
{
private readonly HttpClientInterface $http;
public function __construct(
private readonly string $baseUrl,
private readonly string $apiKey,
) {
$this->http = HttpClient::create([
'timeout' => 30,
'headers' => [
'Authorization' => 'Bearer '.$this->apiKey,
'User-Agent' => 'tqs-cli',
],
]);
}
public static function fromConfiguration(Configuration $config): self
{
return new self($config->url, $config->apiKey());
}
/**
* @return array<string, mixed>
*/
public function whoami(): array
{
return $this->request('GET', '/delivery/v1/whoami');
}
/**
* @param array<string, mixed> $keys
*
* @return array<string, mixed>
*/
public function sync(string $project, string $platform, array $keys, bool $prune, bool $dryRun): array
{
return $this->request(
'POST',
sprintf('/api/v1/projects/%s/platforms/%s/sync', rawurlencode($project), rawurlencode($platform)),
['keys' => $keys, 'prune' => $prune, 'dryRun' => $dryRun],
);
}
/**
* @return array<string, mixed>
*/
public function stats(string $project): array
{
return $this->request('GET', sprintf('/api/v1/projects/%s/stats', rawurlencode($project)));
}
/**
* Contenu brut d'un bundle publié.
*
* Renvoie null sur 404 : une langue absente d'une release n'est pas une
* erreur fatale `tqs pull` la signale et poursuit avec les autres.
*/
public function bundle(string $project, string $environment, string $platform, string $locale): ?string
{
$url = sprintf(
'%s/delivery/v1/%s/%s/%s/%s.json',
$this->baseUrl,
rawurlencode($project),
rawurlencode($environment),
rawurlencode($platform),
rawurlencode($locale),
);
try {
$response = $this->http->request('GET', $url);
$status = $response->getStatusCode();
if (404 === $status) {
return null;
}
if ($status >= 400) {
throw new CliException($this->explain($status, $response->getContent(false), $url));
}
return $response->getContent();
} catch (TransportExceptionInterface $exception) {
throw new CliException($this->unreachable($exception));
}
}
/**
* @param array<string, mixed>|null $body
*
* @return array<string, mixed>
*/
private function request(string $method, string $path, ?array $body = null): array
{
$url = $this->baseUrl.$path;
$options = ['headers' => ['Accept' => 'application/json']];
if (null !== $body) {
$options['headers']['Content-Type'] = 'application/ld+json';
$options['body'] = json_encode($body, \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES);
}
try {
$response = $this->http->request($method, $url, $options);
$status = $response->getStatusCode();
$raw = $response->getContent(false);
} catch (TransportExceptionInterface $exception) {
throw new CliException($this->unreachable($exception));
}
if ($status >= 400) {
throw new CliException($this->explain($status, $raw, $url));
}
$decoded = json_decode($raw, true);
if (!\is_array($decoded)) {
throw new CliException(sprintf("Réponse inattendue de %s :\n%s", $url, mb_substr($raw, 0, 500)));
}
return $decoded;
}
private function explain(int $status, string $raw, string $url): string
{
$decoded = json_decode($raw, true);
$detail = \is_array($decoded) && isset($decoded['detail']) ? (string) $decoded['detail'] : mb_substr($raw, 0, 500);
// Les deux erreurs qui coûtent le plus de temps à diagnostiquer méritent
// une aide explicite plutôt que le seul message du serveur.
$hint = match ($status) {
401 => "\n\nLa clé API est absente, invalide ou révoquée. Vérifiez ".Configuration::API_KEY_ENV.'.',
403 => "\n\nLa clé est valide mais n'a pas les droits requis. Vérifiez son environnement et ses scopes avec « tqs init ».",
default => '',
};
return sprintf("HTTP %d sur %s\n\n%s%s", $status, $url, $detail, $hint);
}
private function unreachable(TransportExceptionInterface $exception): string
{
return sprintf(
"Serveur injoignable à %s.\n\n%s\n\nVérifiez le champ « url » de %s.",
$this->baseUrl,
$exception->getMessage(),
Configuration::FILENAME,
);
}
}

16
src/Cli/CliException.php Normal file
View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Cli;
/**
* Erreur destinée à l'utilisateur du CLI.
*
* Distinguée des autres exceptions parce qu'elle s'affiche SANS trace : un
* développeur qui a oublié d'exporter sa clé API n'a rien à faire d'une pile
* d'appels. Les erreurs inattendues, elles, gardent leur trace.
*/
final class CliException extends \RuntimeException
{
}

Some files were not shown because too many files have changed in this diff Show more