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
1949 lines
66 KiB
TypeScript
1949 lines
66 KiB
TypeScript
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<T> {
|
|
items: T[];
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
}
|
|
|
|
export interface FacetItem<T extends number | string> {
|
|
id: T;
|
|
name: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface EntryFacets {
|
|
genres: FacetItem<number>[];
|
|
languages: FacetItem<string>[];
|
|
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;
|
|
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<PagedResult<SearchResultItem>> {
|
|
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<ReturnType<typeof eq>> = [];
|
|
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<number>`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<number>`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<EntryDetail | null> {
|
|
|
|
// 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<number, DownloadRow[]>();
|
|
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<PagedResult<LabelSummary>> {
|
|
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<number>`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<number>`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<LabelDetail | null> {
|
|
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<PagedResult<SearchResultItem>> {
|
|
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<number>`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<number>`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<PagedResult<SearchResultItem>> {
|
|
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<number>`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<number>`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<number>`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<number>`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<number>`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<number>`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<number>`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<number>`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<PagedResult<SearchResultItem>> {
|
|
const offset = (page - 1) * pageSize;
|
|
const hasQ = !!(q && q.trim());
|
|
|
|
if (!hasQ) {
|
|
const countRows = await db
|
|
.select({ total: sql<number>`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<number>`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<PagedResult<SearchResultItem>> {
|
|
const offset = (page - 1) * pageSize;
|
|
const hasQ = !!(q && q.trim());
|
|
|
|
if (!hasQ) {
|
|
const countRows = await db
|
|
.select({ total: sql<number>`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<number>`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<PagedResult<SearchResultItem>> {
|
|
const offset = (page - 1) * pageSize;
|
|
const hasQ = !!(q && q.trim());
|
|
|
|
if (!hasQ) {
|
|
const countRows = await db
|
|
.select({ total: sql<number>`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<number>`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<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) {
|
|
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<PagedResult<ReleaseListItem>> {
|
|
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<ReturnType<typeof sql>> = [];
|
|
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<ReturnType<typeof sql>> = [];
|
|
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<number>`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<number, number>();
|
|
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<ReleaseDetail | null> {
|
|
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<PagedResult<MagazineListItem>> {
|
|
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<number>`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<number>`count(*)` })
|
|
.from(magazines)
|
|
.where(whereExpr),
|
|
]);
|
|
|
|
return {
|
|
items,
|
|
page,
|
|
pageSize,
|
|
total: totalRows[0]?.cnt ?? 0,
|
|
};
|
|
}
|
|
|
|
export async function getMagazine(id: number): Promise<MagazineDetail | null> {
|
|
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<IssueDetail | null> {
|
|
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,
|
|
})),
|
|
};
|
|
}
|