Files

178 lines
5.6 KiB
JavaScript

#!/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/<other>/ -> public/images/<other>/
*/
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;