From 976fbe9fe1b8b208a378bb5da49c6d984d17ea88 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:12:25 +0100 Subject: [PATCH] feat(db): startbestand tool reads alt_titles column (KAT-01, 3a) Parses de:Title;en:Title (';' forbidden in titles, errors with line number), generates an idempotent reconcile for game_alt_titles, adds an info row to checks.sql and attaches alternative titles to Testspiel B. Regenerated files. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- supabase/catalog/build_startbestand.ts | 96 +++++++++++++++++++ supabase/catalog/build_startbestand_test.ts | 88 ++++++++++++++++- supabase/catalog/checks.sql | 3 + supabase/catalog/testdata/startbestand.csv | 2 +- supabase/tests/support/checks_fixture.inc | 3 + .../tests/support/startbestand_fixture.inc | 15 +++ 6 files changed, 203 insertions(+), 4 deletions(-) diff --git a/supabase/catalog/build_startbestand.ts b/supabase/catalog/build_startbestand.ts index d6723da..a1afa85 100644 --- a/supabase/catalog/build_startbestand.ts +++ b/supabase/catalog/build_startbestand.ts @@ -257,6 +257,12 @@ export interface Game { mechanics: string[]; // name_de description: string; hidden: boolean; + alt_titles: AltTitle[]; +} + +export interface AltTitle { + locale: "de" | "en"; + title: string; } const REQUIRED_COLUMNS = [ @@ -270,6 +276,68 @@ function splitList(value: string): string[] { return value.split(";").map((s) => s.trim()).filter((s) => s !== ""); } +// Annäherung an public.search_normalize (Marken- und Schutzzeichen entfernen, Akzente/Umlaute entfernen, +// Kleinschreibung, alles außer a-z0-9 zu einem Leerzeichen). Maßgeblich bleibt die Datenbank +// (Unique-Constraint game_alt_titles_game_locale_title_key); hier nur für frühe Fehlermeldungen. +const FOLD: Record = { "ß": "ss", "æ": "ae", "œ": "oe", "ø": "o", "đ": "d", "ł": "l", "ð": "d", "þ": "th" }; +export function approxNormalize(s: string): string { + return s + .replace(/[®©™℗℠]/g, "") + .toLowerCase() + .replace(/[ßæœøđłðþ]/g, (c) => FOLD[c]) + .normalize("NFD") + .replace(/\p{M}+/gu, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +// Spalte alt_titles: "de:Titel;en:Title" (Spec #3, 4.3). Sprache und Titel am ersten ":" getrennt. +// ";" ist in Titeln nicht erlaubt (kein Escaping); ein Titel mit ";" fällt als Eintrag ohne passende Sprache auf. +export function parseAltTitles(raw: string, mainTitle: string, fail: (msg: string) => void): AltTitle[] { + const out: AltTitle[] = []; + const seen = new Set(); + const mainNorm = approxNormalize(mainTitle); + const semicolonHint = '(";" ist in alternativen Titeln nicht erlaubt, Titel dieser Art im Studio pflegen)'; + for (const entry of splitList(raw)) { + const colon = entry.indexOf(":"); + if (colon < 0) { + fail(`alt_titles: Eintrag "${entry.slice(0, 40)}" hat kein ":" (Format de:Titel;en:Title) ${semicolonHint}`); + continue; + } + const locale = entry.slice(0, colon).trim(); + const title = entry.slice(colon + 1).trim(); + if (locale !== "de" && locale !== "en") { + fail(`alt_titles: Sprache "${locale.slice(0, 20)}" in "${entry.slice(0, 40)}" muss "de" oder "en" sein ${semicolonHint}`); + continue; + } + if (title === "") { + fail(`alt_titles: leerer Titel bei Sprache "${locale}"`); + continue; + } + if (title.length > 200) { + fail(`alt_titles: Titel "${title.slice(0, 30)}…" länger als 200 Zeichen`); + continue; + } + const norm = approxNormalize(title); + if (norm === "") { + fail(`alt_titles: Titel "${title}" besteht nur aus Sonderzeichen`); + continue; + } + if (norm === mainNorm) { + fail(`alt_titles: "${title}" ist gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen)`); + continue; + } + const k = `${locale}:${norm}`; + if (seen.has(k)) { + fail(`alt_titles: Titel "${title}" doppelt (Sprache ${locale})`); + continue; + } + seen.add(k); + out.push({ locale, title }); + } + return out; +} + export async function parseStartbestand( text: string, vocabulary: VocabEntry[], @@ -363,10 +431,13 @@ export async function parseStartbestand( else if (!allKeys.has(base)) fail(`base_game_key "${base}" hat keinen passenden key (SB7)`); } + const alt_titles = parseAltTitles(cells.alt_titles ?? "", title, fail); + if (problems.length === before) { games.push({ key, id: await uuidV5(`game:${key}`), title, publishers, year, min_players, max_players, min_minutes, max_minutes, min_age, genres, mechanics, description, hidden: hiddenRaw === "ja", + alt_titles, }); } } @@ -515,6 +586,28 @@ export function generateStartbestandSql(games: Game[]): string { reconcile("Genre", "game_genres", "genre_id", "genres", "t.name_de = w.term", (g) => g.genres); reconcile("Mechanik", "game_mechanics", "mechanic_id", "mechanics", "t.name_de = w.term", (g) => g.mechanics); reconcile("Verlag", "game_publishers", "publisher_id", "publishers", "lower(t.name) = lower(w.term)", (g) => g.publishers); + + // 5. Alternative Titel (Spec #3, 4.3): Abgleich je Spiel über die normalisierte Form. Unveränderte + // Zeilen bleiben unberührt (updated_at und manual_fields stabil); Spiele ohne alt_titles verlieren + // früher gesetzte Titel. Keine abgeleitete ID, Konfliktziel ist die Unique-Constraint. + const altRows = games.flatMap((g) => g.alt_titles.map((a) => ` (${q(g.id)}::uuid, ${q(a.locale)}, ${q(a.title)})`)); + out.push("-- Alternative Titel: fehlende anlegen, geänderte Schreibweise aktualisieren, nicht mehr gewünschte löschen"); + if (altRows.length === 0) { + out.push(`delete from public.game_alt_titles a where a.game_id = any(array[${ids}]::uuid[]);`, ""); + } else { + out.push(`with want(game_id, locale, title) as (values`); + out.push(altRows.join(",\n")); + out.push(`), removed as (`); + out.push(` delete from public.game_alt_titles a`); + out.push(` where a.game_id = any(array[${ids}]::uuid[])`); + out.push(` and not exists (select 1 from want w where w.game_id = a.game_id and w.locale = a.locale`); + out.push(` and public.search_normalize(w.title) = a.title_search)`); + out.push(`)`); + out.push(`insert into public.game_alt_titles (game_id, locale, title)`); + out.push(`select game_id, locale, title from want`); + out.push(`on conflict (game_id, locale, title_search) do update set title = excluded.title`); + out.push(`where public.game_alt_titles.title is distinct from excluded.title;`, ""); + } return out.join("\n"); } @@ -559,6 +652,9 @@ export function generateChecksQuery(startbestandIds: string[], vocabulary: Vocab ` select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean`, ` from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)`, ` + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x`, + ` union all`, + ` select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean`, + ` from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x`, `) r`, `order by ord`, ].join("\n"); diff --git a/supabase/catalog/build_startbestand_test.ts b/supabase/catalog/build_startbestand_test.ts index 6ec0e07..60ccebd 100644 --- a/supabase/catalog/build_startbestand_test.ts +++ b/supabase/catalog/build_startbestand_test.ts @@ -7,6 +7,8 @@ import { BuildError, generateStartbestandSql, NAMESPACE, + approxNormalize, + parseAltTitles, parseCsv, parseKeys, parseStartbestand, @@ -250,9 +252,89 @@ Deno.test("hidden = ja erzeugt hidden_at, leer fasst hidden_at nicht an", async assert.doesNotMatch(plain, /update public\.games set/); }); -Deno.test("facts_source, alt_titles und base_game_key landen nicht in der SQL", async () => { - const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", alt_titles: "de:Alt", base_game_key: "" })); - assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|alt_titles|base_game_key|facts_source/); +Deno.test("facts_source und base_game_key landen nicht in der SQL", async () => { + const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", base_game_key: "" })); + assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|base_game_key|facts_source/); +}); + +// ---- alt_titles (KAT-01, Spec #3 4.3) -------------------------------------------------------- +function altProblems(raw: string, main = "Testspiel A"): string[] { + const problems: string[] = []; + parseAltTitles(raw, main, (m) => problems.push(m)); + return problems; +} + +Deno.test("alt_titles: Format de:Titel;en:Title, Leerraum am Rand wird entfernt, ':' im Titel ist erlaubt", () => { + const problems: string[] = []; + const parsed = parseAltTitles(" de: Catan: Das Duell ; en:Test Duel ", "Testspiel A", (m) => problems.push(m)); + assert.deepEqual(problems, []); + assert.deepEqual(parsed, [{ locale: "de", title: "Catan: Das Duell" }, { locale: "en", title: "Test Duel" }]); + assert.deepEqual(parseAltTitles("", "Testspiel A", () => assert.fail("kein Fehler erwartet")), []); +}); + +Deno.test("alt_titles: Sprache, fehlender Doppelpunkt, leerer und zu langer Titel werden abgelehnt", () => { + assert.match(altProblems("fr:Titre").join("\n"), /Sprache "fr".*"de" oder "en"/); + assert.match(altProblems("Nur ein Titel").join("\n"), /kein ":"/); + assert.match(altProblems("de: ").join("\n"), /leerer Titel/); + assert.match(altProblems("de:" + "x".repeat(201)).join("\n"), /länger als 200 Zeichen/); + assert.equal(altProblems("de:" + "x".repeat(200)).length, 0); + assert.match(altProblems("de:!!!").join("\n"), /nur aus Sonderzeichen/); +}); + +Deno.test("alt_titles: Duplikat in derselben Sprache und Titel gleich dem Haupttitel werden abgelehnt", () => { + assert.match(altProblems("de:Café;de:CAFE").join("\n"), /doppelt \(Sprache de\)/); + assert.equal(altProblems("de:Café;en:Café").length, 0, "gleicher Titel in anderer Sprache ist zulässig"); + assert.match(altProblems("de:testspiel-a").join("\n"), /gleich dem Haupttitel/); + assert.match(altProblems("en:CATAN®", "Catan").join("\n"), /gleich dem Haupttitel/); +}); + +Deno.test("alt_titles: ';' im Titel ergibt einen Fehler mit Zeilennummer und Hinweis", async () => { + await rejects( + csv(row(), row({ key: "testspiel-b", title: "Testspiel B", alt_titles: "de:Catan;Das Duell" })), + /startbestand\.csv:3: alt_titles: Eintrag "Das Duell" hat kein ":".*";" ist in alternativen Titeln nicht erlaubt/, + ); + await rejects( + csv(row({ alt_titles: "de:Eins;Zwei: Drei" })), + /startbestand\.csv:2: alt_titles: Sprache "Zwei" .*";" ist in alternativen Titeln nicht erlaubt/, + ); +}); + +Deno.test("alt_titles: Fehler anderer Art nennen die Zeilennummer", async () => { + await rejects(csv(row(), row({ key: "testspiel-b", alt_titles: "fr:X" })), /startbestand\.csv:3: alt_titles: Sprache "fr"/); +}); + +Deno.test("approxNormalize: Marken- und Schutzzeichen, Akzente, ß, Æ, Sonderzeichen", () => { + assert.equal(approxNormalize("CATAN®"), "catan"); + assert.equal(approxNormalize("Café Größe"), "cafe grosse"); + assert.equal(approxNormalize("Æon’s End"), "aeon s end"); + assert.equal(approxNormalize("7 Wonders: Duel"), "7 wonders duel"); + assert.equal(approxNormalize("Ticket to Ride – Europe"), "ticket to ride europe"); + assert.equal(approxNormalize("!!!"), ""); +}); + +Deno.test("SQL alt_titles: Abgleich über normalisierten Titel, Update nur bei geänderter Schreibweise", async () => { + const sql = await sqlFor(row({ alt_titles: "de:Alter Titel;en:Old 'Title'" })); + assert.match(sql, /with want\(game_id, locale, title\) as \(values/); + assert.match(sql, /'de', 'Alter Titel'\)/); + assert.match(sql, /'en', 'Old ''Title'''\)/); // Apostroph verdoppelt + assert.match(sql, /delete from public\.game_alt_titles a/); + assert.match(sql, /public\.search_normalize\(w\.title\) = a\.title_search/); + assert.match(sql, /on conflict \(game_id, locale, title_search\) do update set title = excluded\.title/); + assert.match(sql, /where public\.game_alt_titles\.title is distinct from excluded\.title;/); + assert.doesNotMatch(sql, /game_alt_titles \(id/); // keine abgeleitete ID +}); + +Deno.test("SQL alt_titles: ohne alternative Titel nur das Löschen, nie ein leeres values", async () => { + const sql = await sqlFor(row()); + assert.match(sql, /delete from public\.game_alt_titles a where a\.game_id = any\(array\['/); + assert.doesNotMatch(sql, /with want\(game_id, locale, title\)/); + assert.doesNotMatch(sql, /insert into public\.game_alt_titles/); +}); + +Deno.test("checks.sql: Informationszeile zu Startbestandsspielen mit alternativen Titeln", async () => { + const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row()), previousKeysCsv: "key,uuid\n" }); + assert.match(out["checks.sql"], /'Info-Alt', 'Startbestandsspiele mit alternativen Titeln'/); + assert.match(out["checks.sql"], /count\(distinct a\.game_id\) as n from public\.game_alt_titles a join sb on sb\.id = a\.game_id/); }); Deno.test("SQL: Apostroph im Text wird verdoppelt, Upsert nutzt is distinct from, Zuordnungen werden abgeglichen", async () => { diff --git a/supabase/catalog/checks.sql b/supabase/catalog/checks.sql index 087ffb4..50537aa 100644 --- a/supabase/catalog/checks.sql +++ b/supabase/catalog/checks.sql @@ -82,5 +82,8 @@ from ( select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id) + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x + union all + select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean + from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x ) r order by ord; diff --git a/supabase/catalog/testdata/startbestand.csv b/supabase/catalog/testdata/startbestand.csv index e120cb6..b09c81d 100644 --- a/supabase/catalog/testdata/startbestand.csv +++ b/supabase/catalog/testdata/startbestand.csv @@ -1,4 +1,4 @@ key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key testspiel-a,Testspiel A,Testverlag,2020,2,4,30,60,8,Testgenre A;Testgenre B,Testmechanik A,Fiktives Testspiel A. Nur für automatische Tests.,de,,Testdaten,, -testspiel-b,Testspiel B,Testverlag;Zweiter Testverlag,2021,1,5,10,20,6,Testgenre A,Testmechanik A;Testmechanik B,"Fiktives Testspiel B, mit Komma.",de,,Testdaten,,testspiel-a +testspiel-b,Testspiel B,Testverlag;Zweiter Testverlag,2021,1,5,10,20,6,Testgenre A,Testmechanik A;Testmechanik B,"Fiktives Testspiel B, mit Komma.",de,,Testdaten,de:Testspiel Bravo;en:Test Game Bravo,testspiel-a testspiel-c,Testspiel C: Wo's lang geht,TESTVERLAG,1999,3,3,45,45,12,Testgenre B,Testmechanik B,Fiktives Testspiel C mit Apostroph im Titel.,de,ja,Testdaten,, diff --git a/supabase/tests/support/checks_fixture.inc b/supabase/tests/support/checks_fixture.inc index caaedd7..a1eb32d 100644 --- a/supabase/tests/support/checks_fixture.inc +++ b/supabase/tests/support/checks_fixture.inc @@ -45,5 +45,8 @@ from ( select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id) + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x + union all + select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean + from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x ) r order by ord; diff --git a/supabase/tests/support/startbestand_fixture.inc b/supabase/tests/support/startbestand_fixture.inc index 09a6a2f..0040a6f 100644 --- a/supabase/tests/support/startbestand_fixture.inc +++ b/supabase/tests/support/startbestand_fixture.inc @@ -98,3 +98,18 @@ with want(game_id, term) as (values insert into public.game_publishers (game_id, publisher_id) select game_id, publisher_id from resolved on conflict (game_id, publisher_id) do nothing; + +-- Alternative Titel: fehlende anlegen, geänderte Schreibweise aktualisieren, nicht mehr gewünschte löschen +with want(game_id, locale, title) as (values + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'de', 'Testspiel Bravo'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'en', 'Test Game Bravo') +), removed as ( + delete from public.game_alt_titles a + where a.game_id = any(array['5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', '6577717f-4c1f-5829-af22-ae6bb37c8394', 'c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]) + and not exists (select 1 from want w where w.game_id = a.game_id and w.locale = a.locale + and public.search_normalize(w.title) = a.title_search) +) +insert into public.game_alt_titles (game_id, locale, title) +select game_id, locale, title from want +on conflict (game_id, locale, title_search) do update set title = excluded.title +where public.game_alt_titles.title is distinct from excluded.title;