#!/usr/bin/env node /** * Uploads all images from a directory to Hetzner S3 + pictures table. * Re-encodes each file to WebP at 85% quality via cwebp. * * Usage: * TOKEN=your-dev-token node scripts/upload-pictures.mjs /path/to/folder * TOKEN=... BASE_URL=https://hyggecraftery.com/api/snakkimo node scripts/upload-pictures.mjs /path/to/folder */ import { readdir, readFile, unlink, writeFile } from 'fs/promises'; import { execSync } from 'child_process'; import { join, basename, extname } from 'path'; import { tmpdir } from 'os'; import { randomUUID } from 'crypto'; const TOKEN = process.env.TOKEN; const BASE_URL = (process.env.BASE_URL || 'https://hyggecraftery.com/api/snakkimo/api').replace(/\/$/, ''); const CONCURRENCY = 4; if (!TOKEN) { console.error('ERROR: TOKEN env var required. Run: TOKEN=your-token node scripts/upload-pictures.mjs '); process.exit(1); } const dir = process.argv[2]; if (!dir) { console.error('ERROR: Pass the image directory as argument.'); process.exit(1); } function extractDesign(filename) { const name = basename(filename, extname(filename)); // Strip trailing _xxxxxxxx hash (8 hex chars) return name.replace(/_[0-9a-f]{8}$/i, '').replace(/_/g, ' '); } async function apiPost(path, body) { const res = await fetch(`${BASE_URL}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`POST ${path} → ${res.status}: ${await res.text()}`); return res.json(); } async function apiUpload(pictureId, fileBuffer) { const form = new FormData(); const blob = new Blob([fileBuffer], { type: 'image/webp' }); form.append('file', blob, `${pictureId}.webp`); const res = await fetch(`${BASE_URL}/pictures/${pictureId}/upload`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}` }, body: form, }); if (!res.ok) throw new Error(`upload → ${res.status}: ${await res.text()}`); return res.json(); } async function processFile(filePath) { const filename = basename(filePath); const design = extractDesign(filename); const tmpFile = join(tmpdir(), `${randomUUID()}.webp`); try { // Re-encode to webp at 85% quality execSync(`cwebp -q 85 "${filePath}" -o "${tmpFile}" -quiet`, { stdio: 'pipe' }); const buffer = await readFile(tmpFile); // 1. Create picture record const picture = await apiPost('/pictures', { design }); // 2. Upload file await apiUpload(picture.id, buffer); return { ok: true, design, id: picture.id }; } finally { await unlink(tmpFile).catch(() => {}); } } async function run() { const files = (await readdir(dir)) .filter(f => /\.(webp|jpg|jpeg|png)$/i.test(f)) .map(f => join(dir, f)); console.log(`Found ${files.length} files. Uploading with concurrency ${CONCURRENCY}...\n`); let done = 0; const errors = []; // Process in chunks of CONCURRENCY for (let i = 0; i < files.length; i += CONCURRENCY) { const chunk = files.slice(i, i + CONCURRENCY); const results = await Promise.allSettled(chunk.map(processFile)); for (let j = 0; j < results.length; j++) { const r = results[j]; done++; if (r.status === 'fulfilled') { console.log(`[${done}/${files.length}] ✓ ${r.value.design} (${r.value.id})`); } else { const name = basename(chunk[j]); console.error(`[${done}/${files.length}] ✗ ${name}: ${r.reason.message}`); errors.push({ file: name, error: r.reason.message }); } } } console.log(`\nDone. ${done - errors.length} succeeded, ${errors.length} failed.`); if (errors.length) { console.error('\nFailed files:'); errors.forEach(e => console.error(` ${e.file}: ${e.error}`)); } } run().catch(err => { console.error(err); process.exit(1); });