feat(F-114): completed feature
This commit is contained in:
235
project/src/modules/categories/legacy/legacy-catalog.ts
Normal file
235
project/src/modules/categories/legacy/legacy-catalog.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Legacy OpenCart catalog import (F-114).
|
||||
*
|
||||
* This module is a pure helper that:
|
||||
* - declares the legacy categories from `oc_category_description`
|
||||
* - normalises names (HTML entities, whitespace, case)
|
||||
* - classifies each entry as a real category, a brand, or excluded
|
||||
* - picks a parent category for each new category
|
||||
*
|
||||
* The seed script (`scripts/seed-legacy-categories.mjs`) consumes this helper
|
||||
* to import the entries idempotently. Domain logic is kept here so it can be
|
||||
* unit tested without touching the database.
|
||||
*/
|
||||
|
||||
export type LegacyKind = 'category' | 'brand' | 'exclude';
|
||||
|
||||
/**
|
||||
* A parent assignment. We resolve by `slug` against the existing categories
|
||||
* at runtime so the seed script can look up the UUID once, and so the
|
||||
* mapping is easy to read in tests.
|
||||
*/
|
||||
export interface LegacyEntry {
|
||||
/** Original OpenCart category_id. */
|
||||
ocId: number;
|
||||
/** Decoded, trimmed name. */
|
||||
name: string;
|
||||
/** Classification (category / brand / exclude). */
|
||||
kind: LegacyKind;
|
||||
/** Parent slug for new categories. undefined = root. */
|
||||
parentSlug?: string;
|
||||
}
|
||||
|
||||
/** Lowercases + collapses whitespace + decodes HTML entities. */
|
||||
export function normalizeName(raw: string): string {
|
||||
const decoded = decodeHtmlEntities(raw)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/** Case-insensitive comparison for "already exists" checks. */
|
||||
export function isSameName(left: string, right: string): boolean {
|
||||
return normalizeName(left).toLowerCase() === normalizeName(right).toLowerCase();
|
||||
}
|
||||
|
||||
/** Minimal HTML entity decoder for the entities we know appear in the legacy table. */
|
||||
export function decodeHtmlEntities(raw: string): string {
|
||||
const named: Record<string, string> = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: ' ',
|
||||
};
|
||||
return raw.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (entity, body) => {
|
||||
if (body in named) return named[body] ?? entity;
|
||||
if (body.startsWith('#x') || body.startsWith('#X')) {
|
||||
const code = parseInt(body.slice(2), 16);
|
||||
if (Number.isFinite(code)) return String.fromCodePoint(code);
|
||||
} else if (body.startsWith('#')) {
|
||||
const code = parseInt(body.slice(1), 10);
|
||||
if (Number.isFinite(code)) return String.fromCodePoint(code);
|
||||
}
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a name into a URL slug matching the categories/brands validator:
|
||||
* `^[a-z0-9]+(?:-[a-z0-9]+)*$`. Strips diacritics, drops non-alphanumerics.
|
||||
*/
|
||||
export function slugify(name: string): string {
|
||||
const stripped = normalizeName(name)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return stripped || 'entry';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a slug that does not collide with `existing` (case-insensitive).
|
||||
* Appends `-2`, `-3`, … until a free one is found.
|
||||
*/
|
||||
export function uniqueSlug(base: string, existing: ReadonlySet<string>): string {
|
||||
const lower = base.toLowerCase();
|
||||
if (!existing.has(lower)) return base;
|
||||
let i = 2;
|
||||
while (existing.has(`${lower}-${i}`)) i += 1;
|
||||
return `${base}-${i}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy OpenCart rows. Each entry is classified and (for categories)
|
||||
* pinned to a parent by slug so the import is deterministic.
|
||||
* Excluded entries are legacy markers (`00 - SIN CODIGO`, etc.) that we
|
||||
* do not want to surface in the new catalog.
|
||||
*/
|
||||
export const LEGACY_ENTRIES: ReadonlyArray<LegacyEntry> = [
|
||||
{ ocId: 66, name: '00 - SIN CODIGO', kind: 'exclude' },
|
||||
{ ocId: 67, name: 'HERBALIST', kind: 'category', parentSlug: 'hierbas-infusiones' },
|
||||
{ ocId: 68, name: 'FOOD', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 69, name: 'COSMETICS', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
|
||||
{ ocId: 70, name: 'DIET AND NUTRITION', kind: 'category', parentSlug: 'suplementos' },
|
||||
{ ocId: 71, name: 'NUTS & SEEDS', kind: 'category', parentSlug: 'frutos-secos' },
|
||||
{ ocId: 72, name: 'BREAD & PASTRIES', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 75, name: 'LEGUMES', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 76, name: 'FLOUR & CEREALS', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 77, name: 'PASTA & RICE', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 78, name: 'CREAMS & JAMS', kind: 'category', parentSlug: 'cremas' },
|
||||
{ ocId: 79, name: 'FRUTAS Y VERDURAS', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 80, name: 'SUGAR & SWEETENERS', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 81, name: 'BEVERAGES', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 82, name: 'OIL AND VINEGAR', kind: 'category', parentSlug: 'aceites' },
|
||||
{ ocId: 83, name: 'RAW FOOD', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 84, name: 'HIERBAS MEDICINALES', kind: 'category', parentSlug: 'hierbas-infusiones' },
|
||||
{ ocId: 85, name: 'SUPLEMENTS', kind: 'category', parentSlug: 'suplementos' },
|
||||
{ ocId: 86, name: 'OILS & EXTRACTS', kind: 'category', parentSlug: 'aceites' },
|
||||
{ ocId: 87, name: 'FACIAL', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
|
||||
{ ocId: 88, name: 'CORPORAL', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
|
||||
{ ocId: 89, name: 'ASEO PERSONAL', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
|
||||
{ ocId: 90, name: 'HOME', kind: 'category', parentSlug: 'hogar-y-mascotas' },
|
||||
{ ocId: 91, name: 'MACROBIOTIC', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 93, name: 'SNACKS', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 95, name: 'BOOKS', kind: 'category', parentSlug: 'hogar-y-mascotas' },
|
||||
{ ocId: 97, name: 'FRESH PRODUCTS', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 102, name: 'WINE', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 103, name: 'BEER', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 104, name: 'VEGETAL MILKS', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 105, name: 'JUICES', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 106, name: 'SODAS', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 107, name: 'CHILDREN', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 108, name: 'BABYS & KIDS', kind: 'category', parentSlug: 'cosmetica-e-higiene' },
|
||||
{ ocId: 115, name: 'TEA & INFUSIONS', kind: 'category', parentSlug: 'hierbas-infusiones' },
|
||||
{ ocId: 116, name: 'SPICE & CONDIMENTS', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 117, name: 'CHOCOLATE & SWEETS', kind: 'category', parentSlug: 'alimentacion' },
|
||||
{ ocId: 129, name: 'CLEANING', kind: 'category', parentSlug: 'limpieza-ecologica' },
|
||||
{ ocId: 127, name: 'PROVEEDORES', kind: 'category' }, // root
|
||||
// ── Marcas ───────────────────────────────────────────────────────────────
|
||||
{ ocId: 98, name: 'PROVEEDORES', kind: 'brand' },
|
||||
{ ocId: 99, name: 'BIOSPIRIT', kind: 'brand' },
|
||||
{ ocId: 100, name: 'SALUD VIVA', kind: 'brand' },
|
||||
{ ocId: 101, name: 'GLOBO NATURA', kind: 'brand' },
|
||||
{ ocId: 102, name: 'SOLGAR', kind: 'brand' },
|
||||
{ ocId: 103, name: 'VITAFOOD', kind: 'brand' },
|
||||
{ ocId: 104, name: 'EL GRANERO INTEGRAL', kind: 'brand' },
|
||||
{ ocId: 105, name: 'A.VOGEL', kind: 'brand' },
|
||||
{ ocId: 106, name: 'BIOCOP', kind: 'brand' },
|
||||
{ ocId: 107, name: 'QBIO', kind: 'brand' },
|
||||
{ ocId: 108, name: 'LA FINESTRA SUL CIELO', kind: 'brand' },
|
||||
{ ocId: 109, name: 'CADIDIET', kind: 'brand' },
|
||||
{ ocId: 110, name: 'COMPLEMENTOS Y NUTRICIÓN', kind: 'brand' },
|
||||
{ ocId: 111, name: 'CHISVERT', kind: 'brand' },
|
||||
{ ocId: 112, name: 'SUPERFOOD', kind: 'brand' },
|
||||
{ ocId: 113, name: 'DIETISUR', kind: 'brand' },
|
||||
{ ocId: 114, name: 'GOURMET BIO', kind: 'brand' },
|
||||
{ ocId: 116, name: 'NATURALMENTE MEDITERRANEO', kind: 'brand' },
|
||||
{ ocId: 117, name: 'NATURAL', kind: 'brand' },
|
||||
{ ocId: 126, name: 'GOURMET CASH', kind: 'brand' },
|
||||
{ ocId: 127, name: 'NUTRINAT', kind: 'brand' },
|
||||
{ ocId: 131, name: 'COMERCIAL GARZA', kind: 'brand' },
|
||||
{ ocId: 132, name: 'ARTESANÍA AGRÍCOLA', kind: 'brand' },
|
||||
{ ocId: 133, name: 'DAS BROT', kind: 'brand' },
|
||||
{ ocId: 134, name: 'DULCES LISSEN', kind: 'brand' },
|
||||
{ ocId: 135, name: 'LEMONPHARMA', kind: 'brand' },
|
||||
{ ocId: 136, name: 'VEKINE', kind: 'brand' },
|
||||
{ ocId: 137, name: 'NAAY BOTANICALS', kind: 'brand' },
|
||||
{ ocId: 138, name: 'LAMBERTS', kind: 'brand' },
|
||||
{ ocId: 139, name: 'YODETIENDAS', kind: 'brand' },
|
||||
{ ocId: 140, name: 'NATURCOSMETIKA', kind: 'brand' },
|
||||
{ ocId: 142, name: 'TONGIL', kind: 'brand' },
|
||||
{ ocId: 96, name: 'SUPLEMENTS', kind: 'exclude' }, // duplicate of 85 (we keep 85 as category)
|
||||
{ ocId: 141, name: '01 - PRODUCTOS DESCATALOGADOS', kind: 'exclude' },
|
||||
{ ocId: 143, name: '02 - PRODUCTOS RAPIDOS', kind: 'exclude' },
|
||||
{ ocId: 145, name: 'GRANOLA', kind: 'category', parentSlug: 'alimentacion' },
|
||||
];
|
||||
|
||||
export interface ImportPlanCategory {
|
||||
ocId: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
parentSlug?: string;
|
||||
}
|
||||
|
||||
export interface ImportPlanBrand {
|
||||
ocId: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface ImportPlan {
|
||||
categories: ImportPlanCategory[];
|
||||
brands: ImportPlanBrand[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the import plan from `LEGACY_ENTRIES`, skipping entries whose
|
||||
* name (case-insensitive) is already present in `existingCategoryNames` or
|
||||
* `existingBrandNames`. Slugs are uniqued against `existingSlugs`.
|
||||
*/
|
||||
export function buildImportPlan(
|
||||
existingCategoryNames: ReadonlySet<string>,
|
||||
existingBrandNames: ReadonlySet<string>,
|
||||
existingSlugs: ReadonlySet<string>,
|
||||
): ImportPlan {
|
||||
const categories: ImportPlanCategory[] = [];
|
||||
const brands: ImportPlanBrand[] = [];
|
||||
const usedSlugs = new Set<string>(existingSlugs);
|
||||
|
||||
for (const entry of LEGACY_ENTRIES) {
|
||||
if (entry.kind === 'exclude') continue;
|
||||
if (entry.kind === 'category') {
|
||||
if (existingCategoryNames.has(entry.name.toLowerCase())) continue;
|
||||
const base = slugify(entry.name);
|
||||
const slug = uniqueSlug(base, usedSlugs);
|
||||
usedSlugs.add(slug.toLowerCase());
|
||||
categories.push({
|
||||
ocId: entry.ocId,
|
||||
name: entry.name,
|
||||
slug,
|
||||
parentSlug: entry.parentSlug,
|
||||
});
|
||||
} else if (entry.kind === 'brand') {
|
||||
if (existingBrandNames.has(entry.name.toLowerCase())) continue;
|
||||
const base = slugify(entry.name);
|
||||
const slug = uniqueSlug(base, usedSlugs);
|
||||
usedSlugs.add(slug.toLowerCase());
|
||||
brands.push({ ocId: entry.ocId, name: entry.name, slug });
|
||||
}
|
||||
}
|
||||
return { categories, brands };
|
||||
}
|
||||
105
project/src/modules/categories/tests/legacy-catalog.test.ts
Normal file
105
project/src/modules/categories/tests/legacy-catalog.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildImportPlan,
|
||||
decodeHtmlEntities,
|
||||
isSameName,
|
||||
LEGACY_ENTRIES,
|
||||
normalizeName,
|
||||
slugify,
|
||||
uniqueSlug,
|
||||
} from '../legacy/legacy-catalog.js';
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it('trims and collapses whitespace', () => {
|
||||
expect(normalizeName(' hello world ')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('decodes HTML entities', () => {
|
||||
expect(normalizeName('NUTS & SEEDS')).toBe('NUTS & SEEDS');
|
||||
expect(normalizeName('A & B C')).toBe('A & B C');
|
||||
});
|
||||
|
||||
it('keeps unknown entities as-is', () => {
|
||||
expect(normalizeName('foo &bogus; bar')).toBe('foo &bogus; bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeHtmlEntities', () => {
|
||||
it('decodes numeric entities', () => {
|
||||
expect(decodeHtmlEntities('&')).toBe('&');
|
||||
expect(decodeHtmlEntities('&')).toBe('&');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSameName', () => {
|
||||
it('compares case-insensitively after normalization', () => {
|
||||
expect(isSameName('nuez', 'NUEZ')).toBe(true);
|
||||
expect(isSameName('NUTS & SEEDS', 'nuts & seeds')).toBe(true);
|
||||
expect(isSameName('Hierbas', 'Hierba')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('lowercases, strips diacritics and replaces non-alphanumerics', () => {
|
||||
expect(slugify('Frutas y Verduras')).toBe('frutas-y-verduras');
|
||||
expect(slugify('NUTS & SEEDS')).toBe('nuts-and-seeds');
|
||||
expect(slugify('A. Vogel')).toBe('a-vogel');
|
||||
expect(slugify('La Finestra Sul Cielo')).toBe('la-finestra-sul-cielo');
|
||||
});
|
||||
|
||||
it('falls back to "entry" for empty input', () => {
|
||||
expect(slugify('---')).toBe('entry');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueSlug', () => {
|
||||
it('returns the base when free', () => {
|
||||
expect(uniqueSlug('aceites', new Set(['frutos-secos']))).toBe('aceites');
|
||||
});
|
||||
|
||||
it('appends a numeric suffix on collision', () => {
|
||||
expect(uniqueSlug('aceites', new Set(['aceites', 'aceites-2']))).toBe('aceites-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildImportPlan', () => {
|
||||
it('classifies every legacy entry as category, brand, or exclude', () => {
|
||||
const kinds = new Set(LEGACY_ENTRIES.map((e) => e.kind));
|
||||
expect(kinds.has('category')).toBe(true);
|
||||
expect(kinds.has('brand')).toBe(true);
|
||||
expect(kinds.has('exclude')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips entries whose name already exists in the catalog (case-insensitive)', () => {
|
||||
const existingCategories = new Set(['nuts & seeds']);
|
||||
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();
|
||||
});
|
||||
|
||||
it('skips excluded legacy markers', () => {
|
||||
const plan = buildImportPlan(new Set(), new Set(), new Set());
|
||||
expect(plan.categories.find((c) => c.name.startsWith('00 -'))).toBeUndefined();
|
||||
expect(plan.categories.find((c) => c.name.startsWith('01 -'))).toBeUndefined();
|
||||
expect(plan.categories.find((c) => c.name.startsWith('02 -'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('produces unique slugs across categories and brands', () => {
|
||||
const plan = buildImportPlan(new Set(), new Set(), new Set());
|
||||
const slugs = [...plan.categories.map((c) => c.slug), ...plan.brands.map((b) => b.slug)];
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
});
|
||||
|
||||
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');
|
||||
expect(nuts?.parentSlug).toBe('frutos-secos');
|
||||
});
|
||||
|
||||
it('does not include brands as categories', () => {
|
||||
const plan = buildImportPlan(new Set(), new Set(), new Set());
|
||||
expect(plan.categories.find((c) => c.name === 'SOLGAR')).toBeUndefined();
|
||||
expect(plan.brands.find((b) => b.name === 'SOLGAR')).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user