AppLocale : proposer le français

Ajoute 'fr' à AppLocale et à framework.enabled_locales, qui doivent
rester synchronisés (le commentaire de translation.yaml le dit). Le
sélecteur de langue des réglages et celui de l'installation lisent
AppLocale::cases() : « 🇫🇷 Français » y apparaît sans autre
modification.

Deux réglages suivent la langue et devaient connaître le français :
- ClockFormat::forLocale() : le français écrit 14 h 00, donc 24 heures
  par défaut comme l'allemand. Sans ça, un utilisateur francophone qui
  n'a rien choisi verrait « 2 PM ». Testé dans ClockFormatResolverTest.
- Le sélecteur d'emoji charge ses noms et mots-clés par langue (la
  recherche « cœur » ne trouve rien dans les données anglaises).
  assets/emoji/fr/data.json vient de emoji-picker-element-data, comme
  en/ et de/. J'ai vérifié que ces deux fichiers sont identiques octet
  pour octet à ceux publiés par le paquet, donc fr/ vient bien de la
  même source. Le gabarit choisit maintenant de, fr ou en d'après la
  langue au lieu du seul test « starts with 'de' ».

Laissé tel quel : DeterministicDateDetector, qui lit les dates dans le
contenu des mails selon leur langue, et non la langue de l'interface.
Apprendre les dates françaises (« le 4 août ») est un autre chantier.

Docs : README, features/appearance.md (et sa traduction allemande, avec
le hash mis à jour), et une entrée de CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
This commit is contained in:
jo2904andClaude Opus 5.5 committed 2026-10-13 23:33:38 +02:00
1 parent 32f6926df8
commit 5550727888
11 files changed
+24 -8

No files matched your search

