feat: persistence, rerun OCR, Google Gemini, sticker mode, manual entry

- SQLite DB (photos.db) — persists all processed photo records
- Dedup by SHA256 hash — same file upload returns duplicate: true
- /api/photos — list previously processed photos
- /api/photos/{id} — get single record
- /api/photos/{id}/file — serve saved image
- /api/photos/{id}/reprocess — re-run OCR with different engine/model
- Google Gemini OCR engine (gemini-2.5-flash, free tier) alongside
  OpenCode Go LLM and Tesseract
- Sticker mode — specialized LLM/Google prompt for green/orange/yellow
  equipment stickers with 2D barcode + machine ID
- Manual machine ID entry — when GPS exists but OCR fails, show text
  input for manual lookup
- Frontend: Previous Photos section, Re-run OCR per photo, duplicate
  badges, engine dropdown, sticker toggle
This commit is contained in:
2026-05-25 17:42:10 -04:00
parent 70d8374ca6
commit a8b1e694b0
4 changed files with 802 additions and 182 deletions
+283 -69
View File
@@ -26,19 +26,17 @@
h1 { font-size: 20px; margin-bottom: 4px; }
.subtitle { font-size: 12px; color: var(--text2); margin-bottom: 16px; }
/* Upload area */
.upload-area {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 28px 20px; text-align: center; cursor: pointer;
transition: border-color 0.2s; background: var(--card);
margin-bottom: 16px;
margin-bottom: 12px;
}
.upload-area:active { border-color: var(--accent); background: var(--card2); }
.upload-area .icon { font-size: 36px; display: block; margin-bottom: 6px; }
.upload-area .label { font-size: 15px; font-weight: 600; }
.upload-area .hint { font-size: 11px; color: var(--text2); margin-top: 4px; }
/* Summary bar */
#summary {
display: none; background: var(--card); border-radius: var(--radius);
padding: 12px 14px; margin-bottom: 12px;
@@ -53,7 +51,6 @@
.summary-nogps { color: var(--amber); }
.summary-total { color: var(--text); }
/* Photo grid */
#gallery {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
margin-bottom: 12px;
@@ -84,7 +81,6 @@
}
.photo-card.selected .check { display: flex; }
/* Detail card */
#detail {
display: none; background: var(--card); border-radius: var(--radius);
padding: 14px; margin-bottom: 12px;
@@ -102,6 +98,8 @@
.badge-warn { background: var(--amber); color: #000; }
.badge-fail { background: var(--red); color: #fff; }
.badge-info { background: var(--accent); color: #fff; }
.badge-dup { background: var(--red); color: #fff; }
.badge-sticker { background: #8b5cf6; color: #fff; }
.exif-row {
display: flex; justify-content: space-between; padding: 6px 0;
@@ -118,7 +116,6 @@
border-radius: var(--radius-sm); margin: 8px 0;
}
/* Buttons */
.btn {
display: block; width: 100%; padding: 14px; font-size: 15px;
font-weight: 600; border: none; border-radius: var(--radius-sm);
@@ -132,6 +129,7 @@
.btn:disabled { opacity: 0.4; pointer-events: none; }
.btn-row { display: flex; gap: 8px; margin-top: 8px; }
.btn-sm { padding: 8px 14px; font-size: 12px; width: auto; }
.btn-xs { padding: 4px 10px; font-size: 11px; width: auto; }
.spinner {
display: inline-block; width: 16px; height: 16px;
@@ -145,7 +143,6 @@
#fileInput { display: none; }
/* Filter bar */
#filterBar {
display: none; align-items: center; gap: 6px; margin-bottom: 10px;
}
@@ -157,14 +154,11 @@
}
.filter-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Asset match card */
.asset-card {
background: var(--card2); border-radius: var(--radius-sm);
padding: 12px; margin-top: 10px; border-left: 3px solid var(--green);
}
.asset-card .asset-name {
font-size: 15px; font-weight: 700; margin-bottom: 4px;
}
.asset-card .asset-name { font-size: 15px; font-weight: 700; margin-bottom: 4px; }
.asset-card .asset-meta {
font-size: 12px; color: var(--text2);
display: flex; flex-wrap: wrap; gap: 8px;
@@ -174,7 +168,6 @@
white-space: nowrap;
}
/* Server section */
.section {
background: var(--card); border-radius: var(--radius);
padding: 14px; margin-bottom: 12px;
@@ -187,10 +180,9 @@
margin-top: 6px;
}
/* OCR engine toggle */
.ocr-toggle {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
font-size: 12px; color: var(--text2);
display: flex; align-items: center; gap: 10px; margin-bottom: 8px;
font-size: 12px; color: var(--text2); flex-wrap: wrap;
}
.ocr-toggle label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.ocr-toggle input[type="checkbox"] { accent-color: var(--accent); }
@@ -201,7 +193,6 @@
.engine-badge.llm { background: var(--accent); color: #fff; }
.engine-badge.tesseract { background: var(--card2); color: var(--text2); }
/* Bulk results */
#mapContainer { height: 250px; border-radius: var(--radius-sm); margin-bottom: 10px; display: none; }
.bulk-card {
background: var(--card); border-radius: var(--radius-sm); padding: 10px;
@@ -229,6 +220,51 @@
.match-badge.no-match { background: var(--border); color: var(--text2); }
.match-badge.no-gps { background: var(--amber); color: #000; }
.match-badge.has-gps { background: var(--card2); color: var(--text2); }
.match-badge.dup { background: var(--red); color: #fff; }
.match-badge.sticker { background: #8b5cf6; color: #fff; }
/* Reprocess card */
.reprocess-row {
display: flex; align-items: center; gap: 8px; margin-top: 8px;
padding-top: 8px; border-top: 1px solid var(--border);
}
.reprocess-row select {
background: var(--card2); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 4px 8px; font-size: 11px; flex: 1;
}
.sticker-chip.active { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
/* Previous photos */
.prev-photo-card {
background: var(--card); border-radius: var(--radius-sm); padding: 10px;
margin-bottom: 8px; border-left: 3px solid var(--border);
font-size: 12px;
}
.prev-photo-card .prev-header {
display: flex; justify-content: space-between; align-items: center; gap: 6px;
margin-bottom: 4px;
}
.prev-photo-card .prev-filename {
font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.prev-photo-card .prev-time { font-size: 10px; color: var(--text3); white-space: nowrap; }
.prev-photo-card .prev-details { font-size: 11px; color: var(--text2); }
.prev-photo-card .prev-actions {
margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap;
align-items: center;
}
.prev-photo-card .prev-actions select,
.prev-photo-card .prev-actions input {
background: var(--card2); color: var(--text); border: 1px solid var(--border);
border-radius: 8px; padding: 3px 6px; font-size: 11px;
}
.prev-photo-card .prev-actions label {
display: flex; align-items: center; gap: 3px; font-size: 10px; color: var(--text2);
}
.prev-photo-card .prev-actions label input { accent-color: #8b5cf6; }
.prev-photo-card .sticker-color {
display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 4px;
}
</style>
</head>
<body>
@@ -243,6 +279,23 @@
</div>
<input type="file" id="fileInput" accept="image/*" multiple>
<!-- Options -->
<div class="ocr-toggle" style="margin-bottom:4px;">
<label style="gap:6px;">
<span style="font-size:11px;">OCR Engine:</span>
<select id="ocrEngine" onchange="onOcrToggle()" style="background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:3px 8px;font-size:11px;">
<option value="tesseract">🖥️ Tesseract</option>
<option value="llm" selected>🧠 LLM (mimo-v2-omni)</option>
<option value="google">🌐 Google Gemini</option>
</select>
</label>
<label>
<input type="checkbox" id="stickerToggle" onchange="onOcrToggle()">
🏷️ Sticker Mode
</label>
<span style="font-size:10px;color:var(--text3);" id="modelLabel">mimo-v2-omni</span>
</div>
<!-- Summary stats -->
<div id="summary">
<div class="summary-grid">
@@ -262,7 +315,7 @@
<!-- Photo grid -->
<div id="gallery"></div>
<!-- Detail card (tap a photo to see) -->
<!-- Detail card -->
<div id="detail">
<img id="detailPreview" alt="">
<div id="detailExif"></div>
@@ -271,20 +324,12 @@
</div>
</div>
<!-- Server results for single upload -->
<!-- Server results -->
<div class="section" id="serverSection" style="display:none;">
<div class="detail-section">🖥️ Server Results <span class="badge badge-info">PIL + OCR</span></div>
<div id="serverResults"></div>
</div>
<!-- OCR engine toggle -->
<div class="ocr-toggle" style="margin-bottom:4px;">
<label>
<input type="checkbox" id="ocrLlmToggle" onchange="onOcrToggle()">
🧠 Use LLM OCR (<code id="ocrModelLabel">mimo-v2-omni</code>)
</label>
</div>
<!-- Bulk process button -->
<button class="btn btn-primary" id="bulkBtn" style="display:none;margin-top:12px;" onclick="startBulkProcess()">
🔍 Bulk Process GPS Photos
@@ -303,10 +348,101 @@
<button class="btn btn-outline btn-sm" id="clearBtn" style="display:none;" onclick="resetAll()">🔄 Clear All & Re-select</button>
</div>
<!-- Previously processed -->
<div class="section" id="prevSection" style="display:none;">
<div class="detail-section">📂 Previously Processed <span class="badge badge-info" id="prevCount">0</span></div>
<div id="prevResults"></div>
</div>
<script>
let allPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
let selectedIdx = -1;
let currentFilter = 'all';
let bulkMap = null;
let bulkData = [];
// On load: fetch previously processed photos
document.addEventListener('DOMContentLoaded', loadPreviousPhotos);
function loadPreviousPhotos() {
fetch('/api/photos?limit=30')
.then(r => r.json())
.then(data => {
if (!data.photos || !data.photos.length) return;
const section = document.getElementById('prevSection');
const div = document.getElementById('prevResults');
section.style.display = 'block';
document.getElementById('prevCount').textContent = data.photos.length;
div.innerHTML = data.photos.map(p => renderPrevCard(p)).join('');
})
.catch(() => {});
}
function renderPrevCard(p) {
const hasMatch = p.ocr_match_5dash6 ? 'matched' : 'no-match';
const matchText = p.ocr_match_5dash6 || (p.ocr_match_5plus ? 'digits found' : 'no match');
const engine = p.ocr_engine || 'tesseract';
const engCls = engine === 'llm' || engine === 'llm_batch' ? 'llm' : 'tesseract';
const time = p.created_at ? new Date(p.created_at + 'Z').toLocaleString() : '';
const colorHtml = p.sticker_color
? `<span class="sticker-color" style="background:${p.sticker_color === 'green' ? 'var(--green)' : p.sticker_color === 'orange' ? 'var(--amber)' : p.sticker_color === 'yellow' ? '#eab308' : 'var(--text3)'}"></span>`
: '';
return `<div class="prev-photo-card">
<div class="prev-header">
<span class="prev-filename">${esc(p.orig_filename)}</span>
<span class="prev-time">${time}</span>
</div>
<div class="prev-details">
Machine: <strong>${p.machine_id ? esc(p.machine_id) : '—'}</strong>
<span class="match-badge ${hasMatch}">${esc(matchText)}</span>
<span class="engine-badge ${engCls}">${esc(engine)}${p.ocr_model ? ' ' + esc(p.ocr_model) : ''}</span>
${colorHtml}
</div>
<div class="prev-actions">
<select id="reEng${p.id}">
<option value="tesseract">Tesseract</option>
<option value="llm" ${engine.startsWith('llm') ? 'selected' : ''}>LLM</option>
<option value="google" ${engine === 'google' ? 'selected' : ''}>🌐 Google</option>
</select>
<select id="reModel${p.id}">
<option value="">default</option>
<option value="mimo-v2-omni">mimo-v2-omni</option>
<option value="mimo-v2-pro">mimo-v2-pro</option>
<option value="kimi-k2.5">kimi-k2.5</option>
<option value="glm-5.1">glm-5.1</option>
<option value="gemini-2.5-flash">gemini-2.5-flash</option>
</select>
<label><input type="checkbox" id="reSticker${p.id}"> 🏷️</label>
<button class="btn btn-primary btn-xs" onclick="reprocessPhoto(${p.id})">🔄 Re-run</button>
</div>
</div>`;
}
async function reprocessPhoto(photoId) {
const eng = document.getElementById('reEng' + photoId).value;
const model = document.getElementById('reModel' + photoId).value;
const sticker = document.getElementById('reSticker' + photoId).checked;
let params = '?ocr_engine=' + eng;
if (model) params += '&ocr_model=' + model;
if (sticker) params += '&sticker_mode=true';
const card = document.querySelector(`#reEng${photoId}`).closest('.prev-photo-card');
const actions = card.querySelector('.prev-actions');
actions.innerHTML = '<span class="spinner" style="width:14px;height:14px;"></span> Reprocessing...';
try {
const resp = await fetch(`/api/photos/${photoId}/reprocess${params}`, { method: 'POST' });
const data = await resp.json();
// Refresh the card
const updated = { ...data, orig_filename: card.querySelector('.prev-filename')?.textContent || 'Photo' };
// Just reload the full list
loadPreviousPhotos();
} catch (e) {
actions.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Failed</span>';
}
}
// --- File handling ---
document.getElementById('fileInput').addEventListener('change', async function(e) {
const files = Array.from(e.target.files || []);
@@ -316,7 +452,6 @@ document.getElementById('fileInput').addEventListener('change', async function(e
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('clearBtn').style.display = 'block';
// Show scanning state
document.getElementById('gallery').innerHTML = files.map((_, i) =>
'<div class="photo-card" id="card' + i + '">' +
'<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:24px;">' +
@@ -326,7 +461,6 @@ document.getElementById('fileInput').addEventListener('change', async function(e
'</div>'
).join('');
// Scan in small batches — iOS Safari can't handle many concurrent file reads
const results = [];
const batchSize = 2;
for (let i = 0; i < files.length; i += batchSize) {
@@ -342,12 +476,7 @@ document.getElementById('fileInput').addEventListener('change', async function(e
async function scanPhoto(file) {
const result = { file, exif: null, hasGps: false, lat: null, lng: null, thumb: null };
// Use object URL instead of readAsDataURL — avoids loading the entire file
// into memory as a base64 string (the main cause of iOS hangs with many photos)
result.thumb = URL.createObjectURL(file);
// Read EXIF
try {
const exif = await exifr.parse(file);
if (exif && Object.keys(exif).length > 0) {
@@ -361,8 +490,7 @@ async function scanPhoto(file) {
result.lng = exif.longitude;
}
}
} catch (_) { /* EXIF is best-effort */ }
} catch (_) {}
return result;
}
@@ -373,7 +501,6 @@ function updateSummary() {
document.getElementById('sumGps').textContent = gps;
document.getElementById('sumNoGps').textContent = nogps;
document.getElementById('summary').style.display = 'block';
// Show bulk button if we have GPS photos
document.getElementById('bulkBtn').style.display = gps > 0 ? 'block' : 'none';
}
@@ -412,7 +539,7 @@ function showDetail(idx) {
const p = allPhotos[idx];
if (!p) return;
renderGallery(); // update selection highlights
renderGallery();
const detail = document.getElementById('detail');
detail.style.display = 'block';
@@ -466,6 +593,9 @@ async function uploadSelected() {
const data = await resp.json();
let html = '';
if (data.duplicate) {
html += '<div style="margin-bottom:8px;"><span class="match-badge dup">♻️ Already processed — skipped</span></div>';
}
html += '<div class="exif-row"><span class="exif-key">Saved as</span><span class="exif-val">' + esc(data.saved_as) + '</span></div>';
html += '<div class="exif-row"><span class="exif-key">File size</span><span class="exif-val">' + data.file_size_kb + ' KB</span></div>';
@@ -478,7 +608,6 @@ async function uploadSelected() {
html = '<div class="gps-coords" style="color:var(--red);margin:8px 0;">❌ Server found no EXIF — stripped during upload</div>' + html;
}
// EXIF tags
if (exif.has_exif) {
for (const [k, v] of Object.entries(exif.tags).slice(0, 8)) {
html += '<div class="exif-row"><span class="exif-key">' + esc(k) + '</span><span class="exif-val">' + esc(v) + '</span></div>';
@@ -488,14 +617,17 @@ async function uploadSelected() {
// OCR
const ocr = data.ocr;
const engineCls = ocr.engine === 'llm' || ocr.engine === 'llm_batch' ? 'llm' : 'tesseract';
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR <span class="engine-badge ' + engineCls + '">' + esc(ocr.engine || 'tesseract') + (ocr.llm_model ? ' ' + esc(ocr.llm_model) : '') + '</span></div>';
let ocrBadge = '<span class="engine-badge ' + engineCls + '">' + esc(ocr.engine || 'tesseract');
if (ocr.llm_model) ocrBadge += ' ' + esc(ocr.llm_model);
if (ocr.sticker_color) ocrBadge += ' 🏷️' + esc(ocr.sticker_color);
ocrBadge += '</span>';
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR ' + ocrBadge + '</div>';
if (ocr.raw_text) {
html += '<div class="ocr-text">' + esc(ocr.raw_text) + '</div>';
}
if (ocr.match_5dash6) {
const mid = ocr.match_5dash6.replace(/[^0-9]/g,'').slice(-5);
html += '<div style="margin-top:4px;color:var(--green);">✅ Matched: <strong>' + esc(ocr.match_5dash6) + '</strong> → machine ID: ' + mid + '</div>';
// Auto-lookup
lookupAsset(mid);
} else if (ocr.match_5plus) {
html += '<div style="margin-top:4px;color:var(--amber);">⚠️ Digits: <strong>' + esc(ocr.match_5plus) + '</strong> (no 5-6 pattern)</div>';
@@ -503,12 +635,96 @@ async function uploadSelected() {
html += '<div style="margin-top:4px;color:var(--text3);">No machine ID found in image</div>';
}
// Re-run controls
html += '<div class="reprocess-row">' +
'<select id="reRunEng">' +
'<option value="tesseract">Tesseract</option>' +
'<option value="llm" ' + (ocr.engine === 'llm' || ocr.engine === 'llm_batch' ? 'selected' : '') + '>LLM</option>' +
'<option value="google"' + (ocr.engine === 'google' ? 'selected' : '') + '>🌐 Google</option>' +
'</select>' +
'<select id="reRunModel">' +
'<option value="">default</option>' +
'<option value="mimo-v2-omni">mimo-v2-omni</option>' +
'<option value="kimi-k2.5">kimi-k2.5</option>' +
'<option value="glm-5.1">glm-5.1</option>' +
'<option value="gemini-2.5-flash">gemini-2.5-flash</option>' +
'</select>' +
'<label style="font-size:10px;"><input type="checkbox" id="reRunSticker"> 🏷️</label>' +
'<button class="btn btn-primary btn-xs" onclick="reprocessCurrent()">🔄 Re-run</button>' +
'</div>';
// Manual entry when GPS exists but OCR failed
if ((!ocr.match_5dash6 && !ocr.match_5plus) && data.exif && data.exif.gps) {
html += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border);">' +
'<div style="font-size:11px;color:var(--text2);margin-bottom:4px;">✏️ GPS found but no machine ID in OCR. Enter it manually:</div>' +
'<div style="display:flex;gap:6px;">' +
'<input type="text" id="manualMid" placeholder="e.g. 12345-678901" style="flex:1;background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px;font-size:13px;">' +
'<button class="btn btn-primary btn-xs" style="width:auto;white-space:nowrap;" onclick="manualLookup()">🔍 Lookup</button>' +
'</div>' +
'<div id="manualResult" style="margin-top:4px;"></div>' +
'</div>';
}
div.innerHTML = html;
} catch (e) {
div.innerHTML = '<div class="empty">❌ Upload failed: ' + esc(e.message) + '</div>';
}
}
function manualLookup() {
const input = document.getElementById('manualMid');
const resultDiv = document.getElementById('manualResult');
const val = input.value.trim();
if (!val) { resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">Enter a machine ID</span>'; return; }
resultDiv.innerHTML = '<span class="spinner" style="width:12px;height:12px;"></span>';
fetch('/api/lookup?machine_id=' + encodeURIComponent(val))
.then(r => r.json())
.then(data => {
if (data.found) {
const a = data.asset;
resultDiv.innerHTML = '<div class="asset-card" style="margin-top:0;">' +
'<div class="asset-name">' + esc(a.name) + '</div>' +
'<div class="asset-meta"><span>🆔 ' + esc(a.machine_id) + '</span><span>📦 ' + esc(a.category) + '</span>' +
(a.status === 'active' ? '🟢 Active' : '⚪ ' + esc(a.status)) +
'</div></div>';
} else {
resultDiv.innerHTML = '<span style="color:var(--amber);font-size:11px;">⚠️ No asset found for <strong>' + esc(val) + '</strong></span>';
}
})
.catch(e => {
resultDiv.innerHTML = '<span style="color:var(--red);font-size:11px;">❌ Error: ' + esc(e.message) + '</span>';
});
}
async function reprocessCurrent() {
const eng = document.getElementById('reRunEng').value;
const model = document.getElementById('reRunModel').value;
const sticker = document.getElementById('reRunSticker').checked;
// Need the photo_id from the last upload. Get it from server results.
// We'll just re-upload the same file with new params
if (selectedIdx < 0 || !allPhotos[selectedIdx]) return;
const file = allPhotos[selectedIdx].file;
const div = document.getElementById('serverResults');
div.innerHTML += '<div style="text-align:center;padding:8px;"><span class="spinner"></span> Reprocessing...</div>';
const fd = new FormData();
fd.append('file', file, file.name || 'photo.jpg');
let params = '?ocr_engine=' + eng;
if (model) params += '&ocr_model=' + model;
if (sticker) params += '&sticker_mode=true';
try {
const resp = await fetch('/api/analyze' + params, { method: 'POST', body: fd });
const data = await resp.json();
// Just reload the whole result
uploadSelected();
} catch (e) {
div.innerHTML += '<div class="empty">❌ Re-run failed: ' + esc(e.message) + '</div>';
}
}
async function lookupAsset(machineId) {
const div = document.getElementById('serverResults');
try {
@@ -545,9 +761,6 @@ async function lookupAsset(machineId) {
}
}
let bulkMap = null;
let bulkData = [];
async function startBulkProcess() {
const gpsPhotos = allPhotos.filter(p => p.hasGps);
if (!gpsPhotos.length) return;
@@ -556,7 +769,6 @@ async function startBulkProcess() {
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Processing ' + gpsPhotos.length + ' photos...';
// Build FormData with all GPS photos
const fd = new FormData();
gpsPhotos.forEach(p => fd.append('files', p.file, p.file.name || 'photo.jpg'));
@@ -564,7 +776,6 @@ async function startBulkProcess() {
const resp = await fetch('/api/bulk-process' + getOcrParams(), { method: 'POST', body: fd });
const data = await resp.json();
// Merge thumbnails back into results
bulkData = data.results.map((r, i) => ({
...r,
thumb: gpsPhotos[i]?.thumb || null,
@@ -586,13 +797,12 @@ function renderBulkResults(summary) {
const section = document.getElementById('bulkSection');
section.style.display = 'block';
// Summary badge
document.getElementById('bulkSummary').textContent =
summary.matched + ' matched, ' + summary.needs_gps + ' need GPS';
summary.matched + ' matched, ' + summary.needs_gps + ' need GPS' +
(summary.duplicates ? ', ' + summary.duplicates + ' skipped' : '');
document.getElementById('bulkSummary').className =
'match-badge ' + (summary.matched > 0 ? 'matched' : 'no-match');
// Init map
const mapDiv = document.getElementById('mapContainer');
mapDiv.style.display = 'block';
if (bulkMap) bulkMap.remove();
@@ -610,7 +820,6 @@ function renderBulkResults(summary) {
const gpsLat = hasGps ? item.exif.gps.lat : null;
const gpsLng = hasGps ? item.exif.gps.lng : null;
// Map marker for every GPS photo
if (gpsLat && gpsLng) {
const marker = L.marker([gpsLat, gpsLng])
.addTo(bulkMap)
@@ -622,21 +831,17 @@ function renderBulkResults(summary) {
markers.push([gpsLat, gpsLng]);
}
// Asset marker if it already has GPS in DB
if (asset && asset.latitude && asset.longitude) {
L.marker([asset.latitude, asset.longitude], {
icon: L.divIcon({
className: 'cat-pin-icon',
html: '<div style="width:22px;height:22px;border-radius:50%;background:#3b82f6;' +
'display:flex;align-items:center;justify-content:center;font-size:11px;' +
'box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">🏠</div>',
html: '<div style="width:22px;height:22px;border-radius:50%;background:#3b82f6;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">🏠</div>',
iconSize: [24, 24], iconAnchor: [12, 12]
})
}).addTo(bulkMap)
.bindPopup('<b>' + esc(asset.name) + '</b><br>Existing location');
}
// Card
const isGpsReady = item.needs_gps;
html += '<div class="bulk-card' + (isGpsReady ? ' gps-ready' : '') + '">';
@@ -646,7 +851,10 @@ function renderBulkResults(summary) {
html += '<div class="bulk-info">';
if (asset) {
if (item.duplicate) {
html += '<div class="bulk-name">' + esc(item.filename || 'Photo') + '</div>';
html += '<div class="bulk-meta"><span class="match-badge dup">♻️ Already processed</span></div>';
} else if (asset) {
html += '<div class="bulk-name">' + esc(asset.name) + '</div>';
html += '<div class="bulk-meta">';
html += '<span class="match-badge matched">🆔 ' + esc(asset.machine_id) + '</span> ';
@@ -668,9 +876,8 @@ function renderBulkResults(summary) {
html += '<div class="bulk-meta"><span class="match-badge no-match">No OCR match</span></div>';
}
html += '</div>'; // bulk-info
html += '</div>';
// Push button
html += '<div class="bulk-action">';
if (isGpsReady) {
html += '<button class="btn-push" id="pushBtn' + i + '" onclick="pushGpsToAsset(' + i + ')">📤 Push GPS</button>';
@@ -679,20 +886,17 @@ function renderBulkResults(summary) {
}
html += '</div>';
html += '</div>'; // bulk-card
html += '</div>';
});
document.getElementById('bulkResults').innerHTML = html;
// Fit map to markers
if (markers.length) {
const bounds = L.latLngBounds(markers);
bulkMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 15 });
}
// Invalidate map size after display
setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200);
section.scrollIntoView({ behavior: 'smooth' });
}
@@ -719,14 +923,11 @@ async function pushGpsToAsset(idx) {
if (data.updated) {
btn.className = 'btn-push done';
btn.textContent = '✓ Pushed!';
// Add marker to map for pushed location
if (bulkMap) {
L.marker([gps.lat, gps.lng], {
icon: L.divIcon({
className: 'cat-pin-icon',
html: '<div style="width:22px;height:22px;border-radius:50%;background:#22c55e;' +
'display:flex;align-items:center;justify-content:center;font-size:11px;' +
'box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">✓</div>',
html: '<div style="width:22px;height:22px;border-radius:50%;background:#22c55e;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">✓</div>',
iconSize: [24, 24], iconAnchor: [12, 12]
})
}).addTo(bulkMap)
@@ -744,7 +945,6 @@ async function pushGpsToAsset(idx) {
}
function resetAll() {
// Revoke all blob URLs to prevent memory leaks
allPhotos.forEach(p => {
if (p.thumb && p.thumb.startsWith('blob:')) {
URL.revokeObjectURL(p.thumb);
@@ -774,10 +974,24 @@ function esc(s) {
return d.innerHTML;
}
function onOcrToggle() {
const engine = document.getElementById('ocrEngine').value;
const sticker = document.getElementById('stickerToggle').checked;
const label = document.getElementById('modelLabel');
if (engine === 'llm' && sticker) label.textContent = '🏷️ sticker mode';
else if (engine === 'llm') label.textContent = 'mimo-v2-omni';
else if (engine === 'google' && sticker) label.textContent = '🏷️ gemini sticker';
else if (engine === 'google') label.textContent = 'gemini-2.5-flash';
else label.textContent = 'tesseract';
}
function getOcrParams() {
const useLlm = document.getElementById('ocrLlmToggle').checked;
if (!useLlm) return '';
return '?ocr_engine=llm';
const engine = document.getElementById('ocrEngine').value;
const sticker = document.getElementById('stickerToggle').checked;
let params = [];
if (engine !== 'tesseract') params.push('ocr_engine=' + engine);
if (sticker) params.push('sticker_mode=true');
return params.length ? '?' + params.join('&') : '';
}
function formatSize(bytes) {