Source code for pbpicat.config

import json
import shutil
from pathlib import Path

from PySide6.QtCore import QSettings

from pbpicat.platform import config_dir as _config_dir

_BASE_DIR = _config_dir()
_CATALOG_CONF = _BASE_DIR / "catalog.conf"
_GLOBAL_CONFIG_PATH = _BASE_DIR / "global_settings.json"

_current_catalog: str = "default"


[docs] def set_base_dir(path: Path) -> None: """Override the base config directory. Must be called before init_catalogs().""" global _BASE_DIR, _CATALOG_CONF, _GLOBAL_CONFIG_PATH _BASE_DIR = path _CATALOG_CONF = _BASE_DIR / "catalog.conf" _GLOBAL_CONFIG_PATH = _BASE_DIR / "global_settings.json"
DEFAULT_SIDECAR_EXTENSIONS = [".xmp", ".dop", ".pp3"] DEFAULT_VIDEO_EXTENSIONS = [ ".mp4", ".mov", ".avi", ".mkv", ".mts", ".m2ts", ".wmv", ".flv", ".webm", ] DEFAULT_IMAGE_EXTENSIONS = [ # Common raster formats ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff", ".webp", # Modern / recent formats ".heic", ".heif", ".avif", ".jxl", # RAW — generic ".raw", ".dng", # RAW — Canon ".cr2", ".cr3", # RAW — Nikon ".nef", ".nrw", # RAW — Sony ".arw", ".srf", ".sr2", # RAW — Olympus / OM System ".orf", # RAW — Panasonic ".rw2", # RAW — Pentax ".pef", # RAW — Fujifilm ".raf", # RAW — Hasselblad ".3fr", # RAW — Mamiya ".mef", # RAW — Minolta / Konica Minolta ".mrw", # RAW — Epson ".erf", # RAW — Kodak ".kdc", ".dcr", # RAW — Leica ".rwl", # RAW — Samsung ".srw", # RAW — Sigma ".x3f", # HDR / high bit-depth ".exr", ".hdr", # JPEG 2000 ".jp2", ".j2k", # JPEG XR ".jxr", # Legacy raster ".tga", ".pcx", ".pnm", ".pgm", ".ppm", ".pbm", # Icons ".ico", # Adobe Photoshop ".psd", ".psb", ] DEFAULTS = { "sidecar_extensions": DEFAULT_SIDECAR_EXTENSIONS, "image_extensions": DEFAULT_IMAGE_EXTENSIONS, "video_extensions": DEFAULT_VIDEO_EXTENSIONS, "video_marker": "", "schema_field_count": 6, "schema_field_titles": ["Catégorie", "Sous-cat.", "Série", "Sujet", "Détail", "Numéro"], "thumbnail_max_width": 128, "thumbnail_max_height": 128, "history_max": 20, "zoom_step_percent": 25, "zoom_max_percent": 400, "language": "", "sidecar_new_extension": ".xmp", "delete_empty_sidecars": True, "delete_list_max_files": 12, "confirm_deletions": True, "use_trash": True, "exif_auto_rotate": True, } _DEFAULT_GLOBAL_CONFIG: dict = { "default_sidecar_extensions": DEFAULT_SIDECAR_EXTENSIONS, "language": "", } # --------------------------------------------------------------------------- # Dynamic path helpers (depend on _BASE_DIR / _current_catalog at call time) # --------------------------------------------------------------------------- def _catalog_dir() -> Path: return _BASE_DIR / _current_catalog def _config_path() -> Path: return _catalog_dir() / "settings.json" def _history_path() -> Path: return _catalog_dir() / "history.json" def _qsettings_path() -> Path: return _catalog_dir() / "ui.conf" # --------------------------------------------------------------------------- # Catalog management # ---------------------------------------------------------------------------
[docs] def current_catalog() -> str: return _current_catalog
[docs] def load_current_catalog_name() -> str: """Read catalog.conf; validate that the named directory exists; fall back to first available.""" if _CATALOG_CONF.exists(): try: name = _CATALOG_CONF.read_text(encoding="utf-8").strip() if name and (_BASE_DIR / name).is_dir(): return name except OSError: pass catalogs = list_catalogs() return catalogs[0] if catalogs else "default"
[docs] def set_current_catalog(name: str) -> None: global _current_catalog _current_catalog = name _BASE_DIR.mkdir(parents=True, exist_ok=True) _CATALOG_CONF.write_text(name, encoding="utf-8")
[docs] def list_catalogs() -> list[str]: if not _BASE_DIR.exists(): return ["default"] return sorted(p.name for p in _BASE_DIR.iterdir() if p.is_dir())
[docs] def create_catalog(name: str, initial_config: dict | None = None) -> None: catalog_dir = _BASE_DIR / name catalog_dir.mkdir(parents=True, exist_ok=True) if initial_config: settings_path = catalog_dir / "settings.json" if not settings_path.exists(): with open(settings_path, "w", encoding="utf-8") as f: json.dump(initial_config, f, indent=2, ensure_ascii=False)
[docs] def delete_catalog(name: str) -> None: shutil.rmtree(_BASE_DIR / name)
[docs] def duplicate_catalog(source_name: str, dest_name: str) -> None: """Copy all files from source_name catalog into a new dest_name catalog.""" src_dir = _BASE_DIR / source_name dst_dir = _BASE_DIR / dest_name dst_dir.mkdir(parents=True, exist_ok=True) for filename in ("settings.json", "history.json"): src_file = src_dir / filename if src_file.exists(): shutil.copy2(src_file, dst_dir / filename)
[docs] def init_catalogs() -> None: """Ensure at least one catalog exists, migrate legacy files if needed, load active catalog.""" global _current_catalog _BASE_DIR.mkdir(parents=True, exist_ok=True) default_dir = _BASE_DIR / "default" # Migrate files from pre-catalog layout (directly in _BASE_DIR) into default/ for filename in ("settings.json", "history.json", "ui.conf"): src = _BASE_DIR / filename dst = default_dir / filename if src.exists(): default_dir.mkdir(exist_ok=True) if not dst.exists(): src.rename(dst) else: src.unlink() if not list_catalogs(): default_dir.mkdir(exist_ok=True) _current_catalog = load_current_catalog_name()
# --------------------------------------------------------------------------- # Program configuration (settings.json) # ---------------------------------------------------------------------------
[docs] def qsettings() -> QSettings: """Return a QSettings instance pointing to the active catalog's ui.conf.""" _catalog_dir().mkdir(parents=True, exist_ok=True) return QSettings(str(_qsettings_path()), QSettings.Format.IniFormat)
[docs] def app_qsettings() -> QSettings: """Return a QSettings instance for application-level state (window geometry, etc.). Stored in <config_dir>/app.conf, independent of the active catalog. """ _BASE_DIR.mkdir(parents=True, exist_ok=True) return QSettings(str(_BASE_DIR / "app.conf"), QSettings.Format.IniFormat)
[docs] def load_config() -> dict: config = DEFAULTS.copy() path = _config_path() if path.exists(): try: with open(path, encoding="utf-8") as f: data = json.load(f) # Only accept keys whose type matches the default; fall back to default otherwise. for k, default in DEFAULTS.items(): if k in data and type(data[k]) is type(default): config[k] = data[k] # Preserve extra keys (e.g. last_dest) that have no default. for k, v in data.items(): if k not in DEFAULTS: config[k] = v except (OSError, json.JSONDecodeError, ValueError): pass return config
[docs] def save_config(config: dict) -> None: path = _config_path() path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2, ensure_ascii=False)
[docs] def load_global_config() -> dict: config = {k: list(v) if isinstance(v, list) else v for k, v in _DEFAULT_GLOBAL_CONFIG.items()} if _GLOBAL_CONFIG_PATH.exists(): try: with open(_GLOBAL_CONFIG_PATH, encoding="utf-8") as f: data = json.load(f) for k, default in _DEFAULT_GLOBAL_CONFIG.items(): if k in data and type(data[k]) is type(default): config[k] = data[k] except (OSError, json.JSONDecodeError, ValueError): pass return config
[docs] def save_global_config(config: dict) -> None: _BASE_DIR.mkdir(parents=True, exist_ok=True) with open(_GLOBAL_CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(config, f, indent=2, ensure_ascii=False)
# --------------------------------------------------------------------------- # History (history.json) — one list of strings per field key # --------------------------------------------------------------------------- def _load_history_file() -> dict: path = _history_path() if path.exists(): try: with open(path, encoding="utf-8") as f: return json.load(f) except (OSError, json.JSONDecodeError, ValueError): pass return _migrate_from_qsettings() def _save_history_file(data: dict) -> None: path = _history_path() path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) def _migrate_from_qsettings() -> dict: """One-time migration from legacy QSettings storage.""" qs = QSettings("PBPicat", "PBPicat") data: dict = {} qs.beginGroup("history") for key in qs.childKeys(): values = qs.value(key, [], type=list) if values: data[key] = values qs.endGroup() return data
[docs] def load_history(key: str) -> list[str]: return _load_history_file().get(key, [])
[docs] def save_history(key: str, values: list[str]) -> None: data = _load_history_file() data[key] = values _save_history_file(data)
[docs] def load_all_history() -> dict[str, list[str]]: return _load_history_file()
[docs] def save_all_history(data: dict[str, list[str]]) -> None: _save_history_file(data)
# --------------------------------------------------------------------------- # UI state (ui.conf — QSettings IniFormat in active catalog dir) # ---------------------------------------------------------------------------
[docs] def load_last_dest() -> str: return load_config().get("last_dest", "")
[docs] def save_last_dest(path: str) -> None: config = load_config() config["last_dest"] = path save_config(config)
[docs] def load_last_source_dir() -> str: """Return the last selected source directory, falling back to $HOME if missing.""" path = qsettings().value("source/last_dir", "", type=str) if path and Path(path).is_dir(): return path return str(Path.home())
[docs] def save_last_source_dir(path: str) -> None: qs = qsettings() qs.setValue("source/last_dir", path) qs.sync()
[docs] def load_video_marker_pos() -> int: return int(qsettings().value("schema/video_marker_pos", 0))
[docs] def save_video_marker_pos(pos: int) -> None: qs = qsettings() qs.setValue("schema/video_marker_pos", pos) qs.sync()