From 9025c64c0b77d3718238a34dd34b94753bdb06b3 Mon Sep 17 00:00:00 2001 From: Stephan Morand Date: Fri, 21 Aug 2026 08:16:05 +0200 Subject: [PATCH] =?UTF-8?q?Socle=20complet=20de=20TQ-Slator=20:=20=C3=A9di?= =?UTF-8?q?teur,=20API,=20CLI,=20administration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .editorconfig | 17 + .env | 79 + .env.dev | 4 + .env.test | 3 + .gitignore | 37 + .php-cs-fixer.dist.php | 17 + Dockerfile | 104 + README.md | 424 + assets/api/client.ts | 417 + assets/api/icu.ts | 183 + assets/api/types.ts | 326 + assets/app.css | 81 + assets/auth/AuthProvider.tsx | 81 + assets/components/ProjectNav.tsx | 53 + assets/components/RequiresAdmin.tsx | 40 + assets/components/SourceText.tsx | 162 + assets/components/Status.tsx | 107 + assets/editor/ContextPanel.tsx | 210 + assets/editor/FocusMode.tsx | 200 + assets/editor/KeyFocusMode.tsx | 317 + assets/editor/KeyGrid.tsx | 304 + assets/editor/NamespaceTree.tsx | 143 + assets/editor/TranslationGrid.tsx | 186 + assets/editor/TranslationInput.tsx | 258 + assets/index.html | 14 + assets/main.tsx | 117 + assets/pages/AdminPage.tsx | 523 + assets/pages/EditorPage.tsx | 425 + assets/pages/IntegrationPage.tsx | 328 + assets/pages/InvitationPage.tsx | 181 + assets/pages/LoginPage.tsx | 93 + assets/pages/MembersPage.tsx | 367 + assets/pages/PlatformsPage.tsx | 721 + assets/pages/ProjectsPage.tsx | 230 + bin/build-tqs-phar.php | 256 + bin/console | 21 + bin/phpunit | 4 + bin/tqs | 71 + compose.override.yaml | 18 + compose.yaml | 119 + composer.json | 106 + composer.lock | 11941 ++++++++++++++++ config/bundles.php | 14 + config/packages/api_platform.yaml | 64 + config/packages/cache.yaml | 19 + config/packages/doctrine.yaml | 63 + config/packages/doctrine_migrations.yaml | 6 + config/packages/framework.yaml | 15 + config/packages/mailer.yaml | 3 + config/packages/messenger.yaml | 32 + config/packages/monolog.yaml | 55 + config/packages/nelmio_cors.yaml | 10 + config/packages/property_info.yaml | 3 + config/packages/routing.yaml | 10 + config/packages/security.yaml | 93 + config/packages/twig.yaml | 6 + config/packages/validator.yaml | 11 + config/preload.php | 5 + config/reference.php | 1879 +++ config/routes.yaml | 11 + config/routes/api_platform.yaml | 4 + config/routes/framework.yaml | 4 + config/routes/security.yaml | 3 + config/services.yaml | 54 + docker/entrypoint.dev.sh | 35 + docker/mariadb/init.sql | 11 + docs/01-architecture-proposal.md | 709 + frankenphp/Caddyfile | 35 + frankenphp/conf.d/10-app.ini | 27 + frankenphp/conf.d/20-app.dev.ini | 15 + frankenphp/conf.d/20-app.prod.ini | 21 + migrations/.gitignore | 0 migrations/Version20260814090000.php | 202 + migrations/Version20260817121733.php | 42 + package-lock.json | 2577 ++++ package.json | 27 + phpstan.neon | 17 + phpunit.dist.xml | 44 + public/index.php | 9 + .../Extension/ProjectVisibilityExtension.php | 145 + .../Resource/EnvironmentDeployment.php | 81 + src/ApiPlatform/Resource/GridRow.php | 138 + src/ApiPlatform/Resource/KeyRow.php | 118 + src/ApiPlatform/Resource/KeyRowTarget.php | 52 + src/ApiPlatform/Resource/KeySync.php | 201 + src/ApiPlatform/Resource/ReleaseDiff.php | 98 + .../Resource/ReleasePublication.php | 126 + src/ApiPlatform/Resource/TranslationEntry.php | 148 + .../State/EnvironmentDeploymentProcessor.php | 88 + .../State/EnvironmentDeploymentProvider.php | 28 + src/ApiPlatform/State/GridProvider.php | 199 + src/ApiPlatform/State/KeyRowProvider.php | 194 + src/ApiPlatform/State/KeySyncProcessor.php | 172 + src/ApiPlatform/State/ReleaseDiffProvider.php | 170 + .../State/ReleasePublicationProcessor.php | 117 + .../State/TranslationEntryProcessor.php | 208 + .../State/TranslationEntryProvider.php | 110 + src/ApiResource/.gitignore | 0 src/Cli/ApiClient.php | 168 + src/Cli/CliException.php | 16 + src/Cli/Command/InitCommand.php | 165 + src/Cli/Command/PullCommand.php | 156 + src/Cli/Command/PushCommand.php | 165 + src/Cli/Command/StatusCommand.php | 133 + src/Cli/Configuration.php | 139 + src/Cli/TranslationFile.php | 109 + src/Controller/.gitignore | 0 .../Admin/ProjectAdminController.php | 195 + src/Controller/Admin/UserAdminController.php | 196 + src/Controller/ApiKeyController.php | 235 + src/Controller/AuthController.php | 61 + src/Controller/DeliveryController.php | 198 + src/Controller/EditorContextController.php | 307 + src/Controller/EnvironmentController.php | 206 + src/Controller/HealthController.php | 62 + src/Controller/InvitationController.php | 104 + src/Controller/MembershipController.php | 315 + src/Controller/PlatformController.php | 304 + src/Controller/SpaController.php | 63 + src/DataFixtures/AppFixtures.php | 529 + src/Doctrine/Filter/OrganizationFilter.php | 52 + src/Entity/.gitignore | 0 src/Entity/ApiKey.php | 234 + src/Entity/AuditLog.php | 170 + src/Entity/Behavior/TimestampableTrait.php | 49 + src/Entity/CompletionStat.php | 176 + src/Entity/Environment.php | 183 + src/Entity/GlossaryTerm.php | 155 + src/Entity/Invitation.php | 212 + src/Entity/KeyComment.php | 128 + src/Entity/KeyScreenshot.php | 130 + src/Entity/Locale.php | 154 + src/Entity/Organization.php | 82 + src/Entity/Platform.php | 294 + src/Entity/Project.php | 308 + src/Entity/ProjectLocale.php | 96 + src/Entity/ProjectMember.php | 174 + src/Entity/Release.php | 277 + src/Entity/ReleaseBundle.php | 177 + src/Entity/TenantAwareInterface.php | 34 + src/Entity/Translation.php | 241 + src/Entity/TranslationKey.php | 442 + src/Entity/TranslationNamespace.php | 130 + src/Entity/TranslationVersion.php | 112 + src/Entity/User.php | 330 + src/Enum/ApiScope.php | 61 + src/Enum/AuthProvider.php | 29 + src/Enum/ChangeSource.php | 29 + src/Enum/ExportLayout.php | 20 + src/Enum/MessageFormat.php | 74 + src/Enum/PlatformKind.php | 29 + src/Enum/ProjectRole.php | 81 + src/Enum/TextDirection.php | 15 + src/Enum/TranslationStatus.php | 60 + src/EventListener/TenantContextListener.php | 57 + src/Kernel.php | 11 + src/Membership/InvitationService.php | 248 + src/Message/BuildReleaseBundles.php | 21 + src/Message/RecomputeCompletionStats.php | 24 + src/Message/SendInvitationEmail.php | 22 + src/Message/TouchApiKey.php | 22 + .../SendInvitationEmailHandler.php | 91 + src/Repository/.gitignore | 0 src/Repository/ApiKeyRepository.php | 58 + src/Repository/InvitationRepository.php | 63 + src/Repository/LocaleRepository.php | 50 + src/Repository/OrganizationRepository.php | 25 + src/Repository/ProjectMemberRepository.php | 42 + src/Repository/ProjectRepository.php | 79 + src/Repository/ReleaseBundleRepository.php | 69 + src/Repository/ReleaseRepository.php | 72 + src/Repository/TranslationKeyRepository.php | 474 + src/Repository/TranslationRepository.php | 129 + src/Repository/UserRepository.php | 129 + src/Security/ApiEntryPoint.php | 31 + src/Security/ApiKeyAuthenticator.php | 118 + src/Security/ApiKeyUser.php | 76 + src/Security/AuthenticationHandler.php | 83 + src/Security/Voter/ProjectVoter.php | 119 + src/Security/Voter/TranslationVoter.php | 84 + src/Security/WritableLocaleResolver.php | 93 + src/Tenant/TenantContext.php | 91 + src/Translation/Format/Ast/ArgumentNode.php | 36 + src/Translation/Format/Ast/LiteralNode.php | 20 + src/Translation/Format/Ast/MessageAst.php | 85 + src/Translation/Format/Ast/MessageNode.php | 25 + src/Translation/Format/Ast/PluralNode.php | 84 + src/Translation/Format/Ast/PoundNode.php | 20 + src/Translation/Format/Ast/SelectNode.php | 44 + .../Exception/MessageParseException.php | 43 + .../UnsupportedMessageFeatureException.php | 37 + .../Format/MessageFormatRegistry.php | 107 + src/Translation/Format/MessageValidator.php | 187 + .../Format/Parser/I18nextParser.php | 200 + src/Translation/Format/Parser/IcuParser.php | 477 + .../Format/Parser/MessageParserInterface.php | 47 + .../Format/PlaceholderExtractor.php | 73 + .../Format/Serializer/I18nextSerializer.php | 242 + .../Format/Serializer/IcuSerializer.php | 178 + .../Serializer/MessageSerializerInterface.php | 33 + src/Translation/Format/ValidationIssue.php | 56 + src/Translation/Release/BuiltBundle.php | 33 + src/Translation/Release/BundleBuilder.php | 225 + src/Translation/Release/ReleasePublisher.php | 157 + src/Translation/Sync/KeySynchronizer.php | 350 + src/Translation/Sync/NamespaceResolver.php | 90 + src/Translation/Sync/SyncReport.php | 64 + symfony.lock | 268 + templates/base.html.twig | 23 + tests/Architecture/TenantIsolationTest.php | 146 + tests/Entity/PlatformArchivingTest.php | 95 + tests/Security/AccountDeactivationTest.php | 118 + tests/Security/WritableLocaleResolverTest.php | 145 + .../Format/I18nextRoundTripTest.php | 276 + tests/Translation/Format/IcuRoundTripTest.php | 162 + .../Format/MessageFormatRegistryTest.php | 73 + .../Format/MessageValidatorTest.php | 184 + .../Translation/Release/BundleBuilderTest.php | 272 + .../Translation/Sync/KeySynchronizerTest.php | 333 + tests/bootstrap.php | 38 + tests/object-manager.php | 18 + tsconfig.json | 23 + vite.config.ts | 43 + 223 files changed, 43891 insertions(+) create mode 100644 .editorconfig create mode 100644 .env create mode 100644 .env.dev create mode 100644 .env.test create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 assets/api/client.ts create mode 100644 assets/api/icu.ts create mode 100644 assets/api/types.ts create mode 100644 assets/app.css create mode 100644 assets/auth/AuthProvider.tsx create mode 100644 assets/components/ProjectNav.tsx create mode 100644 assets/components/RequiresAdmin.tsx create mode 100644 assets/components/SourceText.tsx create mode 100644 assets/components/Status.tsx create mode 100644 assets/editor/ContextPanel.tsx create mode 100644 assets/editor/FocusMode.tsx create mode 100644 assets/editor/KeyFocusMode.tsx create mode 100644 assets/editor/KeyGrid.tsx create mode 100644 assets/editor/NamespaceTree.tsx create mode 100644 assets/editor/TranslationGrid.tsx create mode 100644 assets/editor/TranslationInput.tsx create mode 100644 assets/index.html create mode 100644 assets/main.tsx create mode 100644 assets/pages/AdminPage.tsx create mode 100644 assets/pages/EditorPage.tsx create mode 100644 assets/pages/IntegrationPage.tsx create mode 100644 assets/pages/InvitationPage.tsx create mode 100644 assets/pages/LoginPage.tsx create mode 100644 assets/pages/MembersPage.tsx create mode 100644 assets/pages/PlatformsPage.tsx create mode 100644 assets/pages/ProjectsPage.tsx create mode 100644 bin/build-tqs-phar.php create mode 100755 bin/console create mode 100755 bin/phpunit create mode 100755 bin/tqs create mode 100644 compose.override.yaml create mode 100644 compose.yaml create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/bundles.php create mode 100644 config/packages/api_platform.yaml create mode 100644 config/packages/cache.yaml create mode 100644 config/packages/doctrine.yaml create mode 100644 config/packages/doctrine_migrations.yaml create mode 100644 config/packages/framework.yaml create mode 100644 config/packages/mailer.yaml create mode 100644 config/packages/messenger.yaml create mode 100644 config/packages/monolog.yaml create mode 100644 config/packages/nelmio_cors.yaml create mode 100644 config/packages/property_info.yaml create mode 100644 config/packages/routing.yaml create mode 100644 config/packages/security.yaml create mode 100644 config/packages/twig.yaml create mode 100644 config/packages/validator.yaml create mode 100644 config/preload.php create mode 100644 config/reference.php create mode 100644 config/routes.yaml create mode 100644 config/routes/api_platform.yaml create mode 100644 config/routes/framework.yaml create mode 100644 config/routes/security.yaml create mode 100644 config/services.yaml create mode 100644 docker/entrypoint.dev.sh create mode 100644 docker/mariadb/init.sql create mode 100644 docs/01-architecture-proposal.md create mode 100644 frankenphp/Caddyfile create mode 100644 frankenphp/conf.d/10-app.ini create mode 100644 frankenphp/conf.d/20-app.dev.ini create mode 100644 frankenphp/conf.d/20-app.prod.ini create mode 100644 migrations/.gitignore create mode 100644 migrations/Version20260814090000.php create mode 100644 migrations/Version20260817121733.php create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpstan.neon create mode 100644 phpunit.dist.xml create mode 100644 public/index.php create mode 100644 src/ApiPlatform/Extension/ProjectVisibilityExtension.php create mode 100644 src/ApiPlatform/Resource/EnvironmentDeployment.php create mode 100644 src/ApiPlatform/Resource/GridRow.php create mode 100644 src/ApiPlatform/Resource/KeyRow.php create mode 100644 src/ApiPlatform/Resource/KeyRowTarget.php create mode 100644 src/ApiPlatform/Resource/KeySync.php create mode 100644 src/ApiPlatform/Resource/ReleaseDiff.php create mode 100644 src/ApiPlatform/Resource/ReleasePublication.php create mode 100644 src/ApiPlatform/Resource/TranslationEntry.php create mode 100644 src/ApiPlatform/State/EnvironmentDeploymentProcessor.php create mode 100644 src/ApiPlatform/State/EnvironmentDeploymentProvider.php create mode 100644 src/ApiPlatform/State/GridProvider.php create mode 100644 src/ApiPlatform/State/KeyRowProvider.php create mode 100644 src/ApiPlatform/State/KeySyncProcessor.php create mode 100644 src/ApiPlatform/State/ReleaseDiffProvider.php create mode 100644 src/ApiPlatform/State/ReleasePublicationProcessor.php create mode 100644 src/ApiPlatform/State/TranslationEntryProcessor.php create mode 100644 src/ApiPlatform/State/TranslationEntryProvider.php create mode 100644 src/ApiResource/.gitignore create mode 100644 src/Cli/ApiClient.php create mode 100644 src/Cli/CliException.php create mode 100644 src/Cli/Command/InitCommand.php create mode 100644 src/Cli/Command/PullCommand.php create mode 100644 src/Cli/Command/PushCommand.php create mode 100644 src/Cli/Command/StatusCommand.php create mode 100644 src/Cli/Configuration.php create mode 100644 src/Cli/TranslationFile.php create mode 100644 src/Controller/.gitignore create mode 100644 src/Controller/Admin/ProjectAdminController.php create mode 100644 src/Controller/Admin/UserAdminController.php create mode 100644 src/Controller/ApiKeyController.php create mode 100644 src/Controller/AuthController.php create mode 100644 src/Controller/DeliveryController.php create mode 100644 src/Controller/EditorContextController.php create mode 100644 src/Controller/EnvironmentController.php create mode 100644 src/Controller/HealthController.php create mode 100644 src/Controller/InvitationController.php create mode 100644 src/Controller/MembershipController.php create mode 100644 src/Controller/PlatformController.php create mode 100644 src/Controller/SpaController.php create mode 100644 src/DataFixtures/AppFixtures.php create mode 100644 src/Doctrine/Filter/OrganizationFilter.php create mode 100644 src/Entity/.gitignore create mode 100644 src/Entity/ApiKey.php create mode 100644 src/Entity/AuditLog.php create mode 100644 src/Entity/Behavior/TimestampableTrait.php create mode 100644 src/Entity/CompletionStat.php create mode 100644 src/Entity/Environment.php create mode 100644 src/Entity/GlossaryTerm.php create mode 100644 src/Entity/Invitation.php create mode 100644 src/Entity/KeyComment.php create mode 100644 src/Entity/KeyScreenshot.php create mode 100644 src/Entity/Locale.php create mode 100644 src/Entity/Organization.php create mode 100644 src/Entity/Platform.php create mode 100644 src/Entity/Project.php create mode 100644 src/Entity/ProjectLocale.php create mode 100644 src/Entity/ProjectMember.php create mode 100644 src/Entity/Release.php create mode 100644 src/Entity/ReleaseBundle.php create mode 100644 src/Entity/TenantAwareInterface.php create mode 100644 src/Entity/Translation.php create mode 100644 src/Entity/TranslationKey.php create mode 100644 src/Entity/TranslationNamespace.php create mode 100644 src/Entity/TranslationVersion.php create mode 100644 src/Entity/User.php create mode 100644 src/Enum/ApiScope.php create mode 100644 src/Enum/AuthProvider.php create mode 100644 src/Enum/ChangeSource.php create mode 100644 src/Enum/ExportLayout.php create mode 100644 src/Enum/MessageFormat.php create mode 100644 src/Enum/PlatformKind.php create mode 100644 src/Enum/ProjectRole.php create mode 100644 src/Enum/TextDirection.php create mode 100644 src/Enum/TranslationStatus.php create mode 100644 src/EventListener/TenantContextListener.php create mode 100644 src/Kernel.php create mode 100644 src/Membership/InvitationService.php create mode 100644 src/Message/BuildReleaseBundles.php create mode 100644 src/Message/RecomputeCompletionStats.php create mode 100644 src/Message/SendInvitationEmail.php create mode 100644 src/Message/TouchApiKey.php create mode 100644 src/MessageHandler/SendInvitationEmailHandler.php create mode 100644 src/Repository/.gitignore create mode 100644 src/Repository/ApiKeyRepository.php create mode 100644 src/Repository/InvitationRepository.php create mode 100644 src/Repository/LocaleRepository.php create mode 100644 src/Repository/OrganizationRepository.php create mode 100644 src/Repository/ProjectMemberRepository.php create mode 100644 src/Repository/ProjectRepository.php create mode 100644 src/Repository/ReleaseBundleRepository.php create mode 100644 src/Repository/ReleaseRepository.php create mode 100644 src/Repository/TranslationKeyRepository.php create mode 100644 src/Repository/TranslationRepository.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/Security/ApiEntryPoint.php create mode 100644 src/Security/ApiKeyAuthenticator.php create mode 100644 src/Security/ApiKeyUser.php create mode 100644 src/Security/AuthenticationHandler.php create mode 100644 src/Security/Voter/ProjectVoter.php create mode 100644 src/Security/Voter/TranslationVoter.php create mode 100644 src/Security/WritableLocaleResolver.php create mode 100644 src/Tenant/TenantContext.php create mode 100644 src/Translation/Format/Ast/ArgumentNode.php create mode 100644 src/Translation/Format/Ast/LiteralNode.php create mode 100644 src/Translation/Format/Ast/MessageAst.php create mode 100644 src/Translation/Format/Ast/MessageNode.php create mode 100644 src/Translation/Format/Ast/PluralNode.php create mode 100644 src/Translation/Format/Ast/PoundNode.php create mode 100644 src/Translation/Format/Ast/SelectNode.php create mode 100644 src/Translation/Format/Exception/MessageParseException.php create mode 100644 src/Translation/Format/Exception/UnsupportedMessageFeatureException.php create mode 100644 src/Translation/Format/MessageFormatRegistry.php create mode 100644 src/Translation/Format/MessageValidator.php create mode 100644 src/Translation/Format/Parser/I18nextParser.php create mode 100644 src/Translation/Format/Parser/IcuParser.php create mode 100644 src/Translation/Format/Parser/MessageParserInterface.php create mode 100644 src/Translation/Format/PlaceholderExtractor.php create mode 100644 src/Translation/Format/Serializer/I18nextSerializer.php create mode 100644 src/Translation/Format/Serializer/IcuSerializer.php create mode 100644 src/Translation/Format/Serializer/MessageSerializerInterface.php create mode 100644 src/Translation/Format/ValidationIssue.php create mode 100644 src/Translation/Release/BuiltBundle.php create mode 100644 src/Translation/Release/BundleBuilder.php create mode 100644 src/Translation/Release/ReleasePublisher.php create mode 100644 src/Translation/Sync/KeySynchronizer.php create mode 100644 src/Translation/Sync/NamespaceResolver.php create mode 100644 src/Translation/Sync/SyncReport.php create mode 100644 symfony.lock create mode 100644 templates/base.html.twig create mode 100644 tests/Architecture/TenantIsolationTest.php create mode 100644 tests/Entity/PlatformArchivingTest.php create mode 100644 tests/Security/AccountDeactivationTest.php create mode 100644 tests/Security/WritableLocaleResolverTest.php create mode 100644 tests/Translation/Format/I18nextRoundTripTest.php create mode 100644 tests/Translation/Format/IcuRoundTripTest.php create mode 100644 tests/Translation/Format/MessageFormatRegistryTest.php create mode 100644 tests/Translation/Format/MessageValidatorTest.php create mode 100644 tests/Translation/Release/BundleBuilderTest.php create mode 100644 tests/Translation/Sync/KeySynchronizerTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/object-manager.php create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6699076 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env b/.env new file mode 100644 index 0000000..b588016 --- /dev/null +++ b/.env @@ -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 ### diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..bcfafed --- /dev/null +++ b/.env.dev @@ -0,0 +1,4 @@ + +###> symfony/framework-bundle ### +APP_SECRET=8a86933946a19a17bf08506aa550a011 +###< symfony/framework-bundle ### diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..64bd111 --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c50f8b0 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..1c883f0 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,17 @@ +in(__DIR__) + ->exclude('var') + ->notPath([ + 'config/bundles.php', + 'config/reference.php', + ]) +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@Symfony' => true, + ]) + ->setFinder($finder) +; diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a0845e --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6a1934 --- /dev/null +++ b/README.md @@ -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= +E= + +# 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//diff/ + +# 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 +``` diff --git a/assets/api/client.ts b/assets/api/client.ts new file mode 100644 index 0000000..b3f96bf --- /dev/null +++ b/assets/api/client.ts @@ -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(path: string, init: RequestInit = {}): Promise { + 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(payload: { member?: T[]; totalItems?: number }): Page { + 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 { + return request('/api/v1/auth/me', { headers: { Accept: 'application/json' } }); + }, + + async login(email: string, password: string): Promise { + return request('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ email, password }), + }); + }, + + async logout(): Promise { + await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'same-origin' }); + }, + + async projects(): Promise { + const payload = await request<{ member?: Project[] }>('/api/v1/projects'); + return payload.member ?? []; + }, + + async stats(projectUuid: string): Promise { + return request(`/api/v1/projects/${projectUuid}/stats`, { + headers: { Accept: 'application/json' }, + }); + }, + + async namespaces(projectUuid: string, locale: string): Promise { + return request( + `/api/v1/projects/${projectUuid}/namespaces?locale=${encodeURIComponent(locale)}`, + { headers: { Accept: 'application/json' } }, + ); + }, + + async grid(query: GridQuery): Promise> { + 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 { + return request(`/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 { + 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 { + await request(`/api/v1/projects/${project}/members/${memberUuid}`, { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + }, + + async apiKeys(project: string): Promise { + return request(`/api/v1/projects/${project}/api-keys`, { + headers: { Accept: 'application/json' }, + }); + }, + + async createApiKey( + project: string, + body: { name: string; environment: string; scopes: string[] }, + ): Promise { + return request(`/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 { + 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 & { focus?: boolean }): Promise> { + 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 { + return request(`/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 { + return request(`/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 { + return request(`/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 { + return request(`/api/v1/projects/${project}/environments`, { + headers: { Accept: 'application/json' }, + }); + }, + + async createEnvironment( + project: string, + body: { name: string; slug: string }, + ): Promise { + return request(`/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 { + return request( + `/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 { + const query = search ? `?q=${encodeURIComponent(search)}` : ''; + + return request(`/api/v1/admin/users${query}`, { + headers: { Accept: 'application/json' }, + }); + }, + + async updateAdminUser( + uuid: string, + body: { isActive?: boolean; isSuperAdmin?: boolean }, + ): Promise { + return request(`/api/v1/admin/users/${uuid}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }); + }, + + async adminProjects(): Promise { + return request('/api/v1/admin/projects', { + headers: { Accept: 'application/json' }, + }); + }, + + async createProject(body: { + name: string; + slug: string; + description: string; + sourceLocale: string; + targetLocales: string[]; + }): Promise { + return request('/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 { + return request(`/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 { + return request( + `/api/v1/keys/${keyUuid}/translations/${encodeURIComponent(locale)}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/merge-patch+json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }, + ); + }, +}; diff --git a/assets/api/icu.ts b/assets/api/icu.ts new file mode 100644 index 0000000..afa84f0 --- /dev/null +++ b/assets/api/icu.ts @@ -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; + /** 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(); + 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 | null { + const branches: Record = {}; + 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; +} diff --git a/assets/api/types.ts b/assets/api/types.ts new file mode 100644 index 0000000..5228348 --- /dev/null +++ b/assets/api/types.ts @@ -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 { + 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; +} diff --git a/assets/app.css b/assets/app.css new file mode 100644 index 0000000..0bc1977 --- /dev/null +++ b/assets/app.css @@ -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; + } +} diff --git a/assets/auth/AuthProvider.tsx b/assets/auth/AuthProvider.tsx new file mode 100644 index 0000000..ae0458c --- /dev/null +++ b/assets/auth/AuthProvider.tsx @@ -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; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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( + () => ({ user, loading, login, logout }), + [user, loading, login, logout], + ); + + return {children}; +} + +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.'; +} diff --git a/assets/components/ProjectNav.tsx b/assets/components/ProjectNav.tsx new file mode 100644 index 0000000..07d1b39 --- /dev/null +++ b/assets/components/ProjectNav.tsx @@ -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) => ( + + `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} + + ); + + return ( +
+ + TQ-Slator + + +
+ + {item(`/p/${projectUuid}/editor`, 'Éditeur')} + {canAdminister && item(`/p/${projectUuid}/members`, 'Membres')} + {canAdminister && item(`/p/${projectUuid}/platforms`, 'Plateformes')} + {canAdminister && item(`/p/${projectUuid}/integration`, 'Intégration')} + + + {user?.name} + + +
+ ); +} diff --git a/assets/components/RequiresAdmin.tsx b/assets/components/RequiresAdmin.tsx new file mode 100644 index 0000000..ad44c8b --- /dev/null +++ b/assets/components/RequiresAdmin.tsx @@ -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 ( +
+
+ +
+ +
+
+

Cette page est réservée aux administrateurs

+

+ 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. +

+ + Revenir à l'éditeur + +
+
+
+ ); +} diff --git a/assets/components/SourceText.tsx b/assets/components/SourceText.tsx new file mode 100644 index 0000000..2c9a1ed --- /dev/null +++ b/assets/components/SourceText.tsx @@ -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 ( + + {parts.map((part, index) => + part.kind === 'text' ? ( + {part.text} + ) : ( + + {part.text} + + ), + )} + + {/* Le message varie selon un nombre : le signaler sans montrer la + mécanique, que l'éditeur de pluriels prend en charge. */} + {hasPlural && ( + + pluriel + + )} + + ); +} + +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; +} diff --git a/assets/components/Status.tsx b/assets/components/Status.tsx new file mode 100644 index 0000000..981f1b0 --- /dev/null +++ b/assets/components/Status.tsx @@ -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 ( + + ); +} + +export function StatusBadge({ status }: { status: TranslationStatus }) { + const meta = STATUS_META[status]; + + return ( + + + {meta.label} + + ); +} + +/** + * 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
; + } + + const pct = (value: number) => `${(value / total) * 100}%`; + + return ( +
+
+
+
+
+
+ ); +} diff --git a/assets/editor/ContextPanel.tsx b/assets/editor/ContextPanel.tsx new file mode 100644 index 0000000..9e9d8fe --- /dev/null +++ b/assets/editor/ContextPanel.tsx @@ -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 où 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 ( + + ); + } + + return ( + + ); +} + +const STATUS_LABEL: Record = { + untranslated: 'à traduire', + draft: 'brouillon', + needs_review: 'à revoir', + translated: 'traduite', + reviewed: 'validée', +}; + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} diff --git a/assets/editor/FocusMode.tsx b/assets/editor/FocusMode.tsx new file mode 100644 index 0000000..326ed2f --- /dev/null +++ b/assets/editor/FocusMode.tsx @@ -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 où 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>(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 ( +
+
+ + {Math.min(index + 1, rows.length)} / {rows.length} + + +
+
+
+ + {done.size} traitée{done.size > 1 ? 's' : ''} + + +
+ +
+ {queue.isLoading &&

Constitution de la file…

} + + {!queue.isLoading && current === undefined && ( +
+

File terminée.

+

+ {done.size > 0 + ? `${done.size} traduction${done.size > 1 ? 's' : ''} enregistrée${done.size > 1 ? 's' : ''}.` + : 'Aucune clé ne restait à traiter.'} +

+ +
+ )} + + {current !== undefined && ( +
+
+

+ {current.keyPath} +

+ {current.platforms.map((slug) => ( + + {slug} + + ))} +
+ + {current.description !== null && current.description !== '' && ( +

+ {current.description} +

+ )} + +

+ {current.sourceValue !== null && } +

+ +
+ setDone((prev) => new Set(prev).add(current.id))} + onRequestNext={next} + /> +
+ +
+ + +

+ + ⌘↵{' '} + enregistrer et suivante + + + Échap{' '} + quitter + +

+ + +
+
+ )} +
+
+ ); +} diff --git a/assets/editor/KeyFocusMode.tsx b/assets/editor/KeyFocusMode.tsx new file mode 100644 index 0000000..15ac77f --- /dev/null +++ b/assets/editor/KeyFocusMode.tsx @@ -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>(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 ( +
+
+ + {Math.min(index + 1, rows.length)} / {rows.length} + + +
+
+
+ + + {done.size} traitée{done.size > 1 ? 's' : ''} + + + + {writable.map((l) => l.code).join(' · ')} + + + +
+ +
+ {queue.isLoading &&

Constitution de la file…

} + + {!queue.isLoading && writable.length === 0 && ( +
+

Aucune langue à traiter.

+

+ Vous n'êtes habilité à écrire dans aucune langue de ce projet. Le mode + Focus n'a rien à vous proposer ; la consultation reste ouverte. +

+ +
+ )} + + {!queue.isLoading && writable.length > 0 && current === undefined && ( +
+

File terminée.

+

+ {done.size > 0 + ? `${done.size} clé${done.size > 1 ? 's' : ''} traitée${done.size > 1 ? 's' : ''}.` + : 'Aucune clé ne restait à traiter dans vos langues.'} +

+ +
+ )} + + {current !== undefined && writable.length > 0 && ( +
+
+

+ {current.keyPath} +

+ {current.platforms.map((slug) => ( + + {slug} + + ))} + + {current.completion}% + +
+ + {current.description !== null && current.description !== '' && ( +

+ {current.description} +

+ )} + +

+ {current.sourceValue !== null && } +

+ + {/* 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. */} +
+ {todo.map((locale, position) => { + const target = byCode.get(locale.code); + + return ( +
{ + fieldRefs.current[position] = element; + }} + > +
+ + + {locale.code} + + + {locale.nativeName} + + {target?.isStale === true && ( + + source modifiée + + )} +
+ + + setDone((prev) => new Set(prev).add(current.id)) + } + onRequestNext={() => advanceFrom(position)} + /> +
+ ); + })} +
+ + {/* Les langues déjà faites, en lecture. Une traduction + voisine tranche souvent mieux un registre que la + source elle-même. */} + {reference.length > 0 && ( +
+

+ Déjà traduites +

+
    + {reference.map((target) => ( +
  • + + {target.locale} + + {target.value} +
  • + ))} +
+
+ )} + +
+ + +

