import { and, desc, eq, like, sql, asc } from "drizzle-orm"; import { cache } from "react"; // import { alias } from "drizzle-orm/mysql-core"; import { db } from "@/server/db"; import { entries, searchByTitles, searchByAliases, searchByOrigins, labels, authors, publishers, languages, machinetypes, genretypes, files, filetypes, releases, downloads, scraps, schemetypes, sourcetypes, casetypes, availabletypes, currencies, roletypes, aliases, relatedlicenses, licenses, licensetypes, licensors, permissions, permissiontypes, webrefs, websites, magazines, issues, magrefs, searchByMagrefs, referencetypes, } from "@/server/schema/zxdb"; export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins"; export interface SearchParams { q?: string; page?: number; // 1-based pageSize?: number; // default 20 // Optional simple filters (ANDed together) genreId?: number; languageId?: string; machinetypeId?: number; // Sorting sort?: "title" | "id_desc"; // Search scope (defaults to titles only) scope?: EntrySearchScope; } export interface SearchResultItem { id: number; title: string; isXrated: number; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null; } export interface PagedResult { items: T[]; page: number; pageSize: number; total: number; } export interface FacetItem { id: T; name: string; count: number; } export interface EntryFacets { genres: FacetItem[]; languages: FacetItem[]; machinetypes: FacetItem[]; } function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) { const parts: Array> = [ 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; languageId: string; issueCount: number; } export interface MagazineDetail { id: number; title: string; languageId: string; linkSite?: string | null; linkMask?: string | null; archiveMask?: string | null; issues: Array<{ id: number; dateYear: number | null; dateMonth: number | null; number: number | null; volume: number | null; special: string | null; supplement: string | null; linkMask?: string | null; archiveMask?: string | null; }>; } export interface IssueDetail { id: number; magazine: { id: number; title: string }; dateYear: number | null; dateMonth: number | null; number: number | null; volume: number | null; special: string | null; supplement: string | null; linkMask?: string | null; archiveMask?: string | null; refs: Array<{ id: number; page: number; typeId: number; typeName: string; entryId: number | null; entryTitle: string | null; labelId: number | null; labelName: string | null; isOriginal: number; scoreGroup: string; }> } export async function searchEntries(params: SearchParams): Promise> { const q = (params.q ?? "").trim(); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); 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) // Apply optional filters even without q const whereClauses: Array> = []; if (typeof params.genreId === "number") { whereClauses.push(eq(entries.genretypeId, params.genreId)); } if (typeof params.languageId === "string") { whereClauses.push(eq(entries.languageId, params.languageId)); } if (typeof params.machinetypeId === "number") { whereClauses.push(eq(entries.machinetypeId, params.machinetypeId)); } const whereExpr = whereClauses.length ? and(...whereClauses) : undefined; const [items, countRows] = await Promise.all([ (async () => { const q1 = 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(whereExpr ?? sql`true`) .orderBy(sort === "id_desc" ? desc(entries.id) : entries.title) .limit(pageSize) .offset(offset); return q1; })(), db .select({ total: sql`count(*)` }) .from(entries) .where(whereExpr ?? sql`true`), ]); const total = Number(countRows?.[0]?.total ?? 0); return { items, page, pageSize, total }; } 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`count(distinct ${searchByTitles.entryId})` }) .from(searchByTitles) .where(like(searchByTitles.entryTitle, pattern)); const total = Number(countRows[0]?.total ?? 0); // Items using join to entries, distinct entry ids 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(searchByTitles) .innerJoin(entries, eq(entries.id, searchByTitles.entryId)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(languages, eq(languages.id, entries.languageId)) .where(like(searchByTitles.entryTitle, pattern)) .groupBy(entries.id) .orderBy(sort === "id_desc" ? desc(entries.id) : entries.title) .limit(pageSize) .offset(offset); return { items, page, pageSize, total }; } export interface LabelSummary { id: number; name: string; labeltypeId: string | null; } export interface EntryDetail { id: number; title: string; isXrated: number; machinetype: { id: number | null; name: string | null }; language: { id: string | null; name: string | null }; 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; withoutLoadScreen?: number; withoutInlay?: number; issueId?: number | null; files?: { id: number; link: string; size: number | null; md5: string | null; comments: string | null; type: { id: number; name: string }; }[]; // Flat downloads by entry_id (no dependency on releases) downloadsFlat?: { id: number; link: string; size: number | null; md5: string | null; comments: string | null; isDemo: boolean; type: { id: number; name: string }; language: { id: string | null; name: string | null }; machinetype: { id: number | null; name: string | null }; scheme: { id: string | null; name: string | null }; source: { id: string | null; name: string | null }; case: { id: string | null; name: string | null }; year: number | null; releaseSeq: number; }[]; releases?: { releaseSeq: number; type: { id: string | null; name: string | null }; language: { id: string | null; name: string | null }; machinetype: { id: number | null; name: string | null }; year: number | null; comments: string | null; downloads: { id: number; link: string; size: number | null; md5: string | null; comments: string | null; isDemo: boolean; type: { id: number; name: string }; language: { id: string | null; name: string | null }; machinetype: { id: number | null; name: string | null }; scheme: { id: string | null; name: string | null }; source: { id: string | null; name: string | null }; case: { id: string | null; name: string | null }; year: number | null; }[]; }[]; // Additional relationships surfaced on the entry detail page aliases?: { releaseSeq: number; languageId: string; title: string }[]; webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[]; } export async function getEntryById(id: number): Promise { // Run base row + contributors in parallel to reduce latency const [rows, authorRows, publisherRows] = await Promise.all([ db .select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, machinetypeName: machinetypes.name, languageId: entries.languageId, languageName: languages.name, genreId: entries.genretypeId, genreName: genretypes.name, maxPlayers: entries.maxPlayers, availabletypeId: entries.availabletypeId, withoutLoadScreen: entries.withoutLoadScreen, withoutInlay: entries.withoutInlay, issueId: entries.issueId, }) .from(entries) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(languages, eq(languages.id, entries.languageId)) .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId)) .where(eq(entries.id, id)), db .select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId }) .from(authors) .innerJoin(labels, eq(labels.id, authors.labelId)) .where(eq(authors.entryId, id)) .groupBy(labels.id), db .select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId }) .from(publishers) .innerJoin(labels, eq(labels.id, publishers.labelId)) .where(eq(publishers.entryId, id)) .groupBy(labels.id), ]); const base = rows[0]; if (!base) return null; // Fetch related files if the entry is associated with an issue let fileRows: { id: number; link: string; size: number | null; md5: string | null; comments: string | null; typeId: number; typeName: string; }[] = []; if (base.issueId != null) { fileRows = (await db .select({ id: files.id, link: files.fileLink, size: files.fileSize, md5: files.fileMd5, comments: files.comments, typeId: filetypes.id, typeName: filetypes.name, }) .from(files) .innerJoin(filetypes, eq(filetypes.id, files.filetypeId)) .where(eq(files.issueId, base.issueId))); } type ReleaseRow = { releaseSeq: number | string; year: number | string | null }; type DownloadRow = { id: number | string; releaseSeq: number | string; link: string; size: number | string | null; md5: string | null; comments: string | null; isDemo: number | boolean | null; filetypeId: number | string; filetypeName: string; dlLangId: string | null; dlLangName: string | null; dlMachineId: number | string | null; dlMachineName: string | null; schemeId: string | null; schemeName: string | null; sourceId: string | null; sourceName: string | null; caseId: string | null; caseName: string | null; year: number | string | null; }; let releaseRows: ReleaseRow[] = []; let downloadRows: DownloadRow[] = []; let downloadFlatRows: DownloadRow[] = []; // Fetch releases for this entry (optional; ignore if table missing) try { releaseRows = (await db .select({ releaseSeq: releases.releaseSeq, year: releases.releaseYear, }) .from(releases) .where(eq(releases.entryId, id))); } catch { releaseRows = []; } // Fetch downloads for this entry, join lookups (do not gate behind schema checks) try { downloadRows = (await db .select({ id: downloads.id, releaseSeq: downloads.releaseSeq, link: downloads.fileLink, size: downloads.fileSize, md5: downloads.fileMd5, comments: downloads.comments, isDemo: downloads.isDemo, filetypeId: filetypes.id, filetypeName: filetypes.name, dlLangId: downloads.languageId, dlLangName: languages.name, dlMachineId: downloads.machinetypeId, dlMachineName: machinetypes.name, schemeId: schemetypes.id, schemeName: schemetypes.name, sourceId: sourcetypes.id, sourceName: sourcetypes.name, caseId: casetypes.id, caseName: casetypes.name, year: downloads.releaseYear, }) .from(downloads) .innerJoin(filetypes, eq(filetypes.id, downloads.filetypeId)) .leftJoin(languages, eq(languages.id, downloads.languageId)) .leftJoin(machinetypes, eq(machinetypes.id, downloads.machinetypeId)) .leftJoin(schemetypes, eq(schemetypes.id, downloads.schemetypeId)) .leftJoin(sourcetypes, eq(sourcetypes.id, downloads.sourcetypeId)) .leftJoin(casetypes, eq(casetypes.id, downloads.casetypeId)) .where(eq(downloads.entryId, id))); } catch { downloadRows = []; } // Flat list: same rows mapped, independent of releases downloadFlatRows = downloadRows; const downloadsBySeq = new Map(); for (const row of downloadRows) { const key = Number(row.releaseSeq); const arr = downloadsBySeq.get(key) ?? []; arr.push(row); downloadsBySeq.set(key, arr); } // Build a map of downloads grouped by release_seq // Then ensure we create "synthetic" release groups for any release_seq // that appears in downloads but has no corresponding releases row. const releasesData = releaseRows.map((r) => ({ releaseSeq: Number(r.releaseSeq), type: { id: null, name: null }, language: { id: null, name: null }, machinetype: { id: null, name: null }, year: r.year != null ? Number(r.year) : null, comments: null, downloads: (downloadsBySeq.get(Number(r.releaseSeq)) ?? []).map((d) => ({ id: Number(d.id), link: d.link, size: d.size != null ? Number(d.size) : null, md5: d.md5 ?? null, comments: d.comments ?? null, isDemo: !!d.isDemo, type: { id: Number(d.filetypeId), name: d.filetypeName }, language: { id: (d.dlLangId) ?? null, name: (d.dlLangName) ?? null }, machinetype: { id: d.dlMachineId != null ? Number(d.dlMachineId) : null, name: (d.dlMachineName) ?? null }, scheme: { id: (d.schemeId) ?? null, name: (d.schemeName) ?? null }, source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null }, case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null }, year: d.year != null ? Number(d.year) : null, })), })); // No synthetic release groups: only real releases are returned // Sort releases by sequence for stable UI order releasesData.sort((a, b) => a.releaseSeq - b.releaseSeq); // 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 }) .from(aliases) .where(eq(aliases.entryId, id)); } catch {} try { const rows = await db .select({ link: webrefs.link, languageId: webrefs.languageId, websiteId: websites.id, websiteName: websites.name, websiteLink: websites.link }) .from(webrefs) .innerJoin(websites, eq(websites.id, webrefs.websiteId)) .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, title: base.title, isXrated: base.isXrated, machinetype: { id: (base.machinetypeId) ?? null, name: (base.machinetypeName) ?? null }, language: { id: (base.languageId) ?? null, name: (base.languageName) ?? 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, withoutInlay: (base.withoutInlay) ?? undefined, issueId: (base.issueId) ?? undefined, files: fileRows.length > 0 ? fileRows.map((f) => ({ id: f.id, link: f.link, size: f.size ?? null, md5: f.md5 ?? null, comments: f.comments ?? null, type: { id: f.typeId, name: f.typeName }, })) : [], releases: releasesData, downloadsFlat: downloadFlatRows.map((d) => ({ id: Number(d.id), link: d.link, size: d.size != null ? Number(d.size) : null, md5: d.md5 ?? null, comments: d.comments ?? null, isDemo: !!d.isDemo, type: { id: Number(d.filetypeId), name: d.filetypeName }, language: { id: (d.dlLangId) ?? null, name: (d.dlLangName) ?? null }, machinetype: { id: d.dlMachineId != null ? Number(d.dlMachineId) : null, name: (d.dlMachineName) ?? null }, scheme: { id: (d.schemeId) ?? null, name: (d.schemeName) ?? null }, source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null }, case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null }, year: d.year != null ? Number(d.year) : null, releaseSeq: Number(d.releaseSeq), })), aliases: aliasRows.map((a) => ({ releaseSeq: Number(a.releaseSeq), languageId: a.languageId, title: a.title })), webrefs: webrefRows.map((w) => ({ link: w.link, languageId: w.languageId, website: { id: Number(w.websiteId), name: w.websiteName, link: w.websiteLink } })), }; } // ----- Labels ----- 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; page?: number; pageSize?: number; } export async function searchLabels(params: LabelSearchParams): Promise> { const q = (params.q ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, ""); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; if (!q) { const [items, countRows] = await Promise.all([ db.select().from(labels).orderBy(labels.name).limit(pageSize).offset(offset), db .select({ total: sql`count(*)` }) .from(labels) as unknown as Promise<{ total: number }[]>, ]); const total = Number(countRows?.[0]?.total ?? 0); return { items: items, page, pageSize, total }; } // Using helper search_by_names for efficiency via subselect to avoid raw identifier typing const pattern = `%${q}%`; const countRows = await db .select({ total: sql`count(*)` }) .from(labels) .where(sql`${labels.id} in (select distinct label_id from search_by_names where label_name like ${pattern})`); const total = Number(countRows[0]?.total ?? 0); const items = await db .select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId }) .from(labels) .where(sql`${labels.id} in (select distinct label_id from search_by_names where label_name like ${pattern})`) .orderBy(labels.name) .limit(pageSize) .offset(offset); return { items: items, page, pageSize, total }; } export async function getLabelById(id: number): Promise { const rows = await db.select().from(labels).where(eq(labels.id, id)).limit(1); 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 { page?: number; pageSize?: number; q?: string; // optional title filter } export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise> { const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; const hasQ = !!(params.q && params.q.trim()); if (!hasQ) { const countRows = await db .select({ total: sql`count(distinct ${authors.entryId})` }) .from(authors) .where(eq(authors.labelId, labelId)); const total = Number(countRows[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(authors) .innerJoin(entries, eq(entries.id, authors.entryId)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(languages, eq(languages.id, entries.languageId)) .where(eq(authors.labelId, labelId)) .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items: items, page, pageSize, total }; } const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; const countRows = await db .select({ total: sql`count(distinct ${entries.id})` }) .from(authors) .innerJoin(entries, eq(entries.id, authors.entryId)) .where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)); const total = Number((countRows)[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(authors) .innerJoin(entries, eq(entries.id, authors.entryId)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(languages, eq(languages.id, entries.languageId)) .where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)) .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items: items, page, pageSize, total }; } export async function getLabelPublishedEntries(labelId: number, params: LabelContribsParams): Promise> { const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; const hasQ = !!(params.q && params.q.trim()); if (!hasQ) { const countRows = await db .select({ total: sql`count(distinct ${publishers.entryId})` }) .from(publishers) .where(eq(publishers.labelId, labelId)); const total = Number(countRows[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(publishers) .innerJoin(entries, eq(entries.id, publishers.entryId)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(languages, eq(languages.id, entries.languageId)) .where(eq(publishers.labelId, labelId)) .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items: items, page, pageSize, total }; } const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; const countRows = await db .select({ total: sql`count(distinct ${entries.id})` }) .from(publishers) .innerJoin(entries, eq(entries.id, publishers.entryId)) .where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)); const total = Number((countRows)[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(publishers) .innerJoin(entries, eq(entries.id, publishers.entryId)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId)) .leftJoin(languages, eq(languages.id, entries.languageId)) .where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)) .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items: items, page, pageSize, total }; } // ----- Lookups lists and category browsing ----- export const listGenres = cache(async () => db.select().from(genretypes).orderBy(genretypes.name)); export const listLanguages = cache(async () => db.select().from(languages).orderBy(languages.name)); export const listMachinetypes = cache(async () => db.select().from(machinetypes).orderBy(machinetypes.name)); // Note: ZXDB structure in this project does not include a `releasetypes` table. // Do not attempt to query it here. // Search with pagination for lookups export interface SimpleSearchParams { q?: string; page?: number; pageSize?: number; } export async function searchLanguages(params: SimpleSearchParams) { const q = (params.q ?? "").trim(); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; if (!q) { const [items, countRows] = await Promise.all([ db.select().from(languages).orderBy(languages.name).limit(pageSize).offset(offset), db.select({ total: sql`count(*)` }).from(languages), ]); const total = Number(countRows?.[0]?.total ?? 0); return { items, page, pageSize, total }; } const pattern = `%${q}%`; const [items, countRows] = await Promise.all([ db .select() .from(languages) .where(like(languages.name, pattern)) .orderBy(languages.name) .limit(pageSize) .offset(offset), db.select({ total: sql`count(*)` }).from(languages).where(like(languages.name, pattern)), ]); const total = Number(countRows?.[0]?.total ?? 0); return { items, page, pageSize, total }; } export async function searchGenres(params: SimpleSearchParams) { const q = (params.q ?? "").trim(); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; if (!q) { const [items, countRows] = await Promise.all([ db.select().from(genretypes).orderBy(genretypes.name).limit(pageSize).offset(offset), db.select({ total: sql`count(*)` }).from(genretypes), ]); const total = Number(countRows?.[0]?.total ?? 0); return { items, page, pageSize, total }; } const pattern = `%${q}%`; const [items, countRows] = await Promise.all([ db .select() .from(genretypes) .where(like(genretypes.name, pattern)) .orderBy(genretypes.name) .limit(pageSize) .offset(offset), db.select({ total: sql`count(*)` }).from(genretypes).where(like(genretypes.name, pattern)), ]); const total = Number(countRows?.[0]?.total ?? 0); return { items, page, pageSize, total }; } export async function searchMachinetypes(params: SimpleSearchParams) { const q = (params.q ?? "").trim(); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; if (!q) { const [items, countRows] = await Promise.all([ db.select().from(machinetypes).orderBy(machinetypes.name).limit(pageSize).offset(offset), db.select({ total: sql`count(*)` }).from(machinetypes), ]); const total = Number(countRows?.[0]?.total ?? 0); return { items, page, pageSize, total }; } const pattern = `%${q}%`; const [items, countRows] = await Promise.all([ db .select() .from(machinetypes) .where(like(machinetypes.name, pattern)) .orderBy(machinetypes.name) .limit(pageSize) .offset(offset), db.select({ total: sql`count(*)` }).from(machinetypes).where(like(machinetypes.name, pattern)), ]); const total = Number((countRows as { total: number }[])[0]?.total ?? 0); return { items, page, pageSize, total }; } export async function entriesByGenre( genreId: number, page: number, pageSize: number, q?: string ): Promise> { const offset = (page - 1) * pageSize; const hasQ = !!(q && q.trim()); if (!hasQ) { const countRows = await db .select({ total: sql`count(*)` }) .from(entries) .where(eq(entries.genretypeId, genreId)); 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(eq(entries.genretypeId, genreId)) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; } const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; const countRows = await db .select({ total: sql`count(distinct ${entries.id})` }) .from(entries) .where(and(eq(entries.genretypeId, genreId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)); const total = Number(countRows[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(and(eq(entries.genretypeId, genreId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)) .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items, page, pageSize, total }; } export async function entriesByLanguage( langId: string, page: number, pageSize: number, q?: string ): Promise> { const offset = (page - 1) * pageSize; const hasQ = !!(q && q.trim()); if (!hasQ) { const countRows = await db .select({ total: sql`count(*)` }) .from(entries) .where(eq(entries.languageId, langId)); 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(eq(entries.languageId, langId)) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; } const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; const countRows = await db .select({ total: sql`count(distinct ${entries.id})` }) .from(entries) .where(and(eq(entries.languageId, langId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)); const total = Number(countRows[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(and(eq(entries.languageId, langId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)) .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items, page, pageSize, total }; } export async function entriesByMachinetype( mtId: number, page: number, pageSize: number, q?: string ): Promise> { const offset = (page - 1) * pageSize; const hasQ = !!(q && q.trim()); if (!hasQ) { const countRows = await db .select({ total: sql`count(*)` }) .from(entries) .where(eq(entries.machinetypeId, mtId)); 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(eq(entries.machinetypeId, mtId)) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; } const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; const countRows = await db .select({ total: sql`count(distinct ${entries.id})` }) .from(entries) .where(and(eq(entries.machinetypeId, mtId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)); const total = Number(countRows[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(and(eq(entries.machinetypeId, mtId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`)) .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); return { items, page, pageSize, total }; } // ----- Facets for search ----- export async function getEntryFacets(params: SearchParams): Promise { 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> = []; if (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}`); if (params.machinetypeId) whereParts.push(sql`${entries.machinetypeId} = ${params.machinetypeId}`); const whereSql = whereParts.length ? sql.join([sql`where `, sql.join(whereParts, sql` and `)], sql``) : sql``; // Genres facet const genresRows = await db.execute(sql` select e.genretype_id as id, gt.text as name, count(*) as count from ${entries} as e left join ${genretypes} as gt on gt.id = e.genretype_id ${whereSql} group by e.genretype_id, gt.text order by count desc, name asc `); // Languages facet const langRows = await db.execute(sql` select e.language_id as id, l.text as name, count(*) as count from ${entries} as e left join ${languages} as l on l.id = e.language_id ${whereSql} group by e.language_id, l.text order by count desc, name asc `); // Machinetypes facet const mtRows = await db.execute(sql` select e.machinetype_id as id, m.text as name, count(*) as count from ${entries} as e left join ${machinetypes} as m on m.id = e.machinetype_id ${whereSql} group by e.machinetype_id, m.text order by count desc, name asc `); type FacetRow = { id: number | string | null; name: string | null; count: number | string }; return { genres: (genresRows as unknown as FacetRow[]) .map((r) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })) .filter((r) => !!r.id), languages: (langRows as unknown as FacetRow[]) .map((r) => ({ id: String(r.id), name: r.name ?? "(none)", count: Number(r.count) })) .filter((r) => !!r.id), machinetypes: (mtRows as unknown as FacetRow[]) .map((r) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })) .filter((r) => !!r.id), }; } // ----- Releases search (browser) ----- export interface ReleaseSearchParams { q?: string; // match entry title via helper search page?: number; pageSize?: number; year?: number; sort?: "year_desc" | "year_asc" | "title" | "entry_id_desc"; // Optional download-based filters (matched via EXISTS on downloads) dLanguageId?: string; // downloads.language_id dMachinetypeId?: number; // downloads.machinetype_id filetypeId?: number; // downloads.filetype_id schemetypeId?: string; // downloads.schemetype_id sourcetypeId?: string; // downloads.sourcetype_id casetypeId?: string; // downloads.casetype_id isDemo?: boolean; // downloads.is_demo } export interface ReleaseListItem { entryId: number; releaseSeq: number; entryTitle: string; year: number | null; magrefCount: number; } export async function searchReleases(params: ReleaseSearchParams): Promise> { const q = (params.q ?? "").trim(); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; // Build WHERE conditions in Drizzle QB const wherePartsQB: Array> = []; if (q) { const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; wherePartsQB.push(sql`${releases.entryId} in (select ${searchByTitles.entryId} from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`); } if (params.year != null) { wherePartsQB.push(eq(releases.releaseYear, params.year)); } // Optional filters via downloads table: use EXISTS for performance and correctness // IMPORTANT: when hand-writing SQL with an aliased table, we must render // "from downloads as d" explicitly; using only the alias identifier ("d") // would produce "from `d`" which MySQL interprets as a literal table. const dlConds: Array> = []; if (params.dLanguageId) dlConds.push(sql`d.language_id = ${params.dLanguageId}`); if (params.dMachinetypeId != null) dlConds.push(sql`d.machinetype_id = ${params.dMachinetypeId}`); if (params.filetypeId != null) dlConds.push(sql`d.filetype_id = ${params.filetypeId}`); if (params.schemetypeId) dlConds.push(sql`d.schemetype_id = ${params.schemetypeId}`); if (params.sourcetypeId) dlConds.push(sql`d.sourcetype_id = ${params.sourcetypeId}`); if (params.casetypeId) dlConds.push(sql`d.casetype_id = ${params.casetypeId}`); if (params.isDemo != null) dlConds.push(sql`d.is_demo = ${params.isDemo ? 1 : 0}`); if (dlConds.length) { const baseConds = [ sql`d.entry_id = ${releases.entryId}`, sql`d.release_seq = ${releases.releaseSeq}`, ...dlConds, ]; wherePartsQB.push(sql`exists (select 1 from ${downloads} as d where ${sql.join(baseConds, sql` and `)})`); } const whereExpr = wherePartsQB.length ? and(...wherePartsQB) : undefined; // Count total const countRows = await db .select({ total: sql`count(*)` }) .from(releases) .where(whereExpr ?? sql`true`); const total = Number(countRows?.[0]?.total ?? 0); // Rows via Drizzle QB to avoid tuple/field leakage let orderBy1; let orderBy2; let orderBy3; switch (params.sort) { case "year_asc": orderBy1 = asc(releases.releaseYear); orderBy2 = asc(releases.entryId); orderBy3 = asc(releases.releaseSeq); break; case "title": orderBy1 = asc(entries.title); orderBy2 = desc(releases.releaseYear); orderBy3 = asc(releases.releaseSeq); break; case "entry_id_desc": orderBy1 = desc(releases.entryId); orderBy2 = desc(releases.releaseSeq); break; case "year_desc": default: orderBy1 = desc(releases.releaseYear); orderBy2 = desc(releases.entryId); orderBy3 = desc(releases.releaseSeq); break; } const rowsQB = await db .select({ entryId: releases.entryId, releaseSeq: releases.releaseSeq, entryTitle: entries.title, year: releases.releaseYear, }) .from(releases) .leftJoin(entries, eq(entries.id, releases.entryId)) .where(whereExpr ?? sql`true`) .orderBy(orderBy1!, ...(orderBy2 ? [orderBy2] : []), ...(orderBy3 ? [orderBy3] : [])) .limit(pageSize) .offset(offset); const entryIds = Array.from(new Set(rowsQB.map((r) => Number(r.entryId)).filter((id) => Number.isFinite(id)))); const magrefCounts = new Map(); if (entryIds.length > 0) { try { const values = entryIds.map((id) => sql`${id}`); const rows = await db.execute(sql` select ${searchByMagrefs.entryId} as entryId, count(*) as count from ${searchByMagrefs} where ${searchByMagrefs.entryId} in (${sql.join(values, sql`, `)}) group by ${searchByMagrefs.entryId} `); type CountRow = { entryId: number | string; count: number | string }; for (const row of rows as unknown as CountRow[]) { magrefCounts.set(Number(row.entryId), Number(row.count)); } } catch { // Helper table might be missing; default to 0 counts. } } // Ensure plain primitives const items: ReleaseListItem[] = rowsQB.map((r) => ({ entryId: Number(r.entryId), releaseSeq: Number(r.releaseSeq), entryTitle: r.entryTitle ?? "", year: r.year != null ? Number(r.year) : null, magrefCount: magrefCounts.get(Number(r.entryId)) ?? 0, })); return { items, page, pageSize, total }; } export interface ReleaseDetail { entry: { id: number; title: string; issueId: number | null; }; entryReleases: Array<{ releaseSeq: number; year: number | null; }>; release: { entryId: number; releaseSeq: number; year: number | null; month: number | null; day: number | null; currency: { id: string | null; name: string | null; symbol: string | null; prefix: number | null }; prices: { release: number | null; budget: number | null; microdrive: number | null; disk: number | null; cartridge: number | null; }; book: { isbn: string | null; pages: number | null }; }; downloads: Array<{ id: number; link: string; size: number | null; md5: string | null; comments: string | null; isDemo: boolean; type: { id: number; name: string }; language: { id: string | null; name: string | null }; machinetype: { id: number | null; name: string | null }; scheme: { id: string | null; name: string | null }; source: { id: string | null; name: string | null }; case: { id: string | null; name: string | null }; year: number | null; }>; scraps: Array<{ id: number; link: string | null; size: number | null; comments: string | null; rationale: string; isDemo: boolean; type: { id: number; name: string }; language: { id: string | null; name: string | null }; machinetype: { id: number | null; name: string | null }; scheme: { id: string | null; name: string | null }; source: { id: string | null; name: string | null }; case: { id: string | null; name: string | null }; year: number | null; }>; files: Array<{ id: number; link: string; size: number | null; md5: string | null; comments: string | null; type: { id: number; name: string }; }>; magazineRefs: Array<{ id: number; issueId: number; magazineId: number | null; magazineName: string | null; referencetypeId: number; referencetypeName: string | null; page: number; isOriginal: number; scoreGroup: string; issue: { dateYear: number | null; dateMonth: number | null; dateDay: number | null; volume: number | null; number: number | null; special: string | null; supplement: string | null; }; }>; } export async function getReleaseDetail(entryId: number, releaseSeq: number): Promise { const rows = await db .select({ entryId: releases.entryId, releaseSeq: releases.releaseSeq, year: releases.releaseYear, month: releases.releaseMonth, day: releases.releaseDay, currencyId: releases.currencyId, currencyName: currencies.name, currencySymbol: currencies.symbol, currencyPrefix: currencies.prefix, releasePrice: releases.releasePrice, budgetPrice: releases.budgetPrice, microdrivePrice: releases.microdrivePrice, diskPrice: releases.diskPrice, cartridgePrice: releases.cartridgePrice, bookIsbn: releases.bookIsbn, bookPages: releases.bookPages, entryTitle: entries.title, issueId: entries.issueId, }) .from(releases) .leftJoin(entries, eq(entries.id, releases.entryId)) .leftJoin(currencies, eq(currencies.id, releases.currencyId)) .where(and(eq(releases.entryId, entryId), eq(releases.releaseSeq, releaseSeq))) .limit(1); const base = rows[0]; if (!base) return null; const entryReleaseRows = await db .select({ releaseSeq: releases.releaseSeq, year: releases.releaseYear, }) .from(releases) .where(eq(releases.entryId, entryId)) .orderBy(asc(releases.releaseSeq)); type DownloadRow = { id: number | string; link: string; size: number | string | null; md5: string | null; comments: string | null; isDemo: number | boolean | null; filetypeId: number | string; filetypeName: string; dlLangId: string | null; dlLangName: string | null; dlMachineId: number | string | null; dlMachineName: string | null; schemeId: string | null; schemeName: string | null; sourceId: string | null; sourceName: string | null; caseId: string | null; caseName: string | null; year: number | string | null; }; type ScrapRow = DownloadRow & { rationale: string }; const downloadRows = await db .select({ id: downloads.id, link: downloads.fileLink, size: downloads.fileSize, md5: downloads.fileMd5, comments: downloads.comments, isDemo: downloads.isDemo, filetypeId: filetypes.id, filetypeName: filetypes.name, dlLangId: downloads.languageId, dlLangName: languages.name, dlMachineId: downloads.machinetypeId, dlMachineName: machinetypes.name, schemeId: schemetypes.id, schemeName: schemetypes.name, sourceId: sourcetypes.id, sourceName: sourcetypes.name, caseId: casetypes.id, caseName: casetypes.name, year: downloads.releaseYear, }) .from(downloads) .innerJoin(filetypes, eq(filetypes.id, downloads.filetypeId)) .leftJoin(languages, eq(languages.id, downloads.languageId)) .leftJoin(machinetypes, eq(machinetypes.id, downloads.machinetypeId)) .leftJoin(schemetypes, eq(schemetypes.id, downloads.schemetypeId)) .leftJoin(sourcetypes, eq(sourcetypes.id, downloads.sourcetypeId)) .leftJoin(casetypes, eq(casetypes.id, downloads.casetypeId)) .where(and(eq(downloads.entryId, entryId), eq(downloads.releaseSeq, releaseSeq))); const scrapRows = await db .select({ id: scraps.id, link: scraps.fileLink, size: scraps.fileSize, comments: scraps.comments, rationale: scraps.rationale, isDemo: scraps.isDemo, filetypeId: filetypes.id, filetypeName: filetypes.name, dlLangId: scraps.languageId, dlLangName: languages.name, dlMachineId: scraps.machinetypeId, dlMachineName: machinetypes.name, schemeId: schemetypes.id, schemeName: schemetypes.name, sourceId: sourcetypes.id, sourceName: sourcetypes.name, caseId: casetypes.id, caseName: casetypes.name, year: scraps.releaseYear, }) .from(scraps) .innerJoin(filetypes, eq(filetypes.id, scraps.filetypeId)) .leftJoin(languages, eq(languages.id, scraps.languageId)) .leftJoin(machinetypes, eq(machinetypes.id, scraps.machinetypeId)) .leftJoin(schemetypes, eq(schemetypes.id, scraps.schemetypeId)) .leftJoin(sourcetypes, eq(sourcetypes.id, scraps.sourcetypeId)) .leftJoin(casetypes, eq(casetypes.id, scraps.casetypeId)) .where(and(eq(scraps.entryId, entryId), eq(scraps.releaseSeq, releaseSeq))); const fileRows = base.issueId != null ? await db .select({ id: files.id, link: files.fileLink, size: files.fileSize, md5: files.fileMd5, comments: files.comments, typeId: filetypes.id, typeName: filetypes.name, }) .from(files) .innerJoin(filetypes, eq(filetypes.id, files.filetypeId)) .where(eq(files.issueId, base.issueId)) : []; const magazineRefs = await db .select({ id: magrefs.id, issueId: magrefs.issueId, magazineId: magazines.id, magazineName: magazines.name, referencetypeId: magrefs.referencetypeId, referencetypeName: referencetypes.name, page: magrefs.page, isOriginal: magrefs.isOriginal, scoreGroup: magrefs.scoreGroup, issueDateYear: issues.dateYear, issueDateMonth: issues.dateMonth, issueDateDay: issues.dateDay, issueVolume: issues.volume, issueNumber: issues.number, issueSpecial: issues.special, issueSupplement: issues.supplement, }) .from(searchByMagrefs) .innerJoin(magrefs, eq(magrefs.id, searchByMagrefs.magrefId)) .leftJoin(issues, eq(issues.id, magrefs.issueId)) .leftJoin(magazines, eq(magazines.id, issues.magazineId)) .leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId)) .where(eq(searchByMagrefs.entryId, entryId)) .orderBy( asc(magazines.name), asc(issues.dateYear), asc(issues.dateMonth), asc(issues.id), asc(magrefs.page), asc(magrefs.id), ); return { entry: { id: Number(base.entryId), title: base.entryTitle ?? "", issueId: base.issueId ?? null, }, entryReleases: entryReleaseRows.map((r) => ({ releaseSeq: Number(r.releaseSeq), year: r.year != null ? Number(r.year) : null, })), release: { entryId: Number(base.entryId), releaseSeq: Number(base.releaseSeq), year: base.year != null ? Number(base.year) : null, month: base.month != null ? Number(base.month) : null, day: base.day != null ? Number(base.day) : null, currency: { id: base.currencyId ?? null, name: base.currencyName ?? null, symbol: base.currencySymbol ?? null, prefix: base.currencyPrefix != null ? Number(base.currencyPrefix) : null, }, prices: { release: base.releasePrice != null ? Number(base.releasePrice) : null, budget: base.budgetPrice != null ? Number(base.budgetPrice) : null, microdrive: base.microdrivePrice != null ? Number(base.microdrivePrice) : null, disk: base.diskPrice != null ? Number(base.diskPrice) : null, cartridge: base.cartridgePrice != null ? Number(base.cartridgePrice) : null, }, book: { isbn: base.bookIsbn ?? null, pages: base.bookPages != null ? Number(base.bookPages) : null, }, }, downloads: (downloadRows as DownloadRow[]).map((d) => ({ id: Number(d.id), link: d.link, size: d.size != null ? Number(d.size) : null, md5: d.md5 ?? null, comments: d.comments ?? null, isDemo: !!d.isDemo, type: { id: Number(d.filetypeId), name: d.filetypeName }, language: { id: d.dlLangId ?? null, name: d.dlLangName ?? null }, machinetype: { id: d.dlMachineId != null ? Number(d.dlMachineId) : null, name: d.dlMachineName ?? null }, scheme: { id: d.schemeId ?? null, name: d.schemeName ?? null }, source: { id: d.sourceId ?? null, name: d.sourceName ?? null }, case: { id: d.caseId ?? null, name: d.caseName ?? null }, year: d.year != null ? Number(d.year) : null, })), scraps: (scrapRows as ScrapRow[]).map((s) => ({ id: Number(s.id), link: s.link ?? null, size: s.size != null ? Number(s.size) : null, comments: s.comments ?? null, rationale: s.rationale ?? "", isDemo: !!s.isDemo, type: { id: Number(s.filetypeId), name: s.filetypeName }, language: { id: s.dlLangId ?? null, name: s.dlLangName ?? null }, machinetype: { id: s.dlMachineId != null ? Number(s.dlMachineId) : null, name: s.dlMachineName ?? null }, scheme: { id: s.schemeId ?? null, name: s.schemeName ?? null }, source: { id: s.sourceId ?? null, name: s.sourceName ?? null }, case: { id: s.caseId ?? null, name: s.caseName ?? null }, year: s.year != null ? Number(s.year) : null, })), files: fileRows.map((f) => ({ id: f.id, link: f.link, size: f.size ?? null, md5: f.md5 ?? null, comments: f.comments ?? null, type: { id: f.typeId, name: f.typeName }, })), magazineRefs: magazineRefs.map((m) => ({ id: m.id, issueId: Number(m.issueId), magazineId: m.magazineId != null ? Number(m.magazineId) : null, magazineName: m.magazineName ?? null, referencetypeId: Number(m.referencetypeId), referencetypeName: m.referencetypeName ?? null, page: Number(m.page), isOriginal: Number(m.isOriginal), scoreGroup: m.scoreGroup ?? "", issue: { dateYear: m.issueDateYear != null ? Number(m.issueDateYear) : null, dateMonth: m.issueDateMonth != null ? Number(m.issueDateMonth) : null, dateDay: m.issueDateDay != null ? Number(m.issueDateDay) : null, volume: m.issueVolume != null ? Number(m.issueVolume) : null, number: m.issueNumber != null ? Number(m.issueNumber) : null, special: m.issueSpecial ?? null, supplement: m.issueSupplement ?? null, }, })), }; } // ----- Download/lookups simple lists ----- export const listFiletypes = cache(async () => db.select().from(filetypes).orderBy(filetypes.name)); export const listSchemetypes = cache(async () => db.select().from(schemetypes).orderBy(schemetypes.name)); export const listSourcetypes = cache(async () => db.select().from(sourcetypes).orderBy(sourcetypes.name)); export const listCasetypes = cache(async () => db.select().from(casetypes).orderBy(casetypes.name)); // Newly exposed lookups export const listAvailabletypes = cache(async () => db.select().from(availabletypes).orderBy(availabletypes.name)); export const listCurrencies = cache(async () => db .select({ id: currencies.id, name: currencies.name, symbol: currencies.symbol, prefix: currencies.prefix }) .from(currencies) .orderBy(currencies.name) ); export const listRoletypes = cache(async () => db.select().from(roletypes).orderBy(roletypes.name)); export async function listMagazines(params: { q?: string; page?: number; pageSize?: number }): Promise> { const q = (params.q ?? "").trim(); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; const whereExpr = q ? like(magazines.name, `%${q}%`) : sql`true`; const [items, totalRows] = await Promise.all([ db .select({ id: magazines.id, // Expose as `title` to UI while DB column is `name` title: magazines.name, languageId: magazines.languageId, issueCount: sql`count(${issues.id})`, }) .from(magazines) .leftJoin(issues, eq(issues.magazineId, magazines.id)) .where(whereExpr) .groupBy(magazines.id) .orderBy(asc(magazines.name)) .limit(pageSize) .offset(offset), db .select({ cnt: sql`count(*)` }) .from(magazines) .where(whereExpr), ]); return { items, page, pageSize, total: totalRows[0]?.cnt ?? 0, }; } export async function getMagazine(id: number): Promise { const rows = await db .select({ id: magazines.id, // Alias DB `name` as `title` for UI shape title: magazines.name, languageId: magazines.languageId, linkSite: magazines.linkSite, linkMask: magazines.linkMask, archiveMask: magazines.archiveMask, }) .from(magazines) .where(eq(magazines.id, id)); if (rows.length === 0) return null; const mag = rows[0]; const iss = await db .select({ id: issues.id, dateYear: issues.dateYear, dateMonth: issues.dateMonth, number: issues.number, volume: issues.volume, special: issues.special, supplement: issues.supplement, linkMask: issues.linkMask, archiveMask: issues.archiveMask, }) .from(issues) .where(eq(issues.magazineId, id)) .orderBy( asc(issues.dateYear), asc(issues.dateMonth), asc(issues.volume), asc(issues.number), asc(issues.id), ); return { ...mag, issues: iss }; } export async function getIssue(id: number): Promise { const rows = await db .select({ id: issues.id, magazineId: issues.magazineId, magazineTitle: magazines.name, dateYear: issues.dateYear, dateMonth: issues.dateMonth, number: issues.number, volume: issues.volume, special: issues.special, supplement: issues.supplement, linkMask: issues.linkMask, archiveMask: issues.archiveMask, }) .from(issues) .leftJoin(magazines, eq(magazines.id, issues.magazineId)) .where(eq(issues.id, id)); const base = rows[0]; if (!base) return null; const refs = await db .select({ id: magrefs.id, page: magrefs.page, typeId: magrefs.referencetypeId, typeName: referencetypes.name, entryId: magrefs.entryId, entryTitle: entries.title, labelId: magrefs.labelId, labelName: labels.name, isOriginal: magrefs.isOriginal, scoreGroup: magrefs.scoreGroup, }) .from(magrefs) .leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId)) .leftJoin(entries, eq(entries.id, magrefs.entryId)) .leftJoin(labels, eq(labels.id, magrefs.labelId)) .where(eq(magrefs.issueId, id)) .orderBy(asc(magrefs.page), asc(magrefs.id)); return { id: base.id, magazine: { id: Number(base.magazineId), title: base.magazineTitle ?? "" }, dateYear: base.dateYear, dateMonth: base.dateMonth, number: base.number, volume: base.volume, special: base.special, supplement: base.supplement, linkMask: base.linkMask, archiveMask: base.archiveMask, refs: refs.map((r) => ({ id: r.id, page: r.page, typeId: Number(r.typeId), typeName: r.typeName ?? "", entryId: r.entryId ?? null, entryTitle: r.entryTitle ?? null, labelId: r.labelId ?? null, labelName: r.labelName ?? null, isOriginal: Number(r.isOriginal), scoreGroup: r.scoreGroup, })), }; }