feat(F-116): completed feature
This commit is contained in:
@@ -196,6 +196,90 @@ export interface ImportPlan {
|
||||
brands: ImportPlanBrand[];
|
||||
}
|
||||
|
||||
/**
|
||||
* F-116: Spanish translations of every legacy category name in Title Case.
|
||||
* Names already in Spanish are kept as-is. Keys are the `name` field of each
|
||||
* legacy entry (case-insensitive matching is done by the caller).
|
||||
*/
|
||||
export const CATEGORY_TRANSLATIONS: Readonly<Record<string, string>> = {
|
||||
HERBALIST: 'Herbolaria',
|
||||
FOOD: 'Alimentación',
|
||||
COSMETICS: 'Cosmética',
|
||||
'DIET AND NUTRITION': 'Dietética y Nutrición',
|
||||
'NUTS & SEEDS': 'Frutos Secos y Semillas',
|
||||
'BREAD & PASTRIES': 'Pan y Bollería',
|
||||
LEGUMES: 'Legumbres',
|
||||
'FLOUR & CEREALS': 'Harinas y Cereales',
|
||||
'PASTA & RICE': 'Pasta y Arroz',
|
||||
'CREAMS & JAMS': 'Cremas y Mermeladas',
|
||||
'SUGAR & SWEETENERS': 'Azúcar y Endulzantes',
|
||||
BEVERAGES: 'Bebidas',
|
||||
'OIL AND VINEGAR': 'Aceite y Vinagre',
|
||||
'RAW FOOD': 'Comida Cruda',
|
||||
'SUPLEMENTS': 'Suplementos',
|
||||
'OILS & EXTRACTS': 'Aceites y Extractos',
|
||||
FACIAL: 'Facial',
|
||||
CORPORAL: 'Corporal',
|
||||
'ASEO PERSONAL': 'Aseo Personal',
|
||||
HOME: 'Hogar',
|
||||
MACROBIOTIC: 'Macrobiótica',
|
||||
SNACKS: 'Snacks',
|
||||
BOOKS: 'Libros',
|
||||
'FRESH PRODUCTS': 'Productos Frescos',
|
||||
WINE: 'Vino',
|
||||
BEER: 'Cerveza',
|
||||
'VEGETAL MILKS': 'Bebidas Vegetales',
|
||||
JUICES: 'Zumos',
|
||||
SODAS: 'Refrescos',
|
||||
CHILDREN: 'Niños',
|
||||
'BABYS & KIDS': 'Bebés y Niños',
|
||||
'TEA & INFUSIONS': 'Té e Infusiones',
|
||||
'SPICE & CONDIMENTS': 'Especias y Condimentos',
|
||||
'CHOCOLATE & SWEETS': 'Chocolate y Dulces',
|
||||
CLEANING: 'Limpieza',
|
||||
// Aliases already in Spanish (kept for completeness).
|
||||
'FRUTAS Y VERDURAS': 'Frutas y Verduras',
|
||||
'HIERBAS MEDICINALES': 'Hierbas Medicinales',
|
||||
PROVEEDORES: 'Proveedores',
|
||||
GRANOLA: 'Granola',
|
||||
};
|
||||
|
||||
export function translateCategoryName(name: string): string {
|
||||
const exact = CATEGORY_TRANSLATIONS[name];
|
||||
if (exact) return exact;
|
||||
const upper = name.toUpperCase();
|
||||
return CATEGORY_TRANSLATIONS[upper] ?? name;
|
||||
}
|
||||
|
||||
export interface RenamePlanEntry {
|
||||
ocId: number;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* F-116: returns the legacy categories whose English name is already in
|
||||
* the catalog and need to be renamed to their Spanish translation.
|
||||
* Entries whose Spanish translation already exists are skipped (no
|
||||
* overwrite).
|
||||
*/
|
||||
export function buildRenamePlan(
|
||||
existingCategoryNames: ReadonlySet<string>,
|
||||
): RenamePlanEntry[] {
|
||||
const renames: RenamePlanEntry[] = [];
|
||||
for (const entry of LEGACY_ENTRIES) {
|
||||
if (entry.kind !== 'category') continue;
|
||||
const translated = translateCategoryName(entry.name);
|
||||
const oldLower = entry.name.toLowerCase();
|
||||
const newLower = translated.toLowerCase();
|
||||
if (oldLower === newLower) continue;
|
||||
if (!existingCategoryNames.has(oldLower)) continue;
|
||||
if (existingCategoryNames.has(newLower)) continue;
|
||||
renames.push({ ocId: entry.ocId, oldName: entry.name, newName: translated });
|
||||
}
|
||||
return renames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the import plan from `LEGACY_ENTRIES`, skipping entries whose
|
||||
* name (case-insensitive) is already present in `existingCategoryNames` or
|
||||
@@ -213,13 +297,17 @@ export function buildImportPlan(
|
||||
for (const entry of LEGACY_ENTRIES) {
|
||||
if (entry.kind === 'exclude') continue;
|
||||
if (entry.kind === 'category') {
|
||||
const translatedName = translateCategoryName(entry.name);
|
||||
// Skip if the Spanish name already exists, or if the English name is
|
||||
// already in the catalog (rename plan will handle it in place).
|
||||
if (existingCategoryNames.has(translatedName.toLowerCase())) continue;
|
||||
if (existingCategoryNames.has(entry.name.toLowerCase())) continue;
|
||||
const base = slugify(entry.name);
|
||||
const base = slugify(translatedName);
|
||||
const slug = uniqueSlug(base, usedSlugs);
|
||||
usedSlugs.add(slug.toLowerCase());
|
||||
categories.push({
|
||||
ocId: entry.ocId,
|
||||
name: entry.name,
|
||||
name: translatedName,
|
||||
slug,
|
||||
parentSlug: entry.parentSlug,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildImportPlan,
|
||||
CATEGORY_TRANSLATIONS,
|
||||
decodeHtmlEntities,
|
||||
isSameName,
|
||||
LEGACY_ENTRIES,
|
||||
normalizeName,
|
||||
slugify,
|
||||
translateCategoryName,
|
||||
uniqueSlug,
|
||||
} from '../legacy/legacy-catalog.js';
|
||||
|
||||
@@ -71,11 +73,11 @@ describe('buildImportPlan', () => {
|
||||
});
|
||||
|
||||
it('skips entries whose name already exists in the catalog (case-insensitive)', () => {
|
||||
const existingCategories = new Set(['nuts & seeds']);
|
||||
const existingCategories = new Set(['frutos secos y semillas']);
|
||||
const existingBrands = new Set<string>();
|
||||
const existingSlugs = new Set<string>();
|
||||
const plan = buildImportPlan(existingCategories, existingBrands, existingSlugs);
|
||||
expect(plan.categories.find((c) => c.name === 'NUTS & SEEDS')).toBeUndefined();
|
||||
expect(plan.categories.find((c) => c.name === 'Frutos Secos y Semillas')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips excluded legacy markers', () => {
|
||||
@@ -93,7 +95,7 @@ describe('buildImportPlan', () => {
|
||||
|
||||
it('attaches the declared parent to each category', () => {
|
||||
const plan = buildImportPlan(new Set(), new Set(), new Set());
|
||||
const nuts = plan.categories.find((c) => c.name === 'NUTS & SEEDS');
|
||||
const nuts = plan.categories.find((c) => c.name === 'Frutos Secos y Semillas');
|
||||
expect(nuts?.parentSlug).toBe('frutos-secos');
|
||||
});
|
||||
|
||||
@@ -102,4 +104,44 @@ describe('buildImportPlan', () => {
|
||||
expect(plan.categories.find((c) => c.name === 'SOLGAR')).toBeUndefined();
|
||||
expect(plan.brands.find((b) => b.name === 'SOLGAR')).toBeDefined();
|
||||
});
|
||||
|
||||
it('translates every legacy category name to Spanish Title Case', () => {
|
||||
const plan = buildImportPlan(new Set(), new Set(), new Set());
|
||||
const translatedNames = plan.categories.map((c) => c.name);
|
||||
// Spanish conjunctions/prepositions that stay lowercase in Title Case.
|
||||
const lowercase = new Set(['y', 'e', 'de', 'del', 'la', 'el', 'los', 'las', 'en', 'a', 'con']);
|
||||
for (const name of translatedNames) {
|
||||
const words = name.split(/\s+/).filter(Boolean);
|
||||
for (const word of words) {
|
||||
const firstChar = word.charAt(0);
|
||||
if (!/^[A-Za-zÁÉÍÓÚáéíóÚÑñ]/.test(firstChar)) continue;
|
||||
if (lowercase.has(word.toLowerCase())) {
|
||||
expect(firstChar).toBe(firstChar.toLowerCase());
|
||||
} else {
|
||||
expect(firstChar).toBe(firstChar.toUpperCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('covers every legacy category with a translation entry', () => {
|
||||
const legacyCategoryNames = LEGACY_ENTRIES
|
||||
.filter((e) => e.kind === 'category')
|
||||
.map((e) => e.name);
|
||||
for (const name of legacyCategoryNames) {
|
||||
expect(translateCategoryName(name)).not.toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('translation table has one entry per legacy category', () => {
|
||||
const legacyCategoryNames = LEGACY_ENTRIES
|
||||
.filter((e) => e.kind === 'category')
|
||||
.map((e) => e.name);
|
||||
// Includes renamed entries and aliases already in Spanish.
|
||||
const known = new Set<string>();
|
||||
for (const key of Object.keys(CATEGORY_TRANSLATIONS)) known.add(key.toUpperCase());
|
||||
for (const name of legacyCategoryNames) {
|
||||
expect(known.has(name.toUpperCase())).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user