i18n: server-wide locale via lang/<code>.conf catalogs (fr, es); numeric prose auto-translated at the chokepoint, en = zero-cost passthrough

This commit is contained in:
Jean Chevronnet 2026-08-25 12:14:35 +00:00
parent 5914a6d993
commit 560c860f1d
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
6 changed files with 686 additions and 0 deletions

View file

@ -372,6 +372,12 @@ antiabuse {
# --- hashident: replace ident with a stable opaque token per IP --- # --- hashident: replace ident with a stable opaque token per IP ---
# hashident yes; # hashident yes;
# hashident_key "CHANGE_THIS_SECRET"; # HMAC key; makes the mapping unforgeable # 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 <code> loads lang/<code>.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 <code>.conf catalogs (default: lang)
# --- solvemsg: unvouched users answer one arithmetic question before their # --- solvemsg: unvouched users answer one arithmetic question before their
# PMs are delivered (opers & logged-in accounts exempt). --- # PMs are delivered (opers & logged-in accounts exempt). ---
# solvemsg yes; # solvemsg yes;

177
lang/es.conf Normal file
View file

@ -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 <channel> <b|e|I> [pattern]" = "Uso de RMODE: RMODE <canal> <b|e|I> [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"

177
lang/fr.conf Normal file
View file

@ -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 <channel> <b|e|I> [pattern]" = "Usage RMODE : RMODE <salon> <b|e|I> [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"

294
src/i18n.rs Normal file
View file

@ -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/<code>.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<str>, Box<str>>,
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 `<dir>/<locale>.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<String>) {
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<str>, Box<str>> = 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::<usize>() {
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<usize> {
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::<usize>() {
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
}
}

View file

@ -20,6 +20,7 @@ pub mod config;
pub mod coremods; pub mod coremods;
pub mod extensible; pub mod extensible;
pub mod http; pub mod http;
pub mod i18n;
pub mod ircd; pub mod ircd;
pub mod link; pub mod link;
pub mod map; pub mod map;

View file

@ -169,6 +169,7 @@ pub struct Server {
pub events: VecDeque<Hook>, pub events: VecDeque<Hook>,
pub opers: Vec<crate::config::OperBlock>, // oper logins from config pub opers: Vec<crate::config::OperBlock>, // oper logins from config
pub brands: Vec<crate::config::BrandBlock>, // per-SNI server/network branding pub brands: Vec<crate::config::BrandBlock>, // per-SNI server/network branding
pub catalog: crate::i18n::Catalog, // active-locale message catalog (i18n; en = passthrough)
pub cloak_key: Option<String>, // host-cloaking key (see modules::cloak) pub cloak_key: Option<String>, // host-cloaking key (see modules::cloak)
pub line_ctags: String, // client-only tags of the line being handled pub line_ctags: String, // client-only tags of the line being handled
// --- server-to-server (see crate::link) --- // --- server-to-server (see crate::link) ---
@ -232,6 +233,13 @@ pub struct Server {
impl Server { impl Server {
pub fn new(cfg: Config, event_tx: Sender<Event>, conn_counter: Arc<AtomicU64>) -> Server { pub fn new(cfg: Config, event_tx: Sender<Event>, conn_counter: Arc<AtomicU64>) -> 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 { Server {
name: cfg.servername, name: cfg.servername,
network: cfg.network, network: cfg.network,
@ -243,6 +251,7 @@ impl Server {
events: VecDeque::new(), events: VecDeque::new(),
opers: cfg.opers, opers: cfg.opers,
brands: cfg.brands, brands: cfg.brands,
catalog,
cloak_key: cfg.cloak_key, cloak_key: cfg.cloak_key,
line_ctags: String::new(), line_ctags: String::new(),
sid: cfg.sid, sid: cfg.sid,
@ -312,6 +321,19 @@ impl Server {
.unwrap_or(default) .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 /// Apply a freshly-loaded config to the running server — the shared body of
/// REHASH and the `server.rehash` RPC. Reloads the typed fields **and** /// REHASH and the `server.rehash` RPC. Reloads the typed fields **and**
/// `raw_config`, so modules reading via `conf*` see the new values too. /// `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.dnsbl_reason = fresh.dnsbl_reason;
self.sasl_server = fresh.sasl_server; self.sasl_server = fresh.sasl_server;
self.webirc = fresh.webirc; 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.raw_config = fresh.raw;
self.config_gen = self.config_gen.wrapping_add(1); // invalidate module config caches self.config_gen = self.config_gen.wrapping_add(1); // invalidate module config caches
// Re-evaluate which linked servers are services against the fresh // Re-evaluate which linked servers are services against the fresh
@ -1008,6 +1038,7 @@ impl Server {
let srv = u let srv = u
.and_then(|u| u.brand_server.as_deref()) .and_then(|u| u.brand_server.as_deref())
.unwrap_or(&self.name); .unwrap_or(&self.name);
let rest = self.catalog.tr_numeric(rest);
format!(":{srv} {code:03} {target} {rest}") format!(":{srv} {code:03} {target} {rest}")
}; };
self.send(uid, line); self.send(uid, line);