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 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-04 01:12:25 +01:00
parent 3bb1c28831
commit 976fbe9fe1
6 changed files with 203 additions and 4 deletions

View file

@ -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<string, string> = { "ß": "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<string>();
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");

View file

@ -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 () => {

View file

@ -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;

View file

@ -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,,

1 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
2 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
3 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
4 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

View file

@ -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;

View file

@ -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;