From e2a950da58f553e15166318b817f2a86cb6d694f Mon Sep 17 00:00:00 2001 From: "CanbiZ (MickLesk)" <47820557+MickLesk@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:35:03 +0100 Subject: [PATCH] feat: cache logos locally, show config_path, rename Sync button (#511) - logoCacheService.ts: download script logos to public/logos/ for local serving - cache-logos.ts: build-time script caching 500+ logos from PocketBase - scripts.ts router: resolve local logo paths, resyncScripts now caches logos - autoSyncService.js: cache logos during background auto-sync - ScriptDetailModal: show config_path per install method - ResyncButton: renamed 'Sync Json Files' to 'Sync Scripts' - GeneralSettingsModal: updated auto-sync description text - .gitignore: ignore public/logos/ and data/*.db --- .gitignore | 5 +- package.json | 2 +- scripts/cache-logos.ts | 32 +++++ src/app/_components/GeneralSettingsModal.tsx | 2 +- src/app/_components/ResyncButton.tsx | 4 +- src/app/_components/ScriptDetailModal.tsx | 10 ++ src/server/api/routers/scripts.ts | 48 +++++-- src/server/services/autoSyncService.js | 12 ++ src/server/services/logoCacheService.ts | 129 +++++++++++++++++++ 9 files changed, 229 insertions(+), 15 deletions(-) create mode 100644 scripts/cache-logos.ts create mode 100644 src/server/services/logoCacheService.ts diff --git a/.gitignore b/.gitignore index 1e7ad08..dbc949a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ /prisma/db.sqlite /prisma/db.sqlite-journal db.sqlite -data/settings.db +data/*.db # prisma generated client /prisma/generated/ @@ -27,6 +27,9 @@ data/ssh-keys/ /out/ next-env.d.ts +# cached logos (downloaded at runtime) +/public/logos/ + # production /build diff --git a/package.json b/package.json index aeb75eb..f3c336d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "prisma generate && next build --webpack", + "build": "prisma generate && node --import tsx scripts/cache-logos.ts && next build --webpack", "check": "eslint . && tsc --noEmit", "dev": "next dev --webpack", "dev:server": "node --import tsx server.js", diff --git a/scripts/cache-logos.ts b/scripts/cache-logos.ts new file mode 100644 index 0000000..ce7a835 --- /dev/null +++ b/scripts/cache-logos.ts @@ -0,0 +1,32 @@ +/** + * Build-time script: fetch all logos from PocketBase and cache them to public/logos/. + * Called as part of `npm run build` so the app starts with logos pre-cached. + */ + +import { getPb } from '../src/server/services/pbService'; +import { cacheLogos } from '../src/server/services/logoCacheService'; + +async function main() { + console.log('[cache-logos] Fetching script list from PocketBase...'); + const pb = getPb(); + const records = await pb.collection('script_scripts').getFullList({ + fields: 'slug,logo', + batch: 500, + }); + + const entries = records + .filter((r) => r.logo) + .map((r) => ({ slug: r.slug, url: r.logo })); + + console.log(`[cache-logos] Caching ${entries.length} logos...`); + const result = await cacheLogos(entries); + console.log( + `[cache-logos] Done: ${result.downloaded} downloaded, ${result.skipped} already cached, ${result.errors} errors`, + ); +} + +main().catch((err) => { + console.error('[cache-logos] Failed:', err); + // Non-fatal — build should continue even if logo caching fails + process.exit(0); +}); diff --git a/src/app/_components/GeneralSettingsModal.tsx b/src/app/_components/GeneralSettingsModal.tsx index a9146de..6ea0cb2 100644 --- a/src/app/_components/GeneralSettingsModal.tsx +++ b/src/app/_components/GeneralSettingsModal.tsx @@ -1210,7 +1210,7 @@ export function GeneralSettingsModal({ Enable Auto-Sync

- Automatically sync JSON files from GitHub at specified + Automatically sync scripts from PocketBase at specified intervals

diff --git a/src/app/_components/ResyncButton.tsx b/src/app/_components/ResyncButton.tsx index 6ba6171..6d94830 100644 --- a/src/app/_components/ResyncButton.tsx +++ b/src/app/_components/ResyncButton.tsx @@ -104,7 +104,7 @@ export function ResyncButton() { return (
- Sync scripts with configured repositories + Sync scripts and cache logos locally
@@ -125,7 +125,7 @@ export function ResyncButton() { - Sync Json Files + Sync Scripts )} diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx index 07a703b..b744fd0 100644 --- a/src/app/_components/ScriptDetailModal.tsx +++ b/src/app/_components/ScriptDetailModal.tsx @@ -808,6 +808,16 @@ export function ScriptDetailModal({
+ {method.config_path && ( +
+
+ Config Path +
+
+ {method.config_path} +
+
+ )}
))} diff --git a/src/server/api/routers/scripts.ts b/src/server/api/routers/scripts.ts index c7d0b3b..118f7cb 100644 --- a/src/server/api/routers/scripts.ts +++ b/src/server/api/routers/scripts.ts @@ -16,6 +16,7 @@ import { } from "~/server/services/pbScripts"; import type { Script, ScriptCard } from "~/types/script"; import type { Server } from "~/types/server"; +import { cacheLogos, getLocalLogoPath } from "~/server/services/logoCacheService"; // --------------------------------------------------------------------------- // Mapper: PocketBase record → internal Script type (used by scriptDownloader) @@ -177,7 +178,14 @@ export const scriptsRouter = createTRPCRouter({ .query(async () => { try { const cards = await getScriptCards(); - return { success: true, cards: cards.map(pbCardToScriptCard) }; + return { + success: true, + cards: cards.map((c) => { + const card = pbCardToScriptCard(c); + card.logo = getLocalLogoPath(c.slug, card.logo); + return card; + }), + }; } catch (error) { console.error('Error in getScriptCards:', error); return { @@ -212,7 +220,9 @@ export const scriptsRouter = createTRPCRouter({ if (!pb) { return { success: false, error: 'Script not found', script: null }; } - return { success: true, script: pbToScript(pb) }; + const script = pbToScript(pb); + script.logo = getLocalLogoPath(pb.slug, script.logo); + return { success: true, script }; } catch (error) { console.error('Error in getScriptBySlug:', error); return { @@ -245,7 +255,11 @@ export const scriptsRouter = createTRPCRouter({ try { // PocketBase already returns category names expanded on each card const cards = await getScriptCards(); - const scriptCards = cards.map(pbCardToScriptCard); + const scriptCards = cards.map((c) => { + const card = pbCardToScriptCard(c); + card.logo = getLocalLogoPath(c.slug, card.logo); + return card; + }); // Also return the category list for the sidebar filter const metadata = await pbGetMetadata(); @@ -262,15 +276,29 @@ export const scriptsRouter = createTRPCRouter({ } }), - // PocketBase is always up to date – this is a no-op kept for API compatibility + // Sync: cache logos locally from PocketBase script data resyncScripts: publicProcedure .mutation(async () => { - return { - success: true, - message: 'Script catalog is served directly from PocketBase and is always up to date.', - count: 0, - error: undefined as string | undefined, - }; + try { + const cards = await getScriptCards(); + const entries = cards + .filter((c) => c.logo) + .map((c) => ({ slug: c.slug, url: c.logo! })); + const result = await cacheLogos(entries); + return { + success: true, + message: `Logo cache updated: ${result.downloaded} downloaded, ${result.skipped} cached, ${result.errors} errors.`, + count: result.downloaded, + error: undefined as string | undefined, + }; + } catch (error) { + return { + success: false, + message: 'Failed to sync logos', + count: 0, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } }), // Load script files from the community repository diff --git a/src/server/services/autoSyncService.js b/src/server/services/autoSyncService.js index 4b1ff1f..0d94ba4 100644 --- a/src/server/services/autoSyncService.js +++ b/src/server/services/autoSyncService.js @@ -360,6 +360,18 @@ export class AutoSyncService { const pbScripts = await pbGetAllScripts(); console.log(`Retrieved ${pbScripts.length} scripts from PocketBase`); + // Step 1b: Cache logos locally + try { + const { cacheLogos } = await import('./logoCacheService'); + const logoEntries = pbScripts + .filter(pb => pb.logo) + .map(pb => ({ slug: pb.slug, url: /** @type {string} */ (pb.logo) })); + const logoResult = await cacheLogos(logoEntries); + console.log(`Logo cache: ${logoResult.downloaded} new, ${logoResult.skipped} cached, ${logoResult.errors} errors`); + } catch (logoErr) { + console.warn('Logo caching failed (non-fatal):', logoErr); + } + // Map PocketBase records to the internal Script format used by scriptDownloader const { scriptDownloaderService: sds } = await import('./scriptDownloader.js'); const allScripts = pbScripts.map(pb => ({ diff --git a/src/server/services/logoCacheService.ts b/src/server/services/logoCacheService.ts new file mode 100644 index 0000000..0ba3920 --- /dev/null +++ b/src/server/services/logoCacheService.ts @@ -0,0 +1,129 @@ +/** + * Logo cache service — downloads script logos to public/logos/ so they can be + * served locally by Next.js instead of fetching from remote CDNs on every request. + * + * Logos are stored as `public/logos/{slug}.webp` (keeping original extension when not webp). + * ScriptCard / ScriptDetailModal can then use `/logos/{slug}.{ext}` as the src. + */ + +import { existsSync, mkdirSync } from 'fs'; +import { writeFile, readdir, unlink } from 'fs/promises'; +import { join, extname } from 'path'; + +const LOGOS_DIR = join(process.cwd(), 'public', 'logos'); + +/** Ensure the logos directory exists. */ +function ensureLogosDir(): void { + if (!existsSync(LOGOS_DIR)) { + mkdirSync(LOGOS_DIR, { recursive: true }); + } +} + +/** Extract a reasonable file extension from a logo URL. */ +function getExtension(url: string): string { + try { + const pathname = new URL(url).pathname; + const ext = extname(pathname).toLowerCase(); + if (['.png', '.jpg', '.jpeg', '.svg', '.webp', '.gif', '.ico'].includes(ext)) { + return ext; + } + } catch { /* invalid URL */ } + return '.webp'; // default +} + +export interface LogoEntry { + slug: string; + url: string; +} + +/** + * Download logos for the given scripts to `public/logos/`. + * Skips logos that already exist locally unless `force` is set. + * Returns the number of newly downloaded logos. + */ +export async function cacheLogos( + entries: LogoEntry[], + options?: { force?: boolean; concurrency?: number } +): Promise<{ downloaded: number; skipped: number; errors: number }> { + ensureLogosDir(); + + const force = options?.force ?? false; + const concurrency = options?.concurrency ?? 10; + let downloaded = 0; + let skipped = 0; + let errors = 0; + + // Process in batches of `concurrency` + for (let i = 0; i < entries.length; i += concurrency) { + const batch = entries.slice(i, i + concurrency); + const results = await Promise.allSettled( + batch.map(async (entry) => { + if (!entry.url) { + skipped++; + return; + } + + const ext = getExtension(entry.url); + const filename = `${entry.slug}${ext}`; + const filepath = join(LOGOS_DIR, filename); + + if (!force && existsSync(filepath)) { + skipped++; + return; + } + + const response = await fetch(entry.url, { + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${entry.url}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + await writeFile(filepath, buffer); + downloaded++; + }), + ); + + for (const r of results) { + if (r.status === 'rejected') { + errors++; + } + } + } + + return { downloaded, skipped, errors }; +} + +/** + * Given a remote logo URL and a slug, return the local path if the logo + * has been cached, otherwise return the original URL. + */ +export function getLocalLogoPath(slug: string, remoteUrl: string | null): string | null { + if (!remoteUrl) return null; + const ext = getExtension(remoteUrl); + const filename = `${slug}${ext}`; + const filepath = join(LOGOS_DIR, filename); + if (existsSync(filepath)) { + return `/logos/${filename}`; + } + return remoteUrl; +} + +/** + * Clean up logos for scripts that no longer exist. + */ +export async function cleanupOrphanedLogos(activeSlugs: Set): Promise { + ensureLogosDir(); + let removed = 0; + try { + const files = await readdir(LOGOS_DIR); + for (const file of files) { + const slug = file.replace(/\.[^.]+$/, ''); + if (!activeSlugs.has(slug)) { + await unlink(join(LOGOS_DIR, file)); + removed++; + } + } + } catch { /* directory may not exist yet */ } + return removed; +}