Add support for private and hashtag channels in localization and channel management

- Updated Polish, Portuguese, Slovak, Slovenian, Swedish, and Chinese localization files to include new strings for creating and joining private channels, as well as joining hashtag channels.
- Enhanced the channel management UI to allow users to create and join private channels, join public channels, and join channels via hashtags.
- Implemented PSK derivation from hashtags using SHA256 in the Channel model.
- Improved the translation script to handle missing keys and translate all locales efficiently.
This commit is contained in:
zjs81 2026-01-16 19:06:39 -07:00
parent a14462978d
commit 14ff8250c0
30 changed files with 1250 additions and 141 deletions

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Повторители",
"listFilter_roomServers": "Сървъри на стая",
"listFilter_unreadOnly": "Само непрочетените",
"listFilter_newGroup": "Нова група"
"listFilter_newGroup": "Нова група",
"channels_createPrivateChannel": "Създай Частен Канал",
"channels_joinPrivateChannel": "Присъедини се към Частен Канал",
"channels_createPrivateChannelDesc": "Защитено с таен ключ.",
"channels_joinPrivateChannelDesc": "Ръчно въведете таен ключ.",
"channels_joinPublicChannel": "Присъединете се към Публичния канал",
"channels_joinPublicChannelDesc": "Всеки може да се присъедини към този канал.",
"channels_joinHashtagChannel": "Присъедини се към Хаштаг Канал",
"channels_joinHashtagChannelDesc": "Всеки може да се присъедини към хаштаговите канали.",
"channels_scanQrCode": "Сканирайте QR код",
"channels_scanQrCodeComingSoon": "Ще излезе скоро",
"channels_enterHashtag": "Въведете хаштаг",
"channels_hashtagHint": "напр. #отбор"
}

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Wiederholer",
"listFilter_roomServers": "Raumserver",
"listFilter_unreadOnly": "Nur nicht gelesen",
"listFilter_newGroup": "Neue Gruppe"
"listFilter_newGroup": "Neue Gruppe",
"channels_joinPrivateChannel": "Treten Sie einem privaten Kanal bei",
"channels_joinPrivateChannelDesc": "Manuelle Eingabe eines geheimen Schlüssels.",
"channels_createPrivateChannel": "Erstelle einen privaten Kanal",
"channels_createPrivateChannelDesc": "Verschlüsselt mit einem geheimen Schlüssel.",
"channels_joinPublicChannel": "Tritt dem öffentlichen Kanal bei",
"channels_joinPublicChannelDesc": "Jeder kann diesem Kanal beitreten.",
"channels_joinHashtagChannel": "Treten Sie einem Hashtag-Kanal bei",
"channels_joinHashtagChannelDesc": "Jeder kann sich bei Hashtag-Kanälen beteiligen.",
"channels_scanQrCode": "Scannen Sie einen QR-Code",
"channels_scanQrCodeComingSoon": "Bald verfügbar",
"channels_enterHashtag": "Gib Hashtag ein",
"channels_hashtagHint": "z.B. #team"
}

View file

@ -361,6 +361,18 @@
"channels_sortAZ": "A-Z",
"channels_sortLatestMessages": "Latest messages",
"channels_sortUnread": "Unread",
"channels_createPrivateChannel": "Create a Private Channel",
"channels_createPrivateChannelDesc": "Secured with a secret key.",
"channels_joinPrivateChannel": "Join a Private Channel",
"channels_joinPrivateChannelDesc": "Manually enter a secret key.",
"channels_joinPublicChannel": "Join the Public Channel",
"channels_joinPublicChannelDesc": "Anyone can join this channel.",
"channels_joinHashtagChannel": "Join a Hashtag Channel",
"channels_joinHashtagChannelDesc": "Anyone can join hashtag channels.",
"channels_scanQrCode": "Scan a QR Code",
"channels_scanQrCodeComingSoon": "Coming soon",
"channels_enterHashtag": "Enter hashtag",
"channels_hashtagHint": "e.g. #team",
"chat_noMessages": "No messages yet",
"chat_sendMessageToStart": "Send a message to get started",

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Repetidores",
"listFilter_roomServers": "Servidores de la sala",
"listFilter_unreadOnly": "Solo sin leer",
"listFilter_newGroup": "Nuevo grupo"
"listFilter_newGroup": "Nuevo grupo",
"channels_joinPrivateChannel": "Únete a un Canal Privado",
"channels_createPrivateChannel": "Crear un Canal Privado",
"channels_createPrivateChannelDesc": "Cifrado con una clave secreta.",
"channels_joinPrivateChannelDesc": "Introducir manualmente una clave secreta.",
"channels_joinPublicChannel": "Únete al Canal Público",
"channels_joinPublicChannelDesc": "Cualquiera puede unirse a este canal.",
"channels_joinHashtagChannel": "Únete a un Canal con Hashtag",
"channels_joinHashtagChannelDesc": "Cualquiera puede unirse a los canales de hashtag.",
"channels_scanQrCode": "Escanear un Código QR",
"channels_scanQrCodeComingSoon": "Próximamente",
"channels_enterHashtag": "Introducir hashtag",
"channels_hashtagHint": "ej. #equipo"
}

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Répéteurs",
"listFilter_roomServers": "Serveurs de pièce",
"listFilter_unreadOnly": "Messages non lus seulement",
"listFilter_newGroup": "Nouvelle groupe"
"listFilter_newGroup": "Nouvelle groupe",
"channels_createPrivateChannelDesc": "Sécurisé avec une clé secrète.",
"channels_joinPrivateChannel": "Rejoindre un Canal Privé",
"channels_createPrivateChannel": "Créer un Canal Privé",
"channels_joinPrivateChannelDesc": "Entrer manuellement une clé secrète.",
"channels_joinPublicChannel": "Rejoindre le canal public",
"channels_joinPublicChannelDesc": "Tout le monde peut rejoindre ce canal.",
"channels_joinHashtagChannel": "Rejoindre un Canal Hashtag",
"channels_joinHashtagChannelDesc": "N'importe qui peut rejoindre les canaux #hashtag.",
"channels_scanQrCode": "Scanner un code QR",
"channels_scanQrCodeComingSoon": "Bientôt disponible",
"channels_enterHashtag": "Entrez le hashtag",
"channels_hashtagHint": "ex. #équipe"
}

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Ripetitori",
"listFilter_roomServers": "Server della stanza",
"listFilter_unreadOnly": "Solo non letto",
"listFilter_newGroup": "Nuovo gruppo"
"listFilter_newGroup": "Nuovo gruppo",
"channels_createPrivateChannel": "Crea un Canale Privato",
"channels_createPrivateChannelDesc": "Protetta con una chiave segreta.",
"channels_joinPrivateChannel": "Unisciti a un Canale Privato",
"channels_joinPrivateChannelDesc": "Inserire manualmente una chiave segreta.",
"channels_joinPublicChannel": "Unisciti al Canale Pubblico",
"channels_joinPublicChannelDesc": "Chiunque può unirsi a questo canale.",
"channels_joinHashtagChannel": "Unisciti a un Canale con Hashtag",
"channels_joinHashtagChannelDesc": "Chiunque può unirsi ai canali hashtag.",
"channels_scanQrCode": "Scansiona un codice QR",
"channels_scanQrCodeComingSoon": "Arriverà presto",
"channels_enterHashtag": "Inserisci hashtag",
"channels_hashtagHint": "es. #team"
}

