feat: Profil-Kategorien + Begrüßung in Zielsprache
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>
This commit is contained in:
119
scripts/upload-pictures.mjs
Normal file
119
scripts/upload-pictures.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/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); });
|
||||
Reference in New Issue
Block a user