Files
electronics-inventory/server.mjs
mikael-lovqvists-claude-agent 2b7d50a53d Add fields and contents to bins; fields to bin types
- bins: fields:{} and contents:[] on all new records
- bin types: fields:{} on all new records
- PUT /api/bins/:id accepts fields
- PUT /api/bin-types/:id accepts fields
- POST/PUT/DELETE /api/bins/:id/contents for content items
  (type: 'component'|'item', component_id or name, quantity, notes)
- api.mjs: add_bin_content, update_bin_content, delete_bin_content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 03:33:53 +00:00

954 lines
34 KiB
JavaScript

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, existsSync } 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/purge-missing-sources', (req, res) => {
const sources = list_source_images();
const removed = [];
for (const src of sources) {
if (!existsSync(join('./data/images', src.id))) {
delete_source_image(src.id);
removed.push(src.id);
}
}
ok(res, { removed, total: sources.length });
});
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 = '', fields = {} } = 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(),
fields: typeof fields === 'object' && fields !== null ? fields : {},
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, fields } = 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();
if (fields !== undefined && typeof fields === 'object' && fields !== null) updated.fields = fields;
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,
fields: {},
contents: [],
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,
fields: {},
contents: [],
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 / fields
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, fields } = 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; }
}
if (fields !== undefined && typeof fields === 'object' && fields !== null) updated.fields = fields;
set_bin(updated);
ok(res, { bin: updated });
});
// Add a content item to a bin
app.post('/api/bins/:id/contents', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
const { type, component_id, name, quantity = '', notes = '' } = req.body;
if (type !== 'component' && type !== 'item') return fail(res, 'type must be "component" or "item"');
if (type === 'component' && !component_id) return fail(res, 'component_id is required for type component');
if (type === 'item' && !name?.trim()) return fail(res, 'name is required for type item');
const item = {
id: generate_id(),
type,
component_id: type === 'component' ? component_id : null,
name: type === 'item' ? name.trim() : null,
quantity: String(quantity).trim(),
notes: String(notes).trim(),
created_at: Date.now(),
};
const updated = { ...bin, contents: [...(bin.contents ?? []), item], updated_at: Date.now() };
set_bin(updated);
ok(res, { bin: updated, item });
});
// Update a content item
app.put('/api/bins/:id/contents/:cid', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
const idx = (bin.contents ?? []).findIndex(c => c.id === req.params.cid);
if (idx === -1) return fail(res, 'content item not found', 404);
const item = bin.contents[idx];
const { quantity, notes, name } = req.body;
const updated_item = { ...item };
if (quantity !== undefined) updated_item.quantity = String(quantity).trim();
if (notes !== undefined) updated_item.notes = String(notes).trim();
if (name !== undefined && item.type === 'item') updated_item.name = name.trim();
const new_contents = bin.contents.map((c, i) => i === idx ? updated_item : c);
const updated = { ...bin, contents: new_contents, updated_at: Date.now() };
set_bin(updated);
ok(res, { bin: updated, item: updated_item });
});
// Remove a content item
app.delete('/api/bins/:id/contents/:cid', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
const exists = (bin.contents ?? []).some(c => c.id === req.params.cid);
if (!exists) return fail(res, 'content item not found', 404);
const updated = { ...bin, contents: bin.contents.filter(c => c.id !== req.params.cid), updated_at: Date.now() };
set_bin(updated);
ok(res);
});
app.delete('/api/bins/:id', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
// Only delete the processed output — source image is managed independently
if (bin.image_filename) remove_image_file(bin.image_filename);
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}`);
});