diff --git a/static/js/i18n.js b/static/js/i18n.js index d73aea73..c4c9f98a 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -28,6 +28,7 @@ export const LANGUAGES = [ { code: "zh-Hans", flag: "🇨🇳", name: "简体中文" }, { code: "de", flag: "🇩🇪", name: "Deutsch" }, { code: "fr", flag: "🇫🇷", name: "Français" }, + { code: "es", flag: "🇪🇸", name: "Español" }, { code: "pt", flag: "🇧🇷", name: "Português (Brasil)" }, { code: "pt-PT", flag: "🇵🇹", name: "Português (Portugal)" }, { code: "id", flag: "🇮🇩", name: "Bahasa Indonesia" }, @@ -55,7 +56,11 @@ function _tables() { let _lang = DEFAULT_LANG; const _listeners = new Set(); -function _detectDefault() { +// Exported for tests/js/i18n-detect.test.mjs. Adding a table to LANGUAGES and +// forgetting the branch here is silent: the language shows up in the picker and +// simply never gets chosen on its own. That happened to fr, which shipped and +// went undetected until Spanish was added beside it. +export function _detectDefault() { const nav = (navigator.languages && navigator.languages[0]) || navigator.language || "en"; const lower = nav.toLowerCase(); if (lower.startsWith("pl")) return "pl"; @@ -64,6 +69,10 @@ function _detectDefault() { // unless it's explicitly a traditional-script region, which we don't ship). if (lower.startsWith("zh")) return "zh-Hans"; if (lower.startsWith("de")) return "de"; + if (lower.startsWith("fr")) return "fr"; + // Neutral Spanish for every es-* locale. There is one table, deliberately + // free of regional vocabulary, so es-ES and es-419 both land on it. + if (lower.startsWith("es")) return "es"; // Portuguese: an explicit Portugal locale gets the European variant, which // overrides only the words that differ and falls through to pt for the rest. // pt-BR and a bare "pt" both take the Brazilian table, which is the more @@ -4344,4 +4353,522 @@ const ptPT = { "failure.hint": "Estes detalhes vão para o relatório. O título da faixa e o link de origem não são incluídos, adicione-os se ajudar. Clicar num botão abaixo copia isto para a área de transferência.", }; -export const TRANSLATIONS = { en, pl, ja, "zh-Hans": zhHans, de, fr, pt, "pt-PT": ptPT, id }; +const es = { + "doc.title": "StemDeck — separa cualquier pista en stems", + + "topbar.urlPlaceholder": "Busca, pega un enlace de YouTube o SoundCloud, o suelta un archivo de audio…", + "topbar.removeFile": "Quitar archivo", + "topbar.uploadFile": "Subir archivo de audio", + "extract.label": "Extraer", + "extract.all": "Todo", + "process.splitStems": "Separar stems", + + "stem.original": "Original", + "stem.vocals": "Voces", + "stem.lead_vocals": "Voz principal", + "stem.backing_vocals": "Coros", + "stem.drums": "Batería", + "stem.bass": "Bajo", + "stem.guitar": "Guitarra", + "stem.piano": "Piano", + "stem.other": "Otros", + "stem.others": "Otros", + + "vocalMode.groupAria": "Modo de voces", + "vocalMode.all": "Todo", + "vocalMode.split": "Principal + coros", + + "aria.mute": "Mute {name}", + "aria.solo": "Solo {name}", + "aria.soloOnly": "Solo únicamente {name}", + "aria.download": "Descargar {name}", + "aria.volume": "Volumen de {name}", + + "notif.bell": "Notificaciones", + "notif.title": "Notificaciones", + "notif.clearAll": "Borrar todo", + "notif.close": "Cerrar notificaciones", + "notif.newRelease": "Nueva versión disponible", + "notif.dismiss": "Descartar notificación", + "notif.empty": "No hay notificaciones nuevas", + + "nav.libraryNav": "Navegación de la biblioteca", + "nav.toggleLibrary": "Mostrar u ocultar la biblioteca", + "nav.library": "Biblioteca", + "nav.favorites": "Favoritos", + "nav.trash": "Papelera", + "nav.importQueue": "Cola de importación", + "nav.queue": "Cola", + "nav.settings": "Ajustes", + "nav.weRecommend": "Recomendamos", + "nav.weRecommendLine1": "Reco-", + "nav.weRecommendLine2": "mendamos", + "nav.help": "Ayuda", + + "search.ariaLabel": "Buscar en la biblioteca", + "search.placeholder": "Busca o #etiqueta…", + "search.tab.ytSongs": "Canciones de YouTube", + "search.tab.ytPlaylists": "Listas de YouTube", + "search.tab.scSongs": "Canciones de SoundCloud", + "search.searching": "Buscando…", + "search.noResults": "Sin resultados", + "search.failed": "La búsqueda falló. Revisa tu conexión.", + "search.tooLong": "Más de {mins} min", + "search.tooLongHint": "Supera tu límite de {mins} minutos. Cámbialo en Ajustes.", + "search.preview": "Vista previa", + "search.play": "Reproducir", + "search.pause": "Pausar", + "search.seek": "Desplazar", + "search.previewFailed": "Vista previa no disponible", + "search.tagSuggestions": "Sugerencias de etiquetas", + "search.placeholderLibrary": "Buscar en la biblioteca…", + "search.placeholderFavorites": "Buscar en favoritos…", + "search.placeholderTrash": "Buscar en la papelera…", + "trash.empty": "Vaciar papelera", + "trash.isEmptyState": "La papelera está vacía", + "trash.noSearchMatch": "Ninguna pista eliminada coincide con tu búsqueda", + + "meta.key": "Tonalidad", + "meta.bpm": "BPM", + "meta.lufs": "LUFS", + "meta.duration": "Duración", + "meta.scale": "Escala", + "meta.dynamicRange": "Rango dinámico", + "meta.tempoStability": "Estabilidad del tempo", + + "presence.vocal": "Presencia de voz", + "presence.drum": "Intensidad de batería", + "presence.bass": "Profundidad del bajo", + "presence.guitar": "Presencia de guitarra", + "presence.piano": "Presencia de piano", + "presence.other": "Otros", + + "sections.title": "Secciones", + "sections.add": "Añadir", + "sections.addAria": "Añadir sección", + "sections.savingAria": "Guardando secciones", + + "mixer.title": "Mezclador", + "mixer.hint": "Arrastra el fader · M/S", + "stemsPanel.ariaLabel": "Stems", + + "beatgrid.toolbarAria": "Editor de GRID", + "beatgrid.gridLabel": "GRID", + "beatgrid.toolAria": "Herramienta", + "beatgrid.move": "Mover", + "beatgrid.moveTitle": "Arrastra los tiempos", + "beatgrid.add": "Añadir", + "beatgrid.addTitle": "Haz clic para añadir un tiempo", + "beatgrid.delete": "Eliminar", + "beatgrid.deleteTitle": "Haz clic en un tiempo para quitarlo", + "beatgrid.barLine": "Línea de compás", + "beatgrid.barLineTitle": "Haz clic en un tiempo para marcarlo como tiempo fuerte", + "beatgrid.ripple": "Cadena", + "beatgrid.snap": "Ajuste", + "beatgrid.bar": "Compás", + "beatgrid.barAria": "Tiempos por compás en esta región", + "beatgrid.undo": "Deshacer", + "beatgrid.undoTitle": "Deshacer (Cmd/Ctrl+Z)", + "beatgrid.redo": "Rehacer", + "beatgrid.redoTitle": "Rehacer (Cmd/Ctrl+Shift+Z)", + "beatgrid.reset": "Restablecer", + "beatgrid.resetTitle": "Descarta todos los cambios y restaura el GRID detectado", + "beatgrid.done": "Listo", + "beatgrid.hint": "Arrastra un tiempo para reajustar la región. Alt+arrastrar mueve un solo tiempo.", + + "wave.loadingAria": "Cargando forma de onda", + "job.cancelAria": "Cancelar tarea", + "job.cancel": "Cancelar", + + "footer.stemsPlaceholder": "— Stems", + "footer.stemsCount": "{count} stems", + "footer.stemsCount.one": "{count} stem", + "footer.stemsCount.other": "{count} stems", + "footer.extractedLabel": "Extraído", + "footer.extracted": "Extraído {when}", + "fav.toggleAria": "Marcar o desmarcar como favorito", + "footer.seekAria": "Desplazar", + + "export.optionsAria": "Opciones de exportación", + "export.formatAria": "Formato de exportación", + "export.includeClick": "Incluir el click", + "export.videoFailed": "La exportación a MP4 no está disponible: no se pudo obtener el vídeo de esta pista. Los stems de audio no se ven afectados.", + "export.includeClickTitle": "Mezcla el click dentro del archivo exportado", + "export.addCountIn": "Añadir cuenta de entrada (1 compás)", + "export.addCountInTitle": "Antepone un compás de click como cuenta de entrada antes del audio", + "export.mix": "Exportar mezcla", + "export.mixDesc": "Exporta el audio mezclado", + "export.mixDescVideo": "Exporta la mezcla con el vídeo original", + "export.failed": "La exportación falló.", + "export.allMuted": "Todos los stems están silenciados: no hay nada que exportar.", + "export.noStems": "No hay stems que exportar.", + "export.mixing": "Exportando…", + "export.allStems": "Exportar todos los stems", + "export.allStemsDesc": "Todos los stems en un .zip", + "export.currentRegion": "Exportar la región actual", + "export.currentRegionDesc": "Exporta la región seleccionada", + + "transport.group": "Transporte", + "transport.stop": "Detener", + "transport.playPause": "Reproducir / Pausar", + + "position.group": "Posición", + "position.loopTitle": "Repetir en loop la posición seleccionada (L)", + "position.loopLabel": "Loop de la posición", + "position.loopExactTitle": "Inicio / fin exacto del loop (mm:ss.mmm o segundos)", + "position.loopStartAria": "Inicio del loop", + "position.loopEndAria": "Fin del loop", + + "speed.group": "Velocidad", + "speed.ariaLabel": "Velocidad", + + "click.group": "Click", + "click.alpha": "Alfa", + "click.toggleTitle": "Click (K)", + "click.toggleAria": "Click", + "click.unavailableAria": "Click no disponible", + "click.on": "SÍ", + "click.optionsAria": "Opciones del click", + "click.countIn": "Cuenta de entrada", + "click.countInTitle": "Cuenta un compás de click antes de que empiece la canción al pulsar reproducir", + "click.barAccentAria": "Acentuar cada N tiempos", + "click.auto": "Automático (detectado)", + "click.autoNone": "Automático (no se detectó)", + "click.off": "No", + "click.editTitle": "Editar el GRID", + "click.grid": "GRID", + "click.rateAria": "Frecuencia del click", + "click.rateTitle": "Reduce a la mitad o duplica la frecuencia del click", + "click.half": "Mitad de tempo", + "click.detected": "Tempo detectado", + "click.double": "Doble de tempo", + "click.volumeAria": "Volumen del click", + "click.volumeTitle": "Volumen del click: {pct}", + "click.reason.loadTrack": "Carga una pista para usar el click", + "click.reason.playbackUnavailable": "Reproducción no disponible para esta pista", + "click.reason.noBeatGrid": "Esta pista no tiene GRID", + "click.reason.unavailableOnPath": "El click no está disponible en esta ruta de reproducción", + "click.reason.needsWebAudio": "El click necesita el motor Web Audio", + + "about.title": "StemDeck", + "about.tagline": "Código abierto. Sin suscripciones. Hecho por músicos, para músicos.", + "about.closeAria": "Cerrar el diálogo Acerca de", + "about.website": "Sitio web", + "about.github": "GitHub", + "about.discord": "Discord", + "about.reddit": "Reddit", + "about.instagram": "Instagram", + "about.x": "X", + + "friends.title": "Recomendamos", + "friends.tagline": "Gente maravillosa haciendo un trabajo precioso. Ve a saludarlos ❤️", + "friends.closeAria": "Cerrar el diálogo de recomendaciones", + + "release.title": "Nueva versión disponible", + "release.closeAria": "Cerrar el diálogo de la versión", + "release.updateServer": "Actualiza tu servidor", + "release.pullImage": "Descarga la nueva imagen y reinicia el contenedor:", + "release.unraidNote": "En Unraid, actualiza desde la plantilla de Community Applications.", + "release.download": "Descargar", + "release.allReleases": "Todas las versiones", + "release.updateNow": "Actualizar ahora", + "release.downloading": "Descargando la actualización…", + "release.restartUpdate": "Reinicia para actualizar", + "release.applying": "Aplicando la actualización…", + "release.updateFailed": "La actualización falló", + + "failure.title": "Algo falló", + "failure.closeAria": "Cerrar el diálogo de error", + "failure.hint": "Estos datos se incluyen en el informe. El título de tu pista y el enlace de origen no se incluyen: añádelos tú si ayudan. Al pulsar un botón de abajo se copia esto al portapapeles.", + "failure.includeLogs": "Incluir los registros recientes", + "failure.collecting": "Recopilando datos…", + "failure.fetchingLogs": "Obteniendo los registros…", + "failure.logsIncluded": "Registros incluidos", + "failure.reportGithub": "Informar en GitHub", + "failure.reportDiscord": "Informar en Discord", + + // ── Settings modal (static/js/catalog.js) ── + "settings.title": "Ajustes", + "settings.closeAria": "Cerrar ajustes", + "settings.tab.general": "General", + "settings.tab.network": "Red", + "settings.tab.export": "Exportación", + "settings.tab.logs": "Registros", + "settings.tab.registry": "Tareas", + "settings.readOnlyServer": "Estos ajustes son de solo lectura en modo servidor. Para cambiarlos, actualiza la configuración de tu servidor (por ejemplo docker-compose.yml) y reinicia.", + + "settings.language.title": "Idioma", + "settings.language.desc": "Idioma de la interfaz de esta aplicación.", + + "settings.maxDuration.title": "Duración máxima de la pista", + "settings.maxDuration.desc": "Duración máxima aceptada para procesar, en minutos (máx. {max}).", + "settings.playlistLimit.title": "Límite de importación de listas", + "settings.playlistLimit.desc": "Número máximo de pistas que se pondrán en cola al importar una lista (máx. 200).", + "settings.cookies.title": "Cookies de YouTube", + "settings.cookies.desc": "Opcional. Ruta a un archivo cookies.txt, que se usa solo cuando YouTube le pide a StemDeck que confirme que no es un bot. Déjalo vacío salvo que las importaciones estén fallando.", + "settings.cookies.placeholder": "Ruta al archivo cookies.txt", + "settings.cookies.invalid": "No se encontró el archivo, o no se puede leer.", + "settings.stemsLocation.title": "Ubicación de StemData", + "settings.autoDelete.title": "Eliminar automáticamente las pistas terminadas", + "settings.autoDelete.desc": "Desactivado salvo que lo actives. Las pistas separadas se conservan de forma indefinida. Eliminarlas no se puede deshacer.", + "settings.autoDelete.daysTitle": "Eliminar después de", + "settings.autoDelete.daysDesc": "Días que se conserva una pista terminada antes de eliminarla (máx. {max}).", + "settings.stemsLocation.change": "Cambiar…", + "settings.stemsLocation.resetting": "Restableciendo…", + "settings.stemsLocation.resetFailed": "No se pudo restablecer — revisa tu conexión.", + "settings.stemsLocation.syncing": "Sincronizando…", + "settings.stemsLocation.syncFailed": "La sincronización falló — revisa tu conexión.", + "settings.stemsLocation.inSync": "Todas las pistas están sincronizadas.", + "settings.stemsLocation.unavailable": "no disponible", + "settings.stemsLocation.persistFailed": "La ubicación se actualizó pero no se guardó: volverá a la carpeta anterior tras reiniciar.", + "settings.stemsLocation.restartNote": "Reinicia StemDeck para terminar el cambio.", + "settings.stemsLocation.pickerFailed": "No se pudo abrir el selector de carpetas.", + "settings.stemsLocation.moving": "Moviendo los stems… puede tardar un rato si la biblioteca es grande.", + "settings.stemsLocation.moveFailed": "No se pudo mover la carpeta de stems.", + "settings.stemsLocation.serverUnreachable": "No se pudo contactar con el servidor.", + "settings.stemsLocation.movedPersistFailed.one": "Se movió {count} elemento, pero StemDeck no pudo guardar esta ubicación como la nueva (comprueba que se pueda escribir en la carpeta). Si reinicias ahora volverá a la anterior. Prueba a configurarla de nuevo.", + "settings.stemsLocation.movedPersistFailed.other": "Se movieron {count} elementos, pero StemDeck no pudo guardar esta ubicación como la nueva (comprueba que se pueda escribir en la carpeta). Si reinicias ahora volverá a la anterior. Prueba a configurarla de nuevo.", + "settings.stemsLocation.movedOk.one": "Se movió {count} elemento. Reinicia StemDeck para terminar el cambio.", + "settings.stemsLocation.movedOk.other": "Se movieron {count} elementos. Reinicia StemDeck para terminar el cambio.", + + "settings.device.title": "Dispositivo de cálculo", + "settings.device.desc": "Dispositivo usado para separar los stems. Se aplica a la siguiente pista{resolved}.", + "settings.device.auto": "Automático", + "settings.device.cuda": "CUDA (NVIDIA)", + "settings.device.mps": "MPS (Apple Silicon)", + "settings.device.cpu": "CPU", + "settings.device.currently": " (actualmente: {device})", + "settings.device.notAvailable": " — no disponible", + "settings.quality.title": "Calidad de la separación", + "settings.quality.desc": "«Máxima» ejecuta el separador dos veces con desplazamientos aleatorios y promedia el resultado: stems más limpios, el doble de tiempo.", + "settings.quality.standard": "Estándar", + "settings.quality.best": "Máxima (2× más lenta)", + + "settings.outOfSync.subhead": "Pistas sin sincronizar", + "settings.outOfSync.colName": "Nombre", + "settings.outOfSync.colSource": "Origen", + "settings.outOfSync.colLocation": "Ubicación", + "settings.outOfSync.resync": "Volver a sincronizar las pistas sin sincronizar", + "settings.outOfSync.allSynced": "Todas las pistas están sincronizadas", + "settings.outOfSync.summary.one": "{count} pista sin sincronizar", + "settings.outOfSync.summary.other": "{count} pistas sin sincronizar", + "library.importedFile": "Archivo importado", + "library.syncing": "Sincronizando…", + "library.syncFailed": "La sincronización falló — revisa tu conexión.", + "library.recent": "Recientes", + "library.stemCollections": "Colecciones de stems", + "library.tags": "Etiquetas", + "library.newFolder": "Nueva carpeta", + "queue.importQueue": "Cola de importación", + "queue.paused.one": "En pausa: {count} pista de tu última sesión.", + "queue.paused.other": "En pausa: {count} pistas de tu última sesión.", + "queue.start": "Iniciar", + "queue.starting": "Iniciando…", + "release.viewDownload": "Ver la descarga", + + "track.localFile": "Archivo local", + "track.web": "Web", + "track.losslessWav": "Sin pérdida (WAV)", + "track.compressedMp3": "Comprimido (MP3)", + "track.qualityHigh": "Alta", + "track.untitled": "Pista sin título", + "track.unknown": "Pista desconocida", + "track.removed": "Eliminada", + "track.moveToTrash": "Mover a la papelera", + "track.moveTitleToTrash": "Mover {title} a la papelera", + "folder.dragToReorder": "Arrastra para reordenar", + "folder.newSubfolder": "Nueva subcarpeta", + "folder.deleteFolder": "Eliminar carpeta", + "folder.unsorted": "Sin ordenar", + "favorites.noSearchMatch": "Ningún favorito coincide con tu búsqueda", + "favorites.empty": "Aún no hay favoritos: pulsa ♥ en una pista para guardarla", + "queue.extractNext": "Extraer esta a continuación", + "queue.moveToFront": "Mover {title} al principio de la cola", + "queue.cancelImport": "Cancelar esta importación", + "queue.cancelImportOf": "Cancelar la importación de {title}", + "queue.pausedStatus": "En pausa", + "queue.positionInLine": "En cola: puesto n.º {position}", + + "settings.exportLogs.title": "Exportar registros", + "settings.exportLogs.desc": "Descarga todos los archivos de registro en un único zip: lo que conviene adjuntar a un informe de error. Mira la pestaña Registros para saber dónde están.", + "settings.exportLogs.button": "Exportar registros", + "settings.exportLogs.preparing": "Preparando…", + "settings.exportLogs.error": "No se pudieron exportar los registros.", + "status.unavailable": "no disponible", + + "settings.resetData.title": "Restablecer los datos de la aplicación", + "settings.resetData.desc": "Elimina de forma permanente todas las pistas, tareas y entradas de la biblioteca. En un servidor compartido esto afecta a todos los que lo usan. No se puede deshacer.", + "settings.resetData.button": "Restablecer los datos…", + + "settings.network.allowTitle": "Hacer que StemDeck esté disponible en tu red", + "settings.network.allowDesc": "Permite que otros dispositivos (como tu teléfono) abran StemDeck en la dirección de abajo.", + "settings.network.lockNote": "De solo lectura cuando StemDeck se inicia en modo servidor: en ese caso el acceso a la red lo define la configuración de tu servidor.", + "settings.network.port.title": "Puerto", + "settings.network.port.desc": "Puerto en el que funciona StemDeck. Reinicia para aplicarlo.", + "settings.network.noConnection": "No se detectó ninguna conexión de red local.", + "settings.network.qrHint": "Desenfocado para que tu cámara no se emocione de más. Toca para mostrarlo.", + "settings.network.tapToUnblur": "Toca para desenfocar", + "settings.network.qrCodeFor": "Código QR de {url}", + + "settings.export.sampleRate.title": "Frecuencia de muestreo", + "settings.export.sampleRate.desc": "Frecuencia de muestreo para las mezclas y regiones exportadas (WAV, FLAC, MP3). 44,1 kHz sirve para la mayoría de DAW y samplers; elige otra si tu equipo la necesita.", + "settings.export.videoQuality.title": "Calidad del vídeo MP4", + "settings.export.videoQuality.desc": "Resolución máxima para la exportación a MP4 y el vídeo de YouTube.", + + "settings.logs.locationTab": "Ubicación", + "settings.logs.applicationTab": "Aplicación", + "settings.logs.backendTab": "Backend", + "settings.logs.setupTab": "Instalación", + "settings.logs.refresh": "Actualizar", + "settings.logs.loading": "Cargando…", + "settings.logs.location.title": "Ubicación de los registros", + "settings.logs.location.desc": "Dónde escribe StemDeck sus registros en este equipo. Solo lectura: ábrelos en un explorador de archivos o usa Exportar registros.", + "settings.logs.application.title": "Registro de la aplicación", + "settings.logs.application.desc": "La última hora de stemdeck.log: actividad del pipeline, la API y las tareas. Solo lectura.", + "settings.logs.applicationAria": "Registro de la aplicación (solo lectura)", + "settings.logs.backend.title": "Registro del backend", + "settings.logs.backend.desc": "La última hora de backend.log: salida en bruto del proceso de Python incluido, con todo lo que lo haya hecho fallar antes de que el registro de la aplicación pudiera anotarlo. Solo en la aplicación de escritorio. Solo lectura.", + "settings.logs.backendAria": "Registro del backend (solo lectura)", + "settings.logs.setup.title": "Registro de instalación", + "settings.logs.setup.desc": "La última hora de setup.log: la instalación inicial y la del entorno de GPU. Solo en la aplicación de escritorio. Solo lectura.", + "settings.logs.setupAria": "Registro de instalación (solo lectura)", + + "settings.registry.title": "Registro de tareas", + "settings.registry.desc": "Vista de solo lectura de registry.json: la lista de tareas completadas guardada en disco.", + "settings.registry.aria": "Registro de tareas (solo lectura)", + "settings.logs.noFilesYet": "Todavía no hay archivos de registro{folderNote}. El registro empieza con el primer mensaje tras el arranque.", + "settings.logs.folderNotCreated": " (la carpeta no se ha creado)", + "settings.logs.failedToLoad": "No se pudo cargar la información de los registros.", + + "settings.done": "Listo", + + "settings.folder.rename": "Cambiar el nombre de la carpeta", + "settings.folder.namePlaceholder": "Nombre de la carpeta", + "settings.folder.emptyError": "Escribe un nombre de carpeta.", + "settings.folder.charsError": "Usa letras, números, espacios, o - _ ' & ( ) . ,", + "settings.folder.cancel": "Cancelar", + "settings.folder.save": "Guardar", + + "settings.dangerZone": "Zona de riesgo", + + // ── Folder editor dialog (static/js/catalog.js openFolderEditor) ── + "folderEditor.title": "Editar carpeta", + "folderEditor.closeAria": "Cerrar", + "folderEditor.nameLabel": "Nombre", + "folderEditor.colorLabel": "Color", + "folderEditor.colorGroupAria": "Color de la carpeta", + "folderEditor.cancel": "Cancelar", + "folderEditor.save": "Guardar", + "folderEditor.emptyError": "Escribe un nombre de carpeta.", + "folderEditor.tooLong": "El nombre de la carpeta es demasiado largo (máx. {max}).", + "folderEditor.charsError": "Usa letras, números, espacios, o - _ ' & ( ) . ,", + + // ── Notifications (static/js/notifications.js) ── + "notifKind.importFailed": "La importación falló", + "notifKind.playbackFailed": "La reproducción falló", + "notifKind.exportFailed": "La exportación falló", + "notifKind.updateCheckFailed": "La comprobación de actualizaciones falló", + "notifKind.default": "Algo falló", + "notif.somethingWentWrong": "Algo salió mal.", + "time.justNow": "ahora mismo", + "time.minAgo.one": "hace {count} min", + "time.minAgo.other": "hace {count} min", + "time.hoursAgo.one": "hace {count} h", + "time.hoursAgo.other": "hace {count} h", + + // ── Job progress / analysis (static/js/job.js) ── + "job.processing": "Procesando", + "job.queuedTrack": "Pista en cola", + "job.process": "Procesar", + "job.tryAgain": "Reintentar", + "job.dismiss": "Descartar", + "job.peakDb": "Pico {value} dB", + "job.dr.compressed": "Comprimido", + "job.dr.moderate": "Moderado", + "job.dr.high": "Alto", + "job.dr.wide": "Amplio", + "job.stability.veryStable": "Muy estable", + "job.stability.stable": "Estable", + "job.stability.moderate": "Moderada", + "job.stability.variable": "Variable", + "job.bpmValue": "{bpm} BPM", + "job.cancelling": "Cancelando…", + "job.processingTrackTitle": "Procesando la pista", + "job.unknownError": "Error desconocido", + "job.noLongerExists": "La tarea ya no existe en el servidor", + "job.audioProcessingFailed": "El procesamiento del audio falló.", + "job.working": "Trabajando…", + "settings.device.changeFailed": "No se pudo cambiar el dispositivo de cálculo.", + "update.checkFailed": "No se pudo comprobar si hay actualizaciones.", + "track.unavailableReimport": "Pista no disponible: haz clic para volver a importarla", + "track.unavailableReupload": "Pista no disponible: vuelve a subirla para restaurarla", + "track.audioUnavailableError": "El audio de esta pista ya no está disponible. Vuelve a subirlo para restaurarlo.", + "job.restoreFailed": "No se pudo restaurar la pista: {message}", + "job.startFailed": "No se pudo iniciar la tarea: {message}", + "upload.unsupportedFormat": "Solo se admiten archivos MP3, WAV, FLAC, MP4, M4A, OGG y Opus.", + "upload.fileTooLarge": "El archivo es demasiado grande ({size}). El máximo es {max}.", + "upload.skippedFiles": "Se omitieron {count} archivos ({reason}).", + "upload.skippedFiles.one": "Se omitió {count} archivo ({reason}).", + "upload.skippedFiles.other": "Se omitieron {count} archivos ({reason}).", + "upload.reasonTooLarge": "demasiado grande o no es audio", + "upload.reasonNotAudio": "no es audio", + + "player.readyToImport": "Listo para importar una pista", + "player.stillLoadingWaveform": "Todavía cargando la forma de onda…", + "player.audioCouldNotLoad": "No se pudo cargar el audio de esta pista.", + "player.playbackDisabledFull": "La reproducción está desactivada para esta pista. Las demás pistas no se ven afectadas.", + "player.playbackDisabled": "La reproducción está desactivada para esta pista.", + + // ── Metronome / click track note (static/js/transport.js) ── + // Built as ONE full-sentence template per variant (not concatenated + // clauses), so translations can reorder freely. + "metro.note.full": "Click a {bpm} BPM — el {conf}% de los tiempos cae sobre un golpe de batería, acentuando cada {accent} tiempos. Usa /2 o x2 si el click va a mitad o al doble de velocidad.", + "metro.note.noAccent": "Click a {bpm} BPM — el {conf}% de los tiempos cae sobre un golpe de batería. Usa /2 o x2 si el click va a mitad o al doble de velocidad.", + "metro.note.detectedSingleBar": "Click a {bpm} BPM — el {conf}% de los tiempos cae sobre un golpe de batería, acentuando {beats}/4 desde el tiempo fuerte detectado. Usa /2 o x2 si el click va a mitad o al doble de velocidad.", + "metro.note.detectedMultiBar": "Click a {bpm} BPM — el {conf}% de los tiempos cae sobre un golpe de batería, acentuando {count} regiones de compás detectadas. Usa /2 o x2 si el click va a mitad o al doble de velocidad.", + "metro.note.fallback": "Click a {bpm} BPM desde el detector de reserva. Usa /2 o x2 si el click va a mitad o al doble de velocidad.", + + // ── Sections save indicator (static/js/sections.js) ── + "sections.saving": "Guardando", + "sections.saved": "Guardado", + "sections.deleteAria": "Eliminar sección", + "sections.defaultName": "Sección", + + // ── Playlist import confirm (static/js/playlist.js) ── + "playlist.skip.unavailable.other": "{count} no disponibles", + "playlist.skip.tooLong.other": "{count} demasiado largas", + "playlist.skip.overflow.other": "{count} que ahora mismo no caben en la cola", + "playlist.skip.truncated": "todo lo que pase de las primeras {cap}", + "playlist.skippingPrefix": "Se omiten {list}.", + "playlist.listSep": ", ", + "playlist.importAriaLabel": "Importar lista", + "playlist.couldNotRead": "No se pudo leer esa lista: {message}", + "playlist.couldNotImport": "No se pudo importar esa lista: {message}", + "playlist.queueFull": "La cola está llena. Espera a que se vacíe, o cancela algo primero.", + "playlist.nothingImportable": "Ahora mismo no se puede importar nada de esa lista.", + "playlist.confirmTitle": "¿Importar esta lista?", + "playlist.confirmBody.one": "Pone en cola {count} pista, de una en una, en una carpeta con el mismo nombre.{skipped}", + "playlist.confirmBody.other": "Pone en cola {count} pistas, de una en una, en una carpeta con el mismo nombre.{skipped}", + "playlist.skippedNote": " (se omitirán {count} no disponibles).", + "playlist.cancel": "Cancelar", + "playlist.import.one": "Importar {count} pista", + "playlist.import.other": "Importar {count} pistas", + + // ── Reset-confirm dialog (static/js/catalog.js openResetConfirm). The + // literal word RESET the user must type is deliberately left untranslated + // in every language -- it's a typed safety keyword, not prose, the same way + // GitHub's repo-delete confirmation uses the repo name rather than a + // translated word. ── + "resetConfirm.title": "¿Restablecer los datos de la aplicación?", + "resetConfirm.ariaLabel": "Restablecer los datos de la aplicación", + "resetConfirm.body": "Esto elimina de forma permanente todas las pistas, tareas y entradas de la biblioteca. En un servidor compartido afecta a todos los que lo usan. No se puede deshacer.", + "resetConfirm.hint": "Escribe RESET para confirmar.", + "resetConfirm.typeToConfirmAria": "Escribe RESET para confirmar", + "resetConfirm.cancel": "Cancelar", + "resetConfirm.go": "Restablecer los datos", + "resetConfirm.resetting": "Restableciendo…", + "resetConfirm.failed": "No se pudo restablecer.", + "resetConfirm.failedConnection": "No se pudo restablecer — revisa tu conexión.", +}; + +export const TRANSLATIONS = { en, pl, ja, "zh-Hans": zhHans, de, fr, es, pt, "pt-PT": ptPT, id }; diff --git a/tests/js/i18n-detect.test.mjs b/tests/js/i18n-detect.test.mjs new file mode 100644 index 00000000..a9154fa1 --- /dev/null +++ b/tests/js/i18n-detect.test.mjs @@ -0,0 +1,80 @@ +// Every shipped language must be reachable from the browser's own locale. +// +// Adding a table to LANGUAGES and forgetting the matching branch in +// _detectDefault fails silently in the worst way: the language appears in the +// picker, every string is translated, and a native speaker still opens the app +// in English. Nothing throws and no existing test notices. +// +// That is not hypothetical. French shipped without a branch and stayed that way +// until Spanish was added next to it and the gap was spotted by reading the +// function. This pins it so the next language cannot repeat it. +// +// Run: node tests/js/i18n-detect.test.mjs + +import { LANGUAGES, _detectDefault } from "../../static/js/i18n.js"; + +let pass = 0, + fail = 0; +const check = (name, cond, detail = "") => { + if (cond) { + pass++; + console.log(`PASS ${name}`); + } else { + fail++; + console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`); + } +}; + +/** Pretend the browser reports `tag`, then ask what StemDeck would pick. */ +function detectFor(tag) { + Object.defineProperty(globalThis, "navigator", { + value: { languages: [tag], language: tag }, + configurable: true, + writable: true, + }); + return _detectDefault(); +} + +// The realistic locale a speaker of each shipped language actually sends. +const LOCALES = { + en: ["en", "en-US", "en-GB"], + pl: ["pl", "pl-PL"], + ja: ["ja", "ja-JP"], + "zh-Hans": ["zh", "zh-CN", "zh-Hans", "zh-SG"], + de: ["de", "de-DE", "de-AT"], + fr: ["fr", "fr-FR", "fr-CA"], + es: ["es", "es-ES", "es-MX", "es-419"], + pt: ["pt", "pt-BR"], + "pt-PT": ["pt-PT"], + id: ["id", "id-ID"], +}; + +// 1. Nothing ships without a way to be found. +for (const { code } of LANGUAGES) { + check( + `${code} has locales covering it`, + Array.isArray(LOCALES[code]) && LOCALES[code].length > 0, + "add this language's locales to LOCALES above", + ); +} + +// 2. Every one of those locales resolves to its own table. +for (const [code, tags] of Object.entries(LOCALES)) { + if (!LANGUAGES.some((l) => l.code === code)) continue; // language was removed + for (const tag of tags) { + const got = detectFor(tag); + check(`${tag} -> ${code}`, got === code, `got ${got}`); + } +} + +// 3. Case is not something a browser guarantees. +check("PT-pt is still European Portuguese", detectFor("PT-pt") === "pt-PT"); +check("ES-MX is still Spanish", detectFor("ES-MX") === "es"); + +// 4. Anything unrecognised falls back to English rather than throwing. +for (const tag of ["", "xx", "kl-GL", "zz-ZZ"]) { + check(`${tag || "(empty)"} falls back to en`, detectFor(tag) === "en"); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);