From 4f01f0f0b51dc4e4ff643c0902b1d2e22e8830de Mon Sep 17 00:00:00 2001 From: Leandro Gazoli Date: Wed, 11 Feb 2026 10:56:57 -0300 Subject: [PATCH] feat(i18n): Implement runtime internationalization with dynamic language support - Added a runtime internationalization loader (`src/utils/i18n.js`) to load translations from `_locales//messages.json`. - Introduced language selection in the Options page, allowing users to choose their preferred language (default: English). - Replaced static strings in HTML files with `__MSG_...__` placeholders for localization. - Updated JavaScript files to utilize the new `t()` helper for consistent translation handling. - Enhanced the Options UI to support dynamic language switching and persist user preferences in settings. - Included English and Portuguese translations in `_locales/en/messages.json` and `_locales/pt/messages.json`. - Updated `webpack.config.js` to copy the `_locales` folder into the distribution directory for translation loading. - Added a `CHANGELOG.md` to document notable changes and updates in the project. --- CHANGELOG.md | 32 +++++ README.md | 25 +++- _locales/en/messages.json | 185 ++++++++++++++++++++++++++++ _locales/pt/messages.json | 185 ++++++++++++++++++++++++++++ package-lock.json | 13 +- package.json | 6 +- scripts/run-webext.js | 66 ++++++++++ src/manifest.json | 21 ++-- src/offscreen/offscreen.html | 3 +- src/offscreen/offscreen.js | 3 + src/options/options.html | 38 +++--- src/options/options.js | 128 +++++++++++++------- src/panel/panel.html | 15 ++- src/panel/panel.js | 56 ++++++--- src/stats/stats.html | 24 ++-- src/stats/stats.js | 225 +++++++++++++++++++++++++---------- src/utils/constants.js | 21 ++-- src/utils/i18n.js | 188 +++++++++++++++++++++++++++++ webpack.config.js | 1 + 19 files changed, 1054 insertions(+), 181 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 _locales/en/messages.json create mode 100644 _locales/pt/messages.json create mode 100644 scripts/run-webext.js create mode 100644 src/utils/i18n.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6828e66 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [7.2.1] - 2026-02-11 + +### Added + +- Runtime internationalization loader (`src/utils/i18n.js`) that loads `_locales//messages.json` dynamically. +- Language selector in the Options page allowing users to choose language (default: English). +- `t()` helper, `setLanguage()` and `applyTranslations()` functions to translate UI without page reload. +- `_locales/en` and `_locales/pt` messages include translations for UI labels and sound names. +- `CHANGELOG.md` (this file). + +### Changed + +- Replaced static strings in HTML with `__MSG_...__` placeholders and added runtime substitution. +- Replaced many `browser.i18n.getMessage(...)` uses with the runtime `t(...)` helper for consistency. +- Options UI now supports dynamic language switching and persists the user's choice in settings. +- `webpack.config.js` updated to copy the `_locales` folder into `dist/` so `web-ext` can load translations. +- Cross-platform `npm start` runner added (`scripts/run-webext.js`) and improved handling for Windows and Chromium. +- `README.md` updated with Windows-specific development instructions. +- Various small updates to ensure i18n and localization work across popup, options and stats pages. + +### Fixed + +- Fixed `spawn EINVAL` when running `web-ext` via `npx` on Windows by using shell invocation in the runner. +- Fixed missing localized strings in built HTML by applying runtime localization. + +## [7.2.0] - previous + +- (previous release notes omitted) diff --git a/README.md b/README.md index 88f2506..1394bb5 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,36 @@ npm run watch:firefox npm run watch:chrome ``` -4. In a separate terminal, run the following command to start a clean clean browser instance with live reloading (https://github.com/mozilla/web-ext): +4. In a separate terminal, run the following command to start a clean browser instance with live reloading (https://github.com/mozilla/web-ext). + +- On Windows (PowerShell): + +```powershell +$env:FIREFOX_PATH = 'C:\\Program Files\\Mozilla Firefox\\firefox.exe' +npm run start +``` + +- On Windows (Command Prompt): + +```cmd +set FIREFOX_PATH=C:\\Program Files\\Mozilla Firefox\\firefox.exe +npm run start +``` + +- On macOS / Linux (if Firefox is in the default location or on PATH): ```sh npm run start -npm run start:firefox +``` +- To run against Chromium/Chrome (when installed): + +```sh npm run start:chrome ``` +The `start` script uses `scripts/run-webext.js` and will attempt to auto-detect a local Firefox installation; set the `FIREFOX_PATH` environment variable if your Firefox is in a non-standard location. + ### Updating the version number Run the following command with the appropriate `npm version {patch/minor/major}` to bump the package.json version based on [semver](http://semver.org/): diff --git a/_locales/en/messages.json b/_locales/en/messages.json new file mode 100644 index 0000000..c130b04 --- /dev/null +++ b/_locales/en/messages.json @@ -0,0 +1,185 @@ +{ + "extName": { + "message": "Tomato Clock - A Simple Pomodoro Timer" + }, + "extDescription": { + "message": "A simple browser extension for managing your productivity." + }, + "actionDefaultTitle": { + "message": "Tomato Clock" + }, + "cmd_start_tomato_description": { + "message": "Start a new tomato timer." + }, + "cmd_start_short_break_description": { + "message": "Start a new short break." + }, + "cmd_start_long_break_description": { + "message": "Start a new long break." + }, + "cmd_reset_timer_description": { + "message": "Reset the current timer." + }, + "confirmResetStats": { + "message": "Are you sure you want to reset your stats?" + }, + "invalidJSON": { + "message": "Invalid JSON" + }, + "tomatoesLabel": { + "message": "Tomatoes" + }, + "dateFormat": { + "message": "dddd, MMMM Do YYYY" + }, + "range_last_7_days": { + "message": "Last 7 Days" + }, + "range_this_week": { + "message": "This week" + }, + "range_last_week": { + "message": "Last week" + }, + "range_last_30_days": { + "message": "Last 30 Days" + }, + "range_this_month": { + "message": "This Month" + }, + "range_last_month": { + "message": "Last Month" + }, + "range_this_year": { + "message": "This Year" + }, + "range_last_year": { + "message": "Last Year" + }, + "sound_alarm_beep_loud_mp3": { + "message": "Alarm Beep Loud" + }, + "sound_alarm_beep_mp3": { + "message": "Alarm Beep" + }, + "sound_beep_beep_mp3": { + "message": "Beep Beep" + }, + "sound_button_mp3": { + "message": "Button" + }, + "sound_kitchen_timer_mp3": { + "message": "Kitchen Timer" + }, + "sound_timer_chime_mp3": { + "message": "Timer Chime" + }, + "sound_custom": { + "message": "Custom" + }, + "panelTitle": { + "message": "Panel - Tomato Clock" + }, + "btn_tomato": { + "message": "Tomato" + }, + "btn_short_break": { + "message": "Short Break" + }, + "btn_long_break": { + "message": "Long Break" + }, + "btn_reset": { + "message": "Reset" + }, + "label_stats": { + "message": "Stats" + }, + "optionsTitle": { + "message": "Options - Tomato Clock" + }, + "label_minutes_in_tomato": { + "message": "Minutes in Tomato" + }, + "label_minutes_in_short_break": { + "message": "Minutes in Short Break" + }, + "label_minutes_in_long_break": { + "message": "Minutes in Long Break" + }, + "label_notification_sound": { + "message": "Notification sound" + }, + "placeholder_custom_sound": { + "message": "Custom Sound" + }, + "btn_clear": { + "message": "Clear" + }, + "label_toolbar_minute_display": { + "message": "Toolbar minute display" + }, + "btn_reset_to_default": { + "message": "Reset to default" + }, + "modal_confirm_reset_title": { + "message": "Confirm Reset" + }, + "modal_confirm_reset_body": { + "message": "Are you sure you want to reset all settings to default?" + }, + "btn_cancel": { + "message": "Cancel" + }, + "btn_reset_confirm": { + "message": "Reset" + }, + "statsTitle": { + "message": "Stats - Tomato Clock" + }, + "statsHeader": { + "message": "Stats - Tomato Clock" + }, + "label_date_range": { + "message": "Date Range" + }, + "label_language": { + "message": "Language" + }, + "table_header_timer": { + "message": "Timer" + }, + "table_header_count": { + "message": "#" + }, + "label_tomatoes": { + "message": "Tomatoes" + }, + "label_short_breaks": { + "message": "Short Breaks" + }, + "label_long_breaks": { + "message": "Long Breaks" + }, + "btn_export_stats": { + "message": "Export Stats as JSON" + }, + "btn_import_stats": { + "message": "Import Stats from JSON" + }, + "btn_reset_stats": { + "message": "Reset Stats" + }, + "offscreenTitle": { + "message": "Offscreen" + }, + "lang_en": { + "message": "English" + }, + "lang_pt": { + "message": "Portuguese" + }, + "label_options": { + "message": "Options" + } +} diff --git a/_locales/pt/messages.json b/_locales/pt/messages.json new file mode 100644 index 0000000..a0ce0db --- /dev/null +++ b/_locales/pt/messages.json @@ -0,0 +1,185 @@ +{ + "extName": { + "message": "Tomato Clock" + }, + "extDescription": { + "message": "Uma extensão simples para gerenciar sua produtividade." + }, + "actionDefaultTitle": { + "message": "Tomato Clock" + }, + "cmd_start_tomato_description": { + "message": "Iniciar um novo timer" + }, + "cmd_start_short_break_description": { + "message": "Iniciar um intervalo curto" + }, + "cmd_start_long_break_description": { + "message": "Iniciar um intervalo longo" + }, + "cmd_reset_timer_description": { + "message": "Reiniciar o timer atual" + }, + "confirmResetStats": { + "message": "Tem certeza de que deseja redefinir suas estatísticas?" + }, + "invalidJSON": { + "message": "JSON inválido" + }, + "tomatoesLabel": { + "message": "Tomates" + }, + "dateFormat": { + "message": "dddd, MMMM Do YYYY" + }, + "range_last_7_days": { + "message": "Últimos 7 dias" + }, + "range_this_week": { + "message": "Esta semana" + }, + "range_last_week": { + "message": "Semana passada" + }, + "range_last_30_days": { + "message": "Últimos 30 dias" + }, + "range_this_month": { + "message": "Este mês" + }, + "range_last_month": { + "message": "Mês passado" + }, + "range_this_year": { + "message": "Este ano" + }, + "range_last_year": { + "message": "Ano passado" + }, + "sound_alarm_beep_loud_mp3": { + "message": "Alarme Alto" + }, + "sound_alarm_beep_mp3": { + "message": "Alarme" + }, + "sound_beep_beep_mp3": { + "message": "Bip Bip" + }, + "sound_button_mp3": { + "message": "Botão" + }, + "sound_kitchen_timer_mp3": { + "message": "Timer de Cozinha" + }, + "sound_timer_chime_mp3": { + "message": "Toque do Timer" + }, + "sound_custom": { + "message": "Personalizado" + }, + "panelTitle": { + "message": "Painel - Tomato Clock" + }, + "btn_tomato": { + "message": "Tomate" + }, + "btn_short_break": { + "message": "Intervalo Curto" + }, + "btn_long_break": { + "message": "Intervalo Longo" + }, + "btn_reset": { + "message": "Reiniciar" + }, + "label_stats": { + "message": "Estatísticas" + }, + "optionsTitle": { + "message": "Opções - Tomato Clock" + }, + "label_minutes_in_tomato": { + "message": "Minutos no Tomate" + }, + "label_minutes_in_short_break": { + "message": "Minutos no Intervalo Curto" + }, + "label_minutes_in_long_break": { + "message": "Minutos no Intervalo Longo" + }, + "label_notification_sound": { + "message": "Som de notificação" + }, + "placeholder_custom_sound": { + "message": "Som Personalizado" + }, + "btn_clear": { + "message": "Limpar" + }, + "label_toolbar_minute_display": { + "message": "Exibir minutos na barra" + }, + "btn_reset_to_default": { + "message": "Redefinir para padrão" + }, + "modal_confirm_reset_title": { + "message": "Confirmar Redefinição" + }, + "modal_confirm_reset_body": { + "message": "Tem certeza de que deseja redefinir todas as configurações para o padrão?" + }, + "btn_cancel": { + "message": "Cancelar" + }, + "btn_reset_confirm": { + "message": "Redefinir" + }, + "statsTitle": { + "message": "Estatísticas - Tomato Clock" + }, + "statsHeader": { + "message": "Estatísticas - Tomato Clock" + }, + "label_date_range": { + "message": "Intervalo de Datas" + }, + "label_language": { + "message": "Idioma" + }, + "table_header_timer": { + "message": "Temporizador" + }, + "table_header_count": { + "message": "#" + }, + "label_tomatoes": { + "message": "Tomates" + }, + "label_short_breaks": { + "message": "Intervalos Curtos" + }, + "label_long_breaks": { + "message": "Intervalos Longos" + }, + "btn_export_stats": { + "message": "Exportar estatísticas como JSON" + }, + "btn_import_stats": { + "message": "Importar estatísticas de JSON" + }, + "btn_reset_stats": { + "message": "Redefinir estatísticas" + }, + "offscreenTitle": { + "message": "Offscreen" + }, + "lang_en": { + "message": "Inglês" + }, + "lang_pt": { + "message": "Português" + }, + "label_options": { + "message": "Opções" + } +} diff --git a/package-lock.json b/package-lock.json index cda8a8a..8f8b766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7664,11 +7664,10 @@ "dev": true }, "node_modules/undici": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", - "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", "dev": true, - "license": "MIT", "engines": { "node": ">=20.18.1" } @@ -13553,9 +13552,9 @@ "dev": true }, "undici": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", - "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", "dev": true }, "undici-types": { diff --git a/package.json b/package.json index 6be998b..48ab415 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tomato-clock", - "version": "7.2.0", + "version": "7.2.1", "description": "Tomato Clock is a simple browser extension for managing your productivity.", "main": "src/background.js", "scripts": { @@ -9,13 +9,13 @@ "test": "echo \"Error: no test specified\" && exit 1", "build": "webpack --mode=production && web-ext build --source-dir ./dist --artifacts-dir ./dist-zip --overwrite-dest", "watch": "webpack --watch", - "start": "web-ext run --source-dir ./dist -f /Applications/Firefox.app/Contents/MacOS/firefox", + "start": "node scripts/run-webext.js", "build:firefox": "npm run build", "watch:firefox": "npm run watch", "start:firefox": "npm run start", "build:chrome": "TARGET_BROWSER=chrome npm run build", "watch:chrome": "TARGET_BROWSER=chrome webpack --watch", - "start:chrome": "web-ext run -t chromium --source-dir ./dist", + "start:chrome": "node scripts/run-webext.js chromium", "prepare": "husky" }, "repository": { diff --git a/scripts/run-webext.js b/scripts/run-webext.js new file mode 100644 index 0000000..d74035e --- /dev/null +++ b/scripts/run-webext.js @@ -0,0 +1,66 @@ +const { spawn } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +// Determine default firefox executable by platform +function defaultFirefoxPath() { + if (process.env.FIREFOX_PATH) return process.env.FIREFOX_PATH; + switch (process.platform) { + case "darwin": + return "/Applications/Firefox.app/Contents/MacOS/firefox"; + case "win32": + // Common install locations on Windows + const programFiles = process.env["PROGRAMFILES"] || "C:\\Program Files"; + const programFilesx86 = + process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)"; + const candidates = [ + path.join(programFiles, "Mozilla Firefox", "firefox.exe"), + path.join(programFilesx86, "Mozilla Firefox", "firefox.exe"), + "C:\\Program Files\\Mozilla Firefox\\firefox.exe", + "C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe", + ]; + return candidates.find((p) => fs.existsSync(p)) || null; + default: + // On Linux, assume `firefox` is on PATH + return "firefox"; + } +} + +(async function main() { + const firefoxExe = defaultFirefoxPath(); + + const target = process.argv[2]; + + let args = []; + if (target === "chromium") { + args = ["run", "-t", "chromium", "--source-dir", "./dist"]; + } else { + args = ["run", "--source-dir", "./dist"]; + + if (firefoxExe) { + try { + if (firefoxExe !== "firefox" && fs.existsSync(firefoxExe)) { + // insert -f after 'run' + const runIndex = args.indexOf("run"); + if (runIndex >= 0) { + args.splice(runIndex + 1, 0, "-f", firefoxExe); + } else { + // fallback: append + args.push("-f", firefoxExe); + } + } + } catch (e) { + // ignore + } + } + } + + // Build command string and run via shell to avoid Windows .cmd spawn issues + const escapeArg = (s) => String(s).replace(/"/g, '\\"'); + const cmdStr = `npx web-ext ${args.map((a) => `"${escapeArg(a)}"`).join(" ")}`; + const proc = spawn(cmdStr, { stdio: "inherit", shell: true }); + + proc.on("close", (code) => { + process.exit(code); + }); +})(); diff --git a/src/manifest.json b/src/manifest.json index e1b5c11..41a42bb 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,10 +1,10 @@ { "manifest_version": 3, - "name": "Tomato Clock - A Simple Pomodoro Timer", + "default_locale": "en", + "name": "__MSG_extName__", "version": "__REPLACED_BY_WEBPACK__", "author": "Samuel Jun", - "description": "A simple browser extension for managing your productivity.", - + "description": "__MSG_extDescription__", "icons": { "16": "/assets/images/tomato-icon-16.png", "32": "/assets/images/tomato-icon-32.png", @@ -13,7 +13,6 @@ "128": "/assets/images/tomato-icon-128.png", "256": "/assets/images/tomato-icon-256.png" }, - "action": { "default_icon": { "16": "/assets/images/tomato-icon-16.png", @@ -23,41 +22,37 @@ "128": "/assets/images/tomato-icon-128.png", "256": "/assets/images/tomato-icon-256.png" }, - "default_title": "Tomato Clock", + "default_title": "__MSG_actionDefaultTitle__", "default_popup": "/panel/panel.html" }, - "background": "__REPLACED_BY_WEBPACK__", - "permissions": "__REPLACED_BY_WEBPACK__", - "commands": { "start-tomato": { "suggested_key": { "default": "Alt+Shift+1" }, - "description": "Start a new tomato timer." + "description": "__MSG_cmd_start_tomato_description__" }, "start-short-break": { "suggested_key": { "default": "Alt+Shift+2" }, - "description": "Start a new short break." + "description": "__MSG_cmd_start_short_break_description__" }, "start-long-break": { "suggested_key": { "default": "Alt+Shift+3" }, - "description": "Start a new long break." + "description": "__MSG_cmd_start_long_break_description__" }, "reset-timer": { "suggested_key": { "default": "Alt+Shift+4" }, - "description": "Reset the current timer." + "description": "__MSG_cmd_reset_timer_description__" } }, - "options_ui": { "page": "/options/options.html" } diff --git a/src/offscreen/offscreen.html b/src/offscreen/offscreen.html index 7eedf71..d026d42 100644 --- a/src/offscreen/offscreen.html +++ b/src/offscreen/offscreen.html @@ -1,8 +1,9 @@ - Offscreen + __MSG_offscreenTitle__ + diff --git a/src/offscreen/offscreen.js b/src/offscreen/offscreen.js index 9890635..9c9bcb3 100644 --- a/src/offscreen/offscreen.js +++ b/src/offscreen/offscreen.js @@ -1,4 +1,7 @@ import browser from "webextension-polyfill"; +import { localizeHtmlPage } from "../utils/i18n"; + +localizeHtmlPage(); browser.runtime.onMessage.addListener((message) => { if (message.target === "offscreen" && message.type === "play-audio") { diff --git a/src/options/options.html b/src/options/options.html index 148dce5..3c8d859 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -4,14 +4,14 @@ - Options - Tomato Clock + __MSG_optionsTitle__
+ +
+ + +

