diff --git a/.gitignore b/.gitignore
index cbdc384..7243072 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
+.venv/
__pycache__/
*.pyc
-assets.db
-uploads/
+*.db
+*.db.real
+*.xlsx
+.env
diff --git a/admin_server.py b/admin_server.py
index 12e55a6..9f5fa35 100644
--- a/admin_server.py
+++ b/admin_server.py
@@ -28,6 +28,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from cantaloupe_sync import router as cantaloupe_router, create_sync_tables
+from exif_module import router as exif_router, create_exif_tables
# ─── Config ─────────────────────────────────────────────────────────────────
@@ -50,6 +51,24 @@ def _load_categories() -> set:
VALID_CATEGORIES = _load_categories()
VALID_STATUSES = {"active", "maintenance", "retired"}
VALID_ROLES = {"admin", "technician", "readonly"}
+# Helpers for comma-separated multi-role support
+def roles_contain(role_str: str, target: str) -> bool:
+ """Check if a comma-separated role string includes target."""
+ return target in [r.strip() for r in (role_str or "").split(",")]
+
+def validate_roles(role_str: str) -> str:
+ """Accept comma-separated role combos. Normalise: strip whitespace, dedupe, sort."""
+ parts = [r.strip().lower() for r in role_str.split(",")]
+ parts = [r for r in parts if r] # drop empties
+ if not parts:
+ raise HTTPException(status_code=422, detail="At least one role is required")
+ invalid = [r for r in parts if r not in VALID_ROLES]
+ if invalid:
+ raise HTTPException(
+ status_code=422,
+ detail=f"Invalid role(s): {', '.join(invalid)}. Must be one of: {', '.join(sorted(VALID_ROLES))}",
+ )
+ return ",".join(sorted(set(parts)))
# ─── Database ───────────────────────────────────────────────────────────────
@@ -358,6 +377,7 @@ async def lifespan(app: FastAPI):
conn = get_db()
init_db(conn)
conn.close()
+ create_exif_tables()
yield
@@ -373,6 +393,7 @@ app.add_middleware(
# Mount Cantaloupe sync routes
app.include_router(cantaloupe_router)
+app.include_router(exif_router)
# ─── Auth Middleware ────────────────────────────────────────────────────────
@@ -385,7 +406,7 @@ async def auth_middleware(request: Request, call_next):
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
return await call_next(request)
- if not path.startswith("/api/") or path == "/api/auth/login":
+ if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business"):
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
@@ -683,12 +704,7 @@ def create_user(body: UserCreate):
password = body.password
if not password:
raise HTTPException(status_code=422, detail="Password must not be empty")
- role = body.role or "technician"
- if role not in VALID_ROLES:
- raise HTTPException(
- status_code=422,
- detail=f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}",
- )
+ role = validate_roles(body.role or "technician")
conn = get_db()
password_hash = _hash_password(password)
@@ -738,13 +754,8 @@ def update_user(user_id: int, body: UserUpdate):
raise HTTPException(status_code=404, detail="User not found")
if body.role is not None:
- if body.role not in VALID_ROLES:
- conn.close()
- raise HTTPException(
- status_code=422,
- detail=f"Invalid role '{body.role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}",
- )
- conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, user_id))
+ role = validate_roles(body.role)
+ conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
if body.password is not None:
pw = body.password.strip()
@@ -1501,7 +1512,7 @@ def get_stats():
THEN (julianday(v.checkout_time) - julianday(v.checkin_time)) * 1440
ELSE 0 END) AS total_minutes
FROM visits v JOIN users u ON v.user_id = u.id
- WHERE u.role = 'technician'
+ WHERE u.role LIKE '%technician%'
GROUP BY v.user_id ORDER BY total_minutes DESC"""
).fetchall()
time_on_site = [
@@ -1596,6 +1607,119 @@ def export_checkins_csv(asset_id: Optional[int] = Query(None)):
# ─── Asset GPS Management ──────────────────────────────────────────────────
+@app.get("/api/assets")
+def list_assets_admin(
+ q: Optional[str] = Query(None),
+ status: Optional[str] = Query(None),
+ category: Optional[str] = Query(None),
+ customer_id: Optional[int] = Query(None),
+ limit: int = Query(500, ge=1, le=5000),
+ offset: int = Query(0, ge=0),
+):
+ """List all assets with optional filters and search."""
+ conn = get_db()
+ conditions = []
+ params = []
+
+ if q:
+ conditions.append("(name LIKE ? OR machine_id LIKE ? OR company LIKE ? OR serial_number LIKE ?)")
+ like = f"%{q}%"
+ params.extend([like, like, like, like])
+ if status:
+ conditions.append("status = ?")
+ params.append(status)
+ if category:
+ conditions.append("category = ?")
+ params.append(category)
+ if customer_id is not None:
+ conditions.append("customer_id = ?")
+ params.append(customer_id)
+
+ where = " AND ".join(conditions)
+ sql = "SELECT * FROM assets"
+ count_sql = "SELECT COUNT(*) FROM assets"
+ if where:
+ sql += f" WHERE {where}"
+ count_sql += f" WHERE {where}"
+
+ total = conn.execute(count_sql, params).fetchone()[0]
+ sql += " ORDER BY name ASC LIMIT ? OFFSET ?"
+ rows = conn.execute(sql, params + [limit, offset]).fetchall()
+ conn.close()
+
+ result = [row_to_dict(r) for r in rows]
+ return JSONResponse({
+ "assets": result,
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ })
+
+
+@app.put("/api/assets/{asset_id}")
+def update_asset_admin(asset_id: int, body: dict):
+ """Update any fields on an asset (admin). Body can include any asset column."""
+ conn = get_db()
+ existing = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
+ if existing is None:
+ conn.close()
+ raise HTTPException(status_code=404, detail="Asset not found")
+
+ # Whitelist of editable fields
+ EDITABLE_FIELDS = {
+ "name", "machine_id", "serial_number", "description", "category", "status",
+ "make", "model", "address", "building_name", "building_number", "floor", "room",
+ "trailer_number", "walking_directions", "map_link", "parking_location",
+ "company", "place", "location_area",
+ "install_date", "dex_report_date", "pulled_date", "deployed",
+ "customer_id", "location_id", "assigned_to",
+ "latitude", "longitude", "geofence_radius_meters",
+ }
+
+ updates = {}
+ for field, val in body.items():
+ if field not in EDITABLE_FIELDS:
+ continue
+ if val is not None:
+ updates[field] = val
+ else:
+ updates[field] = None
+
+ if not updates:
+ conn.close()
+ raise HTTPException(status_code=422, detail="No valid fields to update")
+
+ if "category" in updates and updates["category"]:
+ row = conn.execute("SELECT id FROM categories WHERE name = ?", (updates["category"],)).fetchone()
+ if row is None:
+ conn.close()
+ raise HTTPException(status_code=422, detail=f"Invalid category '{updates['category']}'")
+
+ if "status" in updates and updates["status"]:
+ if updates["status"] not in VALID_STATUSES:
+ conn.close()
+ raise HTTPException(status_code=422, detail=f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}")
+
+ updates["updated_at"] = "datetime('now')"
+ set_clause = ", ".join(
+ f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
+ for k, v in updates.items()
+ )
+ values = [v for k, v in updates.items() if k != "updated_at"]
+ conn.execute(
+ f"UPDATE assets SET {set_clause} WHERE id = ?",
+ values + [asset_id],
+ )
+ conn.commit()
+
+ row = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
+ _log_activity(conn, "updated", "asset", asset_id,
+ f"Asset '{row['name']}' fields updated via admin")
+ conn.commit()
+ conn.close()
+ return row_to_dict(row)
+
+
@app.get("/api/assets/search")
def search_assets_admin(machine_id: str = Query(...)):
"""Search for an asset by machine_id (admin)."""
@@ -1657,7 +1781,7 @@ def reset_database(request: Request):
# Admin-only check
user = getattr(request.state, "current_user", None)
auth_skipped = os.environ.get("CANTEEN_SKIP_AUTH") == "1"
- if not auth_skipped and (not user or user.get("role") != "admin"):
+ if not auth_skipped and (not user or not roles_contain(user.get("role", ""), "admin")):
raise HTTPException(status_code=403, detail="Only admins can reset the database")
# Confirmation header safety check
@@ -1692,6 +1816,116 @@ def reset_database(request: Request):
return {"detail": "Database reset complete — all tables recreated with seed data."}
+# ─── Business Name Lookup (Google Maps via Playwright) ────────────────────
+
+_playwright_browser = None
+
+def _get_browser():
+ """Lazy singleton Playwright browser (reuse across requests)."""
+ global _playwright_browser
+ if _playwright_browser is None or not _playwright_browser.is_connected():
+ from playwright.sync_api import sync_playwright
+ _pw = sync_playwright().start()
+ _playwright_browser = _pw.chromium.launch(channel='chrome', headless=True)
+ return _playwright_browser
+
+
+def lookup_business_name(address: str) -> str:
+ """Use headless Chrome + Google Maps to find the business at an address.
+
+ Returns the business name from the 'At this place' section, or empty string.
+ """
+ if not address or not address.strip():
+ return ""
+ try:
+ browser = _get_browser()
+ context = browser.new_context(
+ user_agent=(
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
+ ),
+ locale="en-US",
+ )
+ page = context.new_page()
+ # Build the Google Maps search URL
+ q = address.strip().replace(" ", "+")
+ page.goto(f"https://www.google.com/maps/search/{q}", timeout=15000)
+ # Wait for the page to settle — the "At this place" section appears
+ page.wait_for_timeout(3000)
+
+ # Try to find business name via several selectors
+ business_name = ""
+ try:
+ # Method 1: article with aria-label in At this place section
+ articles = page.query_selector_all('div[role="article"][aria-label]')
+ for art in articles:
+ label = art.get_attribute("aria-label") or ""
+ label = label.strip()
+ if label and label != address.strip():
+ business_name = label
+ break
+ except Exception:
+ pass
+
+ if not business_name:
+ try:
+ # Method 2: look for the heading after "At this place"
+ el = page.query_selector('h2:has-text("At this place") + div [role="article"]')
+ if el:
+ business_name = (el.get_attribute("aria-label") or "")
+ except Exception:
+ pass
+
+ if not business_name:
+ try:
+ # Method 3: page title sometimes has business name
+ title = page.title()
+ if title and "Google Maps" not in title:
+ business_name = title.replace(" - Google Maps", "").strip()
+ except Exception:
+ pass
+
+ context.close()
+ return business_name.strip()
+ except Exception as e:
+ print(f"[lookup] Error looking up '{address}': {e}")
+ return ""
+
+
+@app.post("/api/lookup-business")
+def lookup_business(body: dict):
+ """Look up business name at an address using Google Maps."""
+ address = (body.get("address") or "").strip()
+ if not address:
+ raise HTTPException(status_code=422, detail="address is required")
+ name = lookup_business_name(address)
+ return {"address": address, "business_name": name}
+
+
+@app.post("/api/lookup-business/batch")
+def lookup_business_batch(body: dict):
+ """Batch lookup — takes assets: [{id, address}] and returns results.
+
+ Max 50 assets per call to keep response time reasonable.
+ """
+ assets = body.get("assets", [])
+ if not assets:
+ raise HTTPException(status_code=422, detail="assets array is required")
+ # Limit to 50
+ assets = assets[:50]
+ results = []
+ total = len(assets)
+ for i, asset in enumerate(assets):
+ aid = asset.get("id")
+ addr = (asset.get("address") or "").strip()
+ if not addr:
+ results.append({"id": aid, "address": addr, "business_name": ""})
+ continue
+ name = lookup_business_name(addr)
+ results.append({"id": aid, "address": addr, "business_name": name})
+ return {"results": results, "total": total}
+
+
# ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
diff --git a/cantaloupe_sync.py b/cantaloupe_sync.py
index 0fb5343..5749691 100644
--- a/cantaloupe_sync.py
+++ b/cantaloupe_sync.py
@@ -578,6 +578,12 @@ async def run_sync(
column_map = map_columns(headers)
mapped_rows = apply_mapping(raw_rows, column_map)
+ # Normalize categories: strip "GF " prefix so "GF Food" → "Food", etc.
+ for row in mapped_rows:
+ cat = row.get("category", "")
+ if cat and cat.startswith("GF "):
+ row["category"] = cat[3:]
+
# Compute diff
live_data = _fetch_live_data(conn)
diff = compute_diff(mapped_rows, live_data)
@@ -1250,7 +1256,7 @@ def get_field_changes(batch_id: int):
for ch in asset.get("changes", []):
old_val = ch.get("old_value")
new_val = ch.get("new_value")
- is_blank = old_val is None or str(old_val).strip() == ""
+ is_blank = new_val is None or str(new_val).strip() == ""
result.append({
"machine_id": mid,
"name": name,
diff --git a/exif_module.py b/exif_module.py
new file mode 100644
index 0000000..c880aed
--- /dev/null
+++ b/exif_module.py
@@ -0,0 +1,1296 @@
+"""
+EXIF Scanner — Admin server integration module.
+
+Ports the exif-test server functionality into the admin server as an APIRouter.
+Shares the same assets.db (via CANTEEN_DB_PATH) and uses the exif-test photos.db
+(via EXIF_PHOTOS_DB) so both servers can access the same photo data.
+
+Mounted at /api/admin/exif and automatically protected by the admin server's auth middleware.
+"""
+import base64
+import csv
+import datetime
+import hashlib
+import io
+import json
+import logging
+import math
+import os
+import re
+import sqlite3
+import urllib.request
+import uuid
+from pathlib import Path
+from typing import Optional
+
+from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
+from fastapi.responses import FileResponse, StreamingResponse
+from PIL import Image as PILImage
+
+# Optional HEIC/HEIF support for iPhone photos
+try:
+ from pillow_heif import register_heif_opener
+ register_heif_opener()
+ HAS_HEIF = True
+except ImportError:
+ HAS_HEIF = False
+
+try:
+ import pytesseract
+ HAS_TESSERACT = True
+except ImportError:
+ HAS_TESSERACT = False
+
+logger = logging.getLogger("exif_module")
+
+# ─── Config ─────────────────────────────────────────────────────────────────
+
+# Path to the exif-test photos database (shared between exif-test and admin)
+EXIF_PHOTOS_DB = Path(os.environ.get(
+ "EXIF_PHOTOS_DB",
+ str(Path(__file__).parent.parent / "exif-test" / "photos.db")
+))
+
+# Path to the exif-test uploads directory
+EXIF_UPLOADS_DIR = Path(os.environ.get(
+ "EXIF_UPLOADS_DIR",
+ str(Path(__file__).parent.parent / "exif-test" / "uploads")
+))
+
+# Canteen asset DB (shared — same as admin_server)
+CANTEEN_DB_PATH = os.environ.get(
+ "CANTEEN_DB_PATH",
+ str(Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db")
+)
+
+# LLM OCR config
+OPENCODE_GO_KEY = (os.environ.get("OPENCODE_GO_API_KEY") or "").strip()
+OPENCODE_GO_BASE = (os.environ.get("OPENCODE_GO_BASE_URL") or "https://opencode.ai/zen/go/v1").strip()
+LLM_OCR_MODEL = (os.environ.get("LLM_OCR_MODEL") or "mimo-v2-omni").strip()
+
+GOOGLE_API_KEY = (os.environ.get("GOOGLE_API_KEY") or "").strip()
+GOOGLE_API_BASE = (os.environ.get("GOOGLE_API_BASE_URL") or "https://generativelanguage.googleapis.com/v1beta").strip()
+GOOGLE_OCR_MODEL = (os.environ.get("GOOGLE_OCR_MODEL") or "gemini-2.5-flash").strip()
+
+# Max images per batch API call
+BATCH_SIZE_LIMIT = 20
+# Downscale images to this max dimension before LLM OCR
+LLM_IMAGE_MAX_DIM = 1600
+
+# OCR prompts
+DEFAULT_OCR_PROMPT = (
+ "Read ALL text and numbers visible in this photo. "
+ "Return the exact text shown, nothing else."
+)
+
+STICKER_OCR_PROMPT = (
+ "This photo shows a colored equipment sticker (green, orange, or yellow background) "
+ "with a 2D barcode and a machine ID number printed below the barcode. "
+ "Read ONLY the machine ID number that appears below the barcode. "
+ "It is typically a 5-digit number followed by a dash and 6 more digits "
+ "(e.g., 12345-678901). Return ONLY the machine ID number, nothing else."
+)
+
+DEFAULT_BATCH_PROMPT = (
+ "I have {n} photos. For EACH photo, read ALL visible text and numbers.\n"
+ 'Respond with a JSON array of objects, one per photo in order:\n'
+ '[{{"i":0,"text":"all text and digits found","digits":"e.g. 12345-678901 or null if none"}}, ...]\n'
+ 'Return ONLY the JSON array, no markdown, no explanation.'
+)
+
+STICKER_BATCH_PROMPT = (
+ "I have {n} photos of colored equipment stickers (green, orange, or yellow). "
+ "Each sticker has a 2D barcode and a machine ID printed below it.\n"
+ 'Respond with a JSON array of objects, one per photo in order:\n'
+ '[{{"i":0,"sticker_color":"green/orange/yellow/unknown","machine_id":"12345-678901 or null if not found"}}, ...]\n'
+ 'Return ONLY the JSON array, no markdown, no explanation.'
+)
+
+# ─── Router ──────────────────────────────────────────────────────────────────
+
+router = APIRouter(prefix="/api/admin/exif", tags=["exif"])
+
+# Ensure upload dir exists
+EXIF_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
+
+
+# ─── DB init (called from admin_server lifespan) ────────────────────────────
+
+def create_exif_tables():
+ """Create photos and sessions tables if they don't exist."""
+ conn = sqlite3.connect(str(EXIF_PHOTOS_DB))
+ try:
+ conn.execute("""CREATE TABLE IF NOT EXISTS photos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ orig_filename TEXT NOT NULL,
+ file_hash TEXT NOT NULL UNIQUE,
+ saved_as TEXT NOT NULL,
+ file_size INTEGER,
+ exif_json TEXT,
+ gps_lat REAL,
+ gps_lng REAL,
+ ocr_engine TEXT,
+ ocr_model TEXT,
+ ocr_raw_text TEXT,
+ ocr_match_5dash6 TEXT,
+ ocr_match_5plus TEXT,
+ machine_id TEXT,
+ sticker_color TEXT,
+ has_barcode INTEGER DEFAULT 0,
+ session_id INTEGER,
+ created_at TEXT DEFAULT (datetime('now'))
+ )""")
+ conn.execute("""CREATE TABLE IF NOT EXISTS sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL DEFAULT '',
+ building TEXT DEFAULT '',
+ floor TEXT DEFAULT '',
+ photo_count INTEGER DEFAULT 0,
+ matched_count INTEGER DEFAULT 0,
+ needs_gps_count INTEGER DEFAULT 0,
+ closed_at TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+ )""")
+ # Session column migration
+ cols = [r[1] for r in conn.execute("PRAGMA table_info(photos)").fetchall()]
+ if "session_id" not in cols:
+ conn.execute("ALTER TABLE photos ADD COLUMN session_id INTEGER REFERENCES sessions(id)")
+ conn.commit()
+ except Exception:
+ with sqlite3.connect(str(EXIF_PHOTOS_DB)) as c2:
+ c2.execute("""CREATE TABLE IF NOT EXISTS photos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ orig_filename TEXT NOT NULL,
+ file_hash TEXT NOT NULL UNIQUE,
+ saved_as TEXT NOT NULL,
+ file_size INTEGER DEFAULT NULL,
+ exif_json TEXT DEFAULT NULL,
+ gps_lat REAL DEFAULT NULL,
+ gps_lng REAL DEFAULT NULL,
+ ocr_engine TEXT DEFAULT NULL,
+ ocr_model TEXT DEFAULT NULL,
+ ocr_raw_text TEXT DEFAULT NULL,
+ ocr_match_5dash6 TEXT DEFAULT NULL,
+ ocr_match_5plus TEXT DEFAULT NULL,
+ machine_id TEXT DEFAULT NULL,
+ sticker_color TEXT DEFAULT NULL,
+ has_barcode INTEGER DEFAULT 0,
+ session_id INTEGER DEFAULT NULL,
+ created_at TEXT DEFAULT (datetime('now'))
+ )""")
+ c2.execute("""CREATE TABLE IF NOT EXISTS sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL DEFAULT '',
+ building TEXT DEFAULT '',
+ floor TEXT DEFAULT '',
+ photo_count INTEGER DEFAULT 0,
+ matched_count INTEGER DEFAULT 0,
+ needs_gps_count INTEGER DEFAULT 0,
+ closed_at TEXT,
+ created_at TEXT DEFAULT (datetime('now'))
+ )""")
+ finally:
+ conn.close()
+
+
+# ─── DB helpers ──────────────────────────────────────────────────────────────
+
+def _get_photos_db() -> sqlite3.Connection:
+ conn = sqlite3.connect(str(EXIF_PHOTOS_DB))
+ conn.row_factory = sqlite3.Row
+ return conn
+
+
+def _get_canteen_db():
+ """Get a read/write connection to the canteen assets database."""
+ path = Path(CANTEEN_DB_PATH)
+ if not path.exists():
+ return None
+ conn = sqlite3.connect(str(path))
+ conn.row_factory = sqlite3.Row
+ return conn
+
+
+def _file_hash(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def _row_to_dict(row: sqlite3.Row) -> dict:
+ d = dict(row)
+ d["exif_data"] = json.loads(d.pop("exif_json", "{}") or "{}")
+ return d
+
+
+def _save_photo_to_db(
+ orig_filename: str, file_hash: str, saved_as: str, file_size: int,
+ exif_result: dict, ocr_result: dict, machine_id: str | None,
+ session_id: int | None = None,
+) -> int:
+ conn = _get_photos_db()
+ try:
+ cur = conn.execute(
+ """INSERT OR IGNORE INTO photos
+ (orig_filename, file_hash, saved_as, file_size, exif_json,
+ gps_lat, gps_lng, ocr_engine, ocr_model, ocr_raw_text,
+ ocr_match_5dash6, ocr_match_5plus, machine_id, sticker_color, has_barcode, session_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ orig_filename,
+ file_hash,
+ saved_as,
+ file_size,
+ json.dumps(exif_result) if exif_result else None,
+ exif_result.get("gps", {}).get("lat") if exif_result and exif_result.get("gps") else None,
+ exif_result.get("gps", {}).get("lng") if exif_result and exif_result.get("gps") else None,
+ ocr_result.get("engine") if ocr_result else None,
+ ocr_result.get("llm_model") if ocr_result else None,
+ ocr_result.get("raw_text") if ocr_result else None,
+ ocr_result.get("match_5dash6") if ocr_result else None,
+ ocr_result.get("match_5plus") if ocr_result else None,
+ machine_id,
+ ocr_result.get("sticker_color") if ocr_result else None,
+ 1 if ocr_result and ocr_result.get("has_barcode") else 0,
+ session_id,
+ ),
+ )
+ conn.commit()
+ return cur.lastrowid or 0
+ except sqlite3.IntegrityError:
+ return 0
+ finally:
+ conn.close()
+
+
+def lookup_machine_id(machine_id: str) -> dict | None:
+ """Look up an asset by machine_id. Returns asset dict or None."""
+ conn = _get_canteen_db()
+ if not conn:
+ return None
+ try:
+ row = conn.execute(
+ "SELECT id, machine_id, name, category, status, address, building_name, "
+ "floor, room, latitude, longitude, make, model, description, photo_path "
+ "FROM assets WHERE machine_id = ?",
+ (machine_id.strip(),),
+ ).fetchone()
+ if row:
+ return {
+ "id": row["id"],
+ "machine_id": row["machine_id"],
+ "name": row["name"],
+ "category": row["category"],
+ "status": row["status"],
+ "address": row["address"],
+ "building_name": row["building_name"],
+ "floor": row["floor"],
+ "room": row["room"],
+ "latitude": row["latitude"],
+ "longitude": row["longitude"],
+ "make": row["make"],
+ "model": row["model"],
+ "description": row["description"],
+ "photo_path": row["photo_path"],
+ }
+ finally:
+ conn.close()
+ return None
+
+
+# ─── EXIF extraction ─────────────────────────────────────────────────────────
+
+def _dms_to_decimal(dms, ref):
+ try:
+ deg, minutes, sec = float(dms[0]), float(dms[1]), float(dms[2])
+ decimal = deg + minutes / 60.0 + sec / 3600.0
+ if ref in ("S", "W"):
+ decimal = -decimal
+ return round(decimal, 7)
+ except Exception:
+ return None
+
+
+def extract_exif(image_bytes: bytes) -> dict:
+ result = {"has_exif": False, "tags": {}, "gps": None}
+ try:
+ img = PILImage.open(io.BytesIO(image_bytes))
+ exif = img.getexif()
+ if not exif:
+ return result
+ for tag_id, value in exif.items():
+ tag_name = PILImage.ExifTags.TAGS.get(tag_id, f"0x{tag_id:04x}")
+ if tag_name in ("MakerNote", "UserComment", "PrintImageMatching"):
+ continue
+ result["tags"][tag_name] = str(value)[:300]
+ if result["tags"]:
+ result["has_exif"] = True
+ gps_ifd = exif.get_ifd(0x8825)
+ if gps_ifd:
+ lat_ref = gps_ifd.get(1, "N")
+ lat_dms = gps_ifd.get(2)
+ lng_ref = gps_ifd.get(3, "E")
+ lng_dms = gps_ifd.get(4)
+ if lat_dms and lng_dms:
+ lat = _dms_to_decimal(lat_dms, lat_ref)
+ lng = _dms_to_decimal(lng_dms, lng_ref)
+ if lat is not None and lng is not None:
+ result["gps"] = {
+ "lat": lat,
+ "lng": lng,
+ "lat_ref": str(lat_ref),
+ "lng_ref": str(lng_ref),
+ "raw_lat_dms": [float(v) for v in lat_dms],
+ "raw_lng_dms": [float(v) for v in lng_dms],
+ }
+ except Exception as e:
+ result["error"] = str(e)
+ return result
+
+
+# ─── Tesseract OCR ───────────────────────────────────────────────────────────
+
+def run_ocr(image_bytes: bytes) -> dict:
+ if not HAS_TESSERACT:
+ return {"available": False, "text": "", "error": "pytesseract not installed"}
+ tmp_path = EXIF_UPLOADS_DIR / f"ocr_{uuid.uuid4().hex}.jpg"
+ tmp_path.write_bytes(image_bytes)
+ try:
+ img = PILImage.open(tmp_path)
+ img_gray = img.convert("L")
+ text = pytesseract.image_to_string(img_gray, config="--psm 6")
+ match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
+ match_5plus = re.search(r"(\d{5,})", text)
+ return {
+ "available": True,
+ "raw_text": text.strip()[:500],
+ "match_5dash6": match_5dash.group(0) if match_5dash else None,
+ "match_5plus": match_5plus.group(0) if match_5plus else None,
+ }
+ finally:
+ tmp_path.unlink(missing_ok=True)
+
+
+# ─── LLM OCR helpers ─────────────────────────────────────────────────────────
+
+def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> bytes:
+ img = PILImage.open(io.BytesIO(image_bytes))
+ w, h = img.size
+ if max(w, h) <= max_dim:
+ return image_bytes
+ ratio = max_dim / max(w, h)
+ new_size = (int(w * ratio), int(h * ratio))
+ img_resized = img.resize(new_size, PILImage.LANCZOS)
+ buf = io.BytesIO()
+ ext = img.format or "JPEG"
+ if ext.upper() in ("PNG", "WEBP", "TIFF", "BMP"):
+ img_resized = img_resized.convert("RGB")
+ ext = "JPEG"
+ img_resized.save(buf, format=ext, quality=85)
+ return buf.getvalue()
+
+
+def run_ocr_llm(
+ image_bytes: bytes, model: str | None = None,
+ sticker_mode: bool = False,
+) -> dict:
+ if not OPENCODE_GO_KEY:
+ result = run_ocr(image_bytes)
+ result["engine"] = "tesseract"
+ result["llm_fallback_reason"] = "no_api_key"
+ return result
+ model = model or LLM_OCR_MODEL
+ image_bytes = _resize_for_llm(image_bytes)
+ b64_img = base64.b64encode(image_bytes).decode()
+ prompt = STICKER_OCR_PROMPT if sticker_mode else DEFAULT_OCR_PROMPT
+ body = json.dumps({
+ "model": model,
+ "messages": [{
+ "role": "user",
+ "content": [
+ {"type": "text", "text": prompt},
+ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_img}"}}
+ ]
+ }],
+ "max_tokens": 200,
+ }).encode()
+ req = urllib.request.Request(
+ f"{OPENCODE_GO_BASE}/chat/completions",
+ data=body,
+ headers={
+ "Authorization": f"Bearer {OPENCODE_GO_KEY}",
+ "Content-Type": "application/json",
+ "User-Agent": "Hermes-Agent/1.0",
+ },
+ )
+ try:
+ resp = urllib.request.urlopen(req, timeout=60)
+ result = json.loads(resp.read())
+ text = result["choices"][0]["message"]["content"].strip()
+ except Exception as exc:
+ result = run_ocr(image_bytes)
+ result["engine"] = "tesseract"
+ result["llm_fallback_reason"] = str(exc)[:200]
+ return result
+ match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
+ match_5plus = re.search(r"(\d{5,})", text)
+ out = {
+ "available": True,
+ "engine": "llm",
+ "llm_model": model,
+ "raw_text": text[:500],
+ "match_5dash6": match_5dash.group(0) if match_5dash else None,
+ "match_5plus": match_5plus.group(0) if match_5plus else None,
+ }
+ if sticker_mode:
+ color_match = re.search(r"\b(green|orange|yellow)\b", text, re.I)
+ out["sticker_color"] = color_match.group(1).lower() if color_match else "unknown"
+ return out
+
+
+def run_ocr_llm_batch(
+ images: list[tuple[str, bytes]],
+ model: str | None = None,
+ max_per_batch: int = BATCH_SIZE_LIMIT,
+ sticker_mode: bool = False,
+) -> list[dict]:
+ if not OPENCODE_GO_KEY:
+ return [run_ocr(b) for _, b in images]
+ model = model or LLM_OCR_MODEL
+ all_results: list[dict] = []
+ for batch_start in range(0, len(images), max_per_batch):
+ batch = images[batch_start:batch_start + max_per_batch]
+ batch_results = _run_ocr_llm_batch_inner(batch, model, sticker_mode)
+ all_results.extend(batch_results)
+ return all_results
+
+
+def _run_ocr_llm_batch_inner(
+ batch: list[tuple[str, bytes]], model: str,
+ sticker_mode: bool = False,
+) -> list[dict]:
+ resized = [(_resize_for_llm(b), n) for n, b in batch]
+ batch_prompt = (STICKER_BATCH_PROMPT if sticker_mode else DEFAULT_BATCH_PROMPT).format(n=len(batch))
+ content: list[dict] = [{"type": "text", "text": batch_prompt}]
+ for idx, (img_bytes, _) in enumerate(resized):
+ b64_img = base64.b64encode(img_bytes).decode()
+ content.append({
+ "type": "image_url",
+ "image_url": {"url": f"data:image/jpeg;base64,{b64_img}"}
+ })
+ body = json.dumps({
+ "model": model,
+ "messages": [{"role": "user", "content": content}],
+ "max_tokens": min(300 * len(batch), 8000),
+ "temperature": 0.1,
+ }).encode()
+ req = urllib.request.Request(
+ f"{OPENCODE_GO_BASE}/chat/completions",
+ data=body,
+ headers={
+ "Authorization": f"Bearer {OPENCODE_GO_KEY}",
+ "Content-Type": "application/json",
+ "User-Agent": "Hermes-Agent/1.0",
+ },
+ )
+ try:
+ resp = urllib.request.urlopen(req, timeout=120)
+ result = json.loads(resp.read())
+ raw = result["choices"][0]["message"]["content"].strip()
+ except Exception:
+ return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch]
+ raw_clean = raw
+ if raw_clean.startswith("```"):
+ raw_clean = raw_clean.split("\n", 1)[-1]
+ raw_clean = raw_clean.rsplit("```", 1)[0].strip()
+ try:
+ parsed = json.loads(raw_clean)
+ results: list[dict] = []
+ for item in parsed:
+ if sticker_mode:
+ raw_text = str(item.get("machine_id", "") or "")
+ else:
+ raw_text = str(item.get("text", "") or "")
+ digit_str = str(item.get("digits") or "")
+ raw_text = raw_text + " " + digit_str
+ match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", raw_text)
+ match_5plus = re.search(r"(\d{5,})", raw_text)
+ entry: dict = {
+ "available": True,
+ "engine": "llm_batch",
+ "llm_model": model,
+ "raw_text": raw_text[:500],
+ "match_5dash6": match_5dash.group(0) if match_5dash else None,
+ "match_5plus": match_5plus.group(0) if match_5plus else None,
+ }
+ if sticker_mode:
+ entry["sticker_color"] = str(item.get("sticker_color", "unknown"))
+ results.append(entry)
+ return results
+ except (json.JSONDecodeError, KeyError, TypeError):
+ return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch]
+
+
+# ─── Google Gemini OCR ───────────────────────────────────────────────────────
+
+def run_ocr_google(image_bytes: bytes, model: str | None = None, sticker_mode: bool = False) -> dict:
+ if not GOOGLE_API_KEY:
+ result = run_ocr(image_bytes)
+ result["engine"] = "tesseract"
+ result["llm_fallback_reason"] = "no_google_api_key"
+ return result
+ model = model or GOOGLE_OCR_MODEL
+ image_bytes = _resize_for_llm(image_bytes)
+ b64_img = base64.b64encode(image_bytes).decode()
+ prompt = STICKER_OCR_PROMPT if sticker_mode else DEFAULT_OCR_PROMPT
+ body = json.dumps({
+ "contents": [{
+ "parts": [
+ {"text": prompt},
+ {"inline_data": {"mime_type": "image/jpeg", "data": b64_img}}
+ ]
+ }],
+ "generationConfig": {"maxOutputTokens": 200, "temperature": 0.1},
+ }).encode()
+ req = urllib.request.Request(
+ f"{GOOGLE_API_BASE}/models/{model}:generateContent?key={GOOGLE_API_KEY}",
+ data=body,
+ headers={"Content-Type": "application/json"},
+ )
+ try:
+ resp = urllib.request.urlopen(req, timeout=60)
+ result = json.loads(resp.read())
+ text = result["candidates"][0]["content"]["parts"][0]["text"].strip()
+ except Exception as exc:
+ result = run_ocr(image_bytes)
+ result["engine"] = "tesseract"
+ result["llm_fallback_reason"] = str(exc)[:200]
+ return result
+ match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
+ match_5plus = re.search(r"(\d{5,})", text)
+ out: dict = {
+ "available": True,
+ "engine": "google",
+ "llm_model": model,
+ "raw_text": text[:500],
+ "match_5dash6": match_5dash.group(0) if match_5dash else None,
+ "match_5plus": match_5plus.group(0) if match_5plus else None,
+ }
+ if sticker_mode:
+ cm = re.search(r"\b(green|orange|yellow)\b", text, re.I)
+ out["sticker_color"] = cm.group(1).lower() if cm else "unknown"
+ return out
+
+
+# ─── Process helpers ─────────────────────────────────────────────────────────
+
+def _extract_machine_id(ocr_result: dict) -> str | None:
+ match_5dash6 = ocr_result.get("match_5dash6")
+ match_5plus = ocr_result.get("match_5plus")
+ if match_5dash6:
+ clean = re.sub(r"\D", "", match_5dash6)
+ if len(clean) >= 5:
+ return clean[:5]
+ if match_5plus:
+ clean = re.sub(r"\D", "", match_5plus)
+ if len(clean) >= 5:
+ return clean[:5]
+ return None
+
+
+def _process_one(orig_filename: str, contents: bytes, ocr_engine: str,
+ ocr_model: str | None, sticker_mode: bool) -> dict:
+ file_size = len(contents)
+ fhash = _file_hash(contents)
+ dup_row = None
+ db_conn = _get_photos_db()
+ if db_conn:
+ try:
+ dup_row = db_conn.execute(
+ "SELECT id, saved_as FROM photos WHERE file_hash = ?", (fhash,)
+ ).fetchone()
+ finally:
+ db_conn.close()
+ if dup_row:
+ saved_name = dup_row["saved_as"]
+ is_dup = True
+ photo_id = dup_row["id"]
+ else:
+ ext = Path(orig_filename or "photo.jpg").suffix.lower()
+ if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
+ ext = ".jpg"
+ saved_name = f"{uuid.uuid4().hex}{ext}"
+ (EXIF_UPLOADS_DIR / saved_name).write_bytes(contents)
+ is_dup = False
+ photo_id = None
+ exif_result = extract_exif(contents)
+ if ocr_engine == "llm":
+ ocr_result = run_ocr_llm(contents, ocr_model, sticker_mode=sticker_mode)
+ elif ocr_engine == "google":
+ ocr_result = run_ocr_google(contents, ocr_model, sticker_mode=sticker_mode)
+ elif HAS_TESSERACT:
+ ocr_result = run_ocr(contents)
+ ocr_result["engine"] = "tesseract"
+ else:
+ ocr_result = {"available": False, "text": "", "engine": "none"}
+ machine_id = None
+ asset = None
+ mid = _extract_machine_id(ocr_result)
+ if mid:
+ machine_id = mid
+ asset = lookup_machine_id(machine_id)
+ if not is_dup and not dup_row:
+ photo_id = _save_photo_to_db(
+ orig_filename, fhash, saved_name, file_size,
+ exif_result, ocr_result, machine_id,
+ )
+ return {
+ "filename": orig_filename,
+ "saved_as": saved_as if is_dup and not dup_row else saved_name,
+ "photo_id": photo_id or (dup_row["id"] if dup_row else None),
+ "file_size": file_size,
+ "file_size_kb": round(file_size / 1024, 1),
+ "duplicate": is_dup,
+ "exif": exif_result,
+ "ocr": ocr_result,
+ "machine_id": machine_id,
+ "asset": asset,
+ }
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# API Endpoints
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+@router.post("/analyze")
+async def analyze_photo(
+ file: UploadFile = File(...),
+ ocr_engine: str = Query(default="tesseract"),
+ ocr_model: str = Query(default=""),
+ sticker_mode: bool = Query(default=False),
+):
+ """Upload a photo, get back EXIF + OCR results."""
+ contents = await file.read()
+ result = _process_one(
+ file.filename or "photo.jpg", contents,
+ ocr_engine, ocr_model or None, sticker_mode,
+ )
+ return result
+
+
+@router.post("/bulk-process")
+async def bulk_process(
+ files: list[UploadFile] = File(...),
+ ocr_engine: str = Query(default="tesseract"),
+ ocr_model: str = Query(default=""),
+ sticker_mode: bool = Query(default=False),
+ session_id: int | None = Query(default=None),
+):
+ """Process multiple photos: OCR each, extract EXIF GPS, look up matching assets."""
+ if not files:
+ return {"results": [], "summary": {"total": 0, "has_gps": 0, "matched": 0, "needs_gps": 0}}
+
+ all_files: list[tuple[str, bytes]] = []
+ for file in files:
+ contents = await file.read()
+ all_files.append((file.filename or "photo.jpg", contents))
+
+ ocr_use_llm = ocr_engine == "llm"
+ ocr_use_google = ocr_engine == "google"
+
+ if ocr_use_llm:
+ llm_ocr_results = run_ocr_llm_batch(
+ [(f, b) for f, b in all_files],
+ ocr_model or None,
+ sticker_mode=sticker_mode,
+ )
+ else:
+ llm_ocr_results = None
+
+ results = []
+ summary = {"total": len(all_files), "has_gps": 0, "matched": 0, "needs_gps": 0, "duplicates": 0}
+
+ for i, (orig_fname, contents) in enumerate(all_files):
+ file_size = len(contents)
+ fhash = _file_hash(contents)
+ dup_row = None
+ db_conn = _get_photos_db()
+ if db_conn:
+ try:
+ dup_row = db_conn.execute(
+ "SELECT id, saved_as FROM photos WHERE file_hash = ?", (fhash,)
+ ).fetchone()
+ finally:
+ db_conn.close()
+ if dup_row:
+ saved_name = dup_row["saved_as"]
+ is_dup = True
+ photo_id = dup_row["id"]
+ else:
+ ext = Path(orig_fname or "photo.jpg").suffix.lower()
+ if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
+ ext = ".jpg"
+ saved_name = f"{uuid.uuid4().hex}{ext}"
+ (EXIF_UPLOADS_DIR / saved_name).write_bytes(contents)
+ is_dup = False
+ photo_id = None
+ exif_result = extract_exif(contents)
+ if ocr_use_llm:
+ ocr_result = llm_ocr_results[i] if i < len(llm_ocr_results) else {"available": False, "engine": "none", "error": "missing batch result"}
+ elif ocr_use_google:
+ ocr_result = run_ocr_google(contents, ocr_model or None, sticker_mode=sticker_mode)
+ elif HAS_TESSERACT:
+ ocr_result = run_ocr(contents)
+ ocr_result["engine"] = "tesseract"
+ else:
+ ocr_result = {"available": False, "text": "", "engine": "none"}
+ machine_id = None
+ asset = None
+ mid = _extract_machine_id(ocr_result)
+ if mid:
+ machine_id = mid
+ asset = lookup_machine_id(machine_id)
+ needs_gps = False
+ if asset:
+ summary["matched"] += 1
+ has_photo_gps = exif_result.get("gps") is not None
+ needs_gps = has_photo_gps and (asset.get("latitude") is None or asset.get("longitude") is None)
+ if needs_gps:
+ summary["needs_gps"] += 1
+ if exif_result.get("gps"):
+ summary["has_gps"] += 1
+ if is_dup:
+ summary["duplicates"] += 1
+ if not is_dup and not dup_row:
+ photo_id = _save_photo_to_db(
+ orig_fname, fhash, saved_name, file_size,
+ exif_result, ocr_result, machine_id,
+ session_id=session_id,
+ )
+ results.append({
+ "filename": orig_fname,
+ "saved_as": saved_name,
+ "photo_id": photo_id or (dup_row["id"] if dup_row else None),
+ "file_size": file_size,
+ "file_size_kb": round(file_size / 1024, 1),
+ "duplicate": is_dup,
+ "exif": exif_result,
+ "ocr": ocr_result,
+ "machine_id": machine_id,
+ "asset": asset,
+ "needs_gps": needs_gps,
+ })
+
+ return {"results": results, "summary": summary}
+
+
+@router.get("/photos")
+async def list_photos(limit: int = Query(default=50, le=200)):
+ """List previously processed photos from DB (newest first)."""
+ conn = _get_photos_db()
+ try:
+ rows = conn.execute(
+ "SELECT * FROM photos ORDER BY created_at DESC LIMIT ?", (limit,)
+ ).fetchall()
+ return {"photos": [_row_to_dict(r) for r in rows]}
+ finally:
+ conn.close()
+
+
+@router.get("/photos/{photo_id}")
+async def get_photo(photo_id: int):
+ """Get a single photo record from DB."""
+ conn = _get_photos_db()
+ try:
+ row = conn.execute("SELECT * FROM photos WHERE id = ?", (photo_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, "Photo not found")
+ return _row_to_dict(row)
+ finally:
+ conn.close()
+
+
+@router.get("/photos/{photo_id}/file")
+async def get_photo_file(photo_id: int):
+ """Serve the saved image file for a photo record."""
+ conn = _get_photos_db()
+ try:
+ row = conn.execute("SELECT saved_as FROM photos WHERE id = ?", (photo_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, "Photo not found")
+ finally:
+ conn.close()
+ filepath = EXIF_UPLOADS_DIR / row["saved_as"]
+ if not filepath.exists():
+ raise HTTPException(404, "File not found on disk")
+ return FileResponse(str(filepath))
+
+
+@router.post("/photos/{photo_id}/reprocess")
+async def reprocess_photo(
+ photo_id: int,
+ ocr_engine: str = Query(default="tesseract"),
+ ocr_model: str = Query(default=""),
+ sticker_mode: bool = Query(default=False),
+):
+ """Re-run OCR on a previously saved photo with different engine/model."""
+ conn = _get_photos_db()
+ try:
+ row = conn.execute(
+ "SELECT saved_as, orig_filename FROM photos WHERE id = ?", (photo_id,)
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, "Photo not found")
+ finally:
+ conn.close()
+ filepath = EXIF_UPLOADS_DIR / row["saved_as"]
+ if not filepath.exists():
+ raise HTTPException(404, "Uploaded file not found on disk")
+ contents = filepath.read_bytes()
+ if ocr_engine == "llm":
+ ocr_result = run_ocr_llm(contents, ocr_model or None, sticker_mode=sticker_mode)
+ elif ocr_engine == "google":
+ ocr_result = run_ocr_google(contents, ocr_model or None, sticker_mode=sticker_mode)
+ elif HAS_TESSERACT:
+ ocr_result = run_ocr(contents)
+ ocr_result["engine"] = "tesseract"
+ else:
+ ocr_result = {"available": False, "text": "", "engine": "none"}
+ machine_id = None
+ asset = None
+ mid = _extract_machine_id(ocr_result)
+ if mid:
+ machine_id = mid
+ asset = lookup_machine_id(machine_id)
+ db2 = _get_photos_db()
+ try:
+ db2.execute(
+ """UPDATE photos SET ocr_engine=?, ocr_model=?, ocr_raw_text=?,
+ ocr_match_5dash6=?, ocr_match_5plus=?, machine_id=?,
+ sticker_color=?
+ WHERE id=?""",
+ (
+ ocr_result.get("engine"),
+ ocr_result.get("llm_model"),
+ ocr_result.get("raw_text"),
+ ocr_result.get("match_5dash6"),
+ ocr_result.get("match_5plus"),
+ machine_id,
+ ocr_result.get("sticker_color"),
+ photo_id,
+ ),
+ )
+ db2.commit()
+ finally:
+ db2.close()
+ return {
+ "photo_id": photo_id,
+ "ocr_engine": ocr_result.get("engine"),
+ "llm_model": ocr_result.get("llm_model"),
+ "ocr": ocr_result,
+ "machine_id": machine_id,
+ "asset": asset,
+ }
+
+
+@router.post("/photos/{photo_id}/reset")
+async def reset_photo(photo_id: int):
+ """Delete a photo record and its file, allowing re-upload as new."""
+ conn = _get_photos_db()
+ try:
+ row = conn.execute("SELECT saved_as FROM photos WHERE id = ?", (photo_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, "Photo not found")
+ filepath = EXIF_UPLOADS_DIR / row["saved_as"]
+ if filepath.exists():
+ filepath.unlink()
+ conn.execute("DELETE FROM photos WHERE id = ?", (photo_id,))
+ conn.commit()
+ finally:
+ conn.close()
+ return {"ok": True, "reset": photo_id, "message": "Photo entry deleted. Re-upload to process as new."}
+
+
+@router.delete("/photos/{photo_id}")
+async def delete_photo(photo_id: int):
+ """Delete a photo record and its file from disk."""
+ conn = _get_photos_db()
+ try:
+ row = conn.execute("SELECT saved_as FROM photos WHERE id = ?", (photo_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, "Photo not found")
+ filepath = EXIF_UPLOADS_DIR / row["saved_as"]
+ if filepath.exists():
+ filepath.unlink()
+ conn.execute("DELETE FROM photos WHERE id = ?", (photo_id,))
+ conn.commit()
+ finally:
+ conn.close()
+ return {"ok": True, "deleted": photo_id}
+
+
+@router.get("/lookup")
+async def lookup_asset(machine_id: str = ""):
+ """Look up an asset by machine_id in the canteen assets database."""
+ if not machine_id or not machine_id.strip():
+ return {"found": False, "reason": "No machine_id provided"}
+ asset = lookup_machine_id(machine_id.strip())
+ if asset:
+ return {"found": True, "asset": asset}
+ return {"found": False, "reason": f"No asset with machine_id '{machine_id}'"}
+
+
+@router.post("/push-gps")
+async def push_gps(request: dict):
+ """Update an asset's GPS coordinates from photo EXIF data."""
+ asset_id = request.get("asset_id")
+ latitude = request.get("latitude")
+ longitude = request.get("longitude")
+ if not asset_id or latitude is None or longitude is None:
+ raise HTTPException(400, "asset_id, latitude, and longitude are required")
+ conn = _get_canteen_db()
+ if not conn:
+ raise HTTPException(500, "Database not available")
+ try:
+ row = conn.execute(
+ "SELECT latitude, longitude FROM assets WHERE id = ?", (int(asset_id),)
+ ).fetchone()
+ if not row:
+ raise HTTPException(404, f"Asset {asset_id} not found")
+ if row["latitude"] is not None and row["longitude"] is not None:
+ return {"updated": False, "reason": "Asset already has GPS coordinates", "asset_id": asset_id}
+ conn.execute(
+ "UPDATE assets SET latitude = ?, longitude = ?, updated_at = datetime('now') WHERE id = ?",
+ (float(latitude), float(longitude), int(asset_id)),
+ )
+ conn.commit()
+ return {"updated": True, "asset_id": asset_id, "latitude": float(latitude), "longitude": float(longitude)}
+ except Exception as e:
+ raise HTTPException(500, str(e))
+ finally:
+ conn.close()
+
+
+@router.get("/uploads")
+async def list_uploads():
+ """List previously uploaded files."""
+ files = sorted(EXIF_UPLOADS_DIR.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True)
+ return [
+ {"name": f.name, "size_kb": round(f.stat().st_size / 1024, 1)}
+ for f in files[:20]
+ ]
+
+
+@router.get("/export")
+async def export_photos(
+ format: str = Query(default="csv", pattern="^(csv|kml|clipboard)$"),
+ session_id: int | None = Query(default=None),
+ limit: int = Query(default=500, le=5000),
+):
+ """Export photos as CSV, KML, or clipboard-ready text."""
+ conn = _get_photos_db()
+ try:
+ if session_id:
+ rows = conn.execute(
+ "SELECT * FROM photos WHERE session_id = ? ORDER BY created_at DESC LIMIT ?",
+ (session_id, limit),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT * FROM photos ORDER BY created_at DESC LIMIT ?", (limit,)
+ ).fetchall()
+ finally:
+ conn.close()
+ records = []
+ for r in rows:
+ d = _row_to_dict(r)
+ rec = {
+ "filename": d.get("orig_filename", ""),
+ "lat": d.get("gps_lat"),
+ "lng": d.get("gps_lng"),
+ "machine_id": d.get("machine_id", ""),
+ "ocr_engine": d.get("ocr_engine", ""),
+ "sticker_color": d.get("sticker_color", ""),
+ "timestamp": d.get("created_at", ""),
+ }
+ if d.get("machine_id"):
+ asset = lookup_machine_id(d["machine_id"])
+ if asset:
+ rec.update({
+ "asset_name": asset.get("name", ""),
+ "building": asset.get("building_name", ""),
+ "floor": asset.get("floor", ""),
+ "room": asset.get("room", ""),
+ "make": asset.get("make", ""),
+ "model": asset.get("model", ""),
+ })
+ records.append(rec)
+ if format == "csv":
+ output = io.StringIO()
+ writer = csv.writer(output)
+ header = ["filename", "lat", "lng", "machine_id", "asset_name",
+ "building", "floor", "room", "make", "model",
+ "ocr_engine", "sticker_color", "timestamp"]
+ bom = "\ufeff"
+ output.write(bom)
+ writer.writerow(header)
+ for rec in records:
+ writer.writerow([rec.get(h.replace("-", "_"), "") for h in header])
+ csv_content = output.getvalue()
+ return StreamingResponse(
+ iter([csv_content]),
+ media_type="text/csv",
+ headers={"Content-Disposition": f"attachment; filename=exif-export.csv"},
+ )
+ elif format == "kml":
+ kml_parts = [
+ '',
+ '',
+ " ",
+ f" EXIF Export ({len(records)} photos)",
+ ]
+ for rec in records:
+ if rec["lat"] is None or rec["lng"] is None:
+ continue
+ name = rec.get("asset_name") or rec.get("machine_id") or rec.get("filename", "Photo")
+ desc_lines = [
+ f"Filename: {rec['filename']}",
+ f"Machine ID: {rec.get('machine_id', 'N/A')}",
+ f"OCR Engine: {rec.get('ocr_engine', 'N/A')}",
+ f"Timestamp: {rec.get('timestamp', 'N/A')}",
+ ]
+ kml_parts.append(" ")
+ kml_parts.append(f" {name}")
+ kml_parts.append(f" {chr(10).join(desc_lines)}")
+ kml_parts.append(" ")
+ kml_parts.append(f" {rec['lng']},{rec['lat']},0")
+ kml_parts.append(" ")
+ kml_parts.append(" ")
+ kml_parts.append(" ")
+ kml_parts.append("")
+ kml_content = "\n".join(kml_parts)
+ return StreamingResponse(
+ iter([kml_content]),
+ media_type="application/vnd.google-earth.kml+xml",
+ headers={"Content-Disposition": f"attachment; filename=exif-export.kml"},
+ )
+ else: # clipboard
+ lines = []
+ for rec in records:
+ if rec["lat"] is not None and rec["lng"] is not None:
+ label = rec.get("asset_name") or rec.get("machine_id") or rec.get("filename", "?")
+ lines.append(f"{rec['lat']:.6f},{rec['lng']:.6f} # {label}")
+ return {"count": len(lines), "text": "\n".join(lines)}
+
+
+@router.post("/assign-machine-id")
+async def assign_machine_id(request: dict):
+ """Manually assign a machine_id to a photo record."""
+ photo_id = request.get("photo_id")
+ machine_id = (request.get("machine_id") or "").strip()
+ if not photo_id or not machine_id:
+ raise HTTPException(400, "photo_id and machine_id are required")
+ conn = _get_photos_db()
+ try:
+ row = conn.execute("SELECT id, gps_lat, gps_lng FROM photos WHERE id = ?", (photo_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, f"Photo {photo_id} not found")
+ conn.execute("UPDATE photos SET machine_id = ? WHERE id = ?", (machine_id, photo_id))
+ conn.commit()
+ finally:
+ conn.close()
+ asset = lookup_machine_id(machine_id)
+ has_photo_gps = row["gps_lat"] is not None and row["gps_lng"] is not None
+ needs_gps = bool(asset and (asset["latitude"] is None or asset["longitude"] is None) and has_photo_gps)
+ return {
+ "photo_id": photo_id,
+ "machine_id": machine_id,
+ "asset": asset,
+ "needs_gps": needs_gps,
+ }
+
+
+@router.get("/nearby")
+async def nearby_assets(
+ lat: float = Query(...),
+ lng: float = Query(...),
+ radius: float = Query(default=10.0, ge=1, le=1000),
+ exclude_photo_id: int | None = Query(default=None),
+):
+ """Find canteen assets near a GPS point."""
+ conn = _get_canteen_db()
+ if not conn:
+ return {"nearby": [], "count": 0, "radius_m": radius}
+ try:
+ lat_rad = lat * 3.14159265 / 180.0
+ deg_per_m_lat = 1.0 / 111320.0
+ deg_per_m_lng = 1.0 / (111320.0 * math.cos(lat_rad))
+ rad_deg = radius * deg_per_m_lat
+ rows = conn.execute(
+ """SELECT id, machine_id, name, latitude, longitude,
+ building_name, floor, room, category, status
+ FROM assets
+ WHERE latitude IS NOT NULL AND longitude IS NOT NULL
+ AND (latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?) < ?
+ ORDER BY (latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?)
+ LIMIT 50""",
+ (lat, lat, lng, lng, rad_deg * rad_deg,
+ lat, lat, lng, lng),
+ ).fetchall()
+ nearby = []
+ for row in rows:
+ dlat = row["latitude"] - lat
+ dlng = (row["longitude"] - lng) * math.cos(lat_rad)
+ distance_m = (dlat * dlat + dlng * dlng) ** 0.5 * 111320.0
+ if distance_m >= radius:
+ continue
+ nearby.append({
+ "asset_id": row["id"],
+ "machine_id": row["machine_id"],
+ "name": row["name"],
+ "distance_m": round(distance_m, 1),
+ "latitude": row["latitude"],
+ "longitude": row["longitude"],
+ "has_gps": True,
+ "building": row.get("building_name"),
+ "floor": row.get("floor"),
+ "room": row.get("room"),
+ "category": row.get("category"),
+ "status": row.get("status"),
+ })
+ nearby.sort(key=lambda x: x["distance_m"])
+ return {"nearby": nearby, "count": len(nearby), "radius_m": radius}
+ finally:
+ conn.close()
+
+
+@router.get("/sessions")
+async def list_sessions():
+ """List all sessions, newest first."""
+ conn = _get_photos_db()
+ try:
+ rows = conn.execute(
+ "SELECT * FROM sessions ORDER BY created_at DESC LIMIT 100"
+ ).fetchall()
+ return {"sessions": [_row_to_dict(r) for r in rows]}
+ finally:
+ conn.close()
+
+
+@router.post("/sessions")
+async def create_session(request: dict):
+ """Create a new session."""
+ name = (request.get("name") or "").strip() or f"Session {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}"
+ building = (request.get("building") or "").strip()
+ floor = (request.get("floor") or "").strip()
+ conn = _get_photos_db()
+ try:
+ cur = conn.execute(
+ "INSERT INTO sessions (name, building, floor) VALUES (?, ?, ?)",
+ (name, building, floor),
+ )
+ conn.commit()
+ session_id = cur.lastrowid
+ row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
+ return _row_to_dict(row)
+ finally:
+ conn.close()
+
+
+@router.get("/sessions/{session_id}")
+async def get_session(session_id: int):
+ """Get a session with all its photos."""
+ conn = _get_photos_db()
+ try:
+ srow = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
+ if not srow:
+ raise HTTPException(404, "Session not found")
+ session = _row_to_dict(srow)
+ photos = conn.execute(
+ "SELECT * FROM photos WHERE session_id = ? ORDER BY created_at ASC",
+ (session_id,),
+ ).fetchall()
+ session["photos"] = [_row_to_dict(r) for r in photos]
+ total = len(session["photos"])
+ matched = sum(1 for p in session["photos"] if p.get("machine_id"))
+ needs_gps = sum(1 for p in session["photos"] if p.get("machine_id") and (p.get("gps_lat") is None))
+ conn.execute(
+ "UPDATE sessions SET photo_count=?, matched_count=?, needs_gps_count=? WHERE id=?",
+ (total, matched, needs_gps, session_id),
+ )
+ conn.commit()
+ session["photo_count"] = total
+ session["matched_count"] = matched
+ session["needs_gps_count"] = needs_gps
+ return session
+ finally:
+ conn.close()
+
+
+@router.post("/sessions/{session_id}/close")
+async def close_session(session_id: int):
+ """Finalize a session."""
+ conn = _get_photos_db()
+ try:
+ row = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
+ if not row:
+ raise HTTPException(404, "Session not found")
+ conn.execute(
+ "UPDATE sessions SET closed_at = datetime('now') WHERE id = ?",
+ (session_id,),
+ )
+ conn.commit()
+ return {"ok": True, "session_id": session_id, "closed_at": "now"}
+ finally:
+ conn.close()
+
+
+@router.post("/bulk-assign")
+async def bulk_assign_machine_id(request: dict):
+ """Assign the same machine_id to multiple photos at once."""
+ photo_ids = request.get("photo_ids", [])
+ machine_id = (request.get("machine_id") or "").strip()
+ if not photo_ids or not isinstance(photo_ids, list):
+ raise HTTPException(400, "photo_ids must be a non-empty list")
+ if not machine_id:
+ raise HTTPException(400, "machine_id is required")
+ asset = lookup_machine_id(machine_id)
+ conn = _get_photos_db()
+ no_gps_files = []
+ gps_pushed = 0
+ try:
+ for pid in photo_ids:
+ row = conn.execute(
+ "SELECT id, orig_filename, gps_lat, gps_lng, machine_id FROM photos WHERE id = ?",
+ (pid,),
+ ).fetchone()
+ if not row:
+ continue
+ conn.execute(
+ "UPDATE photos SET machine_id = ? WHERE id = ?",
+ (machine_id, pid),
+ )
+ if asset and (asset["latitude"] is None or asset["longitude"] is None):
+ gps_lat = row["gps_lat"]
+ gps_lng = row["gps_lng"]
+ if gps_lat is not None and gps_lng is not None:
+ canteen = _get_canteen_db()
+ if canteen:
+ try:
+ canteen.execute(
+ "UPDATE assets SET latitude=?, longitude=?, updated_at=datetime('now') WHERE id=?",
+ (float(gps_lat), float(gps_lng), asset["id"]),
+ )
+ canteen.commit()
+ gps_pushed += 1
+ finally:
+ canteen.close()
+ else:
+ no_gps_files.append(row.get("orig_filename", f"photo_{pid}"))
+ conn.commit()
+ finally:
+ conn.close()
+ return {
+ "updated": len(photo_ids),
+ "gps_pushed": gps_pushed,
+ "no_gps": no_gps_files,
+ }
diff --git a/static/index.html b/static/index.html
index ce58495..4c0ce5a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -267,8 +267,32 @@
.data-table .td-actions button { margin-left: 4px; }
.data-table .rpt-number { text-align: right; font-variant-numeric: tabular-nums; }
+ .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
+ .status-active { background: var(--green); }
+ .status-maintenance { background: var(--amber); }
+ .status-retired { background: var(--red); }
+
.table-wrap { overflow-x: auto; }
+ .ae-cell { cursor: pointer; transition: background 0.15s; }
+ .ae-cell:hover { background: rgba(91,110,247,0.08); }
+ .ae-cell.editing { padding: 0 !important; }
+ .ae-empty-cell { border-left: 2px solid var(--amber); }
+ .ae-empty { color: var(--amber); font-style: italic; }
+ .ae-input {
+ background: var(--card2); color: var(--text);
+ border: 1px solid var(--accent); border-radius: 4px;
+ padding: 6px 8px; font-size: 11px; width: 100%;
+ outline: none; box-sizing: border-box;
+ }
+ .ae-input:focus { border-color: var(--accent2); box-shadow: 0 0 0 2px var(--accent-bg); }
+ .ae-select {
+ background: var(--card2); color: var(--text);
+ border: 1px solid var(--accent); border-radius: 4px;
+ padding: 6px 4px; font-size: 11px; width: 100%;
+ outline: none; cursor: pointer;
+ }
+
/* ═══════════════════════════════════════════════════════════════════════
STATUS & BADGES
═══════════════════════════════════════════════════════════════════════ */
@@ -295,6 +319,10 @@
.role-badge.technician { background: var(--green-bg); color: var(--green); }
.role-badge.readonly { background: var(--amber-bg); color: var(--amber); }
+ .checkbox-group { display: flex; flex-direction: column; gap: 6px; }
+ .checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
+ .checkbox-label input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; }
+
/* ═══════════════════════════════════════════════════════════════════════
STATS GRID
═══════════════════════════════════════════════════════════════════════ */
@@ -510,6 +538,7 @@
.mt-8 { margin-top: 8px; }
.mb-8 { margin-bottom: 8px; }
.text-sm { font-size: 12px; }
+ .text-xs { font-size: 11px; }
.text-muted { color: var(--text2); }
.text-danger { color: var(--red); }
.w-full { width: 100%; }
@@ -688,6 +717,8 @@
.cs-repl-status.rejected { background: rgba(240,61,62,0.15); color: var(--red); }
.cs-repl-resolve-all { margin-top: 8px; }
+
+
@@ -724,6 +755,9 @@
+
@@ -742,6 +776,9 @@
+
+
+
+
+
Assets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📸 EXIF Scanner
+
+
+
+
+
+
+
+
📷
+
Drop photos or click to upload
+
JPEG, PNG, HEIC — GPS data preserved
+
+
+
+
+
+
+ 🔎 OCR:
+
+
+ tesseract
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+ Bulk Results
+
+
+
+
+
+
+
+ 📎 0 selected
+
+
+
+
+
+
+
+
+
+
+
📋 Sessions 0
+
+
+
+
+
+
+
+
+
+
+
+ 📂 Previously Processed 0
+
+
+
+
+
+
@@ -1085,11 +1238,13 @@ function switchPage(page) {
document.getElementById('sidebarOverlay')?.classList.remove('open');
// Lazy load
if (page === 'dashboard') loadDashboard();
+ else if (page === 'assets') loadAssets();
else if (page === 'settings') renderAllSettings();
else if (page === 'users') loadUsers();
else if (page === 'customers') loadCustomers();
else if (page === 'activity') loadActivity();
else if (page === 'cantaloupe') loadCantaloupeSync();
+ else if (page === 'exifscanner') loadExifScanner();
}
function closeDetail() {
if (AppState._prevPage) switchPage(AppState._prevPage);
@@ -1256,7 +1411,437 @@ function renderDashActivity(items) {
}
// ═════════════════════════════════════════════════════════════════════════════
-// SETTINGS — All Entities CRUD
+// ASSETS — Sortable Table View with Inline Editing
+// ═════════════════════════════════════════════════════════════════════════════
+
+let assetSortField = 'name';
+let assetSortDir = 'asc';
+let cachedAssets = [];
+let assetSettingsCache = {};
+
+const ASSET_DROPDOWN_FIELDS = {
+ category: { api: '/api/settings/categories', labelKey: 'name', valueKey: 'name' },
+ status: { static: ['active', 'maintenance', 'retired'] },
+ make: { api: '/api/settings/makes', labelKey: 'name', valueKey: 'name' },
+ model: { api: '/api/settings/models', labelKey: 'name', valueKey: 'name' },
+};
+
+const ASSET_DATE_FIELDS = ['install_date', 'dex_report_date', 'pulled_date'];
+const ASSET_CHECK_FIELDS = ['deployed'];
+const ASSET_TEXT_FIELDS = ['serial_number', 'name', 'company', 'address', 'building_name', 'building_number', 'floor', 'room', 'place', 'location_area'];
+
+async function loadAssetSettings() {
+ if (assetSettingsCache._loaded) return;
+ try {
+ const [catRes, makeRes] = await Promise.all([
+ api('/api/settings/categories'),
+ api('/api/settings/makes'),
+ ]);
+ assetSettingsCache = {
+ categories: catRes || [],
+ makes: makeRes || [],
+ _loaded: true,
+ };
+ } catch (e) {
+ console.warn('Failed to load asset settings:', e);
+ assetSettingsCache = { categories: [], makes: [], _loaded: true };
+ }
+}
+
+async function loadAssets() {
+ const el = document.getElementById('assetsContent');
+ const search = document.getElementById('assetSearch')?.value?.trim() || '';
+ const status = document.getElementById('assetStatusFilter')?.value || '';
+
+ await loadAssetSettings();
+
+ let url = '/api/assets?limit=5000';
+ if (search) url += '&q=' + encodeURIComponent(search);
+ if (status) url += '&status=' + encodeURIComponent(status);
+
+ try {
+ const resp = await api(url);
+ const data = typeof resp === 'object' && resp.assets ? resp : { assets: resp, total: resp?.length || 0 };
+ cachedAssets = data.assets || [];
+ document.getElementById('assetBadgeNav').textContent = data.total || cachedAssets.length;
+ renderAssetTable();
+ } catch (e) {
+ el.innerHTML = `⚠️
Failed to load assets: ${esc(e.message)}
`;
+ }
+}
+
+function toggleAssetSort(field) {
+ if (assetSortField === field) assetSortDir = assetSortDir === 'asc' ? 'desc' : 'asc';
+ else { assetSortField = field; assetSortDir = 'asc'; }
+ renderAssetTable();
+}
+
+// ── Inline Editing ──
+
+function startEdit(assetId, field, currentVal) {
+ const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="${field}"]`);
+ if (!cell) return;
+ if (cell.classList.contains('editing')) return;
+ cell.classList.add('editing');
+
+ const safeVal = currentVal == null ? '' : String(currentVal);
+
+ if (field === 'deployed') {
+ cell.innerHTML = ``;
+ const sel = cell.querySelector('select');
+ sel.focus();
+ sel.onchange = () => saveCell(assetId, field, sel.value);
+ sel.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200);
+ return;
+ }
+
+ if (ASSET_DATE_FIELDS.includes(field)) {
+ const dateVal = safeVal.substring(0, 10);
+ cell.innerHTML = ``;
+ const inp = cell.querySelector('input');
+ inp.focus();
+ inp.onchange = () => saveCell(assetId, field, inp.value);
+ inp.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200);
+ inp.onkeydown = (e) => { if (e.key === 'Escape') renderAssetTable(); };
+ return;
+ }
+
+ // Dropdown fields
+ if (ASSET_DROPDOWN_FIELDS[field]) {
+ const cfg = ASSET_DROPDOWN_FIELDS[field];
+ let options = [];
+ if (cfg.static) {
+ options = cfg.static;
+ } else if (cfg.api) {
+ const list = cfg.api.includes('categories') ? assetSettingsCache.categories : assetSettingsCache.makes;
+ options = list.map(item => item[cfg.valueKey]);
+ }
+ cell.innerHTML = ``;
+ const sel = cell.querySelector('select');
+ sel.focus();
+ sel.onchange = () => saveCell(assetId, field, sel.value);
+ sel.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200);
+ sel.onkeydown = (e) => { if (e.key === 'Escape') renderAssetTable(); };
+ return;
+ }
+
+ // Text fields
+ cell.innerHTML = ``;
+ const inp = cell.querySelector('input');
+ inp.focus();
+ inp.select();
+ inp.onchange = () => saveCell(assetId, field, inp.value);
+ inp.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200);
+ inp.onkeydown = (e) => {
+ if (e.key === 'Enter') { inp.blur(); }
+ if (e.key === 'Escape') renderAssetTable();
+ };
+}
+
+async function saveCell(assetId, field, value) {
+ const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="${field}"]`);
+ if (!cell) return;
+
+ // Optimistic update
+ const asset = cachedAssets.find(a => a.id === assetId);
+ if (asset) {
+ if (field === 'deployed') {
+ asset[field] = value === '1' ? 1 : 0;
+ } else {
+ asset[field] = value;
+ }
+ }
+
+ cell.classList.remove('editing');
+ renderAssetTable();
+ showToast(`Saving ${field}...`);
+
+ try {
+ const payload = {};
+ payload[field] = value;
+ const updated = await api(`/api/assets/${assetId}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ if (asset && updated) Object.assign(asset, updated);
+ showToast(`✓ ${field} saved`);
+ } catch (e) {
+ showToast(`✗ Failed to save ${field}: ${e.message}`, true);
+ if (asset) renderAssetTable();
+ }
+}
+
+function renderAssetCell(asset, field) {
+ const val = asset[field];
+ const isEmpty = val === null || val === undefined || val === '';
+ const emptyCls = isEmpty ? 'ae-empty' : '';
+
+ if (field === 'deployed') {
+ return `${val ? '✓' : '✗'}`;
+ }
+ if (field === 'status') {
+ const statusClass = val === 'active' ? 'status-active' : val === 'maintenance' ? 'status-maintenance' : 'status-retired';
+ return `${esc(val || '—')}`;
+ }
+ if (field === 'address') {
+ return `${esc((val || '').substring(0, 22))}`;
+ }
+ if (field === 'building_name') {
+ const parts = [asset.building_name, asset.building_number].filter(Boolean);
+ return `${esc(parts.join(' ') || '—')}`;
+ }
+ if (field === 'room') {
+ return `${esc(asset.room || asset.place || '—')}`;
+ }
+ const display = val == null || val === '' ? '—' : String(val).substring(0, 25);
+ return `${esc(display)}`;
+}
+
+function renderAssetTable() {
+ const el = document.getElementById('assetsContent');
+
+ const sorted = [...cachedAssets].sort((a, b) => {
+ let va = (a[assetSortField] || '').toString().toLowerCase();
+ let vb = (b[assetSortField] || '').toString().toLowerCase();
+ if (assetSortField === 'machine_id' || assetSortField === 'id') {
+ va = Number(a[assetSortField]) || 0;
+ vb = Number(b[assetSortField]) || 0;
+ return assetSortDir === 'asc' ? va - vb : vb - va;
+ }
+ if (va < vb) return assetSortDir === 'asc' ? -1 : 1;
+ if (va > vb) return assetSortDir === 'asc' ? 1 : -1;
+ return 0;
+ });
+
+ if (sorted.length === 0) {
+ el.innerHTML = '';
+ document.getElementById('assetPagination').innerHTML = '';
+ return;
+ }
+
+ const sortArrow = (f) => assetSortField === f ? (assetSortDir === 'asc' ? ' ▲' : ' ▼') : '';
+
+ const INLINE_FIELDS = [
+ 'machine_id', 'serial_number', 'name', 'company', 'category', 'status',
+ 'make', 'model', 'address', 'building_name', 'room', 'floor', 'location_area',
+ 'install_date', 'dex_report_date', 'deployed', 'pulled_date',
+ ];
+
+ const HEADER_LABELS = {
+ machine_id: 'ID', serial_number: 'Serial', name: 'Name', company: 'Company',
+ category: 'Cat', status: 'Status', make: 'Make', model: 'Model',
+ address: 'Address', building_name: 'Bldg', room: 'Room/Place', floor: 'Floor',
+ location_area: 'Area', install_date: 'Install', dex_report_date: 'DEX Date',
+ deployed: 'Dep', pulled_date: 'Pulled',
+ };
+
+ const rows = sorted.map(a => {
+ return `
+ ${INLINE_FIELDS.map(f => {
+ let cls = 'text-xs ae-cell';
+ if (f === 'name') cls += ' ae-name';
+ if (f === 'machine_id') cls += ' ae-id';
+ const val = a[f];
+ const isEmpty = val === null || val === undefined || val === '';
+ if (isEmpty) cls += ' ae-empty-cell';
+ return `| ${renderAssetCell(a, f)} | `;
+ }).join('')}
+
+ 🔍
+ |
+
`;
+ }).join('');
+
+ el.innerHTML = `
+ 💡 Click any cell to edit
+ ■ = empty field
+
+
+
+
+
+ ${INLINE_FIELDS.map(f => `| ${HEADER_LABELS[f]}${sortArrow(f)} | `).join('')}
+
+
+ ${rows}
+
+
`;
+
+ document.getElementById('assetPagination').innerHTML = `${sorted.length} asset${sorted.length !== 1 ? 's' : ''} · click cells to edit, Enter to save, Esc to cancel`;
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// BUSINESS NAME LOOKUP (Google Maps)
+// ═════════════════════════════════════════════════════════════════════════════
+
+async function lookupBusinessName(assetId) {
+ const asset = cachedAssets.find(a => a.id === assetId);
+ if (!asset) return;
+ const addr = asset.address?.trim();
+ if (!addr) { showToast('No address to look up for this asset', true); return; }
+
+ showToast(`🔍 Looking up "${addr}"...`);
+ try {
+ const result = await api('/api/lookup-business', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ address: addr }),
+ });
+ const name = result.business_name;
+ if (!name) {
+ showToast('No business name found at this address', true);
+ return;
+ }
+ showToast(`Found: "${name}" — Click "Apply" to save`, false, 8000);
+ // Show a subtle inline prompt to apply
+ const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="company"]`);
+ if (cell) {
+ cell.innerHTML += ` [Apply: ${esc(name)}]`;
+ }
+ } catch (e) {
+ showToast(`✗ Lookup failed: ${e.message}`, true);
+ }
+}
+
+async function applyBusinessName(assetId, name) {
+ const asset = cachedAssets.find(a => a.id === assetId);
+ if (!asset) return;
+ // Save to company field
+ try {
+ const updated = await api(`/api/assets/${assetId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ company: name }),
+ });
+ if (asset) asset.company = name;
+ showToast(`✓ Company set to "${name}"`);
+ renderAssetTable();
+ } catch (e) {
+ showToast(`✗ Failed to save: ${e.message}`, true);
+ }
+}
+
+async function batchLookupAll() {
+ const withAddr = cachedAssets.filter(a => a.address?.trim() && !a.company?.trim());
+ if (withAddr.length === 0) {
+ const anyWithAddr = cachedAssets.filter(a => a.address?.trim());
+ if (anyWithAddr.length === 0) { showToast('No assets with addresses found', true); return; }
+ showToast('All assets already have company names set', true);
+ return;
+ }
+
+ const batchSize = Math.min(withAddr.length, 50);
+ showToast(`🔍 Looking up ${batchSize} address${batchSize > 1 ? 'es' : ''} (showing first 50)...`, false, 10000);
+
+ const el = document.getElementById('assetsContent');
+
+ // Add results panel
+ let resultsPanel = document.getElementById('lookupResultsPanel');
+ if (!resultsPanel) {
+ resultsPanel = document.createElement('div');
+ resultsPanel.id = 'lookupResultsPanel';
+ resultsPanel.className = 'card';
+ resultsPanel.style.marginTop = '12px';
+ el.parentNode.insertBefore(resultsPanel, el.nextSibling);
+ }
+ resultsPanel.innerHTML = `🔍 Business Name Lookup Results
+ Looking up ${batchSize} addresses...
`;
+
+ // Batch call — only pass assets without company names, limited to 50
+ try {
+ const assets = withAddr.slice(0, 50).map(a => ({ id: a.id, address: a.address }));
+ const resp = await api('/api/lookup-business/batch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ assets }),
+ });
+ const results = resp.results || [];
+
+ const found = results.filter(r => r.business_name);
+ const notFound = results.filter(r => !r.business_name);
+
+ resultsPanel.innerHTML = `🔍 Business Name Lookup Results
+
+ ✓ ${found.length} found
+ ✗ ${notFound.length} not found
+ · ${results.length} total looked up
+
+ ${found.length > 0 ? `
+
+
+
+ | Business Name |
+ Address |
+ Current Company |
+ |
+
+
+ ${found.map(r => {
+ const asset = cachedAssets.find(a => a.id === r.id);
+ const currentCompany = asset?.company || '';
+ const needsUpdate = currentCompany !== r.business_name;
+ return `
+ | ${esc(r.business_name)} |
+ ${esc(r.address)} |
+ ${esc(currentCompany || '—empty—')} |
+ ${needsUpdate ? `` : '✅'} |
+
`;
+ }).join('')}
+
+
+
+ ` : ''}
+ ${notFound.length > 0 ? `
+
+
+ ${notFound.length} address${notFound.length > 1 ? 'es' : ''} with no result
+
+
+ ${notFound.map(r => {
+ const asset = cachedAssets.find(a => a.id === r.id);
+ return `
+ ${asset?.name || 'ID ' + r.id}: ${esc(r.address)}
+
`;
+ }).join('')}
+
+
+ ` : ''}
+
+
+
+
`;
+ showToast(`✓ Lookup complete: ${found.length} found, ${notFound.length} not found`);
+ } catch (e) {
+ showToast(`✗ Batch lookup failed: ${e.message}`, true);
+ resultsPanel.innerHTML += `Error: ${esc(e.message)}
`;
+ }
+}
+
+async function applyAllFoundNames() {
+ const panel = document.getElementById('lookupResultsPanel');
+ if (!panel) return;
+ // Parse the results table
+ const rows = panel.querySelectorAll('table tbody tr');
+ let count = 0;
+ for (const row of rows) {
+ const applyBtn = row.querySelector('.btn-green');
+ if (applyBtn) applyBtn.click();
+ count++;
+ // Small delay to not overwhelm
+ if (count % 5 === 0) await new Promise(r => setTimeout(r, 100));
+ }
+ showToast(`Applied ${count} names`);
+}
// ═════════════════════════════════════════════════════════════════════════════
async function loadSettingsCache() {
try {
@@ -1510,6 +2095,12 @@ async function loadUsers() {
}
}
+function renderRoleBadges(roleStr) {
+ if (!roleStr) return 'technician';
+ const roles = roleStr.split(',').map(r => r.trim()).filter(Boolean);
+ return roles.map(r => `${r}`).join(' ');
+}
+
function renderUsers() {
const el = document.getElementById('usersContent');
if (cachedUsers.length === 0) {
@@ -1527,7 +2118,7 @@ function renderUsers() {
${cachedUsers.map(u => `
| ${u.id} |
${esc(u.username)} |
- ${u.role || 'technician'} |
+ ${renderRoleBadges(u.role)} |
${formatDate(u.created_at)} |
@@ -1550,12 +2141,12 @@ function showAddUserForm() {
`;
document.getElementById('modalActions').innerHTML = `
@@ -1565,10 +2156,14 @@ function showAddUserForm() {
document.getElementById('modalOverlay').classList.add('open');
}
+function getSelectedRoles() {
+ return Array.from(document.querySelectorAll('.role-cb:checked')).map(cb => cb.value).join(',');
+}
+
async function saveAddUser() {
const username = document.getElementById('modalFormUser').value.trim();
const password = document.getElementById('modalFormPass').value;
- const role = document.getElementById('modalFormRole').value;
+ const role = getSelectedRoles();
if (!username || !password) { showToast('Username and password required', true); return; }
try {
await api('/api/users', {
@@ -1588,12 +2183,12 @@ function showEditUserForm(userId) {
document.getElementById('modalTitle').textContent = `Edit User: ${esc(user.username)}`;
document.getElementById('modalBody').innerHTML = `
@@ -1608,7 +2203,7 @@ function showEditUserForm(userId) {
}
async function saveEditUser(userId) {
- const role = document.getElementById('modalFormRole').value;
+ const role = getSelectedRoles();
const password = document.getElementById('modalFormPass').value;
const body = { role };
if (password) body.password = password;
@@ -2895,6 +3490,538 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.key === 'Enter') document.getElementById('loginPass').focus();
});
});
+
+// ═════════════════════════════════════════════════════════════════════════════
+// EXIF SCANNER — State
+// ═════════════════════════════════════════════════════════════════════════════
+let exifAllPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
+let exifSelectedIdx = -1;
+let exifBulkData = [];
+let exifMap = null;
+let exifLastPhotoId = null;
+let exifLastPhotoGps = null;
+
+function esc(s) {
+ const d = document.createElement('div');
+ d.textContent = String(s);
+ return d.innerHTML;
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// EXIF SCANNER — Page Init
+// ═════════════════════════════════════════════════════════════════════════════
+async function loadExifScanner() {
+ exifAllPhotos = [];
+ exifSelectedIdx = -1;
+ exifBulkData = [];
+ if (exifMap) { exifMap.remove(); exifMap = null; }
+ document.getElementById('exifGallery').innerHTML = '';
+ document.getElementById('exifBulkResults').innerHTML = '';
+ document.getElementById('exifDetail').style.display = 'none';
+ document.getElementById('exifBulkSection').style.display = 'none';
+ document.getElementById('exifSessionsSection').style.display = 'none';
+ document.getElementById('exifPrevSection').style.display = 'none';
+ document.getElementById('exifOcrOptions').style.display = 'none';
+ document.getElementById('exifSummary').style.display = 'none';
+ // Load sessions and previous photos in parallel
+ await Promise.all([
+ loadExifSessions(),
+ loadExifPrevPhotos(),
+ ]);
+}
+
+async function loadExifSessions() {
+ try {
+ const data = await api('/api/admin/exif/sessions');
+ const sessions = data.sessions || [];
+ const list = document.getElementById('exifSessionsList');
+ const section = document.getElementById('exifSessionsSection');
+ if (sessions.length === 0) {
+ section.style.display = 'none';
+ return;
+ }
+ section.style.display = 'block';
+ document.getElementById('exifSessionCount').textContent = sessions.length;
+ list.innerHTML = sessions.map(s => {
+ const closed = s.closed_at ? `🔒 Closed ${new Date(s.closed_at).toLocaleDateString()}` : '🟢 Open';
+ return `
+
+ ${esc(s.name || 'Unnamed')}
+ ${s.building || ''}${s.floor ? ' • Floor ' + s.floor : ''} — ${s.photo_count || 0} photos, ${s.matched_count || 0} matched
+
+ ${closed}
+ `;
+ }).join('');
+ } catch (e) {
+ /* session section stays hidden */
+ }
+}
+
+async function loadExifPrevPhotos() {
+ try {
+ const data = await api('/api/admin/exif/photos?limit=30');
+ const photos = data.photos || [];
+ const section = document.getElementById('exifPrevSection');
+ const div = document.getElementById('exifPrevResults');
+ if (!photos.length) {
+ section.style.display = 'none';
+ return;
+ }
+ section.style.display = 'block';
+ document.getElementById('exifPrevCount').textContent = photos.length;
+ div.innerHTML = photos.map((p, i) => renderExifPrevCard(p, i)).join('');
+ } catch (e) {
+ const section = document.getElementById('exifPrevSection');
+ section.style.display = 'none';
+ }
+}
+
+function renderExifPrevCard(p, idx) {
+ const hasGps = p.gps_lat && p.gps_lng;
+ const matched = p.machine_id ? 'matched' : 'no-match';
+ const gpsBadge = hasGps
+ ? ' 📍 GPS'
+ : ' 📍 No GPS';
+ const matchBadge = matched === 'matched'
+ ? ` ✓ ${esc(p.machine_id)}`
+ : ' ⏳ No match';
+ const uploaderBadge = p.uploaded_by
+ ? ` 👤 ${esc(p.uploaded_by)}`
+ : '';
+ const thumb = p.id ? `/api/admin/exif/photos/${p.id}/file` : '';
+ return ` \n \n ${thumb ? `  ` : ''}\n \n ${esc(p.orig_filename || 'photo.jpg')} \n \n ${gpsBadge} ${matchBadge} ${uploaderBadge}
+ \n ${p.created_at ? new Date(p.created_at).toLocaleString() : ''} \n \n \n `;
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// EXIF SCANNER — File handling
+// ═════════════════════════════════════════════════════════════════════════════
+function handleExifFiles(files) {
+ if (!files || !files.length) return;
+ exifAllPhotos = [];
+ exifSelectedIdx = -1;
+ exifBulkData = [];
+ if (exifMap) { exifMap.remove(); exifMap = null; }
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ // Check EXIF in browser first
+ let hasGps = false, lat = null, lng = null;
+ if (typeof exifr !== 'undefined' && exifr.parse) {
+ exifr.parse(file).then(exif => {
+ hasGps = !!(exif && exif.latitude && exif.longitude);
+ lat = hasGps ? exif.latitude : null;
+ lng = hasGps ? exif.longitude : null;
+ exifAllPhotos.push({ file, hasGps, lat, lng, thumb: e.target.result });
+ if (exifAllPhotos.length === files.length) {
+ renderExifGallery();
+ showExifOcrOptions();
+ }
+ }).catch(() => {
+ exifAllPhotos.push({ file, hasGps: false, lat: null, lng: null, thumb: e.target.result });
+ if (exifAllPhotos.length === files.length) {
+ renderExifGallery();
+ showExifOcrOptions();
+ }
+ });
+ } else {
+ exifAllPhotos.push({ file, hasGps: false, lat: null, lng: null, thumb: e.target.result });
+ if (exifAllPhotos.length === files.length) {
+ renderExifGallery();
+ showExifOcrOptions();
+ }
+ }
+ };
+ reader.readAsDataURL(file);
+ }
+}
+
+function renderExifGallery() {
+ const gallery = document.getElementById('exifGallery');
+ const total = exifAllPhotos.length;
+ const withGps = exifAllPhotos.filter(p => p.hasGps).length;
+ const noGps = total - withGps;
+ gallery.innerHTML = exifAllPhotos.map((p, i) => `
+
+ })
+ 📍 ${p.hasGps ? 'GPS' : 'No GPS'}
+
+ `).join('');
+ gallery.style.display = 'grid';
+
+ // Update summary
+ document.getElementById('exifSummary').style.display = 'block';
+ document.getElementById('exifSumGps').textContent = withGps;
+ document.getElementById('exifSumNoGps').textContent = noGps;
+ document.getElementById('exifSumTotal').textContent = total;
+ document.getElementById('exifSumMatched').textContent = 0;
+}
+
+function showExifOcrOptions() {
+ const total = exifAllPhotos.length;
+ document.getElementById('exifOcrOptions').style.display = 'block';
+ document.getElementById('exifBulkBtn').style.display = total > 0 ? 'inline-flex' : 'none';
+ document.getElementById('exifFileCount').textContent = total;
+}
+
+function onExifOcrToggle() {
+ const engine = document.getElementById('exifOcrEngine').value;
+ const sticker = document.getElementById('exifStickerMode').checked;
+ const label = document.getElementById('exifModelLabel');
+ if (engine === 'llm' && sticker) label.textContent = '🏷️ sticker mode';
+ else if (engine === 'llm') label.textContent = 'mimo-v2-omni';
+ else if (engine === 'google' && sticker) label.textContent = '🏷️ gemini sticker';
+ else if (engine === 'google') label.textContent = 'gemini-2.5-flash';
+ else label.textContent = 'tesseract';
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// EXIF SCANNER — Bulk Process
+// ═════════════════════════════════════════════════════════════════════════════
+async function startExifBulkProcess() {
+ const btn = document.getElementById('exifBulkBtn');
+ btn.disabled = true;
+ btn.textContent = '⏳ Processing...';
+ try {
+ const formData = new FormData();
+ for (const p of exifAllPhotos) {
+ formData.append('files', p.file);
+ }
+ const engine = document.getElementById('exifOcrEngine').value;
+ const sticker = document.getElementById('exifStickerMode').checked;
+ let url = `/api/admin/exif/bulk-process?ocr_engine=${engine}`;
+ if (sticker) url += '&sticker_mode=true';
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + AppState.authToken },
+ body: formData,
+ });
+ if (!resp.ok) {
+ const err = await resp.text();
+ throw new Error(err);
+ }
+ const data = await resp.json();
+ exifBulkData = data.results || [];
+ renderExifBulkResults();
+ } catch (e) {
+ document.getElementById('exifBulkResults').innerHTML =
+ ' ❌ Bulk process failed: ' + esc(e.message) + ' ';
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '🔍 Bulk Process (' + exifAllPhotos.length + ')';
+ }
+}
+
+function renderExifBulkResults() {
+ const section = document.getElementById('exifBulkSection');
+ section.style.display = 'block';
+ const div = document.getElementById('exifBulkResults');
+ const items = exifBulkData;
+ const hasGps = items.filter(i => i.exif && i.exif.gps).length;
+ const matched = items.filter(i => i.asset).length;
+ const needsGps = items.filter(i => i.needs_gps).length;
+ const total = items.length;
+ document.getElementById('exifBulkSummary').textContent = `📍${hasGps} ✓${matched} 📤${needsGps} 📷${total}`;
+ div.innerHTML = items.map((item, idx) => renderExifBulkCard(item, idx)).join('');
+ // Init map
+ initExifMap(items);
+ // Show batch bar if there are needs_gps items
+ const hasNeedsGps = items.some(i => i.needs_gps);
+ if (hasNeedsGps || items.length > 0) {
+ const bar = document.getElementById('exifBatchBar');
+ bar.style.display = 'block';
+ bar.style.marginTop = '8px';
+ }
+}
+
+function renderExifBulkCard(item, idx) {
+ const gps = item.exif && item.exif.gps;
+ const asset = item.asset;
+ const matchBadge = asset
+ ? ` ✓ ${esc(asset.name || asset.machine_id)}`
+ : (item.machine_id
+ ? ` 🔍 ${esc(item.machine_id)}`
+ : ' ⏳ No match');
+ const gpsBadge = gps
+ ? ` 📍 ${gps.lat.toFixed(5)}, ${gps.lng.toFixed(5)}`
+ : ' 📍 No GPS';
+ const dupBadge = item.duplicate
+ ? ' 🔁 Duplicate'
+ : '';
+ return `
+ ${esc(item.filename)}
+
+ ${gpsBadge} ${matchBadge} ${dupBadge}
+
+ ${item.needs_gps ? `
+
+ ` : ''}
+ `;
+}
+
+function initExifMap(items) {
+ const container = document.getElementById('exifMapContainer');
+ const points = items.filter(i => i.exif && i.exif.gps);
+ if (!points.length) { container.style.display = 'none'; return; }
+ container.style.display = 'block';
+
+ // Load Leaflet dynamically if not loaded
+ if (typeof L === 'undefined') {
+ const script = document.createElement('script');
+ script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
+ script.onload = () => initExifMapInner(points);
+ document.head.appendChild(script);
+ return;
+ }
+ initExifMapInner(points);
+}
+
+function initExifMapInner(points) {
+ if (exifMap) exifMap.remove();
+ const container = document.getElementById('exifMapContainer');
+ const center = { lat: points[0].exif.gps.lat, lng: points[0].exif.gps.lng };
+ exifMap = L.map(container).setView([center.lat, center.lng], 16);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap',
+ maxZoom: 19,
+ }).addTo(exifMap);
+ const bounds = [];
+ points.forEach((item, idx) => {
+ const gps = item.exif.gps;
+ const color = item.asset ? '#4ade80' : '#5b6ef7';
+ const marker = L.circleMarker([gps.lat, gps.lng], {
+ radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.8,
+ }).addTo(exifMap);
+ marker.bindPopup(` ${esc(item.filename)}📍 ${gps.lat.toFixed(5)}, ${gps.lng.toFixed(5)}${item.asset ? ' ✓ ' + esc(item.asset.name) : ''}`);
+ bounds.push([gps.lat, gps.lng]);
+ });
+ if (bounds.length > 1) exifMap.fitBounds(bounds, { padding: [30, 30] });
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// EXIF SCANNER — Detail View
+// ═════════════════════════════════════════════════════════════════════════════
+function showExifDetail(idx) {
+ exifSelectedIdx = idx;
+ renderExifGallery();
+ const photo = exifAllPhotos[idx];
+ const detail = document.getElementById('exifDetail');
+ const preview = document.getElementById('exifDetailPreview');
+ const content = document.getElementById('exifDetailContent');
+ detail.style.display = 'block';
+ preview.src = photo.thumb;
+ let html = '';
+ if (photo.lat && photo.lng) {
+ html += `
+ 📍 ${photo.lat.toFixed(6)}, ${photo.lng.toFixed(6)}
+ `;
+ } else {
+ html += ' ⚠️ No GPS data found in this photo ';
+ }
+ html += `
+
+ `;
+ content.innerHTML = html;
+}
+
+async function exifUploadSelected(idx) {
+ const photo = exifAllPhotos[idx];
+ const engine = document.getElementById('exifOcrEngine').value;
+ const sticker = document.getElementById('exifStickerMode').checked;
+ let url = `/api/admin/exif/analyze?ocr_engine=${engine}`;
+ if (sticker) url += '&sticker_mode=true';
+ const formData = new FormData();
+ formData.append('file', photo.file);
+ try {
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + AppState.authToken },
+ body: formData,
+ });
+ const data = await resp.json();
+ exifLastPhotoId = data.photo_id;
+ exifLastPhotoGps = data.exif && data.exif.gps ? { lat: data.exif.gps.lat, lng: data.exif.gps.lng } : null;
+ showExifServerResults(data);
+ } catch (e) {
+ document.getElementById('exifDetailContent').innerHTML +=
+ ` ❌ Analysis failed: ${esc(e.message)} `;
+ }
+}
+
+function showExifServerResults(data) {
+ const content = document.getElementById('exifDetailContent');
+ let html = ' ';
+ html += ' 🖥️ Server Results ';
+ // EXIF data
+ const exif = data.exif || {};
+ if (exif.has_exif) {
+ html += ' 📷 EXIF Tags ';
+ const tags = exif.tags || {};
+ const tagEntries = Object.entries(tags).slice(0, 15);
+ for (const [k, v] of tagEntries) {
+ html += `
+ ${esc(k)}
+ ${esc(v)}
+ `;
+ }
+ }
+ if (exif.gps) {
+ html += `
+ 📍 ${exif.gps.lat.toFixed(6)}, ${exif.gps.lng.toFixed(6)}
+ `;
+ }
+ // OCR results
+ const ocr = data.ocr || {};
+ if (ocr.raw_text) {
+ html += ' 🔎 OCR';
+ html += ` ${esc(ocr.engine || '?')}`;
+ html += ` ${esc(ocr.raw_text)} `;
+ }
+ if (ocr.match_5dash6 || ocr.match_5plus) {
+ html += ` ✓ ${esc(ocr.match_5dash6 || ocr.match_5plus)} `;
+ }
+ // Machine match
+ const asset = data.asset;
+ if (asset) {
+ html += `
+ ✓ Matched: ${esc(asset.name || asset.machine_id)}
+ ${asset.building_name || ''}${asset.floor ? ' Floor ' + asset.floor : ''}${asset.room ? ' • ' + asset.room : ''}
+ `;
+ } else if (data.machine_id) {
+ html += `
+ 🔍 Machine ID: ${esc(data.machine_id)} (not found in DB)
+ `;
+ }
+ html += ' ';
+ content.innerHTML += html;
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// EXIF SCANNER — GPS Push
+// ═════════════════════════════════════════════════════════════════════════════
+async function exifPushGpsToAsset(idx) {
+ const item = exifBulkData[idx];
+ if (!item || !item.asset || !item.needs_gps) return;
+ const btn = document.getElementById('exifPushBtn' + idx);
+ btn.disabled = true;
+ btn.textContent = '⏳...';
+ btn.style.background = 'var(--card2)';
+ const gps = item.exif.gps;
+ try {
+ const data = await api('/api/admin/exif/push-gps', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ asset_id: item.asset.id,
+ latitude: gps.lat,
+ longitude: gps.lng,
+ }),
+ });
+ if (data.updated) {
+ btn.textContent = '✓ Pushed!';
+ btn.style.background = 'var(--green)';
+ btn.style.color = '#000';
+ } else {
+ btn.textContent = data.reason || 'Failed';
+ btn.style.background = 'var(--red)';
+ }
+ } catch (e) {
+ btn.textContent = 'Error';
+ btn.style.background = 'var(--red)';
+ }
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// EXIF SCANNER — Batch bar
+// ═════════════════════════════════════════════════════════════════════════════
+async function exifBatchLookup() {
+ // Simple: find first unmatched photo and try the typed machine ID
+ const machineId = document.getElementById('exifBatchMachineId').value.trim();
+ if (!machineId) return;
+ try {
+ const data = await api('/api/admin/exif/lookup?machine_id=' + encodeURIComponent(machineId));
+ if (data.found) {
+ showToast('✓ Found: ' + data.asset.name);
+ } else {
+ showToast(data.reason || 'Not found', true);
+ }
+ } catch (e) {
+ showToast('Lookup failed: ' + e.message, true);
+ }
+}
+
+async function exifBatchPushGps() {
+ const machineId = document.getElementById('exifBatchMachineId').value.trim();
+ if (!machineId) return;
+ // Find all needs_gps items
+ const toPush = exifBulkData.filter(i => i.needs_gps && i.asset && i.exif && i.exif.gps);
+ if (!toPush.length) {
+ showToast('No GPS-ready items to push', true);
+ return;
+ }
+ let pushed = 0;
+ for (const item of toPush) {
+ try {
+ const data = await api('/api/admin/exif/push-gps', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ asset_id: item.asset.id,
+ latitude: item.exif.gps.lat,
+ longitude: item.exif.gps.lng,
+ }),
+ });
+ if (data.updated) pushed++;
+ } catch (e) { /* skip */ }
+ }
+ showToast(`📤 GPS pushed to ${pushed} / ${toPush.length} assets`);
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// EXIF SCANNER — Sessions
+// ═════════════════════════════════════════════════════════════════════════════
+async function exifCreateSession() {
+ const name = document.getElementById('exifSessionName').value.trim();
+ if (!name) {
+ showToast('Enter a session name', true);
+ return;
+ }
+ try {
+ await api('/api/admin/exif/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name }),
+ });
+ showToast('✅ Session created');
+ document.getElementById('exifSessionName').value = '';
+ await loadExifSessions();
+ } catch (e) {
+ showToast('Failed: ' + e.message, true);
+ }
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// EXIF SCANNER — Lightbox
+// ═════════════════════════════════════════════════════════════════════════════
+var exifLightboxEl = null;
+function openExifLightbox(src) {
+ if (!exifLightboxEl) {
+ exifLightboxEl = document.createElement('div');
+ exifLightboxEl.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.95);z-index:10000;display:flex;align-items:center;justify-content:center;cursor:zoom-out;';
+ exifLightboxEl.onclick = closeExifLightbox;
+ const img = document.createElement('img');
+ img.style.cssText = 'max-width:95vw;max-height:95vh;object-fit:contain;border-radius:4px;';
+ img.id = 'exifLightboxImg';
+ exifLightboxEl.appendChild(img);
+ document.body.appendChild(exifLightboxEl);
+ }
+ document.getElementById('exifLightboxImg').src = src;
+ exifLightboxEl.style.display = 'flex';
+}
+function closeExifLightbox() {
+ if (exifLightboxEl) exifLightboxEl.style.display = 'none';
+}
|