From 560c860f1da6008985ce7344e17a191fcc2b5a82 Mon Sep 17 00:00:00 2001 From: reverse Date: Tue, 25 Aug 2026 12:14:35 +0000 Subject: [PATCH] i18n: server-wide locale via lang/.conf catalogs (fr, es); numeric prose auto-translated at the chokepoint, en = zero-cost passthrough --- echoircd.conf.example | 6 + lang/es.conf | 177 +++++++++++++++++++++++++ lang/fr.conf | 177 +++++++++++++++++++++++++ src/i18n.rs | 294 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/server.rs | 31 +++++ 6 files changed, 686 insertions(+) create mode 100644 lang/es.conf create mode 100644 lang/fr.conf create mode 100644 src/i18n.rs diff --git a/echoircd.conf.example b/echoircd.conf.example index aadb785..f7096c7 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -372,6 +372,12 @@ antiabuse { # --- hashident: replace ident with a stable opaque token per IP --- # hashident yes; # hashident_key "CHANGE_THIS_SECRET"; # HMAC key; makes the mapping unforgeable + # --- locale: server-wide language for client-facing messages. "en" (default) is + # English passthrough (zero cost). Any other loads lang/.conf and + # translates numeric/notice prose; protocol tokens & the S2S wire stay English. + # Rehash to switch live. Ships with fr (Français) and es (Español). --- + # locale fr; + # locale_dir "lang"; # directory holding .conf catalogs (default: lang) # --- solvemsg: unvouched users answer one arithmetic question before their # PMs are delivered (opers & logged-in accounts exempt). --- # solvemsg yes; diff --git a/lang/es.conf b/lang/es.conf new file mode 100644 index 0000000..5884857 --- /dev/null +++ b/lang/es.conf @@ -0,0 +1,177 @@ +# echoIRCd — catálogo de mensajes: Español (es) +# Formato: "texto en inglés (msgid)" = "traducción" +# Solo se traduce el texto humano; los tokens de protocolo (+i, SASL, RELAYMSG, +# nombres de comandos, letras de modo) se mantienen. Una entrada ausente +# recae en el inglés. + +# --- avisos / desafíos --- +"*** Cloudflare Challenge: verification successful — you may continue." = "*** Desafío de Cloudflare: verificación exitosa — puedes continuar." +"*** Correct — you may now message freely; please resend your message." = "*** Correcto — ya puedes enviar mensajes libremente; por favor reenvía tu mensaje." +"*** Flood detected — slow down" = "*** Flood detectado — más despacio" +"*** NickServ: welcome back" = "*** NickServ: bienvenido de nuevo" +"*** OJOIN is not enabled on this server." = "*** OJOIN no está habilitado en este servidor." +"*** TITLE: invalid title name or password." = "*** TITLE: nombre de título o contraseña inválidos." +"*** reCAPTCHA: verification successful — you may continue." = "*** reCAPTCHA: verificación exitosa — puedes continuar." + +# --- OPERMOTD / encabezados --- +"- End of OPERMOTD -" = "- Fin del OPERMOTD -" +"- IRC Operators Message of the Day -" = "- Mensaje del día de los operadores IRC -" +"Administrative info" = "Información administrativa" +"Users Name" = "Nombre de usuario" +"Server notice mask" = "Máscara de avisos del servidor" + +# --- errores / estados --- +"Busy hashing, try again" = "Ocupado calculando hash, inténtalo de nuevo" +"CTCP is disabled (+C)" = "El CTCP está deshabilitado (+C)" +"Can't change mode for other users" = "No puedes cambiar el modo de otros usuarios" +"Cannot change nick here (+b n:)" = "No puedes cambiar de apodo aquí (+b n:)" +"Cannot change nick while on this channel (+N is set)" = "No puedes cambiar de apodo en este canal (+N está activado)" +"Cannot join channel (+b)" = "No se puede entrar al canal (+b)" +"Cannot join channel (+b), redirecting" = "No se puede entrar al canal (+b), redirigiendo" +"Cannot join channel (+i)" = "No se puede entrar al canal (+i)" +"Cannot join channel (+k)" = "No se puede entrar al canal (+k)" +"Cannot join channel (+l)" = "No se puede entrar al canal (+l)" +"Cannot join channel (+l), redirecting" = "No se puede entrar al canal (+l), redirigiendo" +"Cannot join channel; IRC operators only (+O is set)" = "No se puede entrar al canal; solo operadores IRC (+O está activado)" +"Cannot join channel; TLS users only (+z is set)" = "No se puede entrar al canal; solo usuarios TLS (+z está activado)" +"Cannot join channel; you must be logged in (+R is set)" = "No se puede entrar al canal; debes haber iniciado sesión (+R está activado)" +"Cannot send to channel" = "No se puede enviar al canal" +"Cannot send to channel (+B: too many capitals)" = "No se puede enviar al canal (+B: demasiadas mayúsculas)" +"Cannot send to channel (+G censor)" = "No se puede enviar al canal (+G censura)" +"Cannot send to channel (+K: repeated message)" = "No se puede enviar al canal (+K: mensaje repetido)" +"Cannot send to channel (+m)" = "No se puede enviar al canal (+m)" +"Cannot send to channel (blocked by +g filter)" = "No se puede enviar al canal (bloqueado por el filtro +g)" +"Cannot send to channel (you're muted, +b m:)" = "No se puede enviar al canal (estás silenciado, +b m:)" +"Cannot use an SA command on a network service" = "No puedes usar un comando SA en un servicio de red" +"Channel list is full" = "La lista de canales está llena" +"Channel mode +r may only be set by services" = "El modo de canal +r solo pueden establecerlo los servicios" +"Channel name contains characters not permitted here" = "El nombre del canal contiene caracteres no permitidos aquí" +"DIE requires the server name" = "DIE requiere el nombre del servidor" +"Erroneous nickname" = "Apodo erróneo" +"Formatting/colour is disabled (+c)" = "El formato/color está deshabilitado (+c)" +"Formatting/colour is disabled for you (+b c:)" = "El formato/color está deshabilitado para ti (+b c:)" +"Global message (/amsg or /ame) blocked" = "Mensaje global (/amsg o /ame) bloqueado" +"Invalid vhost credentials" = "Credenciales de vhost inválidas" +"Kicks are disabled here (+Q)" = "Los kicks están deshabilitados aquí (+Q)" +"MOTD File is missing" = "Falta el archivo MOTD" +"Maximum size for WATCH-list exceeded" = "Se superó el tamaño máximo de la lista WATCH" +"Message blocked by the spam filter" = "Mensaje bloqueado por el filtro antispam" +"Monitor list is full" = "La lista MONITOR está llena" +"Nickname is already in use" = "El apodo ya está en uso" +"No OPERMOTD is set" = "No hay OPERMOTD configurado" +"No help available for that topic" = "No hay ayuda disponible para ese tema" +"No matching servers" = "No hay servidores coincidentes" +"No nickname given" = "No se indicó ningún apodo" +"No such channel" = "No existe ese canal" +"No such nick/channel" = "No existe ese apodo/canal" +"No such server" = "No existe ese servidor" +"No text to send" = "No hay texto que enviar" +"No topic is set" = "No hay tema establecido" +"Not enough parameters" = "Parámetros insuficientes" +"Only IRC operators may create new channels here" = "Solo los operadores IRC pueden crear canales nuevos aquí" +"Only operators may remove the +x host cloak" = "Solo los operadores pueden quitar el enmascaramiento de host +x" +"Password incorrect" = "Contraseña incorrecta" +"Password incorrect (a matching TLS client certificate is required)" = "Contraseña incorrecta (se requiere un certificado de cliente TLS correspondiente)" +"Permission Denied- RELAYMSG is for IRC operators" = "Permiso denegado - RELAYMSG es para operadores IRC" +"Permission Denied- SVSLOGIN is a services command" = "Permiso denegado - SVSLOGIN es un comando de servicios" +"Permission Denied- SVSLOGOUT is a services command" = "Permiso denegado - SVSLOGOUT es un comando de servicios" +"Permission Denied- You're not an IRC operator" = "Permiso denegado - No eres operador IRC" +"Permission Denied- account login is handled by services (set oper_svslogin to override)" = "Permiso denegado - el inicio de sesión de cuenta lo gestionan los servicios (define oper_svslogin para anular)" +"Permission denied - only operators may set a server notice mask" = "Permiso denegado - solo los operadores pueden establecer una máscara de avisos del servidor" +"RESTART requires the server name" = "RESTART requiere el nombre del servidor" +"RMODE only supports list modes b, e, I" = "RMODE solo admite los modos de lista b, e, I" +"RMODE usage: RMODE [pattern]" = "Uso de RMODE: RMODE [patrón]" +"Rehashing" = "Recargando" +"SASL EXTERNAL requires a client certificate" = "SASL EXTERNAL requiere un certificado de cliente" +"SASL authentication aborted" = "Autenticación SASL cancelada" +"SASL authentication failed" = "Falló la autenticación SASL" +"SASL authentication failed (services are not available)" = "Falló la autenticación SASL (los servicios no están disponibles)" +"SASL authentication successful" = "Autenticación SASL exitosa" +"SASL message too long" = "Mensaje SASL demasiado largo" +"The server map is hidden; ask an operator." = "El mapa del servidor está oculto; pregunta a un operador." +"There was no such nickname" = "No existía ese apodo" +"They aren't on that channel" = "No están en ese canal" +"This channel is temporarily unavailable (+j join flood)" = "Este canal no está disponible temporalmente (+j flood de entradas)" +"This command has been disabled by the administrator." = "Este comando ha sido deshabilitado por el administrador." +"Too many auth attempts, try again" = "Demasiados intentos de autenticación, inténtalo de nuevo" +"Too many nick changes, try later (+F is set)" = "Demasiados cambios de apodo, inténtalo más tarde (+F está activado)" +"Unknown command" = "Comando desconocido" +"Unsupported SASL mechanism" = "Mecanismo SASL no admitido" + +# --- listas: « End of … » --- +"End of /HELP" = "Fin de /HELP" +"End of /INFO list" = "Fin de la lista /INFO" +"End of /LINKS list" = "Fin de la lista /LINKS" +"End of /LIST" = "Fin de /LIST" +"End of /MAP" = "Fin de /MAP" +"End of /MOTD command." = "Fin del comando /MOTD." +"End of /NAMES list" = "Fin de la lista /NAMES" +"End of /STATS report" = "Fin del informe /STATS" +"End of /WHO list" = "Fin de la lista /WHO" +"End of /WHOIS list" = "Fin de la lista /WHOIS" +"End of ACCEPT list" = "Fin de la lista ACCEPT" +"End of FILTER list" = "Fin de la lista FILTER" +"End of MODULES list" = "Fin de la lista MODULES" +"End of MONITOR list" = "Fin de la lista MONITOR" +"End of SIGNORE list." = "Fin de la lista SIGNORE." +"End of SILENCE list" = "Fin de la lista SILENCE" +"End of WATCH S" = "Fin de WATCH S" +"End of WATCH list" = "Fin de la lista WATCH" +"End of WHOWAS" = "Fin de WHOWAS" +"End of channel list." = "Fin de la lista de canales." + +# --- tú --- +"You are no longer marked as being away" = "Ya no estás marcado como ausente" +"You are now an IRC operator" = "Ahora eres operador IRC" +"You cannot KILL a network service" = "No puedes hacer KILL a un servicio de red" +"You cannot kick a network service" = "No puedes expulsar a un servicio de red" +"You cannot kick a user of higher rank" = "No puedes expulsar a un usuario de rango superior" +"You cannot remove a user of higher rank" = "No puedes quitar a un usuario de rango superior" +"You cannot send messages to this user." = "No puedes enviar mensajes a este usuario." +"You cannot share FILEHOST links over a non-TLS connection." = "No puedes compartir enlaces FILEHOST por una conexión no TLS." +"You have been marked as being away" = "Has sido marcado como ausente" +"You have been redirected." = "Has sido redirigido." +"You have joined too many channels" = "Has entrado en demasiados canales" +"You have not registered" = "No te has registrado" +"You may not reregister" = "No puedes volver a registrarte" +"You may only SSLINFO yourself" = "Solo puedes hacer SSLINFO sobre ti mismo" +"You must be in the channel to use RELAYMSG" = "Debes estar en el canal para usar RELAYMSG" +"You must be logged into an account to message this user (+R)" = "Debes haber iniciado sesión en una cuenta para enviar un mensaje a este usuario (+R)" +"You must be logged into an account to speak here (+M)" = "Debes haber iniciado sesión en una cuenta para hablar aquí (+M)" +"You must enable the draft/relaymsg capability to use RELAYMSG" = "Debes activar la capability draft/relaymsg para usar RELAYMSG" +"You must request the draft/extended-isupport capability to use this command" = "Debes solicitar la capability draft/extended-isupport para usar este comando" +"You must request the sasl capability first" = "Primero debes solicitar la capability sasl" +"You must share a channel to message this user (+c)" = "Debes compartir un canal para enviar un mensaje a este usuario (+c)" +"You're not a channel operator" = "No eres operador del canal" +"You're not on that channel" = "No estás en ese canal" +"Your KNOCK has been delivered" = "Tu KNOCK ha sido entregado" +"Your SIGNORE list is empty." = "Tu lista SIGNORE está vacía." +"Your SILENCE list is full" = "Tu lista SILENCE está llena" +"Your accept list is full" = "Tu lista accept está llena" +"Your nickname is locked and cannot be changed" = "Tu apodo está bloqueado y no se puede cambiar" + +# --- fragmentos (precedidos por un apodo/parámetro) --- +"are available SASL mechanisms" = "son los mecanismos SASL disponibles" +"are supported by this server" = "son compatibles con este servidor" +"cycling" = "reciclando" +"has been informed that you messaged them." = "ha sido informado de que le escribiste." +"is a bot" = "es un bot" +"is a network service" = "es un servicio de red" +"is a registered nick" = "es un apodo registrado" +"is already on channel" = "ya está en el canal" +"is already on your accept list" = "ya está en tu lista accept" +"is an IRC Operator" = "es un operador IRC" +"is available for help." = "está disponible para ayudar." +"is in +g mode (server-side ignore)." = "está en modo +g (ignorar del lado del servidor)." +"is logged in as" = "ha iniciado sesión como" +"is messaging you, and you have umode +g." = "te está enviando un mensaje, y tienes el umode +g." +"is not on your accept list" = "no está en tu lista accept" +"is now offline" = "ahora está desconectado" +"is now online" = "ahora está conectado" +"is now your displayed host" = "es ahora tu host mostrado" +"is offline" = "está desconectado" +"is online" = "está conectado" +"is unknown mode char to me" = "es un carácter de modo desconocido para mí" +"loaded" = "cargado" +"seconds idle, signon time" = "segundos inactivo, hora de conexión" +"stopped watching" = "dejó de vigilar" diff --git a/lang/fr.conf b/lang/fr.conf new file mode 100644 index 0000000..ff8b51d --- /dev/null +++ b/lang/fr.conf @@ -0,0 +1,177 @@ +# echoIRCd — catalogue de messages : Français (fr) +# Format : "texte anglais (msgid)" = "traduction" +# Seul le texte humain est traduit ; les jetons de protocole (+i, SASL, RELAYMSG, +# noms de commandes, lettres de mode) restent tels quels. Une entrée absente +# retombe sur l'anglais. + +# --- notices / défis --- +"*** Cloudflare Challenge: verification successful — you may continue." = "*** Défi Cloudflare : vérification réussie — vous pouvez continuer." +"*** Correct — you may now message freely; please resend your message." = "*** Correct — vous pouvez maintenant envoyer des messages librement ; veuillez renvoyer votre message." +"*** Flood detected — slow down" = "*** Flood détecté — ralentissez" +"*** NickServ: welcome back" = "*** NickServ : bon retour" +"*** OJOIN is not enabled on this server." = "*** OJOIN n'est pas activé sur ce serveur." +"*** TITLE: invalid title name or password." = "*** TITLE : nom de titre ou mot de passe invalide." +"*** reCAPTCHA: verification successful — you may continue." = "*** reCAPTCHA : vérification réussie — vous pouvez continuer." + +# --- OPERMOTD / en-têtes --- +"- End of OPERMOTD -" = "- Fin de l'OPERMOTD -" +"- IRC Operators Message of the Day -" = "- Message du jour des opérateurs IRC -" +"Administrative info" = "Informations administratives" +"Users Name" = "Nom d'utilisateur" +"Server notice mask" = "Masque de notices serveur" + +# --- erreurs / états --- +"Busy hashing, try again" = "Occupé à hacher, réessayez" +"CTCP is disabled (+C)" = "Le CTCP est désactivé (+C)" +"Can't change mode for other users" = "Impossible de changer le mode d'autres utilisateurs" +"Cannot change nick here (+b n:)" = "Impossible de changer de pseudo ici (+b n:)" +"Cannot change nick while on this channel (+N is set)" = "Impossible de changer de pseudo sur ce salon (+N est activé)" +"Cannot join channel (+b)" = "Impossible de rejoindre le salon (+b)" +"Cannot join channel (+b), redirecting" = "Impossible de rejoindre le salon (+b), redirection" +"Cannot join channel (+i)" = "Impossible de rejoindre le salon (+i)" +"Cannot join channel (+k)" = "Impossible de rejoindre le salon (+k)" +"Cannot join channel (+l)" = "Impossible de rejoindre le salon (+l)" +"Cannot join channel (+l), redirecting" = "Impossible de rejoindre le salon (+l), redirection" +"Cannot join channel; IRC operators only (+O is set)" = "Impossible de rejoindre le salon ; réservé aux opérateurs IRC (+O est activé)" +"Cannot join channel; TLS users only (+z is set)" = "Impossible de rejoindre le salon ; réservé aux utilisateurs TLS (+z est activé)" +"Cannot join channel; you must be logged in (+R is set)" = "Impossible de rejoindre le salon ; vous devez être authentifié (+R est activé)" +"Cannot send to channel" = "Impossible d'envoyer au salon" +"Cannot send to channel (+B: too many capitals)" = "Impossible d'envoyer au salon (+B : trop de majuscules)" +"Cannot send to channel (+G censor)" = "Impossible d'envoyer au salon (+G censure)" +"Cannot send to channel (+K: repeated message)" = "Impossible d'envoyer au salon (+K : message répété)" +"Cannot send to channel (+m)" = "Impossible d'envoyer au salon (+m)" +"Cannot send to channel (blocked by +g filter)" = "Impossible d'envoyer au salon (bloqué par le filtre +g)" +"Cannot send to channel (you're muted, +b m:)" = "Impossible d'envoyer au salon (vous êtes rendu muet, +b m:)" +"Cannot use an SA command on a network service" = "Impossible d'utiliser une commande SA sur un service réseau" +"Channel list is full" = "La liste des salons est pleine" +"Channel mode +r may only be set by services" = "Le mode de salon +r ne peut être défini que par les services" +"Channel name contains characters not permitted here" = "Le nom du salon contient des caractères non autorisés ici" +"DIE requires the server name" = "DIE nécessite le nom du serveur" +"Erroneous nickname" = "Pseudo erroné" +"Formatting/colour is disabled (+c)" = "Le formatage/la couleur est désactivé (+c)" +"Formatting/colour is disabled for you (+b c:)" = "Le formatage/la couleur est désactivé pour vous (+b c:)" +"Global message (/amsg or /ame) blocked" = "Message global (/amsg ou /ame) bloqué" +"Invalid vhost credentials" = "Identifiants de vhost invalides" +"Kicks are disabled here (+Q)" = "Les kicks sont désactivés ici (+Q)" +"MOTD File is missing" = "Le fichier MOTD est manquant" +"Maximum size for WATCH-list exceeded" = "Taille maximale de la liste WATCH dépassée" +"Message blocked by the spam filter" = "Message bloqué par le filtre anti-spam" +"Monitor list is full" = "La liste MONITOR est pleine" +"Nickname is already in use" = "Le pseudo est déjà utilisé" +"No OPERMOTD is set" = "Aucun OPERMOTD n'est défini" +"No help available for that topic" = "Aucune aide disponible pour ce sujet" +"No matching servers" = "Aucun serveur correspondant" +"No nickname given" = "Aucun pseudo fourni" +"No such channel" = "Salon inexistant" +"No such nick/channel" = "Pseudo/salon inexistant" +"No such server" = "Serveur inexistant" +"No text to send" = "Aucun texte à envoyer" +"No topic is set" = "Aucun sujet n'est défini" +"Not enough parameters" = "Paramètres insuffisants" +"Only IRC operators may create new channels here" = "Seuls les opérateurs IRC peuvent créer de nouveaux salons ici" +"Only operators may remove the +x host cloak" = "Seuls les opérateurs peuvent retirer le masque d'hôte +x" +"Password incorrect" = "Mot de passe incorrect" +"Password incorrect (a matching TLS client certificate is required)" = "Mot de passe incorrect (un certificat client TLS correspondant est requis)" +"Permission Denied- RELAYMSG is for IRC operators" = "Permission refusée - RELAYMSG est réservé aux opérateurs IRC" +"Permission Denied- SVSLOGIN is a services command" = "Permission refusée - SVSLOGIN est une commande de services" +"Permission Denied- SVSLOGOUT is a services command" = "Permission refusée - SVSLOGOUT est une commande de services" +"Permission Denied- You're not an IRC operator" = "Permission refusée - Vous n'êtes pas opérateur IRC" +"Permission Denied- account login is handled by services (set oper_svslogin to override)" = "Permission refusée - la connexion au compte est gérée par les services (définissez oper_svslogin pour outrepasser)" +"Permission denied - only operators may set a server notice mask" = "Permission refusée - seuls les opérateurs peuvent définir un masque de notices serveur" +"RESTART requires the server name" = "RESTART nécessite le nom du serveur" +"RMODE only supports list modes b, e, I" = "RMODE ne prend en charge que les modes de liste b, e, I" +"RMODE usage: RMODE [pattern]" = "Usage RMODE : RMODE [motif]" +"Rehashing" = "Rechargement" +"SASL EXTERNAL requires a client certificate" = "SASL EXTERNAL nécessite un certificat client" +"SASL authentication aborted" = "Authentification SASL annulée" +"SASL authentication failed" = "Échec de l'authentification SASL" +"SASL authentication failed (services are not available)" = "Échec de l'authentification SASL (les services ne sont pas disponibles)" +"SASL authentication successful" = "Authentification SASL réussie" +"SASL message too long" = "Message SASL trop long" +"The server map is hidden; ask an operator." = "La carte du serveur est masquée ; demandez à un opérateur." +"There was no such nickname" = "Ce pseudo n'existait pas" +"They aren't on that channel" = "Ils ne sont pas sur ce salon" +"This channel is temporarily unavailable (+j join flood)" = "Ce salon est temporairement indisponible (+j flood de connexions)" +"This command has been disabled by the administrator." = "Cette commande a été désactivée par l'administrateur." +"Too many auth attempts, try again" = "Trop de tentatives d'authentification, réessayez" +"Too many nick changes, try later (+F is set)" = "Trop de changements de pseudo, réessayez plus tard (+F est activé)" +"Unknown command" = "Commande inconnue" +"Unsupported SASL mechanism" = "Mécanisme SASL non pris en charge" + +# --- listes : « End of … » --- +"End of /HELP" = "Fin de /HELP" +"End of /INFO list" = "Fin de la liste /INFO" +"End of /LINKS list" = "Fin de la liste /LINKS" +"End of /LIST" = "Fin de /LIST" +"End of /MAP" = "Fin de /MAP" +"End of /MOTD command." = "Fin de la commande /MOTD." +"End of /NAMES list" = "Fin de la liste /NAMES" +"End of /STATS report" = "Fin du rapport /STATS" +"End of /WHO list" = "Fin de la liste /WHO" +"End of /WHOIS list" = "Fin de la liste /WHOIS" +"End of ACCEPT list" = "Fin de la liste ACCEPT" +"End of FILTER list" = "Fin de la liste FILTER" +"End of MODULES list" = "Fin de la liste MODULES" +"End of MONITOR list" = "Fin de la liste MONITOR" +"End of SIGNORE list." = "Fin de la liste SIGNORE." +"End of SILENCE list" = "Fin de la liste SILENCE" +"End of WATCH S" = "Fin de WATCH S" +"End of WATCH list" = "Fin de la liste WATCH" +"End of WHOWAS" = "Fin de WHOWAS" +"End of channel list." = "Fin de la liste des salons." + +# --- toi / vous --- +"You are no longer marked as being away" = "Vous n'êtes plus marqué comme absent" +"You are now an IRC operator" = "Vous êtes maintenant opérateur IRC" +"You cannot KILL a network service" = "Vous ne pouvez pas KILL un service réseau" +"You cannot kick a network service" = "Vous ne pouvez pas expulser un service réseau" +"You cannot kick a user of higher rank" = "Vous ne pouvez pas expulser un utilisateur de rang supérieur" +"You cannot remove a user of higher rank" = "Vous ne pouvez pas retirer un utilisateur de rang supérieur" +"You cannot send messages to this user." = "Vous ne pouvez pas envoyer de messages à cet utilisateur." +"You cannot share FILEHOST links over a non-TLS connection." = "Vous ne pouvez pas partager des liens FILEHOST via une connexion non-TLS." +"You have been marked as being away" = "Vous avez été marqué comme absent" +"You have been redirected." = "Vous avez été redirigé." +"You have joined too many channels" = "Vous avez rejoint trop de salons" +"You have not registered" = "Vous ne vous êtes pas enregistré" +"You may not reregister" = "Vous ne pouvez pas vous réenregistrer" +"You may only SSLINFO yourself" = "Vous ne pouvez faire un SSLINFO que sur vous-même" +"You must be in the channel to use RELAYMSG" = "Vous devez être dans le salon pour utiliser RELAYMSG" +"You must be logged into an account to message this user (+R)" = "Vous devez être connecté à un compte pour envoyer un message à cet utilisateur (+R)" +"You must be logged into an account to speak here (+M)" = "Vous devez être connecté à un compte pour parler ici (+M)" +"You must enable the draft/relaymsg capability to use RELAYMSG" = "Vous devez activer la capability draft/relaymsg pour utiliser RELAYMSG" +"You must request the draft/extended-isupport capability to use this command" = "Vous devez demander la capability draft/extended-isupport pour utiliser cette commande" +"You must request the sasl capability first" = "Vous devez d'abord demander la capability sasl" +"You must share a channel to message this user (+c)" = "Vous devez partager un salon pour envoyer un message à cet utilisateur (+c)" +"You're not a channel operator" = "Vous n'êtes pas opérateur du salon" +"You're not on that channel" = "Vous n'êtes pas sur ce salon" +"Your KNOCK has been delivered" = "Votre KNOCK a été transmis" +"Your SIGNORE list is empty." = "Votre liste SIGNORE est vide." +"Your SILENCE list is full" = "Votre liste SILENCE est pleine" +"Your accept list is full" = "Votre liste accept est pleine" +"Your nickname is locked and cannot be changed" = "Votre pseudo est verrouillé et ne peut pas être changé" + +# --- fragments (préfixés d'un pseudo/param en amont) --- +"are available SASL mechanisms" = "sont les mécanismes SASL disponibles" +"are supported by this server" = "sont pris en charge par ce serveur" +"cycling" = "recyclage" +"has been informed that you messaged them." = "a été informé que vous lui avez écrit." +"is a bot" = "est un bot" +"is a network service" = "est un service réseau" +"is a registered nick" = "est un pseudo enregistré" +"is already on channel" = "est déjà sur le salon" +"is already on your accept list" = "est déjà sur votre liste accept" +"is an IRC Operator" = "est un opérateur IRC" +"is available for help." = "est disponible pour aider." +"is in +g mode (server-side ignore)." = "est en mode +g (ignore côté serveur)." +"is logged in as" = "est connecté en tant que" +"is messaging you, and you have umode +g." = "vous envoie un message, et vous avez le umode +g." +"is not on your accept list" = "n'est pas sur votre liste accept" +"is now offline" = "est maintenant hors ligne" +"is now online" = "est maintenant en ligne" +"is now your displayed host" = "est maintenant votre hôte affiché" +"is offline" = "est hors ligne" +"is online" = "est en ligne" +"is unknown mode char to me" = "est un caractère de mode inconnu pour moi" +"loaded" = "chargé" +"seconds idle, signon time" = "secondes d'inactivité, heure de connexion" +"stopped watching" = "a cessé de surveiller" diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..e5753db --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,294 @@ +//! Server-wide message localization. +//! +//! The English strings in the source are the msgids (the source of truth); a per-locale +//! catalog file `lang/.conf` maps each to its translation. The catalog is parsed +//! ONCE at startup/rehash into an in-memory map — there is no per-message disk access and +//! no per-message parsing. `locale = en` (or a missing file) yields an empty catalog that +//! passes English through untouched, so the default path is byte-for-byte what it was. +//! +//! Only prose sent to a LOCAL client is localized (numeric trailing text, notices). The +//! hot channel-broadcast fan-out is protocol (`PRIVMSG`/`JOIN`/`MODE`) and is never +//! touched, so the fast path is unchanged. Protocol tokens, params, nicks, channels and +//! the S2S wire stay canonical English always. +//! +//! File format, one entry per line (blank lines and `#` comments ignored): +//! ```text +//! "No such nick/channel" = "Aucun pseudo/salon de ce nom" +//! "Welcome to the {0} IRC Network, {1}" = "Bienvenue sur le réseau IRC {0}, {1}" +//! ``` +//! `{0}`,`{1}`,… are positional placeholders filled after translation, so word order +//! may differ per language. A translation whose placeholder set differs from its msgid +//! is rejected at load time (kept as English) so a bad file can never panic or drop args. + +use crate::map::HashMap; +use std::borrow::Cow; + +/// A loaded locale: English msgid -> translated string. Empty = English passthrough. +#[derive(Default)] +pub struct Catalog { + map: HashMap, Box>, + pub locale: String, +} + +impl Catalog { + /// English passthrough (locale `en` or a missing/unreadable file). + pub fn passthrough() -> Catalog { + Catalog { + map: HashMap::default(), + locale: "en".to_string(), + } + } + + #[inline] + pub fn is_passthrough(&self) -> bool { + self.map.is_empty() + } + + pub fn entries(&self) -> usize { + self.map.len() + } + + /// Load `/.conf`. `en`/empty/unreadable -> passthrough. The second + /// return value is a list of human-readable warnings (bad lines, placeholder + /// mismatches, missing file) for the caller to log — loading never fails hard. + pub fn load(dir: &str, locale: &str) -> (Catalog, Vec) { + let mut warnings = Vec::new(); + if locale.is_empty() || locale.eq_ignore_ascii_case("en") { + return (Catalog::passthrough(), warnings); + } + let path = format!("{dir}/{locale}.conf"); + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + warnings.push(format!("locale {locale}: cannot read {path}: {e} (using English)")); + return (Catalog::passthrough(), warnings); + } + }; + let mut map: HashMap, Box> = HashMap::default(); + for (i, raw) in text.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + match parse_entry(line) { + Some((key, val)) => { + if placeholders(&key) != placeholders(&val) { + warnings.push(format!( + "locale {locale}:{}: placeholder set differs from msgid, skipped: {key:?}", + i + 1 + )); + continue; + } + if !val.is_empty() { + map.insert(key.into_boxed_str(), val.into_boxed_str()); + } + } + None => warnings.push(format!("locale {locale}:{}: malformed entry, skipped", i + 1)), + } + } + ( + Catalog { + map, + locale: locale.to_string(), + }, + warnings, + ) + } + + /// Translate a whole English string; returns the input unchanged if not present. + #[inline] + pub fn tr<'a>(&'a self, english: &'a str) -> &'a str { + match self.map.get(english) { + Some(t) => t, + None => english, + } + } + + /// Translate only the *trailing prose* of a numeric body `[params ]:trailing`, + /// leaving params (nicks/channels/numbers) intact. Passthrough locales and bodies + /// with no catalogued trailing return the input borrowed (no allocation). + pub fn tr_numeric<'a>(&'a self, rest: &'a str) -> Cow<'a, str> { + if self.is_passthrough() { + return Cow::Borrowed(rest); + } + let (head, trail) = if let Some(idx) = rest.find(" :") { + (&rest[..idx + 2], &rest[idx + 2..]) + } else if rest.starts_with(':') { + (&rest[..1], &rest[1..]) + } else { + return Cow::Borrowed(rest); + }; + let translated = self.tr(trail); + if std::ptr::eq(translated.as_ptr(), trail.as_ptr()) { + Cow::Borrowed(rest) + } else { + Cow::Owned(format!("{head}{translated}")) + } + } +} + +/// Fill `{0}`,`{1}`,… placeholders in a (already-translated) template. `{{`/`}}` are +/// literal braces; an out-of-range or malformed index is emitted verbatim so a bad +/// template degrades to visible text rather than losing data. +pub fn render(template: &str, args: &[&str]) -> String { + if !template.contains('{') { + return template.to_string(); + } + let mut out = String::with_capacity(template.len() + 16); + let b = template.as_bytes(); + let mut i = 0; + while i < b.len() { + match b[i] { + b'{' if i + 1 < b.len() && b[i + 1] == b'{' => { + out.push('{'); + i += 2; + } + b'}' if i + 1 < b.len() && b[i + 1] == b'}' => { + out.push('}'); + i += 2; + } + b'{' => { + if let Some(end) = template[i + 1..].find('}') { + let inner = &template[i + 1..i + 1 + end]; + if let Ok(idx) = inner.parse::() { + if let Some(a) = args.get(idx) { + out.push_str(a); + } + i += end + 2; + continue; + } + } + out.push('{'); + i += 1; + } + _ => { + // copy one UTF-8 char + let ch_len = utf8_len(b[i]); + out.push_str(&template[i..i + ch_len]); + i += ch_len; + } + } + } + out +} + +fn utf8_len(first: u8) -> usize { + match first { + 0x00..=0x7f => 1, + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + _ => 4, + } +} + +/// The set of placeholder indices `{N}` used in `s` (ignoring `{{`/`}}`). +fn placeholders(s: &str) -> std::collections::BTreeSet { + let mut set = std::collections::BTreeSet::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'{' { + if i + 1 < b.len() && b[i + 1] == b'{' { + i += 2; + continue; + } + if let Some(end) = s[i + 1..].find('}') { + if let Ok(idx) = s[i + 1..i + 1 + end].parse::() { + set.insert(idx); + } + i += end + 2; + continue; + } + } + i += 1; + } + set +} + +/// Parse one `"msgid" = "msgstr"` line, honoring `\"`, `\\` and `\n` escapes. +fn parse_entry(line: &str) -> Option<(String, String)> { + let (key, rest) = read_quoted(line)?; + let rest = rest.trim_start(); + let rest = rest.strip_prefix('=')?.trim_start(); + let (val, tail) = read_quoted(rest)?; + if !tail.trim().is_empty() { + return None; + } + Some((key, val)) +} + +/// Read a `"..."` string at the start of `s`; return (unescaped, remainder). +fn read_quoted(s: &str) -> Option<(String, &str)> { + let mut chars = s.char_indices(); + if chars.next()?.1 != '"' { + return None; + } + let mut out = String::new(); + while let Some((idx, c)) = chars.next() { + match c { + '\\' => match chars.next()?.1 { + 'n' => out.push('\n'), + 't' => out.push('\t'), + '"' => out.push('"'), + '\\' => out.push('\\'), + other => { + out.push('\\'); + out.push(other); + } + }, + '"' => return Some((out, &s[idx + 1..])), + _ => out.push(c), + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_fills_positional_and_reorders() { + assert_eq!(render("Hi {0} and {1}", &["a", "b"]), "Hi a and b"); + // translations may reorder + assert_eq!(render("{1} puis {0}", &["a", "b"]), "b puis a"); + assert_eq!(render("no args here", &[]), "no args here"); + assert_eq!(render("brace {{ ok }}", &[]), "brace { ok }"); + } + + #[test] + fn parse_and_translate() { + let text = "\"No such nick/channel\" = \"Aucun pseudo/salon de ce nom\"\n# c\n\"x {0}\" = \"y {0}\"\n"; + std::fs::create_dir_all("/tmp/echo-i18n-test").unwrap(); + std::fs::write("/tmp/echo-i18n-test/fr.conf", text).unwrap(); + let (cat, warns) = Catalog::load("/tmp/echo-i18n-test", "fr"); + assert!(warns.is_empty(), "{warns:?}"); + assert_eq!(cat.tr("No such nick/channel"), "Aucun pseudo/salon de ce nom"); + assert_eq!(cat.tr("unknown"), "unknown"); // fallback + } + + #[test] + fn numeric_translates_only_trailing() { + let text = "\"You're not on that channel\" = \"Vous n'êtes pas sur ce salon\"\n"; + std::fs::create_dir_all("/tmp/echo-i18n-test2").unwrap(); + std::fs::write("/tmp/echo-i18n-test2/fr.conf", text).unwrap(); + let (cat, _) = Catalog::load("/tmp/echo-i18n-test2", "fr"); + // params (#chan) preserved, trailing prose translated + assert_eq!( + cat.tr_numeric("#chan :You're not on that channel"), + "#chan :Vous n'êtes pas sur ce salon" + ); + // data trailing not in catalog -> unchanged, no allocation + assert!(matches!(cat.tr_numeric("bob user host * :Real Name"), std::borrow::Cow::Borrowed(_))); + } + + #[test] + fn placeholder_mismatch_is_rejected() { + let text = "\"a {0} {1}\" = \"b {0}\"\n"; // drops {1} + std::fs::create_dir_all("/tmp/echo-i18n-test3").unwrap(); + std::fs::write("/tmp/echo-i18n-test3/fr.conf", text).unwrap(); + let (cat, warns) = Catalog::load("/tmp/echo-i18n-test3", "fr"); + assert_eq!(warns.len(), 1); + assert_eq!(cat.tr("a {0} {1}"), "a {0} {1}"); // stayed English + } +} diff --git a/src/lib.rs b/src/lib.rs index d21e046..f719af4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod config; pub mod coremods; pub mod extensible; pub mod http; +pub mod i18n; pub mod ircd; pub mod link; pub mod map; diff --git a/src/server.rs b/src/server.rs index 8b6ce74..64648c1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -169,6 +169,7 @@ pub struct Server { pub events: VecDeque, pub opers: Vec, // oper logins from config pub brands: Vec, // per-SNI server/network branding + pub catalog: crate::i18n::Catalog, // active-locale message catalog (i18n; en = passthrough) pub cloak_key: Option, // host-cloaking key (see modules::cloak) pub line_ctags: String, // client-only tags of the line being handled // --- server-to-server (see crate::link) --- @@ -232,6 +233,13 @@ pub struct Server { impl Server { pub fn new(cfg: Config, event_tx: Sender, conn_counter: Arc) -> Server { + let (catalog, i18n_warn) = crate::i18n::Catalog::load( + cfg.raw.get("locale_dir").and_then(|v| v.last()).map(String::as_str).unwrap_or("lang"), + cfg.raw.get("locale").and_then(|v| v.last()).map(String::as_str).unwrap_or("en"), + ); + for w in &i18n_warn { + eprintln!("echoircd: {w}"); + } Server { name: cfg.servername, network: cfg.network, @@ -243,6 +251,7 @@ impl Server { events: VecDeque::new(), opers: cfg.opers, brands: cfg.brands, + catalog, cloak_key: cfg.cloak_key, line_ctags: String::new(), sid: cfg.sid, @@ -312,6 +321,19 @@ impl Server { .unwrap_or(default) } + /// Translate a whole English string to the active locale (fallback: the input). + /// `en`/passthrough returns the borrow unchanged — zero cost. + #[inline] + pub fn tr<'a>(&'a self, english: &'a str) -> &'a str { + self.catalog.tr(english) + } + + /// Translate an English template, then fill its `{0}`/`{1}`… placeholders. Use at + /// call sites whose prose interpolates data (word order may differ per language). + pub fn trf(&self, template: &str, args: &[&str]) -> String { + crate::i18n::render(self.catalog.tr(template), args) + } + /// Apply a freshly-loaded config to the running server — the shared body of /// REHASH and the `server.rehash` RPC. Reloads the typed fields **and** /// `raw_config`, so modules reading via `conf*` see the new values too. @@ -329,6 +351,14 @@ impl Server { self.dnsbl_reason = fresh.dnsbl_reason; self.sasl_server = fresh.sasl_server; self.webirc = fresh.webirc; + let (catalog, i18n_warn) = crate::i18n::Catalog::load( + fresh.raw.get("locale_dir").and_then(|v| v.last()).map(String::as_str).unwrap_or("lang"), + fresh.raw.get("locale").and_then(|v| v.last()).map(String::as_str).unwrap_or("en"), + ); + for w in &i18n_warn { + eprintln!("echoircd: {w}"); + } + self.catalog = catalog; self.raw_config = fresh.raw; self.config_gen = self.config_gen.wrapping_add(1); // invalidate module config caches // Re-evaluate which linked servers are services against the fresh @@ -1008,6 +1038,7 @@ impl Server { let srv = u .and_then(|u| u.brand_server.as_deref()) .unwrap_or(&self.name); + let rest = self.catalog.tr_numeric(rest); format!(":{srv} {code:03} {target} {rest}") }; self.send(uid, line);