diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 6651564..df7d977 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -30,6 +30,16 @@ jobs: shell sparse-checkout-cone-mode: true + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Run checks env: OMARCHY_PATH: ${{ github.workspace }}/.ci/omarchy diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb4d09..535bf94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to **Taskwarrior Time** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Spanish UI with system-locale detection and manual selection in About + ## [1.0.2] - 2026-09-29 ### Fixed @@ -65,6 +71,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии следуют [Semantic Versioning](https://semver.org/lang/ru/). +## [Не выпущено] + +### Добавлено + +- Испанский интерфейс с автоопределением языка системы и ручным выбором во вкладке «О плагине» + ## [1.0.2] - 2026-09-29 ### Исправлено diff --git a/Panel.qml b/Panel.qml index 748283d..6ff6217 100644 --- a/Panel.qml +++ b/Panel.qml @@ -65,7 +65,7 @@ Panel { property bool debugLogging: false property string debugLogPath: "" property string debugSessionId: "" - property string uiLanguage: "system" // system | ru | en + property string uiLanguage: "system" // system | ru | en | es property var _logQueue: [] property bool _logFlushScheduled: false readonly property string pluginVersion: "1.0.2" @@ -1110,7 +1110,7 @@ Panel { function setUiLanguage(lang) { var next = String(lang || "system") - if (next !== "system" && next !== "ru" && next !== "en") + if (next !== "system" && next !== "ru" && next !== "en" && next !== "es") next = "system" root.uiLanguage = next settingsSetProc.command = [ @@ -1555,7 +1555,7 @@ Panel { root.debugLogging = !!data.debugLogging root.debugLogPath = String(data.logPath || "") root.debugSessionId = String(data.sessionId || "") - if (data.uiLanguage === "ru" || data.uiLanguage === "en" || data.uiLanguage === "system") + if (data.uiLanguage === "ru" || data.uiLanguage === "en" || data.uiLanguage === "es" || data.uiLanguage === "system") root.uiLanguage = String(data.uiLanguage) if (root.debugLogging) root.dlog("ui.settings.loaded", { @@ -1582,7 +1582,7 @@ Panel { root.debugLogging = !!data.debugLogging root.debugLogPath = String(data.logPath || "") root.debugSessionId = String(data.sessionId || "") - if (data.uiLanguage === "ru" || data.uiLanguage === "en" || data.uiLanguage === "system") + if (data.uiLanguage === "ru" || data.uiLanguage === "en" || data.uiLanguage === "es" || data.uiLanguage === "system") root.uiLanguage = String(data.uiLanguage) // Banner already written by helper on enable; acknowledge from UI. if (root.debugLogging) @@ -4245,6 +4245,16 @@ Panel { horizontalPadding: Style.space(8) onClicked: root.setUiLanguage("en") } + Button { + text: root.tr("uiLanguageEs") + selected: root.uiLanguage === "es" + foreground: root.foreground + fontFamily: root.fontFamily + fontSize: Style.font.caption + verticalPadding: Style.space(2) + horizontalPadding: Style.space(8) + onClicked: root.setUiLanguage("es") + } } } diff --git a/README.md b/README.md index 5c23e6b..2dd1d74 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Anything you do here is stored in your normal Taskwarrior / Timewarrior data. Yo - **Projects** view: browse projects, rename, clear project from tasks - **Unsaved changes** dialog when closing a dirty editor (Save / Keep editing / Discard) - Uniform **hotkeys**: `Ctrl+Enter` commit, `Esc` cancel, `Ctrl+Delete` destroy -- **i18n**: English and Russian UI (system language, or pick one in About) +- **i18n**: English, Russian, and Spanish UI (system language, or pick one in About) - **About** tab with version, developer info, What’s new / Marketplace / Report a problem / GitHub, language switch, and debug logging ### Task list @@ -180,11 +180,11 @@ Please do **not** paste secrets, tokens, or private task content. ## Development checks -From a clone of this repository: +From a clone of this repository (Node.js 22+ and Python 3 required for behavior tests): ```bash ./scripts/enable-git-hooks # once per clone — enables pre-push checks -./scripts/check # manifest validate + qmllint +./scripts/check # manifest validate + behavior tests + qmllint ``` `git push` runs the same checks via `.githooks/pre-push` and aborts if they fail. GitHub Actions runs `./scripts/check` on push and pull requests. diff --git a/README.ru.md b/README.ru.md index 383f9c8..5b453bc 100644 --- a/README.ru.md +++ b/README.ru.md @@ -29,7 +29,7 @@ - Вкладка **Проекты**: просмотр, переименование, очистка проекта у задач - Диалог **несохранённых изменений** при закрытии черновика (Сохранить / Продолжить / Отменить) - Единые **горячие клавиши**: `Ctrl+Enter` — сохранить/добавить, `Esc` — отмена, `Ctrl+Delete` — удалить/сбросить -- **Локализация**: английский и русский (системный язык или выбор на вкладке «О плагине») +- **Локализация**: английский, русский и испанский (системный язык или выбор на вкладке «О плагине») - Вкладка **О плагине**: версия, разработчик, Что нового / Маркетплейс / Сообщить о проблеме / GitHub, язык интерфейса и отладочный лог ### Список задач @@ -180,11 +180,11 @@ omarchy plugin remove taskwarrior-time ## Проверки при разработке -В клоне этого репозитория: +В клоне этого репозитория (для поведенческих тестов нужны Node.js 22+ и Python 3): ```bash ./scripts/enable-git-hooks # один раз на клон — включает pre-push -./scripts/check # validate манифеста + qmllint +./scripts/check # validate манифеста + поведенческие тесты + qmllint ``` `git push` прогоняет те же проверки через `.githooks/pre-push` и отменяет push при ошибке. GitHub Actions запускает `./scripts/check` на push и pull request. diff --git a/bin/taskwarrior-time b/bin/taskwarrior-time index 664ef0c..e3ce6f2 100755 --- a/bin/taskwarrior-time +++ b/bin/taskwarrior-time @@ -618,7 +618,7 @@ def _write_private_text(path: Path, text: str) -> None: def _normalize_ui_language(value: Any) -> str: lang = str(value or "system").strip().lower() - if lang in ("system", "ru", "en"): + if lang in ("system", "ru", "en", "es"): return lang return "system" @@ -1758,8 +1758,8 @@ def main() -> int: p_ss.add_argument( "--ui-language", default=None, - choices=["system", "ru", "en"], - help="UI language: system, ru, or en", + choices=["system", "ru", "en", "es"], + help="UI language: system, ru, en, or es", ) p_dl = sub.add_parser("debug-log") diff --git a/i18n.js b/i18n.js index 5d2cf2d..f009a00 100644 --- a/i18n.js +++ b/i18n.js @@ -1,35 +1,33 @@ // Locale-aware strings for Taskwarrior Time (plugin id: taskwarrior-time). -function isRussian(localeName) { +function languageFromLocale(localeName) { var candidates = [] if (localeName !== undefined && localeName !== null && String(localeName) !== "") candidates.push(String(localeName)) + var loc = null try { - var loc = Qt.locale() - if (loc) { - if (loc.name) candidates.push(String(loc.name)) - // Prefer the first UI language (e.g. "ru-RU", "ru") when set. - var ui = loc.uiLanguages - if (ui && ui.length > 0) candidates.push(String(ui[0])) + loc = Qt.locale() + if (loc && loc.uiLanguages) { + for (var i = 0; i < loc.uiLanguages.length; i++) + candidates.push(String(loc.uiLanguages[i])) } } catch (e) {} + if (loc && loc.name) candidates.push(String(loc.name)) - for (var i = 0; i < candidates.length; i++) { - var raw = String(candidates[i] || "").toLowerCase().replace(/-/g, "_") + for (var j = 0; j < candidates.length; j++) { + var raw = String(candidates[j] || "").toLowerCase().replace(/-/g, "_") if (!raw) continue - // "ru", "ru_RU", "ru_RU.UTF-8", "ru.utf8" + // "es", "es_ES", "es_ES.UTF-8", "es.utf8" (and other supported languages). var lang = raw.split(".")[0].split("_")[0] - if (lang === "ru") return true + if (lang === "ru" || lang === "es" || lang === "en") return lang } - return false + return "en" } function t(key, localeName, uiLanguage) { var mode = String(uiLanguage || "system").toLowerCase() - var ru - if (mode === "ru") ru = true - else if (mode === "en") ru = false - else ru = isRussian(localeName) + var language = mode === "ru" || mode === "en" || mode === "es" + ? mode : languageFromLocale(localeName) var en = { title: "Taskwarrior Time", titleInProgress: "Taskwarrior Time", @@ -170,6 +168,7 @@ function t(key, localeName, uiLanguage) { uiLanguageSystem: "System", uiLanguageRu: "Русский", uiLanguageEn: "English", + uiLanguageEs: "Spanish", } var ruMap = { title: "Taskwarrior Time", @@ -311,7 +310,150 @@ function t(key, localeName, uiLanguage) { uiLanguageSystem: "Системный", uiLanguageRu: "Русский", uiLanguageEn: "English", + uiLanguageEs: "Испанский", } - var map = ru ? ruMap : en + var esMap = { + title: "Taskwarrior Time", + titleInProgress: "Taskwarrior Time", + projects: "Proyectos", + filterAll: "Todas", + filterActive: "Activas", + filterToday: "Hoy", + filterDone: "Completadas", + filterSpoiler: "Filtros", + filterClear: "Restablecer", + filterAny: "Cualquiera", + filterStatus: "Estado", + filterStatusOpen: "Abiertas", + filterStatusAll: "Cualquier estado", + filterProject: "Proyecto", + filterDue: "Vencimiento", + filterDueSoon: "Próximas", + filterTimer: "Temporizador", + filterTimerOn: "En marcha", + filterTimerOff: "Parado", + filterDeps: "Dependencias", + filterDepsClear: "Sin dependencias", + filterSearch: "Buscar", + filterSearchHint: "Descripción…", + groupBy: "Agrupar por", + groupNone: "Ninguno", + groupProject: "Proyecto", + groupPriority: "Prioridad", + groupDue: "Vencimiento", + groupStatus: "Estado", + addPlaceholder: "Nueva tarea…", + addSection: "Nueva tarea", + addDetails: "Campos de tarea", + addDetailsHide: "Ocultar campos", + advancedShow: "Programación, enlaces y tiempo", + advancedHide: "Ocultar programación, enlaces y tiempo", + startTimerOnCreate: "Iniciar temporizador al crear", + addTaskBtn: "Añadir tarea", + startDate: "Inicio", + endDate: "Fecha límite", + details: "Detalles", + detailsHint: "Notas adicionales…", + titleField: "Título", + editSectionPlan: "Planificación", + editSectionSchedule: "Fechas", + editSectionLinks: "Dependencias", + editSectionTime: "Tiempo", + priority: "Prioridad", + priorityNone: "—", + project: "Proyecto", + projectNone: "Sin proyecto", + allProjects: "Todos los proyectos", + add: "Añadir", + unsavedChanges: "Esta tarea tiene cambios sin guardar.", + unsavedSave: "Guardar", + unsavedContinue: "Seguir editando", + unsavedDiscard: "Descartar", + save: "Guardar", + cancel: "Cancelar", + deleteTask: "Eliminar", + confirmDelete: "¿Eliminar esta tarea?", + confirmClearProject: "¿Quitar este proyecto de todas las tareas?", + confirm: "Confirmar", + play: "Iniciar temporizador", + stop: "Detener temporizador", + todayTime: "Hoy", + adjustTime: "Ajustar tiempo", + adjustTimeHint: "30 min, 1 h, 1:30", + timeAdd: "+ Añadir", + timeRemove: "− Quitar", + done: "Hecha", + pending: "Pendiente", + waiting: "En espera", + waitingFor: "Esperando a", + waitingForHint: "¿Qué estás esperando?…", + outcome: "Resultado", + outcomeHint: "¿Cuál fue el resultado?…", + status: "Estado", + depends: "Depende de", + blocks: "Bloquea", + blocked: "Bloqueada", + blocking: "Bloquea", + overdue: "Vencida", + noTasks: "No hay tareas", + noProjects: "Aún no hay proyectos", + newProject: "Nuevo proyecto…", + rename: "Renombrar", + clearProject: "Quitar proyecto", + expand: "Detalles", + missingTaskwarrior: "Instala Taskwarrior (task)", + sectionNone: "Bandeja de entrada", + dueOverdue: "Vencidas", + dueToday: "Hoy", + dueTomorrow: "Mañana", + dueWeek: "Esta semana", + dueLater: "Más adelante", + dueNone: "Sin fecha límite", + priH: "alta", + priM: "media", + priL: "baja", + priNone: "Sin prioridad", + statusPending: "Pendiente", + statusCompleted: "Completada", + statusWaiting: "En espera", + tooltipTimer: "Temporizador", + tooltipOverdue: "%1 tareas vencidas", + tooltipPending: "%1 pendientes", + tooltipWaiting: "%1 en espera", + tooltipIdle: "No hay tareas abiertas", + tooltipDebugOn: "Registro de depuración activado", + viewTasks: "Tareas", + viewProjects: "Proyectos", + addDep: "Añadir dependencia", + noDepCandidates: "No hay otras tareas para vincular", + removeDep: "Quitar", + datesHint: "today, tomorrow, DD.MM.AAAA[ HH:MM:SS]", + dateApply: "Aplicar", + dateTime: "Hora", + dateClear: "Borrar", + about: "Acerca de", + aboutTitle: "Taskwarrior Time", + aboutVersion: "Versión", + aboutDeveloper: "Desarrollador", + aboutDeveloperName: "DataArchitectPro", + aboutGithub: "Abrir en GitHub", + aboutMarketplace: "Marketplace de Omarchy", + aboutChangelog: "Novedades", + aboutNewIssue: "Informar de un problema", + aboutLinks: "Ayuda y enlaces", + aboutClose: "Cerrar", + debugLog: "Registro de depuración", + debugLogOn: "Registro: ACTIVADO", + debugLogOff: "Registro: DESACTIVADO", + debugLogHint: "Escribe un registro de diagnóstico con versiones (plugin, Taskwarrior, Timewarrior y Omarchy) y eventos de la interfaz. Adjunta el archivo completo al informar de un problema.", + aboutOpenLogFolder: "Abrir carpeta de registros", + aboutClearLogs: "Borrar registros", + uiLanguage: "Idioma de la interfaz", + uiLanguageSystem: "Sistema", + uiLanguageRu: "Ruso", + uiLanguageEn: "Inglés", + uiLanguageEs: "Español", + } + var map = language === "es" ? esMap : (language === "ru" ? ruMap : en) return map[key] !== undefined ? map[key] : (en[key] !== undefined ? en[key] : key) } diff --git a/scripts/check b/scripts/check index a419b48..c3e3456 100755 --- a/scripts/check +++ b/scripts/check @@ -91,6 +91,23 @@ else fi fi +# --- behavior tests --------------------------------------------------------- +section "behavior tests" + +if command -v node >/dev/null 2>&1; then + node --test tests/i18n.test.js || FAILED=1 +else + echo "behavior tests: node is required for i18n.test.js" >&2 + FAILED=1 +fi + +if command -v python3 >/dev/null 2>&1; then + python3 -m unittest discover -s tests -p 'test_*.py' || FAILED=1 +else + echo "behavior tests: python3 is required for helper settings tests" >&2 + FAILED=1 +fi + # --- summary ---------------------------------------------------------------- section "summary" if ((FAILED)); then diff --git a/tests/i18n.test.js b/tests/i18n.test.js new file mode 100644 index 0000000..7c4bf17 --- /dev/null +++ b/tests/i18n.test.js @@ -0,0 +1,43 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +function createTranslator(systemLocale) { + const context = { Qt: { locale: () => systemLocale } }; + vm.createContext(context); + vm.runInContext( + fs.readFileSync(path.join(__dirname, "..", "i18n.js"), "utf8"), + context, + ); + return (key, locale, language) => context.t(key, locale, language); +} + +const translate = createTranslator({ name: "en_US", uiLanguages: ["en_US"] }); + +test("Spanish override translates UI strings regardless of system locale", () => { + assert.equal(translate("addSection", "en_US", "es"), "Nueva tarea"); + assert.equal(translate("cancel", "en_US", "es"), "Cancelar"); + assert.equal(translate("uiLanguageEs", "en_US", "es"), "Español"); +}); + +test("system Spanish locale selects Spanish translations", () => { + assert.equal(translate("addSection", "es-ES", "system"), "Nueva tarea"); + assert.equal(translate("uiLanguage", "es_ES.UTF-8", "system"), "Idioma de la interfaz"); +}); + +test("Qt Spanish UI language is used when no locale is passed", () => { + const systemTranslate = createTranslator({ name: "en_US", uiLanguages: ["es-ES"] }); + assert.equal(systemTranslate("addSection", "", "system"), "Nueva tarea"); +}); + +test("preferred English UI language beats Spanish formatting locale", () => { + const systemTranslate = createTranslator({ name: "es_ES", uiLanguages: ["en-US"] }); + assert.equal(systemTranslate("addSection", "en-US", "system"), "New task"); +}); + +test("English and Russian overrides remain available", () => { + assert.equal(translate("addSection", "es-ES", "en"), "New task"); + assert.equal(translate("addSection", "es-ES", "ru"), "Новая задача"); +}); diff --git a/tests/test_ui_language.py b/tests/test_ui_language.py new file mode 100644 index 0000000..1c42c24 --- /dev/null +++ b/tests/test_ui_language.py @@ -0,0 +1,34 @@ +import json +import os +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +HELPER = ROOT / "bin" / "taskwarrior-time" + + +class UiLanguageSettingsTest(unittest.TestCase): + def test_spanish_language_choice_is_persisted_privately(self): + with tempfile.TemporaryDirectory() as home: + env = dict(os.environ, HOME=home) + result = subprocess.run( + [str(HELPER), "settings-set", "--ui-language", "es"], + check=False, + capture_output=True, + text=True, + env=env, + ) + + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + self.assertEqual(json.loads(result.stdout)["uiLanguage"], "es") + settings = Path(home) / ".local/share/taskwarrior-time/ui-settings.json" + self.assertEqual(stat.S_IMODE(settings.stat().st_mode), 0o600) + self.assertEqual(json.loads(settings.read_text())["uiLanguage"], "es") + + +if __name__ == "__main__": + unittest.main()