All checks were successful
Filters are evaluated once into an id set used by both search branches. Adds tests for age/genre/mechanic filters with a search term, rank 2 vs 3 and null p_limit. approxNormalize uses NFKD. Perf script checks confirm_local value and unix socket and re-analyzes after rollback. Adds docs/specs/3-katalog-suche-umsetzung.md (measurements, log check incl. GET finding), release checklist section 2a and the CSV-only note for alt titles. Refs #3 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
766 lines
33 KiB
TypeScript
766 lines
33 KiB
TypeScript
// Startbestand-Werkzeug (KAT-05, Spec docs/specs/2-katalog-daten.md, Abschnitt 4.3).
|
|
//
|
|
// Liest die von Hand gepflegten Dateien
|
|
// supabase/catalog/vocabulary.csv (Genres und Mechaniken, de/en)
|
|
// supabase/catalog/startbestand.csv (Spiele; Format siehe docs/katalog-pflege.md)
|
|
// prüft sie (SB2, SB4-SB7) und erzeugt daraus
|
|
// supabase/catalog/vocabulary.sql, startbestand.sql, startbestand-keys.csv, checks.sql
|
|
// supabase/tests/support/{vocabulary,startbestand,checks}_fixture.inc (nur für pgTAP, aus catalog/testdata/,
|
|
// siehe 06_catalog_foundation.test.sql)
|
|
// Die erzeugten Dateien nie von Hand bearbeiten. Keine Abhängigkeiten außer der Deno-Standardumgebung.
|
|
//
|
|
// Aufruf (im Repo-Root):
|
|
// deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts
|
|
//
|
|
// Das Skript erfindet keine Daten. Es erzeugt nie Cover-, Quellen- oder BGG-Angaben (SB6).
|
|
|
|
export const NAMESPACE = "6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4";
|
|
export const MIN_VISIBLE = 180;
|
|
|
|
export const GENERATED_HEADER =
|
|
"-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.\n";
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Fehler
|
|
// ---------------------------------------------------------------------------------------------
|
|
export class BuildError extends Error {
|
|
constructor(public readonly problems: string[]) {
|
|
super(problems.join("\n"));
|
|
this.name = "BuildError";
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// UUID v5 (RFC 4122), gleiches Ergebnis wie uuid_generate_v5() in PostgreSQL
|
|
// ---------------------------------------------------------------------------------------------
|
|
function uuidToBytes(uuid: string): Uint8Array {
|
|
const hex = uuid.replaceAll("-", "");
|
|
const out = new Uint8Array(16);
|
|
for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
return out;
|
|
}
|
|
|
|
export async function uuidV5(name: string, namespace: string = NAMESPACE): Promise<string> {
|
|
const nameBytes = new TextEncoder().encode(name);
|
|
const data = new Uint8Array(16 + nameBytes.length);
|
|
data.set(uuidToBytes(namespace), 0);
|
|
data.set(nameBytes, 16);
|
|
const hash = new Uint8Array(await crypto.subtle.digest("SHA-1", data)).slice(0, 16);
|
|
hash[6] = (hash[6] & 0x0f) | 0x50;
|
|
hash[8] = (hash[8] & 0x3f) | 0x80;
|
|
const hex = Array.from(hash, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// CSV (RFC 4180: Anführungszeichen, "" als Escape, Zeilenumbrüche in Feldern, optionales BOM)
|
|
// ---------------------------------------------------------------------------------------------
|
|
export interface CsvRow {
|
|
line: number;
|
|
cells: Record<string, string>;
|
|
}
|
|
export interface Csv {
|
|
header: string[];
|
|
rows: CsvRow[];
|
|
}
|
|
|
|
export function parseCsv(text: string, file: string): Csv {
|
|
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
const records: { line: number; fields: string[] }[] = [];
|
|
let fields: string[] = [];
|
|
let field = "";
|
|
let inQuotes = false;
|
|
let line = 1;
|
|
let recordLine = 1;
|
|
let touched = false; // Satz hat Inhalt (auch ein leeres Feld nach Komma)
|
|
const problems: string[] = [];
|
|
|
|
const endField = () => {
|
|
fields.push(field);
|
|
field = "";
|
|
};
|
|
const endRecord = () => {
|
|
endField();
|
|
if (touched || fields.length > 1 || fields[0] !== "") {
|
|
records.push({ line: recordLine, fields });
|
|
}
|
|
fields = [];
|
|
touched = false;
|
|
};
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const c = text[i];
|
|
if (inQuotes) {
|
|
if (c === '"') {
|
|
if (text[i + 1] === '"') {
|
|
field += '"';
|
|
i++;
|
|
} else inQuotes = false;
|
|
} else {
|
|
if (c === "\n") line++;
|
|
field += c;
|
|
}
|
|
continue;
|
|
}
|
|
if (c === '"' && field === "") {
|
|
inQuotes = true;
|
|
touched = true;
|
|
} else if (c === ",") {
|
|
touched = true;
|
|
endField();
|
|
} else if (c === "\r") {
|
|
// wird mit dem folgenden \n behandelt; alleinstehendes \r zählt als Zeilenende
|
|
if (text[i + 1] !== "\n") {
|
|
endRecord();
|
|
line++;
|
|
recordLine = line;
|
|
}
|
|
} else if (c === "\n") {
|
|
endRecord();
|
|
line++;
|
|
recordLine = line;
|
|
} else {
|
|
field += c;
|
|
touched = true;
|
|
}
|
|
}
|
|
if (inQuotes) problems.push(`${file}:${recordLine}: Anführungszeichen nicht geschlossen`);
|
|
endRecord();
|
|
|
|
if (problems.length) throw new BuildError(problems);
|
|
if (records.length === 0) throw new BuildError([`${file}: Kopfzeile fehlt`]);
|
|
const header = records[0].fields.map((h) => h.trim());
|
|
const rows: CsvRow[] = [];
|
|
for (const r of records.slice(1)) {
|
|
if (r.fields.length !== header.length) {
|
|
problems.push(
|
|
`${file}:${r.line}: ${r.fields.length} Spalten statt ${header.length} (Komma im Text? Feld in Anführungszeichen setzen)`,
|
|
);
|
|
continue;
|
|
}
|
|
const cells: Record<string, string> = {};
|
|
header.forEach((h, idx) => (cells[h] = r.fields[idx]));
|
|
rows.push({ line: r.line, cells });
|
|
}
|
|
if (problems.length) throw new BuildError(problems);
|
|
return { header, rows };
|
|
}
|
|
|
|
function checkHeader(csv: Csv, file: string, required: string[], optional: string[]): string[] {
|
|
const problems: string[] = [];
|
|
const allowed = new Set([...required, ...optional]);
|
|
for (const col of required) {
|
|
if (!csv.header.includes(col)) problems.push(`${file}:1: Spalte "${col}" fehlt`);
|
|
}
|
|
for (const col of csv.header) {
|
|
if (!allowed.has(col)) {
|
|
// SB6: Es gibt bewusst keine Spalte für Cover, Quelle oder BGG-Kennung.
|
|
problems.push(`${file}:1: unbekannte Spalte "${col}" (erlaubt: ${[...allowed].join(", ")})`);
|
|
}
|
|
}
|
|
const seen = new Set<string>();
|
|
for (const col of csv.header) {
|
|
if (seen.has(col)) problems.push(`${file}:1: Spalte "${col}" doppelt`);
|
|
seen.add(col);
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Vokabular
|
|
// ---------------------------------------------------------------------------------------------
|
|
export type VocabKind = "genre" | "mechanic";
|
|
export interface VocabEntry {
|
|
kind: VocabKind;
|
|
vocab_key: string;
|
|
name_de: string;
|
|
name_en: string;
|
|
id: string;
|
|
}
|
|
|
|
export async function parseVocabulary(text: string, file = "vocabulary.csv"): Promise<VocabEntry[]> {
|
|
const csv = parseCsv(text, file);
|
|
const problems = checkHeader(csv, file, ["kind", "vocab_key", "name_de", "name_en"], []);
|
|
if (problems.length) throw new BuildError(problems);
|
|
|
|
const entries: VocabEntry[] = [];
|
|
const seenKey = new Set<string>();
|
|
const seenDe = new Set<string>();
|
|
const seenEn = new Set<string>();
|
|
for (const { line, cells } of csv.rows) {
|
|
const where = `${file}:${line}`;
|
|
const kind = cells.kind.trim();
|
|
const vocab_key = cells.vocab_key.trim();
|
|
const name_de = cells.name_de.trim();
|
|
const name_en = cells.name_en.trim();
|
|
if (kind !== "genre" && kind !== "mechanic") {
|
|
problems.push(`${where}: kind muss "genre" oder "mechanic" sein, ist "${kind}"`);
|
|
continue;
|
|
}
|
|
if (!/^[a-z0-9-]{2,40}$/.test(vocab_key)) {
|
|
problems.push(`${where}: vocab_key "${vocab_key}" ungültig (a-z, 0-9, Bindestrich, 2-40 Zeichen)`);
|
|
}
|
|
for (const [label, v] of [["name_de", name_de], ["name_en", name_en]] as const) {
|
|
if (v.length < 1 || v.length > 100) problems.push(`${where}: ${label} muss 1-100 Zeichen haben`);
|
|
}
|
|
const k = `${kind}:${vocab_key}`;
|
|
if (seenKey.has(k)) problems.push(`${where}: vocab_key "${vocab_key}" doppelt (${kind})`);
|
|
seenKey.add(k);
|
|
const de = `${kind}:${name_de.toLowerCase()}`;
|
|
if (seenDe.has(de)) problems.push(`${where}: name_de "${name_de}" doppelt (${kind})`);
|
|
seenDe.add(de);
|
|
const en = `${kind}:${name_en.toLowerCase()}`;
|
|
if (seenEn.has(en)) problems.push(`${where}: name_en "${name_en}" doppelt (${kind})`);
|
|
seenEn.add(en);
|
|
entries.push({ kind, vocab_key, name_de, name_en, id: await uuidV5(`${kind}:${vocab_key}`) });
|
|
}
|
|
if (problems.length) throw new BuildError(problems);
|
|
return entries;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Schlüsselliste (append-only)
|
|
// ---------------------------------------------------------------------------------------------
|
|
export function parseKeys(text: string, file = "startbestand-keys.csv"): Map<string, string> {
|
|
const csv = parseCsv(text, file);
|
|
const problems = checkHeader(csv, file, ["key", "uuid"], []);
|
|
if (problems.length) throw new BuildError(problems);
|
|
const keys = new Map<string, string>();
|
|
for (const { line, cells } of csv.rows) {
|
|
if (keys.has(cells.key)) problems.push(`${file}:${line}: key "${cells.key}" doppelt`);
|
|
keys.set(cells.key, cells.uuid);
|
|
}
|
|
if (problems.length) throw new BuildError(problems);
|
|
return keys;
|
|
}
|
|
|
|
export function renderKeys(keys: Map<string, string>): string {
|
|
const lines = [...keys.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, u]) => `${k},${u}`);
|
|
return ["key,uuid", ...lines].join("\n") + "\n";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Startbestand
|
|
// ---------------------------------------------------------------------------------------------
|
|
export interface Game {
|
|
key: string;
|
|
id: string;
|
|
title: string;
|
|
publishers: string[];
|
|
year: number;
|
|
min_players: number;
|
|
max_players: number;
|
|
min_minutes: number;
|
|
max_minutes: number;
|
|
min_age: number;
|
|
genres: string[]; // name_de
|
|
mechanics: string[]; // name_de
|
|
description: string;
|
|
hidden: boolean;
|
|
alt_titles: AltTitle[];
|
|
}
|
|
|
|
export interface AltTitle {
|
|
locale: "de" | "en";
|
|
title: string;
|
|
}
|
|
|
|
const REQUIRED_COLUMNS = [
|
|
"key", "title", "publishers", "year", "min_players", "max_players", "min_minutes", "max_minutes",
|
|
"min_age", "genres", "mechanics", "description", "description_locale",
|
|
];
|
|
const OPTIONAL_COLUMNS = ["hidden", "facts_source", "alt_titles", "base_game_key"];
|
|
const SMALLINT_MAX = 32767;
|
|
|
|
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("NFKD")
|
|
.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[],
|
|
previousKeys: Map<string, string>,
|
|
file = "startbestand.csv",
|
|
): Promise<Game[]> {
|
|
const csv = parseCsv(text, file);
|
|
const problems = checkHeader(csv, file, REQUIRED_COLUMNS, OPTIONAL_COLUMNS);
|
|
if (problems.length) throw new BuildError(problems);
|
|
|
|
const genreNames = new Set(vocabulary.filter((v) => v.kind === "genre").map((v) => v.name_de));
|
|
const mechanicNames = new Set(vocabulary.filter((v) => v.kind === "mechanic").map((v) => v.name_de));
|
|
const games: Game[] = [];
|
|
const seenKeys = new Set<string>();
|
|
const allKeys = new Set(csv.rows.map((r) => r.cells.key.trim()));
|
|
|
|
for (const { line, cells } of csv.rows) {
|
|
const where = `${file}:${line}`;
|
|
const key = cells.key.trim();
|
|
const before = problems.length;
|
|
const fail = (msg: string) => problems.push(`${where}: ${msg}`);
|
|
|
|
if (!/^[a-z0-9-]{2,60}$/.test(key)) fail(`key "${key}" ungültig (a-z, 0-9, Bindestrich, 2-60 Zeichen)`);
|
|
if (seenKeys.has(key)) fail(`key "${key}" doppelt`);
|
|
seenKeys.add(key);
|
|
|
|
const title = cells.title.trim();
|
|
if (title.length < 1 || title.length > 200) fail("title muss 1-200 Zeichen haben");
|
|
|
|
const publishers = splitList(cells.publishers);
|
|
if (publishers.length === 0) fail("mindestens ein Verlag nötig (publishers)");
|
|
const seenPub = new Set<string>();
|
|
for (const p of publishers) {
|
|
if (p.length > 200) fail(`Verlag "${p.slice(0, 30)}…" länger als 200 Zeichen`);
|
|
if (seenPub.has(p.toLowerCase())) fail(`Verlag "${p}" doppelt`);
|
|
seenPub.add(p.toLowerCase());
|
|
}
|
|
|
|
const num = (col: string, min: number, max: number): number => {
|
|
const raw = cells[col].trim();
|
|
if (raw === "") {
|
|
fail(`${col} fehlt (Pflicht-Fakt, SB2)`);
|
|
return NaN;
|
|
}
|
|
if (!/^\d+$/.test(raw)) {
|
|
fail(`${col} "${raw}" ist keine ganze Zahl`);
|
|
return NaN;
|
|
}
|
|
const n = Number(raw);
|
|
if (n < min || n > max) {
|
|
fail(`${col} ${n} außerhalb des erlaubten Bereichs ${min}-${max}`);
|
|
return NaN;
|
|
}
|
|
return n;
|
|
};
|
|
const year = num("year", 1800, 2100);
|
|
const min_players = num("min_players", 1, SMALLINT_MAX);
|
|
const max_players = num("max_players", 1, SMALLINT_MAX);
|
|
const min_minutes = num("min_minutes", 0, SMALLINT_MAX);
|
|
const max_minutes = num("max_minutes", 0, SMALLINT_MAX);
|
|
const min_age = num("min_age", 0, SMALLINT_MAX);
|
|
if (max_players < min_players) fail(`max_players (${max_players}) kleiner als min_players (${min_players})`);
|
|
if (max_minutes < min_minutes) fail(`max_minutes (${max_minutes}) kleiner als min_minutes (${min_minutes})`);
|
|
|
|
const resolveList = (col: "genres" | "mechanics", known: Set<string>, label: string): string[] => {
|
|
const names = splitList(cells[col]);
|
|
if (names.length === 0) fail(`mindestens ${label} nötig (${col}, SB2)`);
|
|
const seen = new Set<string>();
|
|
for (const n of names) {
|
|
if (!known.has(n)) fail(`unbekannt in ${col}: "${n}" (nur Begriffe aus vocabulary.csv, name_de, SB5)`);
|
|
if (seen.has(n)) fail(`${col}: "${n}" doppelt`);
|
|
seen.add(n);
|
|
}
|
|
return names;
|
|
};
|
|
const genres = resolveList("genres", genreNames, "ein Genre");
|
|
const mechanics = resolveList("mechanics", mechanicNames, "eine Mechanik");
|
|
|
|
const description = cells.description.trim();
|
|
if (description === "") fail("Kurztext fehlt (description, SB4)");
|
|
if (description.length > 5000) fail("description länger als 5000 Zeichen");
|
|
const locale = cells.description_locale.trim();
|
|
if (locale !== "de") fail(`description_locale muss "de" sein, ist "${locale}" (SB4)`);
|
|
|
|
const hiddenRaw = (cells.hidden ?? "").trim().toLowerCase();
|
|
if (hiddenRaw !== "" && hiddenRaw !== "ja") fail(`hidden muss leer oder "ja" sein, ist "${cells.hidden}"`);
|
|
|
|
const base = (cells.base_game_key ?? "").trim();
|
|
if (base !== "") {
|
|
if (base === key) fail("base_game_key verweist auf das Spiel selbst");
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Schlüssel sind unveränderlich (W4): Ein früher vorhandener key darf nicht fehlen.
|
|
for (const [key, uuid] of previousKeys) {
|
|
if (!allKeys.has(key)) {
|
|
problems.push(
|
|
`${file}: Schlüssel entfernt oder umbenannt: "${key}" (Spiel nie löschen: Spalte hidden = ja setzen)`,
|
|
);
|
|
}
|
|
const expected = await uuidV5(`game:${key}`);
|
|
if (uuid !== expected) {
|
|
problems.push(`startbestand-keys.csv: uuid zu "${key}" ist ${uuid}, erwartet ${expected} (Namespace geändert?)`);
|
|
}
|
|
}
|
|
|
|
if (problems.length) throw new BuildError(problems);
|
|
return games;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// SQL-Erzeugung
|
|
// ---------------------------------------------------------------------------------------------
|
|
export const q = (s: string): string => `'${s.replaceAll("'", "''")}'`;
|
|
const jsonLit = (v: unknown): string => `${q(JSON.stringify(v))}::jsonb`;
|
|
// Ein Eintrag pro Zeile, damit die erzeugten Dateien lesbar bleiben.
|
|
const jsonLines = (v: unknown[]): string =>
|
|
v.length === 0 ? "'[]'::jsonb" : `${q("[\n" + v.map((x) => " " + JSON.stringify(x)).join(",\n") + "\n]")}::jsonb`;
|
|
const num = (n: number): string => String(n);
|
|
|
|
export function generateVocabularySql(vocabulary: VocabEntry[]): string {
|
|
const out: string[] = [GENERATED_HEADER];
|
|
out.push(
|
|
"-- Vokabular (Genres, Mechaniken). Idempotent. Ein im Studio angelegter Begriff mit gleichem Namen",
|
|
"-- gewinnt (on conflict do nothing fängt ID- und Namenskonflikte); startbestand.sql löst über name_de auf.",
|
|
"",
|
|
);
|
|
for (const [kind, table] of [["genre", "genres"], ["mechanic", "mechanics"]] as const) {
|
|
const rows = vocabulary.filter((v) => v.kind === kind);
|
|
if (rows.length === 0) {
|
|
out.push(`-- keine ${table} im Vokabular`, "");
|
|
continue;
|
|
}
|
|
out.push(`insert into public.${table} (id, name_de, name_en) values`);
|
|
out.push(rows.map((v) => ` (${q(v.id)}, ${q(v.name_de)}, ${q(v.name_en)})`).join(",\n"));
|
|
out.push("on conflict do nothing;", "");
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
export function generateStartbestandSql(games: Game[]): string {
|
|
const out: string[] = [GENERATED_HEADER];
|
|
out.push(
|
|
"-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md).",
|
|
"-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei.",
|
|
"",
|
|
);
|
|
if (games.length === 0) {
|
|
out.push("-- startbestand.csv enthält keine Spiele.", "");
|
|
return out.join("\n");
|
|
}
|
|
|
|
// Wächter: Fehlt ein Begriff (Vokabular nicht eingespielt, im Studio umbenannt), bricht die Datei
|
|
// ab, statt still Zuordnungen zu löschen.
|
|
const genreNames = [...new Set(games.flatMap((g) => g.genres))].sort();
|
|
const mechanicNames = [...new Set(games.flatMap((g) => g.mechanics))].sort();
|
|
out.push(
|
|
"do $guard$",
|
|
"declare",
|
|
" missing text;",
|
|
"begin",
|
|
` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(genreNames)}) as t(n)`,
|
|
" where not exists (select 1 from public.genres g where g.name_de = t.n);",
|
|
" if missing is not null then",
|
|
" raise exception 'startbestand: Genre fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;",
|
|
" end if;",
|
|
` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(mechanicNames)}) as t(n)`,
|
|
" where not exists (select 1 from public.mechanics m where m.name_de = t.n);",
|
|
" if missing is not null then",
|
|
" raise exception 'startbestand: Mechanik fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;",
|
|
" end if;",
|
|
"end",
|
|
"$guard$;",
|
|
"",
|
|
);
|
|
|
|
// 1. Verlage (ohne abgeleitete ID, Auflösung über lower(name))
|
|
const publisherNames = new Map<string, string>(); // lower -> erste Schreibweise
|
|
for (const g of games) for (const p of g.publishers) if (!publisherNames.has(p.toLowerCase())) publisherNames.set(p.toLowerCase(), p);
|
|
out.push("-- Verlage");
|
|
out.push("insert into public.publishers (name) values");
|
|
out.push([...publisherNames.values()].sort().map((p) => ` (${q(p)})`).join(",\n"));
|
|
out.push("on conflict ((lower(name))) do nothing;", "");
|
|
|
|
// 2. Spiele
|
|
const cols = "title, description, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age";
|
|
out.push("-- Spiele (unveränderte Zeilen werden nicht angefasst)");
|
|
out.push(`insert into public.games (id, ${cols}, visibility) values`);
|
|
out.push(
|
|
games.map((g) =>
|
|
` (${q(g.id)}, ${q(g.title)}, ${q(g.description)}, 'de', ${num(g.year)}, ${num(g.min_players)}, ${num(g.max_players)}, ` +
|
|
`${num(g.min_minutes)}, ${num(g.max_minutes)}, ${num(g.min_age)}, 'catalog')`
|
|
).join(",\n"),
|
|
);
|
|
const colList = cols.split(", ");
|
|
out.push("on conflict (id) do update set");
|
|
out.push(colList.map((c) => ` ${c} = excluded.${c}`).join(",\n"));
|
|
out.push(`where (${colList.map((c) => `games.${c}`).join(", ")})`);
|
|
out.push(` is distinct from (${colList.map((c) => `excluded.${c}`).join(", ")});`, "");
|
|
|
|
// 3. Entfernen = ausblenden (nur bei hidden = ja; sonst bleibt hidden_at unberührt)
|
|
const hidden = games.filter((g) => g.hidden);
|
|
if (hidden.length) {
|
|
out.push("-- hidden = ja: ausblenden, nichts löschen");
|
|
out.push(`update public.games set hidden_at = now()`);
|
|
out.push(`where hidden_at is null and id = any(array[${hidden.map((g) => q(g.id)).join(", ")}]::uuid[]);`, "");
|
|
}
|
|
|
|
// 4. Zuordnungen als Abgleich
|
|
const ids = games.map((g) => q(g.id)).join(", ");
|
|
const wantRows = (pick: (g: Game) => string[]) =>
|
|
games.flatMap((g) => pick(g).map((n) => ` (${q(g.id)}::uuid, ${q(n)})`)).join(",\n");
|
|
const reconcile = (
|
|
label: string,
|
|
linkTable: string,
|
|
fkColumn: string,
|
|
termTable: string,
|
|
termMatch: string,
|
|
pick: (g: Game) => string[],
|
|
) => {
|
|
out.push(`-- Zuordnung ${label}: fehlende anlegen, nicht mehr gewünschte löschen`);
|
|
out.push(`with want(game_id, term) as (values`);
|
|
out.push(wantRows(pick));
|
|
out.push(`), resolved as (`);
|
|
out.push(` select w.game_id, t.id as ${fkColumn} from want w join public.${termTable} t on ${termMatch}`);
|
|
out.push(`), removed as (`);
|
|
out.push(` delete from public.${linkTable} l`);
|
|
out.push(` where l.game_id = any(array[${ids}]::uuid[])`);
|
|
out.push(` and not exists (select 1 from resolved r where r.game_id = l.game_id and r.${fkColumn} = l.${fkColumn})`);
|
|
out.push(`)`);
|
|
out.push(`insert into public.${linkTable} (game_id, ${fkColumn})`);
|
|
out.push(`select game_id, ${fkColumn} from resolved`);
|
|
out.push(`on conflict (game_id, ${fkColumn}) do nothing;`, "");
|
|
};
|
|
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");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Prüfabfrage checks.sql (und die Fixture-Variante für pgTAP)
|
|
// ---------------------------------------------------------------------------------------------
|
|
export function generateChecksQuery(startbestandIds: string[], vocabulary: VocabEntry[]): string {
|
|
const sbIds = [...startbestandIds].sort();
|
|
const voc = vocabulary.map((v) => ({ kind: v.kind, id: v.id, name_de: v.name_de }));
|
|
return [
|
|
`with sb(id) as (`,
|
|
` select t.value::uuid from jsonb_array_elements_text(${jsonLines(sbIds)}) as t(value)`,
|
|
`), voc(kind, id, name_de) as (`,
|
|
` select x.kind, x.id, x.name_de from jsonb_to_recordset(${jsonLines(voc)}) as x(kind text, id uuid, name_de text)`,
|
|
`)`,
|
|
`select rule, beschreibung, soll, ist, case when ok is null then 'Information' when ok then 'ok' else 'Verstoß' end as status`,
|
|
`from (`,
|
|
` select 1 as ord, 'SB0' as rule, 'Startbestandsspiele in der Datenbank vorhanden' as beschreibung,`,
|
|
` 'alle ' || (select count(*) from sb) as soll, n as ist, null::boolean as ok`,
|
|
` from (select count(*) as n from public.games g join sb on sb.id = g.id) x`,
|
|
` union all`,
|
|
` select 2, 'SB1', 'Sichtbare Katalogspiele aus dem Startbestand (Abnahme: mindestens ${MIN_VISIBLE})',`,
|
|
` '>= ${MIN_VISIBLE}', n, n >= ${MIN_VISIBLE}`,
|
|
` from (select count(*) as n from public.catalog_games c join sb on sb.id = c.id) x`,
|
|
` union all`,
|
|
` select 3, 'SB2', 'Spiele ohne Pflicht-Fakt (Jahr, Spielerzahl, Dauer, Alter, Verlag, Genre, Mechanik)', '0', n, n = 0`,
|
|
` from (select count(*) as n from public.games g join sb on sb.id = g.id`,
|
|
` where g.year is null or g.min_players is null or g.max_players is null`,
|
|
` or g.min_minutes is null or g.max_minutes is null or g.min_age is null`,
|
|
` or not exists (select 1 from public.game_publishers x where x.game_id = g.id)`,
|
|
` or not exists (select 1 from public.game_genres x where x.game_id = g.id)`,
|
|
` or not exists (select 1 from public.game_mechanics x where x.game_id = g.id)) x`,
|
|
` union all`,
|
|
` select 4, 'SB4', 'Spiele ohne deutschen Kurztext', '0', n, n = 0`,
|
|
` from (select count(*) as n from public.games g join sb on sb.id = g.id`,
|
|
` where g.description is null or g.description_locale is distinct from 'de') x`,
|
|
` union all`,
|
|
` select 5, 'SB6', 'Spiele mit Cover, Quelle oder BGG-Kennung', '0', n, n = 0`,
|
|
` from (select count(*) as n from public.games g join sb on sb.id = g.id`,
|
|
` where g.image_path is not null or g.catalog_source_id is not null or g.bgg_id is not null) x`,
|
|
` union all`,
|
|
` 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");
|
|
}
|
|
|
|
export function generateChecksSql(startbestandIds: string[], vocabulary: VocabEntry[]): string {
|
|
return [
|
|
GENERATED_HEADER.trimEnd(),
|
|
"-- Prüfabfrage zur Abnahme (KAT-05 Krit. 7 und 10). Im Studio-SQL-Editor oder per psql ausführen.",
|
|
"-- Beschränkt sich auf die Spiele aus startbestand-keys.csv; Spiele, die im Studio angelegt wurden,",
|
|
"-- und das Beispielspiel aus seed.sql zählen nicht mit. Abnahme: kein Verstoß, SB1 mindestens " + MIN_VISIBLE + ".",
|
|
"-- Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird.",
|
|
generateChecksQuery(startbestandIds, vocabulary) + ";",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
// Ausschließlich für pgTAP: legt die Prüfabfrage als temporäre View an (psql: \ir). Die IDs stammen
|
|
// aus den Testspielen in catalog/testdata/, damit die Prüfabfrage selbst getestet werden kann.
|
|
export function generateChecksFixtureInclude(startbestandIds: string[], vocabulary: VocabEntry[]): string {
|
|
return [
|
|
"-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.",
|
|
"-- Include für pgTAP (\\ir), keine Test-Datei. Dieselbe Abfrage wie checks.sql, aber mit den IDs der",
|
|
"-- Testspiele aus supabase/catalog/testdata/.",
|
|
"create temp view checks_result as",
|
|
generateChecksQuery(startbestandIds, vocabulary) + ";",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Gesamtlauf
|
|
// ---------------------------------------------------------------------------------------------
|
|
export interface BuildInput {
|
|
vocabularyCsv: string;
|
|
startbestandCsv: string;
|
|
previousKeysCsv: string;
|
|
}
|
|
export interface BuildOutput {
|
|
"vocabulary.sql": string;
|
|
"startbestand.sql": string;
|
|
"startbestand-keys.csv": string;
|
|
"checks.sql": string;
|
|
checksFixtureInclude: string;
|
|
}
|
|
|
|
export async function build(input: BuildInput): Promise<BuildOutput> {
|
|
const vocabulary = await parseVocabulary(input.vocabularyCsv);
|
|
const previous = parseKeys(input.previousKeysCsv);
|
|
const games = await parseStartbestand(input.startbestandCsv, vocabulary, previous);
|
|
const keys = new Map(previous);
|
|
for (const g of games) keys.set(g.key, g.id);
|
|
const ids = [...keys.values()];
|
|
return {
|
|
"vocabulary.sql": generateVocabularySql(vocabulary),
|
|
"startbestand.sql": generateStartbestandSql(games),
|
|
"startbestand-keys.csv": renderKeys(keys),
|
|
"checks.sql": generateChecksSql(ids, vocabulary),
|
|
checksFixtureInclude: generateChecksFixtureInclude(ids, vocabulary),
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const dir = new URL(".", import.meta.url);
|
|
const read = (name: string) => Deno.readTextFile(new URL(name, dir));
|
|
let previousKeysCsv = "key,uuid\n";
|
|
try {
|
|
previousKeysCsv = await read("startbestand-keys.csv");
|
|
} catch (e) {
|
|
if (!(e instanceof Deno.errors.NotFound)) throw e;
|
|
}
|
|
const real = await build({
|
|
vocabularyCsv: await read("vocabulary.csv"),
|
|
startbestandCsv: await read("startbestand.csv"),
|
|
previousKeysCsv,
|
|
});
|
|
// Testdaten (offensichtlich fiktiv) für pgTAP: eigenes Vokabular, eigene Spiele, keine Schlüsselliste.
|
|
const fixture = await build({
|
|
vocabularyCsv: await read("testdata/vocabulary.csv"),
|
|
startbestandCsv: await read("testdata/startbestand.csv"),
|
|
previousKeysCsv: "key,uuid\n",
|
|
});
|
|
|
|
for (const name of ["vocabulary.sql", "startbestand.sql", "startbestand-keys.csv", "checks.sql"] as const) {
|
|
await Deno.writeTextFile(new URL(name, dir), real[name]);
|
|
}
|
|
const support = new URL("../tests/support/", dir);
|
|
await Deno.mkdir(support, { recursive: true });
|
|
await Deno.writeTextFile(new URL("vocabulary_fixture.inc", support), fixture["vocabulary.sql"]);
|
|
await Deno.writeTextFile(new URL("startbestand_fixture.inc", support), fixture["startbestand.sql"]);
|
|
await Deno.writeTextFile(new URL("checks_fixture.inc", support), fixture.checksFixtureInclude);
|
|
console.log(
|
|
"Erzeugt: catalog/{vocabulary.sql,startbestand.sql,startbestand-keys.csv,checks.sql}, " +
|
|
"tests/support/{vocabulary,startbestand,checks}_fixture.inc",
|
|
);
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
try {
|
|
await main();
|
|
} catch (e) {
|
|
if (e instanceof BuildError) {
|
|
console.error("Prüfung fehlgeschlagen:");
|
|
for (const p of e.problems) console.error(" " + p);
|
|
Deno.exit(1);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|