#!/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); });