@@ -126,7 +132,9 @@
+
diff --git a/src/panel/panel.js b/src/panel/panel.js index ea5c291..a55a834 100644 --- a/src/panel/panel.js +++ b/src/panel/panel.js @@ -1,4 +1,9 @@ import browser from "webextension-polyfill"; +import { + localizeHtmlPage, + addLanguageChangeListener, + applyTranslations, +} from "../utils/i18n"; import "bootstrap/dist/css/bootstrap.min.css"; import "./panel.css"; @@ -13,21 +18,32 @@ import Settings from "../utils/settings"; export default class Panel { constructor() { - this.settings = new Settings(); - this.currentTimeText = document.getElementById("current-time-text"); - this.timer = {}; - - browser.runtime - .sendMessage({ - action: RUNTIME_ACTION.GET_TIMER_SCHEDULED_TIME, - }) - .then((scheduledTime) => { - if (scheduledTime) { - this.setDisplayTimer(scheduledTime - Date.now()); - } - }); + // Localize static HTML tokens like __MSG_key__ then initialize + localizeHtmlPage().then(() => { + this.settings = new Settings(); + this.currentTimeText = document.getElementById("current-time-text"); + this.timer = {}; + + browser.runtime + .sendMessage({ + action: RUNTIME_ACTION.GET_TIMER_SCHEDULED_TIME, + }) + .then((scheduledTime) => { + if (scheduledTime) { + this.setDisplayTimer(scheduledTime - Date.now()); + } + }); + + this.setEventListeners(); + }); - this.setEventListeners(); + // Update panel UI when language changes at runtime + addLanguageChangeListener(() => { + // re-run localization and re-apply dynamic translations + localizeHtmlPage().then(() => { + applyTranslations(); + }); + }); } setEventListeners() { @@ -58,6 +74,18 @@ export default class Panel { document.getElementById("stats-link").addEventListener("click", () => { browser.tabs.create({ url: "/stats/stats.html" }); }); + + const optionsLink = document.getElementById("options-link"); + if (optionsLink) { + optionsLink.addEventListener("click", () => { + if (browser.runtime.openOptionsPage) { + browser.runtime.openOptionsPage(); + } else { + // Fallback for older browsers + browser.tabs.create({ url: "/options/options.html" }); + } + }); + } } resetTimer() { diff --git a/src/stats/stats.html b/src/stats/stats.html index 4899e35..6524fc9 100644 --- a/src/stats/stats.html +++ b/src/stats/stats.html @@ -4,7 +4,7 @@ - Stats - Tomato Clock + __MSG_statsTitle__
-

Stats - Tomato Clock

+

__MSG_statsHeader__


- + Stats - Tomato Clock - - + + - + - + - + @@ -66,12 +68,12 @@

Stats - Tomato Clock


Stats - Tomato Clock /> diff --git a/src/stats/stats.js b/src/stats/stats.js index b444779..5b88a3a 100644 --- a/src/stats/stats.js +++ b/src/stats/stats.js @@ -8,6 +8,7 @@ import "daterangepicker/daterangepicker.css"; import "./stats.css"; import Timeline from "../utils/timeline"; +import { localizeHtmlPage, t, addLanguageChangeListener } from "../utils/i18n"; import { getDateLabel, getDateRangeStringArray, @@ -18,42 +19,124 @@ import { DATE_UNIT, TIMER_TYPE } from "../utils/constants"; export default class Stats { constructor() { - // Get DOM Elements - this.tomatoesCount = document.getElementById("tomatoes-count"); - this.shortBreaksCount = document.getElementById("short-breaks-count"); - this.longBreaksCount = document.getElementById("long-breaks-count"); - this.resetStatsButton = document.getElementById("reset-stats-button"); - this.exportStatsButton = document.getElementById("export-stats-button"); - this.importStatsButton = document.getElementById("import-stats-button"); - this.importStatsHiddenInput = document.getElementById( - "import-stats-hidden-input", - ); + // Localize static HTML tokens then initialize DOM bindings + localizeHtmlPage().then(() => { + // Get DOM Elements + this.tomatoesCount = document.getElementById("tomatoes-count"); + this.shortBreaksCount = document.getElementById("short-breaks-count"); + this.longBreaksCount = document.getElementById("long-breaks-count"); + this.resetStatsButton = document.getElementById("reset-stats-button"); + this.exportStatsButton = document.getElementById("export-stats-button"); + this.importStatsButton = document.getElementById("import-stats-button"); + this.importStatsHiddenInput = document.getElementById( + "import-stats-hidden-input", + ); - this.ctx = document - .getElementById("completed-tomato-dates-chart") - .getContext("2d"); - this.completedTomatoesChart = null; - - this.handleResetStatsButtonClick = - this.handleResetStatsButtonClick.bind(this); - this.handleExportStatsButtonClick = - this.handleExportStatsButtonClick.bind(this); - this.handleImportStatsButtonClick = - this.handleImportStatsButtonClick.bind(this); - this.handleImportStatsHiddenInputChange = - this.handleImportStatsHiddenInputChange.bind(this); - this.resetStatsButton.addEventListener( - "click", - this.handleResetStatsButtonClick, - ); - this.exportStatsButton.addEventListener( - "click", - this.handleExportStatsButtonClick, - ); - this.importStatsButton.addEventListener( - "click", - this.handleImportStatsButtonClick, - ); + this.ctx = document + .getElementById("completed-tomato-dates-chart") + .getContext("2d"); + this.completedTomatoesChart = null; + + this.handleResetStatsButtonClick = + this.handleResetStatsButtonClick.bind(this); + this.handleExportStatsButtonClick = + this.handleExportStatsButtonClick.bind(this); + this.handleImportStatsButtonClick = + this.handleImportStatsButtonClick.bind(this); + this.handleImportStatsHiddenInputChange = + this.handleImportStatsHiddenInputChange.bind(this); + this.resetStatsButton.addEventListener( + "click", + this.handleResetStatsButtonClick, + ); + this.exportStatsButton.addEventListener( + "click", + this.handleExportStatsButtonClick, + ); + this.importStatsButton.addEventListener( + "click", + this.handleImportStatsButtonClick, + ); + + this.resetDateRange(); + + // Listen for language changes to update chart label and daterangepicker + addLanguageChangeListener(() => { + if (this.completedTomatoesChart) { + this.completedTomatoesChart.data.datasets[0].label = + t("tomatoesLabel"); + this.completedTomatoesChart.update(); + } + // Reinitialize daterangepicker locale/labels + const picker = $('input[name="daterange"]'); + if (picker && picker.data("daterangepicker")) { + picker.data("daterangepicker").remove(); + // rebuild ranges with new labels + const rangeLabels = { + last7Days: t("range_last_7_days"), + thisWeek: t("range_this_week"), + lastWeek: t("range_last_week"), + last30Days: t("range_last_30_days"), + thisMonth: t("range_this_month"), + lastMonth: t("range_last_month"), + thisYear: t("range_this_year"), + lastYear: t("range_last_year"), + }; + const ranges = {}; + ranges[rangeLabels.last7Days] = [ + moment().subtract(6, "days"), + moment(), + ]; + ranges[rangeLabels.thisWeek] = [ + moment().startOf("week"), + moment().endOf("week"), + ]; + ranges[rangeLabels.lastWeek] = [ + moment().subtract(1, "week").startOf("week"), + moment().subtract(1, "week").endOf("week"), + ]; + ranges[rangeLabels.last30Days] = [ + moment().subtract(29, "days"), + moment(), + ]; + ranges[rangeLabels.thisMonth] = [ + moment().startOf("month"), + moment().endOf("month"), + ]; + ranges[rangeLabels.lastMonth] = [ + moment().subtract(1, "month").startOf("month"), + moment().subtract(1, "month").endOf("month"), + ]; + ranges[rangeLabels.thisYear] = [ + moment().startOf("year"), + moment().endOf("year"), + ]; + ranges[rangeLabels.lastYear] = [ + moment().subtract(1, "year").startOf("year"), + moment().subtract(1, "year").endOf("year"), + ]; + + picker.daterangepicker( + { + locale: { format: t("dateFormat") || "dddd, MMMM Do YYYY" }, + dateLimit: { months: 1 }, + startDate: moment().subtract(6, "days"), + endDate: moment(), + ranges, + }, + (start, end, label) => { + const startDate = start.toDate(); + const endDate = end.toDate(); + const isRangeYear = + label === rangeLabels.thisYear || + label === rangeLabels.lastYear; + const dateUnit = isRangeYear ? DATE_UNIT.MONTH : DATE_UNIT.DAY; + this.changeStatDates(startDate, endDate, dateUnit); + }, + ); + } + }); + }); this.importStatsHiddenInput.addEventListener( "change", this.handleImportStatsHiddenInputChange, @@ -64,7 +147,7 @@ export default class Stats { } handleResetStatsButtonClick() { - if (confirm("Are you sure you want to reset your stats?")) { + if (confirm(t("confirmResetStats"))) { this.timeline.resetTimeline().then(() => { this.resetDateRange(); }); @@ -98,7 +181,7 @@ export default class Stats { try { newTimeline = JSON.parse(timelineJson); } catch { - alert("Invalid JSON"); + alert(t("invalidJSON")); return; } @@ -143,7 +226,7 @@ export default class Stats { labels: dateRangeStrings, datasets: [ { - label: "Tomatoes", + label: t("tomatoesLabel"), fill: true, borderColor: "rgba(255,0,0,1)", backgroundColor: "rgba(255,0,0,0.2)", @@ -224,42 +307,64 @@ $(document).ready(() => { const momentLastWeek = moment().subtract(6, "days"); const momentToday = moment(); + // Build localized ranges for daterangepicker + const rangeLabels = { + last7Days: t("range_last_7_days"), + thisWeek: t("range_this_week"), + lastWeek: t("range_last_week"), + last30Days: t("range_last_30_days"), + thisMonth: t("range_this_month"), + lastMonth: t("range_last_month"), + thisYear: t("range_this_year"), + lastYear: t("range_last_year"), + }; + + const ranges = {}; + ranges[rangeLabels.last7Days] = [moment().subtract(6, "days"), moment()]; + ranges[rangeLabels.thisWeek] = [ + moment().startOf("week"), + moment().endOf("week"), + ]; + ranges[rangeLabels.lastWeek] = [ + moment().subtract(1, "week").startOf("week"), + moment().subtract(1, "week").endOf("week"), + ]; + ranges[rangeLabels.last30Days] = [moment().subtract(29, "days"), moment()]; + ranges[rangeLabels.thisMonth] = [ + moment().startOf("month"), + moment().endOf("month"), + ]; + ranges[rangeLabels.lastMonth] = [ + moment().subtract(1, "month").startOf("month"), + moment().subtract(1, "month").endOf("month"), + ]; + ranges[rangeLabels.thisYear] = [ + moment().startOf("year"), + moment().endOf("year"), + ]; + ranges[rangeLabels.lastYear] = [ + moment().subtract(1, "year").startOf("year"), + moment().subtract(1, "year").endOf("year"), + ]; + $('input[name="daterange"]').daterangepicker( { locale: { - format: "dddd, MMMM Do YYYY", + format: t("dateFormat") || "dddd, MMMM Do YYYY", }, dateLimit: { months: 1, }, startDate: momentLastWeek, endDate: momentToday, - ranges: { - "Last 7 Days": [moment().subtract(6, "days"), moment()], - "This week": [moment().startOf("week"), moment().endOf("week")], - "Last week": [ - moment().subtract(1, "week").startOf("week"), - moment().subtract(1, "week").endOf("week"), - ], - "Last 30 Days": [moment().subtract(29, "days"), moment()], - "This Month": [moment().startOf("month"), moment().endOf("month")], - "Last Month": [ - moment().subtract(1, "month").startOf("month"), - moment().subtract(1, "month").endOf("month"), - ], - "This Year": [moment().startOf("year"), moment().endOf("year")], - "Last Year": [ - moment().subtract(1, "year").startOf("year"), - moment().subtract(1, "year").endOf("year"), - ], - }, + ranges, }, (momentStartDate, momentEndDate, label) => { - // Convert Moment dates to native JS dates const startDate = momentStartDate.toDate(); const endDate = momentEndDate.toDate(); - const isRangeYear = label === "This Year" || label === "Last Year"; + const isRangeYear = + label === rangeLabels.thisYear || label === rangeLabels.lastYear; const dateUnit = isRangeYear ? DATE_UNIT.MONTH : DATE_UNIT.DAY; stats.changeStatDates(startDate, endDate, dateUnit); diff --git a/src/utils/constants.js b/src/utils/constants.js index ef0998d..6702bc9 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -14,6 +14,7 @@ export const SETTINGS_KEY = { MINUTES_IN_LONG_BREAK: "minutesInLongBreak", IS_NOTIFICATION_SOUND_ENABLED: "isNotificationSoundEnabled", SELECTED_NOTIFICATION_SOUND: "selectedNotificationSound", + LANGUAGE: "language", IS_TOOLBAR_BADGE_ENABLED: "isToolbarBadgeEnabled", }; @@ -24,16 +25,17 @@ export const DEFAULT_SETTINGS = { [SETTINGS_KEY.IS_NOTIFICATION_SOUND_ENABLED]: true, [SETTINGS_KEY.IS_TOOLBAR_BADGE_ENABLED]: true, [SETTINGS_KEY.SELECTED_NOTIFICATION_SOUND]: "timer-chime.mp3", + [SETTINGS_KEY.LANGUAGE]: "en", }; export const AVAILABLE_NOTIFICATION_SOUNDS = [ - { id: "alarm-beep-loud.mp3", name: "Alarm Beep Loud" }, - { id: "alarm-beep.mp3", name: "Alarm Beep" }, - { id: "beep-beep.mp3", name: "Beep Beep" }, - { id: "button.mp3", name: "Button" }, - { id: "kitchen-timer.mp3", name: "Kitchen Timer" }, - { id: "timer-chime.mp3", name: "Timer Chime" }, - { id: "custom", name: "Custom" }, + { id: "alarm-beep-loud.mp3" }, + { id: "alarm-beep.mp3" }, + { id: "beep-beep.mp3" }, + { id: "button.mp3" }, + { id: "kitchen-timer.mp3" }, + { id: "timer-chime.mp3" }, + { id: "custom" }, ]; export const TIMER_TYPE = { @@ -42,6 +44,11 @@ export const TIMER_TYPE = { LONG_BREAK: "longBreak", }; +export const AVAILABLE_LANGUAGES = [ + { id: "en", nameKey: "lang_en" }, + { id: "pt", nameKey: "lang_pt" }, +]; + export const BADGE_BACKGROUND_COLOR_BY_TIMER_TYPE = { [TIMER_TYPE.TOMATO]: "#dc3545", [TIMER_TYPE.SHORT_BREAK]: "#666", diff --git a/src/utils/i18n.js b/src/utils/i18n.js new file mode 100644 index 0000000..f211ea8 --- /dev/null +++ b/src/utils/i18n.js @@ -0,0 +1,188 @@ +import browser from "webextension-polyfill"; +import { STORAGE_KEY, SETTINGS_KEY, DEFAULT_SETTINGS } from "./constants"; + +let messages = {}; +let currentLang = null; + +async function loadMessagesForLang(lang) { + try { + const res = await fetch(`/_locales/${lang}/messages.json`); + if (!res.ok) throw new Error("fetch failed"); + const json = await res.json(); + const map = {}; + for (const k of Object.keys(json)) { + if (json[k] && typeof json[k].message === "string") + map[k] = json[k].message; + } + messages = map; + currentLang = lang; + return true; + } catch (e) { + messages = {}; + currentLang = null; + return false; + } +} + +export function t(key) { + if (messages && key in messages) return messages[key]; + try { + const msg = browser.i18n.getMessage(key); + if (msg) return msg; + } catch (e) { + // ignore + } + return key; +} + +export async function setLanguage(lang, persist = false) { + const ok = await loadMessagesForLang(lang); + if (persist) { + // Save to settings + const storageKey = STORAGE_KEY.SETTINGS; + const storage = browser.storage.sync || browser.storage.local; + const existing = await storage.get(storageKey); + const settings = (existing && existing[storageKey]) || DEFAULT_SETTINGS; + settings[SETTINGS_KEY.LANGUAGE] = lang; + await storage.set({ [storageKey]: settings }); + } + // notify listeners + languageChangeListeners.forEach((cb) => { + try { + cb(lang); + } catch (e) { + // ignore listener errors + } + }); + return ok; +} + +const languageChangeListeners = []; + +export function addLanguageChangeListener(cb) { + if (typeof cb === "function") languageChangeListeners.push(cb); +} + +// Listen for settings changes in storage so language updates propagate across +// different extension contexts (options, panel, stats, background, etc.) +try { + if (browser && browser.storage && browser.storage.onChanged) { + browser.storage.onChanged.addListener((changes, area) => { + if (area !== "local" && area !== "sync") return; + if (STORAGE_KEY.SETTINGS in changes) { + const newSettings = changes[STORAGE_KEY.SETTINGS].newValue; + if ( + newSettings && + newSettings[SETTINGS_KEY.LANGUAGE] && + newSettings[SETTINGS_KEY.LANGUAGE] !== currentLang + ) { + const newLang = newSettings[SETTINGS_KEY.LANGUAGE]; + // load messages and notify listeners in this context + loadMessagesForLang(newLang).then(() => { + languageChangeListeners.forEach((cb) => { + try { + cb(newLang); + } catch (e) { + // ignore listener errors + } + }); + }); + } + } + }); + } +} catch (e) { + // ignore if storage listener isn't available +} + +export function applyTranslations(doc = document) { + const nodes = doc.querySelectorAll("[data-i18n-key]"); + nodes.forEach((el) => { + const key = el.dataset.i18nKey; + const attr = el.dataset.i18nAttr || "text"; + const translated = t(key); + if (attr === "text") el.textContent = translated; + else el.setAttribute(attr, translated); + }); +} + +function replaceTokenString(token) { + const m = token.match(/^__MSG_(.+?)__$/); + if (!m) return null; + const key = m[1]; + return t(key) || token; +} + +export async function localizeHtmlPage(doc = document) { + // Ensure language loaded from settings + try { + const storageKey = STORAGE_KEY.SETTINGS; + // Use the same storage preference as Settings class for consistency + const storage = browser.storage.sync || browser.storage.local; + const existing = await storage.get(storageKey); + const settings = (existing && existing[storageKey]) || DEFAULT_SETTINGS; + const lang = + settings[SETTINGS_KEY.LANGUAGE] || + DEFAULT_SETTINGS[SETTINGS_KEY.LANGUAGE]; + if (lang !== currentLang) await loadMessagesForLang(lang); + } catch (e) { + // ignore + } + + const all = doc.querySelectorAll("*"); + all.forEach((el) => { + // attributes + for (let i = 0; i < el.attributes.length; i++) { + const attr = el.attributes[i]; + if (typeof attr.value === "string" && attr.value.startsWith("__MSG_")) { + const replaced = replaceTokenString(attr.value); + if (replaced) el.setAttribute(attr.name, replaced); + // mark element so dynamic updates can re-apply translations + const m = attr.value.match(/^__MSG_(.+?)__$/); + if (m) { + const key = m[1]; + el.dataset.i18nKey = key; + el.dataset.i18nAttr = attr.name; + } + } + } + + // text nodes directly under element + for (let node of Array.from(el.childNodes)) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.nodeValue.trim(); + if (text.startsWith("__MSG_") && text.endsWith("__")) { + const replaced = replaceTokenString(text); + if (replaced && replaced !== text) { + node.nodeValue = replaced; + const m = text.match(/^__MSG_(.+?)__$/); + if (m) { + const key = m[1]; + el.dataset.i18nKey = key; + el.dataset.i18nAttr = "text"; + } + } + } + } + } + }); +} + +export default { t, setLanguage, localizeHtmlPage }; + +// On module load, initialize messages according to stored settings so contexts +// that import this module later will have the correct language available +(async function initializeFromStorage() { + try { + const storage = browser.storage.sync || browser.storage.local; + const storageKey = STORAGE_KEY.SETTINGS; + const existing = await storage.get(storageKey); + const settings = (existing && existing[storageKey]) || DEFAULT_SETTINGS; + const lang = + settings[SETTINGS_KEY.LANGUAGE] || + DEFAULT_SETTINGS[SETTINGS_KEY.LANGUAGE]; + if (lang) await loadMessagesForLang(lang); + } catch (e) { + // ignore initialization errors + } +})(); diff --git a/webpack.config.js b/webpack.config.js index aa10195..da22cb7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -109,6 +109,7 @@ module.exports = { }, }, { from: "./src/assets", to: "./assets" }, + { from: "./_locales", to: "./_locales" }, ], }), ],
Timer#__MSG_table_header_timer____MSG_table_header_count__
Tomatoes__MSG_label_tomatoes__
Short Breaks__MSG_label_short_breaks__
Long Breaks__MSG_label_long_breaks__