"""Loads and validates config.json. Expands Windows env vars in paths. Any user-data path left empty in config.json is filled in from `app.paths` so all caches end up in the same `%LOCALAPPDATA%` folder. """ from __future__ import annotations import glob import json import logging import os import re import sys from dataclasses import dataclass, field from datetime import date from pathlib import Path from . import paths as app_paths log = logging.getLogger(__name__) def _expand(path: str) -> str: """Expand %ENV% and ~ in a path string.""" if not path: return path return os.path.expandvars(os.path.expanduser(path)) # Matches dated parts filenames: MM-DD-YY or MM-DD-YYYY, whitespace, "OnPart", # then any non-whitespace suffix (e.g. "_inv", "_inv(in)", "(in)"), then ".csv". _DATED_CSV_RE = re.compile( r"^(\d{2})-(\d{2})-(\d{2}|\d{4})\s+OnPart\S*\.csv$", re.IGNORECASE, ) def _parse_dated_filename(name: str) -> date | None: """Return the date encoded in a dated parts filename, or None. Accepts any filename matching MM-DD-YY(YY) OnPart*.csv, e.g. 'OnPart.csv', 'OnPart_inv.csv', 'OnPart_inv(in).csv'. """ m = _DATED_CSV_RE.match(name) if not m: return None mm_s, dd_s, yy_s = m.groups() mm, dd = int(mm_s), int(dd_s) # 2-digit years are interpreted as 20YY. 4-digit years used as-is. year = int(yy_s) if len(yy_s) == 4 else 2000 + int(yy_s) try: return date(year, mm, dd) except ValueError: return None # e.g., "13-40-26" def _find_dated_csv(directory: str) -> str: """Find the newest dated parts CSV file in `directory` whose date is today or earlier. Returns the full path, or "" if none found.""" if not directory or not os.path.isdir(directory): return "" today = date.today() best: tuple[date, str] | None = None try: entries = os.listdir(directory) except OSError as e: log.warning("Could not list %s: %s", directory, e) return "" for name in entries: d = _parse_dated_filename(name) if d is None or d > today: continue full = os.path.join(directory, name) if not os.path.isfile(full): continue if best is None or d > best[0]: best = (d, full) return best[1] if best else "" @dataclass class Config: csv_path: str = "" csv_path_glob_fallback: str = "" sqlite_path: str = "" image_cache_dir: str = "" smb_image_dir: str = "" default_tab: str = "parts" max_results: int = 15 ui_scale: float = 1.2 appearance_mode: str = "light" color_theme: str = "blue" # ShipEngine API credentials shipengine_sandbox: bool = True shipengine_api_key: str = "" shipengine_return_key: str = "" # USPS shipper (sender) address usps_shipper_name: str = "" usps_shipper_company: str = "" usps_shipper_phone: str = "" usps_shipper_address1: str = "" usps_shipper_city: str = "" usps_shipper_state: str = "" usps_shipper_zip: str = "" # FedEx Ship API credentials fedex_sandbox: bool = False fedex_account: str = "" fedex_api_key: str = "" fedex_secret: str = "" # FedEx shipper (sender) address — fill in config.json for your location fedex_shipper_name: str = "" fedex_shipper_company: str = "" fedex_shipper_phone: str = "" fedex_shipper_address1: str = "" fedex_shipper_city: str = "" fedex_shipper_state: str = "" fedex_shipper_zip: str = "" # Resolved at runtime - not part of the JSON file itself. resolved_csv_path: str = field(default="", init=False) _loaded_path: Path | None = field(default=None, init=False) # Per-machine override for the scan folder. Set by the UI from ui_state.json # so each workstation can point at its own parts folder without editing # the deployable config.json. parts_folder_override: str = field(default="", init=False) @classmethod def load(cls, path: Path | None = None) -> "Config": if path is None: # Look next to the running app first; fall back to repo-root config.json. base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) path = base / "config.json" if not path.exists(): path = Path(__file__).resolve().parents[1] / "config.json" with open(path, "r", encoding="utf-8") as f: raw = json.load(f) cfg = cls(**{k: raw[k] for k in raw if k in cls.__dataclass_fields__}) cfg._loaded_path = path cfg.csv_path = _expand(cfg.csv_path) cfg.csv_path_glob_fallback = _expand(cfg.csv_path_glob_fallback) cfg.sqlite_path = _expand(cfg.sqlite_path) cfg.image_cache_dir = _expand(cfg.image_cache_dir) cfg.smb_image_dir = _expand(cfg.smb_image_dir) # Fall back to the centralized appdata location whenever a cache path # isn't explicitly set in config.json. This keeps every persisted # artifact under one folder per user so it's easy to find / clear and # never accidentally lands next to the .exe. if not cfg.sqlite_path: cfg.sqlite_path = app_paths.default_sqlite_path() if not cfg.image_cache_dir: cfg.image_cache_dir = app_paths.default_image_cache_dir() # Make sure parent dirs for cache exist. Path(cfg.sqlite_path).parent.mkdir(parents=True, exist_ok=True) Path(cfg.image_cache_dir).mkdir(parents=True, exist_ok=True) # The parts_folder_override is set by the UI after load (from # ui_state.json), so don't resolve the CSV here - the UI will call # resolve_csv() after setting the override. cfg.resolved_csv_path = cfg._resolve_csv() return cfg def _scan_directory(self) -> str: """Return the directory to scan for dated parts CSV files. Resolution order: 1. parts_folder_override - the per-machine folder the user picked via the Change folder button (stored in ui_state.json). 2. csv_path_glob_fallback's dirname - the config.json default. 3. csv_path's dirname - last-resort fallback. """ if self.parts_folder_override and os.path.isdir(self.parts_folder_override): return self.parts_folder_override if self.csv_path_glob_fallback: d = os.path.dirname(self.csv_path_glob_fallback) if d: return d if self.csv_path: d = os.path.dirname(self.csv_path) if d: return d return "" def _resolve_csv(self) -> str: """Pick the CSV the app should load. Resolution order: 1. Newest dated parts CSV file in the scan directory whose date is today or earlier. This is the canonical source and always wins on launch, even if the user previously picked a different file via Change CSV path. 2. The last user-configured csv_path, if it exists on disk. 3. Newest match of csv_path_glob_fallback (legacy fallback). """ scan_dir = self._scan_directory() dated = _find_dated_csv(scan_dir) if dated: log.info("CSV resolved via dated scan: %s (scan_dir=%s)", dated, scan_dir) return dated if self.csv_path and Path(self.csv_path).exists(): log.info( "CSV resolved via csv_path fallback: %s (no dated files in %s)", self.csv_path, scan_dir, ) return self.csv_path if self.csv_path_glob_fallback: matches = glob.glob(self.csv_path_glob_fallback) if matches: matches.sort(key=lambda p: Path(p).stat().st_mtime, reverse=True) log.info("CSV resolved via glob fallback: %s", matches[0]) return matches[0] log.warning( "CSV resolution found nothing - returning csv_path (may not exist): %s", self.csv_path, ) return self.csv_path # may not exist; caller decides what to do def resolve_csv(self) -> str: """Re-run CSV resolution and update self.resolved_csv_path. Call this before each load/reload so a newly-dropped dated file picks up without restarting the app. """ self.resolved_csv_path = self._resolve_csv() return self.resolved_csv_path def save(self) -> None: """Write the current config back to config.json, preserving other fields.""" if not self._loaded_path: return try: with open(self._loaded_path, "r", encoding="utf-8") as f: data = json.load(f) except Exception: data = {} for k in self.__dataclass_fields__: if k not in ("resolved_csv_path", "_loaded_path", "parts_folder_override"): data[k] = getattr(self, k) with open(self._loaded_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2)