feat: catalog browser polish + design system refresh + translation pipeline
- catalog: P5 restriction selector flow (mainGroupsPath), grid/tree/columns view modes for brands and models with persisted user settings - translations: bulk translateMany() path with 1d cache-miss TTL, expanded automotive dictionary; categories.service now drives EN→TR via TranslationsService instead of mapper-side strings - pcat: migrate auth from v1 JWT to v3 widget tokens (TWS- api-key + supporting X-* headers, IP-bound via DataImpulse proxy) - pl24: new fetchP5Restrictions() for restriction-level navigation - subscriptions: trial extended 7 → 30 days - design: oklch color tokens, brand semantic color, Geist + Instrument Serif fonts, tinted shadows, button "brand" variant with hover-lift, accessible focus rings, skip link, 404 NotFound page, auth layout polish - nginx: dynamic resolver for Faro upstream - config: OPENROUTER_API_KEY env (used by emex translate bootstrap script) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -118,10 +118,10 @@ describe("TranslationsService", () => {
|
||||
it("should return original text when no dictionary match found", async () => {
|
||||
const result = await service.translate(
|
||||
"cat:unknown-part",
|
||||
"xylophone bracket",
|
||||
"xylophone harpsichord",
|
||||
);
|
||||
|
||||
expect(result.translatedText).toBe("xylophone bracket");
|
||||
expect(result.translatedText).toBe("xylophone harpsichord");
|
||||
expect(result.source).toBe("none");
|
||||
expect(result.isAutoTranslated).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,105 +1,247 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { eq, ilike, or } from "drizzle-orm";
|
||||
import { eq, ilike, inArray, or } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { emexCategoryTranslations } from "../database/schema/core";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
|
||||
/** 30 days in seconds */
|
||||
const CACHE_TTL = 30 * 24 * 60 * 60;
|
||||
/** 1 day TTL for cache misses (allow re-check sooner) */
|
||||
const CACHE_MISS_TTL = 24 * 60 * 60;
|
||||
const CACHE_PREFIX = "tr:";
|
||||
|
||||
/** Common EN → TR automotive dictionary */
|
||||
/**
|
||||
* EN → TR automotive dictionary. Used as the last fallback before returning
|
||||
* the original text. Keys are lowercased; values use Turkish diacritics.
|
||||
*
|
||||
* Covers the common terms most likely to appear in EMEX category/part names.
|
||||
* Anything unmapped here is sent through the LLM bootstrap script
|
||||
* (scripts/emex-translate-bootstrap.ts) and persisted in
|
||||
* `emex_category_translations`.
|
||||
*/
|
||||
const AUTOMOTIVE_DICTIONARY: Record<string, string> = {
|
||||
"engine": "Motor",
|
||||
"brake": "Fren",
|
||||
"steering": "Direksiyon",
|
||||
"suspension": "Süspansiyon",
|
||||
"exhaust": "Egzoz",
|
||||
"transmission": "Şanzıman",
|
||||
"radiator": "Radyatör",
|
||||
"battery": "Akü",
|
||||
"filter": "Filtre",
|
||||
"clutch": "Debriyaj",
|
||||
"shock absorber": "Amortisör",
|
||||
"alternator": "Alternatör",
|
||||
"starter": "Marş Motoru",
|
||||
// Engine + powertrain
|
||||
engine: "Motor",
|
||||
motor: "Motor",
|
||||
piston: "Piston",
|
||||
cylinder: "Silindir",
|
||||
crankshaft: "Krank Mili",
|
||||
camshaft: "Eksantrik Mili",
|
||||
valve: "Supap",
|
||||
turbocharger: "Turbo",
|
||||
turbo: "Turbo",
|
||||
intercooler: "Intercooler",
|
||||
manifold: "Manifold",
|
||||
"intake manifold": "Emme Manifoldu",
|
||||
"exhaust manifold": "Egzoz Manifoldu",
|
||||
flywheel: "Volan",
|
||||
"spark plug": "Buji",
|
||||
"fuel pump": "Yakıt Pompası",
|
||||
"water pump": "Su Pompası",
|
||||
"oil pump": "Yağ Pompası",
|
||||
"timing belt": "Triger Kayışı",
|
||||
"fan belt": "Vantilatör Kayışı",
|
||||
"gasket": "Conta",
|
||||
"piston": "Piston",
|
||||
"cylinder": "Silindir",
|
||||
"crankshaft": "Krank Mili",
|
||||
"camshaft": "Eksantrik Mili",
|
||||
"valve": "Supap",
|
||||
"turbocharger": "Turbo",
|
||||
"intercooler": "Intercooler",
|
||||
"catalytic converter": "Katalitik Konvertör",
|
||||
"muffler": "Susturucu",
|
||||
"bumper": "Tampon",
|
||||
"fender": "Çamurluk",
|
||||
"hood": "Kaput",
|
||||
"trunk": "Bagaj",
|
||||
"windshield": "Ön Cam",
|
||||
"mirror": "Ayna",
|
||||
"headlight": "Far",
|
||||
"tail light": "Stop Lambası",
|
||||
"wiper": "Silecek",
|
||||
"door": "Kapı",
|
||||
"wheel": "Jant",
|
||||
"tire": "Lastik",
|
||||
"axle": "Aks",
|
||||
"bearing": "Rulman",
|
||||
"caliper": "Kaliper",
|
||||
"ignition coil": "Ateşleme Bobini",
|
||||
injector: "Enjektör",
|
||||
"fuel injector": "Yakıt Enjektörü",
|
||||
distributor: "Distribütör",
|
||||
"voltage regulator": "Voltaj Regülatörü",
|
||||
"throttle body": "Gaz Kelebeği",
|
||||
|
||||
// Transmission
|
||||
transmission: "Şanzıman",
|
||||
gearbox: "Vites Kutusu",
|
||||
clutch: "Debriyaj",
|
||||
"clutch kit": "Debriyaj Seti",
|
||||
"gear lever": "Vites Kolu",
|
||||
differential: "Diferansiyel",
|
||||
"drive shaft": "Şaft",
|
||||
driveshaft: "Şaft",
|
||||
"axle shaft": "Aks Mili",
|
||||
axle: "Aks",
|
||||
"cv joint": "Aks Kafası",
|
||||
"cv boot": "Aks Körüğü",
|
||||
|
||||
// Brake
|
||||
brake: "Fren",
|
||||
brakes: "Fren Sistemi",
|
||||
"brake pad": "Fren Balatası",
|
||||
"brake disc": "Fren Diski",
|
||||
"air filter": "Hava Filtresi",
|
||||
"oil filter": "Yağ Filtresi",
|
||||
"fuel filter": "Yakıt Filtresi",
|
||||
"cabin filter": "Polen Filtresi",
|
||||
"thermostat": "Termostat",
|
||||
"sensor": "Sensör",
|
||||
"relay": "Röle",
|
||||
"fuse": "Sigorta",
|
||||
"compressor": "Kompresör",
|
||||
"condenser": "Kondenser",
|
||||
"evaporator": "Evaporatör",
|
||||
"hose": "Hortum",
|
||||
"belt": "Kayış",
|
||||
"spring": "Yay",
|
||||
"strut": "Amortisör Bacağı",
|
||||
"brake rotor": "Fren Diski",
|
||||
"brake hose": "Fren Hortumu",
|
||||
"brake line": "Fren Borusu",
|
||||
"brake fluid": "Fren Hidroliği",
|
||||
caliper: "Fren Kaliperi",
|
||||
"master cylinder": "Ana Merkez",
|
||||
"slave cylinder": "Yardımcı Merkez",
|
||||
handbrake: "El Freni",
|
||||
|
||||
// Suspension + steering
|
||||
suspension: "Süspansiyon",
|
||||
steering: "Direksiyon",
|
||||
"steering wheel": "Direksiyon Simidi",
|
||||
"steering rack": "Kremayer",
|
||||
"steering gear": "Direksiyon Kutusu",
|
||||
"power steering": "Hidrolik Direksiyon",
|
||||
"power steering pump": "Hidrolik Direksiyon Pompası",
|
||||
"power steering hose": "Hidrolik Direksiyon Hortumu",
|
||||
"shock absorber": "Amortisör",
|
||||
shock: "Amortisör",
|
||||
strut: "Makferson",
|
||||
spring: "Yay",
|
||||
"control arm": "Salıncak",
|
||||
"tie rod": "Rot Başı",
|
||||
"ball joint": "Rotil",
|
||||
"cv joint": "Aks Kafası",
|
||||
"drive shaft": "Şaft",
|
||||
"differential": "Diferansiyel",
|
||||
"gearbox": "Vites Kutusu",
|
||||
"flywheel": "Volan",
|
||||
"injector": "Enjektör",
|
||||
"throttle body": "Gaz Kelebeği",
|
||||
"manifold": "Manifold",
|
||||
|
||||
// Wheels + tires
|
||||
wheel: "Tekerlek",
|
||||
wheels: "Jantlar",
|
||||
"wheel hub": "Poyra",
|
||||
hub: "Poyra",
|
||||
"wheel bearing": "Tekerlek Rulmanı",
|
||||
"hub bearing": "Poyra Rulmanı",
|
||||
bearing: "Rulman",
|
||||
"wheel stud": "Bijon",
|
||||
"lug nut": "Bijon Somunu",
|
||||
tire: "Lastik",
|
||||
tires: "Lastikler",
|
||||
|
||||
// Cooling + fuel + air
|
||||
cooling: "Soğutma Sistemi",
|
||||
radiator: "Radyatör",
|
||||
"radiator hose": "Radyatör Hortumu",
|
||||
thermostat: "Termostat",
|
||||
"water pump": "Su Pompası",
|
||||
"fan belt": "Vantilatör Kayışı",
|
||||
fan: "Fan",
|
||||
coolant: "Antifriz",
|
||||
fuel: "Yakıt",
|
||||
"fuel system": "Yakıt Sistemi",
|
||||
"fuel pump": "Yakıt Pompası",
|
||||
"fuel filter": "Yakıt Filtresi",
|
||||
"fuel tank": "Yakıt Deposu",
|
||||
air: "Hava",
|
||||
"air filter": "Hava Filtresi",
|
||||
"cabin filter": "Polen Filtresi",
|
||||
"pollen filter": "Polen Filtresi",
|
||||
"oil filter": "Yağ Filtresi",
|
||||
"oil pump": "Yağ Pompası",
|
||||
"oil pan": "Karter",
|
||||
"engine oil": "Motor Yağı",
|
||||
oil: "Yağ",
|
||||
filter: "Filtre",
|
||||
filters: "Filtreler",
|
||||
|
||||
// Exhaust
|
||||
exhaust: "Egzoz Sistemi",
|
||||
"catalytic converter": "Katalitik Konvertör",
|
||||
muffler: "Susturucu",
|
||||
|
||||
// Electrical
|
||||
electrical: "Elektrik Sistemi",
|
||||
battery: "Akü",
|
||||
alternator: "Alternatör",
|
||||
starter: "Marş Motoru",
|
||||
sensor: "Sensör",
|
||||
"oxygen sensor": "Oksijen Sensörü",
|
||||
"abs sensor": "ABS Sensörü",
|
||||
"ignition coil": "Ateşleme Bobini",
|
||||
"distributor": "Distribütör",
|
||||
"voltage regulator": "Voltaj Regülatörü",
|
||||
"window regulator": "Cam Krikosu",
|
||||
"crankshaft sensor": "Krank Sensörü",
|
||||
"camshaft sensor": "Eksantrik Sensörü",
|
||||
"coolant sensor": "Su Isısı Sensörü",
|
||||
"oil pressure sensor": "Yağ Basıncı Sensörü",
|
||||
relay: "Röle",
|
||||
fuse: "Sigorta",
|
||||
switch: "Anahtar",
|
||||
horn: "Korna",
|
||||
airbag: "Hava Yastığı",
|
||||
|
||||
// HVAC
|
||||
"air conditioning": "Klima",
|
||||
climate: "Klima",
|
||||
compressor: "Kompresör",
|
||||
condenser: "Kondenser",
|
||||
evaporator: "Evaporatör",
|
||||
heater: "Isıtıcı",
|
||||
blower: "Üfleyici",
|
||||
|
||||
// Belts, seals, gaskets
|
||||
belt: "Kayış",
|
||||
"timing belt": "Triger Kayışı",
|
||||
"timing chain": "Triger Zinciri",
|
||||
"serpentine belt": "V Kayışı",
|
||||
"drive belt": "Tahrik Kayışı",
|
||||
tensioner: "Gerdirici",
|
||||
"belt tensioner": "Kayış Gerdirici",
|
||||
idler: "Avare",
|
||||
"idler pulley": "Avare Kasnak",
|
||||
pulley: "Kasnak",
|
||||
"crankshaft pulley": "Krank Kasnağı",
|
||||
hose: "Hortum",
|
||||
gasket: "Conta",
|
||||
"head gasket": "Silindir Kapağı Contası",
|
||||
"valve cover gasket": "Külbütör Kapağı Contası",
|
||||
"oil pan gasket": "Karter Contası",
|
||||
"intake manifold gasket": "Emme Manifoldu Contası",
|
||||
"exhaust manifold gasket": "Egzoz Manifoldu Contası",
|
||||
seal: "Keçe",
|
||||
"o-ring": "O-Ring",
|
||||
|
||||
// Body + exterior
|
||||
body: "Kaporta",
|
||||
bumper: "Tampon",
|
||||
"front bumper": "Ön Tampon",
|
||||
"rear bumper": "Arka Tampon",
|
||||
fender: "Çamurluk",
|
||||
mudguard: "Çamurluk",
|
||||
"splash guard": "Paçalık",
|
||||
hood: "Kaput",
|
||||
bonnet: "Kaput",
|
||||
trunk: "Bagaj",
|
||||
boot: "Bagaj",
|
||||
grille: "Izgara",
|
||||
exterior: "Dış Aksam",
|
||||
|
||||
// Doors + windows + mirrors
|
||||
door: "Kapı",
|
||||
"door handle": "Kapı Kolu",
|
||||
"door lock": "Kapı Kilidi",
|
||||
"seat": "Koltuk",
|
||||
"dashboard": "Gösterge Paneli",
|
||||
"steering wheel": "Direksiyon Simidi",
|
||||
"gear lever": "Vites Kolu",
|
||||
"handbrake": "El Freni",
|
||||
"pedal": "Pedal",
|
||||
"radiator hose": "Radyatör Hortumu",
|
||||
"coolant": "Antifriz",
|
||||
"brake fluid": "Fren Hidroliği",
|
||||
"engine oil": "Motor Yağı",
|
||||
"power steering": "Hidrolik Direksiyon",
|
||||
"door hinge": "Kapı Menteşesi",
|
||||
window: "Cam",
|
||||
windshield: "Ön Cam",
|
||||
windscreen: "Ön Cam",
|
||||
"window regulator": "Cam Krikosu",
|
||||
"window motor": "Cam Motoru",
|
||||
mirror: "Ayna",
|
||||
"side mirror": "Yan Ayna",
|
||||
"rear view mirror": "İç Ayna",
|
||||
|
||||
// Wipers + lights
|
||||
wiper: "Silecek",
|
||||
"wiper blade": "Silecek Lastiği",
|
||||
"wiper motor": "Silecek Motoru",
|
||||
lighting: "Aydınlatma",
|
||||
lights: "Aydınlatma",
|
||||
headlight: "Far",
|
||||
"headlight bulb": "Far Ampulü",
|
||||
"tail light": "Stop Lambası",
|
||||
taillight: "Stop Lambası",
|
||||
"fog light": "Sis Lambası",
|
||||
"turn signal": "Sinyal Lambası",
|
||||
indicator: "Sinyal Lambası",
|
||||
|
||||
// Interior
|
||||
interior: "İç Aksam",
|
||||
seat: "Koltuk",
|
||||
dashboard: "Gösterge Paneli",
|
||||
pedal: "Pedal",
|
||||
carpet: "Halı",
|
||||
mat: "Paspas",
|
||||
|
||||
// Misc / fasteners
|
||||
pump: "Pompa",
|
||||
screw: "Vida",
|
||||
bolt: "Cıvata",
|
||||
nut: "Somun",
|
||||
washer: "Pul",
|
||||
clip: "Klips",
|
||||
bracket: "Braket",
|
||||
cover: "Kapak",
|
||||
plug: "Tıpa",
|
||||
};
|
||||
|
||||
export interface TranslationResult {
|
||||
@@ -149,32 +291,9 @@ export class TranslationsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. Try dictionary-based translation
|
||||
const dictTranslation = this.translateWithDictionary(sourceText);
|
||||
if (dictTranslation !== null) {
|
||||
const result: TranslationResult = {
|
||||
key,
|
||||
sourceText,
|
||||
translatedText: dictTranslation,
|
||||
source: "dictionary",
|
||||
isAutoTranslated: true,
|
||||
};
|
||||
|
||||
// Persist to DB for future lookups
|
||||
await this.db
|
||||
.insert(emexCategoryTranslations)
|
||||
.values({
|
||||
originalName: sourceText,
|
||||
translatedName: dictTranslation,
|
||||
isManual: false,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
await this.redis.setJson(cacheKey, result, CACHE_TTL);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 4. No translation found — return original with flag
|
||||
// 3. No DB hit — return original. Dictionary fallback removed: its
|
||||
// word-by-word replacement produces half-translated strings and
|
||||
// would poison the DB. Bootstrap script handles new terms via LLM.
|
||||
const result: TranslationResult = {
|
||||
key,
|
||||
sourceText,
|
||||
@@ -182,21 +301,122 @@ export class TranslationsService {
|
||||
source: "none",
|
||||
isAutoTranslated: false,
|
||||
};
|
||||
// Cache "miss" with shorter TTL (1 day) so it gets re-checked sooner
|
||||
await this.redis.setJson(cacheKey, result, 24 * 60 * 60);
|
||||
await this.redis.setJson(cacheKey, result, CACHE_MISS_TTL);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch translate multiple items
|
||||
* Bulk translate many strings in one shot — used by EMEX category/part
|
||||
* insertion paths (categories.service.ts) where a single fetch can yield
|
||||
* dozens to hundreds of names.
|
||||
*
|
||||
* Pipeline: Redis MGET → DB IN(...) → dictionary fallback → bulk INSERT
|
||||
* → Redis pipeline SET. Returns Map<originalText, translatedText>; entries
|
||||
* for which no translation is available map to the original text.
|
||||
*/
|
||||
async translateMany(sourceTexts: string[]): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
if (!sourceTexts.length) return result;
|
||||
|
||||
// Dedupe and drop empties — caller often has duplicates across rows
|
||||
const unique = [...new Set(sourceTexts.filter((s) => s && s.trim().length > 0))];
|
||||
if (!unique.length) return result;
|
||||
|
||||
// Phase 1: Redis MGET
|
||||
const redis = this.redis.getClient();
|
||||
const cacheKeys = unique.map((s) => `${CACHE_PREFIX}${s}`);
|
||||
const cachedRaw = await redis.mget(...cacheKeys);
|
||||
|
||||
const missing: string[] = [];
|
||||
cachedRaw.forEach((raw, idx) => {
|
||||
const text = unique[idx];
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as TranslationResult;
|
||||
result.set(text, parsed.translatedText);
|
||||
} catch {
|
||||
missing.push(text);
|
||||
}
|
||||
} else {
|
||||
missing.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
if (!missing.length) return result;
|
||||
|
||||
// Phase 2: DB IN(...) for cache misses
|
||||
const dbRows = await this.db
|
||||
.select({
|
||||
originalName: emexCategoryTranslations.originalName,
|
||||
translatedName: emexCategoryTranslations.translatedName,
|
||||
isManual: emexCategoryTranslations.isManual,
|
||||
})
|
||||
.from(emexCategoryTranslations)
|
||||
.where(inArray(emexCategoryTranslations.originalName, missing));
|
||||
|
||||
const dbMap = new Map<string, { translated: string; isManual: boolean }>();
|
||||
for (const row of dbRows) {
|
||||
dbMap.set(row.originalName, { translated: row.translatedName, isManual: row.isManual });
|
||||
}
|
||||
|
||||
// Phase 3: Build result map + populate Redis pipeline.
|
||||
// We deliberately do NOT use translateWithDictionary's word-by-word
|
||||
// replacement here — it produces half-translated strings (e.g.
|
||||
// "Body frame" → "Kaporta frame") and would persist them to the DB,
|
||||
// poisoning future lookups. Anything missing falls through to a
|
||||
// short-TTL cache miss; the next emex-translate-bootstrap.ts run
|
||||
// picks it up and writes the proper LLM translation.
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
for (const text of missing) {
|
||||
const dbHit = dbMap.get(text);
|
||||
if (dbHit) {
|
||||
result.set(text, dbHit.translated);
|
||||
const payload: TranslationResult = {
|
||||
key: text,
|
||||
sourceText: text,
|
||||
translatedText: dbHit.translated,
|
||||
source: "db",
|
||||
isAutoTranslated: !dbHit.isManual,
|
||||
};
|
||||
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_TTL);
|
||||
} else {
|
||||
result.set(text, text);
|
||||
const payload: TranslationResult = {
|
||||
key: text,
|
||||
sourceText: text,
|
||||
translatedText: text,
|
||||
source: "none",
|
||||
isAutoTranslated: false,
|
||||
};
|
||||
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_MISS_TTL);
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch translate — kept for backwards compatibility with the controller's
|
||||
* /translations/batch endpoint. Internally uses translateMany.
|
||||
*/
|
||||
async translateBatch(
|
||||
items: { key: string; sourceText: string }[],
|
||||
): Promise<TranslationResult[]> {
|
||||
const results = await Promise.all(
|
||||
items.map((item) => this.translate(item.key, item.sourceText)),
|
||||
);
|
||||
return results;
|
||||
if (!items.length) return [];
|
||||
const trMap = await this.translateMany(items.map((i) => i.sourceText));
|
||||
return items.map((i) => {
|
||||
const translated = trMap.get(i.sourceText) ?? i.sourceText;
|
||||
const same = translated === i.sourceText;
|
||||
return {
|
||||
key: i.key,
|
||||
sourceText: i.sourceText,
|
||||
translatedText: translated,
|
||||
source: same ? "none" : "db",
|
||||
isAutoTranslated: !same,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,9 +445,11 @@ export class TranslationsService {
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Invalidate cache
|
||||
const cacheKey = `${CACHE_PREFIX}${key}`;
|
||||
await this.redis.del(cacheKey);
|
||||
// Invalidate cache (key + sourceText so both lookup paths see the change)
|
||||
await this.redis.del(`${CACHE_PREFIX}${key}`);
|
||||
if (key !== sourceText) {
|
||||
await this.redis.del(`${CACHE_PREFIX}${sourceText}`);
|
||||
}
|
||||
|
||||
this.logger.log(`Translation set: "${sourceText}" → "${translatedText}"`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user