From 13b4ab301880cdc7422ea76b3428d1fb7f3212cf Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 12 Sep 2025 12:48:04 +0200 Subject: [PATCH] i18n: fix placeholder mismatch, add validation (#23541) --- i18n/ar.json | 8 +-- i18n/ca.json | 12 ++-- i18n/check.ts | 157 ++++++++++++++++++++++++++++++++++++++++++++++ i18n/hu.json | 2 +- i18n/lb.json | 4 +- i18n/nl.json | 4 +- i18n/no.json | 16 ++--- i18n/ro.json | 14 ++--- i18n/ru.json | 14 ++--- i18n/sl.json | 4 +- i18n/sv.json | 2 +- i18n/ta.json | 16 ++--- i18n/zh-Hans.json | 2 +- package-lock.json | 59 +++++++++++++++++ package.json | 6 +- 15 files changed, 269 insertions(+), 51 deletions(-) create mode 100644 i18n/check.ts diff --git a/i18n/ar.json b/i18n/ar.json index feeca7067..b56954d3c 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -2,16 +2,16 @@ "batterySettings": { "batteryLevel": "مستوى البطارية", "bufferStart": { - "full": "عندما أوشكت على الامتلاء", + "full": "عندما أوشكت على الامتلاء {soc}.", "never": "فقط مع فائض كافٍ" }, "legendBottomName": "أولوية المنزل", - "legendBottomSubline": "لا تستخدم للشحن", + "legendBottomSubline": "حتى تصل إلى {soc}.", "legendMiddleName": "السيارة أولا", - "legendMiddleSubline": "المنزل الثاني", + "legendMiddleSubline": "عندما تكون بطارية المنزل فوق {soc}.", "legendTopAutostart": "يبدأ تلقائيًا", "legendTopName": "بطارية تدعم الشحن", - "legendTopSubline": "بدون انقطاع", + "legendTopSubline": "عندما تكون بطارية المنزل فوق {soc}.", "modalTitle": "إعدادات البطارية" }, "config": { diff --git a/i18n/ca.json b/i18n/ca.json index d91fdf277..4244e74dd 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -2,19 +2,19 @@ "batterySettings": { "batteryLevel": "Nivell de bateria", "bufferStart": { - "full": "quan gairebé ple", + "full": "quan gairebé ple {soc}.", "never": "només amb prous excedents" }, "capacity": "{energy} de {total}", "disclaimerHint": "Nota:", "disclaimerText": "aquests paràmetres només afecten el mode solar. El comportament de càrrega s'ajusta en conseqüència.", "legendBottomName": "prioritat de la casa", - "legendBottomSubline": "no utilitzat per carregar", + "legendBottomSubline": "fins que arriba a {soc}.", "legendMiddleName": "primer, el vehicle", - "legendMiddleSubline": "segón, la casa", + "legendMiddleSubline": "quan la bateria de casa està per sobre {soc}.", "legendTopAutostart": "s'inicia automaticament", "legendTopName": "càrrega soportada per bateria", - "legendTopSubline": "sensa interrupcions", + "legendTopSubline": "quan la bateria de casa està per sobre {soc}.", "modalTitle": "Configuració bateria" }, "footer": { @@ -217,8 +217,8 @@ "pvEnable": "Excedent disponible. Començar en {remaining}...", "scale1p": "Reduint a càrrega monofàsica a {remaining}...", "scale3p": "Augmentant la càrrega trifàsica en {remaining}...", - "targetChargeActive": "Càrrega objectiu activa...", - "targetChargePlanned": "La càrrega objectiu comença a {time}.", + "targetChargeActive": "Càrrega objectiu activa. Finalització estimada en {duration}.", + "targetChargePlanned": "La càrrega objectiu comença en {duration}.", "targetChargeWaitForVehicle": "Càrrega objectiu llesta. Esperar vehicle.", "vehicleLimitReached": "Límit de vehicle {soc} assolit.", "waitForVehicle": "A punt per carregar. Esperant vehicle." diff --git a/i18n/check.ts b/i18n/check.ts new file mode 100644 index 000000000..3f9f6dd98 --- /dev/null +++ b/i18n/check.ts @@ -0,0 +1,157 @@ +#!/usr/bin/env tsx + +import { readFileSync, readdirSync, existsSync } from "fs"; +import { join, dirname, basename, extname, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Security: Validate that file path is within the i18n directory +function validateFilePath(filePath: string): string { + const resolvedPath = resolve(filePath); + const resolvedBaseDir = resolve(__dirname); + + if (!resolvedPath.startsWith(resolvedBaseDir)) { + throw new Error(`Path traversal attempt detected: ${filePath}`); + } + + // Ensure it's a JSON file + if (extname(resolvedPath) !== ".json") { + throw new Error(`Invalid file type: ${filePath}`); + } + + return resolvedPath; +} + +// Extract placeholders from a translation string (e.g., "{title}", "{soc}", etc.) +function extractPlaceholders(text: string): string[] { + const matches = text.match(/\{[^}]+\}/g); + if (!matches) return []; + + // Remove duplicates and sort + const unique = [...new Set(matches)]; + return unique.sort(); +} + +// Flatten nested translation object into dot-notation keys +function flattenObject(obj: any, prefix = ""): Record { + return Object.entries(obj).reduce((acc, [key, val]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (val && typeof val === "object") { + Object.assign(acc, flattenObject(val, path)); + } else if (typeof val === "string") { + acc[path] = val; + } + return acc; + }, {} as Record); +} + +// Compare placeholder arrays using Set equality for better performance +function placeholdersMatch(a: string[], b: string[]): boolean { + const setA = new Set(a); + const setB = new Set(b); + if (setA.size !== setB.size) return false; + for (const placeholder of setA) { + if (!setB.has(placeholder)) return false; + } + return true; +} + +// Load and parse JSON translation file safely +function loadTranslationFile(filePath: string): Record { + const validatedPath = validateFilePath(filePath); + const content = readFileSync(validatedPath, "utf-8"); + const parsed = JSON.parse(content); + return flattenObject(parsed); +} + +// Get all translation files in the directory safely +function getTranslationFiles(): string[] { + const files = readdirSync(__dirname) + .filter(file => { + // Only allow valid filename characters and JSON extension + return /^[a-zA-Z0-9_-]+\.json$/.test(file); + }) + .map(file => validateFilePath(join(__dirname, file))); + + return files; +} + +// Main validation function +function validateTranslations(): void { + console.log("🔍 Checking translation placeholder consistency..."); + + const sourceFile = validateFilePath(join(__dirname, "en.json")); + + if (!existsSync(sourceFile)) { + console.error(`❌ Source file not found: ${sourceFile}`); + process.exit(1); + } + + const sourceFlat = loadTranslationFile(sourceFile); + const translationFiles = getTranslationFiles() + .filter(file => basename(file) !== "en.json"); + + if (translationFiles.length === 0) { + console.log("ℹ️ No translation files to check."); + process.exit(0); + } + + const allErrors: Array<{file: string; key: string; expected: string[]; found: string[]}> = []; + + for (const file of translationFiles) { + const lang = basename(file, ".json"); + const targetFlat = loadTranslationFile(file); + + for (const [key, sourceStr] of Object.entries(sourceFlat)) { + const sourcePlaceholders = extractPlaceholders(sourceStr); + + // Only check if source has placeholders + if (sourcePlaceholders.length === 0) continue; + + // Skip if translation doesn't exist at all (fallback to English is fine) + if (!(key in targetFlat)) continue; + + const targetPlaceholders = extractPlaceholders(targetFlat[key]); + + if (!placeholdersMatch(sourcePlaceholders, targetPlaceholders)) { + allErrors.push({ + file: lang, + key, + expected: sourcePlaceholders, + found: targetPlaceholders + }); + } + } + } + + // Group errors by file and output + const errorsByFile = new Map(); + for (const error of allErrors) { + if (!errorsByFile.has(error.file)) { + errorsByFile.set(error.file, []); + } + errorsByFile.get(error.file)!.push(error); + } + + for (const [lang, errors] of errorsByFile) { + console.log(`\n📄 i18n/${lang}.json (${errors.length} errors)`); + for (const error of errors) { + console.log(` ${error.key}`); + console.log(` expected: ${error.expected.join(", ")}`); + console.log(` found: ${error.found.join(", ") || "(none)"}`); + } + } + + if (allErrors.length > 0) { + console.log(`\n💡 ${allErrors.length} placeholder errors found.`); + process.exit(1); + } else { + console.log("✅ All translations have correct placeholders!"); + process.exit(0); + } +} + +// Run the validation +validateTranslations(); \ No newline at end of file diff --git a/i18n/hu.json b/i18n/hu.json index 6d8dbf2b2..ac944c74e 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -429,7 +429,7 @@ "addToken": "Token beírása", "changeToken": "Token megváltoztatása", "description": "A szponzorációs modell segít a projekt fenntartásában és az új izgalmas funkciók bevezetésében. Szponzorként teljes hozzáférést kapsz az összes töltőberendezés implementációjához.", - "descriptionToken": "A tokent innen kapja: {url}. A teszteléshez próba tokent is kínálunk. ", + "descriptionToken": "A tokent innen kapja: {url}. A teszteléshez {trialToken} tokent is kínálunk.", "enterYourToken": "Add meg a tokenedet", "error": "A szponzor token nem érvényes.", "invalid": "érvénytelen", diff --git a/i18n/lb.json b/i18n/lb.json index f1dde49df..1303a89d0 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -825,7 +825,7 @@ "onlyInPvMode": "Luedplanifikatioun ass nëmmen am PV-Modus aktiv.", "planDuration": "Dauer vum Oplueden", "planPeriodLabel": "Zäitraum", - "planPeriodValue": "{Start} bis {end}", + "planPeriodValue": "{start} bis {end}", "planUnknown": "nach net bekannt", "preview": "Virschau vun der Planifikatioun", "priceLimit": "Präisgrenz vu {price}", @@ -890,7 +890,7 @@ "scale1p": "Reduktioun op eng eenzeg Phase.", "scale3p": "Erhéije geschwënn op dräi Phasen.", "targetChargeActive": "Luedplanung aktiv. Ageschate Schluss an {duration}.", - "targetChargePlanned": "Luedpladung fänkt un an {time} un.", + "targetChargePlanned": "Luedpladung fänkt un an {duration} un.", "targetChargeWaitForVehicle": "Luedplang ass prett. Waarden op d'Gefier…", "vehicleLimit": "Limitt vum Gefier", "vehicleLimitReached": "Limitt vum Gefier erreecht.", diff --git a/i18n/nl.json b/i18n/nl.json index d1bbed271..87450be12 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -429,7 +429,7 @@ "addToken": "Token invoeren", "changeToken": "Token bewerken", "description": "Het sponsormodel helpt ons het project te onderhouden en nieuwe toffe functies te ontwikkelen. Als sponsor krijg je toegang tot de implementaties voor alle laadpunten.", - "descriptionToken": "Sponsors vinden hun token op {url}. Om te starten, bieden we een ", + "descriptionToken": "Sponsors vinden hun token op {url}. Om te starten, bieden we een {trialToken}.", "enterYourToken": "Typ hier je token", "error": "De sponsortoken is ongeldig.", "invalid": "ongeldig", @@ -693,7 +693,7 @@ "modalTitle": "Laad Plan", "none": "geen", "planNumber": "Schema {number}", - "preconditionDescription": "Laad(duur) voor vertrek voor accu voorbereiding.", + "preconditionDescription": "Laad {duration} voor vertrek voor accu voorbereiding.", "preconditionLong": "Laat Opladen", "preconditionOptionAll": "alles", "preconditionOptionNo": "nee", diff --git a/i18n/no.json b/i18n/no.json index a813a8de3..e42d271a2 100644 --- a/i18n/no.json +++ b/i18n/no.json @@ -2,19 +2,19 @@ "batterySettings": { "batteryLevel": "Ladenivå", "bufferStart": { - "full": "når det er nesten fullt", + "full": "når det er på {soc}.", "never": "bare med nok PV-overskudd" }, "capacity": "{energy} av {total}", "disclaimerHint": "Merk:", "disclaimerText": "Kun relevant i PV-modus. Ladeadferden justeres deretter.", "legendBottomName": "Hjemmelading har prioritet", - "legendBottomSubline": "ikke bruk til lading", + "legendBottomSubline": "til det når {soc}.", "legendMiddleName": "kjøretøy først", - "legendMiddleSubline": "hjemme etterpå", + "legendMiddleSubline": "når hjemmebatteriet er over {soc}.", "legendTopAutostart": "starter automatisk", "legendTopName": "batteriassistert lading", - "legendTopSubline": "uten avbrudd", + "legendTopSubline": "når hjemmebatteriet er over {soc}.", "modalTitle": "Batteriinnstillinger" }, "config": { @@ -66,7 +66,7 @@ "footerLong": "{percent} solcelleenergi", "footerShort": "{percent} solenergi", "modalTitle": "Oversikt over ladeenergi", - "percentGrid": "{self} kWh lysnett", + "percentGrid": "{grid} kWh lysnett", "percentSelf": "{self} kWt sol", "percentTitle": "Solenergi", "priceTitle": "Kraftpris", @@ -253,8 +253,8 @@ "pvEnable": "Overskudd tilgjengelig. Starter om {remaining}...", "scale1p": "Reduserer til 1-fase strøm i {remaining}...", "scale3p": "Øker til 3-fase strøm i {remaining}...", - "targetChargeActive": "Målladning aktiv ...", - "targetChargePlanned": "Mållading starter på {time}.", + "targetChargeActive": "Målladning aktiv. Estimert ferdig om {duration}.", + "targetChargePlanned": "Mållading starter på {duration}.", "targetChargeWaitForVehicle": "Mållading klar. Venter på kjøretøy...", "vehicleLimitReached": "Kjøretøygrense på {soc} nådd.", "waitForVehicle": "Klar. Venter på kjøretøy..." @@ -298,7 +298,7 @@ "odometer": "Kilometerstand", "vehicle": "Kjøretøy" }, - "csvPeriod": "Last ned (måned) CSV", + "csvPeriod": "Last ned {period} CSV", "csvTotal": "Last ned total CSV", "date": "Start", "energy": "Oppladet", diff --git a/i18n/ro.json b/i18n/ro.json index f82e052ac..22a56b826 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -3,7 +3,7 @@ "batteryLevel": "Nivelul bateriei", "bufferStart": { "above": "cand e peste {soc}", - "full": "cand atinge starea de incarcare", + "full": "când atinge {soc}.", "never": "numai cand exista surplus suficient" }, "capacity": "{energy} din {total}", @@ -12,12 +12,12 @@ "disclaimerHint": "Nota:", "disclaimerText": "Aceste setari afecteaza doar modul solar. Procesul de incarcare este ajustat corespunzator", "legendBottomName": "prioritate casa", - "legendBottomSubline": "neutilizat pentru incarcare", + "legendBottomSubline": "până când atinge {soc}.", "legendMiddleName": "in primul rand vehiculul", - "legendMiddleSubline": "in al doilea rand casa", + "legendMiddleSubline": "când bateria casei e peste {soc}.", "legendTopAutostart": "porneste automat", "legendTopName": "Incarcare ajutata de baterie", - "legendTopSubline": "fara intreruperi", + "legendTopSubline": "când bateria casei e peste {soc}.", "modalTitle": "Setari bateriei" }, "config": { @@ -278,7 +278,7 @@ "descriptionEnergy": "Pâna când ar trebui ca {targetEnergy} să fie încărcată in vehicul?", "descriptionSoc": "Când ar trebui vehiculul să fie încărcat la {targetSoc}?", "inactiveLabel": "Timp dorit", - "notReachableInTime": "Telul nu a fost atins la timp. Estimare: {endTime}.", + "notReachableInTime": "Telul nu a fost atins la timp. Estimare: {overrun}.", "onlyInPvMode": "Planul de incarcare merge doar in mod Solar.", "planDuration": "Timp de încărcare", "planPeriodLabel": "Perioada", @@ -335,8 +335,8 @@ "pvEnable": "Surplus FV disponibil. Pornesc în {remaining}…", "scale1p": "Reduc încărcarea la faza 1 în {remaining}…", "scale3p": "Schimbam încărcarea la 3 faze în {remaining}…", - "targetChargeActive": "Încarcare țintă activă…", - "targetChargePlanned": "Încărcarea pornește la {time}.", + "targetChargeActive": "Încărcare țintă activă. Terminare estimată în {duration}.", + "targetChargePlanned": "Încărcarea pornește la {duration}.", "targetChargeWaitForVehicle": "Încărcare terminată. Aștept după vehicul…", "vehicleLimitReached": "Limita {soc} a fost atinsă.", "waitForVehicle": "Pregătit. Aștept după vehicul…" diff --git a/i18n/ru.json b/i18n/ru.json index 589ad3470..e3c274a4c 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -2,7 +2,7 @@ "batterySettings": { "batteryLevel": "Уровень заряда батареи", "bufferStart": { - "full": "когда почти полная", + "full": "когда на {soc}.", "never": "только при достаточном избытке" }, "capacity": "{energy} из {total}", @@ -11,12 +11,12 @@ "disclaimerHint": "Примечание:", "disclaimerText": "Эти настройки влияют только на режим солнечной батареи. Поведение зарядки изменяется соответствующе.", "legendBottomName": "домашний приоритет", - "legendBottomSubline": "не используется для зарядки", + "legendBottomSubline": "пока не достигнет {soc}.", "legendMiddleName": "сначала автомобиль", - "legendMiddleSubline": "дом вторым", + "legendMiddleSubline": "когда домашняя батарея выше {soc}.", "legendTopAutostart": "запускается автоматически", "legendTopName": "зарядка с поддержкой от аккумулятора", - "legendTopSubline": "без перебоев", + "legendTopSubline": "когда домашняя батарея выше {soc}.", "modalTitle": "Настройки батареи" }, "config": { @@ -48,7 +48,7 @@ "footerLong": "{percent} солнечной энергии", "footerShort": "{percent} солнечной", "modalTitle": "Обзор потребленной электроэнергии", - "percentGrid": "{self} кВт/ч из сети", + "percentGrid": "{grid} кВт/ч из сети", "percentSelf": "{self} кВт/ч солнечной", "percentTitle": "Солнечная энергия", "priceTitle": "Цена электроэнергии", @@ -175,8 +175,8 @@ "pvEnable": "Доступен избыток. Начинаю через {remaining}...", "scale1p": "Снижаю до одной фазы через {remaining}...", "scale3p": "Увеличиваю до трех фаз через {remaining}...", - "targetChargeActive": "Плановая зарядка активна...", - "targetChargePlanned": "Плановая зарядка начнется в {time}.", + "targetChargeActive": "Плановая зарядка активна. Ориентировочное завершение через {duration}.", + "targetChargePlanned": "Плановая зарядка начнется в {duration}.", "targetChargeWaitForVehicle": "Плановая зарядка готова. Ожидаю транспортное средство...", "waitForVehicle": "Готов. Ожидание транспортного средства..." }, diff --git a/i18n/sl.json b/i18n/sl.json index 5b9d0e07c..c0f83de31 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -481,7 +481,7 @@ "today": "Danes", "tomorrow": "Jutri" }, - "solarAdjust": "Prilagodite sončno napoved na podlagi dejanskih podatkov o proizvodnji.", + "solarAdjust": "Prilagodite sončno napoved na podlagi dejanskih podatkov o proizvodnji {percent}.", "type": { "co2": "CO₂", "price": "Cena", @@ -729,7 +729,7 @@ "scale1p": "Zmanjšanje na 1-fazno napajanje kmalu...", "scale3p": "Povečanje na 3-fazno napajanje kmalu...", "targetChargeActive": "Ciljno polnjenje je aktivno, predviden konec {duration}.", - "targetChargePlanned": "Ciljno polnjenje se začne ob {time}.", + "targetChargePlanned": "Ciljno polnjenje se začne čez {duration}.", "targetChargeWaitForVehicle": "Ciljno polnjenje je pripravljeno. Čakam na vozilo ...", "vehicleLimit": "Omejitev vozila", "vehicleLimitReached": "Omejitev vozila dosežena.", diff --git a/i18n/sv.json b/i18n/sv.json index ea8b52e9b..ea9339b03 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -822,7 +822,7 @@ "goalReached": "Målet är uppnått", "inactiveLabel": "Mål-tid", "nextPlan": "Nästa plan", - "notReachableInTime": "Laddmål kommer att nås {endTime} senare.", + "notReachableInTime": "Laddmål kommer att nås {overrun} senare.", "onlyInPvMode": "Laddplan fungerar enbart i sol-läge.", "planDuration": "Laddtid", "planPeriodLabel": "Period", diff --git a/i18n/ta.json b/i18n/ta.json index f18e0cad5..8a8950f80 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -3,7 +3,7 @@ "batteryLevel": "பேட்டரி நிலை", "bufferStart": { "above": "மேலே {soc}.", - "full": "போது {soc at இல் இருக்கும்போது.", + "full": "{soc} இல் இருக்கும்போது.", "never": "போதுமான உபரி மட்டுமே." }, "capacity": "{energy} இன் {total} \"", @@ -277,7 +277,7 @@ "thresholdDisableLabel": "கட்டம் சக்தியை முடக்கு", "thresholdEnableDelayLabel": "தாமதத்தை இயக்கவும்", "thresholdEnableHelpInvalid": "எதிர்மறை மதிப்பைப் பயன்படுத்தவும்.", - "thresholdEnableHelpNegative": "{surplus} உபரி {தாமதத்திற்கு கிடைக்கும்போது தொடங்குங்கள்.", + "thresholdEnableHelpNegative": "{surplus} உபரி {delay} தாமதத்திற்கு கிடைக்கும்போது தொடங்குங்கள்.", "thresholdEnableHelpZero": "{delay} க்கு குறைந்தபட்ச தேவையான சக்தியை நிறைவு செய்யும்போது தொடங்கவும்.", "thresholdEnableLabel": "கட்டம் சக்தியை இயக்கவும்", "titleAdd": { @@ -429,7 +429,7 @@ "addToken": "ஒப்புரவாளர் கிள்ளாக்கை உள்ளிடவும்", "changeToken": "ஒப்புரவாளர் கிள்ளாக்கை மாற்றவும்", "description": "ஒப்புரவாளர் மாதிரி திட்டத்தை பராமரிக்கவும் புதிய மற்றும் அற்புதமான அம்சங்களை நிலையானதாக உருவாக்கவும் உதவுகிறது. ஒரு ச்பான்சராக நீங்கள் அனைத்து சார்சர் செயலாக்கங்களுக்கும் அணுகலைப் பெறுவீர்கள்.", - "descriptionToken": "நீங்கள் {url இருந்து இலிருந்து கிள்ளாக்கைப் பெறுவீர்கள். சோதனைக்கு ஒரு சோதனை டோக்கனையும் நாங்கள் வழங்குகிறோம்.", + "descriptionToken": "ஸ்பான்சர்கள் {url} இலிருந்து டோக்கனைப் பெறுவீர்கள். தொடங்குவதற்கு {trialToken} வழங்குகிறோம்.", "error": "ஒப்புரவாளர் கிள்ளாக்கு செல்லுபடியாகாது.", "labelToken": "ஒப்புரவாளர் கிள்ளாக்கு", "title": "ச்பான்சர்சிப்", @@ -530,7 +530,7 @@ "greenShareSub1": "வழங்கிய ஆற்றல்", "greenShareSub2": "சூரிய மற்றும் பேட்டரி சேமிப்பு", "power": "கட்டணம் வசூலிக்கும் ஆற்றல்", - "powerSub1": "{செயலில் உள்ள வாடிக்கையாளர்கள் {activeClients} பங்கேற்பாளர்கள்", + "powerSub1": "{activeClients} of {totalClients} பங்கேற்பாளர்கள்", "powerSub2": "சார்சிங்…", "tabTitle": "நேரடி சமூகம்" }, @@ -684,7 +684,7 @@ "modalTitle": "மின்சேர்வி திட்டம்", "none": "எதுவுமில்லை", "planNumber": "திட்டம் {number}", - "preconditionDescription": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {காலம் {காலம்.", + "preconditionDescription": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.", "preconditionLong": "தாமதமாக சார்சிங்", "preconditionOptionAll": "எல்லாம்", "preconditionOptionNo": "இல்லை", @@ -864,7 +864,7 @@ "feedinPriorityNextStart": "அதிக தீவன விகிதங்கள் {duration} இல் தொடங்குகின்றன.", "feedinPriorityPausing": "சூரிய சார்ச் ஊட்டத்தை அதிகரிக்க இடைநிறுத்தப்பட்டது.", "finished": "முடிந்தது.", - "minCharge": "{Soc} க்கு குறைந்தஅளவு கட்டணம்.", + "minCharge": "{soc} க்கு குறைந்தஅளவு கட்டணம்.", "pvDisable": "போதுமான உபரி இல்லை. விரைவில் இடைநிறுத்துதல்.", "pvEnable": "உபரி கிடைக்கிறது. விரைவில் தொடங்குகிறது.", "scale1p": "விரைவில் 1-கட்ட கட்டணம் வசூலிப்பதைக் குறைத்தல்.", @@ -1055,7 +1055,7 @@ "priceLabel": "ஆற்றல் விலை", "priceLimit": "விலை வரம்பு", "resetAction": "வரம்பை அகற்று", - "resetWarning": "மாறும் கட்டம் விலை அல்லது கோ-மூல கட்டமைக்கப்படவில்லை. இருப்பினும், {வரம்பு of இன் வரம்பு இன்னும் உள்ளது. உங்கள் உள்ளமைவை தூய்மை செய்யவா?", + "resetWarning": "மாறும் கிரிட் விலை அல்லது CO₂ மூலம் கட்டமைக்கப்படவில்லை. ஆனால், {limit} வரம்பு இன்னும் உள்ளது. உங்கள் கட்டமைவை சுத்தம் செய்யவா?", "saved": "சேமிக்கப்பட்டது." }, "smartFeedInPriority": { @@ -1063,7 +1063,7 @@ "description": "இலாபகரமான கட்டம் ஊட்டத்திற்கு முன்னுரிமை அளிக்க அதிக விலையில் கட்டணம் வசூலிப்பதை இடைநிறுத்துகிறது.", "priceLabel": "தீவன வீதம்", "priceLimit": "தீவன வரம்பு", - "resetWarning": "மாறும் ஃபீட்-இன் கட்டணம் எதுவும் கட்டப்படவில்லை. இருப்பினும், {வரம்பு of இன் வரம்பு இன்னும் உள்ளது. உங்கள் உள்ளமைவை தூய்மை செய்யவா?", + "resetWarning": "மாறும் ஃபீட்-இன் டாரிஃப் கட்டமைக்கப்படவில்லை. ஆனால், {limit} வரம்பு இன்னும் உள்ளது. உங்கள் கட்டமைவை சுத்தம் செய்யவா?", "title": "தீவன முன்னுரிமை" }, "startupError": { diff --git a/i18n/zh-Hans.json b/i18n/zh-Hans.json index c1bb92428..1379cd2d6 100644 --- a/i18n/zh-Hans.json +++ b/i18n/zh-Hans.json @@ -794,7 +794,7 @@ "scale1p": "准备切换为单相充电。", "scale3p": "准备切换为三相充电。", "targetChargeActive": "充电计划已激活。预计在 {duration} 内完成。", - "targetChargePlanned": "充电计划将于 {duration} 后开始({time})。", + "targetChargePlanned": "充电计划将于 {duration} 后开始。", "targetChargeWaitForVehicle": "充电计划准备就绪。等待车辆连接…", "vehicleLimit": "车辆充电上限", "vehicleLimitReached": "已达到车辆充电上限 {soc}。", diff --git a/package-lock.json b/package-lock.json index d4daa6068..5c9b91f2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "rollup-plugin-visualizer": "^6.0.3", "storybook": "^10.0.0-beta.2", "terser": "^5.44.0", + "tsx": "^4.20.5", "typescript": "^5.9.2", "typescript-eslint-language-service": "^5.0.5", "vite": "^7.1.5", @@ -6579,6 +6580,19 @@ "dev": true, "license": "MIT" }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -9164,6 +9178,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -10341,6 +10365,41 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.20.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", + "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 1b506d01e..b7c013671 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "scripts": { "build": "vite build", "test": "cross-env TZ=Europe/Berlin vitest", - "lint": "npm run lint:prettier && npm run lint:eslint && npm run lint:tsc", + "lint": "npm run lint:prettier && npm run lint:eslint && npm run lint:tsc && npm run lint:i18n", "lint:prettier": "prettier assets tests **/*.{yaml,sh} i18n/*.json --write", "lint:eslint": "eslint --fix --max-warnings=0", "lint:tsc": "vue-tsc --noEmit", + "lint:i18n": "tsx i18n/check.ts", "dev": "vite", "playwright": "playwright test --ui", "simulator": "vite tests/simulator", @@ -66,7 +67,6 @@ "@vue/tsconfig": "^0.8.1", "body-parser": "^2.2.0", "cross-env": "^10.0.0", - "license-compliance": "^3.0.1", "eslint": "^9.35.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", @@ -77,6 +77,7 @@ "globals": "^16.3.0", "happy-dom": "^18.0.1", "kill-port": "^2.0.1", + "license-compliance": "^3.0.1", "mqtt": "^5.14.1", "prettier": "^3.6.2", "prettier-plugin-sh": "^0.18.0", @@ -84,6 +85,7 @@ "rollup-plugin-visualizer": "^6.0.3", "storybook": "^10.0.0-beta.2", "terser": "^5.44.0", + "tsx": "^4.20.5", "typescript": "^5.9.2", "typescript-eslint-language-service": "^5.0.5", "vite": "^7.1.5",