Hardlink generation added
This commit is contained in:
66
CHANGELOG.md
Normal file
66
CHANGELOG.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
Všechny významné změny v projektu Tagger jsou dokumentovány v tomto souboru.
|
||||
|
||||
## [0.3.0] - 2024-12-28
|
||||
|
||||
### Přidáno
|
||||
- **Hardlink struktura** - Nová funkcionalita pro vytváření adresářové struktury pomocí hardlinků
|
||||
- `HardlinkManager` třída v `src/core/hardlink_manager.py`
|
||||
- Vytváření hardlinků podle tagů souborů (např. `output/žánr/Komedie/film.mkv`)
|
||||
- Synchronizace struktury - detekce a odstranění zastaralých hardlinků při změně tagů
|
||||
- Podpora filtrování podle kategorií
|
||||
- Preview režim (dry run)
|
||||
- **Menu položky pro hardlinky**
|
||||
- "Nastavit hardlink složku..." - konfigurace výstupní složky a kategorií (ukládá se do `.tagger.json`)
|
||||
- "Aktualizovat hardlink strukturu" - rychlá synchronizace s uloženým nastavením
|
||||
- "Vytvořit hardlink strukturu..." - ruční výběr složky a kategorií
|
||||
- **Tříúrovňový konfigurační systém**
|
||||
- Globální config (`config.json`) - nastavení aplikace (geometrie okna, poslední složka)
|
||||
- Složkový config (`.tagger.json`) - nastavení projektu (ignore patterns, hardlink nastavení)
|
||||
- Souborové tagy (`.filename.!tag`) - metadata jednotlivých souborů
|
||||
- **Výchozí tagy**
|
||||
- Kategorie "Hodnocení" s hvězdičkami (1-5 hvězd)
|
||||
- Kategorie "Barva" s barevnými štítky
|
||||
- Exkluzivní výběr v kategorii Hodnocení (pouze jeden tag)
|
||||
- **Testy**
|
||||
- 189 testů pokrývajících všechny moduly
|
||||
- Testy pro hardlink manager včetně synchronizace
|
||||
|
||||
### Změněno
|
||||
- Modernizované GUI inspirované qBittorrentem
|
||||
- Ukládání geometrie okna do globálního configu
|
||||
- Ignore patterns se ukládají do složkového configu
|
||||
|
||||
## [0.2.0] - 2024-12-27
|
||||
|
||||
### Přidáno
|
||||
- **Moderní GUI** - Přepracované rozhraní ve stylu qBittorrent
|
||||
- Postranní panel s kategoriemi a tagy
|
||||
- Tabulka souborů s řazením podle sloupců
|
||||
- Kontextová menu pro soubory a tagy
|
||||
- Vyhledávací pole
|
||||
- Stavový řádek s počtem souborů a velikostí výběru
|
||||
- **Hromadné přiřazování tagů** - Dialog pro přiřazení tagů více souborům najednou
|
||||
- Třístav checkboxy (zaškrtnuto/nezaškrtnuto/smíšené)
|
||||
- Barevné rozlišení kategorií
|
||||
- **Detekce rozlišení videa** - Automatická detekce pomocí ffprobe
|
||||
- **Klávesové zkratky**
|
||||
- Ctrl+O - Otevřít složku
|
||||
- Ctrl+T - Přiřadit tagy
|
||||
- Ctrl+D - Nastavit datum
|
||||
- F5 - Obnovit
|
||||
- Delete - Odstranit z indexu
|
||||
|
||||
### Změněno
|
||||
- Refaktorizace struktury projektu do modulů (`src/core/`, `src/ui/`)
|
||||
|
||||
## [0.1.0] - 2024-10-03
|
||||
|
||||
### Přidáno
|
||||
- Základní funkcionalita tagování souborů
|
||||
- Ukládání tagů do skrytých souborů (`.filename.!tag`)
|
||||
- Správa kategorií a tagů
|
||||
- Rekurzivní skenování složek
|
||||
- Ignore patterns pro filtrování souborů
|
||||
- Základní GUI v Tkinter
|
||||
@@ -20,6 +20,7 @@
|
||||
- Metadata uložená v JSON souborech
|
||||
- Automatická detekce rozlišení videí (ffprobe)
|
||||
- Dvě verze GUI: klasické a moderní (qBittorrent-style)
|
||||
- TODO: Budu mit filmotéku ve složce sloužící jako zdroj (zadne složky uvnitr jen hromada souborů a tagy) a chctel bych na pokyn (menu funkce) aby povytvářel složky dle kategorii tagů a uložil hardlinky na prislušná místa (orig složka: film s tagy "žánr/Komedie" "žánr/Akční" "rok/1988" a soubor v originalni složce zanechá a jen vytvoří na danem míste všechny složky zala tyto zmínene tagy a vytvoří linky)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,10 +10,9 @@ from pathlib import Path
|
||||
class State():
|
||||
def __init__(self) -> None:
|
||||
self.tagmanager = TagManager()
|
||||
self.filehandler = FileManager(self.tagmanager)
|
||||
self.filehandler = FileManager(self.tagmanager)
|
||||
self.app = App(self.filehandler, self.tagmanager)
|
||||
|
||||
|
||||
|
||||
STATE = State()
|
||||
STATE.app.main()
|
||||
STATE.app.main()
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Imports
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from src.ui.gui_modern import ModernApp
|
||||
from src.core.file_manager import list_files, FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
from pathlib import Path
|
||||
|
||||
class State():
|
||||
def __init__(self) -> None:
|
||||
self.tagmanager = TagManager()
|
||||
self.filehandler = FileManager(self.tagmanager)
|
||||
self.app = ModernApp(self.filehandler, self.tagmanager)
|
||||
|
||||
|
||||
STATE = State()
|
||||
STATE.app.main()
|
||||
BIN
data/HLS/Rozlišení/4K/50.png
Normal file
BIN
data/HLS/Rozlišení/4K/50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
210
data/HLS/Rozlišení/4K/DORMER_PRAMET.PDF
Normal file
210
data/HLS/Rozlišení/4K/DORMER_PRAMET.PDF
Normal file
File diff suppressed because one or more lines are too long
BIN
data/HLS/Rozlišení/FullHD/50.png
Normal file
BIN
data/HLS/Rozlišení/FullHD/50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
@@ -2,8 +2,10 @@
|
||||
"new": true,
|
||||
"ignored": false,
|
||||
"tags": [
|
||||
"Rozlišení/4K",
|
||||
"Rozlišení/FullHD"
|
||||
"Rozlišení/FullHD",
|
||||
"Barva/🟠 Oranžová",
|
||||
"Barva/🟡 Žlutá",
|
||||
"Hodnocení/⭐⭐⭐⭐"
|
||||
],
|
||||
"date": null
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
"new": true,
|
||||
"ignored": false,
|
||||
"tags": [
|
||||
"Rozlišení/4K"
|
||||
"Rozlišení/4K",
|
||||
"Barva/🟣 Fialová"
|
||||
],
|
||||
"date": "2025-09-15"
|
||||
}
|
||||
@@ -1,22 +1,112 @@
|
||||
"""
|
||||
Configuration management for Tagger
|
||||
|
||||
Three levels of configuration:
|
||||
1. Global config (config.json next to Tagger.py) - app-wide settings
|
||||
2. Folder config (.tagger.json in project root) - folder-specific settings
|
||||
3. File tags (.filename.!tag) - per-file metadata (handled in file.py)
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_FILE = Path("config.json")
|
||||
# Global config file (next to the main script)
|
||||
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / "config.json"
|
||||
|
||||
default_config = {
|
||||
"ignore_patterns": [],
|
||||
"last_folder": None
|
||||
# Folder config filename
|
||||
FOLDER_CONFIG_NAME = ".tagger.json"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GLOBAL CONFIG - Application settings
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_GLOBAL_CONFIG = {
|
||||
"window_geometry": "1200x800",
|
||||
"window_maximized": False,
|
||||
"last_folder": None,
|
||||
"sidebar_width": 250,
|
||||
"recent_folders": [],
|
||||
}
|
||||
|
||||
def load_config():
|
||||
if CONFIG_FILE.exists():
|
||||
|
||||
def load_global_config() -> dict:
|
||||
"""Load global application config"""
|
||||
if GLOBAL_CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
# Merge with defaults for any missing keys
|
||||
for key, value in DEFAULT_GLOBAL_CONFIG.items():
|
||||
if key not in config:
|
||||
config[key] = value
|
||||
return config
|
||||
except Exception:
|
||||
return default_config.copy()
|
||||
return default_config.copy()
|
||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||
|
||||
|
||||
def save_global_config(cfg: dict):
|
||||
"""Save global application config"""
|
||||
with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
def load_config():
|
||||
"""Legacy function - returns global config"""
|
||||
return load_global_config()
|
||||
|
||||
|
||||
def save_config(cfg: dict):
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
"""Legacy function - saves global config"""
|
||||
save_global_config(cfg)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# src/core/constants.py
|
||||
VERSION = "v1.0.2"
|
||||
VERSION = "v1.0.3"
|
||||
APP_NAME = "Tagger"
|
||||
APP_VIEWPORT = "1000x700"
|
||||
@@ -4,7 +4,11 @@ from .tag_manager import TagManager
|
||||
from .utils import list_files
|
||||
from typing import Iterable
|
||||
import fnmatch
|
||||
from src.core.config import load_config, save_config
|
||||
from src.core.config import (
|
||||
load_global_config, save_global_config,
|
||||
load_folder_config, save_folder_config
|
||||
)
|
||||
|
||||
|
||||
class FileManager:
|
||||
def __init__(self, tagmanager: TagManager):
|
||||
@@ -12,21 +16,44 @@ class FileManager:
|
||||
self.folders: list[Path] = []
|
||||
self.tagmanager = tagmanager
|
||||
self.on_files_changed = None # callback do GUI
|
||||
self.config = load_config()
|
||||
self.global_config = load_global_config()
|
||||
self.folder_configs: dict[Path, dict] = {} # folder -> config
|
||||
self.current_folder: Path | None = None
|
||||
|
||||
def append(self, folder: Path) -> None:
|
||||
"""Add a folder to scan for files"""
|
||||
self.folders.append(folder)
|
||||
self.config["last_folder"] = str(folder)
|
||||
save_config(self.config)
|
||||
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", [])
|
||||
|
||||
ignore_patterns = self.config.get("ignore_patterns", [])
|
||||
for each in list_files(folder):
|
||||
if each.name.endswith(".!tag"):
|
||||
continue
|
||||
if each.name == ".tagger.json":
|
||||
continue
|
||||
|
||||
full_path = each.as_posix() # celá cesta jako string
|
||||
full_path = each.as_posix()
|
||||
|
||||
# kontrolujeme jméno i celou cestu
|
||||
# Check against ignore patterns
|
||||
if any(
|
||||
fnmatch.fnmatch(each.name, pat) or fnmatch.fnmatch(full_path, pat)
|
||||
for pat in ignore_patterns
|
||||
@@ -36,6 +63,38 @@ class FileManager:
|
||||
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:
|
||||
@@ -44,7 +103,6 @@ class FileManager:
|
||||
category, name = tag.split("/", 1)
|
||||
tag_obj = self.tagmanager.add_tag(category, name)
|
||||
else:
|
||||
# pokud není uvedena kategorie, zařadíme pod "default"
|
||||
tag_obj = self.tagmanager.add_tag("default", tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
@@ -60,8 +118,6 @@ class FileManager:
|
||||
if isinstance(tag, str):
|
||||
if "/" in tag:
|
||||
category, name = tag.split("/", 1)
|
||||
tag_obj = File.__module__ # dummy to satisfy typing (we create Tag below)
|
||||
# use Tag class directly
|
||||
from .tag import Tag as TagClass
|
||||
tag_obj = TagClass(category, name)
|
||||
else:
|
||||
@@ -84,7 +140,6 @@ class FileManager:
|
||||
if not tags_list:
|
||||
return self.filelist
|
||||
|
||||
# normalizuj cílové tagy na full_path stringy
|
||||
target_full_paths = set()
|
||||
from .tag import Tag as TagClass
|
||||
for t in tags_list:
|
||||
@@ -93,7 +148,6 @@ class FileManager:
|
||||
elif isinstance(t, str):
|
||||
target_full_paths.add(t)
|
||||
else:
|
||||
# neznámý typ: ignorovat
|
||||
continue
|
||||
|
||||
filtered = []
|
||||
@@ -101,4 +155,10 @@ class FileManager:
|
||||
file_tags = {t.full_path for t in f.tags}
|
||||
if all(tag in file_tags for tag in target_full_paths):
|
||||
filtered.append(f)
|
||||
return filtered
|
||||
return filtered
|
||||
|
||||
# Legacy property for backwards compatibility
|
||||
@property
|
||||
def config(self):
|
||||
"""Legacy: returns global config"""
|
||||
return self.global_config
|
||||
|
||||
352
src/core/hardlink_manager.py
Normal file
352
src/core/hardlink_manager.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Hardlink Manager for Tagger
|
||||
|
||||
Creates directory structure based on file tags and creates hardlinks
|
||||
to organize files without duplicating them on disk.
|
||||
|
||||
Example:
|
||||
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
|
||||
|
||||
output/
|
||||
├── žánr/
|
||||
│ ├── Komedie/
|
||||
│ │ └── film.mkv (hardlink)
|
||||
│ └── Akční/
|
||||
│ └── film.mkv (hardlink)
|
||||
└── rok/
|
||||
└── 1988/
|
||||
└── film.mkv (hardlink)
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
from .file import File
|
||||
|
||||
|
||||
class HardlinkManager:
|
||||
"""Manager for creating hardlink-based directory structures from tagged files."""
|
||||
|
||||
def __init__(self, output_dir: Path):
|
||||
"""
|
||||
Initialize HardlinkManager.
|
||||
|
||||
Args:
|
||||
output_dir: Base directory where the tag-based structure will be created
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.created_links: List[Path] = []
|
||||
self.errors: List[Tuple[Path, str]] = []
|
||||
|
||||
def create_structure_for_files(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
dry_run: bool = False
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Create hardlink structure for given files based on their tags.
|
||||
|
||||
Args:
|
||||
files: List of File objects to process
|
||||
categories: Optional list of categories to include (None = all)
|
||||
dry_run: If True, only simulate without creating actual links
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_links, failed_links)
|
||||
"""
|
||||
self.created_links = []
|
||||
self.errors = []
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for file_obj in files:
|
||||
if not file_obj.tags:
|
||||
continue
|
||||
|
||||
for tag in file_obj.tags:
|
||||
# Skip if category filter is set and this category is not included
|
||||
if categories is not None and tag.category not in categories:
|
||||
continue
|
||||
|
||||
# Create target directory path: output/category/tag_name/
|
||||
target_dir = self.output_dir / tag.category / tag.name
|
||||
target_file = target_dir / file_obj.filename
|
||||
|
||||
try:
|
||||
if not dry_run:
|
||||
# Create directory structure
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Skip if link already exists
|
||||
if target_file.exists():
|
||||
# Check if it's already a hardlink to the same file
|
||||
if self._is_same_file(file_obj.file_path, target_file):
|
||||
continue
|
||||
else:
|
||||
# Different file exists, add suffix
|
||||
target_file = self._get_unique_name(target_file)
|
||||
|
||||
# Create hardlink
|
||||
os.link(file_obj.file_path, target_file)
|
||||
|
||||
self.created_links.append(target_file)
|
||||
success_count += 1
|
||||
|
||||
except OSError as e:
|
||||
self.errors.append((file_obj.file_path, str(e)))
|
||||
fail_count += 1
|
||||
|
||||
return success_count, fail_count
|
||||
|
||||
def _is_same_file(self, path1: Path, path2: Path) -> bool:
|
||||
"""Check if two paths point to the same file (same inode)."""
|
||||
try:
|
||||
return path1.stat().st_ino == path2.stat().st_ino
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _get_unique_name(self, path: Path) -> Path:
|
||||
"""Get a unique filename by adding a numeric suffix."""
|
||||
stem = path.stem
|
||||
suffix = path.suffix
|
||||
parent = path.parent
|
||||
counter = 1
|
||||
|
||||
while True:
|
||||
new_name = f"{stem}_{counter}{suffix}"
|
||||
new_path = parent / new_name
|
||||
if not new_path.exists():
|
||||
return new_path
|
||||
counter += 1
|
||||
|
||||
def remove_created_links(self) -> int:
|
||||
"""
|
||||
Remove all hardlinks created by the last operation.
|
||||
|
||||
Returns:
|
||||
Number of links removed
|
||||
"""
|
||||
removed = 0
|
||||
for link_path in self.created_links:
|
||||
try:
|
||||
if link_path.exists() and link_path.is_file():
|
||||
link_path.unlink()
|
||||
removed += 1
|
||||
|
||||
# Try to remove empty parent directories
|
||||
self._remove_empty_parents(link_path.parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self.created_links = []
|
||||
return removed
|
||||
|
||||
def _remove_empty_parents(self, path: Path) -> None:
|
||||
"""Remove empty parent directories up to output_dir."""
|
||||
try:
|
||||
while path != self.output_dir and path.is_dir():
|
||||
if any(path.iterdir()):
|
||||
break # Directory not empty
|
||||
path.rmdir()
|
||||
path = path.parent
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def get_preview(self, files: List[File], categories: Optional[List[str]] = None) -> List[Tuple[Path, Path]]:
|
||||
"""
|
||||
Get a preview of what links would be created.
|
||||
|
||||
Args:
|
||||
files: List of File objects
|
||||
categories: Optional list of categories to include
|
||||
|
||||
Returns:
|
||||
List of tuples (source_path, target_path)
|
||||
"""
|
||||
preview = []
|
||||
|
||||
for file_obj in files:
|
||||
if not file_obj.tags:
|
||||
continue
|
||||
|
||||
for tag in file_obj.tags:
|
||||
if categories is not None and tag.category not in categories:
|
||||
continue
|
||||
|
||||
target_dir = self.output_dir / tag.category / tag.name
|
||||
target_file = target_dir / file_obj.filename
|
||||
|
||||
preview.append((file_obj.file_path, target_file))
|
||||
|
||||
return preview
|
||||
|
||||
def find_obsolete_links(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None
|
||||
) -> List[Tuple[Path, Path]]:
|
||||
"""
|
||||
Find hardlinks in the output directory that no longer match file tags.
|
||||
|
||||
Scans the output directory for hardlinks that point to source files,
|
||||
but whose category/tag path no longer matches the file's current tags.
|
||||
|
||||
Args:
|
||||
files: List of File objects (source files)
|
||||
categories: Optional list of categories to check (None = all)
|
||||
|
||||
Returns:
|
||||
List of tuples (link_path, source_path) for obsolete links
|
||||
"""
|
||||
obsolete = []
|
||||
|
||||
if not self.output_dir.exists():
|
||||
return obsolete
|
||||
|
||||
# Build a map of source file inodes to File objects
|
||||
inode_to_file: dict[int, File] = {}
|
||||
for file_obj in files:
|
||||
try:
|
||||
inode = file_obj.file_path.stat().st_ino
|
||||
inode_to_file[inode] = file_obj
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Build expected paths for each file based on current tags
|
||||
expected_paths: dict[int, set[Path]] = {}
|
||||
for file_obj in files:
|
||||
try:
|
||||
inode = file_obj.file_path.stat().st_ino
|
||||
expected_paths[inode] = set()
|
||||
|
||||
for tag in file_obj.tags:
|
||||
if categories is not None and tag.category not in categories:
|
||||
continue
|
||||
target = self.output_dir / tag.category / tag.name / file_obj.filename
|
||||
expected_paths[inode].add(target)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Scan output directory for existing hardlinks
|
||||
for category_dir in self.output_dir.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Filter by categories if specified
|
||||
if categories is not None and category_dir.name not in categories:
|
||||
continue
|
||||
|
||||
for tag_dir in category_dir.iterdir():
|
||||
if not tag_dir.is_dir():
|
||||
continue
|
||||
|
||||
for link_file in tag_dir.iterdir():
|
||||
if not link_file.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
link_inode = link_file.stat().st_ino
|
||||
|
||||
# Check if this inode belongs to one of our source files
|
||||
if link_inode in inode_to_file:
|
||||
source_file = inode_to_file[link_inode]
|
||||
|
||||
# Check if this link path is expected
|
||||
if link_inode in expected_paths:
|
||||
if link_file not in expected_paths[link_inode]:
|
||||
# This link exists but tag was removed
|
||||
obsolete.append((link_file, source_file.file_path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return obsolete
|
||||
|
||||
def remove_obsolete_links(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
dry_run: bool = False
|
||||
) -> Tuple[int, List[Path]]:
|
||||
"""
|
||||
Remove hardlinks that no longer match file tags.
|
||||
|
||||
Args:
|
||||
files: List of File objects
|
||||
categories: Optional list of categories to check
|
||||
dry_run: If True, only return what would be removed
|
||||
|
||||
Returns:
|
||||
Tuple of (removed_count, list_of_removed_paths)
|
||||
"""
|
||||
obsolete = self.find_obsolete_links(files, categories)
|
||||
removed_paths = []
|
||||
|
||||
if dry_run:
|
||||
return len(obsolete), [link for link, _ in obsolete]
|
||||
|
||||
for link_path, _ in obsolete:
|
||||
try:
|
||||
link_path.unlink()
|
||||
removed_paths.append(link_path)
|
||||
|
||||
# Try to remove empty parent directories
|
||||
self._remove_empty_parents(link_path.parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return len(removed_paths), removed_paths
|
||||
|
||||
def sync_structure(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
dry_run: bool = False
|
||||
) -> Tuple[int, int, int, int]:
|
||||
"""
|
||||
Synchronize hardlink structure with current file tags.
|
||||
|
||||
This will:
|
||||
1. Remove hardlinks for removed tags
|
||||
2. Create new hardlinks for new tags
|
||||
|
||||
Args:
|
||||
files: List of File objects
|
||||
categories: Optional list of categories to sync
|
||||
dry_run: If True, only simulate
|
||||
|
||||
Returns:
|
||||
Tuple of (created, create_failed, removed, remove_failed)
|
||||
"""
|
||||
# First find how many obsolete links there are
|
||||
obsolete_count = len(self.find_obsolete_links(files, categories))
|
||||
|
||||
# Remove obsolete links
|
||||
removed, removed_paths = self.remove_obsolete_links(files, categories, dry_run)
|
||||
remove_failed = obsolete_count - removed if not dry_run else 0
|
||||
|
||||
# Then create new links
|
||||
created, create_failed = self.create_structure_for_files(files, categories, dry_run)
|
||||
|
||||
return created, create_failed, removed, remove_failed
|
||||
|
||||
|
||||
def create_hardlink_structure(
|
||||
files: List[File],
|
||||
output_dir: Path,
|
||||
categories: Optional[List[str]] = None
|
||||
) -> Tuple[int, int, List[Tuple[Path, str]]]:
|
||||
"""
|
||||
Convenience function to create hardlink structure.
|
||||
|
||||
Args:
|
||||
files: List of File objects to process
|
||||
output_dir: Base directory for output
|
||||
categories: Optional list of categories to include
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_count, failed_count, errors_list)
|
||||
"""
|
||||
manager = HardlinkManager(output_dir)
|
||||
success, fail = manager.create_structure_for_files(files, categories)
|
||||
return success, fail, manager.errors
|
||||
@@ -1,8 +1,22 @@
|
||||
from .tag import Tag
|
||||
|
||||
# Default tags that are always available
|
||||
DEFAULT_TAGS = {
|
||||
"Hodnocení": ["⭐", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"],
|
||||
"Barva": ["🔴 Červená", "🟠 Oranžová", "🟡 Žlutá", "🟢 Zelená", "🔵 Modrá", "🟣 Fialová"],
|
||||
}
|
||||
|
||||
|
||||
class TagManager:
|
||||
def __init__(self):
|
||||
self.tags_by_category = {} # {category: set(Tag)}
|
||||
self._init_default_tags()
|
||||
|
||||
def _init_default_tags(self):
|
||||
"""Initialize default tags (ratings and colors)"""
|
||||
for category, tags in DEFAULT_TAGS.items():
|
||||
for tag_name in tags:
|
||||
self.add_tag(category, tag_name)
|
||||
|
||||
def add_category(self, category: str):
|
||||
if category not in self.tags_by_category:
|
||||
|
||||
1709
src/ui/gui.py
1709
src/ui/gui.py
File diff suppressed because it is too large
Load Diff
@@ -1,712 +0,0 @@
|
||||
"""
|
||||
Modern qBittorrent-style GUI for Tagger
|
||||
"""
|
||||
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
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
from src.core.list_manager import ListManager
|
||||
from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT
|
||||
from src.core.config import save_config
|
||||
|
||||
|
||||
# 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",
|
||||
}
|
||||
|
||||
|
||||
class ModernApp:
|
||||
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.filehandler.on_files_changed = self.update_files_from_manager
|
||||
|
||||
def main(self):
|
||||
root = tk.Tk()
|
||||
root.title(f"{APP_NAME} {VERSION}")
|
||||
root.geometry(APP_VIEWPORT)
|
||||
root.configure(bg=COLORS["bg"])
|
||||
self.root = root
|
||||
|
||||
self.hide_ignored_var = tk.BooleanVar(value=False, master=root)
|
||||
|
||||
# Load last folder
|
||||
last = self.filehandler.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)
|
||||
|
||||
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("<Button-1>", self.on_tree_left_click)
|
||||
self.tag_tree.bind("<Button-3>", 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)
|
||||
|
||||
# Sort options
|
||||
tk.Label(control_frame, text="Třídění:", bg=COLORS["bg"]).pack(side=tk.LEFT, padx=(15, 5))
|
||||
self.sort_combo = ttk.Combobox(control_frame, values=["Název", "Datum"], width=10, state="readonly")
|
||||
self.sort_combo.current(0)
|
||||
self.sort_combo.bind("<<ComboboxSelected>>", lambda e: self.toggle_sort_mode())
|
||||
self.sort_combo.pack(side=tk.LEFT)
|
||||
|
||||
self.order_var = tk.StringVar(value="▲ Vzestupně")
|
||||
order_btn = tk.Button(control_frame, textvariable=self.order_var, command=self.toggle_sort_order,
|
||||
relief=tk.FLAT, bg=COLORS["bg"])
|
||||
order_btn.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
|
||||
self.file_table.heading("name", text="📄 Název souboru")
|
||||
self.file_table.heading("date", text="📅 Datum")
|
||||
self.file_table.heading("tags", text="🏷️ Štítky")
|
||||
self.file_table.heading("size", text="💾 Velikost")
|
||||
|
||||
# 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("<Double-1>", self.on_file_double_click)
|
||||
self.file_table.bind("<Button-3>", self.on_file_right_click)
|
||||
|
||||
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)
|
||||
|
||||
# Middle - 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("<Control-o>", lambda e: self.open_folder_dialog())
|
||||
self.root.bind("<Control-q>", lambda e: self.root.quit())
|
||||
self.root.bind("<Control-t>", lambda e: self.assign_tag_to_selected_bulk())
|
||||
self.root.bind("<Control-d>", lambda e: self.set_date_for_selected())
|
||||
self.root.bind("<Control-f>", lambda e: self.search_var.get()) # Focus search
|
||||
self.root.bind("<F5>", lambda e: self.refresh_all())
|
||||
self.root.bind("<Delete>", 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)
|
||||
|
||||
# Add root
|
||||
root_id = self.tag_tree.insert("", "end", text="📂 Všechny tagy", image=self.icons.get("tag"))
|
||||
self.tag_tree.item(root_id, open=True)
|
||||
self.root_tag_id = root_id
|
||||
|
||||
# Add categories and tags
|
||||
for category in self.tagmanager.get_categories():
|
||||
cat_id = self.tag_tree.insert(root_id, "end", text=f"📁 {category}", image=self.icons.get("tag"))
|
||||
self.states[cat_id] = False
|
||||
|
||||
for tag in self.tagmanager.get_tags_in_category(category):
|
||||
tag_id = self.tag_tree.insert(cat_id, "end", text=f" {tag.name}",
|
||||
image=self.icons.get("unchecked"))
|
||||
self.states[tag_id] = False
|
||||
|
||||
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("📁 ", "")
|
||||
name = self.tag_tree.item(item_id, "text").strip()
|
||||
tags.append(Tag(category, 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)
|
||||
|
||||
# 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ů")
|
||||
|
||||
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_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
|
||||
|
||||
# Import the dialog from old GUI
|
||||
from src.ui.gui_old import MultiFileTagAssignDialog
|
||||
|
||||
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)
|
||||
result = getattr(dialog, "result", None)
|
||||
|
||||
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"""
|
||||
current = ", ".join(self.filehandler.config.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.config["ignore_patterns"] = patterns
|
||||
save_config(self.filehandler.config)
|
||||
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 toggle_sort_mode(self):
|
||||
"""Toggle sort mode"""
|
||||
selected = self.sort_combo.get()
|
||||
self.sort_mode = "date" if selected == "Datum" else "name"
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_sort_order(self):
|
||||
"""Toggle sort order"""
|
||||
self.sort_order = "desc" if self.sort_order == "asc" else "asc"
|
||||
self.order_var.set("▼ Sestupně" if self.sort_order == "desc" else "▲ Vzestupně")
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
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")
|
||||
@@ -1,711 +0,0 @@
|
||||
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
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
from src.core.list_manager import ListManager
|
||||
from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT
|
||||
from src.core.config import save_config # <-- doplněno
|
||||
|
||||
|
||||
|
||||
|
||||
class TagSelectionDialog(tk.Toplevel):
|
||||
"""
|
||||
Jednoduchý dialog pro výběr tagů (původní, používán jinde).
|
||||
(tento třída zůstává pro jednobodové použití)
|
||||
"""
|
||||
def __init__(self, parent, tags: list[str]):
|
||||
super().__init__(parent)
|
||||
self.title("Vyber tagy")
|
||||
self.selected_tags = []
|
||||
self.vars = {}
|
||||
|
||||
tk.Label(self, text="Vyber tagy k přiřazení:").pack(pady=5)
|
||||
|
||||
frame = tk.Frame(self)
|
||||
frame.pack(padx=10, pady=5)
|
||||
|
||||
for tag in tags:
|
||||
var = tk.BooleanVar(value=False)
|
||||
chk = tk.Checkbutton(frame, text=tag, variable=var)
|
||||
chk.pack(anchor="w")
|
||||
self.vars[tag] = var
|
||||
|
||||
btn_frame = tk.Frame(self)
|
||||
btn_frame.pack(pady=5)
|
||||
tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5)
|
||||
tk.Button(btn_frame, text="Zrušit", command=self.destroy).pack(side="left", padx=5)
|
||||
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
parent.wait_window(self)
|
||||
|
||||
def on_ok(self):
|
||||
self.selected_tags = [tag for tag, var in self.vars.items() if var.get()]
|
||||
self.destroy()
|
||||
|
||||
|
||||
class MultiFileTagAssignDialog(tk.Toplevel):
|
||||
def __init__(self, parent, all_tags: List[Tag], files: List[File]):
|
||||
super().__init__(parent)
|
||||
self.title("Přiřadit tagy k vybraným souborům")
|
||||
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
|
||||
|
||||
tk.Label(self, text=f"Vybráno souborů: {len(files)}").pack(pady=5)
|
||||
|
||||
frame = tk.Frame(self)
|
||||
frame.pack(padx=10, pady=5, fill="both", expand=True)
|
||||
|
||||
file_tag_sets = [{t.full_path for t in f.tags} for f in files]
|
||||
|
||||
for full_path, tag in sorted(self.tags_by_full.items()):
|
||||
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=full_path, anchor="w")
|
||||
cb.state_value = init
|
||||
cb.full_path = full_path
|
||||
cb.pack(fill="x", anchor="w")
|
||||
cb.bind("<Button-1>", self._on_toggle)
|
||||
|
||||
self._update_checkbox_look(cb)
|
||||
self.checkbuttons[full_path] = cb
|
||||
self.vars[full_path] = init
|
||||
|
||||
btn_frame = tk.Frame(self)
|
||||
btn_frame.pack(pady=5)
|
||||
tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5)
|
||||
tk.Button(btn_frame, text="Cancel", command=self.destroy).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
|
||||
cur = cb.state_value
|
||||
if cur == 0: # OFF → ON
|
||||
cb.state_value = 1
|
||||
elif cur == 1: # ON → OFF
|
||||
cb.state_value = 0
|
||||
elif cur == 2: # MIXED → ON
|
||||
cb.state_value = 1
|
||||
self._update_checkbox_look(cb)
|
||||
return "break"
|
||||
|
||||
def _update_checkbox_look(self, cb: tk.Checkbutton):
|
||||
"""Aktualizuje vizuál podle stavu."""
|
||||
v = cb.state_value
|
||||
if v == 0:
|
||||
cb.deselect()
|
||||
cb.config(fg="black")
|
||||
elif v == 1:
|
||||
cb.select()
|
||||
cb.config(fg="blue")
|
||||
elif v == 2:
|
||||
cb.deselect() # mixed = nezaškrtnuté, ale červený text
|
||||
cb.config(fg="red")
|
||||
|
||||
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.states = {}
|
||||
self.listbox_map: dict[int, list[File]] = {}
|
||||
self.selected_tree_item_for_context = None
|
||||
self.selected_list_index_for_context = None
|
||||
self.filehandler = filehandler
|
||||
self.tagmanager = tagmanager
|
||||
self.list_manager = ListManager()
|
||||
|
||||
# tady jen připravíme proměnnou, ale nevytváříme BooleanVar!
|
||||
self.hide_ignored_var = None
|
||||
|
||||
self.filter_text = ""
|
||||
self.show_full_path = False
|
||||
self.sort_mode = "name"
|
||||
self.sort_order = "asc"
|
||||
|
||||
self.filehandler.on_files_changed = self.update_files_from_manager
|
||||
|
||||
def detect_video_resolution(self):
|
||||
files = self.get_selected_files_objects()
|
||||
if not files:
|
||||
self.status_bar.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_bar.config(text=f"Přiřazeno rozlišení tagů k {count} souborům")
|
||||
|
||||
|
||||
# ==================================================
|
||||
# MAIN GUI
|
||||
# ==================================================
|
||||
def main(self):
|
||||
root = tk.Tk()
|
||||
root.title(APP_NAME + " " + VERSION)
|
||||
root.geometry(APP_VIEWPORT)
|
||||
self.root = root
|
||||
|
||||
# teď už máme root, takže můžeme vytvořit BooleanVar
|
||||
self.hide_ignored_var = tk.BooleanVar(value=False, master=root)
|
||||
|
||||
last = self.filehandler.config.get("last_folder")
|
||||
if last:
|
||||
try:
|
||||
self.filehandler.append(Path(last))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- Ikony
|
||||
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}
|
||||
root.unchecked_img = unchecked
|
||||
root.checked_img = checked
|
||||
root.tag_img = tag_icon
|
||||
|
||||
# ---- Layout
|
||||
menu_bar = tk.Menu(root)
|
||||
root.config(menu=menu_bar)
|
||||
|
||||
file_menu = tk.Menu(menu_bar, tearoff=0)
|
||||
file_menu.add_command(label="Open Folder...", 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", command=root.quit)
|
||||
|
||||
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
|
||||
)
|
||||
function_menu = tk.Menu(menu_bar, tearoff=0)
|
||||
function_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
|
||||
function_menu.add_command(label="Detekovat rozlišení u videí", command=self.detect_video_resolution)
|
||||
|
||||
|
||||
menu_bar.add_cascade(label="Soubor", menu=file_menu)
|
||||
menu_bar.add_cascade(label="Pohled", menu=view_menu)
|
||||
menu_bar.add_cascade(label="Funkce", menu=function_menu)
|
||||
|
||||
main_frame = tk.Frame(root)
|
||||
main_frame.pack(fill="both", expand=True)
|
||||
main_frame.columnconfigure(0, weight=1)
|
||||
main_frame.columnconfigure(1, weight=2)
|
||||
main_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# ---- Tree (left)
|
||||
self.tree = ttk.Treeview(main_frame)
|
||||
self.tree.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
|
||||
self.tree.bind("<Button-1>", self.on_tree_left_click)
|
||||
self.tree.bind("<Button-3>", self.on_tree_right_click)
|
||||
|
||||
# ---- Right side (filter + listbox)
|
||||
right_frame = tk.Frame(main_frame)
|
||||
right_frame.grid(row=0, column=1, sticky="nsew", padx=4, pady=4)
|
||||
right_frame.rowconfigure(1, weight=1)
|
||||
right_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Filter + buttons row
|
||||
filter_frame = tk.Frame(right_frame)
|
||||
filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 4))
|
||||
filter_frame.columnconfigure(0, weight=1)
|
||||
|
||||
self.filter_entry = tk.Entry(filter_frame)
|
||||
self.filter_entry.grid(row=0, column=0, sticky="ew")
|
||||
self.filter_entry.bind("<KeyRelease>", lambda e: self.on_filter_changed())
|
||||
|
||||
self.btn_toggle_path = tk.Button(filter_frame, text="Name", width=6, command=self.toggle_show_path)
|
||||
self.btn_toggle_path.grid(row=0, column=1, padx=2)
|
||||
|
||||
self.btn_toggle_sortmode = tk.Button(filter_frame, text="Sort:Name", width=8, command=self.toggle_sort_mode)
|
||||
self.btn_toggle_sortmode.grid(row=0, column=2, padx=2)
|
||||
|
||||
self.btn_toggle_order = tk.Button(filter_frame, text="ASC", width=5, command=self.toggle_sort_order)
|
||||
self.btn_toggle_order.grid(row=0, column=3, padx=2)
|
||||
|
||||
# Listbox + scrollbar
|
||||
self.listbox = tk.Listbox(right_frame, selectmode="extended")
|
||||
self.listbox.grid(row=1, column=0, sticky="nsew")
|
||||
self.listbox.bind("<Double-1>", self.on_list_double)
|
||||
self.listbox.bind("<Button-3>", self.on_list_right_click)
|
||||
|
||||
lb_scroll = tk.Scrollbar(right_frame, orient="vertical", command=self.listbox.yview)
|
||||
lb_scroll.grid(row=1, column=1, sticky="ns")
|
||||
self.listbox.config(yscrollcommand=lb_scroll.set)
|
||||
|
||||
# ---- Status bar
|
||||
self.status_bar = tk.Label(root, text="Připraven", anchor="w", relief="sunken")
|
||||
self.status_bar.pack(side="bottom", fill="x")
|
||||
|
||||
# ---- Context menus
|
||||
self.tree_menu = tk.Menu(root, tearoff=0)
|
||||
self.tree_menu.add_command(label="Nový štítek", command=self.tree_add_tag)
|
||||
self.tree_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag)
|
||||
|
||||
self.list_menu = tk.Menu(root, tearoff=0)
|
||||
self.list_menu.add_command(label="Otevřít soubor", command=self.list_open_file)
|
||||
self.list_menu.add_command(label="Smazat z indexu", command=self.list_remove_file)
|
||||
self.list_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
|
||||
self.list_menu.add_command(label="Přiřadit štítek", command=self.assign_tag_to_selected_bulk)
|
||||
|
||||
# ---- Root node
|
||||
root_id = self.tree.insert("", "end", text="Štítky", image=self.icons["tag"])
|
||||
self.tree.item(root_id, open=True)
|
||||
self.root_id = root_id
|
||||
|
||||
# ⚡ refresh při startu
|
||||
self.refresh_tree_tags()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
root.mainloop()
|
||||
|
||||
|
||||
# ==================================================
|
||||
# FILTER + SORT TOGGLES
|
||||
# ==================================================
|
||||
def set_ignore_patterns(self):
|
||||
current = ", ".join(self.filehandler.config.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.config["ignore_patterns"] = patterns
|
||||
save_config(self.filehandler.config)
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_hide_ignored(self):
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def on_filter_changed(self):
|
||||
self.filter_text = self.filter_entry.get().strip().lower()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_show_path(self):
|
||||
self.show_full_path = not self.show_full_path
|
||||
self.btn_toggle_path.config(text="Path" if self.show_full_path else "Name")
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_sort_mode(self):
|
||||
self.sort_mode = "date" if self.sort_mode == "name" else "name"
|
||||
self.btn_toggle_sortmode.config(text=f"Sort:{self.sort_mode.capitalize()}")
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_sort_order(self):
|
||||
self.sort_order = "desc" if self.sort_order == "asc" else "asc"
|
||||
self.btn_toggle_order.config(text=self.sort_order.upper())
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
# ==================================================
|
||||
# FILE REFRESH + MAP
|
||||
# ==================================================
|
||||
def update_files_from_manager(self, filelist=None):
|
||||
if filelist is None:
|
||||
filelist = self.filehandler.filelist
|
||||
|
||||
# filtr tagy
|
||||
checked_tags = self.get_checked_tags()
|
||||
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
|
||||
|
||||
# filtr text
|
||||
if self.filter_text:
|
||||
filtered_files = [
|
||||
f for f in filtered_files
|
||||
if self.filter_text in f.filename.lower() or
|
||||
(self.show_full_path and self.filter_text in str(f.file_path).lower())
|
||||
]
|
||||
|
||||
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}
|
||||
]
|
||||
|
||||
|
||||
|
||||
# řazení
|
||||
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)
|
||||
|
||||
# naplníme listbox
|
||||
self.listbox.delete(0, "end")
|
||||
self.listbox_map = {}
|
||||
|
||||
for i, f in enumerate(filtered_files):
|
||||
if self.show_full_path:
|
||||
display = str(f.file_path)
|
||||
else:
|
||||
display = f.filename
|
||||
if f.date:
|
||||
display = f"{display} — {f.date}"
|
||||
self.listbox.insert("end", display)
|
||||
self.listbox_map[i] = [f]
|
||||
|
||||
self.status_bar.config(text=f"Zobrazeno {self.listbox.size()} položek")
|
||||
|
||||
# ==================================================
|
||||
# GET SELECTED FILES
|
||||
# ==================================================
|
||||
def get_selected_files_objects(self):
|
||||
indices = self.listbox.curselection()
|
||||
files = []
|
||||
for idx in indices:
|
||||
files.extend(self.listbox_map.get(idx, []))
|
||||
return files
|
||||
|
||||
# ==================================================
|
||||
# ASSIGN TAG (jednoduchý)
|
||||
# ==================================================
|
||||
def assign_tag_to_selected(self):
|
||||
files = self.get_selected_files_objects()
|
||||
if not files:
|
||||
self.status_bar.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
|
||||
all_tags: List[Tag] = []
|
||||
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
|
||||
|
||||
tag_strings = [tag.full_path for tag in all_tags]
|
||||
dialog = TagSelectionDialog(self.root, tag_strings)
|
||||
selected_tag_strings = dialog.selected_tags
|
||||
|
||||
if not selected_tag_strings:
|
||||
self.status_bar.config(text="Nebyl vybrán žádný tag")
|
||||
return
|
||||
|
||||
selected_tags: list[Tag] = []
|
||||
for full_tag in selected_tag_strings:
|
||||
if "/" in full_tag:
|
||||
category, name = full_tag.split("/", 1)
|
||||
selected_tags.append(self.tagmanager.add_tag(category, name))
|
||||
|
||||
for tag in selected_tags:
|
||||
self.filehandler.assign_tag_to_file_objects(files, tag)
|
||||
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_bar.config(text=f"Přiřazeny tagy: {', '.join(selected_tag_strings)}")
|
||||
|
||||
# ==================================================
|
||||
# ASSIGN TAG (pokročilé pro více souborů - tri-state)
|
||||
# ==================================================
|
||||
def assign_tag_to_selected_bulk(self):
|
||||
files = self.get_selected_files_objects()
|
||||
if not files:
|
||||
self.status_bar.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
|
||||
all_tags: List[Tag] = []
|
||||
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)
|
||||
result = getattr(dialog, "result", None)
|
||||
if result is None:
|
||||
self.status_bar.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)
|
||||
from src.core.tag import Tag as TagClass
|
||||
tag_obj = TagClass(category, name)
|
||||
self.filehandler.remove_tag_from_file_objects(files, tag_obj)
|
||||
else:
|
||||
continue
|
||||
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_bar.config(text="Hromadné přiřazení tagů dokončeno")
|
||||
|
||||
# ==================================================
|
||||
# SET DATE FOR SELECTED FILES
|
||||
# ==================================================
|
||||
def set_date_for_selected(self):
|
||||
files = self.get_selected_files_objects()
|
||||
if not files:
|
||||
self.status_bar.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_bar.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)")
|
||||
|
||||
# ==================================================
|
||||
# DOUBLE CLICK OPEN
|
||||
# ==================================================
|
||||
def on_list_double(self, event):
|
||||
for f in self.get_selected_files_objects():
|
||||
self.open_file(f.file_path)
|
||||
|
||||
# ==================================================
|
||||
# OPEN FILE
|
||||
# ==================================================
|
||||
def open_file(self, path):
|
||||
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_bar.config(text=f"Otevírám: {path}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}")
|
||||
|
||||
# ==================================================
|
||||
# LIST CONTEXT MENU
|
||||
# ==================================================
|
||||
def on_list_right_click(self, event):
|
||||
idx = self.listbox.nearest(event.y)
|
||||
if idx is None:
|
||||
return
|
||||
|
||||
# pokud položka není součástí aktuálního výběru, přidáme ji
|
||||
if idx not in self.listbox.curselection():
|
||||
self.listbox.selection_set(idx)
|
||||
|
||||
self.selected_list_index_for_context = idx
|
||||
self.list_menu.tk_popup(event.x_root, event.y_root)
|
||||
|
||||
|
||||
def list_open_file(self):
|
||||
for f in self.get_selected_files_objects():
|
||||
self.open_file(f.file_path)
|
||||
|
||||
def list_remove_file(self):
|
||||
files = self.get_selected_files_objects()
|
||||
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_bar.config(text=f"Odstraněno {len(files)} souborů z indexu")
|
||||
|
||||
# ==================================================
|
||||
# OPEN FOLDER
|
||||
# ==================================================
|
||||
def open_folder_dialog(self):
|
||||
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_bar.config(text=f"Přidána složka: {folder_path}")
|
||||
self.refresh_tree_tags()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}")
|
||||
|
||||
# ==================================================
|
||||
# TREE EVENTS
|
||||
# ==================================================
|
||||
def on_tree_left_click(self, event):
|
||||
region = self.tree.identify("region", event.x, event.y)
|
||||
if region not in ("tree", "icon"):
|
||||
return
|
||||
|
||||
item_id = self.tree.identify_row(event.y)
|
||||
if not item_id:
|
||||
return
|
||||
|
||||
parent_id = self.tree.parent(item_id)
|
||||
if parent_id == "" or parent_id == self.root_id:
|
||||
is_open = self.tree.item(item_id, "open")
|
||||
self.tree.item(item_id, open=not is_open)
|
||||
return
|
||||
|
||||
self.states[item_id] = not self.states.get(item_id, False)
|
||||
self.tree.item(
|
||||
item_id,
|
||||
image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]
|
||||
)
|
||||
self.status_bar.config(
|
||||
text=f"Tag {'checked' if self.states[item_id] else 'unchecked'}: {self.build_full_tag(item_id)}"
|
||||
)
|
||||
|
||||
checked_tags = self.get_checked_tags()
|
||||
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
|
||||
self.update_files_from_manager(filtered_files)
|
||||
|
||||
def on_tree_right_click(self, event):
|
||||
item_id = self.tree.identify_row(event.y)
|
||||
if item_id:
|
||||
self.selected_tree_item_for_context = item_id
|
||||
self.tree.selection_set(item_id)
|
||||
self.tree_menu.tk_popup(event.x_root, event.y_root)
|
||||
else:
|
||||
menu = tk.Menu(self.root, tearoff=0)
|
||||
menu.add_command(label="Nový top-level tag", command=lambda: self.tree_add_tag(background=True))
|
||||
menu.tk_popup(event.x_root, event.y_root)
|
||||
|
||||
# ==================================================
|
||||
# TREE TAG CRUD
|
||||
# ==================================================
|
||||
def tree_add_tag(self, background=False):
|
||||
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_id
|
||||
new_id = self.tree.insert(parent, "end", text=name, image=self.icons["unchecked"])
|
||||
self.states[new_id] = False
|
||||
|
||||
if parent == self.root_id:
|
||||
category = name
|
||||
self.tagmanager.add_category(category)
|
||||
self.tree.item(new_id, image=self.icons["tag"])
|
||||
else:
|
||||
category = self.tree.item(parent, "text")
|
||||
self.tagmanager.add_tag(category, name)
|
||||
|
||||
self.status_bar.config(text=f"Vytvořen tag: {self.build_full_tag(new_id)}")
|
||||
|
||||
def tree_delete_tag(self):
|
||||
item = self.selected_tree_item_for_context
|
||||
if not item:
|
||||
return
|
||||
full = self.build_full_tag(item)
|
||||
ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{full}'?")
|
||||
if not ans:
|
||||
return
|
||||
tag_name = self.tree.item(item, "text")
|
||||
parent_id = self.tree.parent(item)
|
||||
self.tree.delete(item)
|
||||
self.states.pop(item, None)
|
||||
|
||||
if parent_id == self.root_id:
|
||||
self.tagmanager.remove_category(tag_name)
|
||||
else:
|
||||
category = self.tree.item(parent_id, "text")
|
||||
self.tagmanager.remove_tag(category, tag_name)
|
||||
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_bar.config(text=f"Smazán tag: {full}")
|
||||
|
||||
# ==================================================
|
||||
# TREE HELPERS
|
||||
# ==================================================
|
||||
def build_full_tag(self, item_id):
|
||||
parts = []
|
||||
cur = item_id
|
||||
while cur and cur != self.root_id:
|
||||
parts.append(self.tree.item(cur, "text"))
|
||||
cur = self.tree.parent(cur)
|
||||
parts.reverse()
|
||||
return "/".join(parts) if parts else ""
|
||||
|
||||
def get_checked_full_tags(self):
|
||||
return {self.build_full_tag(i) for i, v in self.states.items() if v}
|
||||
|
||||
def refresh_tree_tags(self):
|
||||
for child in self.tree.get_children(self.root_id):
|
||||
self.tree.delete(child)
|
||||
|
||||
for category in self.tagmanager.get_categories():
|
||||
cat_id = self.tree.insert(self.root_id, "end", text=category, image=self.icons["tag"])
|
||||
self.states[cat_id] = False
|
||||
for tag in self.tagmanager.get_tags_in_category(category):
|
||||
tag_id = self.tree.insert(cat_id, "end", text=tag.name, image=self.icons["unchecked"])
|
||||
self.states[tag_id] = False
|
||||
|
||||
self.tree.item(self.root_id, open=True)
|
||||
|
||||
def get_checked_tags(self) -> List[Tag]:
|
||||
tags: List[Tag] = []
|
||||
for item_id, checked in self.states.items():
|
||||
if not checked:
|
||||
continue
|
||||
parent_id = self.tree.parent(item_id)
|
||||
if parent_id == self.root_id:
|
||||
continue
|
||||
category = self.tree.item(parent_id, "text")
|
||||
name = self.tree.item(item_id, "text")
|
||||
tags.append(Tag(category, name))
|
||||
return tags
|
||||
|
||||
def _get_checked_recursive(self, item):
|
||||
tags = []
|
||||
if self.states.get(item, False):
|
||||
parent = self.tree.parent(item)
|
||||
if parent and parent != self.root_id:
|
||||
parent_text = self.tree.item(parent, "text")
|
||||
text = self.tree.item(item, "text")
|
||||
tags.append(f"{parent_text}/{text}")
|
||||
for child in self.tree.get_children(item):
|
||||
tags.extend(self._get_checked_recursive(child))
|
||||
return tags
|
||||
@@ -1,252 +1,411 @@
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from src.core.config import load_config, save_config, default_config
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Testy pro config modul"""
|
||||
class TestGlobalConfig:
|
||||
"""Testy pro globální config"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config_file(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný config soubor"""
|
||||
config_path = tmp_path / "test_config.json"
|
||||
# Změníme CONFIG_FILE v modulu config
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný globální config soubor"""
|
||||
config_path = tmp_path / "config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'CONFIG_FILE', config_path)
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
def test_default_config_structure(self):
|
||||
"""Test struktury defaultní konfigurace"""
|
||||
assert "ignore_patterns" in default_config
|
||||
assert "last_folder" in default_config
|
||||
assert isinstance(default_config["ignore_patterns"], list)
|
||||
assert default_config["last_folder"] is None
|
||||
def test_default_global_config_structure(self):
|
||||
"""Test struktury defaultní globální konfigurace"""
|
||||
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 DEFAULT_GLOBAL_CONFIG["window_geometry"] == "1200x800"
|
||||
assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False
|
||||
assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None
|
||||
|
||||
def test_load_config_nonexistent_file(self, temp_config_file):
|
||||
"""Test načtení konfigurace když soubor neexistuje"""
|
||||
config = load_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()
|
||||
assert config == DEFAULT_GLOBAL_CONFIG
|
||||
|
||||
assert config == default_config
|
||||
assert config["ignore_patterns"] == []
|
||||
assert config["last_folder"] is None
|
||||
|
||||
def test_save_config(self, temp_config_file):
|
||||
"""Test uložení konfigurace"""
|
||||
def test_save_global_config(self, temp_global_config):
|
||||
"""Test uložení globální konfigurace"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["*.tmp", "*.log"],
|
||||
"last_folder": "/home/user/documents"
|
||||
"window_geometry": "800x600",
|
||||
"window_maximized": True,
|
||||
"last_folder": "/home/user/documents",
|
||||
"sidebar_width": 300,
|
||||
"recent_folders": ["/path1", "/path2"],
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
save_global_config(test_config)
|
||||
|
||||
# Kontrola že soubor existuje
|
||||
assert temp_config_file.exists()
|
||||
|
||||
# Kontrola obsahu
|
||||
with open(temp_config_file, "r", encoding="utf-8") as f:
|
||||
assert temp_global_config.exists()
|
||||
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||
saved_data = json.load(f)
|
||||
|
||||
assert saved_data == test_config
|
||||
|
||||
def test_load_config_existing_file(self, temp_config_file):
|
||||
"""Test načtení existující konfigurace"""
|
||||
def test_load_global_config_existing_file(self, temp_global_config):
|
||||
"""Test načtení existující globální konfigurace"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["*.tmp"],
|
||||
"last_folder": "/test/path"
|
||||
"window_geometry": "1920x1080",
|
||||
"window_maximized": False,
|
||||
"last_folder": "/test/path",
|
||||
"sidebar_width": 250,
|
||||
"recent_folders": [],
|
||||
}
|
||||
|
||||
# Uložení
|
||||
save_config(test_config)
|
||||
|
||||
# Načtení
|
||||
loaded_config = load_config()
|
||||
save_global_config(test_config)
|
||||
loaded_config = load_global_config()
|
||||
|
||||
assert loaded_config == test_config
|
||||
assert loaded_config["ignore_patterns"] == ["*.tmp"]
|
||||
assert loaded_config["last_folder"] == "/test/path"
|
||||
|
||||
def test_save_and_load_config_cycle(self, temp_config_file):
|
||||
"""Test cyklu uložení a načtení"""
|
||||
original_config = {
|
||||
"ignore_patterns": ["*.jpg", "*.png", "*.gif"],
|
||||
"last_folder": "/home/user/pictures"
|
||||
}
|
||||
def test_load_global_config_merges_defaults(self, temp_global_config):
|
||||
"""Test že chybějící klíče jsou doplněny z defaultů"""
|
||||
partial_config = {"window_geometry": "800x600"}
|
||||
|
||||
save_config(original_config)
|
||||
loaded_config = load_config()
|
||||
with open(temp_global_config, "w", encoding="utf-8") as f:
|
||||
json.dump(partial_config, f)
|
||||
|
||||
assert loaded_config == original_config
|
||||
loaded = load_global_config()
|
||||
assert loaded["window_geometry"] == "800x600"
|
||||
assert loaded["window_maximized"] == DEFAULT_GLOBAL_CONFIG["window_maximized"]
|
||||
assert loaded["sidebar_width"] == DEFAULT_GLOBAL_CONFIG["sidebar_width"]
|
||||
|
||||
def test_config_json_format(self, temp_config_file):
|
||||
"""Test že config je uložen ve správném JSON formátu"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["*.tmp"],
|
||||
"last_folder": "/test"
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
|
||||
# Kontrola formátování
|
||||
with open(temp_config_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Mělo by být naformátováno s indentací
|
||||
assert " " in content # 2 mezery jako indent
|
||||
|
||||
def test_config_utf8_encoding(self, temp_config_file):
|
||||
"""Test UTF-8 encoding s českými znaky"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["*.čeština"],
|
||||
"last_folder": "/cesta/s/čestnými/znaky"
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
loaded_config = load_config()
|
||||
|
||||
assert loaded_config == test_config
|
||||
assert loaded_config["last_folder"] == "/cesta/s/čestnými/znaky"
|
||||
|
||||
def test_config_empty_ignore_patterns(self, temp_config_file):
|
||||
"""Test s prázdným seznamem ignore_patterns"""
|
||||
test_config = {
|
||||
"ignore_patterns": [],
|
||||
"last_folder": "/test"
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
loaded_config = load_config()
|
||||
|
||||
assert loaded_config["ignore_patterns"] == []
|
||||
|
||||
def test_config_null_last_folder(self, temp_config_file):
|
||||
"""Test s None hodnotou pro last_folder"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["*.tmp"],
|
||||
"last_folder": None
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
loaded_config = load_config()
|
||||
|
||||
assert loaded_config["last_folder"] is None
|
||||
|
||||
def test_config_multiple_ignore_patterns(self, temp_config_file):
|
||||
"""Test s více ignore patterny"""
|
||||
patterns = ["*.tmp", "*.log", "*.cache", "*/node_modules/*", "*.pyc"]
|
||||
test_config = {
|
||||
"ignore_patterns": patterns,
|
||||
"last_folder": "/test"
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
loaded_config = load_config()
|
||||
|
||||
assert loaded_config["ignore_patterns"] == patterns
|
||||
assert len(loaded_config["ignore_patterns"]) == 5
|
||||
|
||||
def test_config_special_characters_in_patterns(self, temp_config_file):
|
||||
"""Test se speciálními znaky v patterns"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["*.tmp", "file[0-9].txt", "test?.log"],
|
||||
"last_folder": "/test"
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
loaded_config = load_config()
|
||||
|
||||
assert loaded_config["ignore_patterns"] == test_config["ignore_patterns"]
|
||||
|
||||
def test_load_config_corrupted_file(self, temp_config_file):
|
||||
"""Test načtení poškozeného config souboru"""
|
||||
# Vytvoření poškozeného JSON
|
||||
with open(temp_config_file, "w") as f:
|
||||
def test_global_config_corrupted_file(self, temp_global_config):
|
||||
"""Test načtení poškozeného global config souboru"""
|
||||
with open(temp_global_config, "w") as f:
|
||||
f.write("{ invalid json }")
|
||||
|
||||
# Mělo by vrátit default config
|
||||
config = load_config()
|
||||
assert config == default_config
|
||||
config = load_global_config()
|
||||
assert config == DEFAULT_GLOBAL_CONFIG
|
||||
|
||||
def test_load_config_returns_new_dict(self, temp_config_file):
|
||||
"""Test že load_config vrací nový dictionary (ne stejnou referenci)"""
|
||||
config1 = load_config()
|
||||
config2 = load_config()
|
||||
def test_global_config_utf8_encoding(self, temp_global_config):
|
||||
"""Test UTF-8 encoding s českými znaky"""
|
||||
test_config = {
|
||||
**DEFAULT_GLOBAL_CONFIG,
|
||||
"last_folder": "/cesta/s/českými/znaky",
|
||||
"recent_folders": ["/složka/čeština"],
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded_config = load_global_config()
|
||||
|
||||
assert loaded_config["last_folder"] == "/cesta/s/českými/znaky"
|
||||
assert loaded_config["recent_folders"] == ["/složka/čeština"]
|
||||
|
||||
def test_global_config_returns_new_dict(self, temp_global_config):
|
||||
"""Test že load_global_config vrací nový dictionary"""
|
||||
config1 = load_global_config()
|
||||
config2 = load_global_config()
|
||||
|
||||
# Měly by to být různé objekty (ne stejná reference)
|
||||
assert config1 is not config2
|
||||
|
||||
# Ale hodnoty by měly být stejné
|
||||
assert config1 == config2
|
||||
|
||||
def test_config_overwrite(self, temp_config_file):
|
||||
"""Test přepsání existující konfigurace"""
|
||||
config1 = {
|
||||
"ignore_patterns": ["*.tmp"],
|
||||
"last_folder": "/path1"
|
||||
def test_global_config_recent_folders(self, temp_global_config):
|
||||
"""Test ukládání recent_folders"""
|
||||
folders = ["/path/one", "/path/two", "/path/three"]
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["recent_folders"] == folders
|
||||
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 == ".tagger.json"
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
config2 = {
|
||||
"ignore_patterns": ["*.log"],
|
||||
"last_folder": "/path2"
|
||||
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_config(config1)
|
||||
save_config(config2)
|
||||
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"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný globální config soubor"""
|
||||
config_path = tmp_path / "config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
def test_load_config_legacy(self, temp_global_config):
|
||||
"""Test že load_config funguje jako alias pro load_global_config"""
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/test"}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_config()
|
||||
assert loaded == config2
|
||||
|
||||
def test_config_path_with_spaces(self, temp_config_file):
|
||||
assert loaded["last_folder"] == "/test"
|
||||
|
||||
def test_save_config_legacy(self, temp_global_config):
|
||||
"""Test že save_config funguje jako alias pro save_global_config"""
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/legacy"}
|
||||
|
||||
save_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["last_folder"] == "/legacy"
|
||||
|
||||
|
||||
class TestConfigEdgeCases:
|
||||
"""Testy pro edge cases"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný globální config soubor"""
|
||||
config_path = tmp_path / "config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
def test_config_path_with_spaces(self, temp_global_config):
|
||||
"""Test s cestou obsahující mezery"""
|
||||
test_config = {
|
||||
"ignore_patterns": [],
|
||||
**DEFAULT_GLOBAL_CONFIG,
|
||||
"last_folder": "/path/with spaces/in name"
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
loaded_config = load_config()
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded_config["last_folder"] == "/path/with spaces/in name"
|
||||
assert loaded["last_folder"] == "/path/with spaces/in name"
|
||||
|
||||
def test_config_long_path(self, temp_config_file):
|
||||
def test_config_long_path(self, temp_global_config):
|
||||
"""Test s dlouhou cestou"""
|
||||
long_path = "/very/long/path/" + "subdir/" * 50 + "final"
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": long_path}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["last_folder"] == long_path
|
||||
|
||||
def test_config_many_recent_folders(self, temp_global_config):
|
||||
"""Test s velkým počtem recent folders"""
|
||||
folders = [f"/path/folder{i}" for i in range(100)]
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
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 = {
|
||||
"ignore_patterns": [],
|
||||
"last_folder": long_path
|
||||
**DEFAULT_FOLDER_CONFIG,
|
||||
"ignore_patterns": ["*.tmp", "file[0-9].txt", "test?.log"]
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
loaded_config = load_config()
|
||||
save_folder_config(tmp_path, test_config)
|
||||
loaded = load_folder_config(tmp_path)
|
||||
|
||||
assert loaded_config["last_folder"] == long_path
|
||||
assert loaded["ignore_patterns"] == test_config["ignore_patterns"]
|
||||
|
||||
def test_config_many_patterns(self, temp_config_file):
|
||||
"""Test s velkým počtem patterns"""
|
||||
patterns = [f"*.ext{i}" for i in range(100)]
|
||||
test_config = {
|
||||
"ignore_patterns": patterns,
|
||||
"last_folder": "/test"
|
||||
}
|
||||
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}
|
||||
|
||||
save_config(test_config)
|
||||
loaded_config = load_config()
|
||||
save_global_config(test_config)
|
||||
|
||||
assert len(loaded_config["ignore_patterns"]) == 100
|
||||
assert loaded_config["ignore_patterns"] == patterns
|
||||
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
def test_config_ensure_ascii_false(self, temp_config_file):
|
||||
# Mělo by být naformátováno s indentací
|
||||
assert " " in content
|
||||
|
||||
def test_config_ensure_ascii_false(self, temp_global_config):
|
||||
"""Test že ensure_ascii=False funguje správně"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["čeština", "русский", "中文"],
|
||||
**DEFAULT_GLOBAL_CONFIG,
|
||||
"last_folder": "/cesta/čeština"
|
||||
}
|
||||
|
||||
save_config(test_config)
|
||||
save_global_config(test_config)
|
||||
|
||||
# Kontrola že znaky nejsou escapovány
|
||||
with open(temp_config_file, "r", encoding="utf-8") as f:
|
||||
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
assert "čeština" in content
|
||||
assert "\\u" not in content # Nemělo by být escapováno
|
||||
|
||||
def test_config_overwrite(self, temp_global_config):
|
||||
"""Test přepsání existující konfigurace"""
|
||||
config1 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path1"}
|
||||
config2 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path2"}
|
||||
|
||||
save_global_config(config1)
|
||||
save_global_config(config2)
|
||||
|
||||
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
|
||||
|
||||
@@ -15,7 +15,7 @@ class TestFileManager:
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, tag_manager):
|
||||
def file_manager(self, tag_manager, temp_global_config):
|
||||
"""Fixture pro FileManager"""
|
||||
return FileManager(tag_manager)
|
||||
|
||||
@@ -35,12 +35,11 @@ class TestFileManager:
|
||||
return tmp_path
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config_file(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný config soubor"""
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný global config soubor"""
|
||||
config_path = tmp_path / "test_config.json"
|
||||
# Změníme CONFIG_FILE v modulu config
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'CONFIG_FILE', config_path)
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
def test_file_manager_creation(self, file_manager, tag_manager):
|
||||
@@ -48,15 +47,19 @@ class TestFileManager:
|
||||
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, temp_config_file):
|
||||
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, temp_config_file):
|
||||
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)
|
||||
|
||||
@@ -68,7 +71,7 @@ class TestFileManager:
|
||||
assert "file3.jpg" in filenames
|
||||
assert "file4.txt" in filenames
|
||||
|
||||
def test_file_manager_ignores_tag_files(self, file_manager, temp_dir, temp_config_file):
|
||||
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": []}')
|
||||
@@ -78,29 +81,212 @@ class TestFileManager:
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert ".file1.txt.!tag" not in filenames
|
||||
|
||||
def test_file_manager_ignore_patterns(self, file_manager, temp_dir, temp_config_file):
|
||||
"""Test ignorování souborů podle patternů"""
|
||||
file_manager.config["ignore_patterns"] = ["*.jpg"]
|
||||
def test_file_manager_ignores_tagger_json(self, file_manager, temp_dir):
|
||||
"""Test že .tagger.json je ignorován"""
|
||||
(temp_dir / ".tagger.json").write_text('{}')
|
||||
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert ".tagger.json" 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_file_manager_ignore_patterns_path(self, file_manager, temp_dir, temp_config_file):
|
||||
def test_ignore_patterns_path(self, file_manager, temp_dir):
|
||||
"""Test ignorování podle celé cesty"""
|
||||
file_manager.config["ignore_patterns"] = ["*/subdir/*"]
|
||||
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_file_manager_assign_tag_to_file_objects(self, file_manager, temp_dir, temp_config_file):
|
||||
"""Test přiřazení tagu k souborům"""
|
||||
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)
|
||||
|
||||
# Vybereme první dva soubory
|
||||
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
|
||||
|
||||
|
||||
class TestFileManagerTagOperations:
|
||||
"""Testy pro operace s tagy"""
|
||||
|
||||
@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]
|
||||
tag = Tag("Video", "HD")
|
||||
|
||||
@@ -109,84 +295,129 @@ class TestFileManager:
|
||||
for f in files:
|
||||
assert tag in f.tags
|
||||
|
||||
def test_file_manager_assign_tag_string(self, file_manager, temp_dir, temp_config_file):
|
||||
"""Test přiřazení tagu jako string"""
|
||||
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]
|
||||
|
||||
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_file_manager_assign_tag_without_category(self, file_manager, temp_dir, temp_config_file):
|
||||
"""Test přiřazení tagu bez kategorie"""
|
||||
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]
|
||||
|
||||
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_file_manager_remove_tag_from_file_objects(self, file_manager, temp_dir, temp_config_file):
|
||||
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]
|
||||
tag = Tag("Video", "HD")
|
||||
|
||||
file_manager.assign_tag_to_file_objects(files, tag)
|
||||
file_manager.assign_tag_to_file_objects(files, tag)
|
||||
|
||||
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]
|
||||
tag = Tag("Video", "HD")
|
||||
|
||||
# Přidání a pak odstranění
|
||||
file_manager.assign_tag_to_file_objects(files, tag)
|
||||
file_manager.remove_tag_from_file_objects(files, tag)
|
||||
|
||||
for f in files:
|
||||
assert tag not in f.tags
|
||||
|
||||
def test_file_manager_remove_tag_string(self, file_manager, temp_dir, temp_config_file):
|
||||
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]
|
||||
|
||||
file_manager.assign_tag_to_file_objects(files, "Video/HD")
|
||||
file_manager.remove_tag_from_file_objects(files, "Video/HD")
|
||||
|
||||
tag_paths = {tag.full_path for tag in files[0].tags}
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_file_manager_filter_files_by_tags_empty(self, file_manager, temp_dir, temp_config_file):
|
||||
def test_callback_on_tag_change(self, file_manager, temp_dir):
|
||||
"""Test callback při změně tagů"""
|
||||
file_manager.append(temp_dir)
|
||||
callback_calls = []
|
||||
|
||||
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"))
|
||||
|
||||
assert len(callback_calls) == 1
|
||||
|
||||
|
||||
class TestFileManagerFiltering:
|
||||
"""Testy pro filtrování souborů"""
|
||||
|
||||
@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_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_file_manager_filter_files_by_tags_none(self, file_manager, temp_dir, temp_config_file):
|
||||
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_file_manager_filter_files_by_single_tag(self, file_manager, temp_dir, temp_config_file):
|
||||
def test_filter_by_single_tag(self, file_manager, temp_dir):
|
||||
"""Test filtrace podle jednoho tagu"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přiřadíme tag některým souborům
|
||||
tag = Tag("Video", "HD")
|
||||
files_to_tag = file_manager.filelist[:2]
|
||||
file_manager.assign_tag_to_file_objects(files_to_tag, tag)
|
||||
|
||||
# Filtrujeme
|
||||
filtered = file_manager.filter_files_by_tags([tag])
|
||||
assert len(filtered) == 2
|
||||
for f in filtered:
|
||||
assert tag in f.tags
|
||||
|
||||
def test_file_manager_filter_files_by_multiple_tags(self, file_manager, temp_dir, temp_config_file):
|
||||
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)
|
||||
|
||||
tag1 = Tag("Video", "HD")
|
||||
tag2 = Tag("Audio", "Stereo")
|
||||
|
||||
@@ -197,87 +428,129 @@ class TestFileManager:
|
||||
# Druhý soubor má jen první tag
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], tag1)
|
||||
|
||||
# Filtrujeme podle obou tagů
|
||||
filtered = file_manager.filter_files_by_tags([tag1, tag2])
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0] == file_manager.filelist[0]
|
||||
|
||||
def test_file_manager_filter_files_by_tag_strings(self, file_manager, temp_dir, temp_config_file):
|
||||
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")
|
||||
|
||||
filtered = file_manager.filter_files_by_tags(["Video/HD"])
|
||||
assert len(filtered) == 1
|
||||
|
||||
def test_file_manager_on_files_changed_callback(self, file_manager, temp_dir, temp_config_file):
|
||||
"""Test callback při změně souborů"""
|
||||
callback_called = []
|
||||
|
||||
def callback(filelist):
|
||||
callback_called.append(filelist)
|
||||
|
||||
file_manager.on_files_changed = callback
|
||||
def test_filter_no_match(self, file_manager, temp_dir):
|
||||
"""Test filtrace když nic neodpovídá"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přiřazení tagu by mělo zavolat callback
|
||||
tag = Tag("Video", "HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag)
|
||||
filtered = file_manager.filter_files_by_tags([Tag("NonExistent", "Tag")])
|
||||
assert len(filtered) == 0
|
||||
|
||||
assert len(callback_called) == 1
|
||||
|
||||
def test_file_manager_complex_scenario(self, file_manager, temp_dir, temp_config_file):
|
||||
"""Test komplexního scénáře"""
|
||||
# Přidání složky
|
||||
file_manager.append(temp_dir)
|
||||
initial_count = len(file_manager.filelist)
|
||||
assert initial_count > 0
|
||||
class TestFileManagerLegacy:
|
||||
"""Testy pro zpětnou kompatibilitu"""
|
||||
|
||||
# Přiřazení různých tagů různým souborům
|
||||
tag_hd = Tag("Video", "HD")
|
||||
tag_4k = Tag("Video", "4K")
|
||||
tag_stereo = Tag("Audio", "Stereo")
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag_hd)
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag_stereo)
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], tag_4k)
|
||||
@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
|
||||
|
||||
# Filtrace podle HD
|
||||
filtered_hd = file_manager.filter_files_by_tags([tag_hd])
|
||||
assert len(filtered_hd) == 1
|
||||
@pytest.fixture
|
||||
def file_manager(self, tag_manager, temp_global_config):
|
||||
return FileManager(tag_manager)
|
||||
|
||||
# Filtrace podle HD + Stereo
|
||||
filtered_both = file_manager.filter_files_by_tags([tag_hd, tag_stereo])
|
||||
assert len(filtered_both) == 1
|
||||
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
|
||||
|
||||
# Filtrace podle 4K
|
||||
filtered_4k = file_manager.filter_files_by_tags([tag_4k])
|
||||
assert len(filtered_4k) == 1
|
||||
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_file_manager_config_last_folder(self, file_manager, temp_dir, temp_config_file):
|
||||
"""Test uložení poslední složky do konfigurace"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
assert file_manager.config["last_folder"] == str(temp_dir)
|
||||
class TestFileManagerEdgeCases:
|
||||
"""Testy pro edge cases"""
|
||||
|
||||
def test_file_manager_empty_filelist(self, file_manager):
|
||||
"""Test práce s prázdným filelistem"""
|
||||
# Test filtrace na prázdném seznamu
|
||||
@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 == []
|
||||
|
||||
# Test přiřazení tagů na prázdný seznam
|
||||
# Přiřazení tagů na prázdný seznam
|
||||
file_manager.assign_tag_to_file_objects([], Tag("Video", "HD"))
|
||||
assert len(file_manager.filelist) == 0
|
||||
|
||||
def test_file_manager_multiple_ignore_patterns(self, file_manager, temp_dir, temp_config_file):
|
||||
"""Test více ignore patternů najednou"""
|
||||
file_manager.config["ignore_patterns"] = ["*.jpg", "*.png", "*/subdir/*"]
|
||||
file_manager.append(temp_dir)
|
||||
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
|
||||
|
||||
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 "file3.jpg" not in filenames
|
||||
assert "file4.txt" not in filenames
|
||||
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
|
||||
|
||||
585
tests/test_hardlink_manager.py
Normal file
585
tests/test_hardlink_manager.py
Normal file
@@ -0,0 +1,585 @@
|
||||
import pytest
|
||||
import os
|
||||
from pathlib import Path
|
||||
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
|
||||
class TestHardlinkManager:
|
||||
"""Testy pro HardlinkManager"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
"""Fixture pro TagManager"""
|
||||
tm = TagManager()
|
||||
# Remove default tags for cleaner tests
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
@pytest.fixture
|
||||
def temp_source_dir(self, tmp_path):
|
||||
"""Fixture pro zdrojovou složku s testovacími soubory"""
|
||||
source_dir = tmp_path / "source"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "file1.txt").write_text("content1")
|
||||
(source_dir / "file2.txt").write_text("content2")
|
||||
(source_dir / "file3.txt").write_text("content3")
|
||||
return source_dir
|
||||
|
||||
@pytest.fixture
|
||||
def temp_output_dir(self, tmp_path):
|
||||
"""Fixture pro výstupní složku"""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
return output_dir
|
||||
|
||||
@pytest.fixture
|
||||
def files_with_tags(self, temp_source_dir, tag_manager):
|
||||
"""Fixture pro soubory s tagy"""
|
||||
files = []
|
||||
|
||||
# File 1 with multiple tags
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear() # Remove default "Stav/Nové" tag
|
||||
f1.add_tag(Tag("žánr", "Komedie"))
|
||||
f1.add_tag(Tag("žánr", "Akční"))
|
||||
f1.add_tag(Tag("rok", "1988"))
|
||||
files.append(f1)
|
||||
|
||||
# File 2 with one tag
|
||||
f2 = File(temp_source_dir / "file2.txt", tag_manager)
|
||||
f2.tags.clear() # Remove default "Stav/Nové" tag
|
||||
f2.add_tag(Tag("žánr", "Drama"))
|
||||
files.append(f2)
|
||||
|
||||
# File 3 with no tags
|
||||
f3 = File(temp_source_dir / "file3.txt", tag_manager)
|
||||
f3.tags.clear() # Remove default "Stav/Nové" tag
|
||||
files.append(f3)
|
||||
|
||||
return files
|
||||
|
||||
def test_hardlink_manager_creation(self, temp_output_dir):
|
||||
"""Test vytvoření HardlinkManager"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
assert manager.output_dir == temp_output_dir
|
||||
assert manager.created_links == []
|
||||
assert manager.errors == []
|
||||
|
||||
def test_create_structure_basic(self, files_with_tags, temp_output_dir):
|
||||
"""Test základního vytvoření struktury"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
# File1 has 3 tags, File2 has 1 tag, File3 has 0 tags
|
||||
# Should create 4 hardlinks total
|
||||
assert success == 4
|
||||
assert fail == 0
|
||||
|
||||
# Check directory structure
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "žánr" / "Akční" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "rok" / "1988" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "žánr" / "Drama" / "file2.txt").exists()
|
||||
|
||||
def test_hardlinks_are_same_inode(self, files_with_tags, temp_output_dir, temp_source_dir):
|
||||
"""Test že vytvořené soubory jsou opravdu hardlinky (stejný inode)"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
original = temp_source_dir / "file1.txt"
|
||||
hardlink = temp_output_dir / "žánr" / "Komedie" / "file1.txt"
|
||||
|
||||
# Same inode = hardlink
|
||||
assert original.stat().st_ino == hardlink.stat().st_ino
|
||||
|
||||
def test_create_structure_with_category_filter(self, files_with_tags, temp_output_dir):
|
||||
"""Test vytvoření struktury jen pro vybrané kategorie"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files(files_with_tags, categories=["žánr"])
|
||||
|
||||
# Only "žánr" tags should be processed (3 links)
|
||||
assert success == 3
|
||||
assert fail == 0
|
||||
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
assert not (temp_output_dir / "rok").exists()
|
||||
|
||||
def test_dry_run(self, files_with_tags, temp_output_dir):
|
||||
"""Test dry run (bez skutečného vytváření)"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files(files_with_tags, dry_run=True)
|
||||
|
||||
assert success == 4
|
||||
assert fail == 0
|
||||
|
||||
# No actual files should be created
|
||||
assert not (temp_output_dir / "žánr").exists()
|
||||
|
||||
def test_get_preview(self, files_with_tags, temp_output_dir):
|
||||
"""Test náhledu co bude vytvořeno"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
preview = manager.get_preview(files_with_tags)
|
||||
|
||||
assert len(preview) == 4
|
||||
|
||||
# Check that preview contains expected paths
|
||||
targets = [p[1] for p in preview]
|
||||
assert temp_output_dir / "žánr" / "Komedie" / "file1.txt" in targets
|
||||
assert temp_output_dir / "žánr" / "Drama" / "file2.txt" in targets
|
||||
|
||||
def test_get_preview_with_category_filter(self, files_with_tags, temp_output_dir):
|
||||
"""Test náhledu s filtrem kategorií"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
preview = manager.get_preview(files_with_tags, categories=["rok"])
|
||||
|
||||
assert len(preview) == 1
|
||||
assert preview[0][1] == temp_output_dir / "rok" / "1988" / "file1.txt"
|
||||
|
||||
def test_remove_created_links(self, files_with_tags, temp_output_dir):
|
||||
"""Test odstranění vytvořených hardlinků"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
# Verify links exist
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
|
||||
# Remove links
|
||||
removed = manager.remove_created_links()
|
||||
assert removed == 4
|
||||
|
||||
# Links should be gone
|
||||
assert not (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
|
||||
# Empty directories should also be removed
|
||||
assert not (temp_output_dir / "žánr" / "Komedie").exists()
|
||||
|
||||
def test_empty_files_list(self, temp_output_dir):
|
||||
"""Test s prázdným seznamem souborů"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([])
|
||||
|
||||
assert success == 0
|
||||
assert fail == 0
|
||||
|
||||
def test_files_without_tags(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||
"""Test se soubory bez tagů"""
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear() # Remove default tags
|
||||
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([f1])
|
||||
|
||||
assert success == 0
|
||||
assert fail == 0
|
||||
|
||||
def test_duplicate_link_same_file(self, files_with_tags, temp_output_dir):
|
||||
"""Test že existující hardlink na stejný soubor je přeskočen"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
|
||||
# Create first time
|
||||
success1, _ = manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
# Create second time - should skip existing
|
||||
manager2 = HardlinkManager(temp_output_dir)
|
||||
success2, fail2 = manager2.create_structure_for_files(files_with_tags)
|
||||
|
||||
# All should be skipped (same inode)
|
||||
assert success2 == 0
|
||||
assert fail2 == 0
|
||||
|
||||
def test_unique_name_on_conflict(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||
"""Test že při konfliktu (jiný soubor) se použije unikátní jméno"""
|
||||
# Create first file
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear()
|
||||
f1.add_tag(Tag("test", "tag"))
|
||||
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files([f1])
|
||||
|
||||
# Create different file with same name in different location
|
||||
source2 = temp_source_dir / "subdir"
|
||||
source2.mkdir()
|
||||
(source2 / "file1.txt").write_text("different content")
|
||||
|
||||
f2 = File(source2 / "file1.txt", tag_manager)
|
||||
f2.tags.clear()
|
||||
f2.add_tag(Tag("test", "tag"))
|
||||
|
||||
# Should create file1_1.txt
|
||||
manager2 = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager2.create_structure_for_files([f2])
|
||||
|
||||
assert success == 1
|
||||
assert (temp_output_dir / "test" / "tag" / "file1_1.txt").exists()
|
||||
|
||||
def test_czech_characters_in_tags(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||
"""Test českých znaků v názvech tagů"""
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear()
|
||||
f1.add_tag(Tag("Žánr", "Česká komedie"))
|
||||
f1.add_tag(Tag("Štítky", "Příběh"))
|
||||
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([f1])
|
||||
|
||||
assert success == 2
|
||||
assert fail == 0
|
||||
assert (temp_output_dir / "Žánr" / "Česká komedie" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "Štítky" / "Příběh" / "file1.txt").exists()
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
"""Testy pro convenience funkci create_hardlink_structure"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
tm = TagManager()
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
@pytest.fixture
|
||||
def temp_files(self, tmp_path, tag_manager):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
return [f]
|
||||
|
||||
def test_create_hardlink_structure_function(self, temp_files, tmp_path):
|
||||
"""Test convenience funkce"""
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
success, fail, errors = create_hardlink_structure(temp_files, output)
|
||||
|
||||
assert success == 1
|
||||
assert fail == 0
|
||||
assert len(errors) == 0
|
||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||
|
||||
def test_create_hardlink_structure_with_categories(self, tmp_path, tag_manager):
|
||||
"""Test convenience funkce s filtrem kategorií"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("include", "yes"))
|
||||
f.add_tag(Tag("exclude", "no"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
success, fail, errors = create_hardlink_structure([f], output, categories=["include"])
|
||||
|
||||
assert success == 1
|
||||
assert (output / "include" / "yes" / "file.txt").exists()
|
||||
assert not (output / "exclude").exists()
|
||||
|
||||
|
||||
class TestSyncStructure:
|
||||
"""Testy pro synchronizaci hardlink struktury"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
tm = TagManager()
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
@pytest.fixture
|
||||
def setup_dirs(self, tmp_path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
return source, output
|
||||
|
||||
def test_find_obsolete_links_empty_output(self, setup_dirs, tag_manager):
|
||||
"""Test find_obsolete_links s prázdným výstupem"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
obsolete = manager.find_obsolete_links([f])
|
||||
|
||||
assert obsolete == []
|
||||
|
||||
def test_find_obsolete_links_detects_removed_tag(self, setup_dirs, tag_manager):
|
||||
"""Test že find_obsolete_links najde hardlink pro odebraný tag"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
f.add_tag(Tag("cat", "tag2"))
|
||||
|
||||
# Create structure with both tags
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
assert (output / "cat" / "tag1" / "file.txt").exists()
|
||||
assert (output / "cat" / "tag2" / "file.txt").exists()
|
||||
|
||||
# Remove one tag from file
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1")) # Only tag1 remains
|
||||
|
||||
# Find obsolete
|
||||
obsolete = manager.find_obsolete_links([f])
|
||||
|
||||
assert len(obsolete) == 1
|
||||
assert obsolete[0][0] == output / "cat" / "tag2" / "file.txt"
|
||||
|
||||
def test_remove_obsolete_links(self, setup_dirs, tag_manager):
|
||||
"""Test odstranění zastaralých hardlinků"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
f.add_tag(Tag("cat", "tag2"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Remove tag2
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
|
||||
# Remove obsolete links
|
||||
removed, paths = manager.remove_obsolete_links([f])
|
||||
|
||||
assert removed == 1
|
||||
assert not (output / "cat" / "tag2" / "file.txt").exists()
|
||||
assert (output / "cat" / "tag1" / "file.txt").exists()
|
||||
|
||||
def test_remove_obsolete_links_dry_run(self, setup_dirs, tag_manager):
|
||||
"""Test dry run pro remove_obsolete_links"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
f.add_tag(Tag("cat", "tag2"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
|
||||
removed, paths = manager.remove_obsolete_links([f], dry_run=True)
|
||||
|
||||
assert removed == 1
|
||||
# File should still exist (dry run)
|
||||
assert (output / "cat" / "tag2" / "file.txt").exists()
|
||||
|
||||
def test_sync_structure_creates_and_removes(self, setup_dirs, tag_manager):
|
||||
"""Test sync_structure vytvoří nové a odstraní staré hardlinky"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "old_tag"))
|
||||
|
||||
# Create initial structure
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
assert (output / "cat" / "old_tag" / "file.txt").exists()
|
||||
|
||||
# Change tags
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "new_tag"))
|
||||
|
||||
# Sync
|
||||
created, c_fail, removed, r_fail = manager.sync_structure([f])
|
||||
|
||||
assert created == 1
|
||||
assert removed == 1
|
||||
assert c_fail == 0
|
||||
assert r_fail == 0
|
||||
assert not (output / "cat" / "old_tag").exists()
|
||||
assert (output / "cat" / "new_tag" / "file.txt").exists()
|
||||
|
||||
def test_sync_structure_no_changes_needed(self, setup_dirs, tag_manager):
|
||||
"""Test sync_structure když není potřeba žádná změna"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Sync again without changes
|
||||
created, c_fail, removed, r_fail = manager.sync_structure([f])
|
||||
|
||||
# Nothing should change (existing links are skipped)
|
||||
assert removed == 0
|
||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||
|
||||
def test_find_obsolete_with_category_filter(self, setup_dirs, tag_manager):
|
||||
"""Test find_obsolete_links s filtrem kategorií"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat1", "tag"))
|
||||
f.add_tag(Tag("cat2", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Remove both tags
|
||||
f.tags.clear()
|
||||
|
||||
# Find obsolete only in cat1
|
||||
obsolete = manager.find_obsolete_links([f], categories=["cat1"])
|
||||
|
||||
assert len(obsolete) == 1
|
||||
assert obsolete[0][0] == output / "cat1" / "tag" / "file.txt"
|
||||
|
||||
def test_removes_empty_directories(self, setup_dirs, tag_manager):
|
||||
"""Test že prázdné adresáře jsou odstraněny po sync"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("category", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Remove all tags
|
||||
f.tags.clear()
|
||||
|
||||
manager.remove_obsolete_links([f])
|
||||
|
||||
# Directory should be gone
|
||||
assert not (output / "category" / "tag").exists()
|
||||
assert not (output / "category").exists()
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Testy pro okrajové případy"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
tm = TagManager()
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
def test_nonexistent_output_dir_created(self, tmp_path, tag_manager):
|
||||
"""Test že výstupní složka je vytvořena pokud neexistuje"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
output = tmp_path / "output" / "nested" / "deep"
|
||||
# output doesn't exist
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
success, fail = manager.create_structure_for_files([f])
|
||||
|
||||
assert success == 1
|
||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||
|
||||
def test_special_characters_in_filename(self, tmp_path, tag_manager):
|
||||
"""Test souboru se speciálními znaky v názvu"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file with spaces (2024).txt").write_text("content")
|
||||
|
||||
f = File(source / "file with spaces (2024).txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("test", "tag"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
success, fail = manager.create_structure_for_files([f])
|
||||
|
||||
assert success == 1
|
||||
assert (output / "test" / "tag" / "file with spaces (2024).txt").exists()
|
||||
|
||||
def test_empty_category_filter(self, tmp_path, tag_manager):
|
||||
"""Test s prázdným seznamem kategorií"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
# Empty list = no categories = no links
|
||||
success, fail = manager.create_structure_for_files([f], categories=[])
|
||||
|
||||
assert success == 0
|
||||
|
||||
def test_is_same_file_method(self, tmp_path):
|
||||
"""Test metody _is_same_file"""
|
||||
file1 = tmp_path / "file1.txt"
|
||||
file1.write_text("content")
|
||||
|
||||
link = tmp_path / "link.txt"
|
||||
os.link(file1, link)
|
||||
|
||||
file2 = tmp_path / "file2.txt"
|
||||
file2.write_text("different")
|
||||
|
||||
manager = HardlinkManager(tmp_path)
|
||||
|
||||
# Same inode
|
||||
assert manager._is_same_file(file1, link) is True
|
||||
|
||||
# Different inode
|
||||
assert manager._is_same_file(file1, file2) is False
|
||||
|
||||
# Non-existent file
|
||||
assert manager._is_same_file(file1, tmp_path / "nonexistent") is False
|
||||
|
||||
def test_get_unique_name_method(self, tmp_path):
|
||||
"""Test metody _get_unique_name"""
|
||||
(tmp_path / "file.txt").write_text("1")
|
||||
(tmp_path / "file_1.txt").write_text("2")
|
||||
(tmp_path / "file_2.txt").write_text("3")
|
||||
|
||||
manager = HardlinkManager(tmp_path)
|
||||
unique = manager._get_unique_name(tmp_path / "file.txt")
|
||||
|
||||
assert unique == tmp_path / "file_3.txt"
|
||||
@@ -1,5 +1,5 @@
|
||||
import pytest
|
||||
from src.core.tag_manager import TagManager
|
||||
from src.core.tag_manager import TagManager, DEFAULT_TAGS
|
||||
from src.core.tag import Tag
|
||||
|
||||
|
||||
@@ -11,9 +11,26 @@ class TestTagManager:
|
||||
"""Fixture pro vytvoření TagManager instance"""
|
||||
return TagManager()
|
||||
|
||||
def test_tag_manager_creation(self, tag_manager):
|
||||
"""Test vytvoření TagManager"""
|
||||
assert tag_manager.tags_by_category == {}
|
||||
@pytest.fixture
|
||||
def empty_tag_manager(self):
|
||||
"""Fixture pro prázdný TagManager (bez default tagů)"""
|
||||
tm = TagManager()
|
||||
# Odstranit default tagy pro testy které potřebují prázdný manager
|
||||
for category in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(category)
|
||||
return tm
|
||||
|
||||
def test_tag_manager_creation_has_defaults(self, tag_manager):
|
||||
"""Test vytvoření TagManager obsahuje default tagy"""
|
||||
assert "Hodnocení" in tag_manager.tags_by_category
|
||||
assert "Barva" in tag_manager.tags_by_category
|
||||
|
||||
def test_tag_manager_default_tags_count(self, tag_manager):
|
||||
"""Test počtu default tagů"""
|
||||
# Hodnocení má 5 hvězdiček
|
||||
assert len(tag_manager.tags_by_category["Hodnocení"]) == 5
|
||||
# Barva má 6 barev
|
||||
assert len(tag_manager.tags_by_category["Barva"]) == 6
|
||||
|
||||
def test_add_category(self, tag_manager):
|
||||
"""Test přidání kategorie"""
|
||||
@@ -21,11 +38,11 @@ class TestTagManager:
|
||||
assert "Video" in tag_manager.tags_by_category
|
||||
assert tag_manager.tags_by_category["Video"] == set()
|
||||
|
||||
def test_add_category_duplicate(self, tag_manager):
|
||||
def test_add_category_duplicate(self, empty_tag_manager):
|
||||
"""Test přidání duplicitní kategorie"""
|
||||
tag_manager.add_category("Video")
|
||||
tag_manager.add_category("Video")
|
||||
assert len(tag_manager.tags_by_category) == 1
|
||||
empty_tag_manager.add_category("Video")
|
||||
empty_tag_manager.add_category("Video")
|
||||
assert len(empty_tag_manager.tags_by_category) == 1
|
||||
|
||||
def test_remove_category(self, tag_manager):
|
||||
"""Test odstranění kategorie"""
|
||||
@@ -107,40 +124,52 @@ class TestTagManager:
|
||||
# Nemělo by vyhodit výjimku
|
||||
tag_manager.remove_tag("Neexistující", "Tag")
|
||||
|
||||
def test_get_all_tags_empty(self, tag_manager):
|
||||
def test_get_all_tags_empty(self, empty_tag_manager):
|
||||
"""Test získání všech tagů (prázdný manager)"""
|
||||
tags = tag_manager.get_all_tags()
|
||||
tags = empty_tag_manager.get_all_tags()
|
||||
assert tags == []
|
||||
|
||||
def test_get_all_tags(self, tag_manager):
|
||||
def test_get_all_tags(self, empty_tag_manager):
|
||||
"""Test získání všech tagů"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "4K")
|
||||
tag_manager.add_tag("Audio", "MP3")
|
||||
empty_tag_manager.add_tag("Video", "HD")
|
||||
empty_tag_manager.add_tag("Video", "4K")
|
||||
empty_tag_manager.add_tag("Audio", "MP3")
|
||||
|
||||
tags = tag_manager.get_all_tags()
|
||||
tags = empty_tag_manager.get_all_tags()
|
||||
assert len(tags) == 3
|
||||
assert "Video/HD" in tags
|
||||
assert "Video/4K" in tags
|
||||
assert "Audio/MP3" in tags
|
||||
|
||||
def test_get_categories_empty(self, tag_manager):
|
||||
def test_get_all_tags_includes_defaults(self, tag_manager):
|
||||
"""Test že get_all_tags obsahuje default tagy"""
|
||||
tags = tag_manager.get_all_tags()
|
||||
# Minimálně 11 default tagů (5 hodnocení + 6 barev)
|
||||
assert len(tags) >= 11
|
||||
|
||||
def test_get_categories_empty(self, empty_tag_manager):
|
||||
"""Test získání kategorií (prázdný manager)"""
|
||||
categories = tag_manager.get_categories()
|
||||
categories = empty_tag_manager.get_categories()
|
||||
assert categories == []
|
||||
|
||||
def test_get_categories(self, tag_manager):
|
||||
def test_get_categories(self, empty_tag_manager):
|
||||
"""Test získání kategorií"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Audio", "MP3")
|
||||
tag_manager.add_tag("Foto", "RAW")
|
||||
empty_tag_manager.add_tag("Video", "HD")
|
||||
empty_tag_manager.add_tag("Audio", "MP3")
|
||||
empty_tag_manager.add_tag("Foto", "RAW")
|
||||
|
||||
categories = tag_manager.get_categories()
|
||||
categories = empty_tag_manager.get_categories()
|
||||
assert len(categories) == 3
|
||||
assert "Video" in categories
|
||||
assert "Audio" in categories
|
||||
assert "Foto" in categories
|
||||
|
||||
def test_get_categories_includes_defaults(self, tag_manager):
|
||||
"""Test že get_categories obsahuje default kategorie"""
|
||||
categories = tag_manager.get_categories()
|
||||
assert "Hodnocení" in categories
|
||||
assert "Barva" in categories
|
||||
|
||||
def test_get_tags_in_category_empty(self, tag_manager):
|
||||
"""Test získání tagů z prázdné kategorie"""
|
||||
tag_manager.add_category("Video")
|
||||
@@ -166,27 +195,29 @@ class TestTagManager:
|
||||
tags = tag_manager.get_tags_in_category("Neexistující")
|
||||
assert tags == []
|
||||
|
||||
def test_complex_scenario(self, tag_manager):
|
||||
def test_complex_scenario(self, empty_tag_manager):
|
||||
"""Test komplexního scénáře použití"""
|
||||
tm = empty_tag_manager
|
||||
|
||||
# Přidání několika kategorií a tagů
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "4K")
|
||||
tag_manager.add_tag("Audio", "MP3")
|
||||
tag_manager.add_tag("Audio", "FLAC")
|
||||
tag_manager.add_tag("Foto", "RAW")
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Audio", "MP3")
|
||||
tm.add_tag("Audio", "FLAC")
|
||||
tm.add_tag("Foto", "RAW")
|
||||
|
||||
# Kontrola stavu
|
||||
assert len(tag_manager.get_categories()) == 3
|
||||
assert len(tag_manager.get_all_tags()) == 5
|
||||
assert len(tm.get_categories()) == 3
|
||||
assert len(tm.get_all_tags()) == 5
|
||||
|
||||
# Odstranění některých tagů
|
||||
tag_manager.remove_tag("Video", "HD")
|
||||
assert len(tag_manager.get_tags_in_category("Video")) == 1
|
||||
tm.remove_tag("Video", "HD")
|
||||
assert len(tm.get_tags_in_category("Video")) == 1
|
||||
|
||||
# Odstranění celé kategorie
|
||||
tag_manager.remove_category("Foto")
|
||||
assert "Foto" not in tag_manager.get_categories()
|
||||
assert len(tag_manager.get_all_tags()) == 3
|
||||
tm.remove_category("Foto")
|
||||
assert "Foto" not in tm.get_categories()
|
||||
assert len(tm.get_all_tags()) == 3
|
||||
|
||||
def test_tag_uniqueness_in_set(self, tag_manager):
|
||||
"""Test že tagy jsou správně ukládány jako set (bez duplicit)"""
|
||||
@@ -196,3 +227,73 @@ class TestTagManager:
|
||||
|
||||
# I když přidáme 3x, v setu je jen 1
|
||||
assert len(tag_manager.tags_by_category["Video"]) == 1
|
||||
|
||||
|
||||
class TestDefaultTags:
|
||||
"""Testy pro defaultní tagy"""
|
||||
|
||||
def test_default_tags_constant_exists(self):
|
||||
"""Test že DEFAULT_TAGS konstanta existuje"""
|
||||
assert DEFAULT_TAGS is not None
|
||||
assert isinstance(DEFAULT_TAGS, dict)
|
||||
|
||||
def test_default_tags_has_hodnoceni(self):
|
||||
"""Test že DEFAULT_TAGS obsahuje Hodnocení"""
|
||||
assert "Hodnocení" in DEFAULT_TAGS
|
||||
assert len(DEFAULT_TAGS["Hodnocení"]) == 5
|
||||
|
||||
def test_default_tags_has_barva(self):
|
||||
"""Test že DEFAULT_TAGS obsahuje Barva"""
|
||||
assert "Barva" in DEFAULT_TAGS
|
||||
assert len(DEFAULT_TAGS["Barva"]) == 6
|
||||
|
||||
def test_hodnoceni_stars_content(self):
|
||||
"""Test obsahu hvězdiček v Hodnocení"""
|
||||
stars = DEFAULT_TAGS["Hodnocení"]
|
||||
assert "⭐" in stars
|
||||
assert "⭐⭐⭐⭐⭐" in stars
|
||||
|
||||
def test_barva_colors_content(self):
|
||||
"""Test obsahu barev v Barva"""
|
||||
colors = DEFAULT_TAGS["Barva"]
|
||||
# Kontrolujeme že obsahuje některé barvy
|
||||
color_names = " ".join(colors)
|
||||
assert "Červená" in color_names
|
||||
assert "Zelená" in color_names
|
||||
assert "Modrá" in color_names
|
||||
|
||||
def test_tag_manager_loads_all_default_tags(self):
|
||||
"""Test že TagManager načte všechny default tagy"""
|
||||
tm = TagManager()
|
||||
|
||||
for category, tag_names in DEFAULT_TAGS.items():
|
||||
assert category in tm.tags_by_category
|
||||
tags_in_category = tm.get_tags_in_category(category)
|
||||
assert len(tags_in_category) == len(tag_names)
|
||||
|
||||
def test_can_add_custom_tags_alongside_defaults(self):
|
||||
"""Test že lze přidat vlastní tagy vedle defaultních"""
|
||||
tm = TagManager()
|
||||
initial_count = len(tm.get_all_tags())
|
||||
|
||||
tm.add_tag("Custom", "MyTag")
|
||||
|
||||
assert len(tm.get_all_tags()) == initial_count + 1
|
||||
assert "Custom" in tm.get_categories()
|
||||
|
||||
def test_can_remove_default_category(self):
|
||||
"""Test že lze odstranit default kategorii"""
|
||||
tm = TagManager()
|
||||
tm.remove_category("Hodnocení")
|
||||
|
||||
assert "Hodnocení" not in tm.tags_by_category
|
||||
assert "Barva" in tm.tags_by_category # Druhá zůstává
|
||||
|
||||
def test_can_add_tag_to_default_category(self):
|
||||
"""Test že lze přidat tag do default kategorie"""
|
||||
tm = TagManager()
|
||||
initial_count = len(tm.get_tags_in_category("Hodnocení"))
|
||||
|
||||
tm.add_tag("Hodnocení", "Custom Rating")
|
||||
|
||||
assert len(tm.get_tags_in_category("Hodnocení")) == initial_count + 1
|
||||
|
||||
Reference in New Issue
Block a user