+7
View File
@@ -6,6 +6,13 @@ so anything that changes the schema irreversibly is called out explicitly.
The published image tags: `latest` follows the most recent release below, The published image tags: `latest` follows the most recent release below,
`main` follows the tip of the default branch, and `sha-…` pins one commit. `main` follows the tip of the default branch, and `sha-…` pins one commit.
## Unreleased
**plMail speaks French.** Choose **Français** under **Settings → General → Language**, or when
creating the first account. The whole interface is translated, including the admin panel, the
validation and sign-in messages and the emoji picker's search; French also defaults to the
24-hour clock, as German does. No migration.
## v0.3.5 — 2026-10-13 ## v0.3.5 — 2026-10-13
**Browsers and password managers kept offering your logins in client-secret fields.** Opening **Browsers and password managers kept offering your logins in client-secret fields.** Opening
+1 -1
View File
@@ -80,7 +80,7 @@ provider doesn't.
with what changed since the one they run. with what changed since the one they run.
- **Yours** — two-factor auth, sign-in through Authentik, Keycloak or any other OpenID Connect - **Yours** — two-factor auth, sign-in through Authentik, Keycloak or any other OpenID Connect
provider, per-app JMAP passwords, 41 themes, ten icons for the tab and the phone's home screen, provider, per-app JMAP passwords, 41 themes, ten icons for the tab and the phone's home screen,
English, German and Pirate. English, German, French and Pirate.
## A look around ## A look around
+1
View File
@@ -0,0 +1 @@
[{"shortcodes":["visage_rieur"],"annotation":"visage rieur","tags":["content","dents","grand sourire","heureux","joyeux","rire","sourire","super","visage","visage très souriant","émoticône"],"emoji":"😀","order":1,"group":0,"version":1},{"shortcodes":["visage_souriant_avec_de_grands_yeux"],"annotation":"visage souriant avec de grands yeux","tags":["content","dents","formidable","grand sourire","heureux","sourire","super","visage très souriant avec la bouche ouverte","émoticône"],"emoji":"😃","order":2,"group":0,"version":0.6},{"shortcodes":["visage_tres_souriant_aux_yeux_rieurs"],"annotation":"visage très souriant aux yeux rieurs","tags":["content","grand sourire","heureux","joyeux","lol","mdr","rire","sourire","sourire mignon","visage avec la bouche ouverte et les yeux souriants","émoticône"],"emoji":"😄","order":3,"group":0,"version":0.6,"emoticon":":D"},{"shortcodes":["visage_souriant_aux_yeux_rieurs"],"annotation":"visage souriant aux yeux rieurs","tags":["content","dents","grand sourire","heureux","sourire","sourire de toutes ses dents","super","visage","visage souriant avec yeux souriants","yeux souriants","émoticône"],"emoji":"😁","order":4,"group":0,"version":0.6},{"shortcodes":["visage_souriant_avec_yeux_plisses"],"annotation":"visage souriant avec yeux plissés","tags":["ahah","bouche","content","lol","mdr","rire","sourire","visage","visage souriant avec bouche ouverte et yeux fermés","émoticône"],"emoji":"😆","order":5,"group":0,"version":0.6,"emoticon":"XD"},{"shortcodes":["visage_souriant_avec_une_goutte_de_sueur"],"annotation":"visage souriant avec une goutte de sueur","tags":["excité","froid","goutte","sourire","sourire nerveux","stressé","sueur","sueurs froides","émoticône"],"emoji":"😅","order":6,"group":0,"version":0.6},{"shortcodes":["se_rouler_par_terre_de_rire"],"annotation":"se rouler par terre de rire","tags":["content","drôle","hilare","joyeux","larmes","lol","mdr","pleurer de rire","rire","rire aux larmes","se rouler par terre","se tordre de rire","visage","visage qui rit aux larmes","émoticône"],"emoji":"🤣","order":7,"group":0,"version":3,"emoticon":":'D"},{"shortcodes":["visage_riant_aux_larmes"],"annotation":"visage riant aux larmes","tags":["content","heureux","joie","larmes","lol","mdr","pleurer de joie","pleurer de rire","rire aux larmes","sourire","émoticône"],"emoji":"😂","order":8,"group":0,"version":0.6,"emoticon":":')"},{"shortcodes":["visage_avec_un_leger_sourire"],"annotation":"visage avec un léger sourire","tags":["content","heureux","léger sourire","sourire","visage souriant","émoticône"],"emoji":"🙂","order":9,"group":0,"version":1,"emoticon":":)"},{"shortcodes":["tete_a_lenvers"],"annotation":"tête à l’envers","tags":["incroyable","renversée","tête","visage","émoticône"],"emoji":"🙃","order":10,"group":0,"version":1},{"shortcodes":["visage_qui_fond"],"annotation":"visage qui fond","tags":["disparaître","fondre","gêne","liquide","liquéfié","sarcasme","se dissoudre"],"emoji":"🫠","order":11,"group":0,"version":14},{"shortcodes":["visage_faisant_un_clin_doeil"],"annotation":"visage faisant un clin d’œil","tags":["blague","clin d’œil","flirter","plaisanter","sexy","visage","émoticône"],"emoji":"😉","order":12,"group":0,"version":0.6,"emoticon":";)"},{"shortcodes":["visage_souriant_avec_yeux_rieurs"],"annotation":"visage souriant avec yeux rieurs","tags":["content","heureux","rougir","satisfait","sourire","visage","visage qui rougit","visage souriant avec yeux souriants","yeux","émoticône","œil"],"emoji":"😊","order":13,"group":0,"version":0.6,"emoticon":":>"},{"shortcodes":["visage_souriant_avec_aureole"],"annotation":"visage souriant avec auréole","tags":["ange","angélique","auréole","bible","béni","heureux","innocent","paix","sourire","spirituel","émoticône"],"emoji":"😇","order":14,"group":0,"version":1,"emoticon":"O:)"},{"shortcodes":["visage_souriant_avec_coeurs"],"annotation":"visage souriant avec cœurs","tags":["adorer","aimer","amour","béguin","coeur","cœurs","je t’aime","j’adore","smiley avec 3 coeurs","sourire"],"emoji":"🥰","order":15,"group":0,"version":11},{"shortcodes":["visage_souriant_avec_yeux_en_forme_de_coeur"],"annotation":"visage souriant avec yeux en forme de cœur","tags":["amour","amoureux","cœur","romantique","sourire","visage","yeux","émoticône","œil"],"emoji":"😍","order":16,"group":0,"version":0.6},{"shortcodes":["visage_avec_des_etoiles_a_la_place_des_yeux"],"annotation":"visage avec des étoiles à la place des yeux","tags":["grand sourire","heureux","pressé","surexcité","tête avec étoiles","visage","wow","yeux","étoile","étoiles dans les yeux"],"emoji":"🤩","order":17,"group":0,"version":5},{"shortcodes":["visage_envoyant_un_bisou"],"annotation":"visage envoyant un bisou","tags":["amour","baiser","bise","bisou et cœur","bonjour","bonne nuit","cœur","embrasser","je t’aime","visage qui fait une bise avec un cœur","émoticône"],"Line truncated
+1 -1
View File
@@ -1,7 +1,7 @@
framework: framework:
default_locale: en default_locale: en
# Keep in sync with App\Domain\Enum\AppLocale and translations/messages.*.yaml. # Keep in sync with App\Domain\Enum\AppLocale and translations/messages.*.yaml.
enabled_locales: ['en', 'de', 'en_PI'] enabled_locales: ['en', 'de', 'fr', 'en_PI']
translator: translator:
default_path: '%kernel.project_dir%/translations' default_path: '%kernel.project_dir%/translations'
providers: providers:
+2 -2
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/appearance.md sha1:43f57642347736db71ec013bef9e22bfb70ee747 --> <!-- translated-from: features/appearance.md sha1:963b5496c55b39c78ef14815a4c3c0e5b2ae8f78 -->
# Darstellung # Darstellung
@@ -281,7 +281,7 @@ wurde, bevor es ein bestimmtes Bedienelement gab, trotzdem irgendwo Sinnvollem l
wird. Deine Mail bleibt genau so, wie sie geschrieben wurde — es wird nichts übersetzt und wird. Deine Mail bleibt genau so, wie sie geschrieben wurde — es wird nichts übersetzt und
nichts umgeschrieben. nichts umgeschrieben.
plMail liefert **English**, **Deutsch** und **Pirate English** mit. Eine Änderung lädt die plMail liefert **English**, **Deutsch**, **Français** und **Pirate English** mit. Eine Änderung lädt die
Seite neu, statt sie nachzubessern, denn jede Zeichenkette auf dem Bildschirm muss neu Seite neu, statt sie nachzubessern, denn jede Zeichenkette auf dem Bildschirm muss neu
gerendert werden. gerendert werden.
+1 -1
View File
@@ -256,7 +256,7 @@ sensible.
**Settings → General → Language** sets the language the interface is shown in. Your mail is left **Settings → General → Language** sets the language the interface is shown in. Your mail is left
exactly as it was written — nothing is translated, and nothing is rewritten. exactly as it was written — nothing is translated, and nothing is rewritten.
plMail ships **English**, **Deutsch** and **Pirate English**. Changing it reloads the page rather plMail ships **English**, **Deutsch**, **Français** and **Pirate English**. Changing it reloads the page rather
than patching it, because every string on screen has to be re-rendered. than patching it, because every string on screen has to be re-rendered.
The same section carries **Time zone**, which decides the clock times and dates you are shown. It The same section carries **Time zone**, which decides the clock times and dates you are shown. It
+3
View File
@@ -14,6 +14,7 @@ enum AppLocale: string
{ {
case English = 'en'; case English = 'en';
case German = 'de'; case German = 'de';
case French = 'fr';
case Pirate = 'en_PI'; case Pirate = 'en_PI';
/** /**
@@ -24,6 +25,7 @@ enum AppLocale: string
return match ($this) { return match ($this) {
self::English => 'English', self::English => 'English',
self::German => 'Deutsch', self::German => 'Deutsch',
self::French => 'Français',
self::Pirate => 'Pirate English', self::Pirate => 'Pirate English',
}; };
} }
@@ -36,6 +38,7 @@ enum AppLocale: string
return match ($this) { return match ($this) {
self::English => '🇬🇧', self::English => '🇬🇧',
self::German => '🇩🇪', self::German => '🇩🇪',
self::French => '🇫🇷',
self::Pirate => '🏴‍☠️', self::Pirate => '🏴‍☠️',
}; };
} }
+2 -1
View File
@@ -87,7 +87,8 @@ enum ClockFormat: string
public static function forLocale(?AppLocale $locale): self public static function forLocale(?AppLocale $locale): self
{ {
return match ($locale) { return match ($locale) {
AppLocale::German => self::TwentyFour, AppLocale::German,
AppLocale::French => self::TwentyFour,
default => self::Twelve, default => self::Twelve,
}; };
} }
+1 -1
View File
@@ -883,7 +883,7 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
<div data-controller="ui--dropdown compose--emoji-picker" <div data-controller="ui--dropdown compose--emoji-picker"
data-action="ui--dropdown:opened->compose--emoji-picker#build" data-action="ui--dropdown:opened->compose--emoji-picker#build"
data-compose--emoji-picker-locale-value="{{ app.request.locale|split('_')|first }}" data-compose--emoji-picker-locale-value="{{ app.request.locale|split('_')|first }}"
data-compose--emoji-picker-data-source-value="{{ asset('emoji/' ~ (app.request.locale starts with 'de' ? 'de' : 'en') ~ '/data.json') }}" data-compose--emoji-picker-data-source-value="{{ asset('emoji/' ~ (app.request.locale|split('_')|first in ['de', 'fr'] ? app.request.locale|split('_')|first : 'en') ~ '/data.json') }}"
data-compose--emoji-picker-i18n-value="{{ { data-compose--emoji-picker-i18n-value="{{ {
categoriesLabel: 'compose.emoji.categories'|trans, categoriesLabel: 'compose.emoji.categories'|trans,
emojiUnsupportedMessage: 'compose.emoji.unsupported'|trans, emojiUnsupportedMessage: 'compose.emoji.unsupported'|trans,
+1 -1
View File
@@ -39,7 +39,7 @@ final class EmbeddingPresetTest extends KernelTestCase
$translator = self::getContainer()->get(TranslatorInterface::class); $translator = self::getContainer()->get(TranslatorInterface::class);
foreach (EmbeddingPreset::ordered() as $preset) { foreach (EmbeddingPreset::ordered() as $preset) {
foreach (['en', 'de', 'en_PI'] as $locale) { foreach (['en', 'de', 'fr', 'en_PI'] as $locale) {
$key = $preset->summaryKey(); $key = $preset->summaryKey();
$text = $translator->trans($key, [], 'messages', $locale); $text = $translator->trans($key, [], 'messages', $locale);
@@ -49,8 +49,12 @@ final class ClockFormatResolverTest extends TestCase
$english = new User(); $english = new User();
$english->locale = 'en'; $english->locale = 'en';
$french = new User();
$french->locale = 'fr';
self::assertSame(ClockFormat::TwentyFour, $this->resolver()->resolve($german)); self::assertSame(ClockFormat::TwentyFour, $this->resolver()->resolve($german));
self::assertSame(ClockFormat::Twelve, $this->resolver()->resolve($english)); self::assertSame(ClockFormat::Twelve, $this->resolver()->resolve($english));
self::assertSame(ClockFormat::TwentyFour, $this->resolver()->resolve($french), 'French writes 14 h 00, never 2 PM');
} }
/** A user with no locale of their own follows the install's. */ /** A user with no locale of their own follows the install's. */