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:
parent
3bb1c28831
commit
976fbe9fe1
6 changed files with 203 additions and 4 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
2
supabase/catalog/testdata/startbestand.csv
vendored
2
supabase/catalog/testdata/startbestand.csv
vendored
|
|
@ -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,,
|
||||
|
|
|
|||
|
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue