252 lines
8.9 KiB
Python
252 lines
8.9 KiB
Python
"""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)
|