Hardlink generation added

This commit is contained in:
2025-12-28 16:05:34 +01:00
parent fe529ecfdd
commit aab50864c3
21 changed files with 3392 additions and 2343 deletions

66
CHANGELOG.md Normal file
View 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

View File

@@ -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)
---

View File

@@ -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()

View File

@@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -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
}

View File

@@ -2,7 +2,8 @@
"new": true,
"ignored": false,
"tags": [
"Rozlišení/4K"
"Rozlišení/4K",
"Barva/🟣 Fialová"
],
"date": "2025-09-15"
}

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
# src/core/constants.py
VERSION = "v1.0.2"
VERSION = "v1.0.3"
APP_NAME = "Tagger"
APP_VIEWPORT = "1000x700"

View File

@@ -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

View 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

View File

@@ -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:

File diff suppressed because it is too large Load Diff

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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"

View File

@@ -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