+ + ⌘↵{' '} + enregistrer et descendre + + + Échap{' '} + quitter + +

+ + +
+
+ )} +
+
+ ); +} diff --git a/assets/editor/KeyGrid.tsx b/assets/editor/KeyGrid.tsx new file mode 100644 index 0000000..9989bcd --- /dev/null +++ b/assets/editor/KeyGrid.tsx @@ -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(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 ( +
+ Chargement des clés… +
+ ); + } + + if (rows.length === 0) { + return ( +
+

Aucune clé ne correspond.

+

+ En vue par clé, le filtre de statut retient les clés dont{' '} + au moins une langue est dans cet état. +

+
+ ); + } + + return ( +
+
+ + {rows.length < total ? `${rows.length} sur ${total}` : `${total}`} clé + {total > 1 ? 's' : ''} + + + {locales.length} langue{locales.length > 1 ? 's' : ''} cible + {locales.length > 1 ? 's' : ''} + +
+ +
+
+ {virtualizer.getVirtualItems().map((item) => { + const row = rows[item.index]; + if (!row) return null; + + return ( +
+ onSelect(row.id)} + /> +
+ ); + })} +
+
+
+ ); +} + +function KeyCard({ + row, + locales, + sourceCode, + selected, + onSelect, +}: { + row: KeyRow; + locales: LocaleStats[]; + sourceCode: string; + selected: boolean; + onSelect: () => void; +}) { + const [openLocale, setOpenLocale] = useState(null); + + const byCode = new Map(row.targets.map((target) => [target.locale, target])); + + return ( +
+
+ {row.keyPath} + + + {row.platforms.map((slug) => ( + + {slug} + + ))} + {row.platforms.length === 0 && ( + + non assignée + + )} + + {row.completion}% + + +
+ +

+ + {sourceCode} + + + {row.sourceValue === null ? ( + source absente + ) : ( + + )} + +

+ + {/* La bande de langues : une ligne par langue, refermée. Elle répond + à « où en est cette clé ? » sans rien ouvrir. */} +
+ {locales.map((locale) => { + const target = byCode.get(locale.code); + const open = openLocale === locale.code; + + return ( + setOpenLocale(open ? null : locale.code)} + /> + ); + })} +
+
+ ); +} + +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 ( +
+ + + {open && ( +
+ {locale.canWrite ? ( + + ) : ( +

+ {value ?? '—'} +

+ )} + + {target?.updatedBy != null && ( +

+ {target.updatedBy} + {target.updatedAt !== null && + ` · ${new Date(target.updatedAt).toLocaleDateString('fr-FR')}`} + {target.isMachineTranslated && ' · traduction automatique'} +

+ )} +
+ )} +
+ ); +} diff --git a/assets/editor/NamespaceTree.tsx b/assets/editor/NamespaceTree.tsx new file mode 100644 index 0000000..c19e5e7 --- /dev/null +++ b/assets/editor/NamespaceTree.tsx @@ -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 ( + + ); +} diff --git a/assets/editor/TranslationGrid.tsx b/assets/editor/TranslationGrid.tsx new file mode 100644 index 0000000..c6679c6 --- /dev/null +++ b/assets/editor/TranslationGrid.tsx @@ -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(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 ( +
+ Chargement des clés… +
+ ); + } + + if (rows.length === 0) { + return ( +
+

Aucune clé ne correspond.

+

+ Élargissez les filtres, ou vérifiez que la plateforme sélectionnée porte bien + des clés. +

+
+ ); + } + + return ( +
+
+ + {rows.length < total ? `${rows.length} sur ${total}` : `${total}`} clé + {total > 1 ? 's' : ''} + + {locale} +
+ +
+
+ {virtualizer.getVirtualItems().map((item) => { + const row = rows[item.index]; + if (!row) return null; + + const selected = row.id === selectedId; + + return ( +
+
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' + }`} + > +
+ + + + {row.keyPath} + + + {/* La source modifiée est LE signal qui compte : + il désigne un texte peut-être faux en production. */} + {row.isStale && ( + + source modifiée + + )} + + + {row.platforms.map((slug) => ( + + {slug} + + ))} + {row.platforms.length === 0 && ( + + non assignée + + )} + +
+ +

+ {row.sourceValue === null ? ( + source absente + ) : ( + + )} +

+ +
+ {readOnly ? ( +

+ {row.targetValue ?? '—'} +

+ ) : ( + + )} +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/assets/editor/TranslationInput.tsx b/assets/editor/TranslationInput.tsx new file mode 100644 index 0000000..ee8cc72 --- /dev/null +++ b/assets/editor/TranslationInput.tsx @@ -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-là ; 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>(decomposed?.branches ?? {}); + const [state, setState] = useState('idle'); + const [error, setError] = useState(null); + const [warnings, setWarnings] = useState([]); + + 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 ( +
+ {plural ? ( +
+ {selectors.map((selector) => ( +
+ + 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" + /> +
+ ))} +
+ ) : ( +