import json import os import sys import re import ast import subprocess 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.5" 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 # ========================================== 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 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}") 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}") # 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}") # 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...") for dirpath, _, filenames in os.walk(root_dir): 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}") except Exception as e: print(f"Error renaming {full_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) 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)