a8b1e694b0
- 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
1004 lines
41 KiB
HTML
1004 lines
41 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
<title>EXIF Scanner — Find GPS Photos</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #0f172a; --card: #1e293b; --card2: #334155;
|
|
--text: #f1f5f9; --text2: #94a3b8; --text3: #64748b;
|
|
--accent: #3b82f6; --accent2: #2563eb; --green: #22c55e;
|
|
--amber: #f59e0b; --red: #ef4444; --border: #334155;
|
|
--radius: 12px; --radius-sm: 8px;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg); color: var(--text);
|
|
max-width: 480px; margin: 0 auto; padding: 16px;
|
|
min-height: 100dvh; -webkit-tap-highlight-color: transparent;
|
|
overflow-x: hidden;
|
|
}
|
|
h1 { font-size: 20px; margin-bottom: 4px; }
|
|
.subtitle { font-size: 12px; color: var(--text2); margin-bottom: 16px; }
|
|
|
|
.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: 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 {
|
|
display: none; background: var(--card); border-radius: var(--radius);
|
|
padding: 12px 14px; margin-bottom: 12px;
|
|
}
|
|
.summary-grid {
|
|
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px;
|
|
text-align: center;
|
|
}
|
|
.summary-num { font-size: 24px; font-weight: 800; }
|
|
.summary-label { font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.summary-gps { color: var(--green); }
|
|
.summary-nogps { color: var(--amber); }
|
|
.summary-total { color: var(--text); }
|
|
|
|
#gallery {
|
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.photo-card {
|
|
position: relative; border-radius: var(--radius-sm); overflow: hidden;
|
|
aspect-ratio: 1; background: var(--card); cursor: pointer;
|
|
border: 2px solid var(--border); transition: border-color 0.15s;
|
|
}
|
|
.photo-card.selected { border-color: var(--accent); }
|
|
.photo-card img {
|
|
width: 100%; height: 100%; object-fit: cover; display: block;
|
|
}
|
|
.photo-card .overlay {
|
|
position: absolute; bottom: 0; left: 0; right: 0;
|
|
padding: 4px 6px; font-size: 10px; font-weight: 700;
|
|
display: flex; align-items: center; gap: 3px;
|
|
}
|
|
.overlay-gps { background: rgba(34,197,94,0.85); color: #000; }
|
|
.overlay-nogps { background: rgba(245,158,11,0.85); color: #000; }
|
|
.overlay-scanning { background: rgba(59,130,246,0.85); color: #fff; }
|
|
.photo-card .check {
|
|
position: absolute; top: 4px; right: 4px;
|
|
width: 22px; height: 22px; border-radius: 50%;
|
|
background: var(--accent); color: #fff;
|
|
display: none; align-items: center; justify-content: center;
|
|
font-size: 12px; font-weight: 700;
|
|
}
|
|
.photo-card.selected .check { display: flex; }
|
|
|
|
#detail {
|
|
display: none; background: var(--card); border-radius: var(--radius);
|
|
padding: 14px; margin-bottom: 12px;
|
|
}
|
|
#detailPreview { width: 100%; border-radius: var(--radius-sm); margin-bottom: 10px; }
|
|
.detail-section {
|
|
font-size: 13px; font-weight: 700; text-transform: uppercase;
|
|
letter-spacing: 0.05em; color: var(--text2); margin-bottom: 8px;
|
|
display: flex; align-items: center; gap: 6px;
|
|
}
|
|
.badge {
|
|
font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
|
|
}
|
|
.badge-ok { background: var(--green); color: #000; }
|
|
.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;
|
|
border-bottom: 1px solid var(--border); font-size: 13px;
|
|
}
|
|
.exif-row:last-child { border-bottom: none; }
|
|
.exif-key { color: var(--text2); flex-shrink: 0; margin-right: 12px; }
|
|
.exif-val { color: var(--text); text-align: right; word-break: break-all; }
|
|
|
|
.gps-coords {
|
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
font-size: 16px; font-weight: 700; color: var(--green);
|
|
text-align: center; padding: 12px; background: var(--card2);
|
|
border-radius: var(--radius-sm); margin: 8px 0;
|
|
}
|
|
|
|
.btn {
|
|
display: block; width: 100%; padding: 14px; font-size: 15px;
|
|
font-weight: 600; border: none; border-radius: var(--radius-sm);
|
|
cursor: pointer; transition: opacity 0.15s;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.btn:active { opacity: 0.8; }
|
|
.btn-primary { background: var(--accent); color: #fff; }
|
|
.btn-outline { background: transparent; border: 2px solid var(--border); color: var(--text); }
|
|
.btn-danger { background: transparent; border: 2px solid var(--red); color: var(--red); }
|
|
.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;
|
|
border: 2px solid var(--text3); border-top-color: var(--text);
|
|
border-radius: 50%; animation: spin 0.6s linear infinite;
|
|
vertical-align: middle; margin-right: 6px;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.empty { color: var(--text3); font-size: 12px; text-align: center; padding: 12px; font-style: italic; }
|
|
|
|
#fileInput { display: none; }
|
|
|
|
#filterBar {
|
|
display: none; align-items: center; gap: 6px; margin-bottom: 10px;
|
|
}
|
|
.filter-chip {
|
|
font-size: 11px; font-weight: 600; padding: 5px 10px;
|
|
border-radius: 14px; border: 1px solid var(--border);
|
|
background: var(--card2); color: var(--text2);
|
|
cursor: pointer; -webkit-tap-highlight-color: transparent;
|
|
}
|
|
.filter-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
|
|
.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-meta {
|
|
font-size: 12px; color: var(--text2);
|
|
display: flex; flex-wrap: wrap; gap: 8px;
|
|
}
|
|
.asset-card .asset-meta span {
|
|
background: var(--card); padding: 2px 8px; border-radius: 10px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.section {
|
|
background: var(--card); border-radius: var(--radius);
|
|
padding: 14px; margin-bottom: 12px;
|
|
}
|
|
.ocr-text {
|
|
background: var(--card2); border-radius: var(--radius-sm);
|
|
padding: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
|
|
font-size: 12px; white-space: pre-wrap; word-break: break-all;
|
|
max-height: 120px; overflow-y: auto; color: var(--text2);
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.ocr-toggle {
|
|
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); }
|
|
.engine-badge {
|
|
font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
|
|
background: var(--card2); color: var(--text2); display: inline-block; margin-left: 4px;
|
|
}
|
|
.engine-badge.llm { background: var(--accent); color: #fff; }
|
|
.engine-badge.tesseract { background: var(--card2); color: var(--text2); }
|
|
|
|
#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;
|
|
margin-bottom: 8px; display: flex; align-items: center; gap: 10px;
|
|
border-left: 3px solid var(--border);
|
|
}
|
|
.bulk-card.gps-ready { border-left-color: var(--green); }
|
|
.bulk-card .bulk-thumb { width: 48px; height: 48px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
|
|
.bulk-card .bulk-info { flex: 1; min-width: 0; }
|
|
.bulk-card .bulk-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.bulk-card .bulk-meta { font-size: 11px; color: var(--text2); }
|
|
.bulk-card .bulk-action { flex-shrink: 0; }
|
|
.btn-push {
|
|
font-size: 11px; font-weight: 700; padding: 6px 12px; border-radius: 14px;
|
|
border: none; cursor: pointer; -webkit-tap-highlight-color: transparent;
|
|
background: var(--green); color: #000;
|
|
}
|
|
.btn-push:disabled { opacity: 0.4; }
|
|
.btn-push.done { background: var(--card2); color: var(--text3); }
|
|
|
|
.match-badge {
|
|
display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
|
|
}
|
|
.match-badge.matched { background: var(--green); color: #000; }
|
|
.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>
|
|
|
|
<h1>📸 EXIF Scanner</h1>
|
|
<p class="subtitle">Select multiple photos — instantly see which have GPS</p>
|
|
|
|
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
|
|
<span class="icon">📱</span>
|
|
<span class="label">Tap to Select Photos</span>
|
|
<span class="hint">Pick from gallery — multi-select enabled</span>
|
|
</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">
|
|
<div><div class="summary-num summary-total" id="sumTotal">0</div><div class="summary-label">Total</div></div>
|
|
<div><div class="summary-num summary-gps" id="sumGps">0</div><div class="summary-label">📍 Have GPS</div></div>
|
|
<div><div class="summary-num summary-nogps" id="sumNoGps">0</div><div class="summary-label">⚠️ No GPS</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter chips -->
|
|
<div id="filterBar">
|
|
<span class="filter-chip active" onclick="setFilter('all')">All</span>
|
|
<span class="filter-chip" onclick="setFilter('gps')">📍 GPS</span>
|
|
<span class="filter-chip" onclick="setFilter('nogps')">⚠️ No GPS</span>
|
|
</div>
|
|
|
|
<!-- Photo grid -->
|
|
<div id="gallery"></div>
|
|
|
|
<!-- Detail card -->
|
|
<div id="detail">
|
|
<img id="detailPreview" alt="">
|
|
<div id="detailExif"></div>
|
|
<div style="margin-top:10px;">
|
|
<button class="btn btn-primary btn-sm" onclick="uploadSelected()">🔍 Upload & Run OCR</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Bulk process button -->
|
|
<button class="btn btn-primary" id="bulkBtn" style="display:none;margin-top:12px;" onclick="startBulkProcess()">
|
|
🔍 Bulk Process GPS Photos
|
|
</button>
|
|
|
|
<!-- Bulk results -->
|
|
<div class="section" id="bulkSection" style="display:none;">
|
|
<div class="detail-section">📊 Bulk Results
|
|
<span class="match-badge" id="bulkSummary" style="margin-left:auto;"></span>
|
|
</div>
|
|
<div id="mapContainer"></div>
|
|
<div id="bulkResults"></div>
|
|
</div>
|
|
|
|
<div class="btn-row" style="margin-top:8px;">
|
|
<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 || []);
|
|
if (!files.length) return;
|
|
|
|
document.getElementById('uploadArea').style.display = 'none';
|
|
document.getElementById('filterBar').style.display = 'flex';
|
|
document.getElementById('clearBtn').style.display = 'block';
|
|
|
|
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;">' +
|
|
'<span class="spinner" style="width:24px;height:24px;"></span>' +
|
|
'</div>' +
|
|
'<div class="overlay overlay-scanning">Scanning...</div>' +
|
|
'</div>'
|
|
).join('');
|
|
|
|
const results = [];
|
|
const batchSize = 2;
|
|
for (let i = 0; i < files.length; i += batchSize) {
|
|
const batch = files.slice(i, i + batchSize);
|
|
const batchResults = await Promise.all(batch.map(f => scanPhoto(f)));
|
|
results.push(...batchResults);
|
|
}
|
|
|
|
allPhotos = results;
|
|
updateSummary();
|
|
renderGallery();
|
|
});
|
|
|
|
async function scanPhoto(file) {
|
|
const result = { file, exif: null, hasGps: false, lat: null, lng: null, thumb: null };
|
|
result.thumb = URL.createObjectURL(file);
|
|
try {
|
|
const exif = await exifr.parse(file);
|
|
if (exif && Object.keys(exif).length > 0) {
|
|
result.exif = {};
|
|
for (const [k, v] of Object.entries(exif)) {
|
|
if (v !== undefined && v !== null) result.exif[k] = v;
|
|
}
|
|
if (exif.latitude !== undefined && exif.longitude !== undefined) {
|
|
result.hasGps = true;
|
|
result.lat = exif.latitude;
|
|
result.lng = exif.longitude;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
return result;
|
|
}
|
|
|
|
function updateSummary() {
|
|
const gps = allPhotos.filter(p => p.hasGps).length;
|
|
const nogps = allPhotos.length - gps;
|
|
document.getElementById('sumTotal').textContent = allPhotos.length;
|
|
document.getElementById('sumGps').textContent = gps;
|
|
document.getElementById('sumNoGps').textContent = nogps;
|
|
document.getElementById('summary').style.display = 'block';
|
|
document.getElementById('bulkBtn').style.display = gps > 0 ? 'block' : 'none';
|
|
}
|
|
|
|
function setFilter(f) {
|
|
currentFilter = f;
|
|
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
|
|
event.target.classList.add('active');
|
|
renderGallery();
|
|
}
|
|
|
|
function renderGallery() {
|
|
let filtered = allPhotos;
|
|
if (currentFilter === 'gps') filtered = allPhotos.filter(p => p.hasGps);
|
|
else if (currentFilter === 'nogps') filtered = allPhotos.filter(p => !p.hasGps);
|
|
|
|
const gallery = document.getElementById('gallery');
|
|
gallery.innerHTML = filtered.map(p => {
|
|
const idx = allPhotos.indexOf(p);
|
|
const selClass = idx === selectedIdx ? ' selected' : '';
|
|
const overlayClass = p.hasGps ? 'overlay-gps' : 'overlay-nogps';
|
|
const overlayText = p.hasGps ? '📍 ' + p.lat.toFixed(4) + ', ' + p.lng.toFixed(4) : '⚠️ No GPS';
|
|
return '<div class="photo-card' + selClass + '" id="card' + idx + '" onclick="showDetail(' + idx + ')">' +
|
|
'<img src="' + p.thumb + '" alt="">' +
|
|
'<div class="check">✓</div>' +
|
|
'<div class="overlay ' + overlayClass + '">' + overlayText + '</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
|
|
if (filtered.length === 0) {
|
|
gallery.innerHTML = '<div class="empty" style="grid-column:1/-1;">No photos match filter</div>';
|
|
}
|
|
}
|
|
|
|
function showDetail(idx) {
|
|
selectedIdx = idx;
|
|
const p = allPhotos[idx];
|
|
if (!p) return;
|
|
|
|
renderGallery();
|
|
|
|
const detail = document.getElementById('detail');
|
|
detail.style.display = 'block';
|
|
document.getElementById('detailPreview').src = p.thumb;
|
|
document.getElementById('serverSection').style.display = 'none';
|
|
|
|
let html = '';
|
|
html += '<div class="exif-row"><span class="exif-key">Name</span><span class="exif-val">' + esc(p.file.name) + '</span></div>';
|
|
html += '<div class="exif-row"><span class="exif-key">Size</span><span class="exif-val">' + formatSize(p.file.size) + '</span></div>';
|
|
|
|
if (p.exif) {
|
|
const priority = ['Make', 'Model', 'DateTimeOriginal', 'Orientation'];
|
|
const keys = Object.keys(p.exif);
|
|
const sorted = [...new Set([...priority.filter(k => k in p.exif), ...keys.filter(k => !priority.includes(k))])];
|
|
|
|
for (const k of sorted) {
|
|
const v = p.exif[k];
|
|
const label = k.replace(/([A-Z])/g, ' $1').trim();
|
|
const display = Array.isArray(v) ? v.map(x => typeof x === 'number' ? x.toFixed(3) : x).join(', ') : String(v);
|
|
html += '<div class="exif-row"><span class="exif-key">' + esc(label) + '</span><span class="exif-val">' + esc(display) + '</span></div>';
|
|
}
|
|
|
|
if (p.hasGps) {
|
|
html = '<div class="gps-coords">📍 ' + p.lat.toFixed(6) + ', ' + p.lng.toFixed(6) + '</div>' + html;
|
|
} else {
|
|
html = '<div class="gps-coords" style="color:var(--amber);">⚠️ No GPS in EXIF</div>' + html;
|
|
}
|
|
} else {
|
|
html += '<div class="empty" style="margin-top:8px;">No EXIF data at all — file may have been stripped</div>';
|
|
}
|
|
|
|
document.getElementById('detailExif').innerHTML = html;
|
|
detail.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
async function uploadSelected() {
|
|
if (selectedIdx < 0 || !allPhotos[selectedIdx]) return;
|
|
const file = allPhotos[selectedIdx].file;
|
|
|
|
const section = document.getElementById('serverSection');
|
|
const div = document.getElementById('serverResults');
|
|
section.style.display = 'block';
|
|
div.innerHTML = '<div style="text-align:center;padding:12px;"><span class="spinner"></span> Uploading & analyzing...</div>';
|
|
section.scrollIntoView({ behavior: 'smooth' });
|
|
|
|
const fd = new FormData();
|
|
fd.append('file', file, file.name || 'photo.jpg');
|
|
|
|
try {
|
|
const resp = await fetch('/api/analyze' + getOcrParams(), { method: 'POST', body: fd });
|
|
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>';
|
|
|
|
const exif = data.exif;
|
|
if (exif.gps) {
|
|
html = '<div class="gps-coords" style="margin:8px 0;">📍 Server GPS: ' + exif.gps.lat + ', ' + exif.gps.lng + '</div>' + html;
|
|
} else if (exif.has_exif) {
|
|
html = '<div class="gps-coords" style="color:var(--amber);margin:8px 0;">⚠️ Server found EXIF but no GPS</div>' + html;
|
|
} else {
|
|
html = '<div class="gps-coords" style="color:var(--red);margin:8px 0;">❌ Server found no EXIF — stripped during upload</div>' + html;
|
|
}
|
|
|
|
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>';
|
|
}
|
|
}
|
|
|
|
// OCR
|
|
const ocr = data.ocr;
|
|
const engineCls = ocr.engine === 'llm' || ocr.engine === 'llm_batch' ? 'llm' : 'tesseract';
|
|
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>';
|
|
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>';
|
|
} else {
|
|
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 {
|
|
const resp = await fetch('/api/lookup?machine_id=' + encodeURIComponent(machineId));
|
|
const data = await resp.json();
|
|
if (data.found) {
|
|
const a = data.asset;
|
|
let extra = '';
|
|
if (a.latitude && a.longitude) {
|
|
extra += '<span>📍 ' + Number(a.latitude).toFixed(5) + ', ' + Number(a.longitude).toFixed(5) + '</span>';
|
|
}
|
|
if (a.address) extra += '<span>🏠 ' + esc(a.address) + '</span>';
|
|
if (a.building_name) extra += '<span>🏢 ' + esc(a.building_name) + '</span>';
|
|
if (a.floor) extra += '<span>📶 Floor ' + esc(a.floor) + '</span>';
|
|
if (a.room) extra += '<span>🚪 ' + esc(a.room) + '</span>';
|
|
|
|
div.innerHTML +=
|
|
'<div class="asset-card">' +
|
|
'<div class="asset-name">' + esc(a.name) + '</div>' +
|
|
'<div class="asset-meta">' +
|
|
'<span>🆔 ' + esc(a.machine_id) + '</span>' +
|
|
'<span>📦 ' + esc(a.category) + '</span>' +
|
|
'<span>' + (a.status === 'active' ? '🟢 Active' : '⚪ ' + esc(a.status)) + '</span>' +
|
|
extra +
|
|
'</div>' +
|
|
'</div>';
|
|
} else {
|
|
div.innerHTML +=
|
|
'<div style="margin-top:6px;color:var(--amber);font-size:12px;">⚠️ No asset found for machine ID <strong>' + esc(machineId) + '</strong></div>';
|
|
}
|
|
} catch (e) {
|
|
div.innerHTML +=
|
|
'<div style="margin-top:6px;color:var(--red);font-size:12px;">❌ Lookup error: ' + esc(e.message) + '</div>';
|
|
}
|
|
}
|
|
|
|
async function startBulkProcess() {
|
|
const gpsPhotos = allPhotos.filter(p => p.hasGps);
|
|
if (!gpsPhotos.length) return;
|
|
|
|
const btn = document.getElementById('bulkBtn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner"></span> Processing ' + gpsPhotos.length + ' photos...';
|
|
|
|
const fd = new FormData();
|
|
gpsPhotos.forEach(p => fd.append('files', p.file, p.file.name || 'photo.jpg'));
|
|
|
|
try {
|
|
const resp = await fetch('/api/bulk-process' + getOcrParams(), { method: 'POST', body: fd });
|
|
const data = await resp.json();
|
|
|
|
bulkData = data.results.map((r, i) => ({
|
|
...r,
|
|
thumb: gpsPhotos[i]?.thumb || null,
|
|
clientLat: gpsPhotos[i]?.lat || null,
|
|
clientLng: gpsPhotos[i]?.lng || null,
|
|
}));
|
|
|
|
renderBulkResults(data.summary);
|
|
} catch (e) {
|
|
document.getElementById('bulkResults').innerHTML =
|
|
'<div class="empty">❌ Bulk process failed: ' + esc(e.message) + '</div>';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = '🔍 Bulk Process GPS Photos';
|
|
}
|
|
}
|
|
|
|
function renderBulkResults(summary) {
|
|
const section = document.getElementById('bulkSection');
|
|
section.style.display = 'block';
|
|
|
|
document.getElementById('bulkSummary').textContent =
|
|
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');
|
|
|
|
const mapDiv = document.getElementById('mapContainer');
|
|
mapDiv.style.display = 'block';
|
|
if (bulkMap) bulkMap.remove();
|
|
bulkMap = L.map('mapContainer').setView([28.35, -81.55], 8);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OSM', maxZoom: 19
|
|
}).addTo(bulkMap);
|
|
|
|
const markers = [];
|
|
let html = '';
|
|
|
|
bulkData.forEach((item, i) => {
|
|
const asset = item.asset;
|
|
const hasGps = item.exif?.gps;
|
|
const gpsLat = hasGps ? item.exif.gps.lat : null;
|
|
const gpsLng = hasGps ? item.exif.gps.lng : null;
|
|
|
|
if (gpsLat && gpsLng) {
|
|
const marker = L.marker([gpsLat, gpsLng])
|
|
.addTo(bulkMap)
|
|
.bindPopup(
|
|
(asset ? esc(asset.name) : 'Unknown machine') +
|
|
'<br>📸 ' + esc(item.filename || 'photo') +
|
|
'<br>📍 ' + gpsLat.toFixed(5) + ', ' + gpsLng.toFixed(5)
|
|
);
|
|
markers.push([gpsLat, gpsLng]);
|
|
}
|
|
|
|
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>',
|
|
iconSize: [24, 24], iconAnchor: [12, 12]
|
|
})
|
|
}).addTo(bulkMap)
|
|
.bindPopup('<b>' + esc(asset.name) + '</b><br>Existing location');
|
|
}
|
|
|
|
const isGpsReady = item.needs_gps;
|
|
html += '<div class="bulk-card' + (isGpsReady ? ' gps-ready' : '') + '">';
|
|
|
|
if (item.thumb) {
|
|
html += '<img class="bulk-thumb" src="' + item.thumb + '" alt="">';
|
|
}
|
|
|
|
html += '<div class="bulk-info">';
|
|
|
|
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> ';
|
|
html += '<span class="match-badge ' + (isGpsReady ? 'no-gps' : 'has-gps') + '">';
|
|
if (asset.latitude && asset.longitude) {
|
|
html += '📍 Has GPS in DB';
|
|
} else if (isGpsReady) {
|
|
html += '📍 Photo GPS: ' + gpsLat.toFixed(4) + ', ' + gpsLng.toFixed(4);
|
|
} else {
|
|
html += '⚠️ No photo GPS';
|
|
}
|
|
html += '</span>';
|
|
html += '</div>';
|
|
} else if (item.machine_id) {
|
|
html += '<div class="bulk-name">Machine #' + esc(item.machine_id) + '</div>';
|
|
html += '<div class="bulk-meta"><span class="match-badge no-match">No DB match</span></div>';
|
|
} else {
|
|
html += '<div class="bulk-name">' + esc(item.filename || 'Photo') + '</div>';
|
|
html += '<div class="bulk-meta"><span class="match-badge no-match">No OCR match</span></div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
|
|
html += '<div class="bulk-action">';
|
|
if (isGpsReady) {
|
|
html += '<button class="btn-push" id="pushBtn' + i + '" onclick="pushGpsToAsset(' + i + ')">📤 Push GPS</button>';
|
|
} else if (asset && asset.latitude && asset.longitude) {
|
|
html += '<button class="btn-push done">✓ In DB</button>';
|
|
}
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
});
|
|
|
|
document.getElementById('bulkResults').innerHTML = html;
|
|
|
|
if (markers.length) {
|
|
const bounds = L.latLngBounds(markers);
|
|
bulkMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 15 });
|
|
}
|
|
|
|
setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200);
|
|
section.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
async function pushGpsToAsset(idx) {
|
|
const item = bulkData[idx];
|
|
if (!item || !item.asset || !item.needs_gps) return;
|
|
|
|
const btn = document.getElementById('pushBtn' + idx);
|
|
btn.disabled = true;
|
|
btn.textContent = '⏳ Pushing...';
|
|
|
|
const gps = item.exif.gps;
|
|
try {
|
|
const resp = await fetch('/api/push-gps', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
asset_id: item.asset.id,
|
|
latitude: gps.lat,
|
|
longitude: gps.lng,
|
|
}),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.updated) {
|
|
btn.className = 'btn-push done';
|
|
btn.textContent = '✓ Pushed!';
|
|
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>',
|
|
iconSize: [24, 24], iconAnchor: [12, 12]
|
|
})
|
|
}).addTo(bulkMap)
|
|
.bindPopup('<b>' + esc(item.asset.name) + '</b><br>📍 Pushed: ' + gps.lat.toFixed(5) + ', ' + gps.lng.toFixed(5))
|
|
.openPopup();
|
|
}
|
|
} else {
|
|
btn.textContent = data.reason || 'Failed';
|
|
btn.style.background = 'var(--red)';
|
|
}
|
|
} catch (e) {
|
|
btn.textContent = 'Error';
|
|
btn.style.background = 'var(--red)';
|
|
}
|
|
}
|
|
|
|
function resetAll() {
|
|
allPhotos.forEach(p => {
|
|
if (p.thumb && p.thumb.startsWith('blob:')) {
|
|
URL.revokeObjectURL(p.thumb);
|
|
}
|
|
});
|
|
allPhotos = [];
|
|
selectedIdx = -1;
|
|
currentFilter = 'all';
|
|
bulkData = [];
|
|
if (bulkMap) { bulkMap.remove(); bulkMap = null; }
|
|
document.getElementById('fileInput').value = '';
|
|
document.getElementById('uploadArea').style.display = '';
|
|
document.getElementById('gallery').innerHTML = '';
|
|
document.getElementById('summary').style.display = 'none';
|
|
document.getElementById('filterBar').style.display = 'none';
|
|
document.getElementById('detail').style.display = 'none';
|
|
document.getElementById('serverSection').style.display = 'none';
|
|
document.getElementById('bulkSection').style.display = 'none';
|
|
document.getElementById('bulkBtn').style.display = 'none';
|
|
document.getElementById('mapContainer').style.display = 'none';
|
|
document.getElementById('clearBtn').style.display = 'none';
|
|
}
|
|
|
|
function esc(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = String(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 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) {
|
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / 1048576).toFixed(1) + ' MB';
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|