View file

@ -1596,6 +1596,78 @@ abstract class AppLocalizations {
/// **'Unread'**
String get channels_sortUnread;
/// No description provided for @channels_createPrivateChannel.
///
/// In en, this message translates to:
/// **'Create a Private Channel'**
String get channels_createPrivateChannel;
/// No description provided for @channels_createPrivateChannelDesc.
///
/// In en, this message translates to:
/// **'Secured with a secret key.'**
String get channels_createPrivateChannelDesc;
/// No description provided for @channels_joinPrivateChannel.
///
/// In en, this message translates to:
/// **'Join a Private Channel'**
String get channels_joinPrivateChannel;
/// No description provided for @channels_joinPrivateChannelDesc.
///
/// In en, this message translates to:
/// **'Manually enter a secret key.'**
String get channels_joinPrivateChannelDesc;
/// No description provided for @channels_joinPublicChannel.
///
/// In en, this message translates to:
/// **'Join the Public Channel'**
String get channels_joinPublicChannel;
/// No description provided for @channels_joinPublicChannelDesc.
///
/// In en, this message translates to:
/// **'Anyone can join this channel.'**
String get channels_joinPublicChannelDesc;
/// No description provided for @channels_joinHashtagChannel.
///
/// In en, this message translates to:
/// **'Join a Hashtag Channel'**
String get channels_joinHashtagChannel;
/// No description provided for @channels_joinHashtagChannelDesc.
///
/// In en, this message translates to:
/// **'Anyone can join hashtag channels.'**
String get channels_joinHashtagChannelDesc;
/// No description provided for @channels_scanQrCode.
///
/// In en, this message translates to:
/// **'Scan a QR Code'**
String get channels_scanQrCode;
/// No description provided for @channels_scanQrCodeComingSoon.
///
/// In en, this message translates to:
/// **'Coming soon'**
String get channels_scanQrCodeComingSoon;
/// No description provided for @channels_enterHashtag.
///
/// In en, this message translates to:
/// **'Enter hashtag'**
String get channels_enterHashtag;
/// No description provided for @channels_hashtagHint.
///
/// In en, this message translates to:
/// **'e.g. #team'**
String get channels_hashtagHint;
/// No description provided for @chat_noMessages.
///
/// In en, this message translates to:

View file

@ -830,6 +830,45 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get channels_sortUnread => 'Непрочетено';
@override
String get channels_createPrivateChannel => 'Създай Частен Канал';
@override
String get channels_createPrivateChannelDesc => 'Защитено с таен ключ.';
@override
String get channels_joinPrivateChannel => 'Присъедини се към Частен Канал';
@override
String get channels_joinPrivateChannelDesc => 'Ръчно въведете таен ключ.';
@override
String get channels_joinPublicChannel =>
'Присъединете се към Публичния канал';
@override
String get channels_joinPublicChannelDesc =>
'Всеки може да се присъедини към този канал.';
@override
String get channels_joinHashtagChannel => 'Присъедини се към Хаштаг Канал';
@override
String get channels_joinHashtagChannelDesc =>
'Всеки може да се присъедини към хаштаговите канали.';
@override
String get channels_scanQrCode => 'Сканирайте QR код';
@override
String get channels_scanQrCodeComingSoon => 'Ще излезе скоро';
@override
String get channels_enterHashtag => 'Въведете хаштаг';
@override
String get channels_hashtagHint => 'напр. #отбор';
@override
String get chat_noMessages => 'Няма съобщения.';

View file

@ -828,6 +828,48 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get channels_sortUnread => 'Unlescht';
@override
String get channels_createPrivateChannel => 'Erstelle einen privaten Kanal';
@override
String get channels_createPrivateChannelDesc =>
'Verschlüsselt mit einem geheimen Schlüssel.';
@override
String get channels_joinPrivateChannel =>
'Treten Sie einem privaten Kanal bei';
@override
String get channels_joinPrivateChannelDesc =>
'Manuelle Eingabe eines geheimen Schlüssels.';
@override
String get channels_joinPublicChannel => 'Tritt dem öffentlichen Kanal bei';
@override
String get channels_joinPublicChannelDesc =>
'Jeder kann diesem Kanal beitreten.';
@override
String get channels_joinHashtagChannel =>
'Treten Sie einem Hashtag-Kanal bei';
@override
String get channels_joinHashtagChannelDesc =>
'Jeder kann sich bei Hashtag-Kanälen beteiligen.';
@override
String get channels_scanQrCode => 'Scannen Sie einen QR-Code';
@override
String get channels_scanQrCodeComingSoon => 'Bald verfügbar';
@override
String get channels_enterHashtag => 'Gib Hashtag ein';
@override
String get channels_hashtagHint => 'z.B. #team';
@override
String get chat_noMessages => 'Noch keine Nachrichten.';

View file

@ -818,6 +818,43 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get channels_sortUnread => 'Unread';
@override
String get channels_createPrivateChannel => 'Create a Private Channel';
@override
String get channels_createPrivateChannelDesc => 'Secured with a secret key.';
@override
String get channels_joinPrivateChannel => 'Join a Private Channel';
@override
String get channels_joinPrivateChannelDesc => 'Manually enter a secret key.';
@override
String get channels_joinPublicChannel => 'Join the Public Channel';
@override
String get channels_joinPublicChannelDesc => 'Anyone can join this channel.';
@override
String get channels_joinHashtagChannel => 'Join a Hashtag Channel';
@override
String get channels_joinHashtagChannelDesc =>
'Anyone can join hashtag channels.';
@override
String get channels_scanQrCode => 'Scan a QR Code';
@override
String get channels_scanQrCodeComingSoon => 'Coming soon';
@override
String get channels_enterHashtag => 'Enter hashtag';
@override
String get channels_hashtagHint => 'e.g. #team';
@override
String get chat_noMessages => 'No messages yet';

View file

@ -829,6 +829,46 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get channels_sortUnread => 'Sin leer';
@override
String get channels_createPrivateChannel => 'Crear un Canal Privado';
@override
String get channels_createPrivateChannelDesc =>
'Cifrado con una clave secreta.';
@override
String get channels_joinPrivateChannel => 'Únete a un Canal Privado';
@override
String get channels_joinPrivateChannelDesc =>
'Introducir manualmente una clave secreta.';
@override
String get channels_joinPublicChannel => 'Únete al Canal Público';
@override
String get channels_joinPublicChannelDesc =>
'Cualquiera puede unirse a este canal.';
@override
String get channels_joinHashtagChannel => 'Únete a un Canal con Hashtag';
@override
String get channels_joinHashtagChannelDesc =>
'Cualquiera puede unirse a los canales de hashtag.';
@override
String get channels_scanQrCode => 'Escanear un Código QR';
@override
String get channels_scanQrCodeComingSoon => 'Próximamente';
@override
String get channels_enterHashtag => 'Introducir hashtag';
@override
String get channels_hashtagHint => 'ej. #equipo';
@override
String get chat_noMessages => 'Aún no hay mensajes';

View file

@ -830,6 +830,46 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get channels_sortUnread => 'Non lu';
@override
String get channels_createPrivateChannel => 'Créer un Canal Privé';
@override
String get channels_createPrivateChannelDesc =>
'Sécurisé avec une clé secrète.';
@override
String get channels_joinPrivateChannel => 'Rejoindre un Canal Privé';
@override
String get channels_joinPrivateChannelDesc =>
'Entrer manuellement une clé secrète.';
@override
String get channels_joinPublicChannel => 'Rejoindre le canal public';
@override
String get channels_joinPublicChannelDesc =>
'Tout le monde peut rejoindre ce canal.';
@override
String get channels_joinHashtagChannel => 'Rejoindre un Canal Hashtag';
@override
String get channels_joinHashtagChannelDesc =>
'N\'importe qui peut rejoindre les canaux #hashtag.';
@override
String get channels_scanQrCode => 'Scanner un code QR';
@override
String get channels_scanQrCodeComingSoon => 'Bientôt disponible';
@override
String get channels_enterHashtag => 'Entrez le hashtag';
@override
String get channels_hashtagHint => 'ex. #équipe';
@override
String get chat_noMessages => 'Aucun message pour le moment.';

View file

@ -827,6 +827,46 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get channels_sortUnread => 'Non letto';
@override
String get channels_createPrivateChannel => 'Crea un Canale Privato';
@override
String get channels_createPrivateChannelDesc =>
'Protetta con una chiave segreta.';
@override
String get channels_joinPrivateChannel => 'Unisciti a un Canale Privato';
@override
String get channels_joinPrivateChannelDesc =>
'Inserire manualmente una chiave segreta.';
@override
String get channels_joinPublicChannel => 'Unisciti al Canale Pubblico';
@override
String get channels_joinPublicChannelDesc =>
'Chiunque può unirsi a questo canale.';
@override
String get channels_joinHashtagChannel => 'Unisciti a un Canale con Hashtag';
@override
String get channels_joinHashtagChannelDesc =>
'Chiunque può unirsi ai canali hashtag.';
@override
String get channels_scanQrCode => 'Scansiona un codice QR';
@override
String get channels_scanQrCodeComingSoon => 'Arriverà presto';
@override
String get channels_enterHashtag => 'Inserisci hashtag';
@override
String get channels_hashtagHint => 'es. #team';
@override
String get chat_noMessages => 'Nessun messaggio ancora';

View file

@ -824,6 +824,46 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get channels_sortUnread => 'Ongelezen';
@override
String get channels_createPrivateChannel => 'Maak een Privé Kanaal';
@override
String get channels_createPrivateChannelDesc =>
'Beveiligd met een geheime sleutel.';
@override
String get channels_joinPrivateChannel => 'Sluit een Privé Kanaal aan';
@override
String get channels_joinPrivateChannelDesc =>
'Handmatig een geheime sleutel invoeren.';
@override
String get channels_joinPublicChannel => 'Sluit het Open Kanaal';
@override
String get channels_joinPublicChannelDesc =>
'Iedereen kan dit kanaal aanmelden.';
@override
String get channels_joinHashtagChannel => 'Sluit een Hashtag Kanaal';
@override
String get channels_joinHashtagChannelDesc =>
'Iedereen kan lid worden van hashtag-kanalen.';
@override
String get channels_scanQrCode => 'Scan een QR-code';
@override
String get channels_scanQrCodeComingSoon => 'Komt later';
@override
String get channels_enterHashtag => 'Voer hashtag in';
@override
String get channels_hashtagHint => 'bijv. #team';
@override
String get chat_noMessages => 'Nog geen berichten.';

View file

@ -828,6 +828,46 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get channels_sortUnread => 'Niezgłoszone';
@override
String get channels_createPrivateChannel => 'Utwórz Prywatny Kanał';
@override
String get channels_createPrivateChannelDesc =>
'Zabezpieczone kluczem szyfrowym.';
@override
String get channels_joinPrivateChannel => 'Dołącz do Prywatnego Kanału';
@override
String get channels_joinPrivateChannelDesc => 'Ręcznie wprowadź klucz tajny.';
@override
String get channels_joinPublicChannel => 'Dołącz do kanału publicznego.';
@override
String get channels_joinPublicChannelDesc =>
'Każdy może dołączyć do tego kanału.';
@override
String get channels_joinHashtagChannel =>
'Dołącz do kanału oznaczanego hashtagiem';
@override
String get channels_joinHashtagChannelDesc =>
'Każdy może dołączyć do kanałów z hashtagami.';
@override
String get channels_scanQrCode => 'Skanuj kod QR';
@override
String get channels_scanQrCodeComingSoon => 'Wkrótce';
@override
String get channels_enterHashtag => 'Wprowadź hashtag';
@override
String get channels_hashtagHint => 'np. #zespół';
@override
String get chat_noMessages => 'Brak jeszcze wiadomości';

View file

@ -829,6 +829,46 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get channels_sortUnread => 'Não lido';
@override
String get channels_createPrivateChannel => 'Criar um Canal Privado';
@override
String get channels_createPrivateChannelDesc =>
'Protegido com uma chave secreta.';
@override
String get channels_joinPrivateChannel => 'Junte-se a um Canal Privado';
@override
String get channels_joinPrivateChannelDesc =>
'Inserir uma chave secreta manualmente.';
@override
String get channels_joinPublicChannel => 'Junte-se ao Canal Público';
@override
String get channels_joinPublicChannelDesc =>
'Qualquer pessoa pode entrar neste canal.';
@override
String get channels_joinHashtagChannel => 'Junte-se a um Canal com Hashtag';
@override
String get channels_joinHashtagChannelDesc =>
'Qualquer pessoa pode participar de canais com hashtag.';
@override
String get channels_scanQrCode => 'Digitalizar um Código QR';
@override
String get channels_scanQrCodeComingSoon => 'Em breve';
@override
String get channels_enterHashtag => 'Insira hashtag';
@override
String get channels_hashtagHint => 'ex. #equipe';
@override
String get chat_noMessages => 'Ainda não existem mensagens.';

View file

@ -824,6 +824,45 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get channels_sortUnread => 'Nezriadené';
@override
String get channels_createPrivateChannel => 'Vytvorte súkromný kanál';
@override
String get channels_createPrivateChannelDesc =>
'Zabezpečené pomocou tajného kľúča.';
@override
String get channels_joinPrivateChannel => 'Pripojiť sa k súkromnému kanálu';
@override
String get channels_joinPrivateChannelDesc => 'Ručne zadajte tajný kľúč.';
@override
String get channels_joinPublicChannel => 'Pripojte sa k verejnému kanálu';
@override
String get channels_joinPublicChannelDesc =>
'Któvek sátó na tutó kanalizovát.';
@override
String get channels_joinHashtagChannel => 'Pripojte sa k Hashtag Kanálu';
@override
String get channels_joinHashtagChannelDesc =>
'Ktoekolikoľvek sa môže pridať do hashtag kanálov.';
@override
String get channels_scanQrCode => 'Skenujte QR kód';
@override
String get channels_scanQrCodeComingSoon => 'Čoskoro';
@override
String get channels_enterHashtag => 'Zadajte hashtag';
@override
String get channels_hashtagHint => 'napr. #tím';
@override
String get chat_noMessages => 'Zatiaľ žiadne správy.';

View file

@ -824,6 +824,45 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get channels_sortUnread => 'Nerešeno';
@override
String get channels_createPrivateChannel => 'Ustvari zasebno kanal.';
@override
String get channels_createPrivateChannelDesc =>
'Varno zaklenjeno s skrivnim ključem.';
@override
String get channels_joinPrivateChannel => 'Pridružite se zasebni skupini';
@override
String get channels_joinPrivateChannelDesc => 'Ročno vnesite zaporni ključ.';
@override
String get channels_joinPublicChannel => 'Pridružite se javnemu kanalu';
@override
String get channels_joinPublicChannelDesc =>
'Kdor karkoli je, lahko se pridruži tej skupini.';
@override
String get channels_joinHashtagChannel => 'Pridružite se Kanalu z Hashtagom';
@override
String get channels_joinHashtagChannelDesc =>
'Kdor karkoli, lahko se pridruži hashtag kanalom.';
@override
String get channels_scanQrCode => 'Skeniraj QR kodo';
@override
String get channels_scanQrCodeComingSoon => 'Prihajajoča';
@override
String get channels_enterHashtag => 'Vnesite hashtag';
@override
String get channels_hashtagHint => 'npr. #ekipa';
@override
String get chat_noMessages => 'Še ni sporočil.';

View file

@ -817,6 +817,46 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get channels_sortUnread => 'Oläst';
@override
String get channels_createPrivateChannel => 'Skapa en privat kanal';
@override
String get channels_createPrivateChannelDesc =>
'Skyddat med en hemlig nyckel.';
@override
String get channels_joinPrivateChannel => 'Gå med i en Privat Kanal';
@override
String get channels_joinPrivateChannelDesc =>
'Ange en hemlig nyckel manuellt.';
@override
String get channels_joinPublicChannel => 'Gå med i den Offentliga Kanalen';
@override
String get channels_joinPublicChannelDesc =>
'Vem som helst kan gå med i denna kanal.';
@override
String get channels_joinHashtagChannel => 'Gå med i en Hashtagkanal';
@override
String get channels_joinHashtagChannelDesc =>
'Väldigt enkelt att gå med i hashtag-kanaler.';
@override
String get channels_scanQrCode => 'Skanna en QR-kod';
@override
String get channels_scanQrCodeComingSoon => 'Kommer snart';
@override
String get channels_enterHashtag => 'Ange hashtag';
@override
String get channels_hashtagHint => 't.ex. #team';
@override
String get chat_noMessages => 'Inga meddelanden ännu';

View file

@ -789,6 +789,42 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get channels_sortUnread => '未读';
@override
String get channels_createPrivateChannel => '创建私聊频道';
@override
String get channels_createPrivateChannelDesc => '使用密钥保护。';
@override
String get channels_joinPrivateChannel => '加入私密频道';
@override
String get channels_joinPrivateChannelDesc => '手动输入密钥。';
@override
String get channels_joinPublicChannel => '加入公共频道';
@override
String get channels_joinPublicChannelDesc => '任何人都可以加入这个频道。';
@override
String get channels_joinHashtagChannel => '加入标签频道';
@override
String get channels_joinHashtagChannelDesc => '任何人都可以加入话题频道。';
@override
String get channels_scanQrCode => '扫描二维码';
@override
String get channels_scanQrCodeComingSoon => '即将到来';
@override
String get channels_enterHashtag => '输入标签';
@override
String get channels_hashtagHint => '例如 #团队';
@override
String get chat_noMessages => '目前还没有消息';

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Repeaters",
"listFilter_roomServers": "Roomservers",
"listFilter_unreadOnly": "Alleen ongelezen",
"listFilter_newGroup": "Nieuwe groep"
"listFilter_newGroup": "Nieuwe groep",
"channels_createPrivateChannelDesc": "Beveiligd met een geheime sleutel.",
"channels_createPrivateChannel": "Maak een Privé Kanaal",
"channels_joinPrivateChannel": "Sluit een Privé Kanaal aan",
"channels_joinPrivateChannelDesc": "Handmatig een geheime sleutel invoeren.",
"channels_joinPublicChannel": "Sluit het Open Kanaal",
"channels_joinPublicChannelDesc": "Iedereen kan dit kanaal aanmelden.",
"channels_joinHashtagChannel": "Sluit een Hashtag Kanaal",
"channels_joinHashtagChannelDesc": "Iedereen kan lid worden van hashtag-kanalen.",
"channels_scanQrCode": "Scan een QR-code",
"channels_scanQrCodeComingSoon": "Komt later",
"channels_enterHashtag": "Voer hashtag in",
"channels_hashtagHint": "bijv. #team"
}

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Powtarzacze",
"listFilter_roomServers": "Serwery pokoju",
"listFilter_unreadOnly": "Tylko nieprzeczytane",
"listFilter_newGroup": "Nowa grupa"
"listFilter_newGroup": "Nowa grupa",
"channels_joinPrivateChannelDesc": "Ręcznie wprowadź klucz tajny.",
"channels_createPrivateChannel": "Utwórz Prywatny Kanał",
"channels_createPrivateChannelDesc": "Zabezpieczone kluczem szyfrowym.",
"channels_joinPrivateChannel": "Dołącz do Prywatnego Kanału",
"channels_joinPublicChannel": "Dołącz do kanału publicznego.",
"channels_joinPublicChannelDesc": "Każdy może dołączyć do tego kanału.",
"channels_joinHashtagChannel": "Dołącz do kanału oznaczanego hashtagiem",
"channels_joinHashtagChannelDesc": "Każdy może dołączyć do kanałów z hashtagami.",
"channels_scanQrCode": "Skanuj kod QR",
"channels_scanQrCodeComingSoon": "Wkrótce",
"channels_enterHashtag": "Wprowadź hashtag",
"channels_hashtagHint": "np. #zespół"
}

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Repetidores",
"listFilter_roomServers": "Servidores de sala",
"listFilter_unreadOnly": "Apenas não lido",
"listFilter_newGroup": "Novo grupo"
"listFilter_newGroup": "Novo grupo",
"channels_createPrivateChannelDesc": "Protegido com uma chave secreta.",
"channels_joinPrivateChannelDesc": "Inserir uma chave secreta manualmente.",
"channels_createPrivateChannel": "Criar um Canal Privado",
"channels_joinPrivateChannel": "Junte-se a um Canal Privado",
"channels_joinPublicChannel": "Junte-se ao Canal Público",
"channels_joinPublicChannelDesc": "Qualquer pessoa pode entrar neste canal.",
"channels_joinHashtagChannel": "Junte-se a um Canal com Hashtag",
"channels_joinHashtagChannelDesc": "Qualquer pessoa pode participar de canais com hashtag.",
"channels_scanQrCode": "Digitalizar um Código QR",
"channels_scanQrCodeComingSoon": "Em breve",
"channels_enterHashtag": "Insira hashtag",
"channels_hashtagHint": "ex. #equipe"
}

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Opakovadlá",
"listFilter_roomServers": "Servéry miestnosti",
"listFilter_unreadOnly": "Nezaregistrované len",
"listFilter_newGroup": "Nová skupina"
"listFilter_newGroup": "Nová skupina",
"channels_createPrivateChannel": "Vytvorte súkromný kanál",
"channels_joinPrivateChannel": "Pripojiť sa k súkromnému kanálu",
"channels_joinPrivateChannelDesc": "Ručne zadajte tajný kľúč.",
"channels_createPrivateChannelDesc": "Zabezpečené pomocou tajného kľúča.",
"channels_joinPublicChannel": "Pripojte sa k verejnému kanálu",
"channels_joinPublicChannelDesc": "Któvek sátó na tutó kanalizovát.",
"channels_joinHashtagChannel": "Pripojte sa k Hashtag Kanálu",
"channels_joinHashtagChannelDesc": "Ktoekolikoľvek sa môže pridať do hashtag kanálov.",
"channels_scanQrCode": "Skenujte QR kód",
"channels_scanQrCodeComingSoon": "Čoskoro",
"channels_enterHashtag": "Zadajte hashtag",
"channels_hashtagHint": "napr. #tím"
}

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Ponovitve",
"listFilter_roomServers": "Smeti za prostore",
"listFilter_unreadOnly": "Nezbrani samo",
"listFilter_newGroup": "Nova skupina"
"listFilter_newGroup": "Nova skupina",
"channels_joinPrivateChannel": "Pridružite se zasebni skupini",
"channels_createPrivateChannelDesc": "Varno zaklenjeno s skrivnim ključem.",
"channels_joinPrivateChannelDesc": "Ročno vnesite zaporni ključ.",
"channels_createPrivateChannel": "Ustvari zasebno kanal.",
"channels_joinPublicChannel": "Pridružite se javnemu kanalu",
"channels_joinPublicChannelDesc": "Kdor karkoli je, lahko se pridruži tej skupini.",
"channels_joinHashtagChannel": "Pridružite se Kanalu z Hashtagom",
"channels_joinHashtagChannelDesc": "Kdor karkoli, lahko se pridruži hashtag kanalom.",
"channels_scanQrCode": "Skeniraj QR kodo",
"channels_scanQrCodeComingSoon": "Prihajajoča",
"channels_enterHashtag": "Vnesite hashtag",
"channels_hashtagHint": "npr. #ekipa"
}

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "Upprepare",
"listFilter_roomServers": "Rumservrar",
"listFilter_unreadOnly": "Endast oinlästa",
"listFilter_newGroup": "Ny grupp"
"listFilter_newGroup": "Ny grupp",
"channels_createPrivateChannel": "Skapa en privat kanal",
"channels_joinPrivateChannel": "Gå med i en Privat Kanal",
"channels_joinPrivateChannelDesc": "Ange en hemlig nyckel manuellt.",
"channels_createPrivateChannelDesc": "Skyddat med en hemlig nyckel.",
"channels_joinPublicChannel": "Gå med i den Offentliga Kanalen",
"channels_joinPublicChannelDesc": "Vem som helst kan gå med i denna kanal.",
"channels_joinHashtagChannel": "Gå med i en Hashtagkanal",
"channels_joinHashtagChannelDesc": "Väldigt enkelt att gå med i hashtag-kanaler.",
"channels_scanQrCode": "Skanna en QR-kod",
"channels_scanQrCodeComingSoon": "Kommer snart",
"channels_enterHashtag": "Ange hashtag",
"channels_hashtagHint": "t.ex. #team"
}

