import json import os import sys import re import ast import csv import subprocess import urllib.request import threading import webbrowser import tkinter as tk from tkinter import messagebox, ttk as original_ttk from tkinter import * import ttkbootstrap as ttk from ttkbootstrap.constants import * APP_VERSION = "1.0.7" UPDATE_CHECK_URL = "https://files.spicerhome.net/work/new_builds/" def get_base_path(): """ Returns the directory of the executable (if frozen) or the script (if running from source). """ if getattr(sys, 'frozen', False): # Running as compiled executable exe_path = os.path.dirname(sys.executable) # macOS .app bundle adjustment if sys.platform == 'darwin' and 'Contents/MacOS' in exe_path: # exe_path is .../App.app/Contents/MacOS # We want .../ (the folder containing App.app) # Go up 3 levels: MacOS -> Contents -> App.app -> Container return os.path.abspath(os.path.join(exe_path, '../../..')) return exe_path # Running as script return os.path.abspath(os.path.dirname(__file__)) # ========================================== # ATTRIBUTES CONFIGURATION # ========================================== DEFAULT_ATTRIBUTES = { "folder_name": { "description": "Name of the folder created for this section", "type": "string", "dependencies": [], "incompatible": [], "weight": 100, "default_value": "New Room" }, "room_locator": { "description": "Unique identifier for the room location", "type": "string", "dependencies": [], "incompatible": [], "weight": 95, "default_value": "01.X.XX" }, "desks_start_locator": { "description": "Starting locator code for desk range", "type": "string", "dependencies": ["desks_end_locator"], "incompatible": ["unique_desk_numbers", "desk_count", "number_of_monitors", "number_of_phones", "number_of_desktops", "number_of_laptops"], "weight": 90, "default_value": "01.X.XX" }, "desks_end_locator": { "description": "Ending locator code for desk range", "type": "string", "dependencies": ["variable_pod_sizes", "desks_start_locator"], "incompatible": ["unique_desk_numbers", "desk_count", "number_of_monitors", "number_of_phones", "number_of_desktops", "number_of_laptops"], "weight": 89, "default_value": "01.X.XX" }, "variable_pod_sizes": { "description": "Enable custom desk counts per pod", "type": "boolean", "dependencies": ["desks_start_locator", "desks_end_locator"], "incompatible": [], "weight": 82, "default_value": False }, "desk_count": { "description": "Total number of desks in the room", "type": "integer", "dependencies": ["room_locator", "unique_desk_numbers"], "incompatible": ["number_of_monitors", "number_of_laptops"], "weight": 85, "default_value": 1 }, "unique_desk_numbers": { "description": "Whether desks have individual unique numbering. When disabled desks will be labeled 'Desk X'", "type": "boolean", "dependencies": ["desk_count", "room_locator"], "incompatible": ["max_pod_desk_size", "desks_start_locator", "desks_end_locator"], "weight": 84, "default_value": False }, "number_of_paper_printers": { "description": "Total number of standard paper printers", "type": "integer", "dependencies": [], "incompatible": [], "weight": 70, "default_value": 1 }, "number_of_phones": { "description": "Total number of desk phones. Labeled as 'Phone X'", "type": "integer", "dependencies": [], "incompatible": [], "weight": 65, "default_value": 1 }, "number_of_laptops": { "description": "Total number of laptops. Labeled as 'Laptop X'", "type": "integer", "dependencies": [], "incompatible": [], "weight": 60, "default_value": 1 }, "number_of_desktops": { "description": "Total number of desktop PCs. Labeled as 'Desktop X'", "type": "integer", "dependencies": [], "incompatible": [], "weight": 59, "default_value": 1 }, "number_of_monitors": { "description": "Total number of monitors. Labeled as 'Monitor X'", "type": "integer", "dependencies": [], "incompatible": [], "weight": 58, "default_value": 1 }, "badge_supplies": { "description": "Whether badge supplies are stocked", "type": "boolean", "dependencies": [], "incompatible": [], "weight": 50, "default_value": False }, "number_of_vms_printers": { "description": "Total number of VMS printers", "type": "integer", "dependencies": [], "incompatible": [], "weight": 50, "default_value": 1 }, "number_of_fargo_printers": { "description": "Total number of Fargo ID card printers", "type": "integer", "dependencies": [], "incompatible": [], "weight": 50, "default_value": 1 }, "helpdesk_accessories": { "description": "Availability of helpdesk accessories", "type": "boolean", "dependencies": [], "incompatible": [], "weight": 50, "default_value": False }, "av_rack": { "description": "Presence of an A/V rack", "type": "boolean", "dependencies": [], "incompatible": [], "weight": 50, "default_value": False } } # ========================================== # FILE OPERATIONS LOGIC # ========================================== import shutil import hashlib try: from PIL import Image from pyzbar.pyzbar import decode as decode_barcode HAS_BARCODE_LIB = True except ImportError: HAS_BARCODE_LIB = False def get_file_hash(filepath): """Returns the MD5 hash of the file.""" hasher = hashlib.md5() with open(filepath, 'rb') as f: buf = f.read(65536) while len(buf) > 0: hasher.update(buf) buf = f.read(65536) return hasher.hexdigest() def run_script_logic(parent_dir=None): if parent_dir is None: base_dir = get_base_path() else: base_dir = parent_dir config_path = os.path.join(base_dir, "config.json") site_prefix = os.path.basename(base_dir) try: with open(config_path, 'r') as config_file: site_data = json.load(config_file) except FileNotFoundError: print(f"Error: {config_path} not found.") return except json.JSONDecodeError as e: print(f"Error parsing JSON in {config_path}: {e}") return # Helper to convert 0 -> A, 25 -> Z, 26 -> AA, ... def _index_to_letters(idx: int) -> str: s = "" n = idx while True: s = chr(ord("A") + (n % 26)) + s n = n // 26 - 1 if n < 0: break return s def scan_folder_for_barcode(folder_path): """Scans image files in the folder for valid barcodes. Returns a list of unique barcode strings.""" if not HAS_BARCODE_LIB: print(f" [Barcode] Skipped {os.path.basename(folder_path)}: Library not available.") return [] if not os.path.exists(folder_path): return [] image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tif', '.tiff', '.heic', '.webp'} found_barcodes = [] seen = set() try: # List files in the folder files = sorted(os.listdir(folder_path)) for filename in files: ext = os.path.splitext(filename)[1].lower() if ext in image_exts: image_path = os.path.join(folder_path, filename) try: with Image.open(image_path) as img: decoded_objects = decode_barcode(img) for obj in decoded_objects: data = obj.data.decode('utf-8') if data not in seen: seen.add(data) found_barcodes.append(data) print(f" [Barcode] Found: {data} in {filename}") except Exception as e: print(f" [Barcode] Error reading {filename}: {e}") continue except Exception as e: print(f" [Barcode] Error scanning folder: {e}") pass return found_barcodes csv_rows = [] for room_name, attributes in site_data.items(): folder_name = attributes.get("folder_name") if attributes.get("room_available") and folder_name: folder_path = os.path.join(base_dir, folder_name) os.makedirs(folder_path, exist_ok=True) print(f"Created folder: {folder_path}") # Handle range-based locators start_locator = (attributes.get("desks_start_locator") or "").strip() end_locator = (attributes.get("desks_end_locator") or "").strip() max_pod_desk_size = attributes.get("max_pod_desk_size") # Custom Pod Logic variable_pod_sizes = attributes.get("variable_pod_sizes", False) pod_counts_raw = attributes.get("pod_counts", {}) # Ensure pod_counts_raw is a dict. If it's a string, try to parse or default to empty. if isinstance(pod_counts_raw, str): try: pod_counts = json.loads(pod_counts_raw) except: # Fallback for Python-style dict strings (single quotes) try: pod_counts = ast.literal_eval(pod_counts_raw) if not isinstance(pod_counts, dict): pod_counts = {} except: pod_counts = {} elif isinstance(pod_counts_raw, dict): pod_counts = pod_counts_raw else: pod_counts = {} # If variable sizes enabled, ensure we ignore max_pod_desk_size check roughly # but usually max_pod_desk_size is incompatible or just ignored. has_size = (max_pod_desk_size is not None) or variable_pod_sizes if start_locator and end_locator and has_size: try: default_pod_size = int(max_pod_desk_size) if max_pod_desk_size is not None else 8 except (ValueError, TypeError): default_pod_size = 8 # If variable pod sizes is enabled, but pod_counts is empty or invalid, rely on default m1 = re.match(r"^(.*?)(\d+)\s*$", start_locator) m2 = re.match(r"^(.*?)(\d+)\s*$", end_locator) if m1 and m2: prefix1, num1 = m1.group(1), m1.group(2) prefix2, num2 = m2.group(1), m2.group(2) if prefix1 == prefix2: width = len(num1) try: start_n, end_n = int(num1), int(num2) for n in range(start_n, end_n + 1): formatted = str(n).zfill(width) base_locator = f"{prefix1}{formatted}" # Determine size for THIS pod if variable_pod_sizes: # pod_counts keys are strings like "01", "02" # We try formatted string first specific_size = pod_counts.get(formatted) if specific_size is None: # fallback to max or default current_pod_size = default_pod_size else: try: current_pod_size = int(specific_size) except: current_pod_size = default_pod_size else: current_pod_size = default_pod_size for j in range(current_pod_size): letter = _index_to_letters(j) desk_name = f"{base_locator}{letter}" desk_folder = os.path.join(folder_path, desk_name) os.makedirs(desk_folder, exist_ok=True) print(f" Created pod desk folder: {desk_folder}") # Store info for later scanning csv_rows.append({"folder": folder_name, "desk": desk_name, "path": desk_folder, "type": "Pod Desk"}) except ValueError: pass # Invalid number range # Handle desk count desk_count = attributes.get("desk_count") if desk_count is not None: try: n_desks = int(desk_count) except (ValueError, TypeError): n_desks = 0 if n_desks > 0: unique = bool(attributes.get("unique_desk_numbers")) room_locator = (attributes.get("room_locator") or "").strip() for i in range(1, n_desks + 1): if unique and room_locator: letter = _index_to_letters(i - 1) desk_name = f"{room_locator}{letter}" else: desk_name = f"Desk {i}" desk_folder = os.path.join(folder_path, desk_name) os.makedirs(desk_folder, exist_ok=True) print(f" Created desk folder: {desk_folder}") desk_folder_for_csv = {"folder": folder_name, "desk": desk_name, "path": desk_folder, "type": "Standard Desk"} csv_rows.append(desk_folder_for_csv) # Handle other equipment (Printers, Phones, Laptops, Desktops, Monitors) equipment_types = { "number_of_paper_printers": "Printer", "number_of_vms_printers": "VMS Printer", "number_of_fargo_printers": "Fargo Printer", "number_of_phones": "Phone", "number_of_laptops": "Laptop", "number_of_desktops": "Desktop", "number_of_monitors": "Monitor" } for key, label in equipment_types.items(): count = attributes.get(key) if count is not None: try: n_items = int(count) except (ValueError, TypeError): n_items = 0 if n_items == 1: # Single item: {FolderName} {Label} (no number) item_name = f"{folder_name} {label}" item_folder = os.path.join(folder_path, item_name) os.makedirs(item_folder, exist_ok=True) print(f" Created equipment folder: {item_folder}") elif n_items > 1: # Multiple items: {FolderName} {Label} {i} for i in range(1, n_items + 1): item_name = f"{folder_name} {label} {i}" item_folder = os.path.join(folder_path, item_name) os.makedirs(item_folder, exist_ok=True) print(f" Created equipment folder: {item_folder}") # Handle boolean attributes (create folder if true) boolean_folders = { "helpdesk_accessories": "Accessories", "av_rack": "AV Rack", "badge_supplies": "Badge Supplies" } for key, label in boolean_folders.items(): if attributes.get(key, False): folder_title = f"{folder_name} {label}" bf_path = os.path.join(folder_path, folder_title) os.makedirs(bf_path, exist_ok=True) print(f" Created feature folder: {bf_path}") # Write CSV manifest # First, scan for barcodes in all collected desk locations # NOTE: If we want to capture barcodes from the "Standard Desk" folders created/renamed, # we should do this AFTER generic file renaming/processing? # Actually, the user might be running this script on an already populated structure. # So we scan NOW. print("\n[Barcode] Scanning desk folders for barcodes ...") processed_data = [] max_barcodes = 0 is_dict_mode = False if csv_rows and isinstance(csv_rows[0], dict): is_dict_mode = True for row_data in csv_rows: folder_path = row_data.get("path") barcodes = scan_folder_for_barcode(folder_path) # Now returns list if len(barcodes) > max_barcodes: max_barcodes = len(barcodes) processed_data.append({ "base": [row_data.get("folder"), row_data.get("desk")], "barcodes": barcodes, "type": row_data.get("type") }) manifest_path = os.path.join(base_dir, "room_manifest.csv") try: with open(manifest_path, 'w', newline='', encoding='utf-8') as csvfile: writer = csv.writer(csvfile) # Add warning header writer.writerow(["WARNING: THIS FILE IS RE-WRITTEN EVERY TIME THE SCRIPT RUNS. DO NOT SAVE DATA HERE."]) writer.writerow([]) # Empty row for spacing if is_dict_mode: # Dynamic Header header = ["Section", "Desk Locator", "Type"] if max_barcodes > 0: for i in range(max_barcodes): header.append(f"Barcode {i+1}") else: header.append("Barcode") # Fallback header if none found writer.writerow(header) # Write rows for item in processed_data: row = item["base"][:] # Add Type BEFORE barcodes row.append(item["type"]) # Add barcodes b_list = item["barcodes"] row.extend(b_list) # Pad empty cells for barcodes (must match header length) limit = max_barcodes if max_barcodes > 0 else 1 missing = limit - len(b_list) for _ in range(missing): row.append("") writer.writerow(row) else: # Fallback for unexpected data structure or empty list writer.writerow(["Section", "Desk Locator", "Type"]) if csv_rows: writer.writerows(csv_rows) print(f"\nManifest file written to: {manifest_path}") except Exception as e: print(f"Error writing manifest CSV: {e}") # File renaming logic root_dir = base_dir root_basename = os.path.basename(root_dir) or root_dir image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tif', '.tiff', '.heic', '.webp'} counters = {} print(f"\nScanning for image files under {root_dir} to normalize names...") # Only scan folders authorized by the config (failsafe) allowed_paths = [] for attrs in site_data.values(): if attrs.get("room_available", False): fname = attrs.get("folder_name") if fname: allowed_paths.append(os.path.abspath(os.path.join(root_dir, fname))) # Generator to walk allowed paths without duplicates def get_walk_stream(paths): seen = set() for p in paths: if os.path.isdir(p): for root, dirs, files in os.walk(p): abspath = os.path.abspath(root) if abspath not in seen: seen.add(abspath) yield root, dirs, files # Track renamed files to copy them later renamed_files_map = {} # full_path -> new_full_path for dirpath, _, filenames in get_walk_stream(allowed_paths): for fname in sorted(filenames): _, ext = os.path.splitext(fname.lower()) if ext in image_exts: full_path = os.path.join(dirpath, fname) rel_path = os.path.relpath(full_path, start=root_dir) parts = rel_path.split(os.sep) parts_without_file = parts[:-1] new_parts = [root_basename] + parts_without_file base_without_ext = ' - '.join(new_parts) _, orig_ext = os.path.splitext(fname) counter = counters.get(dirpath, 1) while True: new_name = f"{base_without_ext} - {counter}{orig_ext}" new_path = os.path.join(dirpath, new_name) if not os.path.exists(new_path) or os.path.abspath(new_path) == os.path.abspath(full_path): counters[dirpath] = counter + 1 break counter += 1 if os.path.abspath(new_path) != os.path.abspath(full_path): try: os.rename(full_path, new_path) print(f"Renamed: {full_path} -> {new_path}") renamed_files_map[full_path] = new_path except Exception as e: print(f"Error renaming {full_path}: {e}") else: renamed_files_map[full_path] = full_path # ========================== # COPY TO "All Photos" # ========================== all_photos_dir = os.path.join(root_dir, "All Photos") os.makedirs(all_photos_dir, exist_ok=True) # Build a hash map of existing files in "All Photos" to avoid content duplicates # hash -> filename existing_hashes = set() print(f"\nScanning 'All Photos' for existing content...") for f in os.listdir(all_photos_dir): fp = os.path.join(all_photos_dir, f) if os.path.isfile(fp): try: h = get_file_hash(fp) existing_hashes.add(h) except Exception: pass print(f"Consolidating photos into: {all_photos_dir}") # We iterate over the files we just renamed (or identified) in the allowed paths # renamed_files_map.values() holds the current paths of the files file_list_to_process = sorted(list(renamed_files_map.values())) for current_path in file_list_to_process: if not os.path.isfile(current_path): continue try: # 1. Check Hash current_hash = get_file_hash(current_path) if current_hash in existing_hashes: # Content already exists in All Photos, skip # print(f"Skipping duplicate (content match): {os.path.basename(current_path)}") continue # 2. Determine Destination Filename original_basename = os.path.basename(current_path) dest_path = os.path.join(all_photos_dir, original_basename) # 3. Handle Filename Conflicts (if content is different) # We know content is different because we passed the hash check. # So if the name exists, it's a DIFFERENT photo with the SAME name (unlikely given our renaming logic, but possible) if os.path.exists(dest_path): name, ext = os.path.splitext(original_basename) c = 1 while True: new_dest_name = f"{name}_({c}){ext}" dest_path = os.path.join(all_photos_dir, new_dest_name) if not os.path.exists(dest_path): break c += 1 # 4. Copy shutil.copy2(current_path, dest_path) print(f"Copied to All Photos: {os.path.basename(dest_path)}") existing_hashes.add(current_hash) # Add to set so we don't copy the same file twice if encountered again except Exception as e: print(f"Error copying {current_path}: {e}") # ========================================== # UI CONFIGURATION EDITOR # ========================================== class ConfigUI(ttk.Window): def __init__(self): super().__init__(themename="litera") self.title(f"Configuration Editor v{APP_VERSION}") self.geometry("1100x700") # Base dir is wherever this script files lives self.base_dir = get_base_path() self.config_path = os.path.join(self.base_dir, "config.json") self.site_prefix = os.path.basename(self.base_dir) self.widgets = {} self.view_cache = {} # Cache for room views: {room_name: (header_frame, attrs_frame, widgets_dict)} self.current_room = None self.del_btns_widgets = {} # Map key -> button widget for hover effects self.data = self.load_config() self.attr_metadata = DEFAULT_ATTRIBUTES self.create_layout() self.populate_sidebar() # Select first item if exists if self.data: first_key = list(self.data.keys())[0] if self.tree.exists(first_key): self.tree.selection_set(first_key) self.select_room(first_key) # Check for updates in background self.after(2000, self.start_update_check) def start_update_check(self): print("[Update] Starting background update check...") threading.Thread(target=self._perform_update_check, daemon=True).start() def _perform_update_check(self): try: print(f"[Update] Checking URL: {UPDATE_CHECK_URL}") # Fetch directory listing req = urllib.request.Request(UPDATE_CHECK_URL) req.add_header('User-Agent', f'NewBuildApp/{APP_VERSION}') with urllib.request.urlopen(req, timeout=5) as response: html = response.read().decode('utf-8') print(f"[Update] Manifest fetched ({len(html)} bytes). Parsing versions...") # Find versions # Caddy/Apache/Nginx usually list folders as links matching the folder name valid_versions = [] # Regex to find links that look like "1.0.7/" or "./1.0.7/" or "/path/1.0.7/" # We look for href="..." value containing a version number d.d.d version_pattern = re.compile(r'href=["\']?.*?(\d+\.\d+\.\d+)/?["\']') for match in version_pattern.finditer(html): v_str = match.group(1) # print(f"[Update] Found candidate: {v_str}") if v_str not in valid_versions: valid_versions.append(v_str) print(f"[Update] Found versions: {valid_versions}") if not valid_versions: print("[Update] No valid versions found.") return # Sort and pick latest valid_versions.sort(key=lambda s: [int(u) for u in s.split('.')]) latest_version = valid_versions[-1] print(f"[Update] Latest version on server: {latest_version} (Current: {APP_VERSION})") if self._is_newer_version(latest_version, APP_VERSION): print(f"[Update] Update available: {latest_version}") self._fetch_and_show_update(latest_version) else: print("[Update] No update required.") except Exception as e: print(f"Update check failed: {e}") def _is_newer_version(self, remote, local): try: r_parts = [int(x) for x in remote.split('.')] l_parts = [int(x) for x in local.split('.')] return r_parts > l_parts except Exception as e: print(f"[Update] Version comparison error: {e}") return False def _fetch_and_show_update(self, version): print(f"[Update] Fetching patch notes for {version}...") notes_url = f"{UPDATE_CHECK_URL}{version}/patch_notes.txt" notes_text = "See release page for details." try: with urllib.request.urlopen(notes_url, timeout=5) as resp: notes_text = resp.read().decode('utf-8') print("[Update] Patch notes fetched successfully.") except Exception as e: print(f"[Update] Could not fetch patch notes: {e}") pass self.after(0, lambda: self.show_update_dialog(version, notes_text)) def show_update_dialog(self, version, notes): # Create custom dialog top = ttk.Toplevel(self) top.title("Update Available") top.geometry("500x400") ttk.Label(top, text=f"New Version Available: v{version}", font=("Helvetica", 16, "bold"), bootstyle="primary").pack(pady=15) ttk.Label(top, text="Patch Notes:", font=("Helvetica", 10, "bold")).pack(anchor="w", padx=20) # Scrolled Text txt_frame = ttk.Frame(top) txt_frame.pack(fill=BOTH, expand=True, padx=20, pady=5) from tkinter import scrolledtext st = scrolledtext.ScrolledText(txt_frame, height=10, font=("Consolas", 10)) st.pack(fill=BOTH, expand=True) st.insert(END, notes) st.configure(state='disabled') btn_frame = ttk.Frame(top) btn_frame.pack(fill=X, pady=15, padx=20) def open_download(): webbrowser.open(f"{UPDATE_CHECK_URL}{version}/") top.destroy() ttk.Button(btn_frame, text="Ignore", command=top.destroy, bootstyle="secondary").pack(side=LEFT) ttk.Button(btn_frame, text="Download Update", command=open_download, bootstyle="success").pack(side=RIGHT) def load_config(self): try: with open(self.config_path, 'r') as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError) as e: messagebox.showerror("Error", f"Could not load config.json: {e}") self.destroy() return {} def create_layout(self): # Main container with PanedWindow # We use standard ttk.PanedWindow to ensure compatibility, styled by bootstrap self.paned = original_ttk.PanedWindow(self, orient=HORIZONTAL) self.paned.pack(fill=BOTH, expand=True, padx=10, pady=10) # --- LEFT PANEL (Sidebar) --- sidebar_frame = ttk.Frame(self.paned) self.paned.add(sidebar_frame, weight=1) # Header for sidebar ttk.Label(sidebar_frame, text="Sections", font=("Helvetica", 12, "bold")).pack(side=TOP, pady=5) # Add Section Button (Packed at BOTTOM) ttk.Button(sidebar_frame, text="+ Add Section", command=self.add_section, bootstyle="success-outline").pack(side=BOTTOM, fill=X, pady=5) # Treeview Container tree_frame = ttk.Frame(sidebar_frame) tree_frame.pack(side=TOP, fill=BOTH, expand=True) # Treeview columns = ("status", "name") self.tree = ttk.Treeview(tree_frame, columns=columns, show="tree", selectmode="browse") self.tree.column("#0", width=0, stretch=NO) self.tree.column("status", width=30, anchor="center") self.tree.column("name", anchor="w") # Scrollbar tree_scroll = ttk.Scrollbar(tree_frame, orient=VERTICAL, command=self.tree.yview) self.tree.configure(yscrollcommand=tree_scroll.set) self.tree.pack(side=LEFT, fill=BOTH, expand=True) tree_scroll.pack(side=RIGHT, fill=Y) self.tree.bind("<>", self.on_tree_select) # --- RIGHT PANEL (Details) --- self.detail_frame = ttk.Frame(self.paned, padding=15) self.paned.add(self.detail_frame, weight=4) # Header area (Always exists, contents change) self.header_frame = ttk.Frame(self.detail_frame) self.header_frame.pack(fill=X, pady=(0, 20)) # Attributes Area - Build the scrolling infrastructure ONCE self.attrs_container = ttk.Frame(self.detail_frame) self.attrs_container.pack(fill=BOTH, expand=True) self.canvas = tk.Canvas(self.attrs_container, highlightthickness=0) self.scrollbar = ttk.Scrollbar(self.attrs_container, orient=VERTICAL, command=self.canvas.yview) self.scrollable_frame = ttk.Frame(self.canvas) # Ensure scrollable frame expands self.scrollable_frame.columnconfigure(1, weight=1) self.scrollable_frame.bind( "", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")) ) # Window inside canvas self.canvas_window = self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") self.canvas.configure(yscrollcommand=self.scrollbar.set) # Canvas resizing handling to fit width self.canvas.bind('', self.on_canvas_configure) self.canvas.pack(side=LEFT, fill=BOTH, expand=True) self.scrollbar.pack(side=RIGHT, fill=Y) # Actions Footer footer_frame = ttk.Frame(self.detail_frame) footer_frame.pack(side=BOTTOM, fill=X, pady=10) ttk.Button(footer_frame, text="Save Config", command=self.save_config, bootstyle="primary").pack(side=RIGHT, padx=5) ttk.Button(footer_frame, text="Save & Run Script", command=self.run_script, bootstyle="info").pack(side=RIGHT, padx=5) ttk.Button(footer_frame, text="Delete Section", command=self.delete_current_section, bootstyle="danger-outline").pack(side=LEFT, padx=5) ttk.Button(footer_frame, text="+ Add Attribute", command=self.add_attribute_dialog, bootstyle="success-outline").pack(side=LEFT, padx=5) def on_canvas_configure(self, event): self.canvas.itemconfig(self.canvas_window, width=event.width) def populate_sidebar(self): # Store current selection to restore if possible selected = self.tree.selection() self.tree.delete(*self.tree.get_children()) for room_name, attrs in self.data.items(): is_avail = attrs.get('room_available', False) status_icon = "✅" if is_avail else "❌" display_name = room_name.replace('_', ' ').title() self.tree.insert("", END, iid=room_name, values=(status_icon, display_name)) if selected and self.tree.exists(selected[0]): self.tree.selection_set(selected[0]) def on_tree_select(self, event): selected = self.tree.selection() if not selected: return room_name = selected[0] # First save changes from previous room if valid if self.current_room and self.current_room != room_name and self.current_room in self.data: self.update_data_from_widgets() self.select_room(room_name) def select_room(self, room_name): # Hide previous room frames if they exist in cache if self.current_room and self.current_room in self.view_cache: h, a, _ = self.view_cache[self.current_room] h.pack_forget() a.pack_forget() self.current_room = room_name self.del_btns_widgets = {} # Reset for new view context # Check cache if room_name in self.view_cache: h, a, w = self.view_cache[room_name] # To bind events properly to existing widgets without complex logic, we rebuild the view. # Performance impact is negligible for this size. h.destroy() a.destroy() del self.view_cache[room_name] # Fall through to build new view logic... # --- Build New View --- attributes = self.data.get(room_name, {}) # Create container frames for this specific room # Header attached to header_frame (host) room_header = ttk.Frame(self.header_frame) room_header.pack(fill=X, expand=True) # Attributes attached to scrollable_frame (host) room_attrs = ttk.Frame(self.scrollable_frame) room_attrs.pack(fill=BOTH, expand=True) # Ensure room_attrs expands its content room_attrs.columnconfigure(1, weight=1) # Temporary widget tracking for this room current_widgets = {} # --- Header Content --- ttk.Label(room_header, text="Name:", font=("Helvetica", 10)).pack(side=LEFT, padx=(0,5)) name_var = tk.StringVar(value=room_name.replace('_', ' ').title()) name_entry = ttk.Entry(room_header, textvariable=name_var, font=("Helvetica", 14, "bold")) name_entry.pack(side=LEFT, fill=X, expand=True, padx=(0, 20)) current_widgets['__section_name__'] = name_var # Availability Toggle is_avail = attributes.get('room_available', False) avail_var = tk.BooleanVar(value=is_avail) # Callback to update sidebar icon immediately def on_toggle(): val = avail_var.get() self.data[self.current_room]['room_available'] = val self.update_sidebar_item(self.current_room) toggle = ttk.Checkbutton(room_header, text="Room Available", variable=avail_var, bootstyle="round-toggle-success", command=on_toggle) toggle.pack(side=RIGHT) current_widgets['room_available'] = (avail_var, bool) # --- Attributes List --- current_widgets['__attr_frame__'] = room_attrs # Render Attributes # Sort attributes based on weight in metadata (descending) def get_weight(key): return self.attr_metadata.get(key, {}).get("weight", 0) # Filter out room_available then sort sorted_keys = sorted( [k for k in attributes.keys() if k != 'room_available' and k != 'max_pod_desk_size'], key=get_weight, reverse=True ) for i, key in enumerate(sorted_keys): value = attributes[key] # Pass the DICT to populate so it stores widgets there self.render_attribute_row(room_attrs, i, key, value, current_widgets) # Store in cache and set active self.view_cache[room_name] = (room_header, room_attrs, current_widgets) self.widgets = current_widgets def update_sidebar_item(self, room_name): """Refreshes a single item in the sidebar instead of rebuilding the whole tree.""" if not self.tree.exists(room_name): return attrs = self.data.get(room_name, {}) is_avail = attrs.get('room_available', False) status_icon = "✅" if is_avail else "❌" display_name = room_name.replace('_', ' ').title() # Updating values preserves the selection state implicitly if it's the selected item self.tree.item(room_name, values=(status_icon, display_name)) def render_attribute_row(self, parent, row_idx, key, value, widget_dict=None): if widget_dict is None: widget_dict = self.widgets # Get current room attributes safely for reference current_room_attrs = self.data.get(self.current_room, {}) # Fetch description desc = self.attr_metadata.get(key, {}).get("description", "") # Label Frame (Key + Description) label_frame = ttk.Frame(parent) label_frame.grid(row=row_idx, column=0, padx=5, pady=5, sticky="nw") label = ttk.Label(label_frame, text=key.replace('_', ' ').title(), width=25, anchor="w") label.pack(anchor="w") if desc: ttk.Label(label_frame, text=desc, font=("Helvetica", 11), foreground="gray", wraplength=250).pack(anchor="w") # Determine if this is a locator field is_locator = key in ["room_locator", "desks_start_locator", "desks_end_locator"] if isinstance(value, bool): var = tk.BooleanVar(value=value) # Alignment fix for checkbox widget_frame = ttk.Frame(parent) widget = ttk.Checkbutton(widget_frame, variable=var, bootstyle="round-toggle") widget.pack(anchor="w") # SPECIAL: Dynamic Pod Size Editor if key == "variable_pod_sizes": # Container for dynamic inputs pod_frame = ttk.Frame(widget_frame) pod_frame.pack(fill=X, expand=True, pady=5) # We need to map {pod_id: EntryVar} pod_entry_vars = {} def refresh_pod_list(*args): # Clear existing UI in pod_frame for w in pod_frame.winfo_children(): w.destroy() pod_entry_vars.clear() # Manage widget_dict entries to ensure correct saving based on mode if "pod_counts" in widget_dict: del widget_dict["pod_counts"] if "max_pod_desk_size" in widget_dict: del widget_dict["max_pod_desk_size"] # Default size value logic (persistent from existing data if possible, default to 8) current_defaults = self.data.get(self.current_room, {}) default_mps = current_defaults.get("max_pod_desk_size", 8) if not var.get(): # Case: Disabled -> Show "Default Pod Size" text field ttk.Label(pod_frame, text="Default Pod Size:", font=("Helvetica", 9), foreground="gray").pack(side=LEFT, padx=(0, 5)) mps_var = tk.StringVar(value=str(default_mps)) mps_entry = ttk.Entry(pod_frame, textvariable=mps_var, width=5) mps_entry.pack(side=LEFT) # Save it widget_dict["max_pod_desk_size"] = (mps_var, int) # Add listener for suffix updates on end_locator def on_max_change(*a): try: val = int(mps_var.get()) if "__end_locator_suffix_var__" in widget_dict: # Helper def _get_letter(idx): s = "" n = idx - 1 if n < 0: return "" while True: s = chr(ord("A") + (n % 26)) + s n = n // 26 - 1 if n < 0: break return s widget_dict["__end_locator_suffix_var__"].set(_get_letter(val)) except: pass mps_var.trace_add("write", on_max_change) # Trigger once to set suffix on_max_change() return # Case: Enabled -> Show List of Pods # We preserve max_pod_desk_size in widget_dict (hidden) to prevent data loss on save hidden_mps_var = tk.StringVar(value=str(default_mps)) widget_dict["max_pod_desk_size"] = (hidden_mps_var, int) # Get data from current room metadata if existing, or widgets if already edited # We rely on finding start/end locator WIDGETS first start_var_tuple = widget_dict.get("desks_start_locator") end_var_tuple = widget_dict.get("desks_end_locator") start_txt = "" end_txt = "" if start_var_tuple: start_txt = start_var_tuple[0].get() elif "desks_start_locator" in current_room_attrs: start_txt = str(current_room_attrs["desks_start_locator"]) if end_var_tuple: end_txt = end_var_tuple[0].get() elif "desks_end_locator" in current_room_attrs: end_txt = str(current_room_attrs["desks_end_locator"]) if not start_txt or not end_txt: ttk.Label(pod_frame, text="Set Start/End Locators first.", foreground="red").pack() # Ensure we don't crash save if key is missing (save empty dict) widget_dict["pod_counts"] = ({}, "pod_counts_dict") return # Parse range m1 = re.match(r"^(.*?)(\d+)\s*$", start_txt) m2 = re.match(r"^(.*?)(\d+)\s*$", end_txt) if m1 and m2: # prefix1, num1 = m1.group(1), m1.group(2) # We ignore prefix check strictly here for UI preview, just assume user intent num1 = m1.group(2) num2 = m2.group(2) try: s, e = int(num1), int(num2) width = len(num1) # Load existing values existing_counts_raw = current_room_attrs.get("pod_counts", {}) if isinstance(existing_counts_raw, str): try: existing_counts = json.loads(existing_counts_raw) except: try: existing_counts = ast.literal_eval(existing_counts_raw) if not isinstance(existing_counts, dict): existing_counts = {} except: existing_counts = {} elif isinstance(existing_counts_raw, dict): existing_counts = existing_counts_raw else: existing_counts = {} ttk.Label(pod_frame, text="Desk Counts per Pod:", font=("Helvetica", 9, "bold")).pack(anchor="w") count = 0 for n in range(s, e + 1): count += 1 if count > 50: # Safety limit for UI ttk.Label(pod_frame, text="... too many pods to list ...").pack() break pod_id = str(n).zfill(width) row = ttk.Frame(pod_frame) row.pack(fill=X, pady=1) ttk.Label(row, text=f"Pod {pod_id}:", width=10).pack(side=LEFT) # Use stored default in fallback default_val = existing_counts.get(pod_id, str(default_mps)) p_var = tk.StringVar(value=str(default_val)) p_ent = ttk.Entry(row, textvariable=p_var, width=5) p_ent.pack(side=LEFT) # Suffix Helper (A -> ?) s_lbl = ttk.Label(row, text="", foreground="gray") s_lbl.pack(side=LEFT, padx=5) def update_suffix(v=p_var, l=s_lbl): try: val = int(v.get()) # _get_letter helper dynamic copy idx = val ss = "" nn = idx - 1 if nn >= 0: while True: ss = chr(ord("A") + (nn % 26)) + ss nn = nn // 26 - 1 if nn < 0: break l.config(text=f"(A-{ss})") except: l.config(text="") p_var.trace_add("write", lambda *a, v=p_var, l=s_lbl: update_suffix(v, l)) update_suffix() # init pod_entry_vars[pod_id] = p_var # Register pod counts for saving widget_dict["pod_counts"] = (pod_entry_vars, "pod_counts_dict") except Exception as e: ttk.Label(pod_frame, text=f"Error parsing range: {e}").pack() widget_dict["pod_counts"] = ({}, "pod_counts_dict") else: ttk.Label(pod_frame, text="Invalid Locator Format").pack() widget_dict["pod_counts"] = ({}, "pod_counts_dict") # Trigger refresh when checkbox changes (and initially) var.trace_add("write", refresh_pod_list) if "desks_start_locator" in widget_dict: widget_dict["desks_start_locator"][0].trace_add("write", refresh_pod_list) if "desks_end_locator" in widget_dict: widget_dict["desks_end_locator"][0].trace_add("write", refresh_pod_list) # Initial Load refresh_pod_list() widget_frame.grid(row=row_idx, column=1, padx=5, pady=5, sticky="new") widget_dict[key] = (var, "bool") else: # Handle locator prefix filtering display_value = str(value) if is_locator and display_value.startswith(self.site_prefix + "."): display_value = display_value[len(self.site_prefix)+1:] var = tk.StringVar(value=display_value) if is_locator: # Compound widget for Locators: [Prefix Label] [Entry] [Suffix Label] # Using GRID internally to prevent "disappearing suffix" issue when resizing. widget_frame = ttk.Frame(parent) prefix_lbl = ttk.Label(widget_frame, text=f"{self.site_prefix}.", font=("Helvetica", 10, "bold"), foreground="gray") prefix_lbl.grid(row=0, column=0, sticky="w") # Calculated width to match others: Standard (30) - Prefix (~8) - Suffix (~2) = ~20 entry = ttk.Entry(widget_frame, textvariable=var, width=20) entry.grid(row=0, column=1, sticky="w") if key == "desks_start_locator": # Add "A" suffix suffix_lbl = ttk.Label(widget_frame, text="A", font=("Helvetica", 10, "bold"), foreground="gray") suffix_lbl.grid(row=0, column=2, sticky="w", padx=(2,0)) if key == "desks_end_locator": # Add dynamic suffix based on max_pod_desk_size suffix_var = tk.StringVar(value="") suffix_lbl = ttk.Label(widget_frame, textvariable=suffix_var, font=("Helvetica", 10, "bold"), foreground="gray") suffix_lbl.grid(row=0, column=2, sticky="w", padx=(2,0)) # Store reference to update it later widget_dict["__end_locator_suffix_var__"] = suffix_var # Initial calculation max_size = 8 # Default if "max_pod_desk_size" in self.data.get(self.current_room, {}): max_size = self.data[self.current_room]["max_pod_desk_size"] # Helper function to convert 0->A, etc. def _get_letter(idx): s = "" n = idx - 1 # 1-based to 0-based if n < 0: return "" while True: s = chr(ord("A") + (n % 26)) + s n = n // 26 - 1 if n < 0: break return s try: suffix_var.set(_get_letter(int(max_size))) except: pass # Changed sticky from 'ew' to 'w' so the composite widget doesn't stretch # (keeping it 'smaller' and preventing layout distortion) widget_frame.grid(row=row_idx, column=1, padx=5, pady=5, sticky="w") widget_dict[key] = (var, f"locator") else: # Reverted: User found full-width resize too aggressive ("make fields smaller") # Using fixed width and sticky="w" ensures consistent, smaller appearance. widget = ttk.Entry(parent, textvariable=var, width=25) widget.grid(row=row_idx, column=1, padx=5, pady=5, sticky="w") widget_dict[key] = (var, type(value)) # Special case: If this is max_pod_desk_size, we want to update the end_locator suffix when changed if key == "max_pod_desk_size": def on_max_change(*args): try: val = int(var.get()) # Find the suffix var in the LOCAL widget dict if it exists yet if "__end_locator_suffix_var__" in widget_dict: # Helper logic duplicated here or accessible def _get_letter(idx): s = "" n = idx - 1 if n < 0: return "" while True: s = chr(ord("A") + (n % 26)) + s n = n // 26 - 1 if n < 0: break return s widget_dict["__end_locator_suffix_var__"].set(_get_letter(val)) except: pass var.trace_add("write", on_max_change) # Delete button if key == "folder_name": # Static attribute - no delete button, placeholder to keep alignment if needed ttk.Label(parent, text="", width=3).grid(row=row_idx, column=2, padx=5) else: del_btn = ttk.Button(parent, text="×", command=lambda k=key: self.delete_attribute(k), bootstyle="danger-outline", width=3) del_btn.grid(row=row_idx, column=2, padx=5, sticky="n") self.del_btns_widgets[key] = del_btn # Bind hover events for dependencies del_btn.bind("", lambda e, k=key: self.on_del_hover(k, True)) del_btn.bind("", lambda e, k=key: self.on_del_hover(k, False)) def on_del_hover(self, key, entering): if not entering: # Reset all to outline for btn in self.del_btns_widgets.values(): try: btn.configure(bootstyle="danger-outline") except: pass return # Identify dependencies related_keys = set() related_keys.add(key) # Forward: key depends on X forward_deps = self.attr_metadata.get(key, {}).get("dependencies", []) for f in forward_deps: if f in self.del_btns_widgets: related_keys.add(f) # Reverse: Y depends on key for cur_k in self.del_btns_widgets.keys(): deps = self.attr_metadata.get(cur_k, {}).get("dependencies", []) if key in deps: related_keys.add(cur_k) # Highlight for k in related_keys: if k in self.del_btns_widgets: self.del_btns_widgets[k].configure(bootstyle="danger") def update_data_from_widgets(self): if not self.current_room: return # Get name (handle rename) if '__section_name__' in self.widgets: new_name_display = self.widgets['__section_name__'].get() new_key = new_name_display.strip().replace(' ', '_').lower() else: new_key = self.current_room # Gather attributes new_attrs = {} # If the widget list has 'room_available', get it first if 'room_available' in self.widgets: new_attrs['room_available'] = self.widgets['room_available'][0].get() for key, item in self.widgets.items(): if key.startswith('__') or key == 'room_available': continue var, orig_type = item # Special handling for complex types that aren't single Tk Vars if orig_type == "pod_counts_dict": final_dict = {} for pid, pvar in var.items(): final_dict[pid] = pvar.get() new_attrs[key] = final_dict continue val = var.get() # Type conversion if orig_type == bool or orig_type == "bool": new_attrs[key] = bool(val) elif orig_type == "locator": # Re-attach prefix if it's missing (it should be missing from the Edit widget) if val: new_attrs[key] = f"{self.site_prefix}.{val}" else: new_attrs[key] = "" elif orig_type == int: try: new_attrs[key] = int(val) except: new_attrs[key] = val elif orig_type == float: try: new_attrs[key] = float(val) except: new_attrs[key] = val else: new_attrs[key] = val # Update Main Data if new_key != self.current_room: # Remove old key if self.current_room in self.data: del self.data[self.current_room] # Transfer cache entry to new key so we don't lose the pre-built view if self.current_room in self.view_cache: self.view_cache[new_key] = self.view_cache.pop(self.current_room) self.current_room = new_key self.data[self.current_room] = new_attrs def add_section(self): base_name = "new_section" if base_name in self.data: i = 1 while f"{base_name}_{i}" in self.data: i += 1 base_name = f"{base_name}_{i}" # Create without loop issue final_name = base_name self.data[final_name] = {"room_available": False, "folder_name": "New Section"} self.populate_sidebar() self.tree.selection_set(final_name) # This triggers on_tree_select which loads it def delete_current_section(self): if not self.current_room: return if messagebox.askyesno("Delete", f"Delete section '{self.current_room}'?"): if self.current_room in self.view_cache: h, a, _ = self.view_cache[self.current_room] h.destroy() # Actually destroy widgets a.destroy() del self.view_cache[self.current_room] del self.data[self.current_room] self.current_room = None self.populate_sidebar() # Select first available children = self.tree.get_children() if children: self.tree.selection_set(children[0]) else: # Clear view for w in self.header_frame.winfo_children(): w.destroy() for w in self.scrollable_frame.winfo_children(): w.destroy() def add_attribute_dialog(self): if not self.current_room: return # Prepare dialog dialog = ttk.Toplevel(self) dialog.title("Add Attributes") dialog.geometry("500x600") # Determine available attributes (exclude ones already in widgets) current_keys = set(self.widgets.keys()) def get_weight(key): return self.attr_metadata.get(key, {}).get("weight", 0) # Filter metadata keys to those not currently used and sort by weight if self.attr_metadata: raw_keys = [k for k in self.attr_metadata.keys() if k not in current_keys] available_keys = sorted(raw_keys, key=get_weight, reverse=True) else: available_keys = [] if not available_keys: ttk.Label(dialog, text="All known attributes already added.").pack(pady=(20,20)) ttk.Button(dialog, text="Close", command=dialog.destroy, bootstyle="secondary").pack() return # Main instructions ttk.Label(dialog, text="Select attributes to add:").pack(pady=(10,5)) # Scrollable area canvas_frame = ttk.Frame(dialog) canvas_frame.pack(fill=BOTH, expand=True, padx=10, pady=5) canvas = tk.Canvas(canvas_frame) scrollbar = ttk.Scrollbar(canvas_frame, orient=VERTICAL, command=canvas.yview) scroll_content = ttk.Frame(canvas) scroll_content.bind( "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) canvas.create_window((0, 0), window=scroll_content, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side=LEFT, fill=BOTH, expand=True) scrollbar.pack(side=RIGHT, fill=Y) # Variables to track selection self.add_dialog_vars = {} # {key: BooleanVar} self.add_dialog_checkboxes = {} # {key: Checkbutton} def update_disabled_states(): # Gather all currently active selections active_selections = [k for k, v in self.add_dialog_vars.items() if v.get()] # Determine which keys should be disabled to_disable = set() # 1. Check against existing (Pre-calculated static set for this room) for k in available_keys: # potential to add info = self.attr_metadata.get(k, {}) incomp = info.get("incompatible", []) # If k is incompatible with ANY existing key for exist in current_keys: if exist in incomp: to_disable.add(k) break exist_info = self.attr_metadata.get(exist, {}) if k in exist_info.get("incompatible", []): to_disable.add(k) break # 2. Check against currently selected in dialog for sel in active_selections: sel_info = self.attr_metadata.get(sel, {}) sel_incomp = sel_info.get("incompatible", []) for k in available_keys: if k == sel: continue if k in sel_incomp: to_disable.add(k) continue k_info = self.attr_metadata.get(k, {}) if sel in k_info.get("incompatible", []): to_disable.add(k) # Apply states for k, widget in self.add_dialog_checkboxes.items(): if k in to_disable: if self.add_dialog_vars[k].get(): self.add_dialog_vars[k].set(False) widget.configure(state="disabled") else: widget.configure(state="normal") # Logic for auto-selecting dependencies and handling incompatibilities def toggle_attr(key): # Prevent recursion loop if setting variable manually triggers trace var = self.add_dialog_vars[key] if var.get(): # If Checking ON... info = self.attr_metadata.get(key, {}) deps = info.get("dependencies", []) # Auto-select dependencies for d in deps: # If dependency is missing from room AND available in this dialog if d not in current_keys and d in self.add_dialog_vars: if not self.add_dialog_vars[d].get(): self.add_dialog_vars[d].set(True) toggle_attr(d) # Update visual states for all update_disabled_states() # Build list for key in available_keys: row = ttk.Frame(scroll_content) row.pack(fill=X, pady=4, anchor="w") info = self.attr_metadata.get(key, {}) desc = info.get("description", "") var = tk.BooleanVar(value=False) self.add_dialog_vars[key] = var self.add_dialog_vars[key] = var cb = ttk.Checkbutton(row, text=key.replace('_', ' ').title(), variable=var, command=lambda k=key: toggle_attr(k), bootstyle="round-toggle") cb.pack(anchor="w") self.add_dialog_checkboxes[key] = cb if desc: ttk.Label(row, text=desc, font=("Helvetica", 9), foreground="gray", wraplength=450).pack(anchor="w", padx=(25, 0)) # Initial state check update_disabled_states() # Buttons btn_frame = ttk.Frame(dialog, padding=10) btn_frame.pack(fill=X, side=BOTTOM) def commit_add(): added_any = False # Collect all checked items to_add = [k for k, v in self.add_dialog_vars.items() if v.get()] if not to_add: dialog.destroy() return self.update_data_from_widgets() # Sync first for key in to_add: if key not in self.data[self.current_room]: # Determine default value based on type or metadata default meta = self.attr_metadata.get(key, {}) attr_type = meta.get("type", "string") if "default_value" in meta: default_val = meta["default_value"] elif attr_type == "boolean": default_val = False elif attr_type == "integer": default_val = 0 else: default_val = "" self.data[self.current_room][key] = default_val added_any = True if added_any: # Invalidate cache if self.current_room in self.view_cache: h, a, _ = self.view_cache[self.current_room] h.destroy(); a.destroy() del self.view_cache[self.current_room] self.select_room(self.current_room) dialog.destroy() ttk.Button(btn_frame, text="Add Selected", command=commit_add, bootstyle="success").pack(side=RIGHT, padx=5) ttk.Button(btn_frame, text="Cancel", command=dialog.destroy, bootstyle="secondary").pack(side=RIGHT) def delete_attribute(self, key): self.update_data_from_widgets() # Invalidate cache if self.current_room in self.view_cache: h, a, _ = self.view_cache[self.current_room] h.destroy() a.destroy() del self.view_cache[self.current_room] if key in self.data[self.current_room]: del self.data[self.current_room][key] self.select_room(self.current_room) def save_config(self): self.update_data_from_widgets() try: with open(self.config_path, 'w') as f: json.dump(self.data, f, indent=4) messagebox.showinfo("Saved", "Configuration saved!") self.populate_sidebar() # Refresh names in case of change if self.current_room and self.tree.exists(self.current_room): self.tree.selection_set(self.current_room) return True except Exception as e: messagebox.showerror("Error", str(e)) return False def run_script(self): if self.save_config(): try: run_script_logic(self.base_dir) messagebox.showinfo("Success", "Script finished successfully!") except Exception as e: messagebox.showerror("Error", f"Failed to run script: {e}") # ========================================== # MAIN ENTRY POINT # ========================================== base_dir = get_base_path() config_path = os.path.join(base_dir, "config.json") site_prefix = os.path.basename(base_dir) # Default fallback data used if the JSON file is missing or invalid. # Since we have the logic to create defaults, we can just ensure it exists. # We'll use the existing one or create a minimal one. def ensure_config_exists(): if not os.path.exists(config_path): print(f"'{config_path}' not found. Creating with default values.") # We need a basic default structure if not present basic_defaults = { "cmcc_lite": { "room_available": False, "room_locator": "X.01.B.09", "desk_count": 1, "unique_desk_numbers": False, "folder_name": "CMCC Lite" }, "cmcc": { "room_available": False, "room_locator": "X.01.B.09", "desk_count": 8, "unique_desk_numbers": True, "number_of_paper_printers": 1, "number_of_phones": 1, "folder_name": "CMCC" }, "open_office": { "room_available": False, "desks_start_locator": "X.01.E.41", "desks_end_locator": "X.01.E.44", "variable_pod_sizes": False, "max_pod_desk_size": 8, "number_of_paper_printers": 1, "folder_name": "Open Office" }, "guard_house": { "room_available": False, "number_of_phones": 2, "number_of_laptops": 1, "desk_count": 1, "unique_desk_numbers": False, "folder_name": "Guard House" }, "chef_office": { "room_available": False, "room_locator": "X.01.B.09", "desk_count": 1, "unique_desk_numbers": False, "number_of_paper_printers": 1, "folder_name": "Chef Office" }, "copy_and_print_room": { "room_available": False, "room_locator": "X.01.B.09", "number_of_paper_printers": 1, "folder_name": "Copy and Print Room" }, "foh_lobby": { "room_available": False, "room_locator": "X.01.B.09", "number_of_phones": 1, "number_of_paper_printers": 1, "badge_supplies": True, "number_of_vms_printers": 1, "number_of_fargo_printers": 1, "number_of_laptops": 1, "number_of_monitors": 2, "number_of_desktops": 1, "folder_name": "FOH Lobby" }, "helpdesk": { "room_available": False, "room_locator": "X.01.B.09", "helpdesk_accessories": True, "desk_count": 1, "unique_desk_numbers": False, "number_of_paper_printers": 1, "folder_name": "Helpdesk" }, "shipping_and_receiving": { "room_available": False, "room_locator": "X.01.B.09", "desk_count": 4, "unique_desk_numbers": False, "number_of_paper_printers": 1, "number_of_phones": 1, "folder_name": "Shipping and Receiving Office" }, "it_storage": { "room_available": False, "room_locator": "X.01.B.09", "desk_count": 1, "unique_desk_numbers": False, "number_of_paper_printers": 1, "folder_name": "IT Storage" }, "scr_room": { "room_available": False, "room_locator": "X.01.B.09", "desk_count": 8, "unique_desk_numbers": False, "number_of_paper_printers": 1, "av_rack": True, "folder_name": "SCR Office" }, "vending_machine": { "room_available": False, "folder_name": "Vending Machine" } } try: with open(config_path, 'w') as f: json.dump(basic_defaults, f, indent=4) print(f"Successfully created '{config_path}'") except Exception as e: # Create a temporary root context to show the error since the main UI hasn't started root = tk.Tk() root.withdraw() messagebox.showerror( "Configuration Error", f"Error creating default config file: {e}\n\n" f"Location: {config_path}\n\n" "Please ensure the application is in a writable folder (e.g., Documents or Desktop) " "and NOT running directly from a Read-Only DMG or Zip." ) root.destroy() sys.exit(1) def update_locators(): """ Updates locator prefixes in config.json to match the current folder name. """ if not os.path.exists(config_path): return try: with open(config_path, 'r') as f: data = json.load(f) modified = False target_keys = ["room_locator", "desks_start_locator", "desks_end_locator"] for room, attrs in data.items(): for key in target_keys: if key in attrs: val = attrs[key] # Only update if it looks like a locator (has a dot) and prefix doesn't match if isinstance(val, str) and "." in val: prefix, rest = val.split('.', 1) if prefix != site_prefix: new_val = f"{site_prefix}.{rest}" attrs[key] = new_val modified = True print(f"Updated {room}.{key}: '{val}' -> '{new_val}'") if modified: with open(config_path, 'w') as f: json.dump(data, f, indent=4) print(f"Configuration updated with new site prefix: {site_prefix}") except Exception as e: print(f"Error checking/updating config locators: {e}") if __name__ == "__main__": try: ensure_config_exists() update_locators() app = ConfigUI() app.mainloop() except Exception as e: import traceback err_trace = traceback.format_exc() try: # Emergency GUI error report root = tk.Tk() root.withdraw() messagebox.showerror("Critical Application Error", f"An unexpected error occurred:\n\n{err_trace}") root.destroy() except: # If even Tkinter fails, print to stderr (last resort) print(err_trace, file=sys.stderr) sys.exit(1)