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
-
0
+
+
+ 📡 Offline mode — changes will sync when connected
+ 0
-
-
-
+
+
- 📷 Scan
+ 📷 Barcode
+ 🔍 OCR
✏️ Manual
+ 📱 Pick from Gallery
-
+
-
Scan
+
Barcode Scanner
-
Point camera at a barcode or sticker
+
Point camera at a barcode
-
- 📸 Take Photo
- 📱 Pick from Gallery
-
-
-
-
-
-
📷 Photo Preview
-
-
-
-
-
➕ Create Asset
@@ -1283,7 +1265,45 @@
-
+
+
+
+
OCR Scanner — Photograph Sticker
+
+
+
📸
+
Tap to start camera
+
+
+
+ 📸
+
+
+
Point camera at the machine sticker and tap capture
+
+
+
+
+
+
+
Create from OCR
+
+
+
+ Category
+
+
+ Active
+ Maintenance
+ Retired
+
+
Create Asset
+
+
+
+
Manual Entry
@@ -1391,8 +1411,19 @@
✓ Check In Now
+
+
+
+
+
+
+ 🔍 OCR this photo
+ 📍 Extract GPS
+ ➕ Create Asset
+
+
+
✕ Clear
-
-
-
- 🏷️ Recently Scanned Labels
- Clear
-
-
-
-
@@ -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. Enter manually ';
- }
- } 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'}
-
-
- ✓ Confirm & Check In
- ✏️ Edit
-
- `;
- }
-
- 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:
-
-
- Re-lookup
-
-
-
- ← Cancel
-
- `;
- 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)` : ''}
+
+
+
+
+ 📝 Fill Manual Form
+
+
+ 🗺️ Open in Maps
+
+
+
+ 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';
+ };
+