Intial Commit
This commit is contained in:
93
src/db/client.ts
Normal file
93
src/db/client.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createPool, Pool } from 'mysql2/promise';
|
||||
import { TrainItem } from '../services/openaiVision.js';
|
||||
|
||||
const pool: Pool = createPool({
|
||||
host: process.env.db_ip,
|
||||
port: process.env.db_port ? Number(process.env.db_port) : 3306,
|
||||
user: process.env.db_user,
|
||||
password: process.env.db_pass,
|
||||
database: process.env.db_name || 'trainid',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10
|
||||
});
|
||||
|
||||
async function ensureSchema() {
|
||||
const sql = `
|
||||
create table if not exists items (
|
||||
id int unsigned not null auto_increment primary key,
|
||||
manufacturer varchar(255) not null,
|
||||
model varchar(255) not null,
|
||||
sku varchar(64) null,
|
||||
quantity int not null,
|
||||
description text not null,
|
||||
item_condition varchar(50) not null,
|
||||
has_box boolean not null,
|
||||
image_path text not null,
|
||||
created_at timestamp default current_timestamp
|
||||
) engine=InnoDB;
|
||||
`;
|
||||
await pool.query(sql);
|
||||
// backfill for older tables without sku
|
||||
await pool.query(`alter table items add column if not exists sku varchar(64) null` as any);
|
||||
}
|
||||
|
||||
let schemaEnsured: Promise<void> | null = null;
|
||||
function getReady() {
|
||||
if (!schemaEnsured) schemaEnsured = ensureSchema();
|
||||
return schemaEnsured;
|
||||
}
|
||||
|
||||
export const db = {
|
||||
async insertItem(item: Omit<TrainItem, 'id'>) {
|
||||
await getReady();
|
||||
const [result] = await pool.execute(
|
||||
`insert into items (manufacturer, model, sku, quantity, description, item_condition, has_box, image_path)
|
||||
values (?,?,?,?,?,?,?,?)`,
|
||||
[item.manufacturer, item.model, item.sku || null, item.quantity, item.description, item.condition, item.hasBox, item.imagePath]
|
||||
);
|
||||
const insertId = (result as any).insertId as number;
|
||||
const [rows] = await pool.query(
|
||||
`select id, manufacturer, model, sku, quantity, description, item_condition as cond, has_box as hasBox, image_path as imagePath, created_at as createdAt
|
||||
from items where id = ?`,
|
||||
[insertId]
|
||||
);
|
||||
const row = (rows as any[])[0];
|
||||
if (row) { row.condition = row.cond; delete row.cond; }
|
||||
return row;
|
||||
},
|
||||
async listItems(search?: string) {
|
||||
await getReady();
|
||||
const where = search ? 'where model like ? or sku like ?' : '';
|
||||
const params = search ? [`%${search}%`, `%${search}%`] : [];
|
||||
const [rows] = await pool.query(
|
||||
`select id, manufacturer, model, sku, quantity, description, item_condition as cond, has_box as hasBox, image_path as imagePath, created_at as createdAt
|
||||
from items ${where} order by id asc`,
|
||||
params
|
||||
);
|
||||
const arr = rows as any[];
|
||||
for (const r of arr) { r.condition = r.cond; delete r.cond; }
|
||||
return arr;
|
||||
},
|
||||
async getItem(id: number) {
|
||||
await getReady();
|
||||
const [rows] = await pool.query(
|
||||
`select id, manufacturer, model, sku, quantity, description, item_condition as cond, has_box as hasBox, image_path as imagePath, created_at as createdAt
|
||||
from items where id = ?`,
|
||||
[id]
|
||||
);
|
||||
const row = (rows as any[])[0] || null;
|
||||
if (row) { row.condition = row.cond; delete row.cond; }
|
||||
return row;
|
||||
},
|
||||
async deleteItem(id: number) {
|
||||
await getReady();
|
||||
const [res] = await pool.query('delete from items where id = ?', [id]);
|
||||
return (res as any).affectedRows as number;
|
||||
},
|
||||
async deleteAll() {
|
||||
await getReady();
|
||||
await pool.query('truncate table items');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
163
src/public/index.html
Normal file
163
src/public/index.html
Normal file
@@ -0,0 +1,163 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Train-ID</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; margin: 24px; }
|
||||
header { margin-bottom: 16px; }
|
||||
.card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
label { display: block; font-weight: 600; margin: 8px 0 4px; }
|
||||
input[type=file] { padding: 8px; }
|
||||
button { background: black; color: white; border: none; padding: 10px 14px; border-radius: 6px; cursor: pointer; }
|
||||
button.secondary { background: #666; }
|
||||
input[type=text] { padding: 10px 12px; border: 1px solid #ccc; border-radius: 6px; min-width: 220px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th, td { border: 1px solid #eee; padding: 8px; }
|
||||
img.preview { max-width: 240px; border-radius: 6px; border: 1px solid #ddd; }
|
||||
@media (max-width: 720px) {
|
||||
body { margin: 12px; }
|
||||
table { font-size: 14px; }
|
||||
.hide-sm { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h2>Train-ID</h2>
|
||||
<p>Upload a photo of a model train to extract details and save.</p>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<label>Photo</label>
|
||||
<input id="file" type="file" accept="image/*" />
|
||||
<div class="row" style="align-items:flex-start; margin-top: 12px;">
|
||||
<img id="preview" class="preview" />
|
||||
<div>
|
||||
<button id="analyze">Analyze & Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Items</h3>
|
||||
<div class="actions">
|
||||
<input id="search" type="text" placeholder="Search model or SKU" />
|
||||
<button id="doSearch" class="secondary">Search</button>
|
||||
<button id="clearSearch" class="secondary">Clear</button>
|
||||
<button id="export">Export XLSX</button>
|
||||
<button id="wipe" class="secondary">Wipe All</button>
|
||||
</div>
|
||||
<table id="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Mfg</th>
|
||||
<th>Model</th>
|
||||
<th>Qty</th>
|
||||
<th>SKU</th>
|
||||
<th>Condition</th>
|
||||
<th>Description</th>
|
||||
<th>Box</th>
|
||||
<th class="hide-sm">Image</th>
|
||||
<th>Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<script type="module">
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const fileInput = $('#file');
|
||||
const preview = $('#preview');
|
||||
const status = $('#status');
|
||||
const tableBody = document.querySelector('#items tbody');
|
||||
const searchInput = document.querySelector('#search');
|
||||
const analyzeBtn = document.querySelector('#analyze');
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const f = fileInput.files?.[0];
|
||||
if (f) preview.src = URL.createObjectURL(f);
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
const q = searchInput.value.trim();
|
||||
const url = q ? `/api/items?q=${encodeURIComponent(q)}` : '/api/items';
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
tableBody.innerHTML = '';
|
||||
for (const it of data.items) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${it.id}</td>
|
||||
<td>${it.manufacturer}</td>
|
||||
<td>${it.model}</td>
|
||||
<td>${it.quantity}</td>
|
||||
<td>${it.sku || ''}</td>
|
||||
<td>${it.condition}</td>
|
||||
<td>${it.description}</td>
|
||||
<td>${it.hasBox ? 'Yes' : 'No'}</td>
|
||||
<td class="hide-sm">${it.imagePath ? 'Yes' : ''}</td>
|
||||
<td><button data-del="${it.id}" class="secondary">Delete</button></td>
|
||||
`;
|
||||
tableBody.appendChild(tr);
|
||||
}
|
||||
// hook delete buttons
|
||||
document.querySelectorAll('button[data-del]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.getAttribute('data-del');
|
||||
if (!id) return;
|
||||
if (!confirm('Delete this entry?')) return;
|
||||
await fetch(`/api/items/${id}`, { method: 'DELETE' });
|
||||
await refresh();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
analyzeBtn.addEventListener('click', async () => {
|
||||
const f = fileInput.files?.[0];
|
||||
if (!f) { alert('Choose an image first'); return; }
|
||||
const form = new FormData();
|
||||
form.append('image', f);
|
||||
const prevText = analyzeBtn.textContent;
|
||||
analyzeBtn.textContent = 'Working...';
|
||||
analyzeBtn.disabled = true;
|
||||
status.textContent = 'Analyzing...';
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
if (res.ok) { status.textContent = 'Saved.'; await refresh(); }
|
||||
else { status.textContent = data.error || 'Failed'; }
|
||||
} catch (e) {
|
||||
status.textContent = String(e);
|
||||
} finally {
|
||||
analyzeBtn.disabled = false;
|
||||
analyzeBtn.textContent = prevText;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#export').addEventListener('click', () => {
|
||||
window.location.href = '/api/export/xlsx';
|
||||
});
|
||||
|
||||
document.querySelector('#wipe').addEventListener('click', async () => {
|
||||
if (!confirm('Wipe ALL entries? This cannot be undone.')) return;
|
||||
await fetch('/api/items', { method: 'DELETE' });
|
||||
await refresh();
|
||||
});
|
||||
|
||||
document.querySelector('#doSearch').addEventListener('click', refresh);
|
||||
document.querySelector('#clearSearch').addEventListener('click', () => { searchInput.value=''; refresh(); });
|
||||
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') refresh(); });
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
118
src/routes/api.ts
Normal file
118
src/routes/api.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { analyzeImageToMetadata } from '../services/openaiVision.js';
|
||||
import { db } from '../db/client.js';
|
||||
import { generatePdfForItem } from '../services/pdf.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.post('/upload', async (req, res) => {
|
||||
try {
|
||||
const file = (req.files?.image as any) || null;
|
||||
if (!file) {
|
||||
return res.status(400).json({ error: 'No image uploaded under field name "image"' });
|
||||
}
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads');
|
||||
await fs.mkdir(uploadsDir, { recursive: true });
|
||||
const filename = `${Date.now()}_${file.name.replace(/\s+/g, '_')}`;
|
||||
const savePath = path.join(uploadsDir, filename);
|
||||
await file.mv(savePath);
|
||||
|
||||
const analysis = await analyzeImageToMetadata(savePath);
|
||||
|
||||
const saved = await db.insertItem({
|
||||
manufacturer: analysis.manufacturer,
|
||||
model: analysis.model,
|
||||
sku: analysis.sku,
|
||||
quantity: analysis.quantity,
|
||||
description: analysis.description,
|
||||
condition: analysis.condition,
|
||||
hasBox: analysis.hasBox,
|
||||
imagePath: savePath,
|
||||
});
|
||||
|
||||
res.json({ item: saved });
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message || 'Upload failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/items', async (req, res) => {
|
||||
try {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||
const items = await db.listItems(q);
|
||||
res.json({ items });
|
||||
} catch (err: any) {
|
||||
console.error('Failed to list items:', err);
|
||||
res.status(500).json({ error: 'Database error: ' + (err.message || 'unknown') });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/items/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const item = await db.getItem(id);
|
||||
if (!item) return res.status(404).json({ error: 'Not found' });
|
||||
const pdfPath = await generatePdfForItem(item);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.sendFile(pdfPath);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message || 'PDF generation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Export all items to XLSX
|
||||
router.get('/export/xlsx', async (_req, res) => {
|
||||
try {
|
||||
const { default: ExcelJS } = await import('exceljs');
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet('Inventory');
|
||||
ws.columns = [
|
||||
{ header: 'ID', key: 'id', width: 8 },
|
||||
{ header: 'Manufacturer', key: 'manufacturer', width: 24 },
|
||||
{ header: 'Model', key: 'model', width: 18 },
|
||||
{ header: 'SKU', key: 'sku', width: 16 },
|
||||
{ header: 'Qty', key: 'quantity', width: 8 },
|
||||
{ header: 'Condition', key: 'condition', width: 14 },
|
||||
{ header: 'Description', key: 'description', width: 50 },
|
||||
{ header: 'Has Box', key: 'hasBox', width: 10 },
|
||||
{ header: 'Image Path', key: 'imagePath', width: 50 },
|
||||
{ header: 'Created', key: 'createdAt', width: 24 }
|
||||
];
|
||||
const items = await db.listItems();
|
||||
for (const it of items) ws.addRow(it);
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="train_inventory.xlsx"');
|
||||
await wb.xlsx.write(res);
|
||||
res.end();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message || 'Export failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete one item
|
||||
router.delete('/items/:id', async (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const n = await db.deleteItem(id);
|
||||
res.json({ deleted: n });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Delete failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Wipe all items
|
||||
router.delete('/items', async (_req, res) => {
|
||||
try {
|
||||
await db.deleteAll();
|
||||
res.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Wipe failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
31
src/server.ts
Normal file
31
src/server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import morgan from 'morgan';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
import fileUpload from 'express-fileupload';
|
||||
import { router as apiRouter } from './routes/api.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(fileUpload({ createParentPath: true }));
|
||||
app.use(morgan('dev'));
|
||||
|
||||
const publicDir = path.join(process.cwd(), 'src', 'public');
|
||||
app.use(express.static(publicDir));
|
||||
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, 'index.html'));
|
||||
});
|
||||
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
app.listen(port, () => {
|
||||
console.log(`Server listening on http://localhost:${port}`);
|
||||
});
|
||||
|
||||
|
||||
79
src/services/openaiVision.ts
Normal file
79
src/services/openaiVision.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import OpenAI from 'openai';
|
||||
import fs from 'fs/promises';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
export type TrainItem = {
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
sku?: string; // hyphenated code on the box (not UPC)
|
||||
quantity: number;
|
||||
description: string; // 1 sentence
|
||||
condition: string; // e.g., New, Excellent, Good, Fair, Poor
|
||||
hasBox: boolean;
|
||||
imagePath: string;
|
||||
id?: number;
|
||||
};
|
||||
|
||||
let client: OpenAI | null = null;
|
||||
function getClient(): OpenAI {
|
||||
if (!client) {
|
||||
const apiKey = process.env.openapi_key || process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('Missing OpenAI API key. Set `openapi_key` or `OPENAI_API_KEY`.');
|
||||
}
|
||||
client = new OpenAI({ apiKey });
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function analyzeImageToMetadata(localImagePath: string): Promise<Omit<TrainItem, 'imagePath' | 'id'>> {
|
||||
const imageB64 = await fs.readFile(localImagePath, { encoding: 'base64' });
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
manufacturer: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
sku: { type: 'string', description: 'Hyphenated product code (contains a hyphen), not UPC; leave empty if none', pattern: ".*-.*" },
|
||||
quantity: { type: 'integer', minimum: 1 },
|
||||
description: { type: 'string' },
|
||||
condition: { type: 'string' },
|
||||
hasBox: { type: 'boolean' }
|
||||
},
|
||||
required: ['manufacturer', 'model', 'quantity', 'description', 'condition', 'hasBox'],
|
||||
additionalProperties: false
|
||||
} as const;
|
||||
|
||||
const prompt = `You are labeling model trains from a single product photo.
|
||||
Return strict JSON matching the schema. Extract:
|
||||
- manufacturer brand name
|
||||
- model identifier/number or locomotive class (often top-right of the box front)
|
||||
- sku: the hyphenated product code on the package (must contain a hyphen, e.g. 123-456); this is the other number set besides the model number and UPC; if none is visible, return an empty string
|
||||
- quantity visible in the box (1 if single item)
|
||||
- one concise single-sentence description (<=20 words)
|
||||
- condition category (New, Excellent, Good, Fair, Poor)
|
||||
- hasBox: true if retail box is present, otherwise false.`;
|
||||
|
||||
const response = await getClient().chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: 'You output only valid JSON. No other text.' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: `data:image/png;base64,${imageB64}` } }
|
||||
] as any
|
||||
}
|
||||
],
|
||||
temperature: 0,
|
||||
response_format: { type: 'json_schema', json_schema: { name: 'train_item', schema: schema } } as any
|
||||
});
|
||||
|
||||
const jsonText = response.choices[0]?.message?.content || '{}';
|
||||
const parsed = JSON.parse(jsonText);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
|
||||
55
src/services/pdf.ts
Normal file
55
src/services/pdf.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { TrainItem } from './openaiVision.js';
|
||||
|
||||
export async function generatePdfForItem(item: TrainItem & { id: number }): Promise<string> {
|
||||
const outDir = path.join(process.cwd(), 'generated');
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const pdfPath = path.join(outDir, `item_${item.id}.pdf`);
|
||||
|
||||
const doc = new PDFDocument({ size: 'LETTER', margin: 36 });
|
||||
const writeStream = (await import('node:fs')).createWriteStream(pdfPath);
|
||||
doc.pipe(writeStream);
|
||||
|
||||
const templateImage = path.join(process.cwd(), 'src', 'templates', 'image.png');
|
||||
try {
|
||||
await fs.access(templateImage);
|
||||
doc.image(templateImage, 0, 0, { width: doc.page.width, height: doc.page.height });
|
||||
} catch {}
|
||||
|
||||
doc.fontSize(18).text('Model Train Inventory', { align: 'center' });
|
||||
doc.moveDown();
|
||||
|
||||
const lines = [
|
||||
`Mfg: ${item.manufacturer}`,
|
||||
`Model: ${item.model}`,
|
||||
`SKU: ${'sku' in item && (item as any).sku ? (item as any).sku : ''}`,
|
||||
`Qty: ${item.quantity}`,
|
||||
`Condition: ${item.condition}`,
|
||||
`Has Box: ${item.hasBox ? 'Yes' : 'No'}`,
|
||||
`Description: ${item.description}`
|
||||
];
|
||||
|
||||
doc.fontSize(12);
|
||||
for (const line of lines) {
|
||||
doc.text(line);
|
||||
}
|
||||
|
||||
try {
|
||||
doc.moveDown();
|
||||
doc.text('Photo:', { continued: false });
|
||||
doc.image(item.imagePath, { width: 300 });
|
||||
} catch {}
|
||||
|
||||
doc.end();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.on('finish', () => resolve());
|
||||
writeStream.on('error', (e) => reject(e));
|
||||
});
|
||||
|
||||
return pdfPath;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user