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 @@ + + + + @@ -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 = '
📦
No assets found
'; + 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 => ``).join('')} + + + ${rows} +
${HEADER_LABELS[f]}${sortArrow(f)}
+
`; + + 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 ? ` +
+ + + + + + + + + ${found.map(r => { + const asset = cachedAssets.find(a => a.id === r.id); + const currentCompany = asset?.company || ''; + const needsUpdate = currentCompany !== r.business_name; + return ` + + + + + `; + }).join('')} + +
Business NameAddressCurrent Company
${esc(r.business_name)}${esc(r.address)}${esc(currentCompany || '—empty—')}${needsUpdate ? `` : '✅'}
+
+ ` : ''} + ${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'; +}