From e9b8e403daf7373e11b29fd56096bf77dfabe223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Pol=C3=A1k?= <456647@muni.cz> Date: Sun, 17 Sep 2023 15:08:42 +0200 Subject: [PATCH 1/4] Initial draft of i18n API --- bundle.js | 1 + site/i18n.js | 71 ++++++++++++++++++++++++++++++++++++++++++++ site/index.js | 13 ++++++++ site/locales/cs.json | 0 site/locales/de.json | 0 site/locales/en.json | 0 6 files changed, 85 insertions(+) create mode 100755 site/i18n.js create mode 100755 site/locales/cs.json create mode 100755 site/locales/de.json create mode 100755 site/locales/en.json diff --git a/bundle.js b/bundle.js index 76af6a0..b33f61f 100755 --- a/bundle.js +++ b/bundle.js @@ -23,6 +23,7 @@ function replaceFileContents(string, fileDir) { const pattern = /%%([^%]+)%%|\/\/\s*include\s*"([^"]+)"/g; return string.replace(pattern, (_, filename1, filename2) => { + if (filename2 != null) console.error("include is used!!!!!"); const filename = filename1 || filename2; const filenamePath = path.join(fileDir, filename); try { diff --git a/site/i18n.js b/site/i18n.js new file mode 100755 index 0000000..ee2e330 --- /dev/null +++ b/site/i18n.js @@ -0,0 +1,71 @@ +// Internationalization support +// This file is used both serverside and clientside. + +/** + * Supported languages and their translations. + * Each file contains an object with key-value pairs. + * + * Argument substitution is supported through named arguments: translate("Hello {{name}}", {name: "World"}) + * + * Plurals and arrays are currently not supported. + * + * If the key is missing in the current localization, it falls back to default localization + * and then to treating the key itself as the localization. + */ + +const translations = { + cs: require("./locales/cs.json"), + de: require("./locales/de.json"), + en: require("./locales/en.json"), +}; +/** + * @type {string[]} + */ +const locales = Object.keys(locales); +const defaultLocale = "de"; + +/** The currently selected locale. */ +var currentLocale = defaultLocale; + +/** + * Set the globally used locale. + * Expects a 2 character language code string, one from locales. + */ +function setLocale(locale) { + if (Object.hasOwn(locales, locale)) { + currentLocale = locale; + return true; + } + console.error("Attempted to setLocale to unsupported language: ", locale); + return false; +} + +/** + * @param {string} key to translate + * @param {Object?} args arguments to substitute into the translated key + * @returns translated string + */ +function translate(key, args) { + let translation = locales[currentLocale][key]; + if (translation === undefined) { + console.error("Untranslated key in ", currentLocale, ": ", key); + if (currentLocale != defaultLocale) { + translation = locales[defaultLocale][key] || key; + } else { + translation = key; + } + } + + if (typeof args === "object") { + // Do argument substitution + for (const arg in args) { + translation = translation.replaceAll("{{" + arg + "}}", args[arg]); + } + } + + return translation; +} + +exports.setLocale = setLocale; +exports.locales = locales; +exports.translate = translate; diff --git a/site/index.js b/site/index.js index db47adf..b2315ec 100644 --- a/site/index.js +++ b/site/index.js @@ -6,11 +6,24 @@ const { STORE_KEYS } = require("./model/stores"); const { ProgressBar } = require("./views/progress-bar"); const progressBar = new ProgressBar(STORE_KEYS.length); +const i18n = require("./i18n"); + (async () => { if (location.href.includes("heissepreise.github.io")) { location.href = "https://heisse-preise.io"; } + // Find the most preferred supported language + for (const langCode in navigator.languages) { + // We don't do regional codes, so take just the language code + let lang = langCode.length >= 2 ? langCode.substring(0, 2) : null; + if (lang == null) continue; + if (i18n.locales.indexOf(lang) != -1) { + i18n.setLocale(lang); + break; + } + } + await model.load(() => progressBar.addStep()); const itemsFilter = document.querySelector("items-filter"); const itemsList = document.querySelector("items-list"); diff --git a/site/locales/cs.json b/site/locales/cs.json new file mode 100755 index 0000000..e69de29 diff --git a/site/locales/de.json b/site/locales/de.json new file mode 100755 index 0000000..e69de29 diff --git a/site/locales/en.json b/site/locales/en.json new file mode 100755 index 0000000..e69de29 From d2ae0cc5759ad26023ea18cc675b816137f6a8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Pol=C3=A1k?= <456647@muni.cz> Date: Sun, 17 Sep 2023 17:13:14 +0200 Subject: [PATCH 2/4] Translate all static html files into English and Czech --- bundle.js | 71 ++++++++++++++++++++++++++---------- server.js | 50 ++++++++++++++++++------- site/_templates/_footer.html | 12 +++--- site/_templates/_header.html | 6 +-- site/_templates/_menu.html | 6 +-- site/cart.html | 15 ++++++-- site/carts.html | 8 ++-- site/changes.html | 4 +- site/i18n.js | 44 ++++++++++++++++------ site/imprint.html | 12 +++--- site/index.html | 10 +++-- site/locales/cs.json | 36 ++++++++++++++++++ site/locales/de.json | 36 ++++++++++++++++++ site/locales/en.json | 36 ++++++++++++++++++ site/media.html | 6 +-- site/settings.html | 2 +- 16 files changed, 274 insertions(+), 80 deletions(-) mode change 100755 => 100644 bundle.js mode change 100755 => 100644 site/i18n.js mode change 100755 => 100644 site/locales/cs.json mode change 100755 => 100644 site/locales/de.json mode change 100755 => 100644 site/locales/en.json diff --git a/bundle.js b/bundle.js old mode 100755 new mode 100644 index b33f61f..fbe5dc7 --- a/bundle.js +++ b/bundle.js @@ -4,6 +4,7 @@ const chokidar = require("chokidar"); const esbuild = require("esbuild"); const { exec } = require("child_process"); const { promisify } = require("util"); +const i18n = require("./site/i18n"); function deleteDirectory(directory) { if (fs.existsSync(directory)) { @@ -19,39 +20,69 @@ function deleteDirectory(directory) { } } -function replaceFileContents(string, fileDir) { - const pattern = /%%([^%]+)%%|\/\/\s*include\s*"([^"]+)"/g; +/** + * Process file content by resolving includes and translation placeholders. + * + * @param {string} fileContent original file content + * @param {string} fileDir path of the directory of the file + * @param {string} locale language code which should be used to resolve translation placeholders + * @returns {string} + */ +function replaceFileContents(fileContent, fileDir, locale) { + const pattern = /%%([^%]+)%%|__(.+?)__/g; - return string.replace(pattern, (_, filename1, filename2) => { - if (filename2 != null) console.error("include is used!!!!!"); - const filename = filename1 || filename2; - const filenamePath = path.join(fileDir, filename); - try { - const data = fs.readFileSync(filenamePath, "utf8"); - const replacedData = replaceFileContents(data, path.dirname(filenamePath)); - return replacedData; - } catch (error) { - console.error(`Error reading file "${filenamePath}":`, error); - return ""; + return fileContent.replace(pattern, (_, filename, translationKey) => { + if (filename != undefined) { + const filenamePath = path.join(fileDir, filename); + try { + const data = fs.readFileSync(filenamePath, "utf8"); + const replacedData = replaceFileContents(data, path.dirname(filenamePath), locale); + return replacedData; + } catch (error) { + console.error(`Error reading file "${filenamePath}":`, error); + return ""; + } + } else if (translationKey != undefined) { + return i18n.translateWithLocale(locale, translationKey); } }); } +/** + * Copy inputFile to outputFile, possibly modifying it in the process. + * + * @param {string} inputFile path + * @param {string} outputFile path + * @param {function(string, boolean, string):boolean} filter takes path, whether it is a directory and data (or null if directory), returns true if the file should be left out + */ function processFile(inputFile, outputFile, filter) { - const fileDir = path.dirname(inputFile); - if (inputFile.includes(".mp3")) { + let extension = path.extname(inputFile); + if (extension == ".html") { + const data = fs.readFileSync(inputFile, "utf8"); + if (filter(inputFile, false, data)) return; + for (const locale of i18n.locales) { + const replacedData = replaceFileContents(data, path.dirname(inputFile), locale); + if (locale == i18n.defaultLocale) { + fs.writeFileSync(outputFile, replacedData); + } + let pathWithLanguageCode = outputFile.substring(0, outputFile.length - extension.length) + "." + locale + extension; + fs.writeFileSync(pathWithLanguageCode, replacedData); + } + } else { const data = fs.readFileSync(inputFile); if (filter(inputFile, false, data)) return; fs.writeFileSync(outputFile, data); - } else { - const data = fs.readFileSync(inputFile, "utf8"); - if (filter(inputFile, false, data)) return; - const replacedData = replaceFileContents(data, fileDir); - fs.writeFileSync(outputFile, replacedData); } console.log(`${inputFile} -> ${outputFile}`); } +/** + * + * @param {string} inputDir path to the input directory, traversed recursively, outputDir and files/directories starting with _ are automatically skipped + * @param {string} outputDir path to the output directory + * @param {boolean} deleteOutput whether the contents of output directory should be deleted first + * @param {function(string, boolean, string):boolean} filter takes path, whether it is a directory and data (or null if directory), returns true if the file should be left out + */ function generateSite(inputDir, outputDir, deleteOutput, filter) { if (deleteOutput) { deleteDirectory(outputDir); diff --git a/server.js b/server.js index dec91c0..701ac38 100644 --- a/server.js +++ b/server.js @@ -7,6 +7,7 @@ const csv = require("./site/js/misc"); const chokidar = require("chokidar"); const express = require("express"); const compression = require("compression"); +const i18n = require("./site/i18n"); function copyItemsToSite(dataDir) { const items = analysis.readJSON(`${dataDir}/latest-canonical.json.${analysis.FILE_COMPRESSOR}`); @@ -45,6 +46,7 @@ function parseArguments() { const args = process.argv.slice(2); let port = process.env.PORT !== undefined && process.env.PORT != "" ? parseInt(process.env.PORT) : 3000; let liveReload = process.env.NODE_ENV === "development" || false; + let skipDataUpdate = false; for (let i = 0; i < args.length; i++) { if (args[i] === "-p" || args[i] === "--port") { port = parseInt(args[i + 1]); @@ -54,6 +56,8 @@ function parseArguments() { throw new Error("Live reload is only supported in development mode"); } liveReload = true; + } else if (args[i] === "--skip-data-update") { + skipDataUpdate = true; } else if (args[i] === "-h" || args[i] === "--help") { console.log("Usage: node server.js [-p|--port PORT] [-l|--live-reload]"); console.log(); @@ -64,7 +68,7 @@ function parseArguments() { } } - return { port, liveReload }; + return { port, liveReload, skipDataUpdate }; } function setupLogging() { @@ -81,7 +85,7 @@ function setupLogging() { (async () => { const dataDir = "data"; - const { port, liveReload } = parseArguments(); + const { port, liveReload, skipDataUpdate } = parseArguments(); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir); @@ -102,25 +106,43 @@ function setupLogging() { setupLogging(); bundle.bundle("site", outputDir, liveReload); - analysis.migrateCompression(dataDir, ".json", ".json.br"); - analysis.migrateCompression(dataDir, ".json.gz", ".json.br"); + if (!skipDataUpdate) { + analysis.migrateCompression(dataDir, ".json", ".json.br"); + analysis.migrateCompression(dataDir, ".json.gz", ".json.br"); - if (fs.existsSync(`${dataDir}/latest-canonical.json.${analysis.FILE_COMPRESSOR}`)) { - copyItemsToSite(dataDir); - analysis.updateData(dataDir, (_newItems) => { + if (fs.existsSync(`${dataDir}/latest-canonical.json.${analysis.FILE_COMPRESSOR}`)) { + copyItemsToSite(dataDir); + analysis.updateData(dataDir, (_newItems) => { + copyItemsToSite(dataDir); + }); + } else { + await analysis.updateData(dataDir); + copyItemsToSite(dataDir); + } + scheduleFunction(5, 0, 0, async () => { + items = await analysis.updateData(dataDir); copyItemsToSite(dataDir); }); - } else { - await analysis.updateData(dataDir); - copyItemsToSite(dataDir); } - scheduleFunction(5, 0, 0, async () => { - items = await analysis.updateData(dataDir); - copyItemsToSite(dataDir); - }); const app = express(); app.use(compression()); + app.use(function (req, res, next) { + if (req.method == "GET") { + if (req.path == "/") { + req.url = "/index.html"; + } + if (req.path.endsWith(".html")) { + // Only html files are translated + let pickedLanguage = req.acceptsLanguages(i18n.locales); + if (pickedLanguage) { + let translatedPath = req.path.substring(0, req.path.length - "html".length) + pickedLanguage + ".html"; + req.url = translatedPath; + } // otherwise use default, untranslated file + } + } + next(); + }); app.use(express.static("site/output")); const server = http.createServer(app).listen(port, () => { console.log(`App listening on port ${port}`); diff --git a/site/_templates/_footer.html b/site/_templates/_footer.html index fcb0f58..2b355a6 100644 --- a/site/_templates/_footer.html +++ b/site/_templates/_footer.html @@ -1,8 +1,8 @@ diff --git a/site/_templates/_header.html b/site/_templates/_header.html index d6f1a23..9b38ae8 100644 --- a/site/_templates/_header.html +++ b/site/_templates/_header.html @@ -4,8 +4,8 @@ - - Heisse Preise + + __Heisse Preise__
-

