- {options.map((option) => {
- const active = selected.includes(option.id);
- return (
-
onToggle(option.id)}
- >
- {option.label}
-
- );
- })}
+
+
+ {options.map((option) => {
+ const active = selected.includes(option.id);
+ return (
+ onToggle(option.id)}
+ >
+ {option.label}
+
+ );
+ })}
+
+ {collapsible && (
+
setExpanded(false)}
+ >
+ Collapse
+
+ )}
);
}
diff --git a/src/components/explorer/Pagination.tsx b/src/components/explorer/Pagination.tsx
new file mode 100644
index 0000000..a6d7a24
--- /dev/null
+++ b/src/components/explorer/Pagination.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { useMemo } from "react";
+import { Button, Spinner } from "react-bootstrap";
+import { ChevronLeft, ChevronRight } from "react-bootstrap-icons";
+
+type PaginationProps = {
+ page: number;
+ totalPages: number;
+ loading?: boolean;
+ /** Build href for a given page number (for SSR/link fallback) */
+ buildHref: (p: number) => string;
+ onPageChange: (p: number) => void;
+};
+
+export default function Pagination({
+ page,
+ totalPages,
+ loading,
+ buildHref,
+ onPageChange,
+}: PaginationProps) {
+ const canPrev = page > 1;
+ const canNext = page < totalPages;
+
+ const prevHref = useMemo(() => buildHref(Math.max(1, page - 1)), [buildHref, page]);
+ const nextHref = useMemo(() => buildHref(Math.min(totalPages, page + 1)), [buildHref, page, totalPages]);
+
+ return (
+
+
+ Page {page} / {totalPages}
+
+ {loading && (
+
+ )}
+
+ {
+ if (!canPrev) return;
+ e.preventDefault();
+ onPageChange(page - 1);
+ }}
+ >
+ Prev
+
+ {
+ if (!canNext) return;
+ e.preventDefault();
+ onPageChange(page + 1);
+ }}
+ >
+ Next
+
+
+
+ );
+}
diff --git a/src/env.ts b/src/env.ts
index 4b439c9..19ce6dc 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -14,6 +14,10 @@ const serverSchema = z.object({
ZXDB_FILE_PREFIX: z.string().optional(),
WOS_FILE_PREFIX: z.string().optional(),
+ // Local file paths for mirroring
+ ZXDB_LOCAL_FILEPATH: z.string().optional(),
+ WOS_LOCAL_FILEPATH: z.string().optional(),
+
// OIDC Configuration
OIDC_PROVIDER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(),
diff --git a/src/hooks/useSearchFetch.ts b/src/hooks/useSearchFetch.ts
new file mode 100644
index 0000000..a7a6b32
--- /dev/null
+++ b/src/hooks/useSearchFetch.ts
@@ -0,0 +1,82 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+import type { PagedResult } from "@/types/zxdb";
+
+/**
+ * Manages API search fetching with automatic request cancellation
+ * to prevent race conditions from rapid filter/page changes.
+ * Keeps previous results visible while a new request is in flight.
+ *
+ * @param onExtra - optional callback to capture extra fields from the response
+ * (e.g., facets) that sit alongside the standard paged fields.
+ */
+export default function useSearchFetch
(
+ endpoint: string,
+ initialData: PagedResult | null = null,
+ onExtra?: (json: Record) => void,
+) {
+ const [data, setData] = useState | null>(initialData);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const abortRef = useRef(null);
+ const fetchIdRef = useRef(0);
+ const onExtraRef = useRef(onExtra);
+ onExtraRef.current = onExtra;
+
+ const fetch_ = useCallback(
+ async (params: URLSearchParams) => {
+ // Cancel any in-flight request
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ const id = ++fetchIdRef.current;
+ setLoading(true);
+ setError(null);
+
+ try {
+ const res = await globalThis.fetch(
+ `${endpoint}?${params.toString()}`,
+ { signal: controller.signal },
+ );
+ if (!res.ok) throw new Error(`Search failed (${res.status})`);
+ const json = await res.json();
+
+ // Only apply if this is still the latest request
+ if (id === fetchIdRef.current) {
+ setData({
+ items: json.items,
+ page: json.page,
+ pageSize: json.pageSize,
+ total: json.total,
+ });
+ onExtraRef.current?.(json);
+ }
+ } catch (e: unknown) {
+ if (e instanceof DOMException && e.name === "AbortError") return;
+ if (id === fetchIdRef.current) {
+ const msg = e instanceof Error ? e.message : "Search failed";
+ console.error(msg);
+ setError(msg);
+ setData({ items: [] as T[], page: 1, pageSize: 20, total: 0 });
+ }
+ } finally {
+ if (id === fetchIdRef.current) {
+ setLoading(false);
+ }
+ }
+ },
+ [endpoint],
+ );
+
+ // Allow syncing SSR data without a fetch
+ const syncData = useCallback((d: PagedResult) => {
+ abortRef.current?.abort();
+ setData(d);
+ setLoading(false);
+ setError(null);
+ }, []);
+
+ return { data, loading, error, fetch: fetch_, syncData };
+}
diff --git a/src/middleware.js b/src/middleware.js
deleted file mode 100644
index f77cc69..0000000
--- a/src/middleware.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { NextResponse } from 'next/server'
-
-export function middleware(request) {
- const { method, nextUrl } = request
-
- // Filter out internal Next.js assets if desired
- if (!nextUrl.pathname.startsWith('/_next')) {
- console.log(`${method} ${nextUrl.pathname}`)
- }
-
- return NextResponse.next()
-}
\ No newline at end of file
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..23fe5f4
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,11 @@
+import { NextResponse, NextRequest } from 'next/server';
+
+export function middleware(request: NextRequest) {
+ if (process.env.NODE_ENV === 'development') {
+ const { method, nextUrl } = request;
+ if (!nextUrl.pathname.startsWith('/_next')) {
+ console.log(`${method} ${nextUrl.pathname}`);
+ }
+ }
+ return NextResponse.next();
+}
diff --git a/src/server/repo/index.ts b/src/server/repo/index.ts
new file mode 100644
index 0000000..39dea27
--- /dev/null
+++ b/src/server/repo/index.ts
@@ -0,0 +1,3 @@
+// Barrel re-export — enables import from "@/server/repo" and
+// sets up for incremental per-domain splitting.
+export * from "./zxdb";
diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts
index b9f73cc..74542c2 100644
--- a/src/server/repo/zxdb.ts
+++ b/src/server/repo/zxdb.ts
@@ -1,5 +1,8 @@
import { and, desc, eq, sql, asc } from "drizzle-orm";
import { cache } from "react";
+import fs from "fs";
+import path from "path";
+import { env } from "@/env";
// import { alias } from "drizzle-orm/mysql-core";
import { db } from "@/server/db";
import {
@@ -52,6 +55,8 @@ import {
magrefs,
searchByMagrefs,
referencetypes,
+ countries,
+ softwareHashes,
} from "@/server/schema/zxdb";
export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";
@@ -64,6 +69,8 @@ export interface SearchParams {
genreId?: number;
languageId?: string;
machinetypeId?: number | number[];
+ // Year filter
+ year?: number;
// Sorting
sort?: "title" | "id_desc";
// Search scope (defaults to titles only)
@@ -105,6 +112,35 @@ export interface EntryFacets {
};
}
+/**
+ * Resolves a local link for a given file link if mirroring is enabled and the file exists.
+ */
+function resolveLocalLink(fileLink: string): string | null {
+ let localPath: string | null = null;
+ let source: "zxdb" | "wos" | null = null;
+ let subPath: string | null = null;
+
+ const zxdbPrefix = env.ZXDB_FILE_PREFIX || "";
+ const wosPrefix = env.WOS_FILE_PREFIX || "";
+
+ if (fileLink.startsWith(zxdbPrefix) && env.ZXDB_LOCAL_FILEPATH) {
+ subPath = fileLink.slice(zxdbPrefix.replace(/\/$/, "").length);
+ localPath = path.join(env.ZXDB_LOCAL_FILEPATH, subPath);
+ source = "zxdb";
+ } else if (fileLink.startsWith(wosPrefix) && env.WOS_LOCAL_FILEPATH) {
+ subPath = fileLink.slice(wosPrefix.replace(/\/$/, "").length);
+ localPath = path.join(env.WOS_LOCAL_FILEPATH, subPath);
+ source = "wos";
+ }
+
+ if (localPath && fs.existsSync(localPath) && source && subPath) {
+ // Return an application-relative URL instead of the absolute filesystem path
+ return `/api/zxdb/download?source=${source}&path=${encodeURIComponent(subPath)}`;
+ }
+
+ return null;
+}
+
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
const parts: Array> = [
sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern}`,
@@ -206,6 +242,9 @@ export async function searchEntries(params: SearchParams): Promise sql`${id}`);
whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
}
+ if (typeof params.year === "number") {
+ whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
+ }
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
@@ -250,9 +289,30 @@ export async function searchEntries(params: SearchParams): Promise> = [
+ sql`${entries.id} in (select entry_id from (${union}) as matches)`
+ ];
+ 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));
+ } else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
+ const ids = params.machinetypeId.map((id) => sql`${id}`);
+ whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
+ }
+ if (typeof params.year === "number") {
+ whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
+ }
+ const whereExpr = and(...whereClauses);
+
const countRows = await db.execute(sql`
- select count(distinct entry_id) as total
- from (${union}) as matches
+ select count(distinct id) as total
+ from entries
+ where ${whereExpr}
`);
type CountRow = { total: number | string };
const total = Number((countRows as unknown as CountRow[])[0]?.total ?? 0);
@@ -273,7 +333,7 @@ export async function searchEntries(params: SearchParams): Promise> = [
+ sql`lower(${searchByTitles.entryTitle}) like ${pattern}`
+ ];
+ 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));
+ } else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
+ const ids = params.machinetypeId.map((id) => sql`${id}`);
+ whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
+ }
+ if (typeof params.year === "number") {
+ whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
+ }
+ const whereExpr = and(...whereClauses);
+
const countRows = await db
.select({ total: sql`count(distinct ${searchByTitles.entryId})` })
.from(searchByTitles)
- .where(sql`lower(${searchByTitles.entryTitle}) like ${pattern}`);
+ .innerJoin(entries, eq(entries.id, searchByTitles.entryId))
+ .where(whereExpr);
const total = Number(countRows[0]?.total ?? 0);
@@ -314,7 +395,7 @@ export async function searchEntries(params: SearchParams): Promise {
@@ -642,6 +745,7 @@ export async function getEntryById(id: number): Promise {
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,
+ localLink: resolveLocalLink(d.link),
})),
}));
@@ -731,6 +835,24 @@ export async function getEntryById(id: number): Promise {
notetypeName: string | null;
text: string;
}[] = [];
+ let magazineRefRows: {
+ id: number;
+ issueId: number;
+ magazineId: number | null;
+ magazineName: string | null;
+ referencetypeId: number;
+ referencetypeName: string | null;
+ page: number;
+ isOriginal: number;
+ scoreGroup: string;
+ issueDateYear: number | null;
+ issueDateMonth: number | null;
+ issueDateDay: number | null;
+ issueVolume: number | null;
+ issueNumber: number | null;
+ issueSpecial: string | null;
+ issueSupplement: string | null;
+ }[] = [];
try {
aliasRows = await db
.select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title })
@@ -907,6 +1029,43 @@ export async function getEntryById(id: number): Promise {
noteRows = rows as typeof noteRows;
} catch {}
+ try {
+ const rows = 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, id))
+ .orderBy(
+ asc(magazines.name),
+ asc(issues.dateYear),
+ asc(issues.dateMonth),
+ asc(issues.id),
+ asc(magrefs.page),
+ asc(magrefs.id)
+ );
+ magazineRefRows = rows as typeof magazineRefRows;
+ } catch {}
+
return {
id: base.id,
title: base.title,
@@ -1025,9 +1184,30 @@ export async function getEntryById(id: number): Promise {
case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
year: d.year != null ? Number(d.year) : null,
releaseSeq: Number(d.releaseSeq),
+ localLink: resolveLocalLink(d.link),
})),
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 } })),
+ magazineRefs: magazineRefRows.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,
+ },
+ })),
};
}
@@ -1035,6 +1215,12 @@ export async function getEntryById(id: number): Promise {
export interface LabelDetail extends LabelSummary {
labeltypeName: string | null;
+ countryId: string | null;
+ countryName: string | null;
+ country2Id: string | null;
+ country2Name: string | null;
+ linkWikipedia: string | null;
+ linkSite: string | null;
permissions: {
website: { id: number; name: string; link?: string | null };
type: { id: string; name: string | null };
@@ -1099,9 +1285,17 @@ export async function getLabelById(id: number): Promise {
name: labels.name,
labeltypeId: labels.labeltypeId,
labeltypeName: labeltypes.name,
+ countryId: labels.countryId,
+ countryName: sql`c1.text`,
+ country2Id: labels.country2Id,
+ country2Name: sql`c2.text`,
+ linkWikipedia: labels.linkWikipedia,
+ linkSite: labels.linkSite,
})
.from(labels)
.leftJoin(labeltypes, eq(labeltypes.id, labels.labeltypeId))
+ .leftJoin(sql`${countries} c1`, eq(sql`c1.id`, labels.countryId))
+ .leftJoin(sql`${countries} c2`, eq(sql`c2.id`, labels.country2Id))
.where(eq(labels.id, id))
.limit(1);
const base = rows[0];
@@ -1165,6 +1359,12 @@ export async function getLabelById(id: number): Promise {
name: base.name,
labeltypeId: base.labeltypeId,
labeltypeName: base.labeltypeName ?? null,
+ countryId: base.countryId ?? null,
+ countryName: base.countryName ?? null,
+ country2Id: base.country2Id ?? null,
+ country2Name: base.country2Name ?? null,
+ linkWikipedia: base.linkWikipedia ?? null,
+ linkSite: base.linkSite ?? null,
permissions: permissionRows.map((p) => ({
website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null },
type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null },
@@ -1941,6 +2141,7 @@ export interface ReleaseDetail {
source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null };
year: number | null;
+ localLink?: string | null;
}>;
scraps: Array<{
id: number;
@@ -1956,6 +2157,7 @@ export interface ReleaseDetail {
source: { id: string | null; name: string | null };
case: { id: string | null; name: string | null };
year: number | null;
+ localLink?: string | null;
}>;
files: Array<{
id: number;
@@ -2208,6 +2410,7 @@ export async function getReleaseDetail(entryId: number, releaseSeq: number): Pro
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,
+ localLink: resolveLocalLink(d.link),
})),
scraps: (scrapRows as ScrapRow[]).map((s) => ({
id: Number(s.id),
@@ -2223,6 +2426,7 @@ export async function getReleaseDetail(entryId: number, releaseSeq: number): Pro
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,
+ localLink: s.link ? resolveLocalLink(s.link) : null,
})),
files: fileRows.map((f) => ({
id: f.id,
@@ -2418,3 +2622,86 @@ export async function getIssue(id: number): Promise {
})),
};
}
+
+// ----- Tape identification via software_hashes -----
+
+export type TapeMatch = {
+ downloadId: number;
+ entryId: number;
+ entryTitle: string;
+ innerPath: string;
+ md5: string;
+ crc32: string;
+ sizeBytes: number;
+ machinetype: string | null;
+ genre: string | null;
+ releaseYear: number | null;
+ authors: string[];
+ downloadLink: string;
+};
+
+export async function lookupByMd5(md5: string): Promise {
+ const rows = await db
+ .select({
+ downloadId: softwareHashes.downloadId,
+ entryId: downloads.entryId,
+ entryTitle: entries.title,
+ innerPath: softwareHashes.innerPath,
+ md5: softwareHashes.md5,
+ crc32: softwareHashes.crc32,
+ sizeBytes: softwareHashes.sizeBytes,
+ machinetypeName: machinetypes.name,
+ genreName: genretypes.name,
+ releaseYear: releases.releaseYear,
+ downloadLink: downloads.fileLink,
+ })
+ .from(softwareHashes)
+ .innerJoin(downloads, eq(downloads.id, softwareHashes.downloadId))
+ .innerJoin(entries, eq(entries.id, downloads.entryId))
+ .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
+ .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
+ .leftJoin(
+ releases,
+ and(eq(releases.entryId, downloads.entryId), eq(releases.releaseSeq, downloads.releaseSeq))
+ )
+ .where(eq(softwareHashes.md5, md5.toLowerCase()));
+
+ // Collect unique entry IDs to fetch authors
+ const entryIds = [...new Set(rows.map((r) => Number(r.entryId)))];
+ const authorMap = new Map();
+ if (entryIds.length > 0) {
+ try {
+ const authorRows = await db
+ .select({ entryId: authors.entryId, name: labels.name })
+ .from(authors)
+ .innerJoin(labels, eq(labels.id, authors.labelId))
+ .where(
+ entryIds.length === 1
+ ? eq(authors.entryId, entryIds[0])
+ : sql`${authors.entryId} in (${sql.join(entryIds.map((id) => sql`${id}`), sql`, `)})`
+ )
+ .orderBy(asc(authors.authorSeq));
+ for (const a of authorRows) {
+ const eid = Number(a.entryId);
+ const existing = authorMap.get(eid);
+ if (existing) existing.push(a.name);
+ else authorMap.set(eid, [a.name]);
+ }
+ } catch {}
+ }
+
+ return rows.map((r) => ({
+ downloadId: Number(r.downloadId),
+ entryId: Number(r.entryId),
+ entryTitle: r.entryTitle ?? "",
+ innerPath: r.innerPath,
+ md5: r.md5,
+ crc32: r.crc32,
+ sizeBytes: Number(r.sizeBytes),
+ machinetype: r.machinetypeName ?? null,
+ genre: r.genreName ?? null,
+ releaseYear: r.releaseYear != null ? Number(r.releaseYear) : null,
+ authors: authorMap.get(Number(r.entryId)) ?? [],
+ downloadLink: r.downloadLink,
+ }));
+}
diff --git a/src/server/schema/zxdb.ts b/src/server/schema/zxdb.ts
index 73174df..f5c7a92 100644
--- a/src/server/schema/zxdb.ts
+++ b/src/server/schema/zxdb.ts
@@ -1,4 +1,4 @@
-import { mysqlTable, int, varchar, tinyint, char, smallint, decimal, text, mediumtext, longtext } from "drizzle-orm/mysql-core";
+import { mysqlTable, int, varchar, tinyint, char, smallint, decimal, text, mediumtext, longtext, bigint, timestamp } from "drizzle-orm/mysql-core";
// Minimal subset needed for browsing/searching
export const entries = mysqlTable("entries", {
@@ -646,3 +646,16 @@ export const zxsrScores = mysqlTable("zxsr_scores", {
score: varchar("score", { length: 100 }),
comments: text("comments"),
});
+
+// ---- Derived tables (managed by update scripts, not part of ZXDB upstream) ----
+
+// Stores MD5, CRC32 and size of the inner tape file extracted from download zips.
+// Populated by bin/update-software-hashes.mjs; survives DB wipes via JSON snapshot.
+export const softwareHashes = mysqlTable("software_hashes", {
+ downloadId: int("download_id").notNull().primaryKey(),
+ md5: varchar("md5", { length: 32 }).notNull(),
+ crc32: varchar("crc32", { length: 8 }).notNull(),
+ sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
+ innerPath: varchar("inner_path", { length: 500 }).notNull(),
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
+});
diff --git a/src/services/register.service.ts b/src/services/register.service.ts
index a614138..3780012 100644
--- a/src/services/register.service.ts
+++ b/src/services/register.service.ts
@@ -1,20 +1,15 @@
-
+import { cache } from 'react';
import { promises as fs } from 'fs';
import path from 'path';
-import { Register } from '@/utils/register_parser';
-import { parseNextReg } from '@/utils/register_parser';
-
-let registers: Register[] = [];
+import { Register, parseNextReg } from '@/utils/register_parser';
/**
- * Gets the registers from the in-memory cache, or loads them from the file if not already loaded.
- * @returns A promise that resolves to an array of Register objects.
+ * Gets all registers, with request-level deduplication via React cache().
+ * Multiple calls within the same server request (e.g. generateMetadata + page)
+ * share a single parse result.
*/
-export async function getRegisters(): Promise {
- // if (registers.length === 0) {
- const filePath = path.join(process.cwd(), 'data', 'nextreg.txt');
- const fileContent = await fs.readFile(filePath, 'utf8');
- registers = await parseNextReg(fileContent);
- // }
- return registers;
-}
+export const getRegisters = cache(async (): Promise => {
+ const filePath = path.join(process.cwd(), 'data', 'nextreg.txt');
+ const fileContent = await fs.readFile(filePath, 'utf8');
+ return parseNextReg(fileContent);
+});
diff --git a/src/types/zxdb.ts b/src/types/zxdb.ts
new file mode 100644
index 0000000..593fb71
--- /dev/null
+++ b/src/types/zxdb.ts
@@ -0,0 +1,25 @@
+/** Paginated API response wrapper */
+export type PagedResult = {
+ items: T[];
+ page: number;
+ pageSize: number;
+ total: number;
+};
+
+/** Facet item returned by search APIs */
+export type FacetItem = {
+ id: T;
+ name: string;
+ count: number;
+};
+
+/** Entry search facets */
+export type EntryFacets = {
+ genres: FacetItem[];
+ languages: FacetItem[];
+ machinetypes: FacetItem[];
+ flags: { hasAliases: number; hasOrigins: number };
+};
+
+/** Entry search scope */
+export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";
diff --git a/src/utils/md5.ts b/src/utils/md5.ts
new file mode 100644
index 0000000..b227764
--- /dev/null
+++ b/src/utils/md5.ts
@@ -0,0 +1,164 @@
+// Pure-JS MD5 for browser use (Web Crypto doesn't support MD5).
+// Standard RFC 1321 implementation, typed for TypeScript.
+
+function md5cycle(x: number[], k: number[]) {
+ let a = x[0], b = x[1], c = x[2], d = x[3];
+
+ a = ff(a, b, c, d, k[0], 7, -680876936);
+ d = ff(d, a, b, c, k[1], 12, -389564586);
+ c = ff(c, d, a, b, k[2], 17, 606105819);
+ b = ff(b, c, d, a, k[3], 22, -1044525330);
+ a = ff(a, b, c, d, k[4], 7, -176418897);
+ d = ff(d, a, b, c, k[5], 12, 1200080426);
+ c = ff(c, d, a, b, k[6], 17, -1473231341);
+ b = ff(b, c, d, a, k[7], 22, -45705983);
+ a = ff(a, b, c, d, k[8], 7, 1770035416);
+ d = ff(d, a, b, c, k[9], 12, -1958414417);
+ c = ff(c, d, a, b, k[10], 17, -42063);
+ b = ff(b, c, d, a, k[11], 22, -1990404162);
+ a = ff(a, b, c, d, k[12], 7, 1804603682);
+ d = ff(d, a, b, c, k[13], 12, -40341101);
+ c = ff(c, d, a, b, k[14], 17, -1502002290);
+ b = ff(b, c, d, a, k[15], 22, 1236535329);
+
+ a = gg(a, b, c, d, k[1], 5, -165796510);
+ d = gg(d, a, b, c, k[6], 9, -1069501632);
+ c = gg(c, d, a, b, k[11], 14, 643717713);
+ b = gg(b, c, d, a, k[0], 20, -373897302);
+ a = gg(a, b, c, d, k[5], 5, -701558691);
+ d = gg(d, a, b, c, k[10], 9, 38016083);
+ c = gg(c, d, a, b, k[15], 14, -660478335);
+ b = gg(b, c, d, a, k[4], 20, -405537848);
+ a = gg(a, b, c, d, k[9], 5, 568446438);
+ d = gg(d, a, b, c, k[14], 9, -1019803690);
+ c = gg(c, d, a, b, k[3], 14, -187363961);
+ b = gg(b, c, d, a, k[8], 20, 1163531501);
+ a = gg(a, b, c, d, k[13], 5, -1444681467);
+ d = gg(d, a, b, c, k[2], 9, -51403784);
+ c = gg(c, d, a, b, k[7], 14, 1735328473);
+ b = gg(b, c, d, a, k[12], 20, -1926607734);
+
+ a = hh(a, b, c, d, k[5], 4, -378558);
+ d = hh(d, a, b, c, k[8], 11, -2022574463);
+ c = hh(c, d, a, b, k[11], 16, 1839030562);
+ b = hh(b, c, d, a, k[14], 23, -35309556);
+ a = hh(a, b, c, d, k[1], 4, -1530992060);
+ d = hh(d, a, b, c, k[4], 11, 1272893353);
+ c = hh(c, d, a, b, k[7], 16, -155497632);
+ b = hh(b, c, d, a, k[10], 23, -1094730640);
+ a = hh(a, b, c, d, k[13], 4, 681279174);
+ d = hh(d, a, b, c, k[0], 11, -358537222);
+ c = hh(c, d, a, b, k[3], 16, -722521979);
+ b = hh(b, c, d, a, k[6], 23, 76029189);
+ a = hh(a, b, c, d, k[9], 4, -640364487);
+ d = hh(d, a, b, c, k[12], 11, -421815835);
+ c = hh(c, d, a, b, k[15], 16, 530742520);
+ b = hh(b, c, d, a, k[2], 23, -995338651);
+
+ a = ii(a, b, c, d, k[0], 6, -198630844);
+ d = ii(d, a, b, c, k[7], 10, 1126891415);
+ c = ii(c, d, a, b, k[14], 15, -1416354905);
+ b = ii(b, c, d, a, k[5], 21, -57434055);
+ a = ii(a, b, c, d, k[12], 6, 1700485571);
+ d = ii(d, a, b, c, k[3], 10, -1894986606);
+ c = ii(c, d, a, b, k[10], 15, -1051523);
+ b = ii(b, c, d, a, k[1], 21, -2054922799);
+ a = ii(a, b, c, d, k[8], 6, 1873313359);
+ d = ii(d, a, b, c, k[15], 10, -30611744);
+ c = ii(c, d, a, b, k[6], 15, -1560198380);
+ b = ii(b, c, d, a, k[13], 21, 1309151649);
+ a = ii(a, b, c, d, k[4], 6, -145523070);
+ d = ii(d, a, b, c, k[11], 10, -1120210379);
+ c = ii(c, d, a, b, k[2], 15, 718787259);
+ b = ii(b, c, d, a, k[9], 21, -343485551);
+
+ x[0] = add32(a, x[0]);
+ x[1] = add32(b, x[1]);
+ x[2] = add32(c, x[2]);
+ x[3] = add32(d, x[3]);
+}
+
+function cmn(q: number, a: number, b: number, x: number, s: number, t: number) {
+ a = add32(add32(a, q), add32(x, t));
+ return add32((a << s) | (a >>> (32 - s)), b);
+}
+
+function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
+ return cmn((b & c) | (~b & d), a, b, x, s, t);
+}
+function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
+ return cmn((b & d) | (c & ~d), a, b, x, s, t);
+}
+function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
+ return cmn(b ^ c ^ d, a, b, x, s, t);
+}
+function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) {
+ return cmn(c ^ (b | ~d), a, b, x, s, t);
+}
+
+function add32(a: number, b: number) {
+ return (a + b) & 0xffffffff;
+}
+
+function md5blk(s: Uint8Array, offset: number): number[] {
+ const md5blks: number[] = [];
+ for (let i = 0; i < 64; i += 4) {
+ md5blks[i >> 2] =
+ s[offset + i] +
+ (s[offset + i + 1] << 8) +
+ (s[offset + i + 2] << 16) +
+ (s[offset + i + 3] << 24);
+ }
+ return md5blks;
+}
+
+const hex = "0123456789abcdef".split("");
+
+function rhex(n: number) {
+ let s = "";
+ for (let j = 0; j < 4; j++) {
+ s += hex[(n >> (j * 8 + 4)) & 0x0f] + hex[(n >> (j * 8)) & 0x0f];
+ }
+ return s;
+}
+
+function md5raw(bytes: Uint8Array): string {
+ const n = bytes.length;
+ const state = [1732584193, -271733879, -1732584194, 271733878];
+
+ let i: number;
+ for (i = 64; i <= n; i += 64) {
+ md5cycle(state, md5blk(bytes, i - 64));
+ }
+
+ // Tail: copy remaining bytes into a padded buffer
+ const tail = new Uint8Array(64);
+ const remaining = n - (i - 64);
+ for (let j = 0; j < remaining; j++) {
+ tail[j] = bytes[i - 64 + j];
+ }
+ tail[remaining] = 0x80;
+
+ // If remaining >= 56 we need an extra block
+ if (remaining >= 56) {
+ md5cycle(state, md5blk(tail, 0));
+ tail.fill(0);
+ }
+
+ // Append bit length as 64-bit little-endian
+ const bitLen = n * 8;
+ tail[56] = bitLen & 0xff;
+ tail[57] = (bitLen >> 8) & 0xff;
+ tail[58] = (bitLen >> 16) & 0xff;
+ tail[59] = (bitLen >> 24) & 0xff;
+ // For files < 512 MB the high 32 bits are 0; safe for tape images
+ md5cycle(state, md5blk(tail, 0));
+
+ return rhex(state[0]) + rhex(state[1]) + rhex(state[2]) + rhex(state[3]);
+}
+
+// Reads a File as ArrayBuffer and returns its MD5 hex digest.
+export async function computeMd5(file: File): Promise {
+ const buffer = await file.arrayBuffer();
+ return md5raw(new Uint8Array(buffer));
+}
diff --git a/src/utils/params.ts b/src/utils/params.ts
new file mode 100644
index 0000000..4f947b3
--- /dev/null
+++ b/src/utils/params.ts
@@ -0,0 +1,29 @@
+/**
+ * Parse a comma-separated string of positive integer IDs.
+ * Accepts either a plain string or a string[] (from searchParams).
+ */
+export function parseIdList(value: string | string[] | undefined): number[] | undefined {
+ if (!value) return undefined;
+ const raw = Array.isArray(value) ? value.join(",") : value;
+ const ids = raw
+ .split(",")
+ .map((id) => Number(id.trim()))
+ .filter((id) => Number.isFinite(id) && id > 0);
+ return ids.length ? ids : undefined;
+}
+
+/** Default machine type IDs for entry/release searches */
+export const preferredMachineIds = [27, 26, 8, 9];
+
+/**
+ * Parse machine type IDs from a comma-separated string,
+ * falling back to preferredMachineIds when empty.
+ */
+export function parseMachineIds(value?: string): number[] {
+ if (!value) return preferredMachineIds.slice();
+ const ids = value
+ .split(",")
+ .map((id) => Number(id.trim()))
+ .filter((id) => Number.isFinite(id) && id > 0);
+ return ids.length ? ids : preferredMachineIds.slice();
+}
diff --git a/src/utils/register_helpers.ts b/src/utils/register_helpers.ts
new file mode 100644
index 0000000..87ccd41
--- /dev/null
+++ b/src/utils/register_helpers.ts
@@ -0,0 +1,93 @@
+type RegisterLike = {
+ description: string;
+ text: string;
+ modes: { text: string }[];
+};
+
+/** Returns true if a line contains useful register info (not bitfield/access markers) */
+export function isInfoLine(line: string): boolean {
+ return (
+ line.length > 0 &&
+ !line.startsWith("//") &&
+ !line.startsWith("(R") &&
+ !line.startsWith("(W") &&
+ !line.startsWith("(R/W") &&
+ !line.startsWith("*") &&
+ !/^bits?\s+\d/i.test(line)
+ );
+}
+
+/** Build a single-line summary string for metadata descriptions */
+export function buildRegisterSummary(register: RegisterLike): string {
+ const trimLine = (line: string) => line.trim();
+
+ const modeLines = register.modes
+ .flatMap((mode) => mode.text.split("\n"))
+ .map(trimLine)
+ .filter(isInfoLine);
+ const textLines = register.text.split("\n").map(trimLine).filter(isInfoLine);
+ const descriptionLines = register.description.split("\n").map(trimLine).filter(isInfoLine);
+
+ const rawSummary = [...textLines, ...modeLines, ...descriptionLines]
+ .join(" ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ if (!rawSummary) return "Spectrum Next register details and bit-level behavior.";
+ if (rawSummary.length <= 180) return rawSummary;
+ return `${rawSummary.slice(0, 177).trimEnd()}...`;
+}
+
+/** Build deduplicated summary lines for OG image rendering */
+export function buildRegisterSummaryLines(register: RegisterLike): string[] {
+ const normalizeLines = (raw: string) => {
+ const lines: string[] = [];
+ const rawLines = raw.split("\n");
+ for (const rawLine of rawLines) {
+ const trimmed = rawLine.trim();
+ if (!trimmed) {
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
+ lines.push("");
+ }
+ continue;
+ }
+ if (isInfoLine(trimmed)) {
+ lines.push(trimmed);
+ }
+ }
+ return lines;
+ };
+
+ const textLines = normalizeLines(register.text);
+ const modeLines = register.modes.flatMap((mode) => normalizeLines(mode.text));
+ const descriptionLines = normalizeLines(register.description);
+
+ const combined: string[] = [];
+ const appendBlock = (block: string[]) => {
+ if (block.length === 0) return;
+ if (combined.length > 0 && combined[combined.length - 1] !== "") {
+ combined.push("");
+ }
+ combined.push(...block);
+ };
+
+ appendBlock(textLines);
+ appendBlock(modeLines);
+ appendBlock(descriptionLines);
+
+ const deduped: string[] = [];
+ const seen = new Set();
+ for (const line of combined) {
+ if (!line) {
+ if (deduped.length > 0 && deduped[deduped.length - 1] !== "") {
+ deduped.push("");
+ }
+ continue;
+ }
+ if (seen.has(line)) continue;
+ seen.add(line);
+ deduped.push(line);
+ }
+
+ return deduped.length > 0 ? deduped : ["Spectrum Next register details and bit-level behavior."];
+}
diff --git a/src/utils/register_parser.ts b/src/utils/register_parser.ts
index c752769..32e0821 100644
--- a/src/utils/register_parser.ts
+++ b/src/utils/register_parser.ts
@@ -1,5 +1,6 @@
import { parseDescriptionDefault } from "./register_parsers/reg_default";
import { parseDescriptionF0 } from "./register_parsers/reg_f0";
+import { parseDescription44 } from "./register_parsers/reg_44";
export interface RegisterBitwiseOperation {
bits: string;
@@ -41,12 +42,43 @@ export interface Register {
notes: Note[];
}
+/**
+ * Detects a source line marking the whole register as restricted to certain
+ * board issues, e.g. "Issue 4 Only" or "Issues 4 and 5 Only". Matched loosely
+ * across wording so upstream rewording (the tbblue source has used both forms)
+ * keeps setting the flag rather than silently dropping the badge.
+ *
+ * Case-sensitive on purpose: register-level markers are capitalised, while an
+ * incidental per-bit caveat like "(issue 5 only)" (e.g. nextreg 0x81 bit 3) is
+ * lowercase and must not flag the entire register.
+ */
+export function isIssueRestricted(line: string): boolean {
+ return /Issues?\b.*\bOnly/.test(line);
+}
+
+/**
+ * True when a parsed RegisterDetail carries something worth rendering — a mode
+ * name, an access block, or mode-level text. Body-less registers (e.g. the
+ * "Reserved" entries 0xC7/0xCB/0xCF/0xFF) would otherwise contribute an empty
+ * mode that renders as a stray, contentless tab strip. Parsers call this before
+ * pushing a detail into reg.modes.
+ */
+export function detailHasContent(detail: RegisterDetail): boolean {
+ return Boolean(
+ detail.modeName ||
+ detail.read ||
+ detail.write ||
+ detail.common ||
+ (detail.text && detail.text.trim())
+ );
+}
+
/**
* Parses the content of the nextreg.txt file and returns an array of register objects.
* @param fileContent The content of the nextreg.txt file.
* @returns A promise that resolves to an array of Register objects.
*/
-export async function parseNextReg(fileContent: string): Promise {
+export function parseNextReg(fileContent: string): Register[] {
const registers: Register[] = [];
const paragraphs = fileContent.split(/\n\s*\n/);
@@ -64,6 +96,13 @@ export function processRegisterBlock(paragraph: string, registers: Register[]) {
const lines = paragraph.trim().split('\n');
const firstLine = lines[0];
+ // Skip commented-out register blocks. The header regex below is not anchored,
+ // so a disabled entry like "// 0xA3 (163) => ..." would otherwise match and
+ // leak a phantom register into the output.
+ if (firstLine.trim().startsWith('//')) {
+ return;
+ }
+
const registerMatch = firstLine.match(/([0-9a-fA-F,x]+)\s*\((.*?)\)\s*=>\s*(.*)/);
if (!registerMatch) {
@@ -134,6 +173,9 @@ export function processRegisterBlock(paragraph: string, registers: Register[]) {
case '0xF0':
parseDescriptionF0(reg, description);
break;
+ case '0x44':
+ parseDescription44(reg, description);
+ break;
default:
parseDescriptionDefault(reg, description);
break;
diff --git a/src/utils/register_parsers/reg_44.ts b/src/utils/register_parsers/reg_44.ts
new file mode 100644
index 0000000..907c58d
--- /dev/null
+++ b/src/utils/register_parsers/reg_44.ts
@@ -0,0 +1,91 @@
+// Special-case parser for 0x44 (Palette Value, 9 bit colour).
+// The 9-bit colour is written with two consecutive byte writes, each carrying
+// its own bit layout. The source nests these under "1st write:" / "2nd write:"
+// headers (two leading spaces, trailing colon) with their bit definitions
+// indented beneath (four leading spaces):
+//
+// (R/W)
+// Two consecutive writes are needed to write the 9 bit colour
+// 1st write:
+// bits 7:0 = RRRGGGBB
+// 2nd write:
+// bits 7:1 = Reserved, must be 0
+// ...
+//
+// Each write becomes its own mode with a single Read/Write access block so the
+// two distinct bit layouts render as separate tables. Two-space prose outside a
+// write header is register-level commentary; six-space lines continue the
+// preceding bit definition.
+import { Register, RegisterAccess, RegisterDetail, isIssueRestricted } from "@/utils/register_parser";
+
+export const parseDescription44 = (reg: Register, description: string) => {
+ const descriptionLines = description.split('\n');
+ reg.modes = reg.modes || [];
+
+ let currentDetail: RegisterDetail | null = null;
+ let currentAccess: RegisterAccess | null = null;
+
+ const finishDetail = () => {
+ if (currentDetail && currentAccess) {
+ currentDetail.common = currentAccess;
+ reg.modes.push(currentDetail);
+ }
+ currentDetail = null;
+ currentAccess = null;
+ };
+
+ for (const line of descriptionLines) {
+ reg.source.push(line);
+ reg.search += line.toLowerCase() + " ";
+
+ const trimmedLine = line.trim();
+ if (!trimmedLine) continue;
+ if (trimmedLine.startsWith('//')) continue;
+ if (isIssueRestricted(line)) reg.issue_4_only = true;
+
+ const spaces_at_start = line.match(/^(\s*)/)?.[0].length || 0;
+
+ // The lone "(R/W)" marker just confirms the access type; both writes are R/W.
+ if (trimmedLine.startsWith('(R/W')) continue;
+
+ // A write header: "1st write:", "2nd write:", etc. starts a new mode.
+ if (spaces_at_start <= 2 && /^\d+(st|nd|rd|th)\s+write:?$/i.test(trimmedLine)) {
+ finishDetail();
+ currentDetail = { read: undefined, write: undefined, common: undefined, text: '' };
+ currentDetail.modeName = trimmedLine.replace(/:$/, '');
+ currentAccess = { operations: [], notes: [] };
+ continue;
+ }
+
+ const bitMatch = trimmedLine.match(/^(bits?|bit)\s+([\d:-]+)\s*=\s*(.*)/);
+
+ // Bit definitions live under a write header at deeper indentation.
+ if (currentAccess && spaces_at_start >= 4 && bitMatch) {
+ currentAccess.operations.push({
+ bits: bitMatch[2],
+ description: bitMatch[3].trim(),
+ });
+ continue;
+ }
+
+ // Six-space (or deeper) prose continues the previous bit definition.
+ if (currentAccess && spaces_at_start >= 6 && currentAccess.operations.length > 0) {
+ const ops = currentAccess.operations;
+ ops[ops.length - 1].description += `\n${trimmedLine}`;
+ continue;
+ }
+
+ // Four-space prose inside a write block is access-level description.
+ if (currentAccess && spaces_at_start >= 4) {
+ currentAccess.description = currentAccess.description
+ ? `${currentAccess.description}\n${trimmedLine}`
+ : trimmedLine;
+ continue;
+ }
+
+ // Anything shallower (two-space prose outside a write block) is register commentary.
+ reg.text += `${trimmedLine}\n`;
+ }
+
+ finishDetail();
+};
diff --git a/src/utils/register_parsers/reg_default.ts b/src/utils/register_parsers/reg_default.ts
index 453df22..df30292 100644
--- a/src/utils/register_parsers/reg_default.ts
+++ b/src/utils/register_parsers/reg_default.ts
@@ -1,5 +1,9 @@
-import {Register, RegisterAccess, RegisterDetail, Note} from "@/utils/register_parser";
+import {Register, RegisterAccess, RegisterDetail, Note, detailHasContent, isIssueRestricted} from "@/utils/register_parser";
+// Default parser for the common register format: optional (R) / (W) / (R/W)
+// access blocks, each holding "bit(s) N = description" rows, with footnotes
+// (*, **, ...) that may span multiple indented lines, plus free prose. Produces
+// a single RegisterDetail (mode) per register.
export const parseDescriptionDefault = (reg: Register, description: string) => {
const descriptionLines = description.split('\n');
let currentAccess: 'read' | 'write' | 'common' | null = null;
@@ -10,13 +14,11 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
// Footnote multiline state
let inFootnote = false;
let footnoteBaseIndent = 0;
- // let footnoteTarget: 'global' | 'access' | null = null;
let currentFootnote: Note | null = null;
const endFootnoteIfActive = () => {
inFootnote = false;
footnoteBaseIndent = 0;
- // footnoteTarget = null;
currentFootnote = null;
};
@@ -30,7 +32,7 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
reg.search += line.toLowerCase() + " ";
const spaces_at_start = line.match(/^(\s*)/)?.[0].length || 0;
- if (line.includes('Issue 4 Only')) reg.issue_4_only = true;
+ if (isIssueRestricted(line)) reg.issue_4_only = true;
// Handle multiline footnote continuation
if (inFootnote) {
@@ -73,7 +75,8 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
currentAccess = 'common';
continue;
}
- // New top-level text block (no leading spaces)
+ // A line with no leading whitespace (line === its own trimmed form)
+ // starts a new top-level text block, so close any open access block.
if (line.startsWith(trimmedLine)) {
if (currentAccess) {
detail[currentAccess] = accessData;
@@ -89,10 +92,8 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
const note: Note = { ref: noteMatch[1], text: noteMatch[2] };
if (currentAccess) {
accessData.notes.push(note);
- // footnoteTarget = 'access';
} else {
reg.notes.push(note);
- // footnoteTarget = 'global';
}
currentFootnote = note;
inFootnote = true;
@@ -103,7 +104,6 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
if (currentAccess) {
const bitMatch = trimmedLine.match(/^(bits?|bit)\s+([\d:-]+)\s*=\s*(.*)/);
- // const valueMatch = !line.match(/^\s+/) && trimmedLine.match(/^([01\s]+)\s*=\s*(.*)/);
if (bitMatch) {
let bitDescription = bitMatch[3];
@@ -118,13 +118,9 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
description: bitDescription,
footnoteRef: footnoteRef,
});
- // } else if (valueMatch) {
- // console.error("VALUE MATCH",valueMatch);
- // accessData.operations.push({
- // bits: valueMatch[1].trim().replace(/\s/g, ''),
- // description: valueMatch[2].trim(),
- // });
} else if (trimmedLine) {
+ // Prose indented exactly two spaces inside an access block is
+ // register-level commentary rather than part of the bit table.
if(spaces_at_start == 2) {
reg.text += `${line}\n`;
continue;
@@ -151,7 +147,10 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
if (currentAccess) {
detail[currentAccess] = accessData;
}
- // Push the parsed detail into modes
+ // Push the parsed detail into modes, unless it is empty — body-less
+ // registers (e.g. the "Reserved" entries) would otherwise add a blank mode.
reg.modes = reg.modes || [];
- reg.modes.push(detail);
+ if (detailHasContent(detail)) {
+ reg.modes.push(detail);
+ }
};
\ No newline at end of file
diff --git a/src/utils/register_parsers/reg_f0.ts b/src/utils/register_parsers/reg_f0.ts
index d620676..53a0a78 100644
--- a/src/utils/register_parsers/reg_f0.ts
+++ b/src/utils/register_parsers/reg_f0.ts
@@ -5,7 +5,7 @@
// - Lines with three or more leading spaces (>=3) belong to the current mode.
// - A line with exactly two spaces followed by '*' is a parent (register-level) note, not a mode note.
// - Inside access blocks for F0, lines starting with '*' are headings for description (not notes).
-import { Register, RegisterAccess, RegisterDetail } from "@/utils/register_parser";
+import { Register, RegisterAccess, RegisterDetail, detailHasContent, isIssueRestricted } from "@/utils/register_parser";
export const parseDescriptionF0 = (reg: Register, description: string) => {
const descriptionLines = description.split('\n');
@@ -37,7 +37,10 @@ export const parseDescriptionF0 = (reg: Register, description: string) => {
// finalize previous access block into detail
detail[currentAccess] = accessData;
}
- reg.modes.push(detail);
+ // Skip the initial blank detail that precedes the first mode header.
+ if (detailHasContent(detail)) {
+ reg.modes.push(detail);
+ }
detail = {read: undefined, write: undefined, common: undefined, text: ''};
detail.modeName = trimmedLine;
@@ -47,7 +50,7 @@ export const parseDescriptionF0 = (reg: Register, description: string) => {
}
}
- if (line.includes('Issue 4 Only')) reg.issue_4_only = true;
+ if (isIssueRestricted(line)) reg.issue_4_only = true;
if (trimmedLine.startsWith('//')) continue;
@@ -110,20 +113,17 @@ export const parseDescriptionF0 = (reg: Register, description: string) => {
}
}
} else if (trimmedLine) {
- if (line.match(/^\s+/) && accessData.operations.length > 0) {
- accessData.operations[accessData.operations.length - 1].description += `\n${line}`;
- } else {
- if (!accessData.description) {
- accessData.description = '';
- }
- accessData.description += `\n${trimmedLine}`;
- }
+ // Prose outside any access block (the leading "R/W Issues 4 and 5 Only -
+ // (soft reset = 0x80)" line) is register-level commentary.
+ reg.text += `${trimmedLine}\n`;
}
}
if (currentAccess) {
detail[currentAccess] = accessData;
}
- // Push the parsed detail into modes
- reg.modes.push(detail);
+ // Push the final mode's detail into modes (if it carries content).
+ if (detailHasContent(detail)) {
+ reg.modes.push(detail);
+ }
};
diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts
new file mode 100644
index 0000000..1a478e6
--- /dev/null
+++ b/src/utils/serialize.ts
@@ -0,0 +1,8 @@
+/**
+ * Deep-clone a value through JSON round-trip.
+ * Strips non-serializable properties (e.g. Drizzle decimal wrappers)
+ * so the result is safe to pass from Server Components to Client Components.
+ */
+export function serialize(value: T): T {
+ return JSON.parse(JSON.stringify(value));
+}