Expand ZXDB entry data and search

Add entry release/license sections, label permissions/licenses,
expanded search scope (titles+aliases+origins), and home search.
Also include ZXDB submodule and update gitignore.

Signed-off-by: codex@lucy.xalior.com
This commit is contained in:
2026-01-10 18:04:04 +00:00
parent 3e13da5552
commit e2f6aac856
8 changed files with 422 additions and 9 deletions

View File

@@ -5,6 +5,8 @@ import { db } from "@/server/db";
import {
entries,
searchByTitles,
searchByAliases,
searchByOrigins,
labels,
authors,
publishers,
@@ -23,6 +25,12 @@ import {
currencies,
roletypes,
aliases,
relatedlicenses,
licenses,
licensetypes,
licensors,
permissions,
permissiontypes,
webrefs,
websites,
magazines,
@@ -32,6 +40,8 @@ import {
referencetypes,
} from "@/server/schema/zxdb";
export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";
export interface SearchParams {
q?: string;
page?: number; // 1-based
@@ -42,6 +52,8 @@ export interface SearchParams {
machinetypeId?: number;
// Sorting
sort?: "title" | "id_desc";
// Search scope (defaults to titles only)
scope?: EntrySearchScope;
}
export interface SearchResultItem {
@@ -73,6 +85,19 @@ export interface EntryFacets {
machinetypes: FacetItem<number>[];
}
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
const parts: Array<ReturnType<typeof sql>> = [
sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern}`,
];
if (scope !== "title") {
parts.push(sql`select ${searchByAliases.entryId} as entry_id from ${searchByAliases} where ${searchByAliases.libraryTitle} like ${pattern}`);
}
if (scope === "title_aliases_origins") {
parts.push(sql`select ${searchByOrigins.entryId} as entry_id from ${searchByOrigins} where ${searchByOrigins.libraryTitle} like ${pattern}`);
}
return sql.join(parts, sql` union `);
}
export interface MagazineListItem {
id: number;
title: string;
@@ -131,6 +156,7 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
const sort = params.sort ?? (q ? "title" : "id_desc");
const scope: EntrySearchScope = params.scope ?? "title";
if (q.length === 0) {
// Default listing: return first page by id desc (no guaranteed ordering field; using id)
@@ -180,6 +206,41 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
if (scope !== "title") {
try {
const union = buildEntrySearchUnion(pattern, scope);
const countRows = await db.execute(sql`
select count(distinct entry_id) as total
from (${union}) as matches
`);
type CountRow = { total: number | string };
const total = Number((countRows as unknown as CountRow[])[0]?.total ?? 0);
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(sql`${entries.id} in (select entry_id from (${union}) as matches)`)
.groupBy(entries.id)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
.limit(pageSize)
.offset(offset);
return { items, page, pageSize, total };
} catch {
// Fall through to title-only search if helper tables are unavailable.
}
}
// Count matches via helper table
const countRows = await db
.select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` })
@@ -227,6 +288,15 @@ export interface EntryDetail {
genre: { id: number | null; name: string | null };
authors: LabelSummary[];
publishers: LabelSummary[];
licenses?: {
id: number;
name: string;
type: { id: string; name: string | null };
isOfficial: boolean;
linkWikipedia?: string | null;
linkSite?: string | null;
comments?: string | null;
}[];
// Additional entry fields for richer details
maxPlayers?: number;
availabletypeId?: string | null;
@@ -479,6 +549,16 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
// Fetch extra relationships in parallel
let aliasRows: { releaseSeq: number | string; languageId: string; title: string }[] = [];
let webrefRows: { link: string; languageId: string; websiteId: number | string; websiteName: string; websiteLink: string | null }[] = [];
let licenseRows: {
id: number | string;
name: string;
licensetypeId: string;
licensetypeName: string | null;
isOfficial: number | boolean;
linkWikipedia: string | null;
linkSite: string | null;
comments: string | null;
}[] = [];
try {
aliasRows = await db
.select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title })
@@ -493,6 +573,24 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
.where(eq(webrefs.entryId, id));
webrefRows = rows as typeof webrefRows;
} catch {}
try {
const rows = await db
.select({
id: licenses.id,
name: licenses.name,
licensetypeId: licenses.licensetypeId,
licensetypeName: licensetypes.name,
isOfficial: relatedlicenses.isOfficial,
linkWikipedia: licenses.linkWikipedia,
linkSite: licenses.linkSite,
comments: licenses.comments,
})
.from(relatedlicenses)
.innerJoin(licenses, eq(licenses.id, relatedlicenses.licenseId))
.leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId))
.where(eq(relatedlicenses.entryId, id));
licenseRows = rows as typeof licenseRows;
} catch {}
return {
id: base.id,
@@ -503,6 +601,15 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
genre: { id: (base.genreId) ?? null, name: (base.genreName) ?? null },
authors: authorRows,
publishers: publisherRows,
licenses: licenseRows.map((l) => ({
id: Number(l.id),
name: l.name,
type: { id: l.licensetypeId, name: l.licensetypeName ?? null },
isOfficial: !!l.isOfficial,
linkWikipedia: l.linkWikipedia ?? null,
linkSite: l.linkSite ?? null,
comments: l.comments ?? null,
})),
maxPlayers: (base.maxPlayers) ?? undefined,
availabletypeId: (base.availabletypeId) ?? undefined,
withoutLoadScreen: (base.withoutLoadScreen) ?? undefined,
@@ -543,7 +650,21 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
// ----- Labels -----
export type LabelDetail = LabelSummary;
export interface LabelDetail extends LabelSummary {
permissions: {
website: { id: number; name: string; link?: string | null };
type: { id: string; name: string | null };
text: string | null;
}[];
licenses: {
id: number;
name: string;
type: { id: string; name: string | null };
linkWikipedia?: string | null;
linkSite?: string | null;
comments?: string | null;
}[];
}
export interface LabelSearchParams {
q?: string;
@@ -589,7 +710,80 @@ export async function searchLabels(params: LabelSearchParams): Promise<PagedResu
export async function getLabelById(id: number): Promise<LabelDetail | null> {
const rows = await db.select().from(labels).where(eq(labels.id, id)).limit(1);
return (rows[0]) ?? null;
const base = rows[0];
if (!base) return null;
let permissionRows: {
websiteId: number | string;
websiteName: string;
websiteLink: string | null;
permissiontypeId: string;
permissiontypeName: string | null;
text: string | null;
}[] = [];
let licenseRows: {
id: number | string;
name: string;
licensetypeId: string;
licensetypeName: string | null;
linkWikipedia: string | null;
linkSite: string | null;
comments: string | null;
}[] = [];
try {
const rowsPerm = await db
.select({
websiteId: websites.id,
websiteName: websites.name,
websiteLink: websites.link,
permissiontypeId: permissiontypes.id,
permissiontypeName: permissiontypes.name,
text: permissions.text,
})
.from(permissions)
.innerJoin(websites, eq(websites.id, permissions.websiteId))
.leftJoin(permissiontypes, eq(permissiontypes.id, permissions.permissiontypeId))
.where(eq(permissions.labelId, id));
permissionRows = rowsPerm as typeof permissionRows;
} catch {}
try {
const rowsLic = await db
.select({
id: licenses.id,
name: licenses.name,
licensetypeId: licenses.licensetypeId,
licensetypeName: licensetypes.name,
linkWikipedia: licenses.linkWikipedia,
linkSite: licenses.linkSite,
comments: licenses.comments,
})
.from(licensors)
.innerJoin(licenses, eq(licenses.id, licensors.licenseId))
.leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId))
.where(eq(licensors.labelId, id));
licenseRows = rowsLic as typeof licenseRows;
} catch {}
return {
id: base.id,
name: base.name,
labeltypeId: base.labeltypeId,
permissions: permissionRows.map((p) => ({
website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null },
type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null },
text: p.text ?? null,
})),
licenses: licenseRows.map((l) => ({
id: Number(l.id),
name: l.name,
type: { id: l.licensetypeId, name: l.licensetypeName ?? null },
linkWikipedia: l.linkWikipedia ?? null,
linkSite: l.linkSite ?? null,
comments: l.comments ?? null,
})),
};
}
export interface LabelContribsParams {
@@ -1024,11 +1218,21 @@ export async function entriesByMachinetype(
export async function getEntryFacets(params: SearchParams): Promise<EntryFacets> {
const q = (params.q ?? "").trim();
const pattern = q ? `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%` : null;
const scope: EntrySearchScope = params.scope ?? "title";
// Build base WHERE SQL snippet considering q + filters
const whereParts: Array<ReturnType<typeof sql>> = [];
if (pattern) {
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
if (scope !== "title") {
try {
const union = buildEntrySearchUnion(pattern, scope);
whereParts.push(sql`id in (select entry_id from (${union}) as matches)`);
} catch {
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
}
} else {
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
}
}
if (params.genreId) whereParts.push(sql`${entries.genretypeId} = ${params.genreId}`);
if (params.languageId) whereParts.push(sql`${entries.languageId} = ${params.languageId}`);