🔥 Heisse Preise 🔥

+

🔥 __Heisse Preise__ 🔥

diff --git a/site/_templates/_menu.html b/site/_templates/_menu.html index 2ad5fc1..db3d19d 100644 --- a/site/_templates/_menu.html +++ b/site/_templates/_menu.html @@ -1,5 +1,5 @@
- Suche - Preisänderungen - Warenkörbe + __Suche__ + __Preisänderungen__ + __Warenkörbe__
\ No newline at end of file diff --git a/site/cart.html b/site/cart.html index 93beef9..ec150e4 100644 --- a/site/cart.html +++ b/site/cart.html @@ -4,13 +4,20 @@ - + - + %%_templates/_loader.html%%
diff --git a/site/carts.html b/site/carts.html index 6c283a7..bd0c59f 100644 --- a/site/carts.html +++ b/site/carts.html @@ -2,11 +2,11 @@
-

Warenkörbe

+

__Warenkörbe__

- - - + + +
%%_templates/_loader.html%% diff --git a/site/changes.html b/site/changes.html index 6306604..68d50a9 100644 --- a/site/changes.html +++ b/site/changes.html @@ -1,8 +1,8 @@ %%_templates/_header.html%% %%_templates/_menu.html%%
-

Preisänderungen

- +

__Preisänderungen__

