From 061e21f745a6122619ec929f5a3d15a9d1c5da95 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Sat, 6 Sep 2025 17:52:50 +0200 Subject: [PATCH] feat(tools): add LEGO importer using Rebrickable API - Implement to add sets by query or ID - Interactive selection, set/theme fetch, image downloads - Include minifig images and YAML with title + attribution - Persist API responses under tools/cache/rebrickable (gitignored) - Add tools/config.json for API key --- .gitignore | 3 +- tools/add_lego.js | 206 ++++++++++++++++++++++++++++++++++++++++++++++ tools/config.json | 5 ++ 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 tools/add_lego.js create mode 100644 tools/config.json diff --git a/.gitignore b/.gitignore index 9d05d32c..771972cc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules .hugo_build.lock resources/ public/ -.DS_Store \ No newline at end of file +.DS_Store +tools/cache/ diff --git a/tools/add_lego.js b/tools/add_lego.js new file mode 100644 index 00000000..5d860a48 --- /dev/null +++ b/tools/add_lego.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/* + Adds a LEGO set to the collection using Rebrickable API. + Usage: node tools/add_lego.js + + Flow: + 1) Search sets with the provided query + 2) Interactive choice if multiple results + 3) Fetch chosen set details and its theme + 4) Create content/collections/lego/// + - Always create images/ + - Create data/images/ + - Download all available set images from Rebrickable + - Create data/images/.yaml with attribution + - Create index.md with title and date +*/ + +const fs = require('fs/promises'); +const fsSync = require('fs'); +const path = require('path'); +const https = require('https'); +const readline = require('readline'); + +const ROOT = process.cwd(); +const LEGO_ROOT = path.join(ROOT, 'content', 'collections', 'lego'); +const CONFIG_PATH = path.join(ROOT, 'tools', 'config.json'); +const CACHE_ROOT = path.join(ROOT, 'tools', 'cache', 'rebrickable'); + +function loadConfig() { + const raw = fsSync.readFileSync(CONFIG_PATH, 'utf8'); + return JSON.parse(raw); +} + +function slugify(input) { + return input + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-'); +} + +function prompt(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => rl.question(question, (ans) => { rl.close(); resolve(ans.trim()); })); +} + +function apiGetJson(host, path, apiKey) { + return new Promise((resolve) => { + const req = https.request({ + host, + path, + method: 'GET', + headers: { + 'Authorization': `key ${apiKey}`, + 'Accept': 'application/json', + 'User-Agent': 'add_lego_script/1.0', + }, + }, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { resolve(JSON.parse(data)); }); + }); + req.end(); + }); +} + +function downloadFile(url, destPath) { + return new Promise((resolve) => { + const file = fsSync.createWriteStream(destPath); + https.get(url, (res) => { + res.pipe(file); + file.on('finish', () => file.close(() => resolve())); + }); + }); +} + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +async function saveJson(relPath, data) { + const out = path.join(CACHE_ROOT, relPath); + await ensureDir(path.dirname(out)); + await fs.writeFile(out, JSON.stringify(data, null, 2), 'utf8'); +} + +async function main() { + const query = process.argv[2]; + if (!query) { + console.log('Usage: node tools/add_lego.js '); + process.exit(1); + } + + const config = loadConfig(); + const apiKey = config.rebrickable.apiKey; + const host = 'rebrickable.com'; + + // 1) Search + const searchPath = `/api/v3/lego/sets/?search=${encodeURIComponent(query)}&page_size=25`; + const search = await apiGetJson(host, searchPath, apiKey); + const results = search.results || []; + await saveJson(`search/${encodeURIComponent(query)}.json`, search); + + if (results.length === 0) { + console.log('No sets found for query:', query); + process.exit(0); + } + + // 2) Selection + let selected = results[0]; + if (results.length > 1) { + console.log(`Found ${results.length} sets:`); + results.forEach((r, i) => { + const id = (r.set_num || '').split('-')[0]; + console.log(`${i + 1}. ${r.set_num} — ${r.name} (${r.year}) [id: ${id}]`); + }); + const ans = await prompt('Choose a set (1..N): '); + const idx = Math.max(1, Math.min(results.length, parseInt(ans, 10) || 1)) - 1; + selected = results[idx]; + } + + const setNum = selected.set_num; // e.g., 10283-1 + const setId = (setNum || '').split('-')[0]; + + // 3) Fetch exact set and theme + const setDetails = await apiGetJson(host, `/api/v3/lego/sets/${encodeURIComponent(setNum)}/`, apiKey); + await saveJson(`sets/${setNum}.json`, setDetails); + const themeId = setDetails.theme_id || selected.theme_id; + const theme = await apiGetJson(host, `/api/v3/lego/themes/${themeId}/`, apiKey); + await saveJson(`themes/${themeId}.json`, theme); + const themeName = theme.name; + const themeSlug = slugify(themeName); + + // 4) Prepare dirs + const setDir = path.join(LEGO_ROOT, themeSlug, setId); + const imagesDir = path.join(setDir, 'images'); + const dataImagesDir = path.join(setDir, 'data', 'images'); + + await ensureDir(imagesDir); // created even if no images provided + await ensureDir(dataImagesDir); + + // Images provided by Rebrickable (set images) + const imageUrls = []; + if (setDetails.set_img_url) imageUrls.push(setDetails.set_img_url); + if (setDetails.set_img_url_2) imageUrls.push(setDetails.set_img_url_2); + + for (const url of imageUrls) { + const urlObj = new URL(url); + const base = path.basename(urlObj.pathname); + const dest = path.join(imagesDir, base); + await downloadFile(url, dest); + + const nameNoExt = base.replace(/\.[^.]+$/, ''); + const yamlPath = path.join(dataImagesDir, `${nameNoExt}.yaml`); + const titleForSetImage = (setDetails.name || selected.name || '').replace(/"/g, '\\"'); + const yaml = `attribution: "© Rebrickable"\n` + + `title: "${titleForSetImage}"\n`; + await fs.writeFile(yamlPath, yaml, 'utf8'); + } + + // Minifigs images (stored directly in images/) + const figsResp = await apiGetJson(host, `/api/v3/lego/sets/${encodeURIComponent(setNum)}/minifigs/?page_size=1000`, apiKey); + const figs = figsResp.results || []; + await saveJson(`sets/${setNum}_minifigs.json`, figsResp); + if (figs.length > 0) { + for (const fig of figs) { + const figNum = fig.fig_num || fig.set_num; + let figImg = fig.set_img_url || fig.fig_img_url; + let figTitle = fig.name || ''; + if (!figImg && figNum) { + const figDetails = await apiGetJson(host, `/api/v3/lego/minifigs/${encodeURIComponent(figNum)}/`, apiKey); + await saveJson(`minifigs/${figNum}.json`, figDetails); + figImg = figDetails.set_img_url || figDetails.fig_img_url; + figTitle = figTitle || figDetails.name || ''; + } + if (!figImg) continue; + + const urlObj = new URL(figImg); + const base = path.basename(urlObj.pathname); + const dest = path.join(imagesDir, base); + await downloadFile(figImg, dest); + + const nameNoExt = base.replace(/\.[^.]+$/, ''); + const yamlPath = path.join(dataImagesDir, `${nameNoExt}.yaml`); + const yaml = `attribution: "© Rebrickable"\n` + + `title: "${(figTitle || '').replace(/"/g, '\\"')}"\n`; + await fs.writeFile(yamlPath, yaml, 'utf8'); + } + } + + // index.md + const title = setDetails.name || selected.name; + const today = new Date(); + const date = today.toISOString().slice(0, 10); + const md = `---\n` + + `title: "${title.replace(/"/g, '\\"')}"\n` + + `date: ${date}\n` + + `---\n`; + await fs.writeFile(path.join(setDir, 'index.md'), md, 'utf8'); + + console.log(`\nAdded set ${setNum} → ${path.relative(ROOT, setDir)}`); +} + +main(); diff --git a/tools/config.json b/tools/config.json new file mode 100644 index 00000000..1b42ba96 --- /dev/null +++ b/tools/config.json @@ -0,0 +1,5 @@ +{ + "rebrickable": { + "apiKey": "" + } +} \ No newline at end of file