i18n: fix placeholder mismatch, add validation (#23541)

This commit is contained in:
Michael Geers 2025-09-12 12:48:04 +02:00 • committed by GitHub
parent d8ed7c1e11
commit 13b4ab3018
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 269 additions and 51 deletions

View file

@ -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": {

View file

@ -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."

157
i18n/check.ts Normal file
View file

@ -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<string, string> {
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<string, string>);
}
// 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<string, string> {
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<string, typeof allErrors>();
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();

View file

@ -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",

View file

@ -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.",

View file

@ -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",

View file

@ -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",

View file

@ -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…"

View file

@ -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": "Готов. Ожидание транспортного средства..."
},

View file

@ -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.",

View file

@ -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",

View file

@ -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": {

View file

@ -794,7 +794,7 @@
"scale1p": "准备切换为单相充电。",
"scale3p": "准备切换为三相充电。",
"targetChargeActive": "充电计划已激活。预计在 {duration} 内完成。",
"targetChargePlanned": "充电计划将于 {duration} 后开始({time})。",
"targetChargePlanned": "充电计划将于 {duration} 后开始。",
"targetChargeWaitForVehicle": "充电计划准备就绪。等待车辆连接…",
"vehicleLimit": "车辆充电上限",
"vehicleLimitReached": "已达到车辆充电上限 {soc}。",

59
package-lock.json generated
View file

@ -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",

View file

@ -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",