View file

@ -1335,5 +1335,17 @@
"listFilter_repeaters": "重复器",
"listFilter_roomServers": "房间服务器",
"listFilter_unreadOnly": "未读消息",
"listFilter_newGroup": "新组"
"listFilter_newGroup": "新组",
"channels_joinPrivateChannel": "加入私密频道",
"channels_createPrivateChannelDesc": "使用密钥保护。",
"channels_joinPrivateChannelDesc": "手动输入密钥。",
"channels_createPrivateChannel": "创建私聊频道",
"channels_joinPublicChannel": "加入公共频道",
"channels_joinPublicChannelDesc": "任何人都可以加入这个频道。",
"channels_joinHashtagChannel": "加入标签频道",
"channels_joinHashtagChannelDesc": "任何人都可以加入话题频道。",
"channels_scanQrCode": "扫描二维码",
"channels_scanQrCodeComingSoon": "即将到来",
"channels_enterHashtag": "输入标签",
"channels_hashtagHint": "例如 #团队"
}

View file

@ -1,5 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import '../connector/meshcore_protocol.dart';
class Channel {
@ -61,6 +64,15 @@ class Channel {
return bytes;
}
/// Derive PSK from hashtag name using SHA256.
/// The hashtag is normalized to include '#' prefix.
/// Returns first 16 bytes of SHA256 hash as PSK.
static Uint8List derivePskFromHashtag(String hashtag) {
final name = hashtag.startsWith('#') ? hashtag : '#$hashtag';
final hash = crypto.sha256.convert(utf8.encode(name)).bytes;
return Uint8List.fromList(hash.sublist(0, 16));
}
static String formatPskHex(Uint8List psk) {
return _bytesToHex(psk);
}

View file

@ -515,132 +515,318 @@ class _ChannelsScreenState extends State<ChannelsScreen>
void _showAddChannelDialog(BuildContext context) {
final connector = context.read<MeshCoreConnector>();
final nextIndex = _findNextAvailableIndex(connector.channels, connector.maxChannels);
final hasPublicChannel = connector.channels.any((c) => c.isPublicChannel);
int? selectedOption;
final nameController = TextEditingController();
final pskController = TextEditingController();
final maxChannels = connector.maxChannels;
int selectedIndex = _findNextAvailableIndex(connector.channels, maxChannels);
bool usePublicPsk = false;
final hashtagController = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: Text(dialogContext.l10n.channels_addChannel),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<int>(
initialValue: selectedIndex,
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelIndexLabel,
border: const OutlineInputBorder(),
),
items: List.generate(maxChannels, (i) => i)
.map((i) => DropdownMenuItem(
value: i,
child: Text(dialogContext.l10n.channels_channelIndex(i)),
))
.toList(),
onChanged: (value) {
if (value != null) {
setDialogState(() => selectedIndex = value);
}
},
builder: (dialogContext, setDialogState) {
Widget buildOptionTile({
required int optionIndex,
required IconData icon,
required String title,
required String subtitle,
bool enabled = true,
}) {
final isSelected = selectedOption == optionIndex;
return ListTile(
leading: CircleAvatar(
backgroundColor: enabled
? (isSelected ? Theme.of(dialogContext).colorScheme.primaryContainer : null)
: Colors.grey.withValues(alpha: 0.2),
child: Icon(
icon,
color: enabled
? (isSelected ? Theme.of(dialogContext).colorScheme.primary : null)
: Colors.grey,
),
const SizedBox(height: 16),
TextField(
controller: nameController,
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelName,
border: const OutlineInputBorder(),
),
maxLength: 31,
),
const SizedBox(height: 8),
CheckboxListTile(
title: Text(dialogContext.l10n.channels_usePublicChannel),
subtitle: Text(dialogContext.l10n.channels_standardPublicPsk),
value: usePublicPsk,
onChanged: (value) {
setDialogState(() {
usePublicPsk = value ?? false;
if (usePublicPsk) {
nameController.text = 'Public';
pskController.text = Channel.publicChannelPsk;
} else {
),
title: Text(
title,
style: TextStyle(color: enabled ? null : Colors.grey),
),
subtitle: Text(
subtitle,
style: TextStyle(color: enabled ? null : Colors.grey),
),
trailing: enabled ? const Icon(Icons.chevron_right) : null,
selected: isSelected,
onTap: enabled
? () {
setDialogState(() {
selectedOption = optionIndex;
nameController.clear();
pskController.clear();
}
});
},
),
if (!usePublicPsk) ...[
const SizedBox(height: 8),
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_pskHex,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
tooltip: dialogContext.l10n.channels_generateRandomPsk,
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
for (int i = 0; i < 16; i++) {
bytes[i] = random.nextInt(256);
}
pskController.text = Channel.formatPskHex(bytes);
},
hashtagController.clear();
});
}
: null,
);
}
Widget? buildExpandedContent() {
switch (selectedOption) {
case 0: // Create Private Channel
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: nameController,
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelName,
border: const OutlineInputBorder(),
),
maxLength: 31,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: () {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
);
return;
}
final random = Random.secure();
final psk = Uint8List(16);
for (int i = 0; i < 16; i++) {
psk[i] = random.nextInt(256);
}
Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
);
}
},
child: Text(dialogContext.l10n.common_create),
),
),
],
),
),
],
);
case 1: // Join Private Channel
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: nameController,
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_channelName,
border: const OutlineInputBorder(),
),
maxLength: 31,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: pskController,
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_pskHex,
border: const OutlineInputBorder(),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: () {
final name = nameController.text.trim();
final pskHex = pskController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
);
return;
}
Uint8List psk;
try {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)),
);
return;
}
Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
);
}
},
child: Text(dialogContext.l10n.common_add),
),
),
],
),
),
],
);
case 2: // Join Public Channel
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: () {
final psk = Channel.parsePskHex(Channel.publicChannelPsk);
Navigator.pop(dialogContext);
connector.setChannel(nextIndex, 'Public', psk);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.channels_publicChannelAdded)),
);
}
},
child: Text(dialogContext.l10n.common_add),
),
),
],
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.common_cancel),
),
FilledButton(
onPressed: () {
final name = nameController.text.trim();
final pskHex = usePublicPsk
? Channel.publicChannelPsk
: pskController.text.trim();
);
if (name.isEmpty) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
);
return;
}
case 3: // Join Hashtag Channel
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: hashtagController,
decoration: InputDecoration(
labelText: dialogContext.l10n.channels_enterHashtag,
hintText: dialogContext.l10n.channels_hashtagHint,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.tag),
),
maxLength: 31,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: () {
var hashtag = hashtagController.text.trim();
if (hashtag.isEmpty) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_enterChannelName)),
);
return;
}
// Normalize hashtag name
final name = hashtag.startsWith('#') ? hashtag : '#$hashtag';
final psk = Channel.derivePskFromHashtag(hashtag);
Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
);
}
},
child: Text(dialogContext.l10n.common_add),
),
),
],
),
),
],
);
Uint8List psk;
try {
psk = Channel.parsePskHex(pskHex);
} on FormatException {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text(dialogContext.l10n.channels_pskMustBe32Hex)),
);
return;
}
default:
return null;
}
}
Navigator.pop(dialogContext);
connector.setChannel(selectedIndex, name, psk);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.channels_channelAdded(name))),
);
}
},
child: Text(dialogContext.l10n.common_add),
return AlertDialog(
title: Text(dialogContext.l10n.channels_addChannel),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildOptionTile(
optionIndex: 0,
icon: Icons.add,
title: dialogContext.l10n.channels_createPrivateChannel,
subtitle: dialogContext.l10n.channels_createPrivateChannelDesc,
),
if (selectedOption == 0) buildExpandedContent()!,
const Divider(height: 1),
buildOptionTile(
optionIndex: 1,
icon: Icons.lock,
title: dialogContext.l10n.channels_joinPrivateChannel,
subtitle: dialogContext.l10n.channels_joinPrivateChannelDesc,
),
if (selectedOption == 1) buildExpandedContent()!,
if (!hasPublicChannel) ...[
const Divider(height: 1),
buildOptionTile(
optionIndex: 2,
icon: Icons.public,
title: dialogContext.l10n.channels_joinPublicChannel,
subtitle: dialogContext.l10n.channels_joinPublicChannelDesc,
),
if (selectedOption == 2) buildExpandedContent()!,
],
const Divider(height: 1),
buildOptionTile(
optionIndex: 3,
icon: Icons.tag,
title: dialogContext.l10n.channels_joinHashtagChannel,
subtitle: dialogContext.l10n.channels_joinHashtagChannelDesc,
),
if (selectedOption == 3) buildExpandedContent()!,
const Divider(height: 1),
buildOptionTile(
optionIndex: 4,
icon: Icons.qr_code,
title: dialogContext.l10n.channels_scanQrCode,
subtitle: dialogContext.l10n.channels_scanQrCodeComingSoon,
enabled: false,
),
],
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.common_close),
),
],
);
},
),
);
}

