process.on('unhandledRejection', (reason) => { console.error('[unhandledRejection]', reason); }); process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err); process.exit(1); }); import express from 'express'; import multer from 'multer'; import { unlinkSync, mkdirSync } from 'node:fs'; import { extname, join } from 'node:path'; import { execFileSync } from 'node:child_process'; import sharp from 'sharp'; import { generate_id } from './lib/ids.mjs'; import { compute_cell_size, compute_bin_size, process_grid_image } from './lib/grid-image.mjs'; import { list_fields, get_field, set_field, delete_field, list_components, get_component, set_component, delete_component, list_inventory, get_inventory_entry, set_inventory_entry, delete_inventory_entry, list_grid_drafts, get_grid_draft, set_grid_draft, delete_grid_draft, list_source_images, get_source_image, add_source_image, delete_source_image, list_grid_images, get_grid_image, set_grid_image, delete_grid_image, list_component_templates, get_component_template, set_component_template, delete_component_template, list_pdfs, get_pdf, set_pdf, delete_pdf, list_bins, get_bin, set_bin, delete_bin, list_bin_types, get_bin_type, set_bin_type, delete_bin_type, } from './lib/storage.mjs'; mkdirSync('./data/images', { recursive: true }); mkdirSync('./data/pdfs', { recursive: true }); // Migration: backfill uses[] on existing source images, and register any bin // sources that predate the unified gallery. (function migrate_source_images() { for (const src of list_source_images()) { if (!src.uses) { add_source_image({ ...src, uses: ['grid'] }); } } for (const bin of list_bins()) { if (!get_source_image(bin.source_id)) { // Source image missing from KV — re-register it add_source_image({ id: bin.source_id, original_name: '', width: bin.source_w ?? 0, height: bin.source_h ?? 0, uses: ['bin'], created_at: bin.created_at, }); } } }()); const app = express(); app.use(express.json()); app.use('/img', express.static('./data/images')); app.use(express.static(new URL('./public/', import.meta.url).pathname)); const PORT = process.env.PORT ?? 3020; const BIND_ADDRESS = process.env.BIND_ADDRESS ?? 'localhost'; function ok(res, data = {}) { res.json({ ok: true, ...data }); } function fail(res, msg, status = 400) { res.status(status).json({ ok: false, error: msg }); } const upload = multer({ storage: multer.diskStorage({ destination: './data/images', filename: (req, file, cb) => cb(null, generate_id() + extname(file.originalname).toLowerCase()), }), limits: { fileSize: 20 * 1024 * 1024 }, }); const pdf_upload = multer({ storage: multer.diskStorage({ destination: './data/pdfs', filename: (req, file, cb) => cb(null, generate_id() + '.pdf'), }), limits: { fileSize: 50 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (file.mimetype === 'application/pdf' || extname(file.originalname).toLowerCase() === '.pdf') { cb(null, true); } else { cb(new Error('Only PDF files are allowed')); } }, }); app.use('/pdf', express.static('./data/pdfs')); function remove_image_file(img_id) { try { unlinkSync(join('./data/images', img_id)); } catch {} } // Try to rename temp file to the original name; falls back to temp name on collision. // Returns the final filename (basename only). function settle_image_filename(temp_filename, original_name) { const preferred = original_name || temp_filename; const dest = join('./data/images', preferred); if (rename_no_replace(join('./data/images', temp_filename), dest)) { return preferred; } return temp_filename; } const MV_SYNC = new URL('./tools/mv-sync', import.meta.url).pathname; // Atomically rename src -> dst, failing if dst already exists. // Uses renameat2(RENAME_NOREPLACE) via tools/mv-sync. // Throws on unexpected errors; returns false if dst exists. function rename_no_replace(src, dst) { try { execFileSync(MV_SYNC, [src, dst]); return true; } catch (e) { if (e.status === 1) return false; throw e; } } function sanitize_pdf_filename(display_name) { const base = display_name .replace(/\.pdf$/i, '') .replace(/[^a-zA-Z0-9._\- ]/g, '') .trim() .replace(/\s+/g, '_') || 'document'; return base + '.pdf'; } function generate_pdf_thumb(pdf_path, thumb_prefix) { // Returns thumb filename (id-thumb.png) on success, null if pdftoppm unavailable. try { execFileSync('pdftoppm', [ '-png', '-singlefile', '-scale-to', '512', '-f', '1', '-l', '1', pdf_path, thumb_prefix, ]); return thumb_prefix.split('/').pop() + '.png'; } catch { return null; } } // --------------------------------------------------------------------------- // Field definitions // --------------------------------------------------------------------------- app.get('/api/fields', (req, res) => { ok(res, { fields: list_fields() }); }); app.post('/api/fields', (req, res) => { const { name, unit = '', description = '' } = req.body; if (!name?.trim()) return fail(res, 'name is required'); const field = { id: generate_id(), name: name.trim(), unit: unit.trim(), description: description.trim(), created_at: Date.now(), }; set_field(field); ok(res, { field }); }); app.put('/api/fields/:id', (req, res) => { const existing = get_field(req.params.id); if (!existing) return fail(res, 'not found', 404); const { name, unit, description } = req.body; const updated = { ...existing }; if (name !== undefined) updated.name = name.trim(); if (unit !== undefined) updated.unit = unit.trim(); if (description !== undefined) updated.description = description.trim(); set_field(updated); ok(res, { field: updated }); }); app.delete('/api/fields/:id', (req, res) => { if (!delete_field(req.params.id)) return fail(res, 'not found', 404); ok(res); }); // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- app.get('/api/components', (req, res) => { ok(res, { components: list_components() }); }); app.post('/api/components', (req, res) => { const { name, description = '', fields = {} } = req.body; if (!name?.trim()) return fail(res, 'name is required'); const now = Date.now(); const component = { id: generate_id(), name: name.trim(), description: description.trim(), fields, images: [], created_at: now, updated_at: now, }; set_component(component); ok(res, { component }); }); app.get('/api/components/:id', (req, res) => { const component = get_component(req.params.id); if (!component) return fail(res, 'not found', 404); ok(res, { component }); }); app.put('/api/components/:id', (req, res) => { const existing = get_component(req.params.id); if (!existing) return fail(res, 'not found', 404); const { name, description, fields, file_ids } = req.body; const updated = { ...existing, updated_at: Date.now() }; if (name !== undefined) updated.name = name.trim(); if (description !== undefined) updated.description = description.trim(); if (fields !== undefined) updated.fields = fields; if (file_ids !== undefined) updated.file_ids = file_ids; set_component(updated); ok(res, { component: updated }); }); app.delete('/api/components/:id', (req, res) => { if (!delete_component(req.params.id)) return fail(res, 'not found', 404); ok(res); }); app.post('/api/components/:id/images', upload.array('images', 20), (req, res) => { const comp = get_component(req.params.id); if (!comp) return fail(res, 'not found', 404); const new_ids = req.files.map(f => f.filename); const updated = { ...comp, images: [...(comp.images ?? []), ...new_ids], updated_at: Date.now() }; set_component(updated); ok(res, { component: updated }); }); app.delete('/api/components/:id/images/:img_id', (req, res) => { const comp = get_component(req.params.id); if (!comp) return fail(res, 'not found', 404); const updated = { ...comp, images: (comp.images ?? []).filter(id => id !== req.params.img_id), updated_at: Date.now() }; set_component(updated); remove_image_file(req.params.img_id); ok(res, { component: updated }); }); // --------------------------------------------------------------------------- // Inventory entries // --------------------------------------------------------------------------- app.get('/api/inventory', (req, res) => { ok(res, { entries: list_inventory() }); }); app.post('/api/inventory', (req, res) => { const { component_id, location_type, location_ref = '', quantity = '', notes = '', grid_id = null, grid_row = null, grid_col = null } = req.body; if (!component_id) return fail(res, 'component_id is required'); if (!location_type) return fail(res, 'location_type is required'); if (!get_component(component_id)) return fail(res, 'component not found', 404); const now = Date.now(); const entry = { id: generate_id(), component_id, location_type, location_ref: String(location_ref).trim(), quantity: String(quantity).trim(), notes: String(notes).trim(), grid_id: grid_id ?? null, grid_row: grid_row != null ? parseInt(grid_row) : null, grid_col: grid_col != null ? parseInt(grid_col) : null, images: [], created_at: now, updated_at: now, }; set_inventory_entry(entry); ok(res, { entry }); }); app.put('/api/inventory/:id', (req, res) => { const existing = get_inventory_entry(req.params.id); if (!existing) return fail(res, 'not found', 404); const { location_type, location_ref, quantity, notes } = req.body; const updated = { ...existing, updated_at: Date.now() }; if (location_type !== undefined) updated.location_type = location_type; if (location_ref !== undefined) updated.location_ref = String(location_ref).trim(); if (quantity !== undefined) updated.quantity = String(quantity).trim(); if (notes !== undefined) updated.notes = String(notes).trim(); if (req.body.grid_id !== undefined) updated.grid_id = req.body.grid_id ?? null; if (req.body.grid_row !== undefined) updated.grid_row = req.body.grid_row != null ? parseInt(req.body.grid_row) : null; if (req.body.grid_col !== undefined) updated.grid_col = req.body.grid_col != null ? parseInt(req.body.grid_col) : null; set_inventory_entry(updated); ok(res, { entry: updated }); }); app.delete('/api/inventory/:id', (req, res) => { if (!delete_inventory_entry(req.params.id)) return fail(res, 'not found', 404); ok(res); }); app.post('/api/inventory/:id/images', upload.array('images', 20), (req, res) => { const entry = get_inventory_entry(req.params.id); if (!entry) return fail(res, 'not found', 404); const new_ids = req.files.map(f => f.filename); const updated = { ...entry, images: [...(entry.images ?? []), ...new_ids], updated_at: Date.now() }; set_inventory_entry(updated); ok(res, { entry: updated }); }); app.delete('/api/inventory/:id/images/:img_id', (req, res) => { const entry = get_inventory_entry(req.params.id); if (!entry) return fail(res, 'not found', 404); const updated = { ...entry, images: (entry.images ?? []).filter(id => id !== req.params.img_id), updated_at: Date.now() }; set_inventory_entry(updated); remove_image_file(req.params.img_id); ok(res, { entry: updated }); }); // --------------------------------------------------------------------------- // Grid drafts // --------------------------------------------------------------------------- app.get('/api/grid-drafts', (req, res) => { ok(res, { drafts: list_grid_drafts() }); }); app.post('/api/grid-drafts', (req, res) => { const { name, rows, cols, panel_rows, panel_cols, panels } = req.body; if (!name || !rows || !cols || !panels) return fail(res, 'missing fields'); const draft = { id: generate_id(), name, rows, cols, panel_rows, panel_cols, panels, created_at: Date.now(), updated_at: Date.now(), }; set_grid_draft(draft); ok(res, { draft }); }); app.put('/api/grid-drafts/:id', (req, res) => { const existing = get_grid_draft(req.params.id); if (!existing) return fail(res, 'not found', 404); const { name, rows, cols, panel_rows, panel_cols, panels } = req.body; const draft = { ...existing, name, rows, cols, panel_rows, panel_cols, panels, updated_at: Date.now() }; set_grid_draft(draft); ok(res, { draft }); }); app.delete('/api/grid-drafts/:id', (req, res) => { if (!get_grid_draft(req.params.id)) return fail(res, 'not found', 404); delete_grid_draft(req.params.id); ok(res); }); // --------------------------------------------------------------------------- // Source images (gallery for grid setup) // --------------------------------------------------------------------------- app.get('/api/source-images', (req, res) => { ok(res, { sources: list_source_images() }); }); app.post('/api/source-images', upload.array('images', 50), async (req, res) => { if (!req.files?.length) return fail(res, 'no files'); const added = []; for (const file of req.files) { const final_name = settle_image_filename(file.filename, file.originalname); const meta = await sharp(join('./data/images', final_name)).metadata(); const src = { id: final_name, original_name: file.originalname, width: meta.width, height: meta.height, uses: ['grid'], created_at: Date.now(), }; add_source_image(src); added.push(src); } ok(res, { sources: added }); }); app.put('/api/source-images/:id', (req, res) => { const src = get_source_image(req.params.id); if (!src) return fail(res, 'not found', 404); const { uses } = req.body; if (!Array.isArray(uses)) return fail(res, 'uses must be an array'); const updated = { ...src, uses }; add_source_image(updated); ok(res, { source: updated }); }); app.delete('/api/source-images/:id', (req, res) => { const id = req.params.id; if (!get_source_image(id)) return fail(res, 'not found', 404); const in_grid = list_grid_images().find(g => g.source_id === id || (g.panels && g.panels.some(p => p.source_id === id)) ); if (in_grid) return fail(res, `In use by grid "${in_grid.name}"`, 409); const in_bin = list_bins().find(b => b.source_id === id); if (in_bin) return fail(res, `In use by bin "${in_bin.name}"`, 409); remove_image_file(id); delete_source_image(id); ok(res); }); // --------------------------------------------------------------------------- // Grid images // --------------------------------------------------------------------------- function panel_dims(pi, panel_rows, panel_cols, rows, cols) { const pr = Math.floor(pi / panel_cols); const pc = pi % panel_cols; const base_rows = Math.floor(rows / panel_rows); const base_cols = Math.floor(cols / panel_cols); const p_rows = pr === panel_rows - 1 ? rows - pr * base_rows : base_rows; const p_cols = pc === panel_cols - 1 ? cols - pc * base_cols : base_cols; return { pr, pc, p_rows, p_cols, row_off: pr * base_rows, col_off: pc * base_cols }; } app.get('/api/grid-images', (req, res) => { ok(res, { grids: list_grid_images() }); }); app.get('/api/grid-images/:id', (req, res) => { const grid = get_grid_image(req.params.id); if (!grid) return fail(res, 'not found', 404); ok(res, { grid }); }); app.post('/api/grid-images', async (req, res) => { const { name = '', rows, cols, panel_rows = 1, panel_cols = 1, panels } = req.body; if (!rows || !cols || !panels?.length) return fail(res, 'missing fields'); if (panels.length !== panel_rows * panel_cols) return fail(res, 'panel count mismatch'); const configured = panels.filter(p => p?.source_id && p?.corners?.length === 4); if (configured.length === 0) return fail(res, 'no configured panels'); for (const p of configured) { if (!get_source_image(p.source_id)) return fail(res, `source ${p.source_id} not found`, 404); } try { const full_cells = Array.from({ length: rows }, () => Array(cols).fill(null)); let total_cell_w = 0, total_cell_h = 0, count = 0; for (let pi = 0; pi < panels.length; pi++) { const p = panels[pi]; if (!p?.source_id || !p?.corners) continue; const { p_rows, p_cols, row_off, col_off } = panel_dims(pi, panel_rows, panel_cols, rows, cols); const source_path = join('./data/images', p.source_id); const { cell_w, cell_h } = compute_cell_size(p.corners, p_rows, p_cols); const panel_cells = await process_grid_image( source_path, p.corners, p_rows, p_cols, cell_w, cell_h, './data/images' ); total_cell_w += cell_w; total_cell_h += cell_h; count++; for (let r = 0; r < p_rows; r++) for (let c = 0; c < p_cols; c++) full_cells[row_off + r][col_off + c] = panel_cells[r][c]; } const grid = { id: generate_id(), name: name.trim() || 'Grid', rows, cols, panel_rows, panel_cols, cell_w: Math.round(total_cell_w / count), cell_h: Math.round(total_cell_h / count), panels: panels.map(p => p?.source_id ? { source_id: p.source_id, corners: p.corners } : null), cells: full_cells, created_at: Date.now(), }; set_grid_image(grid); ok(res, { grid }); } catch (err) { console.error(err); fail(res, err.message, 500); } }); // Re-process a single panel of an existing grid app.put('/api/grid-images/:id/panels/:pi', async (req, res) => { const grid = get_grid_image(req.params.id); if (!grid) return fail(res, 'not found', 404); const pi = parseInt(req.params.pi); if (isNaN(pi) || pi < 0 || pi >= grid.panel_rows * grid.panel_cols) return fail(res, 'invalid panel index'); const { source_id, corners } = req.body; if (!source_id || !corners || corners.length !== 4) return fail(res, 'missing fields'); if (!get_source_image(source_id)) return fail(res, 'source image not found', 404); try { const { p_rows, p_cols, row_off, col_off } = panel_dims(pi, grid.panel_rows, grid.panel_cols, grid.rows, grid.cols); // Delete old cell images for this panel's region for (let r = 0; r < p_rows; r++) for (let c = 0; c < p_cols; c++) if (grid.cells[row_off + r]?.[col_off + c]) remove_image_file(grid.cells[row_off + r][col_off + c]); const source_path = join('./data/images', source_id); const { cell_w, cell_h } = compute_cell_size(corners, p_rows, p_cols); const panel_cells = await process_grid_image( source_path, corners, p_rows, p_cols, cell_w, cell_h, './data/images' ); const updated_panels = [...(grid.panels ?? [])]; updated_panels[pi] = { source_id, corners }; const updated_cells = grid.cells.map(row => [...row]); for (let r = 0; r < p_rows; r++) for (let c = 0; c < p_cols; c++) updated_cells[row_off + r][col_off + c] = panel_cells[r][c]; const updated = { ...grid, panels: updated_panels, cells: updated_cells, updated_at: Date.now() }; set_grid_image(updated); ok(res, { grid: updated }); } catch (err) { console.error(err); fail(res, err.message, 500); } }); app.delete('/api/grid-images/:id', (req, res) => { const grid = get_grid_image(req.params.id); if (!grid) return fail(res, 'not found', 404); for (const row of grid.cells) { for (const filename of row) { if (filename) remove_image_file(filename); } } // Source image is managed separately — do not delete it here delete_grid_image(grid.id); ok(res); }); // --------------------------------------------------------------------------- // Component templates // --------------------------------------------------------------------------- app.get('/api/component-templates', (req, res) => { ok(res, { templates: list_component_templates() }); }); app.post('/api/component-templates', (req, res) => { const { name, formatter } = req.body; if (!name) return fail(res, 'name is required'); const tmpl = { id: generate_id(), name, formatter: formatter ?? '', created_at: Date.now(), updated_at: Date.now() }; set_component_template(tmpl); ok(res, { template: tmpl }); }); app.put('/api/component-templates/:id', (req, res) => { const existing = get_component_template(req.params.id); if (!existing) return fail(res, 'not found', 404); const updated = { ...existing, updated_at: Date.now() }; if (req.body.name !== undefined) updated.name = req.body.name; if (req.body.formatter !== undefined) updated.formatter = req.body.formatter; set_component_template(updated); ok(res, { template: updated }); }); app.delete('/api/component-templates/:id', (req, res) => { if (!get_component_template(req.params.id)) return fail(res, 'not found', 404); delete_component_template(req.params.id); ok(res); }); // --------------------------------------------------------------------------- // PDF files // --------------------------------------------------------------------------- app.get('/api/pdfs', (req, res) => { ok(res, { pdfs: list_pdfs() }); }); app.post('/api/pdfs', pdf_upload.single('file'), (req, res) => { if (!req.file) return fail(res, 'no file uploaded'); const display_name = req.body.display_name?.trim() || req.file.originalname; const filename = sanitize_pdf_filename(req.body.filename?.trim() || req.file.originalname); const all = list_pdfs(); if (all.some(p => p.display_name === display_name)) { try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {} return fail(res, 'a file with that display name already exists'); } if (all.some(p => p.filename === filename)) { try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {} return fail(res, 'a file with that filename already exists'); } const id = generate_id(); const temp_path = join('./data/pdfs', req.file.filename); const final_path = join('./data/pdfs', filename); if (!rename_no_replace(temp_path, final_path)) { try { unlinkSync(temp_path); } catch {} return fail(res, 'a file with that filename already exists on disk'); } const thumb_prefix = join('./data/pdfs', id + '-thumb'); const thumb_file = generate_pdf_thumb(final_path, thumb_prefix); const pdf = { id, filename, display_name, original_name: req.file.originalname, size: req.file.size, thumb_filename: thumb_file, uploaded_at: Date.now() }; set_pdf(pdf); ok(res, { pdf }); }); app.put('/api/pdfs/:id', (req, res) => { const pdf = get_pdf(req.params.id); if (!pdf) return fail(res, 'not found', 404); const display_name = req.body.display_name?.trim(); const filename = req.body.filename?.trim() ? sanitize_pdf_filename(req.body.filename.trim()) : pdf.filename; if (!display_name) return fail(res, 'display_name is required'); const all = list_pdfs(); if (all.some(p => p.display_name === display_name && p.id !== pdf.id)) return fail(res, 'a file with that display name already exists'); if (all.some(p => p.filename === filename && p.id !== pdf.id)) return fail(res, 'a file with that filename already exists'); if (filename !== pdf.filename) { const new_path = join('./data/pdfs', filename); if (!rename_no_replace(join('./data/pdfs', pdf.filename), new_path)) return fail(res, 'a file with that filename already exists on disk'); } const updated = { ...pdf, display_name, filename }; set_pdf(updated); ok(res, { pdf: updated }); }); app.delete('/api/pdfs/:id', (req, res) => { const pdf = get_pdf(req.params.id); if (!pdf) return fail(res, 'not found', 404); try { unlinkSync(join('./data/pdfs', pdf.filename)); } catch {} if (pdf.thumb_filename) { try { unlinkSync(join('./data/pdfs', pdf.thumb_filename)); } catch {} } delete_pdf(req.params.id); ok(res); }); // --------------------------------------------------------------------------- // Maintenance // --------------------------------------------------------------------------- app.post('/api/maintenance/pdf-thumbs', (req, res) => { const pdfs = list_pdfs(); let generated = 0; for (const pdf of pdfs) { if (pdf.thumb_filename) continue; const thumb_prefix = join('./data/pdfs', pdf.id + '-thumb'); const thumb_file = generate_pdf_thumb(join('./data/pdfs', pdf.filename), thumb_prefix); if (thumb_file) { set_pdf({ ...pdf, thumb_filename: thumb_file }); generated++; } } ok(res, { generated, total: pdfs.length }); }); // --------------------------------------------------------------------------- // Bin types // --------------------------------------------------------------------------- app.get('/api/bin-types', (req, res) => { ok(res, { bin_types: list_bin_types() }); }); app.post('/api/bin-types', (req, res) => { const { name, phys_w, phys_h, description = '' } = req.body; if (!name?.trim()) return fail(res, 'name is required'); if (!(phys_w > 0)) return fail(res, 'phys_w must be a positive number'); if (!(phys_h > 0)) return fail(res, 'phys_h must be a positive number'); const bt = { id: generate_id(), name: name.trim(), phys_w: Number(phys_w), phys_h: Number(phys_h), description: description.trim(), created_at: Date.now(), updated_at: Date.now(), }; set_bin_type(bt); ok(res, { bin_type: bt }); }); app.put('/api/bin-types/:id', (req, res) => { const existing = get_bin_type(req.params.id); if (!existing) return fail(res, 'not found', 404); const { name, phys_w, phys_h, description } = req.body; const updated = { ...existing, updated_at: Date.now() }; if (name !== undefined) updated.name = name.trim(); if (phys_w !== undefined) { if (!(Number(phys_w) > 0)) return fail(res, 'phys_w must be a positive number'); updated.phys_w = Number(phys_w); } if (phys_h !== undefined) { if (!(Number(phys_h) > 0)) return fail(res, 'phys_h must be a positive number'); updated.phys_h = Number(phys_h); } if (description !== undefined) updated.description = description.trim(); set_bin_type(updated); ok(res, { bin_type: updated }); }); app.delete('/api/bin-types/:id', (req, res) => { if (!get_bin_type(req.params.id)) return fail(res, 'not found', 404); const in_use = list_bins().find(b => b.type_id === req.params.id); if (in_use) return fail(res, `In use by bin "${in_use.name}"`, 409); delete_bin_type(req.params.id); ok(res); }); // --------------------------------------------------------------------------- // Bins // --------------------------------------------------------------------------- app.get('/api/bins', (req, res) => { ok(res, { bins: list_bins() }); }); app.get('/api/bins/:id', (req, res) => { const bin = get_bin(req.params.id); if (!bin) return fail(res, 'not found', 404); ok(res, { bin }); }); // Create a bin from an already-uploaded source image app.post('/api/bins/from-source', (req, res) => { const { source_id, name = '', type_id = null } = req.body; if (!source_id) return fail(res, 'source_id is required'); const src = get_source_image(source_id); if (!src) return fail(res, 'source image not found', 404); if (type_id && !get_bin_type(type_id)) return fail(res, 'bin type not found', 404); if (!src.uses?.includes('bin')) { add_source_image({ ...src, uses: [...(src.uses ?? []), 'bin'] }); } const bt = type_id ? get_bin_type(type_id) : null; const mx = Math.round(src.width * 0.15); const my = Math.round(src.height * 0.15); const bin = { id: generate_id(), name: name.trim() || src.original_name?.replace(/\.[^.]+$/, '') || 'Bin', type_id, source_id, source_w: src.width, source_h: src.height, corners: [ { x: mx, y: my }, { x: src.width - mx, y: my }, { x: src.width - mx, y: src.height - my }, { x: mx, y: src.height - my }, ], phys_w: bt?.phys_w ?? null, phys_h: bt?.phys_h ?? null, image_filename: null, created_at: Date.now(), updated_at: Date.now(), }; set_bin(bin); ok(res, { bin }); }); // Upload a source image for a bin and create the bin record (no processing yet) app.post('/api/bins', upload.single('image'), async (req, res) => { if (!req.file) return fail(res, 'no image uploaded'); const { name = '', type_id = null } = req.body; if (type_id && !get_bin_type(type_id)) return fail(res, 'bin type not found', 404); const final_name = settle_image_filename(req.file.filename, req.file.originalname); const meta = await sharp(join('./data/images', final_name)).metadata(); add_source_image({ id: final_name, original_name: req.file.originalname, width: meta.width, height: meta.height, uses: ['bin'], created_at: Date.now(), }); const bt = type_id ? get_bin_type(type_id) : null; const mx = Math.round(meta.width * 0.15); const my = Math.round(meta.height * 0.15); const bin = { id: generate_id(), name: name.trim() || 'Bin', type_id, source_id: final_name, source_w: meta.width, source_h: meta.height, corners: [ { x: mx, y: my }, { x: meta.width - mx, y: my }, { x: meta.width - mx, y: meta.height - my }, { x: mx, y: meta.height - my }, ], phys_w: bt?.phys_w ?? null, phys_h: bt?.phys_h ?? null, image_filename: null, created_at: Date.now(), updated_at: Date.now(), }; set_bin(bin); ok(res, { bin }); }); // Update corners (and re-process image) app.put('/api/bins/:id/corners', async (req, res) => { const bin = get_bin(req.params.id); if (!bin) return fail(res, 'not found', 404); const { corners, phys_w, phys_h } = req.body; if (!corners || corners.length !== 4) return fail(res, 'corners must be array of 4 points'); try { if (bin.image_filename) remove_image_file(bin.image_filename); const source_path = join('./data/images', bin.source_id); let bin_w, bin_h; if (phys_w > 0 && phys_h > 0) { // Use physical aspect ratio scaled to the same area as computed size const computed = compute_bin_size(corners); const area = computed.bin_w * computed.bin_h; const aspect = phys_w / phys_h; bin_h = Math.round(Math.sqrt(area / aspect)); bin_w = Math.round(bin_h * aspect); } else { ({ bin_w, bin_h } = compute_bin_size(corners)); } const cells = await process_grid_image(source_path, corners, 1, 1, bin_w, bin_h, './data/images'); const image_filename = cells[0][0]; const updated = { ...bin, corners, image_filename, bin_w, bin_h, phys_w: phys_w > 0 ? phys_w : (bin.phys_w ?? null), phys_h: phys_h > 0 ? phys_h : (bin.phys_h ?? null), updated_at: Date.now(), }; set_bin(updated); ok(res, { bin: updated }); } catch (err) { console.error(err); fail(res, err.message, 500); } }); // Update name / type app.put('/api/bins/:id', (req, res) => { const bin = get_bin(req.params.id); if (!bin) return fail(res, 'not found', 404); const { name, type_id } = req.body; if (type_id !== undefined && type_id !== null && !get_bin_type(type_id)) { return fail(res, 'bin type not found', 404); } const updated = { ...bin, updated_at: Date.now() }; if (name !== undefined) updated.name = name.trim() || 'Bin'; if (type_id !== undefined) { updated.type_id = type_id; const bt = type_id ? get_bin_type(type_id) : null; if (bt) { updated.phys_w = bt.phys_w; updated.phys_h = bt.phys_h; } } set_bin(updated); ok(res, { bin: updated }); }); app.delete('/api/bins/:id', (req, res) => { const bin = get_bin(req.params.id); if (!bin) return fail(res, 'not found', 404); if (bin.image_filename) remove_image_file(bin.image_filename); remove_image_file(bin.source_id); delete_bin(bin.id); ok(res); }); // SPA fallback — serve index.html for any non-API, non-asset path const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname; app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML)); // Express error handler — catches errors thrown/rejected in route handlers app.use((err, req, res, next) => { console.error(`[express error] ${req.method} ${req.path}`, err); if (!res.headersSent) { res.status(500).json({ ok: false, error: err.message ?? 'Internal server error' }); } }); app.listen(PORT, BIND_ADDRESS, () => { console.log(`Electronics Inventory running on http://${BIND_ADDRESS}:${PORT}`); });