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:
160
server.mjs
160
server.mjs
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user