feat(F-136): completed feature
This commit is contained in:
101
project/migrations/042_brand_title_case.js
Normal file
101
project/migrations/042_brand_title_case.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* F-136 — Brand names Title Case + SEO title backfill.
|
||||
*
|
||||
* Brands imported from OpenCart in F-114 are ALL-CAPS (e.g. "A.VOGEL",
|
||||
* "BIOCOP", "COMPLEMENTOS Y NUTRICIÓN") and have `seo_title IS NULL`. The
|
||||
* admin form auto-fills `seo_title` from `name` on create, but existing
|
||||
* rows need to be normalized.
|
||||
*
|
||||
* This migration:
|
||||
* - Title-cases ALL-CAPS brand names ("A.VOGEL" → "A.Vogel",
|
||||
* "COMPLEMENTOS Y NUTRICIÓN" → "Complementos y Nutrición").
|
||||
* - Backfills `seo_title` from the (now Title-Cased) name where the
|
||||
* column is NULL or empty.
|
||||
* - Does NOT touch slugs (they are already lowercase, derived via
|
||||
* `slugify()` from the original ALL-CAPS name).
|
||||
* - Does NOT touch mixed-case names like "BioSana" (idempotency).
|
||||
*
|
||||
* Idempotency:
|
||||
* - Re-running on an already-Title-Cased row is a no-op because
|
||||
* `toTitleCase()` returns the input unchanged when it is not all-caps.
|
||||
* - We only issue an UPDATE if either the name actually changes or the
|
||||
* `seo_title` was empty, so `updated_at` is not bumped unnecessarily.
|
||||
*
|
||||
* Note: the `toTitleCase` logic is inlined here (rather than imported
|
||||
* from `src/shared/text.ts`) so the migration is self-contained and does
|
||||
* not require `npm run build` to run first. The shared helper is the
|
||||
* single source of truth for the future; this inline copy is kept in
|
||||
* sync via the vitest unit tests in `src/shared/tests/text.test.ts`.
|
||||
*
|
||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mirror of `toTitleCase` in `project/src/shared/text.ts`.
|
||||
*
|
||||
* Convert an ALL-CAPS brand/category name to Title Case. Splits on word
|
||||
* boundaries (space, hyphen, dot, ampersand, slash), capitalizes the
|
||||
* first letter of each word, lowercases the rest. Preserves Spanish
|
||||
* single-letter conjunctions (y/e/o/u) in lowercase when they are not
|
||||
* the first word. Returns mixed-case strings unchanged.
|
||||
*
|
||||
* Keep this in sync with `src/shared/text.ts` — the vitest tests cover
|
||||
* the behaviour and would catch any drift.
|
||||
*/
|
||||
function toTitleCase(input) {
|
||||
if (!input) return input;
|
||||
if (input !== input.toUpperCase() || input === input.toLowerCase()) {
|
||||
return input;
|
||||
}
|
||||
const LOWERCASE_WORDS = new Set(['y', 'e', 'o', 'u']);
|
||||
const parts = input.split(/([\s.\-/&]+)/g);
|
||||
let isFirstWord = true;
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (/^[\s.\-/&]+$/.test(part)) return part;
|
||||
if (part.length === 0) return part;
|
||||
const lower = part.toLocaleLowerCase('es-ES');
|
||||
if (!isFirstWord && LOWERCASE_WORDS.has(lower)) return lower;
|
||||
isFirstWord = false;
|
||||
return (
|
||||
part.charAt(0).toLocaleUpperCase('es-ES') +
|
||||
part.slice(1).toLocaleLowerCase('es-ES')
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** True iff `value` is null, undefined, or empty string. */
|
||||
function isBlank(value) {
|
||||
return value === null || value === undefined || value === '';
|
||||
}
|
||||
|
||||
export const up = async (pgm) => {
|
||||
const rows = await pgm.db.select(
|
||||
'SELECT id, name, seo_title FROM brands_brands',
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
for (const row of rows) {
|
||||
const titleCased = toTitleCase(row.name);
|
||||
const newSeoTitle = isBlank(row.seo_title) ? titleCased : row.seo_title;
|
||||
const nameChanged = titleCased !== row.name;
|
||||
const seoChanged = newSeoTitle !== row.seo_title;
|
||||
if (!nameChanged && !seoChanged) continue;
|
||||
await pgm.db.query(
|
||||
`UPDATE brands_brands
|
||||
SET name = $1,
|
||||
seo_title = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[titleCased, newSeoTitle, row.id],
|
||||
);
|
||||
updated += 1;
|
||||
}
|
||||
console.log(`F-136: updated ${updated} brand row(s) (of ${rows.length} total)`);
|
||||
};
|
||||
|
||||
export const down = async () => {
|
||||
// Destructive data migration — no automatic rollback. The previous
|
||||
// names (ALL CAPS) are not recoverable from this migration alone.
|
||||
};
|
||||
76
project/src/shared/tests/text.test.ts
Normal file
76
project/src/shared/tests/text.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { toTitleCase } from '../text.js';
|
||||
|
||||
describe('toTitleCase', () => {
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(toTitleCase('')).toBe('');
|
||||
});
|
||||
|
||||
it('title-cases a single ALL-CAPS word', () => {
|
||||
expect(toTitleCase('BIOCOP')).toBe('Biocop');
|
||||
expect(toTitleCase('LAMBERTS')).toBe('Lamberts');
|
||||
expect(toTitleCase('SOLGAR')).toBe('Solgar');
|
||||
});
|
||||
|
||||
it('title-cases a multi-word ALL-CAPS string with spaces', () => {
|
||||
expect(toTitleCase('EL GRANERO INTEGRAL')).toBe('El Granero Integral');
|
||||
expect(toTitleCase('COMPLEMENTOS Y NUTRICIÓN')).toBe('Complementos y Nutrición');
|
||||
expect(toTitleCase('LA FINESTRA SUL CIELO')).toBe('La Finestra Sul Cielo');
|
||||
expect(toTitleCase('DULCES LISSEN')).toBe('Dulces Lissen');
|
||||
});
|
||||
|
||||
it('preserves dots as word boundaries (A.VOGEL → A.Vogel)', () => {
|
||||
expect(toTitleCase('A.VOGEL')).toBe('A.Vogel');
|
||||
});
|
||||
|
||||
it('preserves hyphens as word boundaries (DAS-BROT → Das-Brot)', () => {
|
||||
expect(toTitleCase('DAS-BROT')).toBe('Das-Brot');
|
||||
});
|
||||
|
||||
it('preserves ampersands as word boundaries (TEA & INFUSIONS → Tea & Infusions)', () => {
|
||||
expect(toTitleCase('TEA & INFUSIONS')).toBe('Tea & Infusions');
|
||||
});
|
||||
|
||||
it('handles Spanish accented characters (NUTRICIÓN → Nutrición)', () => {
|
||||
expect(toTitleCase('COMPLEMENTOS Y NUTRICIÓN')).toBe('Complementos y Nutrición');
|
||||
});
|
||||
|
||||
it('is idempotent on already Title-Cased strings', () => {
|
||||
expect(toTitleCase('La Finestra Sul Cielo')).toBe('La Finestra Sul Cielo');
|
||||
expect(toTitleCase('A.Vogel')).toBe('A.Vogel');
|
||||
expect(toTitleCase('El Granero Integral')).toBe('El Granero Integral');
|
||||
expect(toTitleCase(toTitleCase('A.VOGEL'))).toBe('A.Vogel');
|
||||
});
|
||||
|
||||
it('leaves mixed-case strings unchanged (BioSana)', () => {
|
||||
expect(toTitleCase('BioSana')).toBe('BioSana');
|
||||
});
|
||||
|
||||
it('does not corrupt digits or other non-letter characters in words', () => {
|
||||
expect(toTitleCase('500 KILOS')).toBe('500 Kilos');
|
||||
});
|
||||
|
||||
it('handles multiple consecutive separators', () => {
|
||||
expect(toTitleCase('FOO BAR')).toBe('Foo Bar');
|
||||
expect(toTitleCase('A..VOGEL')).toBe('A..Vogel');
|
||||
});
|
||||
|
||||
it('handles a string that is only separators', () => {
|
||||
expect(toTitleCase(' ')).toBe(' ');
|
||||
expect(toTitleCase('---')).toBe('---');
|
||||
});
|
||||
|
||||
it('keeps single-letter conjunction lowercase (y/e/o/u) when not first', () => {
|
||||
expect(toTitleCase('AGUA Y SAL')).toBe('Agua y Sal');
|
||||
expect(toTitleCase('PADRE E HIJO')).toBe('Padre e Hijo');
|
||||
expect(toTitleCase('SIETE O NUEVE')).toBe('Siete o Nueve');
|
||||
});
|
||||
|
||||
it('keeps single-letter conjunction capitalized when it IS the first word', () => {
|
||||
expect(toTitleCase('Y HIRVIO')).toBe('Y Hirvio');
|
||||
});
|
||||
|
||||
it('does not lowercase non-conjunction single-letter words', () => {
|
||||
expect(toTitleCase('A SIDE B')).toBe('A Side B');
|
||||
});
|
||||
});
|
||||
64
project/src/shared/text.ts
Normal file
64
project/src/shared/text.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Pure string utilities shared across modules.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Spanish single-letter conjunctions that stay lowercase in Title Case,
|
||||
* except when they are the first word of the string.
|
||||
*
|
||||
* Standard Spanish typography rule (RAE): preposiciones y conjunciones
|
||||
* (a, con, de, e, en, o, por, u, y…) van en minúscula salvo que sean la
|
||||
* primera palabra. Single-letter conjunctions are included here so a
|
||||
* "Y" between two words renders correctly.
|
||||
*/
|
||||
const LOWERCASE_WORDS = new Set(['y', 'e', 'o', 'u']);
|
||||
|
||||
/**
|
||||
* Convert a brand/category name to Title Case.
|
||||
*
|
||||
* Splits on word boundaries (space, hyphen, dot, ampersand, slash) and
|
||||
* uppercases the first letter of each word, lowercasing the rest of the
|
||||
* word. Punctuation and whitespace separators are preserved verbatim.
|
||||
*
|
||||
* Behavior:
|
||||
* - If the input is ALL-CAPS (every letter is uppercase and at least one
|
||||
* is lowercase-equivalent), each word is Title Cased and Spanish
|
||||
* single-letter conjunctions (y/e/o/u) are lowercased unless they
|
||||
* are the first word.
|
||||
* - If the input has mixed case (e.g. "BioSana"), it is returned
|
||||
* unchanged. This makes the function idempotent for re-application:
|
||||
* a Title-Cased string is a fixed point, and a mixed-case string is
|
||||
* not what we're trying to fix.
|
||||
* - Empty string → empty string.
|
||||
*
|
||||
* @example
|
||||
* toTitleCase('A.VOGEL') // 'A.Vogel'
|
||||
* toTitleCase('BIOCOP') // 'Biocop'
|
||||
* toTitleCase('EL GRANERO INTEGRAL') // 'El Granero Integral'
|
||||
* toTitleCase('COMPLEMENTOS Y NUTRICIÓN') // 'Complementos y Nutrición'
|
||||
* toTitleCase('DULCES LISSEN') // 'Dulces Lissen'
|
||||
* toTitleCase('La Finestra Sul Cielo') // 'La Finestra Sul Cielo' (unchanged)
|
||||
* toTitleCase('BioSana') // 'BioSana' (unchanged)
|
||||
* toTitleCase('') // ''
|
||||
*/
|
||||
export function toTitleCase(input: string): string {
|
||||
if (!input) return input;
|
||||
// Only transform strings that are entirely uppercase. A mixed-case string
|
||||
// is treated as already-formatted and returned verbatim.
|
||||
if (input !== input.toUpperCase() || input === input.toLowerCase()) {
|
||||
return input;
|
||||
}
|
||||
// Split keeping the delimiters so we can reconstruct the string verbatim.
|
||||
const parts = input.split(/([\s.\-/&]+)/g);
|
||||
let isFirstWord = true;
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (/^[\s.\-/&]+$/.test(part)) return part;
|
||||
if (part.length === 0) return part;
|
||||
const lower = part.toLocaleLowerCase('es-ES');
|
||||
if (!isFirstWord && LOWERCASE_WORDS.has(lower)) return lower;
|
||||
isFirstWord = false;
|
||||
return part.charAt(0).toLocaleUpperCase('es-ES') + part.slice(1).toLocaleLowerCase('es-ES');
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
Reference in New Issue
Block a user