Merge pull request #149 from Darkyenus/localization

Localization
This commit is contained in:
Mario Zechner 2023-09-19 20:55:50 +02:00 committed by GitHub
commit 14f4b40b3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 648 additions and 138 deletions

75
bundle.js Executable file → Normal file
View File

@ -4,6 +4,7 @@ const chokidar = require("chokidar");
const esbuild = require("esbuild");
const { exec } = require("child_process");
const { promisify } = require("util");
const i18n = require("./i18n");
function deleteDirectory(directory) {
if (fs.existsSync(directory)) {
@ -19,38 +20,71 @@ 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) => {
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);
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);
} 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}`);
}
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);
@ -135,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;

61
i18n.js Normal file
View File

@ -0,0 +1,61 @@
// 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.
*
* @type {Object.<string, Object.<string, string>>}
*/
const translations = {
// sorted alphabetically
cs: require("./locales/cs.json"),
de: require("./locales/de.json"),
en: require("./locales/en.json"),
};
/**
* @type {string[]}
*/
const locales = Object.keys(translations);
/**
* @type {string}
*/
const defaultLocale = "de";
/**
* @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.<string, string>} [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 ", locale, ": ", key);
if (locale != defaultLocale) {
translation = translations[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.defaultLocale = defaultLocale;
exports.locales = locales;
exports.translateWithLocale = translateWithLocale;

100
locales/cs.json Normal file
View File

@ -0,0 +1,100 @@
{
"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",
"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 &amp; Name": "Řetězec &amp; název",
"ItemsList_Namensähnlichkeit": "Podobnost jména",
"ItemsList_Kette": "Řetězec",
"ItemsList_Name": "Název",
"ItemsList_Preis": "Cena",
"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",
"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)"
}

100
locales/de.json Normal file
View File

@ -0,0 +1,100 @@
{
"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",
"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 &amp; Name": "Kette &amp; Name",
"ItemsList_Namensähnlichkeit": "Namensähnlichkeit",
"ItemsList_Kette": "Kette",
"ItemsList_Name": "Name",
"ItemsList_Preis": "Preis",
"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",
"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)"
}

100
locales/en.json Normal file
View File

@ -0,0 +1,100 @@
{
"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": "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",
"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",
"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 &amp; Name": "Store chain &amp; name",
"ItemsList_Namensähnlichkeit": "Name similarity",
"ItemsList_Kette": "Store chain",
"ItemsList_Name": "Name",
"ItemsList_Preis": "Price",
"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",
"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)"
}

View File

@ -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("./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,45 @@ 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}`);

View File

@ -1,8 +1,8 @@
<footer>
<div class="flex align-center justify-center gap-2 pt-4">
<a class="font-medium" href="settings.html">Einstellungen</a>
<a class="font-medium" href="imprint.html">Impressum</a>
<a class="font-medium" href="data/log.txt">Logs</a>
<a class="font-medium" href="settings.html">__Einstellungen__</a>
<a class="font-medium" href="imprint.html">__Impressum__</a>
<a class="font-medium" href="data/log.txt">__Logs__</a>
<a href="https://twitter.com/badlogicgames" style="width: 24px;" aria-label="twitter.com/badlogicgames"><svg style="padding-top: 2px;" viewBox="328 355 335 276" xmlns="http://www.w3.org/2000/svg">
<path d="
M 630, 425
@ -25,11 +25,11 @@
<a href="https://github.com/badlogic/heissepreise" aria-label="github.com/badlogic/heissepreise"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg></a>
</div>
<div class="flex align-center justify-center gap-2 pt-4 pb-2">
<span>Historische Daten von <a href="http://h.43z.one" class="font-medium">@h43z</a> &amp; <a href="https://www.dossier.at/dossiers/supermaerkte/quellen/anatomie-eines-supermarkts-die-methodik/" class="font-medium">Dossier</a></span>
<span>__Historische Daten von__ <a href="http://h.43z.one" class="font-medium">@h43z</a> &amp; <a href="https://www.dossier.at/dossiers/supermaerkte/quellen/anatomie-eines-supermarkts-die-methodik/" class="font-medium">Dossier</a></span>
</div>
<small class="text-center mb-6">
<p>Alle Angaben ohne Gewähr, Irrtümer vorbehalten. <br />
Markennamen und Warenzeichen sind Eigentum der jeweiligen Inhaber.
<p>__Alle Angaben ohne Gewähr, Irrtümer vorbehalten.__<br />
__Markennamen und Warenzeichen sind Eigentum der jeweiligen Inhaber.__
</p>
</small>
</footer>

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Nicht-kommerzielles Open-Source-Projekt um KonsumentInnen es zu ermöglichen, die günstigste Variante eines Produktes im Handel ausfindig zu machen." />
<title>Heisse Preise</title>
<meta name="description" content="__page_description__" />
<title>__Heisse Preise__</title>
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%2210 0 100 100%22><text y=%22.90em%22 font-size=%2290%22>🔥</text></svg>"
@ -15,4 +15,4 @@
<body class="w-full min-h-full flex flex-col">
<div class="max-w-6xl mx-auto lg:py-4 flex flex-col flex-1 w-full">
<h2 class="bg-primary lg:rounded-t-xl text-center text-white p-3 uppercase text-2xl font-bold">🔥 Heisse Preise 🔥</h2>
<h2 class="bg-primary lg:rounded-t-xl text-center text-white p-3 uppercase text-2xl font-bold">🔥 __Heisse Preise__ 🔥</h2>

