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:
parent
5914a6d993
commit
560c860f1d
6 changed files with 686 additions and 0 deletions
|
|
@ -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
177
lang/es.conf
Normal 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
177
lang/fr.conf
Normal 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
294
src/i18n.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue