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
| {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,
|