View File

@ -1,5 +1,5 @@
<div class="flex justify-center gap-4 py-2 font-bold text-primary border border-primary/50 lg:rounded-b-xl">
<a href="index.html">Suche</a>
<a href="changes.html">Preisänderungen</a>
<a href="carts.html">Warenkörbe</a>
<a href="index.html">__Suche__</a>
<a href="changes.html">__Preisänderungen__</a>
<a href="carts.html">__Warenkörbe__</a>
</div>

46
site/browser_i18n.js Normal file
View File

@ -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.<string, string>} [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;

View File

@ -4,13 +4,20 @@
<cart-header x-id="cartHeader"></cart-header>
<div x-id="noItems" class="hidden block text-center my-3">
Noch keine Produkte im Warenkorb.<br />
Produkte suchen und mit '+' zum Warenkorb hinzufügen.
__Noch keine Produkte im Warenkorb.__<br />
__Produkte suchen und mit '+' zum Warenkorb hinzufügen.__
</div>
<items-filter x-id="cartFilter" stores nochartclear class="hidden" placeholder="Filtern... (min. 3 Zeichen)"></items-filter>
<items-filter x-id="cartFilter" stores nochartclear class="hidden" placeholder="__Filtern...__ __(min. 3 Zeichen)__"></items-filter>
<items-list x-id="linkedCartList" chart json nosort class="hidden"></items-list>
<items-list x-id="cartList" chart json nosort remove updown class="hidden"></items-list>
<items-filter x-id="productsFilter" class="hidden" stores misc nochartclear placeholder="Produkt hinzufügen... (min. 3 Zeichen)"></items-filter>
<items-filter
x-id="productsFilter"
class="hidden"
stores
misc
nochartclear
placeholder="__Produkt hinzufügen...__ (__min. 3 Zeichen__)"
></items-filter>
<items-list x-id="productsList" class="hidden" add></items-list>
%%_templates/_loader.html%%
</div>

View File

@ -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("./browser_i18n");
let carts = null;
@ -35,8 +36,12 @@ class CartHeader extends View {
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">
<span x-id="name"></span>
</h1>
<a x-id="share" class="hidden cursor-pointer font-bold text-sm text-primary hover:underline block text-center mt-3">Teilen</a>
<input x-id="save" class="hidden cursor-pointer font-bold text-sm text-primary block mx-auto mt-3" type="button" value="Speichern">
<a x-id="share" class="hidden cursor-pointer font-bold text-sm text-primary hover:underline block text-center mt-3">${__(
"Cart_Teilen"
)}</a>
<input x-id="save" class="hidden cursor-pointer font-bold text-sm text-primary block mx-auto mt-3" type="button" value="${__(
"Cart_Speichern"
)}">
`;
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 = "<strong>Artikel:</strong>";
cartList.elements.numItemsLabel.innerHTML = `<strong>${__("Cart_Artikel")}:</strong>`;
cartList.elements.enableChart.checked = models.items.length < 2000;
cartList.elements.chart.elements.sumStores.checked = models.items.length < 2000;

View File

@ -2,11 +2,11 @@
<div class="w-full relative px-4 flex-1">
<div class="max-w-3xl mx-auto">
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">Warenkörbe</h1>
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">__Warenkörbe__</h1>
<div class="px-4 py-2 my-4 text-sm border rounded-xl md:mt-8 md:rounded-b-none md:mb-0 bg-gray-100 flex gap-4">
<input type="button" id="new" value="Neuer Warenkorb" class="text-primary font-medium hover:underline cursor-pointer" />
<input type="button" id="export" value="Exportieren" class="text-primary font-medium hover:underline cursor-pointer" />
<input type="button" id="import" value="Importieren" class="text-primary font-medium hover:underline cursor-pointer" />
<input type="button" id="new" value="__Neuer Warenkorb__" class="text-primary font-medium hover:underline cursor-pointer" />
<input type="button" id="export" value="__Exportieren__" class="text-primary font-medium hover:underline cursor-pointer" />
<input type="button" id="import" value="__Importieren__" class="text-primary font-medium hover:underline cursor-pointer" />
</div>
<carts-list id="carts" class="carts-list w-full"></carts-list>
%%_templates/_loader.html%%

View File

@ -1,17 +1,18 @@
const { downloadJSON, today } = require("./js/misc");
const model = require("./model");
require("./views");
const { __ } = require("./browser_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;

View File

@ -1,8 +1,8 @@
%%_templates/_header.html%% %%_templates/_menu.html%%
<div class="w-full relative px-4 flex-1">
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">Preisänderungen</h1>
<items-filter x-id="items-filter" pricechanges pricedirection stores misc placeholder="Filtern... (min. 3 Zeichen)"></items-filter>
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">__Preisänderungen__</h1>
<items-filter x-id="items-filter" pricechanges pricedirection stores misc placeholder="__Filtern...__ __(min. 3 Zeichen)__"></items-filter>
<items-list chart></items-list>
%%_templates/_loader.html%%
</div>

View File

@ -1,14 +1,16 @@
%%_templates/_header.html%% %%_templates/_menu.html%%
<div class="max-w-lg mx-auto my-8 px-4">
<h1 class="text-2xl font-bold">Impressum</h1>
<h1 class="text-2xl font-bold">__Impressum__</h1>
<p class="mt-4">
<strong>Angaben gemäß § 25 Mediengesetz (Österreich)</strong><br /><br />
<b>Medieninhaber:</b> Mario Zechner<br />
<b>Kontakt:</b> <a href="mailto:badlogicgames@gmail.com">badlogicgames@gmail.com</a><br />
<b>Adresse:</b> Schörgelgasse 3, 8010 Graz, Österreich
<b>__Medieninhaber__:</b> Mario Zechner<br />
<b>__Kontakt__:</b> <a href="mailto:badlogicgames@gmail.com">badlogicgames@gmail.com</a><br />
<b>__Adresse__:</b> Schörgelgasse 3, 8010 Graz, Österreich
</p>
<p class="mt-4">
__Diese nicht-kommerzielle Seite dient KonsumentInnen dazu, Preise von Produkten im Lebensmittelhandel vergleichen zu können.__
</p>
<p class="mt-4">Diese nicht-kommerzielle Seite dient KonsumentInnen dazu, Preise von Produkten im Lebensmittelhandel vergleichen zu können.</p>
</div>
%%_templates/_footer.html%%

View File

@ -2,11 +2,13 @@
<div class="w-full max-w-5-xl relative px-4 flex-1">
<div class="flex flex-row w-full justify-center font-bold text-primary gap-4 mt-4">
<a href="https://www.youtube.com/watch?v=2u-T85yMKGI">Video Anleitung</a>
<a href="https://docs.google.com/document/d/1Q5OWJOICXjSzTEIHBZgJl1p3FsiWFO0lzXIuwGbXBck/edit?usp=sharing" target="_blank">Text Anleitung</a>
<a href="./media.html">Medienberichte</a>
<a href="https://www.youtube.com/watch?v=2u-T85yMKGI">__Video Anleitung__</a>
<a href="https://docs.google.com/document/d/1Q5OWJOICXjSzTEIHBZgJl1p3FsiWFO0lzXIuwGbXBck/edit?usp=sharing" target="_blank"
>__Text Anleitung__</a
>
<a href="./media.html">__Medienberichte__</a>
</div>
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">Produktsuche</h1>
<h1 class="text-2xl font-bold pb-2 pt-8 text-center">__Produktsuche__</h1>
<items-filter x-id="items-filter" emptyquery stores misc></items-filter>
<items-list share json chart></items-list>
%%_templates/_loader.html%%

View File

@ -1,3 +1,9 @@
// 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");
require("./views");
@ -7,10 +13,6 @@ const { ProgressBar } = require("./views/progress-bar");
const progressBar = new ProgressBar(STORE_KEYS.length);
(async () => {
if (location.href.includes("heissepreise.github.io")) {
location.href = "https://heisse-preise.io";
}
await model.load(() => progressBar.addStep());
const itemsFilter = document.querySelector("items-filter");
const itemsList = document.querySelector("items-list");

View File

@ -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;
}

View File

@ -1,9 +1,9 @@
%%_templates/_header.html%% %%_templates/_menu.html%%
<div class="mx-auto my-8 px-4">
<h2 class="text-2xl font-bold">Medienberichte</h2>
<h2 class="text-2xl font-bold">__Medienberichte__</h2>
<div class="flex flex-col mt-4 gap-4 text-primary">
<h3 class="text-xl font-bold text-black">Radio & Fernsehen</h3>
<h3 class="text-xl font-bold text-black">__Radio & Fernsehen__</h3>
<a href="https://twitter.com/badlogicgames/status/1661401581097570308"
>24.5.203 Österreichisches Parlament - Rede von Joachim Schnabel zum Thema Lebensmittelpreise, ÖVP</a
>
@ -42,7 +42,7 @@
>
<video src="https://marioslab.io/uploads/ZIB_2-REWE-%C3%96sterreich-Chef_%C3%BCber_hohe_Lebensmittelpreise-0220056522.mp4" controls></video>
<h3 class="text-xl font-bold text-black">Print & Online</h3>
<h3 class="text-xl font-bold text-black">__Print & Online__</h3>
<a href="https://www.puls24.at/news/politik/preisvergleich-politik-braucht-bis-herbst-twitter-user-2-stunden/297474"
>16.5.2023 PULS 24 - Preisvergleich: Politik braucht "bis Herbst", Twitter-User "2 Stunden"</a
>

View File

@ -1,7 +1,7 @@
%%_templates/_header.html%% %%_templates/_menu.html%%
<div class="max-w-lg mx-auto my-8 px-4">
<h2 class="text-2xl font-bold text-center">Einstellungen</h2>
<h2 class="text-2xl font-bold text-center">__Einstellungen__</h2>
<settings-view></settings-view>
</div>

View File

@ -3,13 +3,14 @@ const { View } = require("./views/view");
const { Settings } = require("./model/settings");
require("./js/misc");
require("./views/custom-checkbox");
const { __ } = require("./browser_i18n");
class SettingsView extends View {
constructor() {
super();
this.innerHTML = /*html*/ `
<div class="flex flex-col gap-4 p-4 rounded-xl md:mt-8 bg-gray-100">
<div>Vorselektierte Ketten</div>
<div>${__("Settings_Vorselektierte Ketten")}</div>
<div x-id="stores" class="flex justify-center gap-2 flex-wrap">
${STORE_KEYS.map(
(store) => /*html*/ `
@ -22,7 +23,7 @@ class SettingsView extends View {
).join("")}
</div>
<div class="flex flex-row gap-2">
Start-Datum für Diagramme
${__("Settings_Start-Datum für Diagramme")}
<input
x-id="startDate"
x-change
@ -31,15 +32,21 @@ class SettingsView extends View {
class="flex-grow cursor-pointer inline-flex items-center gap-x-1 rounded-full bg-white border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600">
</div>
<div class="flex flex-row gap-2">
Diagramm Typ
${__("Settings_Diagramm Typ")}
<select x-id="chartType" x-change x-state class="flex-grow">
<option value="stepped">Stufen</option>
<option value="lines">Linien</option>
<option value="stepped">${__("Settings_Stufen")}</option>
<option value="lines">${__("Settings_Linien")}</option>
</select>
</div>
<custom-checkbox x-id="onlyAvailable" x-state x-change label="Nur verfügbare Produkte anzeigen" checked></custom-checkbox>
<custom-checkbox x-id="stickyChart" x-state x-change label="Diagramm immer anzeigen (wenn verfügbar)" checked></custom-checkbox>
<custom-checkbox x-id="stickySearch" x-state x-change label="Suche immer anzeigen (wenn verfügbar)"></custom-checkbox>
<custom-checkbox x-id="onlyAvailable" x-state x-change label="${__(
"Settings_Nur verfügbare Produkte anzeigen"
)}" checked></custom-checkbox>
<custom-checkbox x-id="stickyChart" x-state x-change label="${__(
"Settings_Diagramm immer anzeigen (wenn verfügbar)"
)}" checked></custom-checkbox>
<custom-checkbox x-id="stickySearch" x-state x-change label="${__(
"Settings_Suche immer anzeigen (wenn verfügbar)"
)}"></custom-checkbox>
</div>
`;
this.setupEventHandlers();

View File

@ -1,5 +1,6 @@
const { downloadJSON, dom } = require("../js/misc");
const { View } = require("./view");
const { __ } = require("../browser_i18n");
class CartsList extends View {
constructor() {
@ -8,9 +9,9 @@ class CartsList extends View {
<table class="w-full">
<thead>
<tr class="bg-primary text-left hidden md:table-row uppercase text-sm text-white">
<th class="px-2">Name</th>
<th class="px-2">Produkte</th>
<th class="px-2">Preis</th>
<th class="px-2">${__("CartsList_Name")}</th>
<th class="px-2">${__("CartsList_Produkte")}</th>
<th class="px-2">${__("CartsList_Preis")}</th>
<th class="px-2"></th>
</tr>
</thead>
@ -26,18 +27,20 @@ class CartsList extends View {
<a x-id="name" class="hover:underline"></a>
</td>
<td class="px-2">
<span class="md:hidden text-sm">Produkte: </span>
<span class="md:hidden text-sm">${__("CartsList_Produkte")}: </span>
<span x-id="numProducts"></span>
</td>
<td class="px-2 col-span-2">
<span class="md:hidden text-sm">Preisänderungen: </span>
<span class="md:hidden text-sm">${__("CartsList_Preisänderungen")}: </span>
<span x-id="price"></span>
</td>
<td class="px-2 col-span-3">
<div class="flex gap-4">
<a x-id="share" class="text-primary hover:underline text-sm font-medium">Teilen</a>
<a x-id="json" class="text-primary hover:underline text-sm font-medium" href="">JSON</a>
<input x-id="delete" class="ml-auto text-red-500 hover:underline text-sm font-medium" type="button" value="Löschen">
<a x-id="share" class="text-primary hover:underline text-sm font-medium">${__("CartsList_Teilen")}</a>
<a x-id="json" class="text-primary hover:underline text-sm font-medium" href="">${__("CartsList_JSON")}</a>
<input x-id="delete" class="ml-auto text-red-500 hover:underline text-sm font-medium" type="button" value="${__(
"CartsList_Löschen"
)}">
</div>
</td>
`

View File

@ -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("../browser_i18n");
const { View } = require("./view");
require("./custom-checkbox");
const moment = require("moment");
@ -19,13 +20,13 @@ class ItemsChart extends View {
}">
<div class="w-full grow">
<canvas x-id="canvas" class="bg-white rounded-lg"></canvas>
<div x-id="noData" class="hidden flex items-center justify-center h-full">Keine Daten ausgewählt</div>
<div x-id="noData" class="hidden flex items-center justify-center h-full">${__("ItemsChart_Keine Daten ausgewählt")}</div>
</div>
<div class="filters flex items-center flex-wrap justify-center gap-2 pt-2">
<custom-checkbox x-id="sumTotal" x-change x-state label="Preissumme Gesamt"></custom-checkbox>
<custom-checkbox x-id="sumStores" x-change x-state label="Preissumme Ketten"></custom-checkbox>
<custom-checkbox x-id="onlyToday" x-change x-state label="Nur heutige Preise"></custom-checkbox>
<custom-checkbox x-id="percentageChange" x-change x-state label="Änderung in % seit"></custom-checkbox>
<custom-checkbox x-id="sumTotal" x-change x-state label="${__("ItemsChart_Preissumme Gesamt")}"></custom-checkbox>
<custom-checkbox x-id="sumStores" x-change x-state label="${__("ItemsChart_Preissumme Ketten")}"></custom-checkbox>
<custom-checkbox x-id="onlyToday" x-change x-state label="${__("ItemsChart_Nur heutige Preise")}"></custom-checkbox>
<custom-checkbox x-id="percentageChange" x-change x-state label="${__("ItemsChart_Änderung in % seit")}"></custom-checkbox>
<div
class="cursor-pointer inline-flex items-center gap-x-1 rounded-full bg-white border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600">
<input x-id="startDate" x-change x-state type="date" value="2017-01-01" />
@ -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),
});
}

View File

@ -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("../browser_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 {
? `
<div class="wrapper wrapper--search">
<input x-id="query" x-state x-input-debounce class="rounded-lg px-2 py-1 w-full" type="text" placeholder="${placeholder}" />
<label for="filter-toggle-search"><abbr title="Filter anzeigen/ausblenden">🎚</abbr></label>
<label for="filter-toggle-search"><abbr title="${__("ItemsFilter_Filter anzeigen/ausblenden")}">🎚</abbr></label>
</div>
<input class="toggle toggle--hidden" type="checkbox" id="filter-toggle-search" />
<div class="wrapper wrapper--sticky">
@ -43,9 +46,9 @@ class ItemsFilter extends View {
</div>
<div x-id="stores" class="flex justify-center gap-2 flex-wrap mt-4 ${hideStores}">
<custom-checkbox x-id="allStores" label="Alle" ${
Object.values(stores).every((store) => store.defaultChecked) ? "checked" : ""
}></custom-checkbox>
<custom-checkbox x-id="allStores" label="${__("ItemsFilter_Alle")}" ${
Object.values(stores).every((store) => store.defaultChecked) ? "checked" : ""
}></custom-checkbox>
${STORE_KEYS.map(
(store) => /*html*/ `
<custom-checkbox
@ -59,34 +62,34 @@ class ItemsFilter extends View {
<div x-id="priceChanges" class="text-sm flex justify-center gap-4 mt-4 ${hidePriceChanges}">
<label>
<input x-id="priceChangesToday" x-state type="radio" name="type" checked /> Datum
<input x-id="priceChangesToday" x-state type="radio" name="type" checked /> ${__("ItemsFilter_Datum")}
<select x-id="priceChangesDate" x-state x-change class="bg-white rounded-full">
<option>${today()}</option>
</select>
</label>
<label>
<input x-id="priceChangesCheaper" x-state type="radio" name="type" /> Billiger seit letzter Änderung
<input x-id="priceChangesCheaper" x-state type="radio" name="type" /> ${__("ItemsFilter_Billiger seit letzter Änderung")}
</label>
</div>
<div x-id="misc" class="flex items-center justify-center flex-wrap gap-2 mt-4 ${hideMisc}">
<custom-checkbox
x-id="budgetBrands" x-state x-change
label="Nur Diskont-Eigenmarken"
label="${__("ItemsFilter_Nur Diskont-Eigenmarken")}"
abbr="${BUDGET_BRANDS.map((budgetBrand) => budgetBrand.toUpperCase()).join(", ")}"
></custom-checkbox>
<custom-checkbox x-id="bio" x-state x-change label="Nur Bio"></custom-checkbox>
<custom-checkbox x-id="exact" x-state x-change label="Exaktes Wort"></custom-checkbox>
<custom-checkbox x-id="bio" x-state x-change label="${__("ItemsFilter_Nur Bio")}"></custom-checkbox>
<custom-checkbox x-id="exact" x-state x-change label="${__("ItemsFilter_Exaktes Wort")}"></custom-checkbox>
<label class="cursor-pointer inline-flex items-center gap-x-1 rounded-full bg-white border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600">
Preis <input x-id="minPrice" x-state x-input class="w-12" type="number" min="0" value="0">
${__("ItemsFilter_Preis €")} <input x-id="minPrice" x-state x-input class="w-12" type="number" min="0" value="0">
-
<input x-id="maxPrice" x-state x-input-debounce class="w-12" type="number" min="0" value="100">
</label>
</div>
<div x-id="priceDirection" class="flex justify-center gap-2 mt-4 ${this._hideMisc ? "" : "mb-4"} ${hidePriceDirection}">
<custom-checkbox x-id="priceIncreased" x-state x-change label="Teurer" checked class="gray"></custom-checkbox>
<custom-checkbox x-id="priceDecreased" x-state x-change label="Billiger" checked class="gray"></custom-checkbox>
<custom-checkbox x-id="priceIncreased" x-state x-change label="${__("ItemsFilter_Teurer")}" checked class="gray"></custom-checkbox>
<custom-checkbox x-id="priceDecreased" x-state x-change label="${__("ItemsFilter_Billiger")}" checked class="gray"></custom-checkbox>
</div>
<div x-id="categories" class="flex justify-center gap-2 flex-wrap mt-4 hidden">

View File

@ -14,6 +14,7 @@ const { vectorizeItems, similaritySortItems } = require("../js/knn");
const { stores } = require("../model/stores");
const { View } = require("./view");
const { ItemsChart } = require("./items-chart");
const { __ } = require("../browser_i18n");
class ItemsList extends View {
static priceTypeId = 0;
@ -34,32 +35,34 @@ class ItemsList extends View {
<div>
<div class="flex flex-col md:flex-row gap-2 items-center">
<div class="flex flex-row gap-2 items-center">
<span x-id="numItemsLabel">Resultate</span><span x-id="numItems"></span>
<span x-id="numItemsLabel">${__("ItemsList_Resultate")}</span><span x-id="numItems"></span>
<span>
<a x-id="json" class="hidden text-primary font-medium hover:underline" href="">JSON</a>
<a x-id="csv" class="hidden text-primary font-medium hover:underline" href="">CSV</a>
</span>
<custom-checkbox x-id="enableChart" x-change x-state label="Diagramm" class="${
this._chart ? "" : "hidden"
}"></custom-checkbox>
<custom-checkbox x-id="enableChart" x-change x-state label="${__("ItemsList_Diagramm")}" class="${
this._chart ? "" : "hidden"
}"></custom-checkbox>
</div>
<div class="flex flex-row gap-2 items-center">
<label><input x-id="salesPrice" x-change x-state type="radio" name="priceType${
ItemsList.priceTypeId
}" checked> Verkaufspreis</label>
<label><input x-id="unitPrice" x-change x-state type="radio" name="priceType${ItemsList.priceTypeId++}"> Mengenpreis</label>
<label><input x-id="salesPrice" x-change x-state type="radio" name="priceType${ItemsList.priceTypeId}" checked> ${__(
"ItemsList_Verkaufspreis"
)}</label>
<label><input x-id="unitPrice" x-change x-state type="radio" name="priceType${ItemsList.priceTypeId++}"> ${__(
"ItemsList_Mengenpreis"
)}</label>
</div>
</div>
</div>
<label class="${hideSort}">
Sortieren
${__("ItemsList_Sortieren")}
<select x-id="sort" x-change x-state>
<option value="price-asc">Preis aufsteigend</option>
<option value="price-desc">Preis absteigend</option>
<option value="quantity-asc">Menge aufsteigend</option>
<option value="quantity-desc">Menge absteigend</option>
<option value="store-and-name">Kette &amp; Name</option>
<option value="name-similarity" x-id="nameSimilarity" disabled>Namensähnlichkeit</option>
<option value="price-asc">${__("ItemsList_Preis aufsteigend")}</option>
<option value="price-desc">${__("ItemsList_Preis absteigend")}</option>
<option value="quantity-asc">${__("ItemsList_Menge aufsteigend")}</option>
<option value="quantity-desc">${__("ItemsList_Menge absteigend")}</option>
<option value="store-and-name">${__("ItemsList_Kette &amp; Name")}</option>
<option value="name-similarity" x-id="nameSimilarity" disabled>${__("ItemsList_Namensähnlichkeit")}</option>
</select>
</label>
</div>
@ -67,9 +70,9 @@ class ItemsList extends View {
<table x-id="itemsTable" class="hidden rounded-b-xl overflow-hidden w-full text-left">
<thead>
<tr class="bg-primary text-white md:table-row uppercase text-sm">
<th class="text-center">Kette</th>
<th>Name</th>
<th x-id="expandPriceHistories" class="cursor-pointer">Preis </th>
<th class="text-center">${__("ItemsList_Kette")}</th>
<th>${__("ItemsList_Name")}</th>
<th x-id="expandPriceHistories" class="cursor-pointer">${__("ItemsList_Preis")} </th>
<th></th>
</tr>
</thead>
@ -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")));
});