diff --git a/assets.db.20260523-191237.bak b/assets.db.20260523-191237.bak new file mode 100644 index 0000000..1ce1069 Binary files /dev/null and b/assets.db.20260523-191237.bak differ diff --git a/gps-backup.json b/gps-backup.json new file mode 100644 index 0000000..b579bfc --- /dev/null +++ b/gps-backup.json @@ -0,0 +1 @@ +[{"machine_id":"68002","lat":28.388656,"lng":-81.535256},{"machine_id":"68097","lat":28.425122,"lng":-81.57718},{"machine_id":"68194","lat":28.458878,"lng":-81.452836},{"machine_id":"68356","lat":28.346058,"lng":-81.588889},{"machine_id":"68363","lat":28.316533,"lng":-81.405814},{"machine_id":"68551","lat":28.458892,"lng":-81.452867},{"machine_id":"69150","lat":28.431561,"lng":-81.470789},{"machine_id":"69331","lat":28.315697,"lng":-81.405889},{"machine_id":"69332","lat":28.316533,"lng":-81.405814},{"machine_id":"71440","lat":28.369956,"lng":-81.552925},{"machine_id":"71504","lat":28.458906,"lng":-81.452881},{"machine_id":"71574","lat":28.371833,"lng":-81.514336},{"machine_id":"73016","lat":28.425367,"lng":-81.458389},{"machine_id":"93771","lat":28.338208,"lng":-81.571883},{"machine_id":"95020","lat":28.337258,"lng":-81.5729},{"machine_id":"95054","lat":28.347808,"lng":-81.541811},{"machine_id":"95067","lat":28.350125,"lng":-81.544425},{"machine_id":"95100","lat":28.339014,"lng":-81.553969},{"machine_id":"95167","lat":28.385514,"lng":-81.534722},{"machine_id":"95173","lat":28.388247,"lng":-81.534814},{"machine_id":"95330","lat":28.339564,"lng":-81.576981},{"machine_id":"95418","lat":28.458892,"lng":-81.452867},{"machine_id":"97115","lat":28.415494,"lng":-81.413122},{"machine_id":"98059","lat":28.336683,"lng":-81.587358},{"machine_id":"98072","lat":28.3713,"lng":-81.529883},{"machine_id":"98114","lat":28.406067,"lng":-81.582122},{"machine_id":"98164","lat":29.215369,"lng":-81.094622},{"machine_id":"98176","lat":29.216575,"lng":-81.100233},{"machine_id":"98184","lat":29.215369,"lng":-81.094622}] diff --git a/scripts/extract_asset_location_data.py b/scripts/extract_asset_location_data.py new file mode 100644 index 0000000..e421b1e --- /dev/null +++ b/scripts/extract_asset_location_data.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +""" +Extract floor, building_number, room, trailer_number, and building_name +from asset names in assets.db. Also deduplicate duplicated name segments. + +Usage: + python3 scripts/extract_asset_location_data.py # dry-run (report only) + python3 scripts/extract_asset_location_data.py --apply # update DB +""" + +import re +import sqlite3 +import sys +from pathlib import Path +from typing import Optional + +DB_PATH = str(Path(__file__).resolve().parent.parent / "assets.db") + + +# ─── Regex patterns ────────────────────────────────────────────────────────── + +# Floor: "1ST FL", "2nd Floor", "3rd Floor", "4TH FLO", "5th floor", "6TH FL", +# "SOG MAIN 3RD FLOOR", "7th floor", "G-REAR 6TH FL", etc. +# We capture the floor number as the first group. +FLOOR_PATTERNS = [ + re.compile(r'\b(\d+)(?:ST|ND|RD|TH|st|nd|rd|th)\s+(?:FL(?:OOR)?|Floor|floor|FLO(?:OR)?|Floo|floo)\b', re.IGNORECASE), + # "2ND FLOOR", "3RD FLOOR" etc. + re.compile(r'\b(\d+)(?:TH|ST|ND|RD)\s+FLOOR\b', re.IGNORECASE), + # "3rd FLR" (FLR abbreviation) + re.compile(r'\b(\d+)(?:ST|ND|RD|TH|st|nd|rd|th)\s+FLR\b', re.IGNORECASE), +] + +# Floor: "FL02" in "FL02NEWBLD" -> floor "2" +# Must be FL + 1-2 digits + 2+ uppercase letters or end of field (via word boundary). +# Excludes "FL4950", "FL2500", "FL8108", "FL13323" (4+ digits after FL). +# Also matches "FL10W" (floor 10 west), "FL24TWR" (floor 24 tower). +FLOOR_CODE = re.compile(r'\bFL(\d{1,2})(?:(?:[A-Z]{2,})|\b)') + +# Building-number patterns +# "BLDG 215", "Bldg 210", "Building 1180", "BLDG 220", "Bldg 215-3rd Floor" +BUILDING_PATTERNS = [ + re.compile(r'\b(?:BLDG|Bldg|Building|Bldg)\s+(\d+)\b'), + # "#6328", "#811", "#9842", "#5288", "#1001", "# 6328" (store/chain numbers) + re.compile(r'#\s*(\d{3,})\b'), +] + +# Room patterns +# "Room 1290", "Room 1323", "SUITE 100", "SUITE 214", "Ste 400", "Ste 108" +ROOM_PATTERNS = [ + re.compile(r'\b(?:Room|room)\s+(\d+)\b'), + re.compile(r'\b(?:SUITE|Suite|suite|Ste)\s+(\d+)\b'), + # "Breakroom suite 700" (suite at end) + re.compile(r'\bsuite\s+(\d+)\b'), +] + +# Trailer patterns +# "Trailer 1051", "Trailer 1135", "Trailer 1055", "Trailer 1011", "Trailer 1145", "Trailer 1155" +# Also "Trailer 342" (3-digit trailer) +TRAILER_PATTERNS = [ + re.compile(r'\b(?:Trailer|trailer)\s+(\d+)\b'), +] + + +# ─── Known building/venue names (prefixes to check) ────────────────────────── +# These appear as prefix segments in asset names and are valid building names. +BUILDING_NAME_PREFIXES = [ + "Buena Vista Palace", + "Hilton Worldwide", + "Four Seasons Resort", + "Gran Destino", + "Shades of Green", + "Coronado Springs", + "Caribbean Beach", + "All Star Movies", + "Disney Springs", + "Epcot", + "Celebration Suites", +] + +# Suffixes that indicate what follows is a function/area, NOT part of building name. +AREA_SUFFIXES = [ + "Breakroom", "Break Room", "BRKRM", "Break", "BR", + "Vending", "Vending Area", + "Laundry", "Laundry Room", + "Cafeteria", "Cafe", + "Maintenance", "Warehouse", + "Wardrobe", "Cast", + "Lobby", "Guest", + "Admin", "Office", +] + + +def extract_floor(name: str) -> Optional[str]: + """Extract floor number from an asset name.""" + for pat in FLOOR_PATTERNS: + m = pat.search(name) + if m: + return str(int(m.group(1))) # Normalise "02" -> "2" + m = FLOOR_CODE.search(name) + if m: + val = str(int(m.group(1))) # Normalise "02" -> "2" + # Ensure it's a reasonable floor number (1-100) + try: + num = int(val) + if 1 <= num <= 100: + return val + except ValueError: + pass + # Edge: "Villas 5 fl by room" etc. — rare but present + m = re.search(r'\b(\d+)\s+fl\b', name, re.IGNORECASE) + if m: + val = str(int(m.group(1))) + try: + if 1 <= int(val) <= 100: + return val + except ValueError: + pass + return None + + +def extract_building_number(name: str) -> Optional[str]: + """Extract building number from an asset name.""" + for pat in BUILDING_PATTERNS: + m = pat.search(name) + if m: + return m.group(1) + return None + + +def extract_room(name: str) -> Optional[str]: + """Extract room/suite number from an asset name.""" + for pat in ROOM_PATTERNS: + m = pat.search(name) + if m: + return m.group(1) + return None + + +def extract_trailer(name: str) -> Optional[str]: + """Extract trailer number from an asset name.""" + for pat in TRAILER_PATTERNS: + m = pat.search(name) + if m: + return m.group(1) + return None + + +def extract_building_name(name: str) -> Optional[str]: + """ + Attempt to derive a building/venue name from the asset name. + + Strategy: look for known building-name prefixes, or extract the + first segment before a hyphen/separator that looks like a location name + (not just a person name, address fragment, or functional descriptor). + """ + name_upper = name.upper() + + # Check known building name prefixes + for prefix in BUILDING_NAME_PREFIXES: + if prefix.upper() in name_upper: + return prefix + + # Try prefix extraction: the first significant segment + # Split on common separators + parts = re.split(r'\s*-\s*', name) + first = parts[0].strip() + + # Skip if the first segment looks like a person name (has name pattern) + # e.g. "Todd J - ...", "Brenda G - ...", "Kelli M - ..." + person_pattern = re.match(r'^[A-Z][a-z]+ [A-Z]\.?$', first) + if person_pattern: + return None + + # Skip if it's an address fragment + if re.match(r'^\d+\s+', first): + # Could be a building name like "Building 1180" — but that's already + # captured as building_number. Skip other addresses. + if not re.match(r'^Building\s+\d+', first, re.IGNORECASE): + return None + + # Skip if the first segment is clearly a person or Disney cast ID + # "Jeremy B", "Randal W", "Susan G", "Brenda G", etc. + if re.match(r'^[A-Z][a-z]+ [A-Z] -', first): + return None + + # If first segment is long enough (>10 chars) and doesn't contain area keywords, + # it's probably the building/venue name + first_no_area = first + for suffix in AREA_SUFFIXES: + # Remove trailing area/function suffixes + pat = re.compile(r'\s+' + re.escape(suffix) + r'$', re.IGNORECASE) + first_no_area = pat.sub('', first_no_area) + + if len(first_no_area) > 8 and not re.search(r'\b(?:BREAK|VEND|LAUNDRY|CAFE|MAINT|WAREHOUSE|CAFETERIA|OFFICE|LOBBY|GUEST|ADMIN|SERVICES|BACKSTAGE|WARDROBE|CA$T|DINING|KITCHEN|STORAGE|WORKSHOP|SECURITY|RECEIVING|PARKING|OUTSIDE|OUTLET|HALLWAY)\b', first_no_area.upper()): + return first_no_area.strip() + + return None + + +# ─── Name deduplication ────────────────────────────────────────────────────── + +def should_deduplicate(left: str, right: str) -> Optional[str]: + """ + Check if `right` is a duplicate/continuation of `left`. + Returns the merged name if deduplication is warranted, None otherwise. + """ + l = left.strip() + r = right.strip() + if not l or not r: + return None + + # Exact match: "X-X" -> keep "X" + if l.lower() == r.lower(): + return l + + # Right starts with left: "X" + "-" + "X Y" -> "X Y" + if r.lower().startswith(l.lower()): + return r + + # Left starts with right: "X Y" + "-" + "X" -> "X Y" + if l.lower().startswith(r.lower()): + return l + + # Large common prefix (>= 65 % of the shorter string and >= 10 chars) + min_len = min(len(l), len(r)) + if min_len >= 10: + common = 0 + for a, b in zip(l.lower(), r.lower()): + if a == b: + common += 1 + else: + break + if common >= 0.65 * min_len: + # Keep the longer variant + return l if len(l) >= len(r) else r + + return None + + +def deduplicate_name(name: str) -> str: + """ + Detect duplicated name segments and return a cleaned name. + + Strategy: for assets with multiple hyphen-separated segments, check + if splitting at each hyphen position reveals duplication. + """ + # Find all hyphen positions and try each + hyphens = [m.start() for m in re.finditer('-', name)] + + best_name = name + + # Try splitting at first hyphen + for idx in hyphens: + left = name[:idx].strip() + right = name[idx+1:].strip() + + result = should_deduplicate(left, right) + if result is not None: + candidate = result + # If this is shorter or the same length but cleaner, use it + if len(candidate) < len(best_name) or (len(candidate) <= len(best_name) and candidate != name): + best_name = candidate + + # Iterative: run again on the result (for triple-nested duplicates) + if best_name != name: + best_name = deduplicate_name(best_name) + + return best_name + + +# ─── Main logic ────────────────────────────────────────────────────────────── + +def process_assets(db: sqlite3.Connection, apply: bool = False) -> dict: + """Process assets table, returning counts of extracted values.""" + cursor = db.cursor() + + # Fetch all assets with non-empty names + cursor.execute("SELECT id, name, floor, building_number, room, trailer_number, building_name FROM assets WHERE name != '' ORDER BY id") + rows = cursor.fetchall() + + counts = { + 'floor': 0, + 'building_number': 0, + 'room': 0, + 'trailer_number': 0, + 'building_name': 0, + 'name_cleaned': 0, + 'total': len(rows), + } + + updates = [] + + for row in rows: + asset_id, name, cur_floor, cur_bldg_num, cur_room, cur_trailer, cur_bldg_name = row + + # 1. Deduplicate name + cleaned = deduplicate_name(name) + name_changed = (cleaned != name) + + # 2. Extract fields (only if currently empty) + floor = cur_floor if cur_floor else (extract_floor(cleaned) or '') + building_number = cur_bldg_num if cur_bldg_num else (extract_building_number(cleaned) or '') + room = cur_room if cur_room else (extract_room(cleaned) or '') + trailer = cur_trailer if cur_trailer else (extract_trailer(cleaned) or '') + building_name = cur_bldg_name if cur_bldg_name else (extract_building_name(cleaned) or '') + + # Track counts + if floor and not cur_floor: + counts['floor'] += 1 + if building_number and not cur_bldg_num: + counts['building_number'] += 1 + if room and not cur_room: + counts['room'] += 1 + if trailer and not cur_trailer: + counts['trailer_number'] += 1 + if building_name and not cur_bldg_name: + counts['building_name'] += 1 + if name_changed: + counts['name_cleaned'] += 1 + + updates.append((floor, building_number, room, trailer, building_name, cleaned, asset_id)) + + if apply: + cursor.executemany( + "UPDATE assets SET floor=?, building_number=?, room=?, trailer_number=?, building_name=?, name=? WHERE id=?", + updates + ) + db.commit() + + return counts + + +def process_locations(db: sqlite3.Connection, apply: bool = False) -> dict: + """Process locations table similarly.""" + cursor = db.cursor() + cursor.execute("SELECT id, name, floor, building_number, trailer_number FROM locations WHERE name != '' ORDER BY id") + rows = cursor.fetchall() + + counts = { + 'floor': 0, + 'building_number': 0, + 'trailer_number': 0, + 'name_cleaned': 0, + 'total': len(rows), + } + + updates = [] + + for row in rows: + loc_id, name, cur_floor, cur_bldg_num, cur_trailer = row + + # Deduplicate (locations seem cleaner but process anyway) + cleaned = deduplicate_name(name) + name_changed = (cleaned != name) + + # Extract fields + floor = cur_floor if cur_floor else (extract_floor(cleaned) or '') + building_number = cur_bldg_num if cur_bldg_num else (extract_building_number(cleaned) or '') + trailer = cur_trailer if cur_trailer else (extract_trailer(cleaned) or '') + + if floor and not cur_floor: + counts['floor'] += 1 + if building_number and not cur_bldg_num: + counts['building_number'] += 1 + if trailer and not cur_trailer: + counts['trailer_number'] += 1 + if name_changed: + counts['name_cleaned'] += 1 + + updates.append((floor, building_number, trailer, cleaned, loc_id)) + + if apply: + cursor.executemany( + "UPDATE locations SET floor=?, building_number=?, trailer_number=?, name=? WHERE id=?", + updates + ) + db.commit() + + return counts + + +def print_summary(label: str, counts: dict): + """Print a human-readable summary of extraction results.""" + print(f"\n{'=' * 60}") + print(f" {label}") + print(f"{'=' * 60}") + print(f" Total rows processed: {counts['total']}") + print(f" Floors extracted: {counts['floor']}") + print(f" Building numbers: {counts['building_number']}") + if 'room' in counts: + print(f" Rooms/Suites: {counts['room']}") + if 'trailer_number' in counts: + print(f" Trailer numbers: {counts['trailer_number']}") + if 'building_name' in counts: + print(f" Building names: {counts['building_name']}") + print(f" Names cleaned: {counts['name_cleaned']}") + + +def main(): + apply = '--apply' in sys.argv + + print(f"DB: {DB_PATH}") + print(f"Mode: {'APPLY (will write to DB)' if apply else 'DRY RUN (no changes)'}") + + db = sqlite3.connect(DB_PATH) + db.execute("PRAGMA journal_mode=WAL") + + print("\nProcessing assets...") + asset_counts = process_assets(db, apply=apply) + print_summary("ASSETS", asset_counts) + + print("\nProcessing locations...") + loc_counts = process_locations(db, apply=apply) + print_summary("LOCATIONS", loc_counts) + + db.close() + + if not apply: + print("\n─── DRY RUN ───") + print("Re-run with --apply to write changes to the database.") + + # Sample some cleaned names for verification + print("\n─── Sample deduplications (first 20) ───") + db2 = sqlite3.connect(DB_PATH) + cursor = db2.cursor() + cursor.execute("SELECT id, name FROM assets WHERE name LIKE '%-%' ORDER BY id LIMIT 20") + for row in cursor.fetchall(): + original = row[1] + cleaned = deduplicate_name(original) + if cleaned != original: + print(f" [{row[0]}] '{original}'") + print(f" → '{cleaned}'") + db2.close() + + +if __name__ == '__main__': + main() diff --git a/static/index.html b/static/index.html index d3ce431..4bf048a 100644 --- a/static/index.html +++ b/static/index.html @@ -925,7 +925,7 @@