Intial Commit

This commit is contained in:
2025-10-25 17:31:21 -04:00
parent 8d0e506b18
commit 39e49739c9
13 changed files with 3567 additions and 0 deletions

93
src/db/client.ts Normal file
View 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
View 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
View 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
View 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}`);
});

View 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
View 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;
}