diff --git a/server.py b/server.py index b65b3f4..880e18f 100644 --- a/server.py +++ b/server.py @@ -2585,6 +2585,81 @@ ICON_ALLOWED_EXTS = {".png", ".jpg", ".jpeg", ".svg"} PHOTO_ALLOWED_EXTS = {".png", ".jpg", ".jpeg"} +def _dms_to_decimal(dms_tuple: tuple, ref: str) -> float: + """Convert GPS DMS (degrees, minutes, seconds) tuple to decimal degrees.""" + try: + degrees = float(dms_tuple[0]) + minutes = float(dms_tuple[1]) + seconds = float(dms_tuple[2]) + decimal = degrees + minutes / 60.0 + seconds / 3600.0 + if ref in ("S", "W"): + decimal = -decimal + return round(decimal, 6) + except (TypeError, ValueError, IndexError): + return None + + +def _extract_gps_from_bytes(image_bytes: bytes) -> dict | None: + """Extract GPS coordinates from image EXIF data. + + Reads the raw image bytes with PIL, extracts the GPS IFD, + and converts DMS coordinates to decimal degrees. + + Returns {lat, lng} or None if no GPS data found. + """ + try: + img = PILImage.open(io.BytesIO(image_bytes)) + exif = img.getexif() + # GPSInfo IFD tag = 0x8825 = 34853 + gps_ifd = exif.get_ifd(0x8825) + except Exception: + return None + + if not gps_ifd: + return None + + # GPSLatitudeRef = tag 1, GPSLatitude = tag 2 + # GPSLongitudeRef = tag 3, GPSLongitude = tag 4 + lat_ref = gps_ifd.get(1) + lat_dms = gps_ifd.get(2) + lng_ref = gps_ifd.get(3) + lng_dms = gps_ifd.get(4) + + if lat_dms is None or lng_dms is None: + return None + + lat = _dms_to_decimal(lat_dms, lat_ref or "N") + lng = _dms_to_decimal(lng_dms, lng_ref or "E") + + if lat is None or lng is None: + return None + + return {"lat": lat, "lng": lng} + + +def _save_upload_bytes(contents: bytes, filename: str | None, subdir: str, allowed_exts: set, max_size: int) -> str: + """Save raw bytes to uploads/{subdir}/ with a UUID filename. + + Like _save_upload but accepts pre-read bytes instead of an UploadFile, + so callers can extract EXIF before saving. + """ + ext = Path(filename or "").suffix.lower() + if not ext or ext not in allowed_exts: + allowed = ", ".join(sorted(allowed_exts)) + raise HTTPException(400, f"Invalid file type. Allowed: {allowed}") + + if len(contents) > max_size: + mb = max_size // (1024 * 1024) + raise HTTPException(413, f"File too large. Maximum size: {mb} MB") + + dest_dir = UPLOADS_DIR / subdir + dest_dir.mkdir(parents=True, exist_ok=True) + fname = f"{uuid.uuid4().hex}{ext}" + (dest_dir / fname).write_bytes(contents) + + return f"/uploads/{subdir}/{fname}" + + def _save_upload(upload: UploadFile, subdir: str, allowed_exts: set, max_size: int) -> str: """Save uploaded file to uploads/{subdir}/ with a UUID filename. @@ -2616,8 +2691,15 @@ async def upload_icon(file: UploadFile = File(...)): @app.post("/api/upload/photo", status_code=201) async def upload_photo(file: UploadFile = File(...)): - path = _save_upload(file, "photos", PHOTO_ALLOWED_EXTS, PHOTO_MAX_SIZE) - return {"path": path} + # Read bytes for EXIF extraction before saving + contents = await file.read() + exif_gps = _extract_gps_from_bytes(contents) if contents else None + # Save using the raw bytes + path = _save_upload_bytes(contents, file.filename, "photos", PHOTO_ALLOWED_EXTS, PHOTO_MAX_SIZE) + result = {"path": path} + if exif_gps: + result["exif_gps"] = exif_gps + return result @app.post("/api/ocr", status_code=200) @@ -2625,13 +2707,19 @@ async def ocr_sticker(file: UploadFile = File(...)): """OCR a sticker photo to extract machine_id from XXXXX-XXXXXX format. Accepts an image upload, runs Tesseract OCR, and looks for a pattern like - '12345-678901'. Returns the extracted machine_id or an error. + '12345-678901'. Also extracts EXIF GPS data from the raw file bytes before + processing, returning both in one response. + + Returns {machine_id, exif_gps (if found), raw_text, raw_match, confidence}. """ # Read file contents first (validate size before saving) contents = await file.read() if len(contents) > 20 * 1024 * 1024: # 20MB max raise HTTPException(status_code=400, detail="Image too large (max 20MB)") + # Extract EXIF GPS from raw bytes before any processing + exif_gps = _extract_gps_from_bytes(contents) if contents else None + # Save temp file for OCR — try original extension, fallback to jpg ext = file.filename.rsplit(".", 1)[-1].lower() if "." in (file.filename or "") else "jpg" if ext not in {"png", "jpg", "jpeg", "webp", "bmp", "dng", "tiff", "tif"}: @@ -2653,37 +2741,42 @@ async def ocr_sticker(file: UploadFile = File(...)): # Don't keep temp file after processing temp_path.unlink(missing_ok=True) - # Search for XXXXX-XXXXXX pattern (5 digits - 6 digits or more) + # Build response — search for XXXXX-XXXXXX pattern (5 digits - 6 digits or more) match = re.search(r"(\d{5})[-\s]*(\d{6,})", text) + result: dict = {} + if match: - # Take only the last 5 digits of the matched ID (e.g., "05912-095330" → "95330") full_match = match.group(0) digits_only = re.sub(r"\D", "", full_match) machine_id = digits_only[-5:] - return { + result = { "machine_id": machine_id, "raw_text": text.strip()[:500], "raw_match": full_match, "confidence": "high", } + else: + # Try looser: any 5+ digit number, take the last 5 digits + loose = re.search(r"(\d{5,})", text) + if loose: + digits = loose.group(1) + machine_id = digits[-5:] if len(digits) > 5 else digits + result = { + "machine_id": machine_id, + "raw_text": text.strip()[:500], + "confidence": "low", + } + else: + result = { + "machine_id": None, + "raw_text": text.strip()[:500], + "confidence": "none", + "detail": "No machine ID pattern found in image. Try again with better lighting.", + } - # Try looser: any 5+ digit number, take the last 5 digits - loose = re.search(r"(\d{5,})", text) - if loose: - digits = loose.group(1) - machine_id = digits[-5:] if len(digits) > 5 else digits - return { - "machine_id": machine_id, - "raw_text": text.strip()[:500], - "confidence": "low", - } - - return { - "machine_id": None, - "raw_text": text.strip()[:500], - "confidence": "none", - "detail": "No machine ID pattern found in image. Try again with better lighting.", - } + if exif_gps: + result["exif_gps"] = exif_gps + return result # ─── T4: Connect Label — unified photo + OCR + GPS endpoint ───────────────── @@ -2854,6 +2947,56 @@ def geocode(lat: float = Query(...), lng: float = Query(...)): return result +# ─── Admin endpoints ──────────────────────────────────────────────────────── + + +class AdminQuery(BaseModel): + sql: str + + +@app.post("/api/admin/reset", status_code=200) +def admin_reset(): + """Drop and recreate all tables, reseed defaults. Destructive.""" + conn = get_db() + # Drop all tables (order matters for foreign keys) + tables = [ + "sessions", "geofence_users", "geofences", "visits", + "asset_badges", "asset_keys", "checkins", "assets", + "models", "makes", "badge_types", "key_types", "key_names", + "categories", "rooms", "locations", "customer_contacts", + "customers", "activity_log", "users", "settings", + ] + for table in tables: + conn.execute(f"DROP TABLE IF EXISTS {table}") + conn.commit() + _create_v2_tables(conn) + _seed_data(conn) + _ensure_unique_machine_id(conn) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category)" + ) + conn.commit() + conn.close() + return {"message": "Database reset complete — all tables reinitialized with defaults."} + + +@app.post("/api/admin/query") +def admin_query(body: AdminQuery): + """Run an arbitrary SQL query (SELECT only). Returns rows as JSON.""" + sql = body.sql.strip().upper() + # Only allow SELECT queries + if not sql.startswith("SELECT"): + raise HTTPException(status_code=400, detail="Only SELECT queries are allowed") + conn = get_db() + try: + rows = conn.execute(body.sql).fetchall() + conn.close() + return [dict(r) for r in rows] + except Exception as e: + conn.close() + raise HTTPException(status_code=400, detail=str(e)) + + # ─── Static Files (mounted last to not shadow routes) ────────────────────── app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads") diff --git a/static/index.html b/static/index.html index eb7ddf7..e593e10 100644 --- a/static/index.html +++ b/static/index.html @@ -1149,12 +1149,10 @@ - -
- 📡 Offline mode — changes will sync when connected - + +
+ 📡 Offline mode — changes will sync when connected +
- - - + +
- + + +
- +
-
Scan
+
Barcode Scanner
📷
Tap to start camera
-
-
Point camera at a barcode or sticker
+
Point camera at a barcode
-
- - -
-
- - - @@ -1283,7 +1265,45 @@
- + +
+
+
OCR Scanner — Photograph Sticker
+
+
+ 📸 +
Tap to start camera
+
+ + +
+
Point camera at the machine sticker and tap capture
+
+
+
+
+ + + + +
+ +
Manual Entry
@@ -1391,8 +1411,19 @@
+
+ + + - - -
@@ -2109,10 +2130,6 @@ populateCustomerSelect(); loadBadgeChecklist(); }); - // Start hardware and data loading for auto-login - initGPS(); - startScanning(); - loadAssets(); } catch (e) { // Token expired or invalid — clear it localStorage.removeItem('canteen_session'); @@ -2168,10 +2185,6 @@ populateCustomerSelect(); loadBadgeChecklist(); }); - // Start hardware and data loading - initGPS(); - startScanning(); - loadAssets(); } catch (e) { errEl.textContent = e.message || 'Login failed'; errEl.classList.add('show'); @@ -2371,25 +2384,6 @@ ); } - // Refresh GPS fix on demand (called when user taps Take Photo / Pick from Gallery) - function refreshGPS() { - if (!navigator.geolocation) return; - navigator.geolocation.getCurrentPosition( - pos => { - AppState.gpsLat = pos.coords.latitude; - AppState.gpsLng = pos.coords.longitude; - AppState.gpsAcc = pos.coords.accuracy; - const badge = document.getElementById('gpsBadge'); - if (badge) { - badge.className = 'gps-badge ok'; - badge.textContent = `📍 ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`; - } - }, - () => {}, // silent fail — keep existing fix if user denies - { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 } - ); - } - // ========================================================================= // TOAST // ========================================================================= @@ -2498,7 +2492,7 @@ if (tabId === 'tabAddAsset') startScanning(); else stopScanning(); - if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); renderConnectLabels(); } + if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); } if (tabId === 'tabDashboard') loadDashboard(); if (tabId === 'tabActivity') { loadActivity(); loadActivityUserFilter(); } @@ -2604,6 +2598,7 @@ // Stop any running scanners stopScanning(); + stopOcrCamera(); stopManualPhoto(); if (mode === 'barcode') { @@ -2613,209 +2608,106 @@ // ── Photo Picker / Gallery ──────────────────────────────────────────────── let pickedPhotoFile = null; - let pickedPhotoGps = null; // { lat, lng, accuracy } from EXIF - // Wire the hidden file inputs (camera + gallery — two paths for EXIF GPS workaround) + // Wire the hidden file input (function() { - function wireInput(id) { - const el = document.getElementById(id); - if (el) { - el.addEventListener('change', function(e) { - if (e.target.files && e.target.files[0]) { - handlePickedPhoto(e.target.files[0]); - } - }); - } + 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]); + } + }); } - wireInput('cameraInput'); - wireInput('galleryInput'); })(); function handlePickedPhoto(file) { pickedPhotoFile = file; - pickedPhotoGps = null; - - // Switch to Scan mode - setAddAssetMode('barcode'); - - // Show thumbnail in scan photo preview const reader = new FileReader(); - reader.onerror = function() { - const ocrEl = document.getElementById('scanOcrResult'); - if (ocrEl) ocrEl.textContent = '❌ Could not read file — unsupported format or corrupted. Try a JPEG screenshot instead.'; - const preview = document.getElementById('scanPhotoPreview'); - if (preview) preview.style.display = 'block'; - }; - reader.onload = async function(e) { - const thumb = document.getElementById('scanPhotoThumb'); - if (thumb) thumb.src = e.target.result; - thumb.onerror = function() { - const ocrEl = document.getElementById('scanOcrResult'); - if (ocrEl) ocrEl.textContent = '❌ Image format not supported in browser. Try a JPEG screenshot.'; - thumb.style.display = 'none'; - }; + reader.onload = function(e) { + document.getElementById('pickedPhotoPreview').src = e.target.result; + document.getElementById('photoPreviewCard').style.display = 'block'; - const preview = document.getElementById('scanPhotoPreview'); - if (preview) preview.style.display = 'block'; + // Extract EXIF / file metadata + extractExifData(file); - // Run OCR on the photo - const ocrEl = document.getElementById('scanOcrResult'); - if (ocrEl) ocrEl.textContent = '⏳ Running OCR...'; - - try { - // Try server OCR first (handles more formats like DNG/RAW) - let ocrData = null; - try { - if (ocrEl) ocrEl.textContent = '⏳ Uploading to server OCR...'; - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), 20000); - const fd = new FormData(); - fd.append('file', file, file.name || 'sticker.jpg'); - const res = await fetch('/api/ocr', { - method: 'POST', body: fd, signal: controller.signal, - headers: AppState.authToken - ? { 'Authorization': 'Bearer ' + AppState.authToken } : {}, - }); - clearTimeout(t); - if (res.ok) { - ocrData = await res.json(); - } - } catch (_) { /* server OCR failed, fall through to client */ } - - if (!ocrData || !ocrData.machine_id) { - // Fall back to client-side Tesseract.js - ocrData = await ocrImageClient(file); - } - if (ocrData && ocrData.machine_id) { - document.getElementById('scanMachineId').value = ocrData.machine_id; - if (ocrEl) ocrEl.innerHTML = `✅ Machine ID: ${esc(ocrData.machine_id)}` + - (ocrData.confidence === 'low' ? ' ⚠️ Low confidence' : ''); - // Auto-lookup like barcode scan — route to confirm card or new asset form - handleBarcode(ocrData.machine_id); - } else { - if (ocrEl) ocrEl.innerHTML = '❌ No machine ID detected. '; - } - } catch (ocrErr) { - if (ocrEl) { - const msg = ocrErr.message || ''; - if (msg.includes('unknown error') || msg === 'Client OCR failed: ') { - ocrEl.innerHTML = '❌ Photo format not supported by browser OCR. Use the 📸 live camera instead.'; - } else { - ocrEl.textContent = '❌ OCR failed: ' + msg; - } - } - } - - // Extract GPS from EXIF - const gpsEl = document.getElementById('scanGpsResult'); - try { - const gps = await extractGpsFromPhoto(file); - if (gps && gps.lat != null && gps.lng != null) { - pickedPhotoGps = gps; - if (gpsEl) { - gpsEl.style.display = 'block'; - gpsEl.innerHTML = `📍 GPS: ${gps.lat.toFixed(6)}, ${gps.lng.toFixed(6)}`; - } - } else { - if (gpsEl) { - gpsEl.style.display = 'block'; - gpsEl.innerHTML = '📍 No GPS data in photo'; - } - } - } catch (gpsErr) { - if (gpsEl) { - gpsEl.style.display = 'block'; - gpsEl.innerHTML = '📍 GPS error: ' + gpsErr.message; - } - } + // Scroll to preview + document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' }); }; reader.readAsDataURL(file); } - function createFromScanPhoto() { - // Gather OCR + GPS results from scan photo and create asset - const midEl = document.getElementById('scanMachineId'); - const mid = midEl ? midEl.value : ''; - const name = prompt('Asset name:', ''); - if (!mid || !name) return showToast('Machine ID and name required', true); + 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()) }); - const payload = { machine_id: mid, name }; - if (pickedPhotoGps) { - payload.latitude = pickedPhotoGps.lat; - payload.longitude = pickedPhotoGps.lng; - } else if (AppState.gpsLat) { - payload.latitude = AppState.gpsLat; - payload.longitude = AppState.gpsLng; - } - - api('/api/connect-label', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).then(() => { - showToast('✅ Asset created'); - document.getElementById('scanPhotoPreview').style.display = 'none'; - pickedPhotoFile = null; - pickedPhotoGps = null; - }).catch(e => showToast('Error: ' + e.message, true)); + exifDiv.innerHTML = rows.map(r => + `
${esc(r.label)}${esc(r.value)}
` + ).join(''); + exifDiv.style.display = 'block'; } - - // ▶ Core EXIF GPS extraction using exifr.js - async function extractGpsFromPhoto(file) { - try { - if (typeof exifr === 'undefined') { - console.warn('exifr not loaded'); - return null; - } - const gps = await exifr.gps(file); - if (gps && gps.latitude != null && gps.longitude != null) { - return { - lat: gps.latitude, - lng: gps.longitude, - accuracy: gps.accuracy || null, - }; - } - return null; - } catch (e) { - console.warn('EXIF GPS extraction failed:', e.message); - return null; - } + function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / 1048576).toFixed(1) + ' MB'; } - - // ▶ Render a mini-map preview using Leaflet - function renderGpsPreview(lat, lng, containerEl) { - if (!containerEl) return; - containerEl.innerHTML = ''; - - const wrapper = document.createElement('div'); - wrapper.style.width = '100%'; - wrapper.style.height = '160px'; - wrapper.style.borderRadius = 'var(--radius-sm)'; - wrapper.style.overflow = 'hidden'; - containerEl.appendChild(wrapper); - - const map = L.map(wrapper, { - center: [lat, lng], - zoom: 15, - zoomControl: false, - attributionControl: false, - dragging: false, - scrollWheelZoom: false, - touchZoom: false, - }); - - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - }).addTo(map); - - L.marker([lat, lng]).addTo(map) - .bindPopup(`${lat.toFixed(5)}, ${lng.toFixed(5)}`); - - setTimeout(() => map.invalidateSize(), 300); + 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() { @@ -3015,11 +2907,7 @@ video.style.display = 'block'; scanningActive = true; - // Show the 📸 capture button for text-only stickers (Connect ID, etc.) - const capOverlay = document.getElementById('scanCaptureOverlay'); - if (capOverlay) capOverlay.style.display = 'flex'; - - setScanStatus('Point camera at a barcode, or tap 📸 for text stickers', 'success'); + setScanStatus('Point camera at a barcode', 'success'); // decodeFromVideoDevice handles camera + scanning in one call codeReader.decodeFromVideoDevice(deviceId, video, (result, err) => { @@ -3051,9 +2939,6 @@ ph.style.display = ''; ph.innerHTML = '📷
Tap to start camera
'; } - // Hide the 📸 capture overlay - const capOverlay = document.getElementById('scanCaptureOverlay'); - if (capOverlay) capOverlay.style.display = 'none'; } if (document.getElementById('cameraPlaceholder')) { @@ -3065,55 +2950,6 @@ if (el) { el.textContent = msg; el.className = 'status-bar ' + cls; } } - async function captureScanOcr() { - const video = document.getElementById('cameraVideo'); - if (!video || video.readyState < 2) { - setScanStatus('Camera not ready — tap to start it first', 'error'); - return; - } - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - canvas.getContext('2d').drawImage(video, 0, 0); - const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85)); - setScanStatus('Processing OCR...', 'working'); - - try { - // Try server first with short timeout - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), 6000); - const fd = new FormData(); - fd.append('file', blob, 'sticker.jpg'); - const res = await fetch('/api/ocr', { - method: 'POST', body: fd, signal: controller.signal, - headers: AppState.authToken - ? { 'Authorization': 'Bearer ' + AppState.authToken } : {}, - }); - clearTimeout(t); - if (res.ok) { - const data = await res.json(); - if (data && data.machine_id) { - handleBarcode(data.machine_id); - return; - } - } - throw new Error('Server OCR failed'); - } catch (e) { - // Fall back to client-side Tesseract.js - try { - const data = await ocrImageClient(blob); - if (data && data.machine_id) { - handleBarcode(data.machine_id); - return; - } - } catch (clientErr) { - setScanStatus('OCR failed: ' + clientErr.message, 'error'); - return; - } - } - setScanStatus('No machine ID detected in frame', 'error'); - } - async function handleBarcode(machineId) { if (barcodeDebounce) return; barcodeDebounce = machineId; @@ -3124,49 +2960,10 @@ document.getElementById('newAssetCard').style.display = 'none'; document.getElementById('checkinCard').style.display = 'none'; - // Also OCR the current frame to compare with barcode result - try { - const video = document.getElementById('cameraVideo'); - if (video && video.readyState >= 2) { - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - canvas.getContext('2d').drawImage(video, 0, 0); - const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.8)); - if (blob && blob.size > 0) { - const ocrData = await ocrImageClient(blob); - if (ocrData && ocrData.machine_id) { - console.log(`Barcode: ${machineId}, OCR: ${ocrData.machine_id}`); - // Prefer the one that looks more like a valid machine ID - // Machine ID pattern: 5 digits (last 5 of XXXXX-XXXXXX) - const barcodeDigits = machineId.replace(/\D/g, ''); - const ocrDigits = ocrData.machine_id.replace(/\D/g, ''); - const barcodeValid = barcodeDigits.length >= 5; - const ocrValid = ocrDigits.length >= 5 && ocrData.confidence !== 'low'; - if (ocrValid && !barcodeValid) { - console.log('Using OCR result over barcode'); - machineId = ocrData.machine_id; - currentScannedMachineId = machineId; - } else if (ocrValid && barcodeValid) { - // Both valid — check which matches the XXXXX-XXXXXX pattern better - const barcodeIsId = machineId.includes('-') || /^\d{4,6}$/.test(machineId.trim()); - const ocrIsId = ocrData.machine_id.includes('-') || /^\d{4,6}$/.test(ocrData.machine_id); - if (ocrIsId && !barcodeIsId) { - console.log('OCR result matches ID format better'); - machineId = ocrData.machine_id; - currentScannedMachineId = machineId; - } - } - } - } - } - } catch (ocrErr) { - console.warn('OCR comparison failed:', ocrErr.message); - } - try { const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`); - showScanConfirm(asset, machineId); + autoCheckin(asset.id); + showScannedAsset(asset); } catch (e) { if (e.message === 'Asset not found' || e.message.includes('404')) { showNewAssetForm(machineId); @@ -3197,78 +2994,6 @@ `; } - function showScanConfirm(asset, machineId) { - AppState.currentAssetId = asset.id; - setScanStatus('Confirm machine ID', 'info'); - - const el = document.getElementById('scanResult'); - el.style.display = 'block'; - el.innerHTML = ` -
${esc(asset.name)}
-
- Machine ID: ${esc(machineId)} · ${esc(asset.category || 'Other')} · - ${asset.status || 'active'} -
-
- - -
- `; - } - - async function confirmScanCheckin(assetId) { - setScanStatus('Checking in...', 'working'); - await autoCheckin(assetId); - const asset = await api(`/api/assets/${assetId}`); - showScannedAsset(asset); - } - - function showMachineIdEditor(currentId) { - const el = document.getElementById('scanResult'); - el.innerHTML = ` -
Edit machine ID:
-
- - -
- -
- -
- `; - document.getElementById('editMachineIdInput').focus(); - document.getElementById('editMachineIdInput').select(); - } - - async function relookupMachineId() { - const newId = document.getElementById('editMachineIdInput').value.trim(); - if (!newId) return; - setScanStatus('Re-looking up...', 'working'); - try { - const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(newId)}`); - showScanConfirm(asset, newId); - } catch (e) { - if (e.message === 'Asset not found' || e.message.includes('404')) { - showNewAssetForm(newId); - } else { - document.getElementById('editMachineIdError').textContent = 'Lookup error: ' + e.message; - document.getElementById('editMachineIdError').style.display = 'block'; - } - } - } - - function showScanConfirmFromEdit() { - const assetId = AppState.currentAssetId; - if (!assetId) return; - api(`/api/assets/${assetId}`).then(asset => { - showScanConfirm(asset, asset.machine_id); - }).catch(() => { - setScanStatus('Could not reload asset', 'error'); - }); - } - function showNewAssetForm(machineId) { currentScannedMachineId = machineId; setScanStatus('Machine ID not in database — create asset below', 'error'); @@ -3319,12 +3044,6 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - if (asset._queued) { - // Queued for offline sync — skip checkin and detail view - showToast('📴 Queued offline — will sync when connected'); - document.getElementById('newAssetCard').style.display = 'none'; - return; - } showToast('Asset created!'); document.getElementById('newAssetCard').style.display = 'none'; AppState.currentAssetId = asset.id; @@ -3364,7 +3083,6 @@ // ── Auto check-in on asset creation (GPS grab) ────────────────────────── async function autoCheckin(assetId) { - if (!assetId) return; // Skip if asset was queued offline if (!AppState.gpsLat) return; // No GPS — skip silently try { await api('/api/checkins', { @@ -3499,8 +3217,7 @@ result = await Promise.race([ocrPromise, timeoutPromise]); } catch (e) { updateOcrProgress({ status: 'error' }); - const reason = (e && e.message) ? e.message : (e ? String(e) : 'image format not supported by browser'); - throw new Error('Client OCR failed: ' + reason); + throw new Error('Client OCR failed: ' + (e.message || 'unknown error')); } updateOcrProgress({ status: 'done' }); @@ -3637,13 +3354,6 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - if (asset._queued) { - showToast('📴 Queued offline — will sync when connected'); - document.getElementById('ocrCreateCard').style.display = 'none'; - document.getElementById('ocrResult').style.display = 'none'; - setOcrStatus('Ready — take another photo', 'success'); - return; - } showToast('Asset created!'); document.getElementById('ocrCreateCard').style.display = 'none'; document.getElementById('ocrResult').style.display = 'none'; @@ -3657,397 +3367,6 @@ } } - // ═══════════════════════════════════════════════════════════════════════ - // T4: CONNECT LABEL — unified photo + OCR + GPS - // ═══════════════════════════════════════════════════════════════════════ - let connectStream = null; - let connectFile = null; - let connectGps = null; // { lat, lng, accuracy } - let connectOcrData = null; // { machine_id, confidence, raw_text } - - async function connectFromCamera() { - // Stop any existing streams - stopOcrCamera(); - stopManualPhoto(); - - const area = document.getElementById('connectCameraArea'); - const video = document.getElementById('connectVideo'); - const placeholder = document.getElementById('connectCameraPlaceholder'); - const overlay = document.getElementById('connectCaptureOverlay'); - const results = document.getElementById('connectResults'); - const status = document.getElementById('connectStatus'); - - area.style.display = 'block'; - results.style.display = 'none'; - status.style.display = 'none'; - status.textContent = ''; - placeholder.style.display = 'flex'; - video.style.display = 'none'; - overlay.style.display = 'none'; - - // Stop any previous connect stream - if (connectStream) { - connectStream.getTracks().forEach(t => t.stop()); - connectStream = null; - } - - try { - connectStream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } }, - audio: false, - }); - video.srcObject = connectStream; - video.style.display = 'block'; - placeholder.style.display = 'none'; - overlay.style.display = 'flex'; - } catch (e) { - showToast('Camera error: ' + e.message, true); - area.style.display = 'none'; - } - } - - function connectFromGallery() { - window._connectMode = true; - const gi = document.getElementById('galleryInput'); - if (gi) gi.click(); - } - - async function captureConnectPhoto() { - const video = document.getElementById('connectVideo'); - if (!video || !connectStream) return; - - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - canvas.getContext('2d').drawImage(video, 0, 0); - const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85)); - - // Stop the camera - stopConnectCamera(); - - // Process the photo - await processConnectLabelPhoto(new File([blob], 'sticker.jpg', { type: 'image/jpeg' })); - } - - function stopConnectCamera() { - if (connectStream) { - connectStream.getTracks().forEach(t => t.stop()); - connectStream = null; - } - document.getElementById('connectVideo').srcObject = null; - document.getElementById('connectVideo').style.display = 'none'; - document.getElementById('connectCameraArea').style.display = 'none'; - document.getElementById('connectCaptureOverlay').style.display = 'none'; - document.getElementById('connectCameraPlaceholder').style.display = 'none'; - } - - // ▶ Core: process photo via OCR + GPS extraction - async function processConnectLabelPhoto(file) { - connectFile = file; - connectGps = null; - connectOcrData = null; - - // Show status - const statusEl = document.getElementById('connectStatus'); - statusEl.style.display = 'block'; - statusEl.className = 'status-bar working'; - statusEl.textContent = '⏳ Processing photo...'; - document.getElementById('connectResults').style.display = 'none'; - - // Show thumbnail - const reader = new FileReader(); - reader.onload = function(e) { - document.getElementById('connectThumb').src = e.target.result; - }; - reader.readAsDataURL(file); - - // Run OCR and GPS in parallel - const [ocrResult, gpsResult] = await Promise.allSettled([ - runConnectOcr(file), - extractGpsFromPhoto(file), - ]); - - // Process OCR result - if (ocrResult.status === 'fulfilled' && ocrResult.value) { - connectOcrData = ocrResult.value; - } else { - connectOcrData = { machine_id: '', confidence: 'none', raw_text: 'OCR failed' }; - } - - // Process GPS result - if (gpsResult.status === 'fulfilled' && gpsResult.value) { - connectGps = gpsResult.value; - } else { - connectGps = null; - } - - // Render results - renderConnectResults(); - } - - // ▶ Run OCR for connect mode (server first, client fallback) - async function runConnectOcr(file) { - // Try server-side OCR first - try { - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), 8000); - const formData = new FormData(); - formData.append('file', file, 'sticker.jpg'); - const res = await fetch('/api/ocr', { - method: 'POST', - body: formData, - signal: controller.signal, - headers: AppState.authToken - ? { 'Authorization': 'Bearer ' + AppState.authToken } - : {}, - }); - clearTimeout(t); - if (res.ok) { - const data = await res.json(); - data.method = 'server'; - return data; - } - throw new Error('Server returned ' + res.status); - } catch (e) { - // Fall back to client-side Tesseract.js - try { - const data = await ocrImageClient(file); - return data; - } catch (clientErr) { - return { machine_id: '', confidence: 'none', raw_text: 'OCR unavailable', method: 'client' }; - } - } - } - - function renderConnectResults() { - const resultsEl = document.getElementById('connectResults'); - const statusEl = document.getElementById('connectStatus'); - resultsEl.style.display = 'block'; - - // OCR result line - const ocrEl = document.getElementById('connectOcrResult'); - if (connectOcrData && connectOcrData.machine_id) { - const methodBadge = connectOcrData.method === 'client' - ? '📡 Offline' : ''; - ocrEl.innerHTML = ` -
- ✅ Machine ID found${methodBadge} -
-
- ${esc(connectOcrData.machine_id)} - ${connectOcrData.confidence === 'low' ? ' ⚠️ Low confidence — verify' : ''} -
`; - document.getElementById('connectMachineId').value = connectOcrData.machine_id; - statusEl.className = 'status-bar success'; - statusEl.textContent = '✅ Processing complete'; - } else { - ocrEl.innerHTML = ` -
- ❌ No ID detected -
-
Enter machine ID manually below.
`; - document.getElementById('connectMachineId').value = ''; - statusEl.className = 'status-bar error'; - statusEl.textContent = 'OCR failed — enter ID manually'; - } - - // GPS result line - const gpsEl = document.getElementById('connectGpsResult'); - const miniMapEl = document.getElementById('connectMiniMap'); - if (connectGps) { - gpsEl.innerHTML = ` -
- 📍 GPS from photo -
-
- ${connectGps.lat.toFixed(6)}, ${connectGps.lng.toFixed(6)} - ${connectGps.accuracy ? ` (±${connectGps.accuracy.toFixed(1)}m)` : ''} -
`; - document.getElementById('connectLat').value = connectGps.lat.toFixed(6); - document.getElementById('connectLng').value = connectGps.lng.toFixed(6); - miniMapEl.style.display = 'block'; - setTimeout(() => renderGpsPreview(connectGps.lat, connectGps.lng, miniMapEl), 100); - } else { - gpsEl.innerHTML = ` -
- 📍 No GPS in photo -
-
- Phone may have stripped EXIF GPS. Enter coordinates manually or skip. -
`; - document.getElementById('connectLat').value = ''; - document.getElementById('connectLng').value = ''; - miniMapEl.style.display = 'none'; - } - - // Populate category select - populateCategorySelect('connectCatSelect'); - document.getElementById('connectName').value = ''; - document.getElementById('connectName').focus(); - - // Save to recent connect labels - saveConnectLabel(connectOcrData ? connectOcrData.machine_id : '', connectGps); - } - - async function createConnectAsset() { - const machineId = document.getElementById('connectMachineId').value.trim(); - const name = document.getElementById('connectName').value.trim(); - if (!machineId) { showToast('Machine ID is required', true); return; } - if (!name) { showToast('Asset name is required', true); return; } - - const latStr = document.getElementById('connectLat').value.trim(); - const lngStr = document.getElementById('connectLng').value.trim(); - - const payload = { - machine_id: machineId, - name: name, - category: document.getElementById('connectCatSelect').value || 'Other', - status: 'active', - }; - - if (latStr && lngStr) { - payload.latitude = parseFloat(latStr); - payload.longitude = parseFloat(lngStr); - } - - // Try reverse geocode for address - if (payload.latitude && payload.longitude) { - try { - const geo = await reverseGeocode(payload.latitude, payload.longitude); - if (geo) { - payload.address = geo.address || geo.formatted || ''; - payload.building_name = geo.building_name || ''; - payload.building_number = geo.building_number || ''; - } - } catch (e) { /* ignore */ } - } - - // Upload photo if available - if (connectFile) { - try { - const formData = new FormData(); - formData.append('file', connectFile); - const uploadRes = await fetch('/api/upload/photo', { - method: 'POST', - body: formData, - headers: AppState.authToken - ? { 'Authorization': 'Bearer ' + AppState.authToken } - : {}, - }); - if (uploadRes.ok) { - const uploadData = await uploadRes.json(); - payload.photo_path = uploadData.path; - } - } catch (e) { /* photo upload is optional */ } - } - - try { - const asset = await api('/api/assets', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (asset._queued) { - showToast('📴 Queued offline — will sync when connected'); - } else { - showToast('✅ Asset created!'); - AppState.currentAssetId = asset.id; - autoCheckin(asset.id); - } - // Switch to assets tab - switchTab('tabAssets'); - loadAssets(); - } catch (e) { - showToast(e.message, true); - } - } - - function resetConnectMode() { - connectFile = null; - connectGps = null; - connectOcrData = null; - document.getElementById('connectResults').style.display = 'none'; - document.getElementById('connectStatus').style.display = 'none'; - document.getElementById('connectStatus').textContent = ''; - document.getElementById('connectThumb').src = ''; - document.getElementById('connectMachineId').value = ''; - document.getElementById('connectName').value = ''; - document.getElementById('connectLat').value = ''; - document.getElementById('connectLng').value = ''; - const miniMapEl = document.getElementById('connectMiniMap'); - miniMapEl.innerHTML = ''; - miniMapEl.style.display = 'none'; - stopConnectCamera(); - } - - // ── Connect Label History ─────────────────────────────────────────────── - function saveConnectLabel(machineId, gps) { - try { - const labels = JSON.parse(localStorage.getItem('connectLabels') || '[]'); - labels.unshift({ - machine_id: machineId || 'Manual entry', - lat: gps ? gps.lat : null, - lng: gps ? gps.lng : null, - time: new Date().toISOString(), - }); - // Keep last 20 - if (labels.length > 20) labels.length = 20; - localStorage.setItem('connectLabels', JSON.stringify(labels)); - } catch (e) { /* ignore storage errors */ } - } - - function renderConnectLabels() { - const section = document.getElementById('connectLabelSection'); - const list = document.getElementById('connectLabelList'); - try { - const labels = JSON.parse(localStorage.getItem('connectLabels') || '[]'); - if (!labels.length) { - section.style.display = 'none'; - return; - } - section.style.display = 'block'; - list.innerHTML = labels.map((l, i) => { - const gpsStr = l.lat && l.lng - ? `📍 (${l.lat.toFixed(4)}, ${l.lng.toFixed(4)})` - : '📍 No GPS'; - const timeStr = new Date(l.time).toLocaleString(); - return `
- ${esc(l.machine_id)} - ${gpsStr} - ${timeStr} -
`; - }).join(''); - } catch (e) { - section.style.display = 'none'; - } - } - - function clearConnectLabels() { - localStorage.removeItem('connectLabels'); - document.getElementById('connectLabelSection').style.display = 'none'; - document.getElementById('connectLabelList').innerHTML = ''; - } - - // ── Hook into gallery/camera pickers for connect mode ───────────────── - (function() { - function hookPicker(elId) { - const el = document.getElementById(elId); - if (!el) return; - el.addEventListener('change', function(e) { - if (window._connectMode) { - window._connectMode = false; - if (e.target.files && e.target.files[0]) { - processConnectLabelPhoto(e.target.files[0]); - } - el.value = ''; // Reset so same file can be picked again - return; - } - }); - } - hookPicker('cameraInput'); - hookPicker('galleryInput'); - })(); - // ═══════════════════════════════════════════════════════════════════════ // MANUAL MODE — Photo // ═══════════════════════════════════════════════════════════════════════ @@ -4178,29 +3497,6 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - if (asset._queued) { - showToast('📴 Queued offline — will sync when connected'); - manualPhotoBlob = null; - if (addAnother) { - // Clear form for next entry - document.getElementById('manMachineId').value = ''; - document.getElementById('manName').value = ''; - document.getElementById('manDescription').value = ''; - document.getElementById('manCatSelect').value = ''; - document.getElementById('manMake').value = ''; - document.getElementById('manModel').value = ''; - document.getElementById('manStatus').value = 'active'; - document.getElementById('manAddress').value = ''; - document.getElementById('manBuildingName').value = ''; - document.getElementById('manBuildingNumber').value = ''; - document.getElementById('manFloor').value = ''; - document.getElementById('manRoom').value = ''; - document.getElementById('manPhotoPreview').style.display = 'none'; - document.getElementById('manPhotoRetake').style.display = 'none'; - document.getElementById('manMachineId').focus(); - } - return; - } showToast('Asset created!'); AppState.currentAssetId = asset.id; autoCheckin(asset.id); @@ -7299,8 +6595,10 @@ const OFFLINE_DB_NAME = 'CanteenOfflineDB'; const OFFLINE_DB_VERSION = 1; const OFFLINE_STORE = 'offlineQueue'; + let offlineDB = null; + /** Open (or create) the IndexedDB for offline queue storage. */ function openOfflineDB() { if (offlineDB) return Promise.resolve(offlineDB); return new Promise((resolve, reject) => { @@ -7317,6 +6615,8 @@ req.onerror = (e) => { reject(e.target.error); }; }); } + + /** Store a create-asset request + optional photo blob in the offline queue. */ async function queueOfflineCreate(assetPayload, photoBlob) { await openOfflineDB(); const entry = { @@ -7340,6 +6640,7 @@ }); } + /** Get count of pending queued items. */ async function getQueueCount() { try { await openOfflineDB(); @@ -7354,11 +6655,13 @@ } catch (e) { return 0; } } + /** Update the queue indicator UI. */ async function updateQueueUI() { const count = await getQueueCount(); const qi = document.getElementById('queueIndicator'); const qc = document.getElementById('queueIndicatorCount'); const qb = document.getElementById('queueCountBadge'); + if (count > 0) { qi.classList.add('show'); qc.textContent = count; @@ -7369,6 +6672,7 @@ } } + /** Upload a stored photo blob and return the server path. */ async function uploadQueuedPhoto(blob) { const fd = new FormData(); fd.append('file', blob, 'photo.jpg'); @@ -7376,7 +6680,9 @@ return upData.path; } + /** Process a single queued item. Returns true on success. */ async function processQueueItem(item) { + // Mark as syncing await openOfflineDB(); await new Promise((resolve) => { const tx = offlineDB.transaction(OFFLINE_STORE, 'readwrite'); @@ -7384,29 +6690,42 @@ const req = store.get(item.id); req.onsuccess = () => { const record = req.result; - if (record) { record.status = 'syncing'; store.put(record); } + if (record) { + record.status = 'syncing'; + store.put(record); + } resolve(); }; req.onerror = () => resolve(); }); try { + // If there's a photo blob, upload it first let photoPath = null; if (item.photoBlob) { - try { photoPath = await uploadQueuedPhoto(item.photoBlob); } - catch (e) { console.warn('Queue: photo upload failed, continuing:', e.message); } + try { + photoPath = await uploadQueuedPhoto(item.photoBlob); + } catch (e) { + console.warn('Queue: photo upload failed, continuing without photo:', e.message); + } } + + // Parse and update the payload with photo path const payload = JSON.parse(item.body); if (photoPath) payload.photo_path = photoPath; if (item.checkinGps) { payload.latitude = item.checkinGps.lat; payload.longitude = item.checkinGps.lng; } + + // Create the asset const asset = await api('/api/assets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); + + // Auto check-in if we had GPS if (item.checkinGps && asset && asset.id) { try { await api('/api/checkins', { @@ -7420,8 +6739,10 @@ notes: 'Auto check-in on sync', }), }); - } catch (e) { /* best-effort */ } + } catch (e) { /* best-effort checkin */ } } + + // Mark as done await openOfflineDB(); await new Promise((resolve) => { const tx = offlineDB.transaction(OFFLINE_STORE, 'readwrite'); @@ -7429,19 +6750,25 @@ const req = store.get(item.id); req.onsuccess = () => { const record = req.result; - if (record) { record.status = 'done'; store.put(record); } + if (record) { + record.status = 'done'; + store.put(record); + } resolve(); }; req.onerror = () => resolve(); }); return true; } catch (e) { + // Check for conflict (409 / duplicate machine_id) const isConflict = e.message && ( e.message.toLowerCase().includes('duplicate') || e.message.toLowerCase().includes('unique') || e.message.toLowerCase().includes('already exists') || e.message.toLowerCase().includes('conflict') ); + + // Mark as failed (or done if conflict — don't retry duplicates) await openOfflineDB(); await new Promise((resolve) => { const tx = offlineDB.transaction(OFFLINE_STORE, 'readwrite'); @@ -7459,18 +6786,22 @@ }; req.onerror = () => resolve(); }); - return isConflict; + return isConflict; // conflicts count as "handled" } } + /** Process all pending items in the offline queue. */ async function processOfflineQueue() { if (!navigator.onLine) { showToast('Still offline — will sync when connected', true); return; } + await openOfflineDB(); const qi = document.getElementById('queueIndicator'); qi.classList.add('syncing'); + + // Get all pending items const pendingItems = await new Promise((resolve) => { const tx = offlineDB.transaction(OFFLINE_STORE, 'readonly'); const store = tx.objectStore(OFFLINE_STORE); @@ -7480,45 +6811,74 @@ }; req.onerror = () => resolve([]); }); + if (pendingItems.length === 0) { qi.classList.remove('syncing'); updateQueueUI(); return; } - showToast('Syncing ' + pendingItems.length + ' offline item(s)...'); + + showToast(`Syncing ${pendingItems.length} offline item(s)...`); + let synced = 0; for (const item of pendingItems) { - if (item.retries >= 5) { continue; } + if (item.retries >= 5) { + // Give up after 5 retries + await openOfflineDB(); + await new Promise((resolve) => { + const tx = offlineDB.transaction(OFFLINE_STORE, 'readwrite'); + const store = tx.objectStore(OFFLINE_STORE); + const req = store.get(item.id); + req.onsuccess = () => { + const record = req.result; + if (record) { record.status = 'failed'; store.put(record); } + resolve(); + }; + req.onerror = () => resolve(); + }); + continue; + } + const ok = await processQueueItem(item); if (ok) synced++; } + qi.classList.remove('syncing'); updateQueueUI(); + if (synced > 0) { - showToast('Synced ' + synced + ' item(s)!'); + showToast(`Synced ${synced} item(s)!`); + // Refresh asset list if we're on that tab if (AppState.currentTab === 'tabAssets') loadAssets(); } else if (pendingItems.length > synced) { - showToast((pendingItems.length - synced) + ' item(s) failed to sync', true); + showToast(`${pendingItems.length - synced} item(s) failed to sync`, true); } } + // ── Offline/Online detection ─────────────────────────────────────────── + function showOfflineBanner() { document.getElementById('offlineBanner').classList.add('show'); } function hideOfflineBanner() { document.getElementById('offlineBanner').classList.remove('show'); + // Auto-sync when coming back online processOfflineQueue(); } + // ── Service Worker registration ──────────────────────────────────────── + async function registerServiceWorker() { if (!('serviceWorker' in navigator)) { - console.warn('Service Worker not supported'); + console.warn('Service Worker not supported — offline mode disabled'); return; } try { const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' }); console.log('[SW] Registered, scope:', reg.scope); + + // Listen for sync messages from SW navigator.serviceWorker.addEventListener('message', (event) => { if (event.data && event.data.type === 'PROCESS_OFFLINE_QUEUE') { processOfflineQueue(); @@ -7529,17 +6889,21 @@ } } + // ── Override api() to queue offline writes ───────────────────────────── + const _originalApi = api; - window.api = async function apiWithOffline(url, opts) { - if (!opts) opts = {}; + window.api = async function apiWithOffline(url, opts = {}) { const method = (opts.method || 'GET').toUpperCase(); const isWrite = ['POST', 'PUT', 'DELETE'].includes(method); const isAssetCreate = url === '/api/assets' && method === 'POST'; + const isCheckin = url === '/api/checkins' && method === 'POST'; + // For non-write or when online, just pass through if (!isWrite || navigator.onLine) { try { return await _originalApi(url, opts); } catch (e) { + // If it failed with a network error and we're offline, queue it if (isAssetCreate && (e.message === 'Network error — check your connection' || e.name === 'TypeError')) { return await _handleOfflineAssetCreate(opts); } @@ -7547,99 +6911,341 @@ } } + // We're offline — queue asset creates if (isAssetCreate) { return await _handleOfflineAssetCreate(opts); } - if (isWrite) { + // For other writes while offline, show a toast + if (isWrite && !isCheckin) { showToast('Offline — changes to assets will sync when connected', true); - throw new Error('Offline — unable to save.'); + throw new Error('Offline — unable to save. Asset creation is queued automatically.'); } - throw new Error('Offline'); + // Let checkins fail silently (they can be redone later) + throw new Error('Offline — check in later'); }; + /** Extract asset payload from opts and queue it. Handles manual-mode photo blobs. */ async function _handleOfflineAssetCreate(opts) { let payload, photoBlob = null; + if (typeof opts.body === 'string') { try { payload = JSON.parse(opts.body); } catch (e) { payload = {}; } } else if (opts.body instanceof FormData) { + // This shouldn't happen for asset create (it's JSON), but handle gracefully payload = {}; } else { payload = opts.body || {}; } + + // Check for manual photo blob in the global variable if (typeof manualPhotoBlob !== 'undefined' && manualPhotoBlob) { photoBlob = manualPhotoBlob; + // Clear it so it doesn't get reused manualPhotoBlob = null; } + await queueOfflineCreate(payload, photoBlob); showToast('📴 Queued offline — will sync when connected'); showOfflineBanner(); + + // Return a fake asset so the calling code doesn't crash return { id: null, _queued: true, machine_id: payload.machine_id, name: payload.name }; } - + // ── Init offline support ──────────────────────────────────────────────── async function initOfflineSupport() { + // Register SW await registerServiceWorker(); + + // Open DB and update UI await openOfflineDB(); await updateQueueUI(); - window.addEventListener('online', () => { hideOfflineBanner(); }); - window.addEventListener('offline', () => { showOfflineBanner(); }); - if (!navigator.onLine) { showOfflineBanner(); } + + // Online/offline events + window.addEventListener('online', () => { + console.log('[Offline] Back online — processing queue'); + hideOfflineBanner(); + }); + + window.addEventListener('offline', () => { + console.log('[Offline] Connection lost'); + showOfflineBanner(); + }); + + // Show banner immediately if already offline + if (!navigator.onLine) { + showOfflineBanner(); + } } - window.__scriptLineReached = 7593; // ═══════════════════════════════════════════════════════════════════════ - // T3: Hook EXIF GPS extraction into camera capture + // INIT // ═══════════════════════════════════════════════════════════════════════ - (function() { - const _origCapture = captureManualPhoto; - captureManualPhoto = async function() { - await _origCapture(); - if (manualPhotoBlob) { - try { - const gps = await extractGpsFromPhoto(manualPhotoBlob); - if (gps) { - const coords = gps.lat.toFixed(6) + ', ' + gps.lng.toFixed(6); - const parkingEl = document.getElementById('manParkingLocation'); - if (parkingEl && !parkingEl.value.trim()) { - parkingEl.value = coords; - } - const mapLinkEl = document.getElementById('manMapLink'); - if (mapLinkEl && !mapLinkEl.value.trim()) { - mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${gps.lat}&mlon=${gps.lng}&zoom=17`; - } - let gpsTag = document.getElementById('manPhotoGpsTag'); - if (!gpsTag) { - gpsTag = document.createElement('span'); - gpsTag.id = 'manPhotoGpsTag'; - gpsTag.className = 'gps-badge ok'; - gpsTag.style.marginLeft = '8px'; - gpsTag.textContent = '📍 GPS from photo'; - const title = document.querySelector('#addManualMode .form-section-title'); - if (title) title.appendChild(gpsTag); - } - gpsTag.style.display = 'inline-flex'; - showToast('📍 GPS extracted from photo!'); - } - } catch (e) { /* GPS extraction is non-critical */ } - } - }; - - const _origRetake = retakeManualPhoto; - retakeManualPhoto = function() { - _origRetake(); - const gpsTag = document.getElementById('manPhotoGpsTag'); - if (gpsTag) gpsTag.style.display = 'none'; - }; - })(); - - // ═══════════════════════════════════════════════════════════════════════ - // INIT — only lightweight auth & offline. Camera/GPS/assets start after login. - // ═══════════════════════════════════════════════════════════════════════ - initOfflineSupport().catch(e => console.warn('[Offline] init failed:', e)); + initOfflineSupport(); initAuth(); + initGPS(); + startScanning(); + loadAssets(); + let pickedPhotoGps = null; // { lat, lng, accuracy } or null + + // ▶ Called when user picks a photo from gallery + async function handlePickedPhoto(event) { + const file = event.target.files[0]; + if (!file) return; + + pickedPhotoFile = file; + pickedPhotoGps = null; + + // Show preview + const url = URL.createObjectURL(file); + const preview = document.getElementById('pickedPhotoPreview'); + preview.src = url; + document.getElementById('photoPreviewCard').style.display = 'block'; + + // Clear any previous EXIF data display + const exifEl = document.getElementById('pickedExifData'); + exifEl.style.display = 'none'; + exifEl.innerHTML = ''; + + // Try EXIF GPS extraction immediately + await tryExtractGpsFromPicked(); + } + + // ▶ Core EXIF GPS extraction using exifr.js + async function extractGpsFromPhoto(file) { + try { + if (typeof exifr === 'undefined') { + console.warn('exifr not loaded'); + return null; + } + const gps = await exifr.gps(file); + if (gps && gps.latitude != null && gps.longitude != null) { + return { + lat: gps.latitude, + lng: gps.longitude, + accuracy: gps.accuracy || null, + }; + } + return null; + } catch (e) { + console.warn('EXIF GPS extraction failed:', e.message); + return null; + } + } + + // ▶ Render a mini-map preview using Leaflet + function renderGpsPreview(lat, lng, containerEl) { + if (!containerEl) return; + containerEl.innerHTML = ''; + + const wrapper = document.createElement('div'); + wrapper.style.width = '100%'; + wrapper.style.height = '180px'; + wrapper.style.borderRadius = 'var(--radius-sm)'; + wrapper.style.overflow = 'hidden'; + wrapper.style.marginTop = '8px'; + containerEl.appendChild(wrapper); + + // Leaflet needs the container to be in the DOM and sized before init + const map = L.map(wrapper, { + center: [lat, lng], + zoom: 15, + zoomControl: false, + attributionControl: false, + dragging: false, + scrollWheelZoom: false, + touchZoom: false, + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + }).addTo(map); + + L.marker([lat, lng]).addTo(map) + .bindPopup(`${lat.toFixed(5)}, ${lng.toFixed(5)}`); + + // Fix tile loading — invalidateSize after tiles load + setTimeout(() => map.invalidateSize(), 300); + } + + // ▶ Called when "📍 Extract GPS" button is clicked in photo preview + async function extractGpsFromPicked() { + if (!pickedPhotoFile) { + showToast('No photo picked', true); + return; + } + await tryExtractGpsFromPicked(); + } + + // ▶ Internal: try to extract GPS from the currently picked photo + async function tryExtractGpsFromPicked() { + if (!pickedPhotoFile) return; + const exifEl = document.getElementById('pickedExifData'); + + exifEl.style.display = 'block'; + exifEl.innerHTML = '⏳ Reading EXIF GPS...'; + + const gps = await extractGpsFromPhoto(pickedPhotoFile); + pickedPhotoGps = gps; + + if (gps) { + exifEl.innerHTML = ` +
+ 📍 GPS from photo +
+
+ ${gps.lat.toFixed(6)}, ${gps.lng.toFixed(6)} + ${gps.accuracy ? ` (±${gps.accuracy.toFixed(1)}m)` : ''} +
+
+
+ + +
+
+ Tap "Fill Manual Form" to populate parking & directions, then edit as needed +
+ `; + // Render mini-map + const miniMapEl = document.getElementById('gpsMiniMap'); + if (miniMapEl) { + setTimeout(() => renderGpsPreview(gps.lat, gps.lng, miniMapEl), 100); + } + } else { + exifEl.innerHTML = ` + 📍 No GPS in photo +
+ This photo has no GPS data. Phones often strip EXIF GPS when sharing screenshots or from some apps. + You can manually enter coordinates in the Manual form. +
+ `; + } + } + + // ▶ Fill the manual form fields with extracted GPS + function fillManualFromGps() { + if (!pickedPhotoGps) { + showToast('No GPS data to fill', true); + return; + } + + const coords = pickedPhotoGps.lat.toFixed(6) + ', ' + pickedPhotoGps.lng.toFixed(6); + + // Switch to manual mode + setAddAssetMode('manual'); + + // Fill parking location + const parkingEl = document.getElementById('manParkingLocation'); + if (parkingEl && !parkingEl.value.trim()) { + parkingEl.value = coords; + } + + // Fill map link with OpenStreetMap URL + const mapLinkEl = document.getElementById('manMapLink'); + if (mapLinkEl && !mapLinkEl.value.trim()) { + mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${pickedPhotoGps.lat}&mlon=${pickedPhotoGps.lng}&zoom=17`; + } + + showToast('📍 GPS coordinates filled! Edit if needed.'); + } + + // ▶ Open coordinates in Google Maps + function openInMaps() { + if (!pickedPhotoGps) return; + const url = `https://www.google.com/maps?q=${pickedPhotoGps.lat},${pickedPhotoGps.lng}`; + window.open(url, '_blank'); + } + + // ▶ OCR the picked photo — stub (T2 territory, button exists in UI) + function ocrPickedPhoto() { + if (!pickedPhotoFile) { + showToast('No photo picked', true); + return; + } + showToast('OCR scanning not yet implemented', true); + } + + // ▶ Create asset from picked photo — fills manual form + function createAssetFromPicked() { + // Switch to manual mode with prefilled data + setAddAssetMode('manual'); + if (pickedPhotoGps) { + fillManualFromGps(); + } + // Scroll to manual form + document.getElementById('addManualMode').scrollIntoView({ behavior: 'smooth' }); + } + + // ▶ Clear picked photo and reset state + function clearPickedPhoto() { + pickedPhotoFile = null; + pickedPhotoGps = null; + document.getElementById('pickedPhotoPreview').src = ''; + document.getElementById('photoPreviewCard').style.display = 'none'; + document.getElementById('pickedExifData').style.display = 'none'; + document.getElementById('pickedExifData').innerHTML = ''; + // Reset file input so same file can be re-picked + document.getElementById('photoPicker').value = ''; + } + + // ═══════════════════════════════════════════════════════════════════════ + // HOOK: EXIF GPS extraction after camera capture in manual mode + // ═══════════════════════════════════════════════════════════════════════ + // Patch captureManualPhoto to also try EXIF GPS extraction + const _origCaptureManualPhoto = captureManualPhoto; + captureManualPhoto = async function() { + await _origCaptureManualPhoto(); + // After capture, try EXIF GPS from the blob + if (manualPhotoBlob) { + try { + const gps = await extractGpsFromPhoto(manualPhotoBlob); + if (gps) { + const coords = gps.lat.toFixed(6) + ', ' + gps.lng.toFixed(6); + const parkingEl = document.getElementById('manParkingLocation'); + if (parkingEl && !parkingEl.value.trim()) { + parkingEl.value = coords; + } + const mapLinkEl = document.getElementById('manMapLink'); + if (mapLinkEl && !mapLinkEl.value.trim()) { + mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${gps.lat}&mlon=${gps.lng}&zoom=17`; + } + // Show GPS badge near photo + const photoSection = document.querySelector('#addManualMode .form-section'); + let gpsTag = document.getElementById('manPhotoGpsTag'); + if (!gpsTag) { + gpsTag = document.createElement('span'); + gpsTag.id = 'manPhotoGpsTag'; + gpsTag.className = 'gps-badge ok'; + gpsTag.style.marginLeft = '8px'; + const title = document.querySelector('#addManualMode .form-section-title'); + if (title) title.appendChild(gpsTag); + } + gpsTag.textContent = '📍 GPS from photo'; + gpsTag.style.display = 'inline-flex'; + showToast('📍 GPS extracted from photo!'); + } + } catch (e) { + // GPS extraction failure is non-critical + console.warn('EXIF GPS from capture failed:', e.message); + } + } + }; + + // Patch retakeManualPhoto to clear GPS tag + const _origRetakeManualPhoto = retakeManualPhoto; + retakeManualPhoto = function() { + _origRetakeManualPhoto(); + const gpsTag = document.getElementById('manPhotoGpsTag'); + if (gpsTag) gpsTag.style.display = 'none'; + }; +