View file

@ -10,6 +10,7 @@ Translates ARB/JSON localization values using a local Ollama model, while:
- printing progress as it runs
Usage:
# Translate all strings:
python translate_arb_with_ollama.py \
--in /home/zjs81/Desktop/meshcore-open/lib/l10n/app_en.arb \
--out /home/zjs81/Desktop/meshcore-open/lib/l10n/app_es.arb \
@ -17,12 +18,28 @@ Usage:
--model ministral-3:latest \
--temperature 0 \
--concurrency 4
# Translate only missing/untranslated strings:
python translate_arb_with_ollama.py \
--in /home/zjs81/Desktop/meshcore-open/lib/l10n/app_en.arb \
--out /home/zjs81/Desktop/meshcore-open/lib/l10n/app_es.arb \
--to-locale es \
--missing-only \
--model ministral-3:latest
# Translate all locales (missing strings only):
python translate_arb_with_ollama.py \
--in /home/zjs81/Desktop/meshcore-open/lib/l10n/app_en.arb \
--l10n-dir /home/zjs81/Desktop/meshcore-open/lib/l10n \
--missing-only \
--model ministral-3:latest
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
@ -448,11 +465,48 @@ def fmt_duration(seconds: float) -> str:
return f"{h}h {m2}m"
def find_missing_keys(source_data: Dict[str, Any], target_data: Dict[str, Any]) -> List[str]:
"""Find keys that are in source but not in target (excluding metadata keys)."""
missing = []
for key in source_data:
if key == "@@locale":
continue
if key.startswith("@"):
continue
if key not in target_data:
missing.append(key)
return missing
def get_all_locale_files(l10n_dir: str, template_file: str) -> List[Tuple[str, str]]:
"""Find all locale .arb files in the directory, excluding the template.
Returns list of (locale_code, file_path) tuples.
"""
locales = []
template_basename = os.path.basename(template_file)
for filename in os.listdir(l10n_dir):
if not filename.endswith('.arb'):
continue
if filename == template_basename:
continue
# Extract locale from filename like app_es.arb -> es
if filename.startswith('app_') and filename.endswith('.arb'):
locale = filename[4:-4] # Remove 'app_' prefix and '.arb' suffix
filepath = os.path.join(l10n_dir, filename)
locales.append((locale, filepath))
return sorted(locales)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="in_path", required=True, help="Input .arb/.json file path")
ap.add_argument("--out", dest="out_path", required=True, help="Output .arb/.json file path")
ap.add_argument("--to-locale", required=True, help="Target locale code, e.g. es, fr, de")
ap.add_argument("--in", dest="in_path", required=True, help="Input .arb/.json file path (source/template)")
ap.add_argument("--out", dest="out_path", default=None, help="Output .arb/.json file path (required unless using --l10n-dir)")
ap.add_argument("--to-locale", default=None, help="Target locale code, e.g. es, fr, de (required unless using --l10n-dir)")
ap.add_argument("--l10n-dir", default=None, help="Directory containing locale .arb files. When set, translates all locales.")
ap.add_argument("--missing-only", action="store_true", help="Only translate keys missing from target file")
ap.add_argument("--target-lang", default=None, help="Target language name for the model, e.g. Spanish (defaults from locale)")
ap.add_argument("--model", default="gemma3:4b", help="Ollama model name")
ap.add_argument("--fallback-model", default=None, help="Larger model to use for low-confidence translations")
@ -504,19 +558,119 @@ def main() -> int:
"vi": "Vietnamese",
"id": "Indonesian",
}
target_lang = args.target_lang or locale_map.get(args.to_locale, args.to_locale)
# Read source/template file
try:
with open(args.in_path, "r", encoding="utf-8") as f:
data = json.load(f)
source_data = json.load(f)
except Exception as e:
print(f"Failed to read input: {e}", file=sys.stderr)
return 2
if not isinstance(data, dict):
if not isinstance(source_data, dict):
print("Input JSON must be an object at top-level.", file=sys.stderr)
return 2
# If --l10n-dir is provided, process all locale files
if args.l10n_dir:
locales = get_all_locale_files(args.l10n_dir, args.in_path)
if not locales:
print(f"No locale files found in {args.l10n_dir}", file=sys.stderr)
return 1
print(f"Found {len(locales)} locale file(s) to process")
total_translated = 0
for locale_code, locale_path in locales:
target_lang = locale_map.get(locale_code, locale_code)
# Read existing target file
try:
with open(locale_path, "r", encoding="utf-8") as f:
target_data = json.load(f)
except Exception as e:
print(f" [{locale_code}] Failed to read {locale_path}: {e}")
continue
if args.missing_only:
missing_keys = find_missing_keys(source_data, target_data)
if not missing_keys:
print(f" [{locale_code}] No missing keys")
continue
print(f" [{locale_code}] {len(missing_keys)} missing key(s): {', '.join(missing_keys[:5])}{'...' if len(missing_keys) > 5 else ''}")
else:
missing_keys = None
# Run translation for this locale
result = translate_locale(
source_data=source_data,
target_data=target_data,
target_locale=locale_code,
target_lang=target_lang,
out_path=locale_path,
args=args,
locale_map=locale_map,
missing_keys=missing_keys,
)
total_translated += result
print(f"\nTotal: {total_translated} string(s) translated across {len(locales)} locale(s)")
return 0
# Single locale mode - validate required args
if not args.out_path:
print("--out is required when not using --l10n-dir", file=sys.stderr)
return 1
if not args.to_locale:
print("--to-locale is required when not using --l10n-dir", file=sys.stderr)
return 1
target_lang = args.target_lang or locale_map.get(args.to_locale, args.to_locale)
# Read existing target file if --missing-only and file exists
target_data: Dict[str, Any] = {}
missing_keys: Optional[List[str]] = None
if args.missing_only:
if os.path.exists(args.out_path):
try:
with open(args.out_path, "r", encoding="utf-8") as f:
target_data = json.load(f)
missing_keys = find_missing_keys(source_data, target_data)
if not missing_keys:
print(f"No missing keys in {args.out_path}")
return 0
print(f"Found {len(missing_keys)} missing key(s) to translate")
except Exception as e:
print(f"Failed to read target file: {e}", file=sys.stderr)
return 2
else:
print(f"Target file {args.out_path} does not exist. Will translate all strings.")
result = translate_locale(
source_data=source_data,
target_data=target_data,
target_locale=args.to_locale,
target_lang=target_lang,
out_path=args.out_path,
args=args,
locale_map=locale_map,
missing_keys=missing_keys,
)
return 0 if result >= 0 else 1
def translate_locale(
source_data: Dict[str, Any],
target_data: Dict[str, Any],
target_locale: str,
target_lang: str,
out_path: str,
args,
locale_map: Dict[str, str],
missing_keys: Optional[List[str]] = None,
) -> int:
"""Translate a single locale. Returns number of strings translated."""
cfg = OllamaConfig(
host=args.host,
model=args.model,
@ -540,17 +694,34 @@ def main() -> int:
top_p=args.top_p,
)
out_data: Dict[str, Any] = dict(data)
out_data["@@locale"] = args.to_locale
# Start with target data (preserves existing translations) or source data
if target_data:
out_data: Dict[str, Any] = dict(target_data)
else:
out_data: Dict[str, Any] = dict(source_data)
out_data["@@locale"] = target_locale
items: List[Tuple[str, str]] = [(k, v) for k, v in data.items() if is_translatable_entry(k, v)]
# Build list of items to translate
if missing_keys is not None:
# Only translate missing keys
items: List[Tuple[str, str]] = [
(k, source_data[k]) for k in missing_keys
if is_translatable_entry(k, source_data.get(k))
]
# Also copy over any metadata keys for missing items
for key in missing_keys:
meta_key = f"@{key}"
if meta_key in source_data:
out_data[meta_key] = source_data[meta_key]
else:
items: List[Tuple[str, str]] = [(k, v) for k, v in source_data.items() if is_translatable_entry(k, v)]
# Apply manual translations first
manual_count = 0
items_to_translate: List[Tuple[str, str]] = []
for k, v in items:
if k in MANUAL_TRANSLATIONS and args.to_locale in MANUAL_TRANSLATIONS[k]:
out_data[k] = MANUAL_TRANSLATIONS[k][args.to_locale]
if k in MANUAL_TRANSLATIONS and target_locale in MANUAL_TRANSLATIONS[k]:
out_data[k] = MANUAL_TRANSLATIONS[k][target_locale]
manual_count += 1
else:
items_to_translate.append((k, v))
@ -560,8 +731,8 @@ def main() -> int:
total = len(items_to_translate)
if total == 0 and manual_count == 0:
print("No translatable string entries found (excluding @@locale and @metadata).", file=sys.stderr)
return 1
print("No translatable string entries found (excluding @@locale and @metadata).")
return 0
if total == 0:
print("All strings handled by manual translations.")
@ -705,18 +876,18 @@ def main() -> int:
if args.dry_run:
print("Dry run: not writing output file.")
return 0
return translated_ok
try:
with open(args.out_path, "w", encoding="utf-8") as f:
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, ensure_ascii=False, indent=2)
f.write("\n")
except Exception as e:
print(f"Failed to write output: {e}", file=sys.stderr)
return 2
return -1
print(f"Wrote: {args.out_path}")
return 0
print(f"Wrote: {out_path}")
return translated_ok
if __name__ == "__main__":