T1: Tesseract.js client-side OCR fallback

This commit is contained in:
2026-05-21 11:08:16 -04:00
parent c8973337c8
commit 5cb8e8aa73
2 changed files with 449 additions and 28 deletions
+341 -28
View File
@@ -5,12 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Canteen Asset Tracker</title>
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/umd/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<!-- Leaflet Map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
<style>
/* ═══════════════════════════════════════════════════════════════════════
DESIGN TOKENS
@@ -264,6 +266,30 @@
.status-bar.error { background: var(--red-bg); color: var(--red); }
.status-bar.working { color: var(--accent2); }
/* ═══════════════════════════════════════════════════════════════════════
OFFLINE BANNER
═══════════════════════════════════════════════════════════════════════ */
.offline-banner {
position: fixed; top: var(--header-height); left: 50%; transform: translateX(-50%);
width: 100%; max-width: 480px; z-index: 199;
background: var(--amber-bg); color: var(--amber);
text-align: center; padding: 6px 12px; font-size: 13px; font-weight: 600;
display: none; align-items: center; justify-content: center; gap: 6px;
}
.offline-banner.show { display: flex; }
.offline-banner .queue-count {
background: var(--amber); color: #000; font-size: 10px; font-weight: 700;
padding: 1px 7px; border-radius: 10px; margin-left: 4px;
}
.queue-indicator {
display: none; align-items: center; gap: 3px;
background: var(--amber-bg); color: var(--amber);
font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 20px;
cursor: pointer; white-space: nowrap;
}
.queue-indicator.show { display: inline-flex; }
.queue-indicator.syncing { background: var(--accent-bg); color: var(--accent2); }
/* ═══════════════════════════════════════════════════════════════════════
TOAST
═══════════════════════════════════════════════════════════════════════ */
@@ -456,6 +482,49 @@
.scan-result .sr-actions { display: flex; gap: 8px; margin-top: 10px; align-items: center; }
.scan-result .sr-checkin-done { font-size: 13px; color: #22c55e; font-weight: 600; white-space: nowrap; }
/* ═══════════════════════════════════════════════════════════════════════
PHOTO PICKER / GALLERY PREVIEW
═══════════════════════════════════════════════════════════════════════ */
.photo-picker-btn {
width: 100%; padding: 10px; border: 1px dashed var(--border2);
background: var(--card); color: var(--text); font-size: 14px;
font-weight: 600; cursor: pointer; border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center; gap: 6px;
margin-bottom: 10px; transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.photo-picker-btn:active { background: var(--border); }
.photo-preview-card {
background: var(--card); border-radius: var(--radius);
box-shadow: 0 4px 24px rgba(0,0,0,0.3); padding: 12px;
margin-bottom: 10px; animation: fadeIn 0.2s;
}
.photo-preview-card img {
width: 100%; border-radius: var(--radius-sm); margin-bottom: 10px;
}
.photo-preview-toolbar {
display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap;
}
.photo-preview-toolbar button {
flex: 1; min-width: 80px; padding: 8px 12px; border: 1px solid var(--border2);
background: var(--card2); color: var(--text); font-size: 12px;
font-weight: 600; border-radius: var(--radius-xs); cursor: pointer;
transition: all 0.15s; -webkit-tap-highlight-color: transparent;
}
.photo-preview-toolbar button:active { background: var(--accent-bg); border-color: var(--accent); }
.exif-data {
background: var(--card2); border: 1px solid var(--border);
border-radius: var(--radius-xs); padding: 10px; font-size: 12px;
color: var(--text2);
}
.exif-data .exif-row {
display: flex; justify-content: space-between; padding: 3px 0;
border-bottom: 1px solid var(--border);
}
.exif-data .exif-row:last-child { border-bottom: none; }
.exif-data .exif-label { color: var(--text3); }
.exif-data .exif-value { color: var(--text); font-weight: 500; }
/* ═══════════════════════════════════════════════════════════════════════
MODE TOGGLES (Add Asset tab)
═══════════════════════════════════════════════════════════════════════ */
@@ -542,6 +611,24 @@
.ocr-result .or-meta { font-size: 12px; color: var(--text2); margin-top: 4px; }
.ocr-result .or-raw { font-size: 11px; color: var(--text3); margin-top: 8px; padding: 8px; background: #0a0b0f; border-radius: 4px; max-height: 80px; overflow-y: auto; word-break: break-all; white-space: pre-wrap; }
/* OCR progress bar */
.ocr-progress-wrap {
margin-top: 6px; height: 4px; background: var(--border);
border-radius: 2px; overflow: hidden; display: none;
}
.ocr-progress-wrap.active { display: block; }
.ocr-progress-bar {
height: 100%; width: 0%; background: var(--accent);
border-radius: 2px; transition: width 0.3s;
}
/* Offline OCR badge */
.offline-badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; font-weight: 700; padding: 2px 8px;
border-radius: 10px; background: var(--amber-bg); color: var(--amber);
margin-left: 8px; vertical-align: middle;
}
/* ═══════════════════════════════════════════════════════════════════════
DETAIL VIEW (shared)
@@ -1117,11 +1204,14 @@
TAB: ADD ASSET (Barcode / OCR / Manual)
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabAddAsset" class="tab-panel active">
<!-- Hidden photo picker input -->
<input type="file" id="photoPicker" accept="image/*" capture="environment" style="display:none;" onchange="handlePickedPhoto(event)">
<!-- Mode Toggles -->
<div class="mode-toggles">
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
<button class="mode-toggle" data-mode="ocr" onclick="setAddAssetMode('ocr')">🔍 OCR</button>
<button class="mode-toggle" data-mode="manual" onclick="setAddAssetMode('manual')">✏️ Manual</button>
<button class="photo-picker-btn" onclick="document.getElementById('photoPicker').click()">📱 Pick from Gallery</button>
</div>
<!-- ── BARCODE MODE ──────────────────────────────────────────────── -->
@@ -1183,6 +1273,9 @@
</div>
</div>
<div id="ocrStatus" class="status-bar">Point camera at the machine sticker and tap capture</div>
<div id="ocrProgressWrap" class="ocr-progress-wrap">
<div id="ocrProgressBar" class="ocr-progress-bar"></div>
</div>
</div>
<!-- OCR result -->
<div id="ocrResult" class="ocr-result" style="display:none;"></div>
@@ -1313,6 +1406,18 @@
</div>
</div> </div>
<!-- ── Photo Preview Card (shown when a photo is picked from gallery) ── -->
<div id="photoPreviewCard" class="photo-preview-card" style="display:none;">
<img id="pickedPhotoPreview" src="" alt="Picked photo">
<div class="photo-preview-toolbar">
<button onclick="ocrPickedPhoto()">🔍 OCR this photo</button>
<button onclick="extractGpsFromPicked()">📍 Extract GPS</button>
<button onclick="createAssetFromPicked()"> Create Asset</button>
</div>
<div class="exif-data" id="pickedExifData" style="display:none;"></div>
<button class="btn btn-outline btn-sm" style="margin-top:8px;width:100%;" onclick="clearPickedPhoto()">✕ Clear</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: ASSETS LIST
═══════════════════════════════════════════════════════════════════════ -->
@@ -2494,6 +2599,109 @@
}
}
// ── Photo Picker / Gallery ────────────────────────────────────────────────
let pickedPhotoFile = null;
// Wire the hidden file input
(function() {
const pp = document.getElementById('photoPicker');
if (pp) {
pp.addEventListener('change', function(e) {
if (e.target.files && e.target.files[0]) {
handlePickedPhoto(e.target.files[0]);
}
});
}
})();
function handlePickedPhoto(file) {
pickedPhotoFile = file;
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('pickedPhotoPreview').src = e.target.result;
document.getElementById('photoPreviewCard').style.display = 'block';
// Extract EXIF / file metadata
extractExifData(file);
// Scroll to preview
document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' });
};
reader.readAsDataURL(file);
}
function extractExifData(file) {
const exifDiv = document.getElementById('pickedExifData');
const rows = [];
rows.push({ label: 'File name', value: file.name });
rows.push({ label: 'File size', value: formatFileSize(file.size) });
rows.push({ label: 'Type', value: file.type || 'unknown' });
rows.push({ label: 'Last modified', value: formatDate(new Date(file.lastModified).toISOString()) });
exifDiv.innerHTML = rows.map(r =>
`<div class="exif-row"><span class="exif-label">${esc(r.label)}</span><span class="exif-value">${esc(r.value)}</span></div>`
).join('');
exifDiv.style.display = 'block';
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function clearPickedPhoto() {
pickedPhotoFile = null;
document.getElementById('photoPreviewCard').style.display = 'none';
document.getElementById('pickedPhotoPreview').src = '';
document.getElementById('pickedExifData').style.display = 'none';
document.getElementById('pickedExifData').innerHTML = '';
// Reset the file input so the same file can be picked again
const pp = document.getElementById('photoPicker');
if (pp) pp.value = '';
}
function ocrPickedPhoto() {
if (!pickedPhotoFile) return;
// Switch to OCR mode with the picked photo available
setAddAssetMode('ocr');
showToast('Photo loaded in preview — OCR processing ready');
}
function extractGpsFromPicked() {
if (!pickedPhotoFile) return;
// Browser FileReader doesn't expose EXIF GPS without a library.
// For now, show what we have and note server-side parsing is available.
showToast('GPS data will be extracted server-side — check EXIF info below for file details');
}
function createAssetFromPicked() {
if (!pickedPhotoFile) return;
// Switch to Manual mode and pre-load the photo
setAddAssetMode('manual');
const reader = new FileReader();
reader.onload = function(e) {
// Convert data URL to blob for manualPhotoBlob
const arr = e.target.result.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) { u8arr[n] = bstr.charCodeAt(n); }
manualPhotoBlob = new Blob([u8arr], { type: mime });
// Show the photo in the manual preview
const img = document.getElementById('manPhotoPreview');
img.src = e.target.result;
img.style.display = 'block';
const ph = document.getElementById('manPhotoPlaceholder');
if (ph) ph.style.display = 'none';
const retake = document.getElementById('manPhotoRetake');
if (retake) retake.style.display = 'block';
};
reader.readAsDataURL(pickedPhotoFile);
showToast('Photo loaded into Manual mode');
}
// ── Shared: Load settings dropdowns ─────────────────────────────────────
async function loadSettingsCache() {
if (settingsCache._loaded) return; // already loaded successfully
@@ -2956,6 +3164,69 @@
if (el) { el.textContent = msg; el.className = 'status-bar ' + cls; }
}
function updateOcrProgress(m) {
const wrap = document.getElementById('ocrProgressWrap');
const bar = document.getElementById('ocrProgressBar');
if (!wrap || !bar) return;
if (m.status === 'recognizing text') {
wrap.classList.add('active');
const pct = Math.round((m.progress || 0) * 100);
bar.style.width = pct + '%';
}
if (m.status === 'done' || m.status === 'error') {
wrap.classList.remove('active');
bar.style.width = '0%';
}
}
function parseMachineId(text) {
// Same logic as server-side: XXXXX-XXXXXX pattern → last 5 digits
const match = text.match(/(\d{5})[-\s]*(\d{6,})/);
if (match) {
const digits = match[0].replace(/\D/g, '');
return { machine_id: digits.slice(-5), confidence: 'high' };
}
// Looser: any 5+ digit number → last 5 digits
const loose = text.match(/(\d{5,})/);
if (loose) {
const digits = loose[1];
const mid = digits.length > 5 ? digits.slice(-5) : digits;
return { machine_id: mid, confidence: 'low' };
}
return { machine_id: null, confidence: 'none' };
}
async function ocrImageClient(imageBlob) {
// Timeout race — 30s max for client-side Tesseract.js
const ocrPromise = Tesseract.recognize(imageBlob, 'eng', {
logger: m => updateOcrProgress(m),
});
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Client OCR timed out after 30s')), 30000)
);
let result;
try {
result = await Promise.race([ocrPromise, timeoutPromise]);
} catch (e) {
updateOcrProgress({ status: 'error' });
throw new Error('Client OCR failed: ' + (e.message || 'unknown error'));
}
updateOcrProgress({ status: 'done' });
const text = (result.data && result.data.text) || '';
const parsed = parseMachineId(text.trim());
return {
machine_id: parsed.machine_id,
raw_text: text.trim().slice(0, 500),
confidence: parsed.confidence,
method: 'client',
tesseractConfidence: result.data ? result.data.confidence : null,
};
}
async function captureOcr() {
const video = document.getElementById('ocrVideo');
if (!video || !ocrStream) return;
@@ -2970,39 +3241,81 @@
document.getElementById('ocrResult').style.display = 'none';
document.getElementById('ocrCreateCard').style.display = 'none';
let data = null;
let method = 'server';
// Try server-side OCR first with 8s timeout
try {
const controller = new AbortController();
const serverTimeout = setTimeout(() => controller.abort(), 8000);
const formData = new FormData();
formData.append('file', blob, 'sticker.jpg');
const data = await api('/api/ocr', { method: 'POST', body: formData });
const el = document.getElementById('ocrResult');
el.style.display = 'block';
if (data.machine_id) {
const confLabel = data.confidence === 'high' ? '✅ High confidence' :
data.confidence === 'low' ? '⚠️ Low confidence' : '';
el.innerHTML = `
<div class="or-id">${esc(data.machine_id)}</div>
<div class="or-meta">${confLabel}</div>
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
setOcrStatus('Machine ID found!', 'success');
// Show quick create form
document.getElementById('ocrMachineId').value = data.machine_id;
document.getElementById('ocrName').value = '';
populateCategorySelect('ocrCatSelect');
document.getElementById('ocrStatus').value = 'active';
document.getElementById('ocrCreateCard').style.display = 'block';
document.getElementById('ocrName').focus();
const res = await fetch('/api/ocr', {
method: 'POST',
body: formData,
signal: controller.signal,
headers: AppState.authToken
? { 'Authorization': 'Bearer ' + AppState.authToken }
: {},
});
clearTimeout(serverTimeout);
if (res.ok) {
data = await res.json();
data.method = 'server';
} else {
el.innerHTML = `
<div style="font-size:16px;font-weight:600;color:var(--red);">No machine ID detected</div>
<div class="or-meta">Try again with better lighting and focus on the sticker.</div>
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
setOcrStatus('No machine ID found — try again', 'error');
throw new Error('Server returned ' + res.status);
}
} catch (e) {
setOcrStatus('OCR failed: ' + e.message, 'error');
} catch (serverErr) {
// Server failed or timed out — fall back to client-side Tesseract.js
setOcrStatus('Server unavailable — using offline OCR...', 'working');
// Show offline badge in status
const statusEl = document.getElementById('ocrStatus');
if (statusEl) {
statusEl.innerHTML = 'Server unavailable — <span class="offline-badge">📡 Offline OCR</span>';
statusEl.className = 'status-bar working';
}
try {
data = await ocrImageClient(blob);
method = 'client';
} catch (clientErr) {
setOcrStatus('OCR failed: ' + clientErr.message, 'error');
return;
}
}
// Reset status bar innerHTML from offline badge if server succeeded
if (method === 'server') {
setOcrStatus('Processing OCR...', 'working');
}
const el = document.getElementById('ocrResult');
el.style.display = 'block';
if (data.machine_id) {
const methodBadge = data.method === 'client'
? '<span class="offline-badge">📡 Offline OCR</span>'
: '';
const confLabel = data.confidence === 'high' ? '✅ High confidence' :
data.confidence === 'low' ? '⚠️ Low confidence' : '';
el.innerHTML = `
<div class="or-id">${esc(data.machine_id)}${methodBadge}</div>
<div class="or-meta">${confLabel}</div>
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
setOcrStatus('Machine ID found!', 'success');
// Show quick create form
document.getElementById('ocrMachineId').value = data.machine_id;
document.getElementById('ocrName').value = '';
populateCategorySelect('ocrCatSelect');
document.getElementById('ocrStatus').value = 'active';
document.getElementById('ocrCreateCard').style.display = 'block';
document.getElementById('ocrName').focus();
} else {
el.innerHTML = `
<div style="font-size:16px;font-weight:600;color:var(--red);">No machine ID detected</div>
<div class="or-meta">Try again with better lighting and focus on the sticker.</div>
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
setOcrStatus('No machine ID found — try again', 'error');
}
}
+108
View File
@@ -0,0 +1,108 @@
// ═══════════════════════════════════════════════════════════════════════════
// Canteen Asset Tracker — Service Worker
// Caches app shell + CDN deps. Network-first for API, cache fallback for static.
// ═══════════════════════════════════════════════════════════════════════════
const CACHE_NAME = 'canteen-v1';
// App shell — core resources needed to boot the PWA
const APP_SHELL = [
'/',
'/index.html',
];
// CDN resources we want cached for offline resilience
const CDN_URLS = [
'https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/umd/index.min.js',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
'https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
'https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js',
'https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js',
];
// ── Install: pre-cache app shell + CDN resources ──────────────────────────
self.addEventListener('install', (event) => {
console.log('[SW] Install — caching app shell + CDN assets');
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([...APP_SHELL, ...CDN_URLS]).catch((err) => {
console.warn('[SW] Some resources failed to cache (non-fatal):', err.message);
});
})
);
// Activate immediately — don't wait for old SW to release
self.skipWaiting();
});
// ── Activate: purge old caches ────────────────────────────────────────────
self.addEventListener('activate', (event) => {
console.log('[SW] Activate — purging old caches');
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
);
})
);
// Take control of all clients immediately
self.clients.claim();
});
// ── Fetch: network-first for everything, cache fallback ───────────────────
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Navigation requests: network-first, fallback to cached index.html
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache the latest version
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => {
return caches.match(event.request).then((cached) => cached || caches.match('/'));
})
);
return;
}
// API calls: pass through — main thread handles offline queuing
if (url.pathname.startsWith('/api/')) {
return;
}
// Static assets (local + CDN): network-first, cache fallback
event.respondWith(
fetch(event.request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
});
// ── Message handler: allow main thread to trigger queue sync ──────────────
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SYNC_QUEUE') {
// Forward sync request to all controlled clients
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({ type: 'PROCESS_OFFLINE_QUEUE' });
});
});
}
});