#!/usr/bin/env node /** * Sync ftp-images -> public/images and report changes. * Usage: node scripts/sync-ftp-images.mjs [--dry-run] * * Mapping: * ftp-images/bilder-bp/ -> public/images/dishes/ (menu uses /images/dishes/...) * ftp-images// -> public/images// */ import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, ".."); const DRY_RUN = process.argv.includes("--dry-run"); const FTP_ROOT = path.join(ROOT, "ftp-images"); const PUBLIC_IMAGES = path.join(ROOT, "public", "images"); const LOG_FILE = path.join(ROOT, "sync-images-log.txt"); const RESULT_FILE = path.join(ROOT, "sync-result.json"); /** FTP subfolder name -> public/images subfolder name */ const DEST_ALIASES = { "bilder-bp": "dishes", }; const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"]); const logLines = []; const log = (...args) => { const line = args.map(String).join(" "); logLines.push(line); console.log(...args); }; function hashFile(filePath) { const data = fs.readFileSync(filePath); return crypto.createHash("md5").update(data).digest("hex"); } function walk(dir) { const out = []; if (!fs.existsSync(dir)) return out; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) out.push(...walk(full)); else if (entry.isFile()) out.push(full); } return out; } function rel(from, to) { return path.relative(from, to).split(path.sep).join("/"); } function discoverSources() { if (!fs.existsSync(FTP_ROOT)) return []; const pairs = []; for (const entry of fs.readdirSync(FTP_ROOT, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const destName = DEST_ALIASES[entry.name] ?? entry.name; pairs.push({ src: path.join(FTP_ROOT, entry.name), dest: path.join(PUBLIC_IMAGES, destName), srcLabel: `ftp-images/${entry.name}`, destLabel: `public/images/${destName}`, }); } return pairs; } function syncPair({ src, dest, srcLabel, destLabel }) { fs.mkdirSync(dest, { recursive: true }); const srcFiles = walk(src).filter((f) => IMAGE_EXT.has(path.extname(f).toLowerCase())); const destFiles = walk(dest).filter((f) => IMAGE_EXT.has(path.extname(f).toLowerCase())); const srcSet = new Set(srcFiles.map((f) => rel(src, f))); const copied = []; const updated = []; const unchanged = []; const removed = []; for (const file of srcFiles) { const relPath = rel(src, file); const target = path.join(dest, relPath); fs.mkdirSync(path.dirname(target), { recursive: true }); if (!fs.existsSync(target)) { if (!DRY_RUN) fs.copyFileSync(file, target); copied.push(relPath); continue; } if (hashFile(file) !== hashFile(target)) { if (!DRY_RUN) fs.copyFileSync(file, target); updated.push(relPath); } else { unchanged.push(relPath); } } for (const file of destFiles) { const relPath = rel(dest, file); if (!srcSet.has(relPath)) { if (!DRY_RUN) fs.unlinkSync(file); removed.push(relPath); } } log(`\n=== ${srcLabel} -> ${destLabel} ===`); log(` new: ${copied.length} updated: ${updated.length} unchanged: ${unchanged.length} removed: ${removed.length}`); for (const f of copied) log(` + ${f}`); for (const f of updated) log(` ~ ${f}`); for (const f of removed) log(` - ${f}`); return { copied, updated, unchanged, removed, src: srcLabel, dest: destLabel }; } function checkMenuReferences() { const menuFile = path.join(ROOT, "infrastructure", "menu", "static-menu-data.ts"); if (!fs.existsSync(menuFile)) { log("\nMenu file not found; skipping reference check."); return { unique: [], missing: [] }; } const text = fs.readFileSync(menuFile, "utf8"); const refs = [...text.matchAll(/["'`](\/images\/[^"'`]+)["'`]/g)].map((m) => m[1]); const unique = [...new Set(refs)]; const missing = unique.filter((ref) => !fs.existsSync(path.join(ROOT, "public", ref.replace(/^\//, "")))); log(`\n=== Menu references: ${unique.length} unique, ${missing.length} missing ===`); for (const m of missing) log(` ! ${m}`); return { unique, missing }; } function writeOutputs(result) { if (!DRY_RUN) { fs.writeFileSync(LOG_FILE, logLines.join("\n") + "\n"); fs.writeFileSync(RESULT_FILE, JSON.stringify(result, null, 2) + "\n"); log(`\nWrote ${path.relative(ROOT, LOG_FILE)}`); log(`Wrote ${path.relative(ROOT, RESULT_FILE)}`); } } if (!fs.existsSync(FTP_ROOT)) { console.error(`ftp-images not found at ${FTP_ROOT}`); process.exit(1); } log(DRY_RUN ? "DRY RUN\n" : "Syncing...\n"); const pairs = discoverSources(); if (pairs.length === 0) { log("No subfolders found in ftp-images/"); writeOutputs({ totals: { copied: 0, updated: 0, unchanged: 0, removed: 0 }, pairs: [], menu: { unique: [], missing: [] } }); process.exit(0); } const pairResults = []; const totals = { copied: 0, updated: 0, unchanged: 0, removed: 0 }; for (const pair of pairs) { const result = syncPair(pair); pairResults.push(result); totals.copied += result.copied.length; totals.updated += result.updated.length; totals.unchanged += result.unchanged.length; totals.removed += result.removed.length; } log(`\n=== TOTALS: ${totals.copied} new, ${totals.updated} updated, ${totals.unchanged} unchanged, ${totals.removed} removed ===`); const menu = checkMenuReferences(); writeOutputs({ dryRun: DRY_RUN, totals, pairs: pairResults, menu }); if (menu.missing.length) process.exitCode = 1;