feat(i18n): add Spanish locale for WebUI (#275)
* feat(i18n): add Spanish locale for WebUI * fix(i18n): translate tab_skills to Habilidades in Spanish locale tab_skills was left as 'Skills' (English) in the es block — the only sidebar tab that wasn't translated. Changed to 'Habilidades', the correct Spanish term for Skills. Also added tab_skills and tab_memory to the representative translation assertions in test_spanish_locale.py to lock this in for future changes. --------- Co-authored-by: gabogabucho <gabogabucho@gmail.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
189
static/i18n.js
189
static/i18n.js
@@ -193,6 +193,195 @@ const LOCALES = {
|
||||
suggest_plan: 'Help me plan a small project.',
|
||||
},
|
||||
|
||||
es: {
|
||||
_lang: 'es',
|
||||
_label: 'Español',
|
||||
_speech: 'es-ES',
|
||||
// boot.js
|
||||
cancelling: 'Cancelando…',
|
||||
cancel_failed: 'Error al cancelar: ',
|
||||
mic_denied: 'Acceso al micrófono denegado. Revisa los permisos del navegador.',
|
||||
mic_no_speech: 'No se detectó voz. Inténtalo de nuevo.',
|
||||
mic_network: 'El reconocimiento de voz no está disponible.',
|
||||
mic_error: 'Error de entrada por voz: ',
|
||||
session_imported: 'Sesión importada',
|
||||
import_failed: 'Error al importar: ',
|
||||
import_invalid_json: 'JSON inválido',
|
||||
image_pasted: 'Imagen pegada: ',
|
||||
// messages.js
|
||||
edit_message: 'Editar mensaje',
|
||||
regenerate: 'Regenerar respuesta',
|
||||
copy: 'Copiar',
|
||||
copied: '¡Copiado!',
|
||||
you: 'Tú',
|
||||
thinking: 'Pensando',
|
||||
expand_all: 'Expandir todo',
|
||||
collapse_all: 'Contraer todo',
|
||||
edit_failed: 'Error al editar: ',
|
||||
regen_failed: 'Error al regenerar: ',
|
||||
reconnect_active: 'Todavía se está generando una respuesta. ¿Recargar cuando termine?',
|
||||
reconnect_finished: 'Había una respuesta en curso cuando te fuiste. Puede que los mensajes se hayan actualizado.',
|
||||
// approval card
|
||||
approval_heading: 'Se requiere aprobación',
|
||||
approval_desc_prefix: 'Se detectó un comando peligroso',
|
||||
approval_btn_once: 'Permitir una vez',
|
||||
approval_btn_once_title: 'Permitir solo este comando (Enter)',
|
||||
approval_btn_session: 'Permitir en la sesión',
|
||||
approval_btn_session_title: 'Permitir durante esta sesión de conversación',
|
||||
approval_btn_always: 'Permitir siempre',
|
||||
approval_btn_always_title: 'Permitir siempre este patrón de comando',
|
||||
approval_btn_deny: 'Denegar',
|
||||
approval_btn_deny_title: 'Denegar — no ejecutar este comando',
|
||||
approval_responding: 'Respondiendo…',
|
||||
untitled: 'Sin título',
|
||||
n_messages: (n) => `${n} mensajes`,
|
||||
model_unavailable: ' (no disponible)',
|
||||
model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores',
|
||||
// commands.js
|
||||
cmd_help: 'Listar los comandos disponibles',
|
||||
cmd_clear: 'Borrar los mensajes de la conversación',
|
||||
cmd_compact: 'Comprimir el contexto de la conversación',
|
||||
cmd_model: 'Cambiar de modelo (p. ej. /model gpt-4o)',
|
||||
cmd_workspace: 'Cambiar de espacio de trabajo por nombre',
|
||||
cmd_new: 'Iniciar una nueva sesión de chat',
|
||||
cmd_usage: 'Activar o desactivar el uso de tokens',
|
||||
cmd_theme: 'Cambiar tema (dark/light/slate/solarized/monokai/nord/oled)',
|
||||
cmd_personality: 'Cambiar la personalidad del agente',
|
||||
cmd_skills: 'Listar las skills de Hermes disponibles',
|
||||
available_commands: 'Comandos disponibles:',
|
||||
type_slash: 'Escribe / para ver los comandos',
|
||||
conversation_cleared: 'Conversación borrada',
|
||||
model_usage: 'Uso: /model <name>',
|
||||
no_model_match: 'No hay ningún modelo que coincida con "',
|
||||
switched_to: 'Se cambió a ',
|
||||
workspace_usage: 'Uso: /workspace <name>',
|
||||
no_workspace_match: 'No hay ningún espacio de trabajo que coincida con "',
|
||||
switched_workspace: 'Se cambió al espacio de trabajo: ',
|
||||
workspace_switch_failed: 'Error al cambiar de espacio de trabajo: ',
|
||||
new_session: 'Nueva sesión creada',
|
||||
compressing: 'Solicitando compresión del contexto...',
|
||||
token_usage_on: 'Uso de tokens activado',
|
||||
token_usage_off: 'Uso de tokens desactivado',
|
||||
theme_usage: 'Uso: /theme ',
|
||||
theme_set: 'Tema: ',
|
||||
no_active_session: 'No hay ninguna sesión activa',
|
||||
no_personalities: 'No se encontraron personalidades (añádelas a ~/.hermes/personalities/)',
|
||||
available_personalities: 'Personalidades disponibles:',
|
||||
personality_switch_hint: '\n\nUsa `/personality <name>` para cambiar, o `/personality none` para limpiar.',
|
||||
personalities_load_failed: 'No se pudieron cargar las personalidades',
|
||||
personality_cleared: 'Personalidad borrada',
|
||||
personality_set: 'Personalidad: ',
|
||||
failed_colon: 'Error: ',
|
||||
// ui.js
|
||||
no_workspace: 'Sin espacio de trabajo',
|
||||
// workspace.js
|
||||
unsaved_confirm: 'Tienes cambios sin guardar en la vista previa. ¿Descartar y navegar?',
|
||||
save: 'Guardar',
|
||||
edit: 'Editar',
|
||||
save_title: 'Guardar cambios',
|
||||
edit_title: 'Editar este archivo',
|
||||
saved: 'Guardado',
|
||||
save_failed: 'Error al guardar: ',
|
||||
image_load_failed: 'No se pudo cargar la imagen',
|
||||
file_open_failed: 'No se pudo abrir el archivo',
|
||||
downloading: (name) => `Descargando ${name}…`,
|
||||
double_click_rename: 'Haz doble clic para renombrar',
|
||||
renamed_to: 'Renombrado a ',
|
||||
rename_failed: 'Error al renombrar: ',
|
||||
delete_title: 'Eliminar',
|
||||
delete_confirm: (name) => `¿Eliminar ${name}?`,
|
||||
deleted: 'Eliminado ',
|
||||
delete_failed: 'Error al eliminar: ',
|
||||
new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):',
|
||||
created: 'Creado ',
|
||||
create_failed: 'Error al crear: ',
|
||||
new_folder_prompt: 'Nombre de la carpeta nueva:',
|
||||
folder_created: 'Carpeta creada ',
|
||||
folder_create_failed: 'Error al crear la carpeta: ',
|
||||
remove_title: 'Quitar',
|
||||
empty_dir: '(vacío)',
|
||||
upload_failed: 'Error al subir: ',
|
||||
all_uploads_failed: (n) => `Fallaron las ${n} subida(s)`,
|
||||
// settings panel
|
||||
settings_title: 'Configuración',
|
||||
settings_save_btn: 'Guardar configuración',
|
||||
settings_label_model: 'Modelo predeterminado',
|
||||
settings_label_send_key: 'Tecla de envío',
|
||||
settings_label_theme: 'Tema',
|
||||
settings_label_language: 'Idioma',
|
||||
settings_label_token_usage: 'Mostrar uso de tokens',
|
||||
settings_label_cli_sessions: 'Mostrar sesiones de CLI',
|
||||
settings_label_sync_insights: 'Sincronizar con insights',
|
||||
settings_label_check_updates: 'Buscar actualizaciones',
|
||||
settings_label_bot_name: 'Nombre del asistente',
|
||||
settings_label_password: 'Contraseña de acceso',
|
||||
settings_saved: 'Configuración guardada',
|
||||
settings_save_failed: 'Error al guardar: ',
|
||||
settings_load_failed: 'Error al cargar la configuración: ',
|
||||
settings_saved_pw: 'Configuración guardada (contraseña establecida — ahora se requiere iniciar sesión)',
|
||||
// login page (used server-side via /api/i18n/login endpoint)
|
||||
login_title: 'Iniciar sesión',
|
||||
login_subtitle: 'Introduce tu contraseña para continuar',
|
||||
login_placeholder: 'Contraseña',
|
||||
login_btn: 'Entrar',
|
||||
login_invalid_pw: 'Contraseña inválida',
|
||||
login_conn_failed: 'Error de conexión',
|
||||
dialog_confirm_title: 'Confirmar acción',
|
||||
dialog_prompt_title: 'Introduce un valor',
|
||||
dialog_confirm_btn: 'Confirmar',
|
||||
discard: 'Descartar',
|
||||
clear: 'Borrar',
|
||||
create: 'Crear',
|
||||
remove: 'Quitar',
|
||||
project_name_prompt: 'Nombre del proyecto:',
|
||||
// Sidebar & Tabs
|
||||
tab_chat: 'Chat',
|
||||
tab_tasks: 'Tareas',
|
||||
tab_skills: 'Habilidades',
|
||||
tab_memory: 'Memoria',
|
||||
tab_workspaces: 'Espacios',
|
||||
tab_profiles: 'Perfiles',
|
||||
tab_todos: 'Todos',
|
||||
new_conversation: 'Nueva conversación',
|
||||
filter_conversations: 'Filtrar conversaciones...',
|
||||
scheduled_jobs: 'Tareas programadas',
|
||||
new_job: 'Nueva tarea',
|
||||
loading: 'Cargando...',
|
||||
search_skills: 'Buscar skills...',
|
||||
new_skill: 'Nueva skill',
|
||||
personal_memory: 'Memoria personal',
|
||||
current_task_list: 'Lista de tareas actual',
|
||||
workspace_desc: 'Añade y cambia espacios de trabajo para tus sesiones.',
|
||||
new_profile: 'Nuevo perfil',
|
||||
transcript: 'Transcripción',
|
||||
download_transcript: 'Descargar como Markdown',
|
||||
import: 'Importar',
|
||||
// Settings detail
|
||||
settings_label_sound: 'Sonido de notificación',
|
||||
settings_desc_sound: 'Reproduce un sonido cuando el asistente termina una respuesta.',
|
||||
settings_label_notifications: 'Notificaciones del navegador',
|
||||
settings_desc_notifications: 'Muestra una notificación del sistema cuando una respuesta termina mientras la pestaña está en segundo plano.',
|
||||
settings_desc_token_usage: 'Muestra el conteo de tokens de entrada/salida debajo de cada respuesta del asistente. También se puede alternar con /usage.',
|
||||
settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversación.',
|
||||
settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.',
|
||||
settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.',
|
||||
settings_desc_bot_name: 'Nombre visible del asistente en toda la UI. Por defecto es Hermes.',
|
||||
settings_desc_password: 'Introduce una nueva contraseña para establecerla o cambiarla. Déjalo en blanco para mantener la configuración actual.',
|
||||
password_placeholder: 'Introduce una contraseña nueva…',
|
||||
disable_auth: 'Desactivar autenticación',
|
||||
sign_out: 'Cerrar sesión',
|
||||
cancel: 'Cancelar',
|
||||
create_job: 'Crear tarea',
|
||||
save_skill: 'Guardar skill',
|
||||
editing: 'Editando',
|
||||
// Empty state
|
||||
empty_title: '¿En qué puedo ayudarte?',
|
||||
empty_subtitle: 'Pregunta lo que quieras, ejecuta comandos, explora archivos o gestiona tus tareas programadas.',
|
||||
suggest_files: '¿Qué archivos hay en este espacio de trabajo?',
|
||||
suggest_schedule: '¿Qué tengo hoy en mi agenda?',
|
||||
suggest_plan: 'Ayúdame a planificar un proyecto pequeño.',
|
||||
},
|
||||
|
||||
de: {
|
||||
_lang: 'de',
|
||||
_label: 'Deutsch',
|
||||
|
||||
45
tests/test_spanish_locale.py
Normal file
45
tests/test_spanish_locale.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_spanish_locale_block_exists():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
assert "\n es: {" in src
|
||||
assert "_label: 'Español'" in src
|
||||
assert "_speech: 'es-ES'" in src
|
||||
|
||||
|
||||
def test_spanish_locale_includes_representative_translations():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
expected = [
|
||||
"settings_title: 'Configuración'",
|
||||
"login_title: 'Iniciar sesión'",
|
||||
"approval_heading: 'Se requiere aprobación'",
|
||||
"tab_tasks: 'Tareas'",
|
||||
"tab_skills: 'Habilidades'",
|
||||
"tab_memory: 'Memoria'",
|
||||
]
|
||||
for entry in expected:
|
||||
assert entry in src
|
||||
|
||||
|
||||
def test_spanish_locale_covers_english_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
en_match = re.search(r"\n en: \{([\s\S]*?)\n \},\n\n es: \{", src)
|
||||
es_match = re.search(r"\n es: \{([\s\S]*?)\n \},\n\n de: \{", src)
|
||||
assert en_match, "English locale block not found"
|
||||
assert es_match, "Spanish locale block not found"
|
||||
|
||||
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
en_keys = set(key_pattern.findall(en_match.group(1)))
|
||||
es_keys = set(key_pattern.findall(es_match.group(1)))
|
||||
|
||||
missing = sorted(en_keys - es_keys)
|
||||
assert not missing, f"Spanish locale missing keys: {missing}"
|
||||
Reference in New Issue
Block a user