From 7ae0709f387e460deaf55e91e54903fe649c31f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Fri, 3 Jul 2026 16:06:45 +0200 Subject: [PATCH] Store library settings in the pool index and drop legacy sidecars --- CHANGELOG.md | 23 + PROJECT.md | 14 +- pyproject.toml | 2 +- src/_version.py | 2 +- src/core/config.py | 76 +-- src/core/file.py | 45 +- src/core/file_manager.py | 165 ++--- src/core/pool_index.py | 27 +- src/ui/gui.py | 1296 ------------------------------------ tests/test_config.py | 190 +----- tests/test_file.py | 248 +++---- tests/test_file_manager.py | 581 ++++------------ tests/test_pool_index.py | 2 +- 13 files changed, 379 insertions(+), 2292 deletions(-) delete mode 100644 src/ui/gui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0368982..bfdb8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,29 @@ Each version entry uses these sections (include only those that apply): ## Unreleased +## 1.8.0 — 2026-07-03 + +### Changed +- **Split config responsibilities by scope.** The global config (`.Curator.!gtag`) + now carries **application data only** — window state, MRU folders, and the + `pool_dir` / `filmoteka_dir` pointers. Everything describing a particular + library — **`tag_schema`** and **`copyasis_folders`** — moved into the pool + index (`.Curator.!index`, under a new `settings` section), so it travels with + the pool. `FileManager` reads/writes those via the index (`PoolIndex.get_setting` + / `set_setting`) and **migrates** any values left in an old global config into + the index once, on first open (`FileManager._migrate_global_settings`). + +### Removed +- **Per-folder config (`.Curator.!ftag`)** and the whole legacy folder-scan path + (`FileManager.append` / `get_folder_config` / `save_folder_config` / + `set_ignore_patterns` / `get_ignore_patterns`, `config.load_folder_config` etc.) + — inherited from Tagger and unused by the Filmotéka workflow. +- **Per-file metadata sidecars (`.{filename}.!tag`).** `File` metadata is now + stored only in the pool index; an index-less `File` is an in-memory object and + writes nothing to disk. +- **Old tkinter GUI** (`src/ui/gui.py`), orphaned since the PySide6 rewrite and + the only remaining consumer of the folder-config/sidecar code. + ## 1.7.0 — 2026-07-02 ### Added diff --git a/PROJECT.md b/PROJECT.md index 6a9516c..46807a4 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -51,8 +51,9 @@ Curator manages a personal movie library based on two folders: loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`. - `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index** (`/.Curator.!index`, see `pool_index.py`); `File` writes there when an - index is injected, and still falls back to per-file `.!tag` sidecars for - arbitrary (non-pool) folders. + index is injected. Without an index a `File` is an in-memory object only — the + old per-file `.!tag` sidecars and the per-folder `.!ftag` config have been + removed. ### GUI decision @@ -65,6 +66,15 @@ movie table, and one-click Filmotéka generation. - **Metadata storage:** one **unified metadata file** for the whole pool (a central index), not per-file sidecars. Justified because Curator owns the pool and files are never moved manually, so it is not exposed to path drift. +- **Config split by scope:** the global config (`.Curator.!gtag`) holds **app + data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir` + pointers (which must stay global: the index is found *through* them). Everything + that describes a specific library — `tag_schema` and `copyasis_folders` — lives + in the pool index under a `settings` section, so it travels with the pool. + `FileManager` migrates any pre-existing global values into the index on first + open. The old per-folder `.!ftag` config and per-file `.!tag` sidecars were + removed (they were unused Tagger leftovers), and the orphaned tkinter + `src/ui/gui.py` went with them. - **Import dialog:** **multi-file** — pick several videos at once and give each its own **Title** + **ČSFD link** (one row per file, more can be added from the dialog), or auto-filled with **"Najít ČSFD odkazy"** (cleans each filename into diff --git a/pyproject.toml b/pyproject.toml index 33a0143..3e6b83e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "curator" -version = "1.7.0" +version = "1.8.0" description = "" authors = [ {name = "jan.doubravsky@gmail.com"} diff --git a/src/_version.py b/src/_version.py index 6d8f6bb..04fc7eb 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "1.7.0" +__version__ = "1.8.0" diff --git a/src/core/config.py b/src/core/config.py index 8e6b660..cc4b115 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -1,10 +1,15 @@ """ -Configuration management for Curator +Configuration for Curator. -Three levels of configuration: -1. Global config (.Curator.!gtag next to Curator.py) - app-wide settings -2. Folder config (.Curator.!ftag in project root) - folder-specific settings -3. File tags (.{filename}.!tag) - per-file metadata (handled in file.py) +Two stores, by responsibility: + +1. Global config (``.Curator.!gtag`` next to Curator.py) — **application data + only**: window state, MRU folders, and the pointers to where the pool and the + Filmotéka output live (``pool_dir`` / ``filmoteka_dir``). Those pointers must + stay here because the pool index is found *through* them. +2. Pool index (``/.Curator.!index``, see ``pool_index.py``) — everything + describing a particular library: per-movie metadata **and** library-level + settings (``tag_schema``, ``copyasis_folders``). It travels with the pool. """ import json from pathlib import Path @@ -12,12 +17,9 @@ from pathlib import Path # Global config file (next to the main script) GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag" -# Folder config filename -FOLDER_CONFIG_NAME = ".Curator.!ftag" - # ============================================================================= -# GLOBAL CONFIG - Application settings +# TAG SCHEMA - default library settings (stored per-pool in the index) # ============================================================================= # Tag schema: the single source of truth for which tag categories exist, how @@ -35,6 +37,8 @@ FOLDER_CONFIG_NAME = ".Curator.!ftag" # keep the pool filename. Pool files are never renamed by this. # Grouping folders are prefixed with "- " so DLNA/TV browsers sort the special # folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root. +# This lives in the code as the default; the effective schema is stored per-pool +# in the index (``PoolIndex.settings["tag_schema"]``). DEFAULT_TAG_SCHEMA = [ {"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""}, {"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"}, @@ -44,6 +48,11 @@ DEFAULT_TAG_SCHEMA = [ "filmoteka_root": "- Dle hodnocení"}, ] + +# ============================================================================= +# GLOBAL CONFIG - Application settings only +# ============================================================================= + DEFAULT_GLOBAL_CONFIG = { "window_geometry": "1200x800", "window_maximized": False, @@ -52,8 +61,6 @@ DEFAULT_GLOBAL_CONFIG = { "recent_folders": [], "pool_dir": None, # managed pool root (single source of truth) "filmoteka_dir": None, # generated Filmotéka output (hardlink tree) - "copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is) - "tag_schema": DEFAULT_TAG_SCHEMA, # tag categories + ČSFD/Filmotéka rules } @@ -79,53 +86,6 @@ def save_global_config(cfg: dict): json.dump(cfg, f, indent=2, ensure_ascii=False) -# ============================================================================= -# FOLDER CONFIG - Per-folder settings -# ============================================================================= - -DEFAULT_FOLDER_CONFIG = { - "ignore_patterns": [], - "custom_tags": {}, # Additional tags specific to this folder - "recursive": True, # Whether to scan subfolders - "hardlink_output_dir": None, # Output directory for hardlink structure - "hardlink_categories": None, # Categories to include in hardlink (None = all) -} - - -def get_folder_config_path(folder: Path) -> Path: - """Get path to folder config file""" - return folder / FOLDER_CONFIG_NAME - - -def load_folder_config(folder: Path) -> dict: - """Load folder-specific config""" - config_path = get_folder_config_path(folder) - if config_path.exists(): - try: - with open(config_path, "r", encoding="utf-8") as f: - config = json.load(f) - # Merge with defaults for any missing keys - for key, value in DEFAULT_FOLDER_CONFIG.items(): - if key not in config: - config[key] = value - return config - except Exception: - return DEFAULT_FOLDER_CONFIG.copy() - return DEFAULT_FOLDER_CONFIG.copy() - - -def save_folder_config(folder: Path, cfg: dict): - """Save folder-specific config""" - config_path = get_folder_config_path(folder) - with open(config_path, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) - - -def folder_has_config(folder: Path) -> bool: - """Check if folder has a tagger config""" - return get_folder_config_path(folder).exists() - - # ============================================================================= # BACKWARDS COMPATIBILITY # ============================================================================= diff --git a/src/core/file.py b/src/core/file.py index efe71f9..6a5c678 100644 --- a/src/core/file.py +++ b/src/core/file.py @@ -1,5 +1,4 @@ from pathlib import Path -import json from .tag import Tag # Bump this when the csfd_cache schema changes to force re-fetch on next open. @@ -11,9 +10,9 @@ class File: def __init__(self, file_path: Path, tagmanager=None, index=None) -> None: self.file_path = file_path self.filename = file_path.name - self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag" - # Optional unified pool index; when set, metadata lives there instead of - # in the sidecar file (see PoolIndex). + # Unified pool index; when set, metadata is persisted there (see + # PoolIndex). Without an index a File is an in-memory object only — no + # metadata is written to disk (there are no per-file sidecars anymore). self.index = index self.new = True self.ignored = False @@ -38,20 +37,12 @@ class File: self.get_metadata() def get_metadata(self) -> None: - if self.index is not None: - record = self.index.get(self.file_path) - if record is None: - self._init_new_metadata() - self.save_metadata() - else: - self._apply_record(record) - return - - if not self.metadata_filename.exists(): + record = self.index.get(self.file_path) if self.index is not None else None + if record is None: self._init_new_metadata() self.save_metadata() else: - self.load_metadata() + self._apply_record(record) def _init_new_metadata(self) -> None: self.new = True @@ -111,17 +102,9 @@ class File: self.tags.append(tag) def save_metadata(self): - data = self._build_record() + """Persist metadata to the pool index; a no-op for an index-less File.""" if self.index is not None: - self.index.set(self.file_path, data) - return - with open(self.metadata_filename, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - def load_metadata(self) -> None: - with open(self.metadata_filename, "r", encoding="utf-8") as f: - data = json.load(f) - self._apply_record(data) + self.index.set(self.file_path, self._build_record()) def added_timestamp(self) -> float: """Epoch seconds for 'date added' — the stored ``added`` or the file mtime. @@ -181,27 +164,21 @@ class File: self.save_metadata() def delete_metadata(self) -> None: - """Remove this file's metadata (from the index, or its sidecar file).""" + """Remove this file's metadata from the pool index (no-op without one).""" if self.index is not None: self.index.delete(self.file_path) - elif self.metadata_filename.exists(): - self.metadata_filename.unlink() def relocate(self, new_path: Path) -> None: """Point this File at a new path, moving its metadata along. The physical file must already have been moved/renamed by the caller. - Drops the metadata under the old path (index key or sidecar) and rebinds - to the new path; call ``save_metadata()`` afterwards to write it back. + Drops the metadata under the old index key and rebinds to the new path; + call ``save_metadata()`` afterwards to write it back. """ - old_metadata_filename = self.metadata_filename if self.index is not None: self.index.delete(self.file_path) self.file_path = Path(new_path) self.filename = self.file_path.name - self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag" - if self.index is None and old_metadata_filename.exists(): - old_metadata_filename.rename(self.metadata_filename) def set_date(self, date_str: str | None): """Nastaví datum (např. '2025-09-25') nebo None pro smazání.""" diff --git a/src/core/file_manager.py b/src/core/file_manager.py index 2dd7aeb..f236372 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -7,30 +7,33 @@ from .tag_manager import TagManager from .pool_index import PoolIndex from .utils import list_files from typing import Iterable -import fnmatch from src.core.config import ( - load_global_config, save_global_config, - load_folder_config, save_folder_config + load_global_config, save_global_config, DEFAULT_TAG_SCHEMA ) # Top-level folders inside the managed pool POOL_MOVIES = "Filmy" POOL_SERIES = "Seriály" -# Curator metadata files that must never be treated as content +# Library-level settings that live in the pool index (not the global config). +_LIBRARY_SETTINGS_DEFAULTS = { + "tag_schema": DEFAULT_TAG_SCHEMA, + "copyasis_folders": [POOL_SERIES], +} + +# Curator metadata files that must never be treated as content. The legacy +# per-file (.!tag) and per-folder (.!ftag) sidecars are no longer written, but +# stray ones are still skipped so they never leak into the Filmotéka. METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index") class FileManager: def __init__(self, tagmanager: TagManager): self.filelist: list[File] = [] - self.folders: list[Path] = [] self.tagmanager = tagmanager self.on_files_changed = None # callback do GUI self.global_config = load_global_config() - self.folder_configs: dict[Path, dict] = {} # folder -> config - self.current_folder: Path | None = None - self.index: PoolIndex | None = None # unified pool metadata index + self.index: PoolIndex | None = None # unified pool metadata + settings # ------------------------------------------------------------------ # Pool (single source of truth) and Filmotéka (generated output) @@ -51,27 +54,75 @@ class FileManager: pool = self.pool_dir return pool / POOL_SERIES if pool else None + # ------------------------------------------------------------------ + # Pool index + library-level settings (stored in the index, per pool) + # ------------------------------------------------------------------ + + def _open_index(self, pool: Path) -> PoolIndex: + """(Re)load the pool index and migrate any legacy global settings.""" + self.index = PoolIndex(pool) + self._migrate_global_settings() + return self.index + + def _ensure_index(self) -> PoolIndex | None: + """Return the pool index, opening it lazily when a pool is configured.""" + if self.index is None and self.pool_dir: + self._open_index(self.pool_dir) + return self.index + + def _migrate_global_settings(self) -> None: + """Move library settings from the old global config into the index once. + + Earlier versions stored ``tag_schema`` / ``copyasis_folders`` in the + global ``.!gtag``. They now belong to the pool; seed the index from the + old values (if present and not already migrated) and drop them from the + global config so it carries app data only. + """ + if self.index is None: + return + moved = False + for key in _LIBRARY_SETTINGS_DEFAULTS: + if key in self.global_config: + if key not in self.index.settings: + self.index.set_setting(key, self.global_config[key]) + del self.global_config[key] + moved = True + if moved: + save_global_config(self.global_config) + + def _get_setting(self, key: str): + """Read a library setting from the index (falls back to the default).""" + default = _LIBRARY_SETTINGS_DEFAULTS[key] + idx = self._ensure_index() + if idx is None: + return default + return idx.get_setting(key, default) + + def _set_setting(self, key: str, value) -> None: + """Persist a library setting into the index (needs a configured pool).""" + idx = self._ensure_index() + if idx is None: + raise RuntimeError("Pool není nastaven — není kam uložit nastavení.") + idx.set_setting(key, value) + @property def copyasis_folders(self) -> list[str]: """Names of pool subfolders mirrored 1:1 (copy-as-is) into the output.""" - return self.global_config.get("copyasis_folders", [POOL_SERIES]) + return self._get_setting("copyasis_folders") def set_copyasis_folders(self, names: list[str]) -> None: """Set the copy-as-is subfolder list and persist it.""" cleaned = [n.strip() for n in names if n.strip()] - self.global_config["copyasis_folders"] = cleaned - save_global_config(self.global_config) + self._set_setting("copyasis_folders", cleaned) @property def tag_schema(self) -> list[dict]: """Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA).""" - from src.core.config import DEFAULT_TAG_SCHEMA - return self.global_config.get("tag_schema", DEFAULT_TAG_SCHEMA) + return self._get_setting("tag_schema") def set_tag_schema(self, schema: list[dict]) -> None: """Set the tag schema and persist it.""" - self.global_config["tag_schema"] = schema - save_global_config(self.global_config) + self._set_setting("tag_schema", schema) def filmoteka_category_roots(self) -> dict[str, str]: """Category → output root-folder map derived from the tag schema. @@ -131,7 +182,7 @@ class FileManager: if not (movies and movies.is_dir() and pool): return - self.index = PoolIndex(pool) + self._open_index(pool) for each in list_files(movies): if each.name.endswith(METADATA_SUFFIXES): continue @@ -252,7 +303,7 @@ class FileManager: movies.mkdir(parents=True, exist_ok=True) if self.index is None: - self.index = PoolIndex(pool) + self._open_index(pool) source = Path(source) safe_title = title.strip() or source.stem @@ -325,80 +376,6 @@ class FileManager: self.on_files_changed(self.filelist) return file_obj - def append(self, folder: Path) -> None: - """Add a folder to scan for files""" - self.folders.append(folder) - self.current_folder = folder - - # Update global config with last folder - self.global_config["last_folder"] = str(folder) - - # Update recent folders list - recent = self.global_config.get("recent_folders", []) - folder_str = str(folder) - if folder_str in recent: - recent.remove(folder_str) - recent.insert(0, folder_str) - self.global_config["recent_folders"] = recent[:10] # Keep max 10 - - save_global_config(self.global_config) - - # Load folder-specific config - folder_config = load_folder_config(folder) - self.folder_configs[folder] = folder_config - - # Get ignore patterns from folder config - ignore_patterns = folder_config.get("ignore_patterns", []) - - for each in list_files(folder): - # Skip all Curator metadata files (.!tag / .!ftag / .!gtag / .!index) - if each.name.endswith(METADATA_SUFFIXES): - continue - - full_path = each.as_posix() - - # Check against ignore patterns - if any( - fnmatch.fnmatch(each.name, pat) or fnmatch.fnmatch(full_path, pat) - for pat in ignore_patterns - ): - continue - - file_obj = File(each, self.tagmanager) - self.filelist.append(file_obj) - - def get_folder_config(self, folder: Path = None) -> dict: - """Get config for a folder (or current folder if not specified)""" - if folder is None: - folder = self.current_folder - if folder is None: - return {} - if folder not in self.folder_configs: - self.folder_configs[folder] = load_folder_config(folder) - return self.folder_configs[folder] - - def save_folder_config(self, folder: Path = None, config: dict = None): - """Save config for a folder""" - if folder is None: - folder = self.current_folder - if folder is None: - return - if config is None: - config = self.folder_configs.get(folder, {}) - self.folder_configs[folder] = config - save_folder_config(folder, config) - - def set_ignore_patterns(self, patterns: list[str], folder: Path = None): - """Set ignore patterns for a folder""" - config = self.get_folder_config(folder) - config["ignore_patterns"] = patterns - self.save_folder_config(folder, config) - - def get_ignore_patterns(self, folder: Path = None) -> list[str]: - """Get ignore patterns for a folder""" - config = self.get_folder_config(folder) - return config.get("ignore_patterns", []) - def assign_tag_to_file_objects(self, files_objs: list[File], tag): """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" for f in files_objs: @@ -460,9 +437,3 @@ class FileManager: if all(tag in file_tags for tag in target_full_paths): filtered.append(f) return filtered - - # Legacy property for backwards compatibility - @property - def config(self): - """Legacy: returns global config""" - return self.global_config diff --git a/src/core/pool_index.py b/src/core/pool_index.py index 7c0ad2f..599b6ed 100644 --- a/src/core/pool_index.py +++ b/src/core/pool_index.py @@ -5,6 +5,15 @@ Instead of one sidecar file per movie, the whole pool keeps a single JSON index at ``/.Curator.!index``. Curator owns the pool (it inserts/removes files itself), so files never move behind its back and a central index is safe. +The index also carries **library-level settings** (``tag_schema``, +``copyasis_folders``) under a ``settings`` section, so everything describing a +particular pool travels with the pool. The global config keeps only app data +(window state, MRU) and the pointers to where the pool/output live. + +On-disk shape:: + + {"settings": {...}, "movies": {"Filmy/Matrix.mkv": {...}, ...}} + Records are keyed by the file's path relative to the pool root (POSIX form), e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines. """ @@ -21,6 +30,7 @@ class PoolIndex: self.pool_dir = Path(pool_dir) self.index_path = self.pool_dir / INDEX_FILENAME self.records: dict[str, dict] = {} + self.settings: dict = {} self.load() def _key(self, file_path: Path) -> str: @@ -35,19 +45,34 @@ class PoolIndex: """Load the index from disk (missing/corrupt index = empty).""" if not self.index_path.exists(): self.records = {} + self.settings = {} return try: with open(self.index_path, "r", encoding="utf-8") as f: data = json.load(f) self.records = data.get("movies", {}) + self.settings = data.get("settings", {}) except (json.JSONDecodeError, OSError): self.records = {} + self.settings = {} def save(self) -> None: """Persist the index to disk.""" self.pool_dir.mkdir(parents=True, exist_ok=True) with open(self.index_path, "w", encoding="utf-8") as f: - json.dump({"movies": self.records}, f, indent=2, ensure_ascii=False) + json.dump( + {"settings": self.settings, "movies": self.records}, + f, indent=2, ensure_ascii=False, + ) + + def get_setting(self, key: str, default=None): + """Return a library-level setting stored in the index.""" + return self.settings.get(key, default) + + def set_setting(self, key: str, value) -> None: + """Upsert a library-level setting and persist the index.""" + self.settings[key] = value + self.save() def get(self, file_path: Path) -> dict | None: """Return the record for a file, or None if it is not indexed.""" diff --git a/src/ui/gui.py b/src/ui/gui.py deleted file mode 100644 index 996f172..0000000 --- a/src/ui/gui.py +++ /dev/null @@ -1,1296 +0,0 @@ -""" -Modern qBittorrent-style GUI for Curator -""" -import os -import sys -import subprocess -import tkinter as tk -from tkinter import ttk, simpledialog, messagebox, filedialog -from pathlib import Path -from typing import List - -from src.core.media_utils import load_icon -from src.core.file_manager import FileManager -from src.core.tag_manager import TagManager, DEFAULT_TAG_ORDER -from src.core.file import File -from src.core.tag import Tag -from src.core.list_manager import ListManager -from src.constants import APP_NAME, VERSION, APP_VIEWPORT -from src.core.config import save_global_config -from src.core.hardlink_manager import HardlinkManager - - -# qBittorrent-inspired color scheme -COLORS = { - "bg": "#ffffff", - "sidebar_bg": "#f5f5f5", - "toolbar_bg": "#f0f0f0", - "selected": "#0078d7", - "selected_text": "#ffffff", - "border": "#d0d0d0", - "status_bg": "#f8f8f8", - "text": "#000000", -} - -# Tag category colors -TAG_COLORS = [ - "#e74c3c", # red - "#3498db", # blue - "#2ecc71", # green - "#f39c12", # orange - "#9b59b6", # purple - "#1abc9c", # teal - "#e91e63", # pink - "#00bcd4", # cyan -] - -# Fixed colors for default categories -DEFAULT_CATEGORY_COLORS = { - "Hodnocení": "#f1c40f", # gold/yellow for stars - "Barva": "#95a5a6", # gray for color category -} - - -# Categories where only one tag can be selected (exclusive/radio behavior) -EXCLUSIVE_CATEGORIES = {"Hodnocení"} - - -class MultiFileTagAssignDialog(tk.Toplevel): - """Dialog for bulk tag assignment to multiple files""" - def __init__(self, parent, all_tags: List[Tag], files: List[File], category_colors: dict = None): - super().__init__(parent) - self.title("Přiřadit tagy k vybraným souborům") - self.result = None - self.vars: dict[str, int] = {} - self.checkbuttons: dict[str, tk.Checkbutton] = {} - self.tags_by_full = {t.full_path: t for t in all_tags} - self.files = files - self.category_colors = category_colors or {} - self.category_checkbuttons: dict[str, list] = {} # category -> list of checkbuttons - - self.geometry("500x600") - self.minsize(400, 400) - self.configure(bg=COLORS["bg"]) - - tk.Label(self, text=f"Vybráno souborů: {len(files)}", - bg=COLORS["bg"], font=("Arial", 11, "bold")).pack(pady=10) - - # Scrollable frame - canvas = tk.Canvas(self, bg=COLORS["bg"]) - scrollbar = ttk.Scrollbar(self, orient="vertical", command=canvas.yview) - frame = tk.Frame(canvas, bg=COLORS["bg"]) - - frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side="left", fill="both", expand=True, padx=10) - scrollbar.pack(side="right", fill="y") - - # Enable mousewheel scrolling (only when dialog is active) - def on_mousewheel(event): - if canvas.winfo_exists(): - canvas.yview_scroll(int(-1*(event.delta/120)), "units") - - def on_scroll_up(event): - if canvas.winfo_exists(): - canvas.yview_scroll(-1, "units") - - def on_scroll_down(event): - if canvas.winfo_exists(): - canvas.yview_scroll(1, "units") - - canvas.bind("", on_mousewheel) - canvas.bind("", on_scroll_up) - canvas.bind("", on_scroll_down) - frame.bind("", on_mousewheel) - frame.bind("", on_scroll_up) - frame.bind("", on_scroll_down) - - file_tag_sets = [{t.full_path for t in f.tags} for f in files] - - # Group by category - tags_by_category = {} - for full_path, tag in self.tags_by_full.items(): - if tag.category not in tags_by_category: - tags_by_category[tag.category] = [] - tags_by_category[tag.category].append((full_path, tag)) - - # Sort tags within each category - for category in tags_by_category: - if category in DEFAULT_TAG_ORDER: - order = DEFAULT_TAG_ORDER[category] - tags_by_category[category].sort(key=lambda x: order.get(x[1].name, 999)) - else: - tags_by_category[category].sort(key=lambda x: x[1].name) - - for category in sorted(tags_by_category.keys()): - color = self.category_colors.get(category, "#333333") - is_exclusive = category in EXCLUSIVE_CATEGORIES - exclusive_note = " (pouze jedno)" if is_exclusive else "" - - cat_label = tk.Label(frame, text=f"▸ {category}{exclusive_note}", bg=COLORS["bg"], - fg=color, font=("Arial", 10, "bold")) - cat_label.pack(fill="x", anchor="w", pady=(12, 4)) - - self.category_checkbuttons[category] = [] - - for full_path, tag in tags_by_category[category]: - have_count = sum(1 for s in file_tag_sets if full_path in s) - if have_count == 0: - init = 0 - elif have_count == len(files): - init = 1 - else: - init = 2 # mixed - - cb = tk.Checkbutton(frame, text=f" {tag.name}", anchor="w", bg=COLORS["bg"], - font=("Arial", 10)) - cb.state_value = init - cb.full_path = full_path - cb.tag_color = color - cb.category = category - cb.pack(fill="x", anchor="w", padx=20) - cb.bind("", self._on_toggle) - - self._update_checkbox_look(cb) - self.checkbuttons[full_path] = cb - self.vars[full_path] = init - self.category_checkbuttons[category].append(cb) - - btn_frame = tk.Frame(self, bg=COLORS["bg"]) - btn_frame.pack(pady=15) - tk.Button(btn_frame, text="OK", command=self.on_ok, width=12, - font=("Arial", 10)).pack(side="left", padx=5) - tk.Button(btn_frame, text="Zrušit", command=self.destroy, width=12, - font=("Arial", 10)).pack(side="left", padx=5) - - self.transient(parent) - self.grab_set() - parent.wait_window(self) - - def _on_toggle(self, event): - cb: tk.Checkbutton = event.widget - category = cb.category - cur = cb.state_value - - # For exclusive categories, uncheck others first - if category in EXCLUSIVE_CATEGORIES: - if cur == 0 or cur == 2: # turning on - # Uncheck all others in this category - for other_cb in self.category_checkbuttons.get(category, []): - if other_cb != cb and other_cb.state_value != 0: - other_cb.state_value = 0 - self._update_checkbox_look(other_cb) - cb.state_value = 1 - else: # turning off - cb.state_value = 0 - else: - # Normal toggle behavior - if cur == 0: - cb.state_value = 1 - elif cur == 1: - cb.state_value = 0 - elif cur == 2: - cb.state_value = 1 - - self._update_checkbox_look(cb) - return "break" - - def _update_checkbox_look(self, cb: tk.Checkbutton): - v = cb.state_value - color = getattr(cb, 'tag_color', '#333333') - if v == 0: - cb.deselect() - cb.config(fg="#666666") - elif v == 1: - cb.select() - cb.config(fg=color) - elif v == 2: - cb.deselect() - cb.config(fg="#cc6600") # orange for mixed - - def on_ok(self): - self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()} - self.destroy() - - -class App: - def __init__(self, filehandler: FileManager, tagmanager: TagManager): - self.filehandler = filehandler - self.tagmanager = tagmanager - self.list_manager = ListManager() - - # State - self.states = {} - self.file_items = {} # Treeview item_id -> File object mapping - self.selected_tree_item_for_context = None - self.hide_ignored_var = None - self.filter_text = "" - self.show_full_path = False - self.sort_mode = "name" - self.sort_order = "asc" - self.category_colors = {} # category -> color mapping - - self.filehandler.on_files_changed = self.update_files_from_manager - - def _on_close(self): - """Save window geometry and close""" - # Check if maximized - is_maximized = self.root.state() == 'zoomed' - self.filehandler.global_config["window_maximized"] = is_maximized - - # Save geometry only when not maximized - if not is_maximized: - self.filehandler.global_config["window_geometry"] = self.root.geometry() - - save_global_config(self.filehandler.global_config) - self.root.destroy() - - def main(self): - root = tk.Tk() - root.title(f"{APP_NAME} {VERSION}") - - # Load window geometry from global config - geometry = self.filehandler.global_config.get("window_geometry", APP_VIEWPORT) - root.geometry(geometry) - if self.filehandler.global_config.get("window_maximized", False): - root.state('zoomed') - - root.configure(bg=COLORS["bg"]) - self.root = root - - # Bind window close to save geometry - root.protocol("WM_DELETE_WINDOW", self._on_close) - - self.hide_ignored_var = tk.BooleanVar(value=False, master=root) - - # Load last folder - last = self.filehandler.global_config.get("last_folder") - if last: - try: - self.filehandler.append(Path(last)) - except Exception: - pass - - # Load icons - self._load_icons() - - # Build UI - self._create_menu() - self._create_toolbar() - self._create_main_layout() - self._create_status_bar() - self._create_context_menus() - self._bind_shortcuts() - - # Initial refresh - self.refresh_sidebar() - self.update_files_from_manager(self.filehandler.filelist) - - root.mainloop() - - def _load_icons(self): - """Load application icons""" - try: - unchecked = load_icon("src/resources/images/32/32_unchecked.png") - checked = load_icon("src/resources/images/32/32_checked.png") - tag_icon = load_icon("src/resources/images/32/32_tag.png") - self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon} - self.root.unchecked_img = unchecked - self.root.checked_img = checked - self.root.tag_img = tag_icon - except Exception as e: - print(f"Warning: Could not load icons: {e}") - self.icons = {"unchecked": None, "checked": None, "tag": None} - - def _create_menu(self): - """Create menu bar""" - menu_bar = tk.Menu(self.root) - self.root.config(menu=menu_bar) - - # File menu - file_menu = tk.Menu(menu_bar, tearoff=0) - file_menu.add_command(label="Open Folder... (Ctrl+O)", command=self.open_folder_dialog) - file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns) - file_menu.add_separator() - file_menu.add_command(label="Exit (Ctrl+Q)", command=self.root.quit) - - # View menu - view_menu = tk.Menu(menu_bar, tearoff=0) - view_menu.add_checkbutton( - label="Skrýt ignorované", - variable=self.hide_ignored_var, - command=self.toggle_hide_ignored - ) - view_menu.add_command(label="Refresh (F5)", command=self.refresh_all) - - # Tools menu - tools_menu = tk.Menu(menu_bar, tearoff=0) - tools_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) - tools_menu.add_command(label="Detekovat rozlišení videí", command=self.detect_video_resolution) - tools_menu.add_command(label="Přiřadit tagy (Ctrl+T)", command=self.assign_tag_to_selected_bulk) - tools_menu.add_separator() - tools_menu.add_command(label="Nastavit hardlink složku...", command=self.configure_hardlink_folder) - tools_menu.add_command(label="Aktualizovat hardlink strukturu", command=self.update_hardlink_structure) - tools_menu.add_command(label="Vytvořit hardlink strukturu...", command=self.create_hardlink_structure) - - menu_bar.add_cascade(label="Soubor", menu=file_menu) - menu_bar.add_cascade(label="Pohled", menu=view_menu) - menu_bar.add_cascade(label="Nástroje", menu=tools_menu) - - def _create_toolbar(self): - """Create toolbar with buttons""" - toolbar = tk.Frame(self.root, bg=COLORS["toolbar_bg"], height=40, relief=tk.RAISED, bd=1) - toolbar.pack(side=tk.TOP, fill=tk.X) - - # Buttons - tk.Button(toolbar, text="📁 Otevřít složku", command=self.open_folder_dialog, - relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=5, pady=5) - - tk.Button(toolbar, text="🔄 Obnovit", command=self.refresh_all, - relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) - - ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) - - tk.Button(toolbar, text="🏷️ Nový tag", command=lambda: self.tree_add_tag(background=True), - relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) - - tk.Button(toolbar, text="📅 Nastavit datum", command=self.set_date_for_selected, - relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) - - ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) - - # Search box - search_frame = tk.Frame(toolbar, bg=COLORS["toolbar_bg"]) - search_frame.pack(side=tk.RIGHT, padx=10, pady=5) - - tk.Label(search_frame, text="🔍", bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT) - self.search_var = tk.StringVar() - self.search_var.trace('w', lambda *args: self.on_filter_changed()) - search_entry = tk.Entry(search_frame, textvariable=self.search_var, width=25) - search_entry.pack(side=tk.LEFT, padx=5) - - def _create_main_layout(self): - """Create main split layout""" - # Main container - main_container = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, sashwidth=5, bg=COLORS["border"]) - main_container.pack(fill=tk.BOTH, expand=True) - - # Left sidebar (tags) - self._create_sidebar(main_container) - - # Right panel (files table) - self._create_file_panel(main_container) - - def _create_sidebar(self, parent): - """Create left sidebar with tag tree""" - sidebar_frame = tk.Frame(parent, bg=COLORS["sidebar_bg"], width=250) - - # Sidebar header - header = tk.Frame(sidebar_frame, bg=COLORS["sidebar_bg"]) - header.pack(fill=tk.X, padx=5, pady=5) - - tk.Label(header, text="📂 Štítky", font=("Arial", 10, "bold"), - bg=COLORS["sidebar_bg"]).pack(side=tk.LEFT) - - # Tag tree - tree_frame = tk.Frame(sidebar_frame) - tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - self.tag_tree = ttk.Treeview(tree_frame, selectmode="browse", show="tree") - self.tag_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - tree_scroll = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tag_tree.yview) - tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) - self.tag_tree.config(yscrollcommand=tree_scroll.set) - - # Bind events - self.tag_tree.bind("", self.on_tree_left_click) - self.tag_tree.bind("", self.on_tree_right_click) - - parent.add(sidebar_frame) - - def _create_file_panel(self, parent): - """Create right panel with file table""" - file_frame = tk.Frame(parent, bg=COLORS["bg"]) - - # Control panel - control_frame = tk.Frame(file_frame, bg=COLORS["bg"]) - control_frame.pack(fill=tk.X, padx=5, pady=5) - - # View options - tk.Checkbutton(control_frame, text="Plná cesta", variable=tk.BooleanVar(), - command=self.toggle_show_path, bg=COLORS["bg"]).pack(side=tk.LEFT, padx=5) - - - # File table - table_frame = tk.Frame(file_frame) - table_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Define columns - columns = ("name", "date", "tags", "size") - self.file_table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended") - - # Column headers with sort commands - self.file_table.heading("name", text="📄 Název ▲", command=lambda: self.sort_by_column("name")) - self.file_table.heading("date", text="📅 Datum", command=lambda: self.sort_by_column("date")) - self.file_table.heading("tags", text="🏷️ Štítky") - self.file_table.heading("size", text="💾 Velikost", command=lambda: self.sort_by_column("size")) - - # Column widths - self.file_table.column("name", width=300) - self.file_table.column("date", width=100) - self.file_table.column("tags", width=200) - self.file_table.column("size", width=80) - - # Scrollbars - vsb = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.file_table.yview) - hsb = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.file_table.xview) - self.file_table.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) - - self.file_table.grid(row=0, column=0, sticky="nsew") - vsb.grid(row=0, column=1, sticky="ns") - hsb.grid(row=1, column=0, sticky="ew") - - table_frame.grid_rowconfigure(0, weight=1) - table_frame.grid_columnconfigure(0, weight=1) - - # Bind events - self.file_table.bind("", self.on_file_double_click) - self.file_table.bind("", self.on_file_right_click) - self.file_table.bind("<>", self.on_selection_changed) - - parent.add(file_frame) - - def _create_status_bar(self): - """Create status bar at bottom""" - status_frame = tk.Frame(self.root, bg=COLORS["status_bg"], relief=tk.SUNKEN, bd=1) - status_frame.pack(side=tk.BOTTOM, fill=tk.X) - - # Left side - status message - self.status_label = tk.Label(status_frame, text="Připraven", anchor=tk.W, - bg=COLORS["status_bg"], padx=10) - self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) - - # Right side - file count - self.file_count_label = tk.Label(status_frame, text="0 souborů", anchor=tk.E, - bg=COLORS["status_bg"], padx=10) - self.file_count_label.pack(side=tk.RIGHT) - - # Selected size - self.selected_size_label = tk.Label(status_frame, text="", anchor=tk.E, - bg=COLORS["status_bg"], padx=10) - self.selected_size_label.pack(side=tk.RIGHT) - - # Selected count - self.selected_count_label = tk.Label(status_frame, text="", anchor=tk.E, - bg=COLORS["status_bg"], padx=10) - self.selected_count_label.pack(side=tk.RIGHT) - - def _create_context_menus(self): - """Create context menus""" - # Tag context menu - self.tag_menu = tk.Menu(self.root, tearoff=0) - self.tag_menu.add_command(label="Nový štítek", command=self.tree_add_tag) - self.tag_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag) - - # File context menu - self.file_menu = tk.Menu(self.root, tearoff=0) - self.file_menu.add_command(label="Otevřít soubor", command=self.open_selected_files) - self.file_menu.add_command(label="Přiřadit štítky (Ctrl+T)", command=self.assign_tag_to_selected_bulk) - self.file_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) - self.file_menu.add_separator() - self.file_menu.add_command(label="Smazat z indexu (Del)", command=self.remove_selected_files) - - def _bind_shortcuts(self): - """Bind keyboard shortcuts""" - self.root.bind("", lambda e: self.open_folder_dialog()) - self.root.bind("", lambda e: self.root.quit()) - self.root.bind("", lambda e: self.assign_tag_to_selected_bulk()) - self.root.bind("", lambda e: self.set_date_for_selected()) - self.root.bind("", lambda e: self.search_var.get()) # Focus search - self.root.bind("", lambda e: self.refresh_all()) - self.root.bind("", lambda e: self.remove_selected_files()) - - # ================================================== - # SIDEBAR / TAG TREE METHODS - # ================================================== - - def refresh_sidebar(self): - """Refresh tag tree in sidebar""" - # Clear tree - for item in self.tag_tree.get_children(): - self.tag_tree.delete(item) - - # Reset tag item mapping - self.tag_tree_items = {} # full_path -> tree item_id - - # Count files per tag (from all files) - tag_counts = {} - for f in self.filehandler.filelist: - for t in f.tags: - tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 - - # Add root - total_files = len(self.filehandler.filelist) - root_id = self.tag_tree.insert("", "end", text=f"📂 Všechny soubory ({total_files})", image=self.icons.get("tag")) - self.tag_tree.item(root_id, open=True) - self.root_tag_id = root_id - - # Assign colors to categories - categories = self.tagmanager.get_categories() - color_index = 0 - for category in categories: - if category not in self.category_colors: - # Use predefined color for default categories, otherwise cycle through TAG_COLORS - if category in DEFAULT_CATEGORY_COLORS: - self.category_colors[category] = DEFAULT_CATEGORY_COLORS[category] - else: - self.category_colors[category] = TAG_COLORS[color_index % len(TAG_COLORS)] - color_index += 1 - - # Add categories and tags - for category in categories: - color = self.category_colors.get(category, "#333333") - cat_id = self.tag_tree.insert(root_id, "end", text=f"📁 {category}", image=self.icons.get("tag"), - tags=(f"cat_{category}",)) - self.states[cat_id] = False - - for tag in self.tagmanager.get_tags_in_category(category): - count = tag_counts.get(tag.full_path, 0) - count_str = f" ({count})" if count > 0 else "" - tag_id = self.tag_tree.insert(cat_id, "end", text=f" {tag.name}{count_str}", - image=self.icons.get("unchecked"), - tags=(f"tag_{category}",)) - self.states[tag_id] = False - self.tag_tree_items[tag.full_path] = (tag_id, tag.name) - - # Apply color to category tags - self.tag_tree.tag_configure(f"cat_{category}", foreground=color) - self.tag_tree.tag_configure(f"tag_{category}", foreground=color) - - def update_tag_counts(self, filtered_files): - """Update tag counts in sidebar based on filtered files""" - if not hasattr(self, 'tag_tree_items'): - return - - # Count files per tag from filtered files - tag_counts = {} - for f in filtered_files: - for t in f.tags: - tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 - - # Update each tag item text - for full_path, (item_id, tag_name) in self.tag_tree_items.items(): - count = tag_counts.get(full_path, 0) - count_str = f" ({count})" if count > 0 else "" - # Preserve the checkbox state - current_text = f" {tag_name}{count_str}" - self.tag_tree.item(item_id, text=current_text) - - # Update root count - total = len(filtered_files) - self.tag_tree.item(self.root_tag_id, text=f"📂 Všechny soubory ({total})") - - def on_tree_left_click(self, event): - """Handle left click on tag tree""" - region = self.tag_tree.identify("region", event.x, event.y) - if region not in ("tree", "icon"): - return - - item_id = self.tag_tree.identify_row(event.y) - if not item_id: - return - - parent_id = self.tag_tree.parent(item_id) - - # Toggle folder open/close - if parent_id == "" or parent_id == self.root_tag_id: - is_open = self.tag_tree.item(item_id, "open") - self.tag_tree.item(item_id, open=not is_open) - return - - # Toggle tag checkbox - self.states[item_id] = not self.states.get(item_id, False) - self.tag_tree.item(item_id, image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]) - - # Update file list - self.update_files_from_manager(self.filehandler.filelist) - - def on_tree_right_click(self, event): - """Handle right click on tag tree""" - item_id = self.tag_tree.identify_row(event.y) - if item_id: - self.selected_tree_item_for_context = item_id - self.tag_tree.selection_set(item_id) - self.tag_menu.tk_popup(event.x_root, event.y_root) - - def tree_add_tag(self, background=False): - """Add new tag""" - name = simpledialog.askstring("Nový tag", "Název tagu:") - if not name: - return - - parent = self.selected_tree_item_for_context if not background else self.root_tag_id - new_id = self.tag_tree.insert(parent, "end", text=f" {name}", image=self.icons["unchecked"]) - self.states[new_id] = False - - if parent == self.root_tag_id: - self.tagmanager.add_category(name) - self.tag_tree.item(new_id, image=self.icons["tag"]) - else: - category = self.tag_tree.item(parent, "text").replace("📁 ", "") - self.tagmanager.add_tag(category, name) - - self.status_label.config(text=f"Vytvořen tag: {name}") - - def tree_delete_tag(self): - """Delete selected tag""" - item = self.selected_tree_item_for_context - if not item: - return - - name = self.tag_tree.item(item, "text").strip() - ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{name}'?") - if not ans: - return - - parent_id = self.tag_tree.parent(item) - self.tag_tree.delete(item) - self.states.pop(item, None) - - if parent_id == self.root_tag_id: - self.tagmanager.remove_category(name.replace("📁 ", "")) - else: - category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") - self.tagmanager.remove_tag(category, name) - - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text=f"Smazán tag: {name}") - - def get_checked_tags(self) -> List[Tag]: - """Get list of checked tags""" - tags = [] - for item_id, checked in self.states.items(): - if not checked: - continue - parent_id = self.tag_tree.parent(item_id) - if parent_id == "" or parent_id == self.root_tag_id: - continue - category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") - # Get tag name from stored mapping (not from text which includes count) - tag_name = None - for full_path, (stored_id, stored_name) in self.tag_tree_items.items(): - if stored_id == item_id: - tag_name = stored_name - break - if tag_name: - tags.append(Tag(category, tag_name)) - return tags - - # ================================================== - # FILE TABLE METHODS - # ================================================== - - def update_files_from_manager(self, filelist=None): - """Update file table""" - if filelist is None: - filelist = self.filehandler.filelist - - # Filter by checked tags - checked_tags = self.get_checked_tags() - filtered_files = self.filehandler.filter_files_by_tags(checked_tags) - - # Filter by search text - search_text = self.search_var.get().lower() if hasattr(self, 'search_var') else "" - if search_text: - filtered_files = [ - f for f in filtered_files - if search_text in f.filename.lower() or - (self.show_full_path and search_text in str(f.file_path).lower()) - ] - - # Filter ignored - if self.hide_ignored_var and self.hide_ignored_var.get(): - filtered_files = [ - f for f in filtered_files - if "Stav/Ignorované" not in {t.full_path for t in f.tags} - ] - - # Sort - reverse = (self.sort_order == "desc") - if self.sort_mode == "name": - filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse) - elif self.sort_mode == "date": - filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse) - elif self.sort_mode == "size": - filtered_files.sort(key=lambda f: f.file_path.stat().st_size if f.file_path.exists() else 0, reverse=reverse) - - # Clear table - for item in self.file_table.get_children(): - self.file_table.delete(item) - self.file_items.clear() - - # Populate table - for f in filtered_files: - name = str(f.file_path) if self.show_full_path else f.filename - date = f.date or "" - tags = ", ".join([t.name for t in f.tags[:3]]) # Show first 3 tags - if len(f.tags) > 3: - tags += f" +{len(f.tags) - 3}" - - try: - size = f.file_path.stat().st_size - size_str = self._format_size(size) - except: - size_str = "?" - - item_id = self.file_table.insert("", "end", values=(name, date, tags, size_str)) - self.file_items[item_id] = f - - # Update status - self.file_count_label.config(text=f"{len(filtered_files)} souborů") - self.status_label.config(text=f"Zobrazeno {len(filtered_files)} souborů") - - # Update tag counts in sidebar - self.update_tag_counts(filtered_files) - - def _format_size(self, size_bytes): - """Format file size""" - for unit in ['B', 'KB', 'MB', 'GB']: - if size_bytes < 1024.0: - return f"{size_bytes:.1f} {unit}" - size_bytes /= 1024.0 - return f"{size_bytes:.1f} TB" - - def get_selected_files(self) -> List[File]: - """Get selected files from table""" - selected_items = self.file_table.selection() - return [self.file_items[item] for item in selected_items if item in self.file_items] - - def on_selection_changed(self, event=None): - """Update status bar when selection changes""" - files = self.get_selected_files() - count = len(files) - - if count == 0: - self.selected_count_label.config(text="") - self.selected_size_label.config(text="") - else: - self.selected_count_label.config(text=f"{count} vybráno") - total_size = 0 - for f in files: - try: - total_size += f.file_path.stat().st_size - except: - pass - self.selected_size_label.config(text=f"[{self._format_size(total_size)}]") - - def on_file_double_click(self, event): - """Handle double click on file""" - files = self.get_selected_files() - for f in files: - self.open_file(f.file_path) - - def on_file_right_click(self, event): - """Handle right click on file""" - # Select item under cursor if not selected - item = self.file_table.identify_row(event.y) - if item and item not in self.file_table.selection(): - self.file_table.selection_set(item) - - # Update selected count - count = len(self.file_table.selection()) - self.selected_count_label.config(text=f"{count} vybráno" if count > 0 else "") - - self.file_menu.tk_popup(event.x_root, event.y_root) - - def open_file(self, path): - """Open file with default application""" - try: - if sys.platform.startswith("win"): - os.startfile(path) - elif sys.platform.startswith("darwin"): - subprocess.call(["open", path]) - else: - subprocess.call(["xdg-open", path]) - self.status_label.config(text=f"Otevírám: {path.name}") - except Exception as e: - messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}") - - # ================================================== - # ACTIONS - # ================================================== - - def open_folder_dialog(self): - """Open folder selection dialog""" - folder = filedialog.askdirectory(title="Vyber složku pro sledování") - if not folder: - return - - folder_path = Path(folder) - try: - self.filehandler.append(folder_path) - for f in self.filehandler.filelist: - if f.tags and f.tagmanager: - for t in f.tags: - f.tagmanager.add_tag(t.category, t.name) - - self.status_label.config(text=f"Přidána složka: {folder_path}") - self.refresh_sidebar() - self.update_files_from_manager(self.filehandler.filelist) - except Exception as e: - messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}") - - def open_selected_files(self): - """Open selected files""" - files = self.get_selected_files() - for f in files: - self.open_file(f.file_path) - - def remove_selected_files(self): - """Remove selected files from index""" - files = self.get_selected_files() - if not files: - return - - ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?") - if ans: - for f in files: - if f in self.filehandler.filelist: - self.filehandler.filelist.remove(f) - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text=f"Odstraněno {len(files)} souborů z indexu") - - def assign_tag_to_selected_bulk(self): - """Assign tags to selected files (bulk mode)""" - files = self.get_selected_files() - if not files: - self.status_label.config(text="Nebyly vybrány žádné soubory") - return - - all_tags = [] - for category in self.tagmanager.get_categories(): - for tag in self.tagmanager.get_tags_in_category(category): - all_tags.append(tag) - - if not all_tags: - messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") - return - - dialog = MultiFileTagAssignDialog(self.root, all_tags, files, self.category_colors) - result = dialog.result - - if result is None: - self.status_label.config(text="Přiřazení zrušeno") - return - - for full_path, state in result.items(): - if state == 1: - if "/" in full_path: - category, name = full_path.split("/", 1) - tag_obj = self.tagmanager.add_tag(category, name) - self.filehandler.assign_tag_to_file_objects(files, tag_obj) - elif state == 0: - if "/" in full_path: - category, name = full_path.split("/", 1) - tag_obj = Tag(category, name) - self.filehandler.remove_tag_from_file_objects(files, tag_obj) - - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text="Hromadné přiřazení tagů dokončeno") - - def set_date_for_selected(self): - """Set date for selected files""" - files = self.get_selected_files() - if not files: - self.status_label.config(text="Nebyly vybrány žádné soubory") - return - - prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):" - date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root) - if date_str is None: - return - - for f in files: - f.set_date(date_str if date_str != "" else None) - - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)") - - def detect_video_resolution(self): - """Detect video resolution using ffprobe""" - files = self.get_selected_files() - if not files: - self.status_label.config(text="Nebyly vybrány žádné soubory") - return - - count = 0 - for f in files: - try: - path = str(f.file_path) - result = subprocess.run( - ["ffprobe", "-v", "error", "-select_streams", "v:0", - "-show_entries", "stream=height", "-of", "csv=p=0", path], - capture_output=True, - text=True, - check=True - ) - height_str = result.stdout.strip() - if not height_str.isdigit(): - continue - height = int(height_str) - tag_name = f"{height}p" - tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name) - f.add_tag(tag_obj) - count += 1 - except Exception as e: - print(f"Chyba u {f.filename}: {e}") - - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text=f"Přiřazeno rozlišení tagů k {count} souborům") - - def set_ignore_patterns(self): - """Set ignore patterns for current folder""" - current = ", ".join(self.filehandler.get_ignore_patterns()) - s = simpledialog.askstring("Ignore patterns", - "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", - initialvalue=current) - if s is None: - return - - patterns = [p.strip() for p in s.split(",") if p.strip()] - self.filehandler.set_ignore_patterns(patterns) - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text="Ignore patterns aktualizovány") - - def toggle_hide_ignored(self): - """Toggle hiding ignored files""" - self.update_files_from_manager(self.filehandler.filelist) - - def toggle_show_path(self): - """Toggle showing full path""" - self.show_full_path = not self.show_full_path - self.update_files_from_manager(self.filehandler.filelist) - - def sort_by_column(self, column: str): - """Sort by column header click""" - if self.sort_mode == column: - self.sort_order = "desc" if self.sort_order == "asc" else "asc" - else: - self.sort_mode = column - self.sort_order = "asc" - - self._update_sort_indicators() - self.update_files_from_manager(self.filehandler.filelist) - - def _update_sort_indicators(self): - """Update column header sort indicators""" - arrow = "▲" if self.sort_order == "asc" else "▼" - - headers = { - "name": "📄 Název", - "date": "📅 Datum", - "size": "💾 Velikost" - } - - for col, base_text in headers.items(): - if col == self.sort_mode: - self.file_table.heading(col, text=f"{base_text} {arrow}") - else: - self.file_table.heading(col, text=base_text) - - def on_filter_changed(self): - """Handle search/filter change""" - self.update_files_from_manager(self.filehandler.filelist) - - def refresh_all(self): - """Refresh everything""" - self.refresh_sidebar() - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text="Obnoveno") - - def configure_hardlink_folder(self): - """Configure hardlink output folder for current project""" - if not self.filehandler.current_folder: - messagebox.showwarning("Upozornění", "Nejprve otevřete složku") - return - - # Get current settings - folder_config = self.filehandler.get_folder_config() - current_dir = folder_config.get("hardlink_output_dir") - current_categories = folder_config.get("hardlink_categories") - - # Ask for output directory - initial_dir = current_dir if current_dir else str(self.filehandler.current_folder) - output_dir = filedialog.askdirectory( - title="Vyber cílovou složku pro hardlink strukturu", - initialdir=initial_dir, - mustexist=False - ) - if not output_dir: - return - - # Get available categories - categories = self.tagmanager.get_categories() - if not categories: - messagebox.showwarning("Upozornění", "Žádné kategorie tagů") - return - - # Show category selection dialog - selected_categories = self._show_category_selection_dialog( - categories, - preselected=current_categories - ) - if selected_categories is None: - return # Cancelled - - # Save to folder config - folder_config["hardlink_output_dir"] = output_dir - folder_config["hardlink_categories"] = selected_categories if selected_categories else None - self.filehandler.save_folder_config(config=folder_config) - - messagebox.showinfo("Hotovo", f"Hardlink složka nastavena:\n{output_dir}") - self.status_label.config(text=f"Hardlink složka nastavena: {output_dir}") - - def update_hardlink_structure(self): - """Quick update hardlink structure using saved settings""" - if not self.filehandler.current_folder: - messagebox.showwarning("Upozornění", "Nejprve otevřete složku") - return - - # Get saved settings - folder_config = self.filehandler.get_folder_config() - output_dir = folder_config.get("hardlink_output_dir") - saved_categories = folder_config.get("hardlink_categories") - - if not output_dir: - messagebox.showinfo("Info", "Hardlink složka není nastavena.\nPoužijte 'Nastavit hardlink složku...' pro konfiguraci.") - return - - output_path = Path(output_dir) - files = self.filehandler.filelist - - if not files: - messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") - return - - # Create manager and analyze - manager = HardlinkManager(output_path) - - # Find what needs to be created and removed - preview_create = manager.get_preview(files, saved_categories) - obsolete = manager.find_obsolete_links(files, saved_categories) - - # Filter out already existing links from preview - to_create = [] - for source, target in preview_create: - if not target.exists(): - to_create.append((source, target)) - elif not manager._is_same_file(source, target): - to_create.append((source, target)) - - if not to_create and not obsolete: - messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") - return - - # Build confirmation message - confirm_lines = [] - if to_create: - confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") - if obsolete: - confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") - confirm_lines.append(f"\nCílová složka: {output_path}") - confirm_lines.append("\nPokračovat?") - - if not messagebox.askyesno("Potvrdit aktualizaci", "\n".join(confirm_lines)): - return - - # Perform sync - self.status_label.config(text="Aktualizuji hardlink strukturu...") - self.root.update() - - created, create_fail, removed, remove_fail = manager.sync_structure(files, saved_categories) - - # Show result - result_lines = [] - if created > 0: - result_lines.append(f"Vytvořeno: {created} hardlinků") - if removed > 0: - result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") - - if create_fail > 0 or remove_fail > 0: - if create_fail > 0: - result_lines.append(f"Selhalo vytvoření: {create_fail}") - if remove_fail > 0: - result_lines.append(f"Selhalo odebrání: {remove_fail}") - messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) - else: - messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") - - self.status_label.config(text=f"Hardlink struktura aktualizována (vytvořeno: {created}, odebráno: {removed})") - - def create_hardlink_structure(self): - """Create hardlink directory structure based on file tags (manual selection)""" - files = self.filehandler.filelist - if not files: - messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") - return - - # Ask for output directory - output_dir = filedialog.askdirectory( - title="Vyber cílovou složku pro hardlink strukturu", - mustexist=False - ) - if not output_dir: - return - - output_path = Path(output_dir) - - # Get available categories - categories = self.tagmanager.get_categories() - if not categories: - messagebox.showwarning("Upozornění", "Žádné kategorie tagů") - return - - # Show category selection dialog - selected_categories = self._show_category_selection_dialog(categories) - if selected_categories is None: - return # Cancelled - - cat_filter = selected_categories if selected_categories else None - - # Create manager and analyze - manager = HardlinkManager(output_path) - - # Find what needs to be created and removed - preview_create = manager.get_preview(files, cat_filter) - obsolete = manager.find_obsolete_links(files, cat_filter) - - # Filter out already existing links from preview - to_create = [] - for source, target in preview_create: - if not target.exists(): - to_create.append((source, target)) - elif not manager._is_same_file(source, target): - to_create.append((source, target)) - - if not to_create and not obsolete: - messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") - return - - # Build confirmation message - confirm_lines = [] - if to_create: - confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") - if obsolete: - confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") - confirm_lines.append(f"\nCílová složka: {output_path}") - confirm_lines.append("\nPokračovat?") - - if not messagebox.askyesno("Potvrdit synchronizaci", "\n".join(confirm_lines)): - return - - # Perform sync - self.status_label.config(text="Synchronizuji hardlink strukturu...") - self.root.update() - - created, create_fail, removed, remove_fail = manager.sync_structure(files, cat_filter) - - # Show result - result_lines = [] - if created > 0 or create_fail > 0: - result_lines.append(f"Vytvořeno: {created} hardlinků") - if create_fail > 0: - result_lines.append(f"Selhalo vytvoření: {create_fail}") - if removed > 0 or remove_fail > 0: - result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") - if remove_fail > 0: - result_lines.append(f"Selhalo odebrání: {remove_fail}") - - if create_fail > 0 or remove_fail > 0: - if manager.errors: - result_lines.append("\nChyby:") - for path, err in manager.errors[:5]: - result_lines.append(f"- {path.name}: {err}") - if len(manager.errors) > 5: - result_lines.append(f"... a dalších {len(manager.errors) - 5} chyb") - messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) - else: - messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") - - self.status_label.config(text=f"Hardlink struktura synchronizována (vytvořeno: {created}, odebráno: {removed})") - - def _show_category_selection_dialog(self, categories: List[str], preselected: List[str] | None = None) -> List[str] | None: - """Show dialog to select which categories to include in hardlink structure - - Args: - categories: List of available category names - preselected: Optional list of categories to pre-check (None = all checked) - """ - dialog = tk.Toplevel(self.root) - dialog.title("Vybrat kategorie") - dialog.geometry("350x400") - dialog.transient(self.root) - dialog.grab_set() - - result = {"categories": None} - - tk.Label(dialog, text="Vyberte kategorie pro vytvoření struktury:", - font=("Arial", 10, "bold")).pack(pady=10) - - # Scrollable frame for checkboxes - frame = tk.Frame(dialog) - frame.pack(fill=tk.BOTH, expand=True, padx=10) - - canvas = tk.Canvas(frame) - scrollbar = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) - scrollable_frame = tk.Frame(canvas) - - scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - # Category checkboxes - category_vars = {} - for category in sorted(categories): - # If preselected is None, check all; otherwise check only those in preselected - initial_value = preselected is None or category in preselected - var = tk.BooleanVar(value=initial_value) - category_vars[category] = var - color = self.category_colors.get(category, "#333333") - cb = tk.Checkbutton(scrollable_frame, text=category, variable=var, - fg=color, font=("Arial", 10), anchor="w") - cb.pack(fill="x", pady=2) - - # Buttons - btn_frame = tk.Frame(dialog) - btn_frame.pack(pady=10) - - def on_ok(): - result["categories"] = [cat for cat, var in category_vars.items() if var.get()] - dialog.destroy() - - def on_cancel(): - result["categories"] = None - dialog.destroy() - - def select_all(): - for var in category_vars.values(): - var.set(True) - - def select_none(): - for var in category_vars.values(): - var.set(False) - - tk.Button(btn_frame, text="Všechny", command=select_all, width=8).pack(side=tk.LEFT, padx=2) - tk.Button(btn_frame, text="Žádné", command=select_none, width=8).pack(side=tk.LEFT, padx=2) - tk.Button(btn_frame, text="OK", command=on_ok, width=10).pack(side=tk.LEFT, padx=10) - tk.Button(btn_frame, text="Zrušit", command=on_cancel, width=10).pack(side=tk.LEFT, padx=2) - - self.root.wait_window(dialog) - return result["categories"] diff --git a/tests/test_config.py b/tests/test_config.py index 3c7d1b6..86096f7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,8 +2,6 @@ import pytest import json from src.core.config import ( load_global_config, save_global_config, DEFAULT_GLOBAL_CONFIG, - load_folder_config, save_folder_config, DEFAULT_FOLDER_CONFIG, - get_folder_config_path, folder_has_config, FOLDER_CONFIG_NAME, load_config, save_config # Legacy functions ) @@ -20,16 +18,23 @@ class TestGlobalConfig: return config_path def test_default_global_config_structure(self): - """Test struktury defaultní globální konfigurace""" + """Test struktury defaultní globální konfigurace (jen data aplikace)""" assert "window_geometry" in DEFAULT_GLOBAL_CONFIG assert "window_maximized" in DEFAULT_GLOBAL_CONFIG assert "last_folder" in DEFAULT_GLOBAL_CONFIG assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG assert "recent_folders" in DEFAULT_GLOBAL_CONFIG + assert "pool_dir" in DEFAULT_GLOBAL_CONFIG + assert "filmoteka_dir" in DEFAULT_GLOBAL_CONFIG assert DEFAULT_GLOBAL_CONFIG["window_geometry"] == "1200x800" assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None + def test_global_config_holds_no_library_settings(self): + """Library settings (tag_schema / copyasis_folders) žijí v indexu, ne tady.""" + assert "tag_schema" not in DEFAULT_GLOBAL_CONFIG + assert "copyasis_folders" not in DEFAULT_GLOBAL_CONFIG + def test_load_global_config_nonexistent_file(self, temp_global_config): """Test načtení globální konfigurace když soubor neexistuje""" config = load_global_config() @@ -62,8 +67,6 @@ class TestGlobalConfig: "recent_folders": [], "pool_dir": None, "filmoteka_dir": None, - "copyasis_folders": ["Seriály"], - "tag_schema": DEFAULT_GLOBAL_CONFIG["tag_schema"], } save_global_config(test_config) @@ -125,150 +128,6 @@ class TestGlobalConfig: assert len(loaded["recent_folders"]) == 3 -class TestFolderConfig: - """Testy pro složkový config""" - - def test_default_folder_config_structure(self): - """Test struktury defaultní složkové konfigurace""" - assert "ignore_patterns" in DEFAULT_FOLDER_CONFIG - assert "custom_tags" in DEFAULT_FOLDER_CONFIG - assert "recursive" in DEFAULT_FOLDER_CONFIG - assert isinstance(DEFAULT_FOLDER_CONFIG["ignore_patterns"], list) - assert isinstance(DEFAULT_FOLDER_CONFIG["custom_tags"], dict) - assert DEFAULT_FOLDER_CONFIG["recursive"] is True - - def test_get_folder_config_path(self, tmp_path): - """Test získání cesty ke složkovému configu""" - path = get_folder_config_path(tmp_path) - assert path == tmp_path / FOLDER_CONFIG_NAME - assert path.name == ".Curator.!ftag" - - def test_load_folder_config_nonexistent(self, tmp_path): - """Test načtení neexistujícího složkového configu""" - config = load_folder_config(tmp_path) - assert config == DEFAULT_FOLDER_CONFIG - - def test_save_folder_config(self, tmp_path): - """Test uložení složkového configu""" - test_config = { - "ignore_patterns": ["*.tmp", "*.log"], - "custom_tags": {"Projekt": ["Web", "API"]}, - "recursive": False, - } - - save_folder_config(tmp_path, test_config) - - config_path = get_folder_config_path(tmp_path) - assert config_path.exists() - - with open(config_path, "r", encoding="utf-8") as f: - saved_data = json.load(f) - assert saved_data == test_config - - def test_load_folder_config_existing(self, tmp_path): - """Test načtení existujícího složkového configu""" - test_config = { - "ignore_patterns": ["*.pyc"], - "custom_tags": {}, - "recursive": True, - "hardlink_output_dir": None, - "hardlink_categories": None, - } - - save_folder_config(tmp_path, test_config) - loaded = load_folder_config(tmp_path) - - assert loaded == test_config - - def test_load_folder_config_merges_defaults(self, tmp_path): - """Test že chybějící klíče jsou doplněny z defaultů""" - partial_config = {"ignore_patterns": ["*.tmp"]} - - config_path = get_folder_config_path(tmp_path) - with open(config_path, "w", encoding="utf-8") as f: - json.dump(partial_config, f) - - loaded = load_folder_config(tmp_path) - assert loaded["ignore_patterns"] == ["*.tmp"] - assert loaded["custom_tags"] == DEFAULT_FOLDER_CONFIG["custom_tags"] - assert loaded["recursive"] == DEFAULT_FOLDER_CONFIG["recursive"] - - def test_folder_has_config_true(self, tmp_path): - """Test folder_has_config když config existuje""" - save_folder_config(tmp_path, DEFAULT_FOLDER_CONFIG) - assert folder_has_config(tmp_path) is True - - def test_folder_has_config_false(self, tmp_path): - """Test folder_has_config když config neexistuje""" - assert folder_has_config(tmp_path) is False - - def test_folder_config_ignore_patterns(self, tmp_path): - """Test ukládání ignore patterns""" - patterns = ["*.tmp", "*.log", "*.cache", "*/node_modules/*", "*.pyc"] - test_config = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": patterns} - - save_folder_config(tmp_path, test_config) - loaded = load_folder_config(tmp_path) - - assert loaded["ignore_patterns"] == patterns - assert len(loaded["ignore_patterns"]) == 5 - - def test_folder_config_custom_tags(self, tmp_path): - """Test ukládání custom tagů""" - custom_tags = { - "Projekt": ["Frontend", "Backend", "API"], - "Stav": ["Hotovo", "Rozpracováno"], - } - test_config = {**DEFAULT_FOLDER_CONFIG, "custom_tags": custom_tags} - - save_folder_config(tmp_path, test_config) - loaded = load_folder_config(tmp_path) - - assert loaded["custom_tags"] == custom_tags - - def test_folder_config_corrupted_file(self, tmp_path): - """Test načtení poškozeného folder config souboru""" - config_path = get_folder_config_path(tmp_path) - with open(config_path, "w") as f: - f.write("{ invalid json }") - - config = load_folder_config(tmp_path) - assert config == DEFAULT_FOLDER_CONFIG - - def test_folder_config_utf8_encoding(self, tmp_path): - """Test UTF-8 v folder configu""" - test_config = { - "ignore_patterns": ["*.čeština"], - "custom_tags": {"Štítky": ["Červená", "Žlutá"]}, - "recursive": True, - } - - save_folder_config(tmp_path, test_config) - loaded = load_folder_config(tmp_path) - - assert loaded["ignore_patterns"] == ["*.čeština"] - assert loaded["custom_tags"]["Štítky"] == ["Červená", "Žlutá"] - - def test_multiple_folders_independent_configs(self, tmp_path): - """Test že různé složky mají nezávislé configy""" - folder1 = tmp_path / "folder1" - folder2 = tmp_path / "folder2" - folder1.mkdir() - folder2.mkdir() - - config1 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.txt"]} - config2 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.jpg"]} - - save_folder_config(folder1, config1) - save_folder_config(folder2, config2) - - loaded1 = load_folder_config(folder1) - loaded2 = load_folder_config(folder2) - - assert loaded1["ignore_patterns"] == ["*.txt"] - assert loaded2["ignore_patterns"] == ["*.jpg"] - - class TestLegacyFunctions: """Testy pro zpětnou kompatibilitu""" @@ -342,18 +201,6 @@ class TestConfigEdgeCases: assert len(loaded["recent_folders"]) == 100 - def test_folder_config_special_characters_in_patterns(self, tmp_path): - """Test se speciálními znaky v patterns""" - test_config = { - **DEFAULT_FOLDER_CONFIG, - "ignore_patterns": ["*.tmp", "file[0-9].txt", "test?.log"] - } - - save_folder_config(tmp_path, test_config) - loaded = load_folder_config(tmp_path) - - assert loaded["ignore_patterns"] == test_config["ignore_patterns"] - def test_config_json_formatting(self, temp_global_config): """Test že config je uložen ve správném JSON formátu s indentací""" test_config = {**DEFAULT_GLOBAL_CONFIG} @@ -391,24 +238,3 @@ class TestConfigEdgeCases: loaded = load_global_config() assert loaded["last_folder"] == "/path2" - - def test_folder_config_recursive_false(self, tmp_path): - """Test nastavení recursive na False""" - test_config = {**DEFAULT_FOLDER_CONFIG, "recursive": False} - - save_folder_config(tmp_path, test_config) - loaded = load_folder_config(tmp_path) - - assert loaded["recursive"] is False - - def test_empty_folder_config(self, tmp_path): - """Test prázdného folder configu""" - config_path = get_folder_config_path(tmp_path) - with open(config_path, "w", encoding="utf-8") as f: - json.dump({}, f) - - loaded = load_folder_config(tmp_path) - # Mělo by doplnit všechny defaulty - assert loaded["ignore_patterns"] == [] - assert loaded["custom_tags"] == {} - assert loaded["recursive"] is True diff --git a/tests/test_file.py b/tests/test_file.py index d95e9de..51c585d 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -1,191 +1,154 @@ import pytest -import json -from pathlib import Path from src.core.file import File +from src.core.pool_index import PoolIndex from src.core.tag import Tag from src.core.tag_manager import TagManager class TestFile: - """Testy pro třídu File""" - - @pytest.fixture - def temp_dir(self, tmp_path): - """Fixture pro dočasný adresář""" - return tmp_path + """Testy pro třídu File (metadata žijí v pool indexu)""" @pytest.fixture def tag_manager(self): - """Fixture pro TagManager""" return TagManager() @pytest.fixture - def test_file(self, temp_dir): - """Fixture pro testovací soubor""" - test_file = temp_dir / "test.txt" - test_file.write_text("test content") - return test_file + def index(self, tmp_path): + """Pool index that backs the File's metadata.""" + return PoolIndex(tmp_path) - def test_file_creation(self, test_file, tag_manager): - """Test vytvoření File objektu""" - file_obj = File(test_file, tag_manager) + @pytest.fixture + def test_file(self, tmp_path): + f = tmp_path / "test.txt" + f.write_text("test content") + return f + + def test_file_creation(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) assert file_obj.file_path == test_file assert file_obj.filename == "test.txt" - assert file_obj.new == True + assert file_obj.new is True - def test_file_metadata_filename(self, test_file, tag_manager): - """Test názvu metadata souboru""" - file_obj = File(test_file, tag_manager) - expected = test_file.parent / ".test.txt.!tag" - assert file_obj.metadata_filename == expected - - def test_file_initial_tags(self, test_file, tag_manager): - """Test že nový soubor nemá žádné automatické tagy (Stav/Nové odstraněn)""" - file_obj = File(test_file, tag_manager) + def test_file_initial_tags(self, test_file, tag_manager, index): + """Nový soubor nemá žádné automatické tagy (Stav/Nové odstraněn).""" + file_obj = File(test_file, tag_manager, index=index) assert file_obj.tags == [] - def test_file_metadata_saved(self, test_file, tag_manager): - """Test že metadata jsou uložena při vytvoření""" - file_obj = File(test_file, tag_manager) - assert file_obj.metadata_filename.exists() + def test_file_metadata_saved_to_index(self, test_file, tag_manager, index): + """Metadata jsou zapsána do indexu už při vytvoření.""" + file_obj = File(test_file, tag_manager, index=index) + assert index.get(file_obj.file_path) is not None - def test_file_save_metadata(self, test_file, tag_manager): - """Test uložení metadat""" + def test_file_no_sidecar_written(self, test_file, tag_manager, index): + """Žádný .!tag sidecar už nevzniká.""" + File(test_file, tag_manager, index=index) + assert not (test_file.parent / ".test.txt.!tag").exists() + + def test_index_less_file_is_in_memory_only(self, test_file, tag_manager): + """Bez indexu je File jen v paměti — nic se nezapíše na disk.""" file_obj = File(test_file, tag_manager) + file_obj.add_tag("Video/HD") + file_obj.save_metadata() # no-op + assert not (test_file.parent / ".test.txt.!tag").exists() + + def test_file_save_metadata(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.new = False file_obj.ignored = True file_obj.save_metadata() - # Načtení a kontrola - with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: - data = json.load(f) + data = index.get(file_obj.file_path) + assert data["new"] is False + assert data["ignored"] is True - assert data["new"] == False - assert data["ignored"] == True - - def test_file_load_metadata(self, test_file, tag_manager): - """Test načtení metadat""" - # Vytvoření a uložení metadat - file_obj = File(test_file, tag_manager) + def test_file_load_metadata(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) tag = tag_manager.add_tag("Video", "HD") file_obj.tags.append(tag) file_obj.date = "2025-01-15" file_obj.save_metadata() - # Vytvoření nového objektu - měl by načíst metadata - file_obj2 = File(test_file, tag_manager) + # A fresh File over the same index reloads the metadata + file_obj2 = File(test_file, tag_manager, index=index) assert len(file_obj2.tags) == 1 # Video/HD assert file_obj2.date == "2025-01-15" + assert "Video/HD" in {t.full_path for t in file_obj2.tags} - # Kontrola že tagy obsahují správné hodnoty - tag_paths = {tag.full_path for tag in file_obj2.tags} - assert "Video/HD" in tag_paths - - def test_file_set_date(self, test_file, tag_manager): - """Test nastavení data""" - file_obj = File(test_file, tag_manager) + def test_file_set_date(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.set_date("2025-12-25") assert file_obj.date == "2025-12-25" + assert index.get(file_obj.file_path)["date"] == "2025-12-25" - # Kontrola že bylo uloženo - with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: - data = json.load(f) - assert data["date"] == "2025-12-25" - - def test_file_set_date_to_none(self, test_file, tag_manager): - """Test smazání data""" - file_obj = File(test_file, tag_manager) + def test_file_set_date_to_none(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.set_date("2025-12-25") file_obj.set_date(None) assert file_obj.date is None - def test_file_set_date_empty_string(self, test_file, tag_manager): - """Test nastavení prázdného řetězce jako datum""" - file_obj = File(test_file, tag_manager) + def test_file_set_date_empty_string(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.set_date("2025-12-25") file_obj.set_date("") assert file_obj.date is None - def test_file_add_tag_object(self, test_file, tag_manager): - """Test přidání Tag objektu""" - file_obj = File(test_file, tag_manager) + def test_file_add_tag_object(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) tag = Tag("Video", "4K") file_obj.add_tag(tag) assert tag in file_obj.tags - assert len(file_obj.tags) == 1 # Video/4K + assert len(file_obj.tags) == 1 - def test_file_add_tag_string(self, test_file, tag_manager): - """Test přidání tagu jako string""" - file_obj = File(test_file, tag_manager) + def test_file_add_tag_string(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.add_tag("Audio/MP3") + assert "Audio/MP3" in {t.full_path for t in file_obj.tags} - tag_paths = {tag.full_path for tag in file_obj.tags} - assert "Audio/MP3" in tag_paths - - def test_file_add_tag_string_without_category(self, test_file, tag_manager): - """Test přidání tagu bez kategorie (použije 'default')""" - file_obj = File(test_file, tag_manager) + def test_file_add_tag_string_without_category(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.add_tag("SimpleTag") + assert "default/SimpleTag" in {t.full_path for t in file_obj.tags} - tag_paths = {tag.full_path for tag in file_obj.tags} - assert "default/SimpleTag" in tag_paths - - def test_file_add_duplicate_tag(self, test_file, tag_manager): - """Test že duplicitní tag není přidán""" - file_obj = File(test_file, tag_manager) + def test_file_add_duplicate_tag(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) tag = Tag("Video", "HD") file_obj.add_tag(tag) file_obj.add_tag(tag) + assert sum(1 for t in file_obj.tags if t == tag) == 1 - # Spočítáme kolikrát se tag vyskytuje - count = sum(1 for t in file_obj.tags if t == tag) - assert count == 1 - - def test_file_remove_tag_object(self, test_file, tag_manager): - """Test odstranění Tag objektu""" - file_obj = File(test_file, tag_manager) + def test_file_remove_tag_object(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) tag = Tag("Video", "HD") file_obj.add_tag(tag) file_obj.remove_tag(tag) - assert tag not in file_obj.tags - def test_file_remove_tag_string(self, test_file, tag_manager): - """Test odstranění tagu jako string""" - file_obj = File(test_file, tag_manager) + def test_file_remove_tag_string(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.add_tag("Video/HD") file_obj.remove_tag("Video/HD") + assert "Video/HD" not in {t.full_path for t in file_obj.tags} - tag_paths = {tag.full_path for tag in file_obj.tags} - assert "Video/HD" not in tag_paths - - def test_file_remove_tag_string_without_category(self, test_file, tag_manager): - """Test odstranění tagu bez kategorie""" - file_obj = File(test_file, tag_manager) + def test_file_remove_tag_string_without_category(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.add_tag("SimpleTag") file_obj.remove_tag("SimpleTag") + assert "default/SimpleTag" not in {t.full_path for t in file_obj.tags} - tag_paths = {tag.full_path for tag in file_obj.tags} - assert "default/SimpleTag" not in tag_paths - - def test_file_remove_nonexistent_tag(self, test_file, tag_manager): - """Test odstranění neexistujícího tagu (nemělo by vyhodit výjimku)""" - file_obj = File(test_file, tag_manager) + def test_file_remove_nonexistent_tag(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) initial_count = len(file_obj.tags) file_obj.remove_tag("Nonexistent/Tag") assert len(file_obj.tags) == initial_count def test_file_without_tagmanager(self, test_file): - """Test File bez TagManager""" file_obj = File(test_file, tagmanager=None) assert file_obj.tagmanager is None - assert len(file_obj.tags) == 0 # nový soubor nemá žádné automatické tagy + assert len(file_obj.tags) == 0 - def test_file_metadata_persistence(self, test_file, tag_manager): - """Test že metadata přežijí reload""" - # Vytvoření a úprava souboru - file_obj1 = File(test_file, tag_manager) + def test_file_metadata_persistence(self, test_file, tag_manager, index): + file_obj1 = File(test_file, tag_manager, index=index) file_obj1.add_tag("Video/HD") file_obj1.add_tag("Audio/Stereo") file_obj1.set_date("2025-01-01") @@ -193,73 +156,54 @@ class TestFile: file_obj1.ignored = True file_obj1.save_metadata() - # Načtení nového objektu - file_obj2 = File(test_file, tag_manager) - - # Kontrola - assert file_obj2.new == False - assert file_obj2.ignored == True + file_obj2 = File(test_file, tag_manager, index=index) + assert file_obj2.new is False + assert file_obj2.ignored is True assert file_obj2.date == "2025-01-01" - - tag_paths = {tag.full_path for tag in file_obj2.tags} + tag_paths = {t.full_path for t in file_obj2.tags} assert "Video/HD" in tag_paths assert "Audio/Stereo" in tag_paths - def test_file_metadata_json_format(self, test_file, tag_manager): - """Test formátu JSON metadat""" - file_obj = File(test_file, tag_manager) + def test_file_metadata_record_shape(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.add_tag("Test/Tag") file_obj.set_date("2025-06-15") - # Kontrola obsahu JSON - with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: - data = json.load(f) - + data = index.get(file_obj.file_path) assert "new" in data assert "ignored" in data assert "tags" in data assert "date" in data assert isinstance(data["tags"], list) - def test_file_unicode_handling(self, temp_dir, tag_manager): - """Test správného zacházení s unicode znaky""" - test_file = temp_dir / "český_soubor.txt" + def test_file_unicode_handling(self, tmp_path, tag_manager, index): + test_file = tmp_path / "český_soubor.txt" test_file.write_text("obsah") - file_obj = File(test_file, tag_manager) + file_obj = File(test_file, tag_manager, index=index) file_obj.add_tag("Kategorie/Český tag") file_obj.save_metadata() - # Reload a kontrola - file_obj2 = File(test_file, tag_manager) - tag_paths = {tag.full_path for tag in file_obj2.tags} - assert "Kategorie/Český tag" in tag_paths + file_obj2 = File(test_file, tag_manager, index=index) + assert "Kategorie/Český tag" in {t.full_path for t in file_obj2.tags} - def test_file_complex_scenario(self, test_file, tag_manager): - """Test komplexního scénáře použití""" - file_obj = File(test_file, tag_manager) - - # Přidání více tagů + def test_file_complex_scenario(self, test_file, tag_manager, index): + file_obj = File(test_file, tag_manager, index=index) file_obj.add_tag("Video/HD") file_obj.add_tag("Video/Stereo") file_obj.add_tag("Stav/Zkontrolováno") file_obj.set_date("2025-01-01") - - # Odstranění tagu file_obj.remove_tag("Stav/Nové") - # Kontrola stavu - tag_paths = {tag.full_path for tag in file_obj.tags} + tag_paths = {t.full_path for t in file_obj.tags} assert "Video/HD" in tag_paths assert "Video/Stereo" in tag_paths assert "Stav/Zkontrolováno" in tag_paths assert "Stav/Nové" not in tag_paths assert file_obj.date == "2025-01-01" - # Reload a kontrola persistence - file_obj2 = File(test_file, tag_manager) - tag_paths2 = {tag.full_path for tag in file_obj2.tags} - assert tag_paths == tag_paths2 + file_obj2 = File(test_file, tag_manager, index=index) + assert {t.full_path for t in file_obj2.tags} == tag_paths assert file_obj2.date == "2025-01-01" @@ -271,10 +215,14 @@ class TestApplyCsfdTags: return TagManager() @pytest.fixture - def movie_file(self, tmp_path, tag_manager): + def index(self, tmp_path): + return PoolIndex(tmp_path) + + @pytest.fixture + def movie_file(self, tmp_path, tag_manager, index): path = tmp_path / "Matrix.mkv" path.write_text("x") - f = File(path, tag_manager) + f = File(path, tag_manager, index=index) f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/") return f @@ -314,7 +262,6 @@ class TestApplyCsfdTags: paths = {t.full_path for t in movie_file.tags} assert not any(p.startswith("Režie/") for p in paths) assert not any(p.startswith("Herec/") for p in paths) - # …but the data is kept in the cache cached = movie_file.get_cached_movie() assert cached.directors == ["Lana Wachowski"] assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"] @@ -392,7 +339,6 @@ class TestApplyCsfdTags: first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"]) with patch("src.core.csfd.fetch_movie", return_value=first): movie_file.apply_csfd_tags() - # different movie on re-fetch second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"]) with patch("src.core.csfd.fetch_movie", return_value=second): movie_file.apply_csfd_tags() diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index 88853d7..db67769 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -1,294 +1,55 @@ import pytest +from src.core.file import File from src.core.file_manager import FileManager from src.core.tag_manager import TagManager from src.core.tag import Tag +@pytest.fixture +def temp_global_config(tmp_path, monkeypatch): + """Point the global config file at a throwaway path for each test.""" + config_path = tmp_path / "test_config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path) + return config_path + + +@pytest.fixture +def tag_manager(): + return TagManager() + + +@pytest.fixture +def file_manager(tag_manager, temp_global_config): + return FileManager(tag_manager) + + +def _in_memory_files(tmp_path, tag_manager, *names): + """Create real files and wrap them as index-less (in-memory) File objects.""" + files = [] + for name in names: + path = tmp_path / name + path.write_text("content") + files.append(File(path, tag_manager)) + return files + + class TestFileManager: - """Testy pro třídu FileManager""" - - @pytest.fixture - def tag_manager(self): - """Fixture pro TagManager""" - return TagManager() - - @pytest.fixture - def file_manager(self, tag_manager, temp_global_config): - """Fixture pro FileManager""" - return FileManager(tag_manager) - - @pytest.fixture - def temp_dir(self, tmp_path): - """Fixture pro dočasný adresář s testovacími soubory""" - # Vytvoření struktury souborů - (tmp_path / "file1.txt").write_text("content1") - (tmp_path / "file2.txt").write_text("content2") - (tmp_path / "file3.jpg").write_text("image") - - # Podsložka - subdir = tmp_path / "subdir" - subdir.mkdir() - (subdir / "file4.txt").write_text("content4") - - return tmp_path - - @pytest.fixture - def temp_global_config(self, tmp_path, monkeypatch): - """Fixture pro dočasný global config soubor""" - config_path = tmp_path / "test_config.json" - import src.core.config as config_module - monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) - return config_path + """Basic FileManager state.""" def test_file_manager_creation(self, file_manager, tag_manager): - """Test vytvoření FileManager""" assert file_manager.filelist == [] - assert file_manager.folders == [] assert file_manager.tagmanager == tag_manager assert file_manager.global_config is not None - assert file_manager.folder_configs == {} - assert file_manager.current_folder is None - - def test_file_manager_append_folder(self, file_manager, temp_dir): - """Test přidání složky""" - file_manager.append(temp_dir) - - assert temp_dir in file_manager.folders - assert len(file_manager.filelist) > 0 - assert file_manager.current_folder == temp_dir - - def test_file_manager_append_folder_finds_all_files(self, file_manager, temp_dir): - """Test že append najde všechny soubory včetně podsložek""" - file_manager.append(temp_dir) - - # Měli bychom najít file1.txt, file2.txt, file3.jpg, subdir/file4.txt - # (ne .!tag soubory) - filenames = {f.filename for f in file_manager.filelist} - assert "file1.txt" in filenames - assert "file2.txt" in filenames - assert "file3.jpg" in filenames - assert "file4.txt" in filenames - - def test_file_manager_ignores_tag_files(self, file_manager, temp_dir): - """Test že .!tag soubory jsou ignorovány""" - # Vytvoření .!tag souboru - (temp_dir / ".file1.txt.!tag").write_text('{"tags": []}') - - file_manager.append(temp_dir) - - filenames = {f.filename for f in file_manager.filelist} - assert ".file1.txt.!tag" not in filenames - - def test_file_manager_ignores_curator_config_files(self, file_manager, temp_dir): - """Test že Curator config soubory jsou ignorovány""" - (temp_dir / ".Curator.!ftag").write_text('{}') # Folder config - (temp_dir / ".Curator.!gtag").write_text('{}') # Global config - - file_manager.append(temp_dir) - - filenames = {f.filename for f in file_manager.filelist} - assert ".Curator.!ftag" not in filenames - assert ".Curator.!gtag" not in filenames - - - def test_file_manager_updates_last_folder(self, file_manager, temp_dir): - """Test aktualizace last_folder v global configu""" - file_manager.append(temp_dir) - - assert file_manager.global_config["last_folder"] == str(temp_dir) - - def test_file_manager_updates_recent_folders(self, file_manager, temp_dir): - """Test aktualizace recent_folders""" - file_manager.append(temp_dir) - - assert str(temp_dir) in file_manager.global_config["recent_folders"] - assert file_manager.global_config["recent_folders"][0] == str(temp_dir) - - def test_file_manager_recent_folders_max_10(self, file_manager, tmp_path): - """Test že recent_folders má max 10 položek""" - for i in range(15): - folder = tmp_path / f"folder{i}" - folder.mkdir() - (folder / "file.txt").write_text("content") - file_manager.append(folder) - - assert len(file_manager.global_config["recent_folders"]) <= 10 - - def test_file_manager_loads_folder_config(self, file_manager, temp_dir): - """Test že se načte folder config při append""" - file_manager.append(temp_dir) - - assert temp_dir in file_manager.folder_configs - assert "ignore_patterns" in file_manager.folder_configs[temp_dir] - - -class TestFileManagerIgnorePatterns: - """Testy pro ignore patterns""" - - @pytest.fixture - def tag_manager(self): - return TagManager() - - @pytest.fixture - def temp_global_config(self, tmp_path, monkeypatch): - config_path = tmp_path / "test_config.json" - import src.core.config as config_module - monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) - return config_path - - @pytest.fixture - def file_manager(self, tag_manager, temp_global_config): - return FileManager(tag_manager) - - @pytest.fixture - def temp_dir(self, tmp_path): - (tmp_path / "file1.txt").write_text("content1") - (tmp_path / "file2.txt").write_text("content2") - (tmp_path / "file3.jpg").write_text("image") - subdir = tmp_path / "subdir" - subdir.mkdir() - (subdir / "file4.txt").write_text("content4") - return tmp_path - - def test_ignore_patterns_by_extension(self, file_manager, temp_dir): - """Test ignorování souborů podle přípony""" - from src.core.config import save_folder_config - save_folder_config(temp_dir, {"ignore_patterns": ["*.jpg"], "custom_tags": {}, "recursive": True}) - - file_manager.append(temp_dir) - - filenames = {f.filename for f in file_manager.filelist} - assert "file3.jpg" not in filenames - assert "file1.txt" in filenames - - def test_ignore_patterns_path(self, file_manager, temp_dir): - """Test ignorování podle celé cesty""" - from src.core.config import save_folder_config - save_folder_config(temp_dir, {"ignore_patterns": ["*/subdir/*"], "custom_tags": {}, "recursive": True}) - - file_manager.append(temp_dir) - - filenames = {f.filename for f in file_manager.filelist} - assert "file4.txt" not in filenames - assert "file1.txt" in filenames - - def test_multiple_ignore_patterns(self, file_manager, temp_dir): - """Test více ignore patternů najednou""" - from src.core.config import save_folder_config - save_folder_config(temp_dir, {"ignore_patterns": ["*.jpg", "*/subdir/*"], "custom_tags": {}, "recursive": True}) - - file_manager.append(temp_dir) - - filenames = {f.filename for f in file_manager.filelist} - assert "file3.jpg" not in filenames - assert "file4.txt" not in filenames - assert "file1.txt" in filenames - assert "file2.txt" in filenames - - def test_set_ignore_patterns(self, file_manager, temp_dir): - """Test nastavení ignore patterns přes metodu""" - file_manager.append(temp_dir) - file_manager.set_ignore_patterns(["*.tmp", "*.log"]) - - patterns = file_manager.get_ignore_patterns() - assert patterns == ["*.tmp", "*.log"] - - def test_get_ignore_patterns_empty(self, file_manager, temp_dir): - """Test získání prázdných ignore patterns""" - file_manager.append(temp_dir) - - patterns = file_manager.get_ignore_patterns() - assert patterns == [] - - -class TestFileManagerFolderConfig: - """Testy pro folder config management""" - - @pytest.fixture - def tag_manager(self): - return TagManager() - - @pytest.fixture - def temp_global_config(self, tmp_path, monkeypatch): - config_path = tmp_path / "test_config.json" - import src.core.config as config_module - monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) - return config_path - - @pytest.fixture - def file_manager(self, tag_manager, temp_global_config): - return FileManager(tag_manager) - - @pytest.fixture - def temp_dir(self, tmp_path): - (tmp_path / "file1.txt").write_text("content") - return tmp_path - - def test_get_folder_config_current(self, file_manager, temp_dir): - """Test získání configu pro aktuální složku""" - file_manager.append(temp_dir) - - config = file_manager.get_folder_config() - assert "ignore_patterns" in config - - def test_get_folder_config_specific(self, file_manager, temp_dir, tmp_path): - """Test získání configu pro specifickou složku""" - folder2 = tmp_path / "folder2" - folder2.mkdir() - (folder2 / "file.txt").write_text("content") - - file_manager.append(temp_dir) - file_manager.append(folder2) - - config = file_manager.get_folder_config(temp_dir) - assert config is not None - - def test_get_folder_config_no_current(self, file_manager): - """Test získání configu když není current folder""" - config = file_manager.get_folder_config() - assert config == {} - - def test_save_folder_config(self, file_manager, temp_dir): - """Test uložení folder configu""" - file_manager.append(temp_dir) - - new_config = {"ignore_patterns": ["*.test"], "custom_tags": {}, "recursive": False} - file_manager.save_folder_config(config=new_config) - - loaded = file_manager.get_folder_config() - assert loaded["ignore_patterns"] == ["*.test"] - assert loaded["recursive"] is False + assert file_manager.index is None class TestFileManagerTagOperations: - """Testy pro operace s tagy""" + """assign/remove tag operations over the loaded file list.""" - @pytest.fixture - def tag_manager(self): - return TagManager() - - @pytest.fixture - def temp_global_config(self, tmp_path, monkeypatch): - config_path = tmp_path / "test_config.json" - import src.core.config as config_module - monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) - return config_path - - @pytest.fixture - def file_manager(self, tag_manager, temp_global_config): - return FileManager(tag_manager) - - @pytest.fixture - def temp_dir(self, tmp_path): - (tmp_path / "file1.txt").write_text("content1") - (tmp_path / "file2.txt").write_text("content2") - (tmp_path / "file3.txt").write_text("content3") - return tmp_path - - def test_assign_tag_to_file_objects_tag_object(self, file_manager, temp_dir): - """Test přiřazení Tag objektu k souborům""" - file_manager.append(temp_dir) - files = file_manager.filelist[:2] + def test_assign_tag_to_file_objects_tag_object(self, file_manager, tmp_path, tag_manager): + files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt") + file_manager.filelist = files tag = Tag("Video", "HD") file_manager.assign_tag_to_file_objects(files, tag) @@ -296,30 +57,27 @@ class TestFileManagerTagOperations: for f in files: assert tag in f.tags - def test_assign_tag_string_with_category(self, file_manager, temp_dir): - """Test přiřazení tagu jako string s kategorií""" - file_manager.append(temp_dir) - files = file_manager.filelist[:1] + def test_assign_tag_string_with_category(self, file_manager, tmp_path, tag_manager): + files = _in_memory_files(tmp_path, tag_manager, "f1.txt") + file_manager.filelist = files file_manager.assign_tag_to_file_objects(files, "Video/4K") tag_paths = {tag.full_path for tag in files[0].tags} assert "Video/4K" in tag_paths - def test_assign_tag_string_without_category(self, file_manager, temp_dir): - """Test přiřazení tagu bez kategorie (default)""" - file_manager.append(temp_dir) - files = file_manager.filelist[:1] + def test_assign_tag_string_without_category(self, file_manager, tmp_path, tag_manager): + files = _in_memory_files(tmp_path, tag_manager, "f1.txt") + file_manager.filelist = files file_manager.assign_tag_to_file_objects(files, "SimpleTag") tag_paths = {tag.full_path for tag in files[0].tags} assert "default/SimpleTag" in tag_paths - def test_assign_tag_no_duplicate(self, file_manager, temp_dir): - """Test že tag není přidán dvakrát""" - file_manager.append(temp_dir) - files = file_manager.filelist[:1] + def test_assign_tag_no_duplicate(self, file_manager, tmp_path, tag_manager): + files = _in_memory_files(tmp_path, tag_manager, "f1.txt") + file_manager.filelist = files tag = Tag("Video", "HD") file_manager.assign_tag_to_file_objects(files, tag) @@ -328,10 +86,9 @@ class TestFileManagerTagOperations: count = sum(1 for t in files[0].tags if t == tag) assert count == 1 - def test_remove_tag_from_file_objects(self, file_manager, temp_dir): - """Test odstranění tagu ze souborů""" - file_manager.append(temp_dir) - files = file_manager.filelist[:2] + def test_remove_tag_from_file_objects(self, file_manager, tmp_path, tag_manager): + files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt") + file_manager.filelist = files tag = Tag("Video", "HD") file_manager.assign_tag_to_file_objects(files, tag) @@ -340,10 +97,9 @@ class TestFileManagerTagOperations: for f in files: assert tag not in f.tags - def test_remove_tag_string(self, file_manager, temp_dir): - """Test odstranění tagu jako string""" - file_manager.append(temp_dir) - files = file_manager.filelist[:1] + def test_remove_tag_string(self, file_manager, tmp_path, tag_manager): + files = _in_memory_files(tmp_path, tag_manager, "f1.txt") + file_manager.filelist = files file_manager.assign_tag_to_file_objects(files, "Video/HD") file_manager.remove_tag_from_file_objects(files, "Video/HD") @@ -351,210 +107,73 @@ class TestFileManagerTagOperations: tag_paths = {tag.full_path for tag in files[0].tags} assert "Video/HD" not in tag_paths - def test_callback_on_tag_change(self, file_manager, temp_dir): - """Test callback při změně tagů""" - file_manager.append(temp_dir) + def test_callback_on_tag_change(self, file_manager, tmp_path, tag_manager): + files = _in_memory_files(tmp_path, tag_manager, "f1.txt") + file_manager.filelist = files callback_calls = [] + file_manager.on_files_changed = lambda filelist: callback_calls.append(len(filelist)) - def callback(filelist): - callback_calls.append(len(filelist)) - - file_manager.on_files_changed = callback - file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], Tag("Test", "Tag")) + file_manager.assign_tag_to_file_objects([files[0]], Tag("Test", "Tag")) assert len(callback_calls) == 1 class TestFileManagerFiltering: - """Testy pro filtrování souborů""" + """filter_files_by_tags over the loaded file list.""" @pytest.fixture - def tag_manager(self): - return TagManager() + def loaded(self, file_manager, tmp_path, tag_manager): + files = _in_memory_files(tmp_path, tag_manager, "f1.txt", "f2.txt", "f3.txt") + file_manager.filelist = files + return file_manager - @pytest.fixture - def temp_global_config(self, tmp_path, monkeypatch): - config_path = tmp_path / "test_config.json" - import src.core.config as config_module - monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) - return config_path + def test_filter_empty_tags_returns_all(self, loaded): + assert len(loaded.filter_files_by_tags([])) == len(loaded.filelist) - @pytest.fixture - def file_manager(self, tag_manager, temp_global_config): - return FileManager(tag_manager) + def test_filter_none_returns_all(self, loaded): + assert len(loaded.filter_files_by_tags(None)) == len(loaded.filelist) - @pytest.fixture - def temp_dir(self, tmp_path): - (tmp_path / "file1.txt").write_text("content1") - (tmp_path / "file2.txt").write_text("content2") - (tmp_path / "file3.txt").write_text("content3") - return tmp_path - - def test_filter_empty_tags_returns_all(self, file_manager, temp_dir): - """Test filtrace bez tagů vrací všechny soubory""" - file_manager.append(temp_dir) - - filtered = file_manager.filter_files_by_tags([]) - assert len(filtered) == len(file_manager.filelist) - - def test_filter_none_returns_all(self, file_manager, temp_dir): - """Test filtrace s None vrací všechny soubory""" - file_manager.append(temp_dir) - - filtered = file_manager.filter_files_by_tags(None) - assert len(filtered) == len(file_manager.filelist) - - def test_filter_by_single_tag(self, file_manager, temp_dir): - """Test filtrace podle jednoho tagu""" - file_manager.append(temp_dir) + def test_filter_by_single_tag(self, loaded): tag = Tag("Video", "HD") - files_to_tag = file_manager.filelist[:2] - file_manager.assign_tag_to_file_objects(files_to_tag, tag) + loaded.assign_tag_to_file_objects(loaded.filelist[:2], tag) - filtered = file_manager.filter_files_by_tags([tag]) + filtered = loaded.filter_files_by_tags([tag]) assert len(filtered) == 2 for f in filtered: assert tag in f.tags - def test_filter_by_multiple_tags_and_logic(self, file_manager, temp_dir): - """Test filtrace podle více tagů (AND logika)""" - file_manager.append(temp_dir) + def test_filter_by_multiple_tags_and_logic(self, loaded): tag1 = Tag("Video", "HD") tag2 = Tag("Audio", "Stereo") + loaded.assign_tag_to_file_objects([loaded.filelist[0]], tag1) + loaded.assign_tag_to_file_objects([loaded.filelist[0]], tag2) + loaded.assign_tag_to_file_objects([loaded.filelist[1]], tag1) - # První soubor má oba tagy - file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag1) - file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag2) - - # Druhý soubor má jen první tag - file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], tag1) - - filtered = file_manager.filter_files_by_tags([tag1, tag2]) + filtered = loaded.filter_files_by_tags([tag1, tag2]) assert len(filtered) == 1 - assert filtered[0] == file_manager.filelist[0] + assert filtered[0] == loaded.filelist[0] - def test_filter_by_tag_strings(self, file_manager, temp_dir): - """Test filtrace podle tagů jako stringy""" - file_manager.append(temp_dir) - file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD") + def test_filter_by_tag_strings(self, loaded): + loaded.assign_tag_to_file_objects([loaded.filelist[0]], "Video/HD") + assert len(loaded.filter_files_by_tags(["Video/HD"])) == 1 - filtered = file_manager.filter_files_by_tags(["Video/HD"]) - assert len(filtered) == 1 - - def test_filter_no_match(self, file_manager, temp_dir): - """Test filtrace když nic neodpovídá""" - file_manager.append(temp_dir) - - filtered = file_manager.filter_files_by_tags([Tag("NonExistent", "Tag")]) - assert len(filtered) == 0 - - -class TestFileManagerLegacy: - """Testy pro zpětnou kompatibilitu""" - - @pytest.fixture - def tag_manager(self): - return TagManager() - - @pytest.fixture - def temp_global_config(self, tmp_path, monkeypatch): - config_path = tmp_path / "test_config.json" - import src.core.config as config_module - monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) - return config_path - - @pytest.fixture - def file_manager(self, tag_manager, temp_global_config): - return FileManager(tag_manager) - - def test_config_property_returns_global(self, file_manager): - """Test že property config vrací global_config""" - assert file_manager.config is file_manager.global_config - - def test_config_property_modifiable(self, file_manager): - """Test že změny přes config property se projeví""" - file_manager.config["test_key"] = "test_value" - assert file_manager.global_config["test_key"] == "test_value" + def test_filter_no_match(self, loaded): + assert len(loaded.filter_files_by_tags([Tag("NonExistent", "Tag")])) == 0 class TestFileManagerEdgeCases: - """Testy pro edge cases""" - - @pytest.fixture - def tag_manager(self): - return TagManager() - - @pytest.fixture - def temp_global_config(self, tmp_path, monkeypatch): - config_path = tmp_path / "test_config.json" - import src.core.config as config_module - monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) - return config_path - - @pytest.fixture - def file_manager(self, tag_manager, temp_global_config): - return FileManager(tag_manager) - def test_empty_filelist_operations(self, file_manager): - """Test operací s prázdným filelistem""" - filtered = file_manager.filter_files_by_tags([Tag("Video", "HD")]) - assert filtered == [] - - # Přiřazení tagů na prázdný seznam + assert file_manager.filter_files_by_tags([Tag("Video", "HD")]) == [] file_manager.assign_tag_to_file_objects([], Tag("Video", "HD")) assert len(file_manager.filelist) == 0 def test_assign_tag_to_empty_list(self, file_manager): - """Test přiřazení tagu prázdnému seznamu souborů""" - file_manager.assign_tag_to_file_objects([], Tag("Test", "Tag")) - # Nemělo by vyhodit výjimku + file_manager.assign_tag_to_file_objects([], Tag("Test", "Tag")) # no exception - def test_remove_nonexistent_tag(self, file_manager, tmp_path): - """Test odstranění neexistujícího tagu""" - (tmp_path / "file.txt").write_text("content") - file_manager.append(tmp_path) - - # Nemělo by vyhodit výjimku - file_manager.remove_tag_from_file_objects(file_manager.filelist, Tag("NonExistent", "Tag")) - - def test_multiple_folders(self, file_manager, tmp_path): - """Test práce s více složkami""" - folder1 = tmp_path / "folder1" - folder2 = tmp_path / "folder2" - folder1.mkdir() - folder2.mkdir() - (folder1 / "file1.txt").write_text("content1") - (folder2 / "file2.txt").write_text("content2") - - file_manager.append(folder1) - file_manager.append(folder2) - - assert len(file_manager.folders) == 2 - filenames = {f.filename for f in file_manager.filelist} - assert "file1.txt" in filenames - assert "file2.txt" in filenames - - def test_folder_with_special_characters(self, file_manager, tmp_path): - """Test složky se speciálními znaky v názvu""" - special_folder = tmp_path / "složka s českou diakritikou" - special_folder.mkdir() - (special_folder / "soubor.txt").write_text("obsah") - - file_manager.append(special_folder) - - filenames = {f.filename for f in file_manager.filelist} - assert "soubor.txt" in filenames - - def test_file_with_special_characters(self, file_manager, tmp_path): - """Test souboru se speciálními znaky v názvu""" - (tmp_path / "soubor s mezerami.txt").write_text("content") - (tmp_path / "čeština.txt").write_text("obsah") - - file_manager.append(tmp_path) - - filenames = {f.filename for f in file_manager.filelist} - assert "soubor s mezerami.txt" in filenames - assert "čeština.txt" in filenames + def test_remove_nonexistent_tag(self, file_manager, tmp_path, tag_manager): + file_manager.filelist = _in_memory_files(tmp_path, tag_manager, "file.txt") + file_manager.remove_tag_from_file_objects( + file_manager.filelist, Tag("NonExistent", "Tag")) # no exception class TestPoolManagement: @@ -669,7 +288,8 @@ class TestPoolManagement: assert movie.file_path.exists() assert not source.exists() # moved, not copied - def test_filmoteka_category_roots_from_schema(self, file_manager): + def test_filmoteka_category_roots_from_schema(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_tag_schema([ {"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""}, {"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"}, @@ -802,8 +422,33 @@ class TestPoolManagement: assert len(reloaded.filelist) == 1 assert reloaded.filelist[0].title == "Matrix" - def test_copyasis_folders_default_and_set(self, file_manager): + def test_copyasis_folders_default_and_set(self, file_manager, tmp_path): assert file_manager.copyasis_folders == ["Seriály"] + file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_copyasis_folders(["Seriály", " Dokumenty ", ""]) assert file_manager.copyasis_folders == ["Seriály", "Dokumenty"] + + def test_library_settings_stored_in_index_not_global(self, file_manager, tmp_path): + """tag_schema / copyasis_folders persist in the pool index, not .!gtag.""" + file_manager.set_pool_dir(tmp_path / "pool") + file_manager.set_copyasis_folders(["Seriály", "Dokumenty"]) + + assert "copyasis_folders" not in file_manager.global_config + assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Dokumenty"] + + # a fresh manager over the same pool reads the stored settings back + reloaded = FileManager(TagManager()) + reloaded.set_pool_dir(tmp_path / "pool") + assert reloaded.copyasis_folders == ["Seriály", "Dokumenty"] + + def test_migrates_legacy_global_settings_into_index(self, file_manager, tmp_path): + """Old settings left in the global config move into the index on open.""" + file_manager.set_pool_dir(tmp_path / "pool") + # simulate a pre-migration global config carrying library settings + file_manager.global_config["copyasis_folders"] = ["Seriály", "Filmy jinak"] + file_manager.index = None # force a fresh open + migration + + assert file_manager.copyasis_folders == ["Seriály", "Filmy jinak"] + assert "copyasis_folders" not in file_manager.global_config + assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Filmy jinak"] diff --git a/tests/test_pool_index.py b/tests/test_pool_index.py index 6ac306e..9b5a927 100644 --- a/tests/test_pool_index.py +++ b/tests/test_pool_index.py @@ -61,7 +61,7 @@ class TestFileWithIndex: f = File(movie, TagManager(), index=index) - assert not f.metadata_filename.exists() # no sidecar + assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar assert index.get(movie) is not None # record created in index assert f.tags == [] # no automatic tags