Skip to content

Commit e2a950d

Browse files
authored
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
1 parent d8e92e0 commit e2a950d

9 files changed

Lines changed: 229 additions & 15 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
/prisma/db.sqlite
1515
/prisma/db.sqlite-journal
1616
db.sqlite
17-
data/settings.db
17+
data/*.db
1818

1919
# prisma generated client
2020
/prisma/generated/
@@ -27,6 +27,9 @@ data/ssh-keys/
2727
/out/
2828
next-env.d.ts
2929

30+
# cached logos (downloaded at runtime)
31+
/public/logos/
32+
3033
# production
3134
/build
3235

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"build": "prisma generate && next build --webpack",
7+
"build": "prisma generate && node --import tsx scripts/cache-logos.ts && next build --webpack",
88
"check": "eslint . && tsc --noEmit",
99
"dev": "next dev --webpack",
1010
"dev:server": "node --import tsx server.js",

scripts/cache-logos.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Build-time script: fetch all logos from PocketBase and cache them to public/logos/.
3+
* Called as part of `npm run build` so the app starts with logos pre-cached.
4+
*/
5+
6+
import { getPb } from '../src/server/services/pbService';
7+
import { cacheLogos } from '../src/server/services/logoCacheService';
8+
9+
async function main() {
10+
console.log('[cache-logos] Fetching script list from PocketBase...');
11+
const pb = getPb();
12+
const records = await pb.collection('script_scripts').getFullList({
13+
fields: 'slug,logo',
14+
batch: 500,
15+
});
16+
17+
const entries = records
18+
.filter((r) => r.logo)
19+
.map((r) => ({ slug: r.slug, url: r.logo }));
20+
21+
console.log(`[cache-logos] Caching ${entries.length} logos...`);
22+
const result = await cacheLogos(entries);
23+
console.log(
24+
`[cache-logos] Done: ${result.downloaded} downloaded, ${result.skipped} already cached, ${result.errors} errors`,
25+
);
26+
}
27+
28+
main().catch((err) => {
29+
console.error('[cache-logos] Failed:', err);
30+
// Non-fatal — build should continue even if logo caching fails
31+
process.exit(0);
32+
});

