languages.greeting (de/en/sv geseedet), neue pair_categories-Tabelle (abgeleitet aus statement- und objektverknüpften Wörtern via word_categories) inkl. Backfill für bereits veröffentlichte Pairs. derivePairCategories() wird beim Publish (pairs + pipeline) aufgerufen. /auth/me liefert language_target_greeting, /auth/stats liefert categories[] mit Punkten je Kategorie fürs Profil. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
3.8 KiB
JavaScript
120 lines
3.8 KiB
JavaScript
#!/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 <dir>');
|
|
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); });
|