+ %%_templates/_loader.html%%
diff --git a/site/i18n.js b/site/i18n.js old mode 100755 new mode 100644 index ee2e330..8e01aeb --- a/site/i18n.js +++ b/site/i18n.js @@ -11,8 +11,9 @@ * * If the key is missing in the current localization, it falls back to default localization * and then to treating the key itself as the localization. + * + * @type {Object.>} */ - const translations = { cs: require("./locales/cs.json"), de: require("./locales/de.json"), @@ -21,18 +22,25 @@ const translations = { /** * @type {string[]} */ -const locales = Object.keys(locales); +const locales = Object.keys(translations); +/** + * @type {string} + */ const defaultLocale = "de"; -/** The currently selected locale. */ +/** + * The currently selected locale. + * @type {string} + */ var currentLocale = defaultLocale; /** * Set the globally used locale. * Expects a 2 character language code string, one from locales. + * @param {string} locale */ function setLocale(locale) { - if (Object.hasOwn(locales, locale)) { + if (locales.indexOf(locale) != -1) { currentLocale = locale; return true; } @@ -41,16 +49,28 @@ function setLocale(locale) { } /** - * @param {string} key to translate - * @param {Object?} args arguments to substitute into the translated key - * @returns translated string + * Translates the key using the current global locale. + * + * @param {!string} key to translate + * @param {!Object.} [args] arguments to substitute into the translated key + * @returns {string} translated string */ function translate(key, args) { - let translation = locales[currentLocale][key]; + return translateWithLocale(currentLocale, key, args); +} + +/** + * @param {!string} locale name of the language to use for translation, MUST be one of the supported languages + * @param {!string} key to translate + * @param {!Object.} [args] arguments to substitute into the translated key + * @returns {string} translated string + */ +function translateWithLocale(locale, key, args) { + let translation = translations[locale][key]; if (translation === undefined) { - console.error("Untranslated key in ", currentLocale, ": ", key); - if (currentLocale != defaultLocale) { - translation = locales[defaultLocale][key] || key; + console.error("Untranslated key in ", locale, ": ", key); + if (locale != defaultLocale) { + translation = translations[defaultLocale][key] || key; } else { translation = key; } @@ -67,5 +87,7 @@ function translate(key, args) { } exports.setLocale = setLocale; +exports.defaultLocale = defaultLocale; exports.locales = locales; exports.translate = translate; +exports.translateWithLocale = translateWithLocale; diff --git a/site/imprint.html b/site/imprint.html index 7e51149..609a403 100644 --- a/site/imprint.html +++ b/site/imprint.html @@ -1,14 +1,16 @@ %%_templates/_header.html%% %%_templates/_menu.html%%
-

Impressum

+

__Impressum__

Angaben gemäß § 25 Mediengesetz (Österreich)

- Medieninhaber: Mario Zechner
- Kontakt: badlogicgames@gmail.com
- Adresse: Schörgelgasse 3, 8010 Graz, Österreich + __Medieninhaber__: Mario Zechner
+ __Kontakt__: badlogicgames@gmail.com
+ __Adresse__: Schörgelgasse 3, 8010 Graz, Österreich +

+

+ __Diese nicht-kommerzielle Seite dient KonsumentInnen dazu, Preise von Produkten im Lebensmittelhandel vergleichen zu können.__

-

Diese nicht-kommerzielle Seite dient KonsumentInnen dazu, Preise von Produkten im Lebensmittelhandel vergleichen zu können.

%%_templates/_footer.html%% diff --git a/site/index.html b/site/index.html index ad4e74b..82ebfc1 100644 --- a/site/index.html +++ b/site/index.html @@ -2,11 +2,13 @@
-

Produktsuche

+

__Produktsuche__

%%_templates/_loader.html%% diff --git a/site/locales/cs.json b/site/locales/cs.json old mode 100755 new mode 100644 index e69de29..e216620 --- a/site/locales/cs.json +++ b/site/locales/cs.json @@ -0,0 +1,36 @@ +{ + "Heisse Preise": "Zlevněno?", + "__page_description__": "Nekomerční open source projekt umožňující spotřebitelům najít nejlevnější verzi produktu v obchodech.", + "Einstellungen": "Nastavení", + "Impressum": "Impressum", + "Logs": "Logy", + "Historische Daten von": "Historická data od", + "Alle Angaben ohne Gewähr, Irrtümer vorbehalten.": "Veškeré informace jsou poskytovány bez záruky, chyby vyhrazeny.", + "Markennamen und Warenzeichen sind Eigentum der jeweiligen Inhaber.": "Názvy značek a ochranné známky jsou majetkem příslušných vlastníků.", + "Suche": "Vyhledávání", + "Preisänderungen": "Změny cen", + "Warenkörbe": "Nákupní vozíky", + + "Noch keine Produkte im Warenkorb.": "Nákupní košík je prázdný.", + "Produkte suchen und mit '+' zum Warenkorb hinzufügen.": "Vyhledejte produkty a přidejte je do nákupního košíku pomocí '+'.", + "Filtern...": "Filtr...", + "(min. 3 Zeichen)": "(alespoň 3 znaky)", + "Produkt hinzufügen...": "Přidat produkt...", + + "Neuer Warenkorb": "Nový nákupní košík", + "Exportieren": "Export", + "Importieren": "Import", + + "Medieninhaber": "Majitel", + "Kontakt": "Kontakt", + "Adresse": "Adresa", + "Diese nicht-kommerzielle Seite dient KonsumentInnen dazu, Preise von Produkten im Lebensmittelhandel vergleichen zu können.": "Tato nekomerční stránka umožňuje spotřebitelům porovnávat ceny produktů v obchodě s potravinami.", + + "Video Anleitung": "Video instrukce (německy)", + "Text Anleitung": "Textové instrukce (německy)", + "Medienberichte": "Napsali o nás", + "Produktsuche": "Vyhledávání produktů", + + "Radio & Fernsehen": "Rádio & Televize", + "Print & Online": "Tisk & Online" +} diff --git a/site/locales/de.json b/site/locales/de.json old mode 100755 new mode 100644 index e69de29..07015b9 --- a/site/locales/de.json +++ b/site/locales/de.json @@ -0,0 +1,36 @@ +{ + "Heisse Preise": "Heisse Preise", + "__page_description__": "Nicht-kommerzielles Open-Source-Projekt um KonsumentInnen es zu ermöglichen, die günstigste Variante eines Produktes im Handel ausfindig zu machen.", + "Einstellungen": "Einstellungen", + "Impressum": "Impressum", + "Logs": "Logs", + "Historische Daten von": "Historische Daten von", + "Alle Angaben ohne Gewähr, Irrtümer vorbehalten.": "Alle Angaben ohne Gewähr, Irrtümer vorbehalten.", + "Markennamen und Warenzeichen sind Eigentum der jeweiligen Inhaber.": "Markennamen und Warenzeichen sind Eigentum der jeweiligen Inhaber.", + "Suche": "Suche", + "Preisänderungen": "Preisänderungen", + "Warenkörbe": "Warenkörbe", + + "Noch keine Produkte im Warenkorb.": "Noch keine Produkte im Warenkorb.", + "Produkte suchen und mit '+' zum Warenkorb hinzufügen.": "Produkte suchen und mit '+' zum Warenkorb hinzufügen.", + "Filtern...": "Filtern...", + "(min. 3 Zeichen)": "(min. 3 Zeichen)", + "Produkt hinzufügen...": "Produkt hinzufügen...", + + "Neuer Warenkorb": "Neuer Warenkorb", + "Exportieren": "Exportieren", + "Importieren": "Importieren", + + "Medieninhaber": "Medieninhaber", + "Kontakt": "Kontakt", + "Adresse": "Adresse", + "Diese nicht-kommerzielle Seite dient KonsumentInnen dazu, Preise von Produkten im Lebensmittelhandel vergleichen zu können.": "Diese nicht-kommerzielle Seite dient KonsumentInnen dazu, Preise von Produkten im Lebensmittelhandel vergleichen zu können.", + + "Video Anleitung": "Video Anleitung", + "Text Anleitung": "Text Anleitung", + "Medienberichte": "Medienberichte", + "Produktsuche": "Produktsuche", + + "Radio & Fernsehen": "Radio & Fernsehen", + "Print & Online": "Print & Online" +} diff --git a/site/locales/en.json b/site/locales/en.json old mode 100755 new mode 100644 index e69de29..acc6f27 --- a/site/locales/en.json +++ b/site/locales/en.json @@ -0,0 +1,36 @@ +{ + "Heisse Preise": "Hot Prices", + "__page_description__": "Non-commercial open source project to enable consumers to find the cheapest version of a product in stores.", + "Einstellungen": "Settings", + "Impressum": "Imprint", + "Logs": "Logs", + "Historische Daten von": "Histoic data from", + "Alle Angaben ohne Gewähr, Irrtümer vorbehalten.": "All information provided without guarantee, errors excepted.", + "Markennamen und Warenzeichen sind Eigentum der jeweiligen Inhaber.": "Brand names and trademarks are the property of their respective owners.", + "Suche": "Search", + "Preisänderungen": "Price changes", + "Warenkörbe": "Shopping carts", + + "Noch keine Produkte im Warenkorb.": "No products in your shopping cart yet.", + "Produkte suchen und mit '+' zum Warenkorb hinzufügen.": "Search for products and add them to the shopping cart with '+'.", + "Filtern...": "Filter...", + "(min. 3 Zeichen)": "(at least 3 characters)", + "Produkt hinzufügen...": "Add product...", + + "Neuer Warenkorb": "New shopping cart", + "Exportieren": "Export", + "Importieren": "Import", + + "Medieninhaber": "Owner", + "Kontakt": "Contact", + "Adresse": "Address", + "Diese nicht-kommerzielle Seite dient KonsumentInnen dazu, Preise von Produkten im Lebensmittelhandel vergleichen zu können.": "This non-commercial site allows consumers to compare prices of products in the grocery store.", + + "Video Anleitung": "Video instructions (in German)", + "Text Anleitung": "Text instructions (in German)", + "Medienberichte": "Media reports", + "Produktsuche": "Product search", + + "Radio & Fernsehen": "Radio & Television", + "Print & Online": "Print & Online" +} diff --git a/site/media.html b/site/media.html index e837a55..cc2199c 100644 --- a/site/media.html +++ b/site/media.html @@ -1,9 +1,9 @@ %%_templates/_header.html%% %%_templates/_menu.html%%
-

Medienberichte

+

__Medienberichte__

-

Radio & Fernsehen

+

__Radio & Fernsehen__

24.5.203 Österreichisches Parlament - Rede von Joachim Schnabel zum Thema Lebensmittelpreise, ÖVP @@ -42,7 +42,7 @@ > -

Print & Online

+

__Print & Online__

16.5.2023 PULS 24 - Preisvergleich: Politik braucht "bis Herbst", Twitter-User "2 Stunden" diff --git a/site/settings.html b/site/settings.html index 2821573..1332ea9 100644 --- a/site/settings.html +++ b/site/settings.html @@ -1,7 +1,7 @@ %%_templates/_header.html%% %%_templates/_menu.html%%
-

Einstellungen

+

__Einstellungen__

From f36b44fc42f227bbcbe88255cbd6e0f8a95bb947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Pol=C3=A1k?= <456647@muni.cz> Date: Sun, 17 Sep 2023 19:21:42 +0200 Subject: [PATCH 3/4] Translate strings in JavaScript --- server.js | 2 ++ site/cart.js | 19 +++++++---- site/carts.js | 9 +++-- site/i18n.js | 5 ++- site/index.js | 38 +++++++++++---------- site/js/misc.js | 6 +++- site/locales/cs.json | 67 ++++++++++++++++++++++++++++++++++-- site/locales/de.json | 67 ++++++++++++++++++++++++++++++++++-- site/locales/en.json | 69 ++++++++++++++++++++++++++++++++++++-- site/settings.js | 23 ++++++++----- site/views/carts-list.js | 19 ++++++----- site/views/items-chart.js | 19 ++++++----- site/views/items-filter.js | 29 +++++++++------- site/views/items-list.js | 41 +++++++++++----------- 14 files changed, 321 insertions(+), 92 deletions(-) diff --git a/server.js b/server.js index 701ac38..4fe21d0 100644 --- a/server.js +++ b/server.js @@ -123,6 +123,8 @@ function setupLogging() { items = await analysis.updateData(dataDir); copyItemsToSite(dataDir); }); + } else { + copyItemsToSite(dataDir); } const app = express(); diff --git a/site/cart.js b/site/cart.js index c906ca6..85763aa 100644 --- a/site/cart.js +++ b/site/cart.js @@ -6,6 +6,7 @@ const { STORE_KEYS, stores } = require("./model/stores"); require("./views"); const { ProgressBar } = require("./views/progress-bar"); const progressBar = new ProgressBar(STORE_KEYS.length); +const { __ } = require("./i18n"); let carts = null; @@ -35,8 +36,12 @@ class CartHeader extends View {

- - + + `; const elements = this.elements; @@ -47,7 +52,9 @@ class CartHeader extends View { let newName = cart.name; while (true) { newName = prompt( - "Warenkorb '" + cart.name + " existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben", + __("Cart_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben", { + name: cart.name, + }), cart.name + today() ); if (!newName || newName.trim().length == 0) return; @@ -69,7 +76,7 @@ class CartHeader extends View { render() { const cart = this.model.cart; const elements = this.elements; - elements.name.innerText = `Warenkorb '${cart.name}'`; + elements.name.innerText = __("Cart_Warenkorb {{name}}", { name: cart.name }); if (this.model.linked) { elements.save.classList.remove("hidden"); } else { @@ -112,7 +119,7 @@ function loadCart() { } if (cart == null) { - alert("Warenkorb '" + cartName + "' existiert nicht."); + alert(__("Cart_Warenkorb '{{name}}' existiert nicht.", { name: cartName })); location.href = "carts.html"; } @@ -133,7 +140,7 @@ function loadCart() { STORE_KEYS.forEach((store) => { cartFilter.elements[store].checked = true; }); - cartList.elements.numItemsLabel.innerHTML = "Artikel:"; + cartList.elements.numItemsLabel.innerHTML = `${__("Cart_Artikel")}:`; cartList.elements.enableChart.checked = models.items.length < 2000; cartList.elements.chart.elements.sumStores.checked = models.items.length < 2000; diff --git a/site/carts.js b/site/carts.js index 1152333..50877a3 100644 --- a/site/carts.js +++ b/site/carts.js @@ -1,17 +1,18 @@ const { downloadJSON, today } = require("./js/misc"); const model = require("./model"); require("./views"); +const { __ } = require("./i18n"); const { STORE_KEYS } = require("./model/stores"); const { ProgressBar } = require("./views/progress-bar"); const progressBar = new ProgressBar(STORE_KEYS.length); function newCart() { - let name = prompt("Name für Warenkorb eingeben:"); + let name = prompt(__("Carts_Name für Warenkorb eingeben:")); if (!name || name.trim().length == 0) return; name = name.trim(); if (model.carts.carts.some((cart) => cart.name === name)) { - alert("Warenkorb mit Namen '" + name + "' existiert bereits"); + alert(__("Carts_Warenkorb mit Namen '{{name}}' existiert bereits", { name: name })); return; } model.carts.add(name); @@ -36,7 +37,9 @@ function importCart(importedCart) { let newName = importedCart.name; while (true) { newName = prompt( - "Warenkorb '" + importedCart.name + " existiert bereits. Bitte einen anderen Namen für den zu importierenden Warenkorb eingeben", + __("Carts_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu importierenden Warenkorb eingeben", { + name: importedCart.name, + }), importedCart.name + today() ); if (!newName || newName.trim().length == 0) return; diff --git a/site/i18n.js b/site/i18n.js index 8e01aeb..7930972 100644 --- a/site/i18n.js +++ b/site/i18n.js @@ -15,6 +15,7 @@ * @type {Object.>} */ const translations = { + // sorted alphabetically cs: require("./locales/cs.json"), de: require("./locales/de.json"), en: require("./locales/en.json"), @@ -40,7 +41,8 @@ var currentLocale = defaultLocale; * @param {string} locale */ function setLocale(locale) { - if (locales.indexOf(locale) != -1) { + if (locales.includes(locale)) { + console.log("Locale changed to " + locale); currentLocale = locale; return true; } @@ -90,4 +92,5 @@ exports.setLocale = setLocale; exports.defaultLocale = defaultLocale; exports.locales = locales; exports.translate = translate; +exports.__ = translate; exports.translateWithLocale = translateWithLocale; diff --git a/site/index.js b/site/index.js index b2315ec..dc4b4d3 100644 --- a/site/index.js +++ b/site/index.js @@ -1,3 +1,24 @@ +const i18n = require("./i18n"); + +// Process redirects and localization before running anything else +(() => { + if (location.href.includes("heissepreise.github.io")) { + location.href = "https://heisse-preise.io"; + return; + } + + // Find the most preferred supported language + for (const langCode of navigator.languages) { + // We don't do regional codes, so take just the language code + let lang = langCode.length >= 2 ? langCode.substring(0, 2) : null; + if (lang == null) continue; + if (i18n.locales.includes(lang)) { + i18n.setLocale(lang); + break; + } + } +})(); + const { getQueryParameter } = require("./js/misc"); const model = require("./model"); require("./views"); @@ -6,24 +27,7 @@ const { STORE_KEYS } = require("./model/stores"); const { ProgressBar } = require("./views/progress-bar"); const progressBar = new ProgressBar(STORE_KEYS.length); -const i18n = require("./i18n"); - (async () => { - if (location.href.includes("heissepreise.github.io")) { - location.href = "https://heisse-preise.io"; - } - - // Find the most preferred supported language - for (const langCode in navigator.languages) { - // We don't do regional codes, so take just the language code - let lang = langCode.length >= 2 ? langCode.substring(0, 2) : null; - if (lang == null) continue; - if (i18n.locales.indexOf(lang) != -1) { - i18n.setLocale(lang); - break; - } - } - await model.load(() => progressBar.addStep()); const itemsFilter = document.querySelector("items-filter"); const itemsList = document.querySelector("items-list"); diff --git a/site/js/misc.js b/site/js/misc.js index c226a94..045cc69 100644 --- a/site/js/misc.js +++ b/site/js/misc.js @@ -305,7 +305,11 @@ exports.itemsToCSV = (items) => { */ exports.numberToLocale = (number) => { try { - return number.toLocaleString("at-DE", { minimumFractionDigits: 0, maximumFractionDigits: 2 }); + let locale = "at-DE"; + if (navigator) { + locale = navigator.language; + } + return number.toLocaleString(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } catch (e) { return number; } diff --git a/site/locales/cs.json b/site/locales/cs.json index e216620..8cbd8d2 100644 --- a/site/locales/cs.json +++ b/site/locales/cs.json @@ -1,6 +1,6 @@ { "Heisse Preise": "Zlevněno?", - "__page_description__": "Nekomerční open source projekt umožňující spotřebitelům najít nejlevnější verzi produktu v obchodech.", + "page_description": "Nekomerční open source projekt umožňující spotřebitelům najít nejlevnější verzi produktu v obchodech.", "Einstellungen": "Nastavení", "Impressum": "Impressum", "Logs": "Logy", @@ -32,5 +32,68 @@ "Produktsuche": "Vyhledávání produktů", "Radio & Fernsehen": "Rádio & Televize", - "Print & Online": "Tisk & Online" + "Print & Online": "Tisk & Online", + + "CartsList_Name": "Název", + "CartsList_Produkte": "Produkt", + "CartsList_Preis": "Cena", + + "CartsList_Preisänderungen": "Změny cen", + "CartsList_Teilen": "Sdílet", + "CartsList_JSON": "JSON", + "CartsList_Löschen": "Smazat", + + "ItemsChart_Keine Daten ausgewählt": "Nejsou vybrána žádná data", + "ItemsChart_Preissumme Gesamt": "Celková cena", + "ItemsChart_Preissumme Ketten": "Cena řetězce", + "ItemsChart_Nur heutige Preise": "Cena dnes", + "ItemsChart_Änderung in % seit": "Změna v % od", + "ItemsChart_Änderung in % seit {{date}}": "Změna v % od {{date}}", + "ItemsChart_Preissumme {{s}}": "Cena {{s}}", + + "ItemsFilter_Produkte suchen...": "Vyhledávání produktů...", + "ItemsFilter_Filter anzeigen/ausblenden": "Zobrazit/skrýt filtry", + "ItemsFilter_Alle": "Vše", + "ItemsFilter_Datum": "Datum", + "ItemsFilter_Billiger seit letzter Änderung": "Levnější od poslední změny", + "ItemsFilter_Nur Diskont-Eigenmarken": "Pouze privátní značky", + "ItemsFilter_Nur Bio": "Pouze bio", + "ItemsFilter_Exaktes Wort": "Přesná slova", + "ItemsFilter_Preis €": "Cena €", + "ItemsFilter_Teurer": "Dražší", + "ItemsFilter_Billiger": "Levnější", + + "ItemsList_Resultate": "Výsledky", + "ItemsList_Diagramm": "Graf", + "ItemsList_Verkaufspreis": "Prodejní cena", + "ItemsList_Mengenpreis": "Měrná cena", + "ItemsList_Sortieren": "Seřadit podle", + "ItemsList_Preis aufsteigend": "Cena vzestupně", + "ItemsList_Preis absteigend": "Cena sestupně", + "ItemsList_Menge aufsteigend": "Množství vzsetupně", + "ItemsList_Menge absteigend": "Množství sestupně", + "ItemsList_Kette & Name": "Řetězec & název", + "ItemsList_Namensähnlichkeit": "Podobnost jména", + "ItemsList_Kette": "Řetězec", + "ItemsList_Name": "Název", + "ItemsList_Preis": "Cena", + + "Cart_Teilen": "Sdílet", + "Cart_Speichern": "Uložit", + "Cart_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben": "Nákupní košík '{{name}}' již existuje. Zadejte prosím jiný název", + "Cart_Warenkorb '{{name}}' existiert nicht.": "Nákupní košík '{{name}}' neexistuje.", + "Cart_Artikel": "Položka", + + "Carts_Name für Warenkorb eingeben:": "Zadejte název nákupního košíku:", + "Carts_Warenkorb mit Namen '{{name}}' existiert bereits": "Nákupní košík se jménem '{{name}}' již existuje", + "Carts_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu importierenden Warenkorb eingeben": "Nákupní košík '{{name}}' již existuje. Zadejte prosím jiný název pod který se nákupní košík importuje", + + "Settings_Vorselektierte Ketten": "Předvybrané řetězce", + "Settings_Start-Datum für Diagramme": "Počáteční datum pro grafy", + "Settings_Diagramm Typ": "Typ grafu", + "Settings_Stufen": "Schodový", + "Settings_Linien": "Čárový", + "Settings_Nur verfügbare Produkte anzeigen": "Zobrazit pouze dostupné produkty", + "Settings_Diagramm immer anzeigen (wenn verfügbar)": "Vždy zobrazovat graf (pokud je k dispozici)", + "Settings_Suche immer anzeigen (wenn verfügbar)": "Vždy zobrazit vyhledávání (je-li k dispozici)" } diff --git a/site/locales/de.json b/site/locales/de.json index 07015b9..8db43b5 100644 --- a/site/locales/de.json +++ b/site/locales/de.json @@ -1,6 +1,6 @@ { "Heisse Preise": "Heisse Preise", - "__page_description__": "Nicht-kommerzielles Open-Source-Projekt um KonsumentInnen es zu ermöglichen, die günstigste Variante eines Produktes im Handel ausfindig zu machen.", + "page_description": "Nicht-kommerzielles Open-Source-Projekt um KonsumentInnen es zu ermöglichen, die günstigste Variante eines Produktes im Handel ausfindig zu machen.", "Einstellungen": "Einstellungen", "Impressum": "Impressum", "Logs": "Logs", @@ -32,5 +32,68 @@ "Produktsuche": "Produktsuche", "Radio & Fernsehen": "Radio & Fernsehen", - "Print & Online": "Print & Online" + "Print & Online": "Print & Online", + + "CartsList_Name": "Name", + "CartsList_Produkte": "Produkte", + "CartsList_Preis": "Preis", + + "CartsList_Preisänderungen": "Preisänderungen", + "CartsList_Teilen": "Teilen", + "CartsList_JSON": "JSON", + "CartsList_Löschen": "Löschen", + + "ItemsChart_Keine Daten ausgewählt": "Keine Daten ausgewählt", + "ItemsChart_Preissumme Gesamt": "Preissumme Gesamt", + "ItemsChart_Preissumme Ketten": "Preissumme Ketten", + "ItemsChart_Nur heutige Preise": "Nur heutige Preise", + "ItemsChart_Änderung in % seit": "Änderung in % seit", + "ItemsChart_Änderung in % seit {{date}}": "Änderung in % seit {{date}}", + "ItemsChart_Preissumme {{s}}": "Preissumme {{s}}", + + "ItemsFilter_Produkte suchen...": "Produkte suchen...", + "ItemsFilter_Filter anzeigen/ausblenden": "Filter anzeigen/ausblenden", + "ItemsFilter_Alle": "Alle", + "ItemsFilter_Datum": "Datum", + "ItemsFilter_Billiger seit letzter Änderung": "Billiger seit letzter Änderung", + "ItemsFilter_Nur Diskont-Eigenmarken": "Nur Diskont-Eigenmarken", + "ItemsFilter_Nur Bio": "Nur Bio", + "ItemsFilter_Exaktes Wort": "Exaktes Wort", + "ItemsFilter_Preis €": "Preis €", + "ItemsFilter_Teurer": "Teurer", + "ItemsFilter_Billiger": "Billiger", + + "ItemsList_Resultate": "Resultate", + "ItemsList_Diagramm": "Diagramm", + "ItemsList_Verkaufspreis": "Verkaufspreis", + "ItemsList_Mengenpreis": "Mengenpreis", + "ItemsList_Sortieren": "Sortieren", + "ItemsList_Preis aufsteigend": "Preis aufsteigend", + "ItemsList_Preis absteigend": "Preis absteigend", + "ItemsList_Menge aufsteigend": "Menge aufsteigend", + "ItemsList_Menge absteigend": "Menge absteigend", + "ItemsList_Kette & Name": "Kette & Name", + "ItemsList_Namensähnlichkeit": "Namensähnlichkeit", + "ItemsList_Kette": "Kette", + "ItemsList_Name": "Name", + "ItemsList_Preis": "Preis", + + "Cart_Teilen": "Teilen", + "Cart_Speichern": "Speichern", + "Cart_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben": "Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben", + "Cart_Warenkorb '{{name}}' existiert nicht.": "Warenkorb '{{name}}' existiert nicht.", + "Cart_Artikel": "Artikel", + + "Carts_Name für Warenkorb eingeben:": "Name für Warenkorb eingeben:", + "Carts_Warenkorb mit Namen '{{name}}' existiert bereits": "Warenkorb mit Namen '{{name}}' existiert bereits", + "Carts_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu importierenden Warenkorb eingeben": "Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu importierenden Warenkorb eingeben", + + "Settings_Vorselektierte Ketten": "Vorselektierte Ketten", + "Settings_Start-Datum für Diagramme": "Start-Datum für Diagramme", + "Settings_Diagramm Typ": "Diagramm Typ", + "Settings_Stufen": "Stufen", + "Settings_Linien": "Linien", + "Settings_Nur verfügbare Produkte anzeigen": "Nur verfügbare Produkte anzeigen", + "Settings_Diagramm immer anzeigen (wenn verfügbar)": "Diagramm immer anzeigen (wenn verfügbar)", + "Settings_Suche immer anzeigen (wenn verfügbar)": "Suche immer anzeigen (wenn verfügbar)" } diff --git a/site/locales/en.json b/site/locales/en.json index acc6f27..fd808cd 100644 --- a/site/locales/en.json +++ b/site/locales/en.json @@ -1,10 +1,10 @@ { "Heisse Preise": "Hot Prices", - "__page_description__": "Non-commercial open source project to enable consumers to find the cheapest version of a product in stores.", + "page_description": "Non-commercial open source project to enable consumers to find the cheapest version of a product in stores.", "Einstellungen": "Settings", "Impressum": "Imprint", "Logs": "Logs", - "Historische Daten von": "Histoic data from", + "Historische Daten von": "Historic data from", "Alle Angaben ohne Gewähr, Irrtümer vorbehalten.": "All information provided without guarantee, errors excepted.", "Markennamen und Warenzeichen sind Eigentum der jeweiligen Inhaber.": "Brand names and trademarks are the property of their respective owners.", "Suche": "Search", @@ -32,5 +32,68 @@ "Produktsuche": "Product search", "Radio & Fernsehen": "Radio & Television", - "Print & Online": "Print & Online" + "Print & Online": "Print & Online", + + "CartsList_Name": "Name", + "CartsList_Produkte": "Product", + "CartsList_Preis": "Price", + + "CartsList_Preisänderungen": "Price changes", + "CartsList_Teilen": "Share", + "CartsList_JSON": "JSON", + "CartsList_Löschen": "Delete", + + "ItemsChart_Keine Daten ausgewählt": "No data selected", + "ItemsChart_Preissumme Gesamt": "Total price", + "ItemsChart_Preissumme Ketten": "Store price", + "ItemsChart_Nur heutige Preise": "Price today", + "ItemsChart_Änderung in % seit": "Change in % since", + "ItemsChart_Änderung in % seit {{date}}": "Change in % since {{date}}", + "ItemsChart_Preissumme {{s}}": "Price {{s}}", + + "ItemsFilter_Produkte suchen...": "Product search...", + "ItemsFilter_Filter anzeigen/ausblenden": "Show/hide filters", + "ItemsFilter_Alle": "All", + "ItemsFilter_Datum": "Date", + "ItemsFilter_Billiger seit letzter Änderung": "Cheaper since last change", + "ItemsFilter_Nur Diskont-Eigenmarken": "Private brands only", + "ItemsFilter_Nur Bio": "Only bio", + "ItemsFilter_Exaktes Wort": "Exact word", + "ItemsFilter_Preis €": "Price €", + "ItemsFilter_Teurer": "More expensive", + "ItemsFilter_Billiger": "Cheaper", + + "ItemsList_Resultate": "Results", + "ItemsList_Diagramm": "Chart", + "ItemsList_Verkaufspreis": "Unit price", + "ItemsList_Mengenpreis": "Bulk price", + "ItemsList_Sortieren": "Sort by", + "ItemsList_Preis aufsteigend": "Price ascending", + "ItemsList_Preis absteigend": "Price descending", + "ItemsList_Menge aufsteigend": "Quantity ascending", + "ItemsList_Menge absteigend": "Quantity descending", + "ItemsList_Kette & Name": "Store chain & name", + "ItemsList_Namensähnlichkeit": "Name similarity", + "ItemsList_Kette": "Store chain", + "ItemsList_Name": "Name", + "ItemsList_Preis": "Price", + + "Cart_Teilen": "Share", + "Cart_Speichern": "Save", + "Cart_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben": "Shopping cart '{{name}}' already exists. Please enter a different name for the shopping cart to be saved", + "Cart_Warenkorb '{{name}}' existiert nicht.": "Shopping cart '{{name}}' does not exist.", + "Cart_Artikel": "Item", + + "Carts_Name für Warenkorb eingeben:": "Enter name for shopping cart:", + "Carts_Warenkorb mit Namen '{{name}}' existiert bereits": "Shopping cart with name '{{name}}' already exists", + "Carts_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu importierenden Warenkorb eingeben": "Shopping cart '{{name}}' already exists. Please enter a different name for the shopping cart to be imported", + + "Settings_Vorselektierte Ketten": "Pre-selected store chains", + "Settings_Start-Datum für Diagramme": "Start date for charts", + "Settings_Diagramm Typ": "Chart type", + "Settings_Stufen": "Stepped", + "Settings_Linien": "Lines", + "Settings_Nur verfügbare Produkte anzeigen": "Show only available products", + "Settings_Diagramm immer anzeigen (wenn verfügbar)": "Always show chart (when available)", + "Settings_Suche immer anzeigen (wenn verfügbar)": "Always show search (when available)" } diff --git a/site/settings.js b/site/settings.js index bfaccd3..7dfe840 100644 --- a/site/settings.js +++ b/site/settings.js @@ -3,13 +3,14 @@ const { View } = require("./views/view"); const { Settings } = require("./model/settings"); require("./js/misc"); require("./views/custom-checkbox"); +const { __ } = require("./i18n"); class SettingsView extends View { constructor() { super(); this.innerHTML = /*html*/ `
-
Vorselektierte Ketten
+
${__("Settings_Vorselektierte Ketten")}
${STORE_KEYS.map( (store) => /*html*/ ` @@ -22,7 +23,7 @@ class SettingsView extends View { ).join("")}
- Start-Datum für Diagramme + ${__("Settings_Start-Datum für Diagramme")}
- Diagramm Typ + ${__("Settings_Diagramm Typ")}
- - - + + +
`; this.setupEventHandlers(); diff --git a/site/views/carts-list.js b/site/views/carts-list.js index 75f9c67..6f1735d 100644 --- a/site/views/carts-list.js +++ b/site/views/carts-list.js @@ -1,5 +1,6 @@ const { downloadJSON, dom } = require("../js/misc"); const { View } = require("./view"); +const { __ } = require("../i18n"); class CartsList extends View { constructor() { @@ -8,9 +9,9 @@ class CartsList extends View { - - - + + + @@ -26,18 +27,20 @@ class CartsList extends View { ` diff --git a/site/views/items-chart.js b/site/views/items-chart.js index 7750e3a..83c9dcb 100644 --- a/site/views/items-chart.js +++ b/site/views/items-chart.js @@ -1,6 +1,7 @@ const { STORE_KEYS } = require("../model/stores"); const { settings } = require("../model"); const { today, log, deltaTime, uniqueDates, calculateItemPriceTimeSeries } = require("../js/misc"); +const { __ } = require("../i18n"); const { View } = require("./view"); require("./custom-checkbox"); const moment = require("moment"); @@ -19,13 +20,13 @@ class ItemsChart extends View { }">
- +
- - - - + + + +
@@ -137,7 +138,7 @@ class ItemsChart extends View { let yAxis = { ticks: { callback: function (value, index, ticks) { - return value.toLocaleString("de-DE", { + return value.toLocaleString(navigator.language || "de-DE", { minimumFractionDigits: 2, style: "currency", currency: "EUR", @@ -149,7 +150,7 @@ class ItemsChart extends View { yAxis = { title: { display: true, - text: "Änderung in % seit " + startDate, + text: __("ItemsChart_Änderung in % seit {{date}}", { date: startDate }), }, ticks: { callback: (value) => { @@ -212,7 +213,7 @@ class ItemsChart extends View { if (elements.sumTotal.checked && items.length > 0) { const now = performance.now(); itemsToShow.push({ - name: "Preissumme Gesamt", + name: __("ItemsChart_Preissumme Gesamt"), priceHistory: this.calculateOverallPriceChanges(items, onlyToday, percentageChange, startDate, endDate), }); log("ItemsChart - Calculating overall sum total " + ((performance.now() - now) / 1000).toFixed(2) + " secs"); @@ -224,7 +225,7 @@ class ItemsChart extends View { const storeItems = items.filter((item) => item.store === store); if (storeItems.length > 0) { itemsToShow.push({ - name: "Preissumme " + store, + name: __("ItemsChart_Preissumme {{s}}", { s: store }), priceHistory: this.calculateOverallPriceChanges(storeItems, onlyToday, percentageChange, startDate, endDate), }); } diff --git a/site/views/items-filter.js b/site/views/items-filter.js index 7cc669f..c42f2a4 100644 --- a/site/views/items-filter.js +++ b/site/views/items-filter.js @@ -3,6 +3,7 @@ const { stores, STORE_KEYS, BUDGET_BRANDS } = require("../model/stores"); const { fromCategoryCode, categories } = require("../model/categories"); const { settings } = require("../model"); const { View } = require("./view"); +const { __ } = require("../i18n"); class ItemsFilter extends View { constructor() { @@ -21,7 +22,9 @@ class ItemsFilter extends View { const hideStores = this._filterByStores ? "" : "hidden"; const hideMisc = this._filterByMisc ? "" : "hidden"; const hideAvailableObption = this._availableOption ? "" : "hidden"; - const placeholder = this.hasAttribute("placeholder") ? this.getAttribute("placeholder") : "Produkte suchen... (min. 3 Zeichen)"; + const placeholder = this.hasAttribute("placeholder") + ? this.getAttribute("placeholder") + : __("ItemsFilter_Produkte suchen...") + " " + __("(min. 3 Zeichen)"); this.innerHTML = /*html*/ ` ${ @@ -29,7 +32,7 @@ class ItemsFilter extends View { ? `
@@ -43,9 +46,9 @@ class ItemsFilter extends View {
- store.defaultChecked) ? "checked" : "" - }> + store.defaultChecked) ? "checked" : "" + }> ${STORE_KEYS.map( (store) => /*html*/ `
- - + +
- - + +
@@ -67,9 +70,9 @@ class ItemsList extends View {
- Produkte: + ${__("CartsList_Produkte")}: - Preisänderungen: + ${__("CartsList_Preisänderungen")}:
- - - + + + @@ -106,7 +109,7 @@ class ItemsList extends View { this._showAllPriceHistories = false; elements.expandPriceHistories.addEventListener("click", () => { const showAll = (this._showAllPriceHistories = !this._showAllPriceHistories); - elements.expandPriceHistories.innerText = showAll ? "Preis ▲" : "Preis ▼"; + elements.expandPriceHistories.innerText = __("ItemsList_Preis") + (showAll ? " ▲" : " ▼"); elements.tableBody.querySelectorAll(".priceinfo").forEach((el) => (showAll ? el.classList.remove("hidden") : el.classList.add("hidden"))); }); From 3b9fc43161b8b3a15dc46db754b54aa64fc3cd2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Pol=C3=A1k?= <456647@muni.cz> Date: Sun, 17 Sep 2023 19:48:29 +0200 Subject: [PATCH 4/4] Localization fixes --- bundle.js | 7 +++-- site/i18n.js => i18n.js | 35 ----------------------- {site/locales => locales}/cs.json | 3 +- {site/locales => locales}/de.json | 1 + {site/locales => locales}/en.json | 3 +- server.js | 2 +- site/browser_i18n.js | 46 +++++++++++++++++++++++++++++++ site/cart.js | 2 +- site/carts.js | 2 +- site/index.js | 25 ++++------------- site/settings.js | 2 +- site/views/carts-list.js | 2 +- site/views/items-chart.js | 2 +- site/views/items-filter.js | 2 +- site/views/items-list.js | 2 +- 15 files changed, 69 insertions(+), 67 deletions(-) rename site/i18n.js => i18n.js (67%) rename {site/locales => locales}/cs.json (98%) rename {site/locales => locales}/de.json (98%) rename {site/locales => locales}/en.json (98%) create mode 100644 site/browser_i18n.js diff --git a/bundle.js b/bundle.js index fbe5dc7..c33c3f1 100644 --- a/bundle.js +++ b/bundle.js @@ -4,7 +4,7 @@ const chokidar = require("chokidar"); const esbuild = require("esbuild"); const { exec } = require("child_process"); const { promisify } = require("util"); -const i18n = require("./site/i18n"); +const i18n = require("./i18n"); function deleteDirectory(directory) { if (fs.existsSync(directory)) { @@ -64,16 +64,18 @@ function processFile(inputFile, outputFile, filter) { const replacedData = replaceFileContents(data, path.dirname(inputFile), locale); if (locale == i18n.defaultLocale) { fs.writeFileSync(outputFile, replacedData); + console.log(`${inputFile} -> ${outputFile}`); } let pathWithLanguageCode = outputFile.substring(0, outputFile.length - extension.length) + "." + locale + extension; fs.writeFileSync(pathWithLanguageCode, replacedData); + console.log(`${inputFile} -> ${pathWithLanguageCode}`); } } else { const data = fs.readFileSync(inputFile); if (filter(inputFile, false, data)) return; fs.writeFileSync(outputFile, data); + console.log(`${inputFile} -> ${outputFile}`); } - console.log(`${inputFile} -> ${outputFile}`); } /** @@ -167,6 +169,7 @@ async function bundle(inputDir, outputDir, watch) { bundleHTML(inputDir, outputDir, false, watch, (filePath, isDir, data) => { if (isDir) return false; if (filePath.endsWith("style.css")) return true; + if (filePath.includes("/locales/")) return true; if (filePath.endsWith(".js") && !filePath.includes("socket.io.js")) return true; if (data.includes(`require("`)) return true; return false; diff --git a/site/i18n.js b/i18n.js similarity index 67% rename from site/i18n.js rename to i18n.js index 7930972..7427e13 100644 --- a/site/i18n.js +++ b/i18n.js @@ -29,38 +29,6 @@ const locales = Object.keys(translations); */ const defaultLocale = "de"; -/** - * The currently selected locale. - * @type {string} - */ -var currentLocale = defaultLocale; - -/** - * Set the globally used locale. - * Expects a 2 character language code string, one from locales. - * @param {string} locale - */ -function setLocale(locale) { - if (locales.includes(locale)) { - console.log("Locale changed to " + locale); - currentLocale = locale; - return true; - } - console.error("Attempted to setLocale to unsupported language: ", locale); - return false; -} - -/** - * Translates the key using the current global locale. - * - * @param {!string} key to translate - * @param {!Object.} [args] arguments to substitute into the translated key - * @returns {string} translated string - */ -function translate(key, args) { - return translateWithLocale(currentLocale, key, args); -} - /** * @param {!string} locale name of the language to use for translation, MUST be one of the supported languages * @param {!string} key to translate @@ -88,9 +56,6 @@ function translateWithLocale(locale, key, args) { return translation; } -exports.setLocale = setLocale; exports.defaultLocale = defaultLocale; exports.locales = locales; -exports.translate = translate; -exports.__ = translate; exports.translateWithLocale = translateWithLocale; diff --git a/site/locales/cs.json b/locales/cs.json similarity index 98% rename from site/locales/cs.json rename to locales/cs.json index 8cbd8d2..7c20ee3 100644 --- a/site/locales/cs.json +++ b/locales/cs.json @@ -78,8 +78,9 @@ "ItemsList_Name": "Název", "ItemsList_Preis": "Cena", - "Cart_Teilen": "Sdílet", + "Cart_Teilen": "Detail", "Cart_Speichern": "Uložit", + "Cart_Warenkorb {{name}}": "Nákupní košík {{name}}", "Cart_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben": "Nákupní košík '{{name}}' již existuje. Zadejte prosím jiný název", "Cart_Warenkorb '{{name}}' existiert nicht.": "Nákupní košík '{{name}}' neexistuje.", "Cart_Artikel": "Položka", diff --git a/site/locales/de.json b/locales/de.json similarity index 98% rename from site/locales/de.json rename to locales/de.json index 8db43b5..5ef9db8 100644 --- a/site/locales/de.json +++ b/locales/de.json @@ -80,6 +80,7 @@ "Cart_Teilen": "Teilen", "Cart_Speichern": "Speichern", + "Cart_Warenkorb {{name}}": "Warenkorb {{name}}", "Cart_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben": "Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben", "Cart_Warenkorb '{{name}}' existiert nicht.": "Warenkorb '{{name}}' existiert nicht.", "Cart_Artikel": "Artikel", diff --git a/site/locales/en.json b/locales/en.json similarity index 98% rename from site/locales/en.json rename to locales/en.json index fd808cd..fa2d33c 100644 --- a/site/locales/en.json +++ b/locales/en.json @@ -78,8 +78,9 @@ "ItemsList_Name": "Name", "ItemsList_Preis": "Price", - "Cart_Teilen": "Share", + "Cart_Teilen": "Detail", "Cart_Speichern": "Save", + "Cart_Warenkorb {{name}}": "Shopping cart {{name}}", "Cart_Warenkorb '{{name}}' existiert bereits. Bitte einen anderen Namen für den zu speichernden Warenkorb eingeben": "Shopping cart '{{name}}' already exists. Please enter a different name for the shopping cart to be saved", "Cart_Warenkorb '{{name}}' existiert nicht.": "Shopping cart '{{name}}' does not exist.", "Cart_Artikel": "Item", diff --git a/server.js b/server.js index 4fe21d0..985fcd9 100644 --- a/server.js +++ b/server.js @@ -7,7 +7,7 @@ const csv = require("./site/js/misc"); const chokidar = require("chokidar"); const express = require("express"); const compression = require("compression"); -const i18n = require("./site/i18n"); +const i18n = require("./i18n"); function copyItemsToSite(dataDir) { const items = analysis.readJSON(`${dataDir}/latest-canonical.json.${analysis.FILE_COMPRESSOR}`); diff --git a/site/browser_i18n.js b/site/browser_i18n.js new file mode 100644 index 0000000..539e4a3 --- /dev/null +++ b/site/browser_i18n.js @@ -0,0 +1,46 @@ +const i18n = require("../i18n"); + +/** + * The currently selected locale. + * @type {?string} + */ +var currentLocale = i18n.defaultLocale; + +/** + * Set the globally used locale. + * Expects a 2 character language code string, one from locales. + * @param {string} locale + */ +function setLocale(locale) { + if (i18n.locales.includes(locale)) { + console.log("Locale changed to " + locale); + currentLocale = locale; + return true; + } + console.error("Attempted to setLocale to unsupported language: ", locale); + return false; +} + +/** + * Translates the key using the current global locale. + * + * @param {!string} key to translate + * @param {!Object.} [args] arguments to substitute into the translated key + * @returns {string} translated string + */ +function translate(key, args) { + return i18n.translateWithLocale(currentLocale, key, args); +} + +// Find the most preferred supported language +for (const langCode of navigator.languages) { + // We don't do regional codes, so take just the language code + let lang = langCode.length >= 2 ? langCode.substring(0, 2) : null; + if (lang == null) continue; + if (i18n.locales.includes(lang)) { + setLocale(lang); + break; + } +} + +exports.__ = translate; diff --git a/site/cart.js b/site/cart.js index 85763aa..64253f6 100644 --- a/site/cart.js +++ b/site/cart.js @@ -6,7 +6,7 @@ const { STORE_KEYS, stores } = require("./model/stores"); require("./views"); const { ProgressBar } = require("./views/progress-bar"); const progressBar = new ProgressBar(STORE_KEYS.length); -const { __ } = require("./i18n"); +const { __ } = require("./browser_i18n"); let carts = null; diff --git a/site/carts.js b/site/carts.js index 50877a3..d137926 100644 --- a/site/carts.js +++ b/site/carts.js @@ -1,7 +1,7 @@ const { downloadJSON, today } = require("./js/misc"); const model = require("./model"); require("./views"); -const { __ } = require("./i18n"); +const { __ } = require("./browser_i18n"); const { STORE_KEYS } = require("./model/stores"); const { ProgressBar } = require("./views/progress-bar"); diff --git a/site/index.js b/site/index.js index dc4b4d3..f384e24 100644 --- a/site/index.js +++ b/site/index.js @@ -1,23 +1,8 @@ -const i18n = require("./i18n"); - -// Process redirects and localization before running anything else -(() => { - if (location.href.includes("heissepreise.github.io")) { - location.href = "https://heisse-preise.io"; - return; - } - - // Find the most preferred supported language - for (const langCode of navigator.languages) { - // We don't do regional codes, so take just the language code - let lang = langCode.length >= 2 ? langCode.substring(0, 2) : null; - if (lang == null) continue; - if (i18n.locales.includes(lang)) { - i18n.setLocale(lang); - break; - } - } -})(); +// Process redirects before running anything else +if (location.href.includes("heissepreise.github.io")) { + location.href = "https://heisse-preise.io"; + return; +} const { getQueryParameter } = require("./js/misc"); const model = require("./model"); diff --git a/site/settings.js b/site/settings.js index 7dfe840..778007c 100644 --- a/site/settings.js +++ b/site/settings.js @@ -3,7 +3,7 @@ const { View } = require("./views/view"); const { Settings } = require("./model/settings"); require("./js/misc"); require("./views/custom-checkbox"); -const { __ } = require("./i18n"); +const { __ } = require("./browser_i18n"); class SettingsView extends View { constructor() { diff --git a/site/views/carts-list.js b/site/views/carts-list.js index 6f1735d..9ccfd6c 100644 --- a/site/views/carts-list.js +++ b/site/views/carts-list.js @@ -1,6 +1,6 @@ const { downloadJSON, dom } = require("../js/misc"); const { View } = require("./view"); -const { __ } = require("../i18n"); +const { __ } = require("../browser_i18n"); class CartsList extends View { constructor() { diff --git a/site/views/items-chart.js b/site/views/items-chart.js index 83c9dcb..3d5f2fe 100644 --- a/site/views/items-chart.js +++ b/site/views/items-chart.js @@ -1,7 +1,7 @@ const { STORE_KEYS } = require("../model/stores"); const { settings } = require("../model"); const { today, log, deltaTime, uniqueDates, calculateItemPriceTimeSeries } = require("../js/misc"); -const { __ } = require("../i18n"); +const { __ } = require("../browser_i18n"); const { View } = require("./view"); require("./custom-checkbox"); const moment = require("moment"); diff --git a/site/views/items-filter.js b/site/views/items-filter.js index c42f2a4..6c9377c 100644 --- a/site/views/items-filter.js +++ b/site/views/items-filter.js @@ -3,7 +3,7 @@ const { stores, STORE_KEYS, BUDGET_BRANDS } = require("../model/stores"); const { fromCategoryCode, categories } = require("../model/categories"); const { settings } = require("../model"); const { View } = require("./view"); -const { __ } = require("../i18n"); +const { __ } = require("../browser_i18n"); class ItemsFilter extends View { constructor() { diff --git a/site/views/items-list.js b/site/views/items-list.js index 551111d..84999d3 100644 --- a/site/views/items-list.js +++ b/site/views/items-list.js @@ -14,7 +14,7 @@ const { vectorizeItems, similaritySortItems } = require("../js/knn"); const { stores } = require("../model/stores"); const { View } = require("./view"); const { ItemsChart } = require("./items-chart"); -const { __ } = require("../i18n"); +const { __ } = require("../browser_i18n"); class ItemsList extends View { static priceTypeId = 0;