Add bins feature: upload, de-perspective, gallery

- lib/storage.mjs: bin CRUD with bin: prefix
- lib/grid-image.mjs: compute_bin_size() capped at 1024px
- server.mjs: POST/GET/PUT/DELETE /api/bins routes; PUT /api/bins/:id/corners
  re-processes image via process_grid_image with rows=1 cols=1
- public/lib/api.mjs: bin API wrappers including upload_bin()
- public/index.html: Bins nav button
- public/templates.html: t-section-bins, t-bin-card, t-dialog-bin-editor
- public/app.mjs: render_bins(), open_bin_editor() using Grid_Setup,
  save/cancel wiring in init()
- public/style.css: bin gallery and card styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 04:28:03 +00:00
parent f370b6d48d
commit 28b4590903
8 changed files with 510 additions and 11 deletions

View File

@@ -8,7 +8,7 @@ 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, process_grid_image } from './lib/grid-image.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,
@@ -18,11 +18,35 @@ import {
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,
} 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'));
@@ -63,6 +87,17 @@ 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.
@@ -328,12 +363,14 @@ 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 final_name = settle_image_filename(file.filename, file.originalname);
const meta = await sharp(join('./data/images', final_name)).metadata();
const src = {
id: file.filename,
id: final_name,
original_name: file.originalname,
width: meta.width,
height: meta.height,
uses: ['grid'],
created_at: Date.now(),
};
add_source_image(src);
@@ -342,15 +379,26 @@ app.post('/api/source-images', upload.array('images', 50), async (req, res) => {
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 grids = list_grid_images();
const in_use = grids.find(g =>
const in_grid = list_grid_images().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);
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);
@@ -600,6 +648,106 @@ app.post('/api/maintenance/pdf-thumbs', (req, res) => {
ok(res, { generated, total: pdfs.length });
});
// ---------------------------------------------------------------------------
// 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 });
});
// 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 = '' } = req.body;
const final_name = settle_image_filename(req.file.filename, req.file.originalname);
const meta = await sharp(join('./data/images', final_name)).metadata();
// Register in unified source image gallery
const src = {
id: final_name,
original_name: req.file.originalname,
width: meta.width,
height: meta.height,
uses: ['bin'],
created_at: Date.now(),
};
add_source_image(src);
const mx = Math.round(meta.width * 0.15);
const my = Math.round(meta.height * 0.15);
const default_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 },
];
const bin = {
id: generate_id(),
name: name.trim() || 'Bin',
source_id: final_name,
source_w: meta.width,
source_h: meta.height,
corners: default_corners,
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 } = req.body;
if (!corners || corners.length !== 4) return fail(res, 'corners must be array of 4 points');
try {
// Delete old processed image if any
if (bin.image_filename) remove_image_file(bin.image_filename);
const source_path = join('./data/images', bin.source_id);
const { 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, updated_at: Date.now() };
set_bin(updated);
ok(res, { bin: updated });
} catch (err) {
console.error(err);
fail(res, err.message, 500);
}
});
// Update name only
app.put('/api/bins/:id', (req, res) => {
const bin = get_bin(req.params.id);
if (!bin) return fail(res, 'not found', 404);
const { name } = req.body;
const updated = { ...bin, updated_at: Date.now() };
if (name !== undefined) updated.name = name.trim() || 'Bin';
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));