#!/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()