Templates section: - Define JS formatter functions per template (e.g. resistor, capacitor) - First non-null result from any formatter is used as display name - Live preview in template editor against first component - Display names applied in component list, detail view, and inventory rows Grid navigation: - Grid-type inventory entries in component detail view show a '⊞' button to navigate directly to that grid's viewer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
475 lines
17 KiB
JavaScript
475 lines
17 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 } from 'node:fs';
|
|
import { extname, join } from 'node:path';
|
|
import sharp from 'sharp';
|
|
import { generate_id } from './lib/ids.mjs';
|
|
import { compute_cell_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,
|
|
} from './lib/storage.mjs';
|
|
|
|
mkdirSync('./data/images', { recursive: true });
|
|
|
|
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 },
|
|
});
|
|
|
|
function remove_image_file(img_id) {
|
|
try { unlinkSync(join('./data/images', img_id)); } catch {}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 } = 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;
|
|
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 meta = await sharp(file.path).metadata();
|
|
const src = {
|
|
id: file.filename,
|
|
original_name: file.originalname,
|
|
width: meta.width,
|
|
height: meta.height,
|
|
created_at: Date.now(),
|
|
};
|
|
add_source_image(src);
|
|
added.push(src);
|
|
}
|
|
ok(res, { sources: added });
|
|
});
|
|
|
|
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 grids = list_grid_images();
|
|
const in_use = grids.find(g =>
|
|
g.source_id === id ||
|
|
(g.panels && g.panels.some(p => p.source_id === id))
|
|
);
|
|
if (in_use) return fail(res, `In use by grid "${in_use.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);
|
|
});
|
|
|
|
// 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}`);
|
|
});
|