From 77b5e76a084e3455831510af8743d36f564fb524 Mon Sep 17 00:00:00 2001 From: "D. Rimron-Soutter" Date: Tue, 17 Feb 2026 12:30:55 +0000 Subject: [PATCH] Correct local file path resolution for ZXDB/WoS mirrors - Remove optional path prefix and prepend the required local string. - Avoid hardcoded 'SC' or 'WoS' subdirectories in path mapping. - Maintain binary state: show local link only if env var is set and file exists. Signed-off: junie@McFiver.local --- CLAUDE.md | 1 + example.env | 8 ++-- src/app/zxdb/entries/[id]/EntryDetail.tsx | 19 ++++++--- .../[entryId]/[releaseSeq]/ReleaseDetail.tsx | 42 +++++++++++++------ src/env.ts | 4 ++ src/server/repo/zxdb.ts | 35 ++++++++++++++++ 6 files changed, 87 insertions(+), 22 deletions(-) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/example.env b/example.env index 4a8fa95..868a991 100644 --- a/example.env +++ b/example.env @@ -12,12 +12,12 @@ PROTO=http ZXDB_URL=mysql://zxdb_readonly:password@hostname:3306/zxdb # Base HTTP locations for CDN sources used by downloads.file_link -# When file_link starts with /zxdb, it will be fetched from ZXDB_FILEPATH -ZXDB_FILEPATH=https://zxdbfiles.com/ +# When file_link starts with /zxdb, it will be fetched from ZXDB_REMOTE_FILEPATH +ZXDB_REMOTE_FILEPATH=https://zxdbfiles.com/ -# When file_link starts with /public, it will be fetched from WOS_FILEPATH +# When file_link starts with /public, it will be fetched from WOS_REMOTE_FILEPATH # Note: Example uses the Internet Archive WoS mirror; keep the trailing slash -WOS_FILEPATH=https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/ +WOS_REMOTE_FILEPATH=https://archive.org/download/World_of_Spectrum_June_2017_Mirror/World%20of%20Spectrum%20June%202017%20Mirror.zip/World%20of%20Spectrum%20June%202017%20Mirror/ # Local cache root where files will be mirrored (without the leading slash) CDN_CACHE=/mnt/files/zxfiles diff --git a/src/app/zxdb/entries/[id]/EntryDetail.tsx b/src/app/zxdb/entries/[id]/EntryDetail.tsx index bd6dc50..2d9c11b 100644 --- a/src/app/zxdb/entries/[id]/EntryDetail.tsx +++ b/src/app/zxdb/entries/[id]/EntryDetail.tsx @@ -103,6 +103,7 @@ export type EntryDetailData = { case: { id: string | null; name: string | null }; year: number | null; releaseSeq: number; + localLink?: string | null; }[]; releases?: { releaseSeq: number; @@ -125,6 +126,7 @@ export type EntryDetailData = { source: { id: string | null; name: string | null }; case: { id: string | null; name: string | null }; year: number | null; + localLink?: string | null; }[]; }[]; // Additional relationships @@ -392,11 +394,18 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu {d.type.name} - {isHttp ? ( - {d.link} - ) : ( - {d.link} - )} +
+ {isHttp ? ( + {d.link} + ) : ( + {d.link} + )} + {d.localLink && ( + + Local Mirror + + )} +
{typeof d.size === "number" ? d.size.toLocaleString() : "-"} {d.md5 ?? "-"} diff --git a/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx b/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx index 86d6e22..e752b2a 100644 --- a/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx +++ b/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx @@ -43,6 +43,7 @@ type ReleaseDetailData = { source: { id: string | null; name: string | null }; case: { id: string | null; name: string | null }; year: number | null; + localLink?: string | null; }>; scraps: Array<{ id: number; @@ -58,6 +59,7 @@ type ReleaseDetailData = { source: { id: string | null; name: string | null }; case: { id: string | null; name: string | null }; year: number | null; + localLink?: string | null; }>; files: Array<{ id: number; @@ -376,11 +378,18 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData {d.type.name} - {isHttp ? ( - {d.link} - ) : ( - {d.link} - )} +
+ {isHttp ? ( + {d.link} + ) : ( + {d.link} + )} + {d.localLink && ( + + Local Mirror + + )} +
{typeof d.size === "number" ? d.size.toLocaleString() : "-"} {d.md5 ?? "-"} @@ -438,15 +447,22 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData {s.type.name} - {s.link ? ( - isHttp ? ( - {s.link} +
+ {s.link ? ( + isHttp ? ( + {s.link} + ) : ( + {s.link} + ) ) : ( - {s.link} - ) - ) : ( - - - )} + - + )} + {s.localLink && ( + + Local Mirror + + )} +
{typeof s.size === "number" ? s.size.toLocaleString() : "-"} 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/server/repo/zxdb.ts b/src/server/repo/zxdb.ts index eb1fbad..63ddcbf 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 { @@ -108,6 +111,30 @@ 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; + + const zxdbPrefix = env.ZXDB_FILE_PREFIX || "/zxdb/sinclair/"; + const wosPrefix = env.WOS_FILE_PREFIX || "/pub/sinclair/"; + + if (fileLink.startsWith(zxdbPrefix) && env.ZXDB_LOCAL_FILEPATH) { + const sub = fileLink.slice(zxdbPrefix.replace(/\/$/, "").length); + localPath = path.join(env.ZXDB_LOCAL_FILEPATH, sub); + } else if (fileLink.startsWith(wosPrefix) && env.WOS_LOCAL_FILEPATH) { + const sub = fileLink.slice(wosPrefix.replace(/\/$/, "").length); + localPath = path.join(env.WOS_LOCAL_FILEPATH, sub); + } + + if (localPath && fs.existsSync(localPath)) { + return localPath; + } + + 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}`, @@ -479,6 +506,7 @@ export interface EntryDetail { case: { id: string | null; name: string | null }; year: number | null; releaseSeq: number; + localLink?: string | null; }[]; releases?: { releaseSeq: number; @@ -501,6 +529,7 @@ export interface EntryDetail { source: { id: string | null; name: string | null }; case: { id: string | null; name: string | null }; year: number | null; + localLink?: string | null; }[]; }[]; // Additional relationships surfaced on the entry detail page @@ -710,6 +739,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), })), })); @@ -1148,6 +1178,7 @@ 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 } })), @@ -2104,6 +2135,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; @@ -2119,6 +2151,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; @@ -2371,6 +2404,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), @@ -2386,6 +2420,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,