src/app/_components/GeneralSettingsModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1210,7 +1210,7 @@ export function GeneralSettingsModal({
12101210
Enable Auto-Sync
12111211
</h4>
12121212
<p className="text-muted-foreground text-sm">
1213-
Automatically sync JSON files from GitHub at specified
1213+
Automatically sync scripts from PocketBase at specified
12141214
intervals
12151215
</p>
12161216
</div>

src/app/_components/ResyncButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export function ResyncButton() {
104104
return (
105105
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
106106
<div className="text-sm text-muted-foreground font-medium">
107-
Sync scripts with configured repositories
107+
Sync scripts and cache logos locally
108108
</div>
109109
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
110110
<div className="flex items-center gap-2">
@@ -125,7 +125,7 @@ export function ResyncButton() {
125125
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
126126
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
127127
</svg>
128-
<span>Sync Json Files</span>
128+
<span>Sync Scripts</span>
129129
</>
130130
)}
131131
</Button>

src/app/_components/ScriptDetailModal.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,16 @@ export function ScriptDetailModal({
808808
</dd>
809809
</div>
810810
</div>
811+
{method.config_path && (
812+
<div className="mt-2 text-xs sm:text-sm">
813+
<dt className="text-muted-foreground font-medium">
814+
Config Path
815+
</dt>
816+
<dd className="text-foreground font-mono text-xs break-all">
817+
{method.config_path}
818+
</dd>
819+
</div>
820+
)}
811821
</div>
812822
))}
813823
</div>

src/server/api/routers/scripts.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "~/server/services/pbScripts";
1717
import type { Script, ScriptCard } from "~/types/script";
1818
import type { Server } from "~/types/server";
19+
import { cacheLogos, getLocalLogoPath } from "~/server/services/logoCacheService";
1920

2021
// ---------------------------------------------------------------------------
2122
// Mapper: PocketBase record → internal Script type (used by scriptDownloader)
@@ -177,7 +178,14 @@ export const scriptsRouter = createTRPCRouter({
177178
.query(async () => {
178179
try {
179180
const cards = await getScriptCards();
180-
return { success: true, cards: cards.map(pbCardToScriptCard) };
181+
return {
182+
success: true,
183+
cards: cards.map((c) => {
184+
const card = pbCardToScriptCard(c);
185+
card.logo = getLocalLogoPath(c.slug, card.logo);
186+
return card;
187+
}),
188+
};
181189
} catch (error) {
182190
console.error('Error in getScriptCards:', error);
183191
return {
@@ -212,7 +220,9 @@ export const scriptsRouter = createTRPCRouter({
212220
if (!pb) {
213221
return { success: false, error: 'Script not found', script: null };
214222
}
215-
return { success: true, script: pbToScript(pb) };
223+
const script = pbToScript(pb);
224+
script.logo = getLocalLogoPath(pb.slug, script.logo);
225+
return { success: true, script };
216226
} catch (error) {
217227
console.error('Error in getScriptBySlug:', error);
218228
return {
@@ -245,7 +255,11 @@ export const scriptsRouter = createTRPCRouter({
245255
try {
246256
// PocketBase already returns category names expanded on each card
247257
const cards = await getScriptCards();
248-
const scriptCards = cards.map(pbCardToScriptCard);
258+
const scriptCards = cards.map((c) => {
259+
const card = pbCardToScriptCard(c);
260+
card.logo = getLocalLogoPath(c.slug, card.logo);
261+
return card;
262+
});
249263

250264
// Also return the category list for the sidebar filter
251265
const metadata = await pbGetMetadata();
@@ -262,15 +276,29 @@ export const scriptsRouter = createTRPCRouter({
262276
}
263277
}),
264278

265-
// PocketBase is always up to date – this is a no-op kept for API compatibility
279+
// Sync: cache logos locally from PocketBase script data
266280
resyncScripts: publicProcedure
267281
.mutation(async () => {
268-
return {
269-
success: true,
270-
message: 'Script catalog is served directly from PocketBase and is always up to date.',
271-
count: 0,
272-
error: undefined as string | undefined,
273-
};
282+
try {
283+
const cards = await getScriptCards();
284+
const entries = cards
285+
.filter((c) => c.logo)
286+
.map((c) => ({ slug: c.slug, url: c.logo! }));
287+
const result = await cacheLogos(entries);
288+
return {
289+
success: true,
290+
message: `Logo cache updated: ${result.downloaded} downloaded, ${result.skipped} cached, ${result.errors} errors.`,
291+
count: result.downloaded,
292+
error: undefined as string | undefined,
293+
};
294+
} catch (error) {
295+
return {
296+
success: false,
297+
message: 'Failed to sync logos',
298+
count: 0,
299+
error: error instanceof Error ? error.message : 'Unknown error',
300+
};
301+
}
274302
}),
275303

276304
// Load script files from the community repository

src/server/services/autoSyncService.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,18 @@ export class AutoSyncService {
360360
const pbScripts = await pbGetAllScripts();
361361
console.log(`Retrieved ${pbScripts.length} scripts from PocketBase`);
362362

363+
// Step 1b: Cache logos locally
364+
try {
365+
const { cacheLogos } = await import('./logoCacheService');
366+
const logoEntries = pbScripts
367+
.filter(pb => pb.logo)
368+
.map(pb => ({ slug: pb.slug, url: /** @type {string} */ (pb.logo) }));
369+
const logoResult = await cacheLogos(logoEntries);
370+
console.log(`Logo cache: ${logoResult.downloaded} new, ${logoResult.skipped} cached, ${logoResult.errors} errors`);
371+
} catch (logoErr) {
372+
console.warn('Logo caching failed (non-fatal):', logoErr);
373+
}
374+
363375
// Map PocketBase records to the internal Script format used by scriptDownloader
364376
const { scriptDownloaderService: sds } = await import('./scriptDownloader.js');
365377
const allScripts = pbScripts.map(pb => ({
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Logo cache service — downloads script logos to public/logos/ so they can be
3+
* served locally by Next.js instead of fetching from remote CDNs on every request.
4+
*
5+
* Logos are stored as `public/logos/{slug}.webp` (keeping original extension when not webp).
6+
* ScriptCard / ScriptDetailModal can then use `/logos/{slug}.{ext}` as the src.
7+
*/
8+
9+
import { existsSync, mkdirSync } from 'fs';
10+
import { writeFile, readdir, unlink } from 'fs/promises';
11+
import { join, extname } from 'path';
12+
13+
const LOGOS_DIR = join(process.cwd(), 'public', 'logos');
14+
15+
/** Ensure the logos directory exists. */
16+
function ensureLogosDir(): void {
17+
if (!existsSync(LOGOS_DIR)) {
18+
mkdirSync(LOGOS_DIR, { recursive: true });
19+
}
20+
}
21+
22+
/** Extract a reasonable file extension from a logo URL. */
23+
function getExtension(url: string): string {
24+
try {
25+
const pathname = new URL(url).pathname;
26+
const ext = extname(pathname).toLowerCase();
27+
if (['.png', '.jpg', '.jpeg', '.svg', '.webp', '.gif', '.ico'].includes(ext)) {
28+
return ext;
29+
}
30+
} catch { /* invalid URL */ }
31+
return '.webp'; // default
32+
}
33+
34+
export interface LogoEntry {
35+
slug: string;
36+
url: string;
37+
}
38+
39+
/**
40+
* Download logos for the given scripts to `public/logos/`.
41+
* Skips logos that already exist locally unless `force` is set.
42+
* Returns the number of newly downloaded logos.
43+
*/
44+
export async function cacheLogos(
45+
entries: LogoEntry[],
46+
options?: { force?: boolean; concurrency?: number }
47+
): Promise<{ downloaded: number; skipped: number; errors: number }> {
48+
ensureLogosDir();
49+
50+
const force = options?.force ?? false;
51+
const concurrency = options?.concurrency ?? 10;
52+
let downloaded = 0;
53+
let skipped = 0;
54+
let errors = 0;
55+
56+
// Process in batches of `concurrency`
57+
for (let i = 0; i < entries.length; i += concurrency) {
58+
const batch = entries.slice(i, i + concurrency);
59+
const results = await Promise.allSettled(
60+
batch.map(async (entry) => {
61+
if (!entry.url) {
62+
skipped++;
63+
return;
64+
}
65+
66+
const ext = getExtension(entry.url);
67+
const filename = `${entry.slug}${ext}`;
68+
const filepath = join(LOGOS_DIR, filename);
69+
70+
if (!force && existsSync(filepath)) {
71+
skipped++;
72+
return;
73+
}
74+
75+
const response = await fetch(entry.url, {
76+
signal: AbortSignal.timeout(10_000),
77+
});
78+
if (!response.ok) {
79+
throw new Error(`HTTP ${response.status} for ${entry.url}`);
80+
}
81+
const buffer = Buffer.from(await response.arrayBuffer());
82+
await writeFile(filepath, buffer);
83+
downloaded++;
84+
}),
85+
);
86+
87+
for (const r of results) {
88+
if (r.status === 'rejected') {
89+
errors++;
90+
}
91+
}
92+
}
93+
94+
return { downloaded, skipped, errors };
95+
}
96+
97+
/**
98+
* Given a remote logo URL and a slug, return the local path if the logo
99+
* has been cached, otherwise return the original URL.
100+
*/
101+
export function getLocalLogoPath(slug: string, remoteUrl: string | null): string | null {
102+
if (!remoteUrl) return null;
103+
const ext = getExtension(remoteUrl);
104+
const filename = `${slug}${ext}`;
105+
const filepath = join(LOGOS_DIR, filename);
106+
if (existsSync(filepath)) {
107+
return `/logos/${filename}`;
108+
}
109+
return remoteUrl;
110+
}
111+
112+
/**
113+
* Clean up logos for scripts that no longer exist.
114+
*/
115+
export async function cleanupOrphanedLogos(activeSlugs: Set<string>): Promise<number> {
116+
ensureLogosDir();
117+
let removed = 0;
118+
try {
119+
const files = await readdir(LOGOS_DIR);
120+
for (const file of files) {
121+
const slug = file.replace(/\.[^.]+$/, '');
122+
if (!activeSlugs.has(slug)) {
123+
await unlink(join(LOGOS_DIR, file));
124+
removed++;
125+
}
126+
}
127+
} catch { /* directory may not exist yet */ }
128+
return removed;
129+
}

0 commit comments

Comments
 (0)