4 Commits

39 changed files with 5160 additions and 2560 deletions

52
.gitignore vendored
View File

@@ -1,10 +1,48 @@
.venv
__pycache__
.pytest_cache
build
.claude
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
# Config a temp soubory
# Virtual environments
.venv/
venv/
env/
# Distribution / packaging
build/
dist/
*.egg-info/
*.spec.bak
# Testing
.pytest_cache/
.mypy_cache/
.coverage
htmlcov/
# Environment
.env
*.env.local
# IDE
.vscode/settings.json
.idea/
# Claude
.claude/
.claudeignore
# App temp / tag soubory
*.!tag
*.!ftag
*.!gtag
*.!gtag
# Data samples (binary/media, not source)
data/samples/
# Documentation not for commit
DESIGN_DOCUMENT.md
AGENTS.md
TEMPLATE.md
CLAUDE.md

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

View File

@@ -6,5 +6,7 @@
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
"python.testing.pytestEnabled": true,
"python-envs.defaultEnvManager": "ms-python.python:poetry",
"python-envs.defaultPackageManager": "ms-python.python:poetry"
}

View File

@@ -1,68 +1,173 @@
# Changelog
Všechny významné změny v projektu Tagger jsou dokumentovány v tomto souboru.
All notable changes to the Tagger project are documented in this file.
## [1.2.0] - 2026-04-09
### Added
- **Undo/redo** - `Ctrl+Z` / `Ctrl+Y` vrátí/zopakuje tag operace (assign, remove, rename, merge)
- Zásobník 50 kroků, čistí se při zavření složky
- Edit menu zobrazuje popis poslední operace
- Pokrývá: přiřazení tagu, odebrání tagu, přejmenování tagu/kategorie, sloučení tagu/kategorie
- **CSFD cache** - Movie data cached in `.!tag` after first fetch, no re-fetching on reopen
- `CSFDMovie.to_dict()` / `from_dict()` for serialization
- `File.get_cached_movie()` - returns cached data without network access
- Cache versioning (`CSFD_CACHE_VERSION`) for future schema invalidation
- Cache invalidated automatically when CSFD URL changes
- **Orphaned sidecar detection** - On folder scan, `.!tag` files without a matching media file are reported
- `FileManager.on_orphaned_tags` callback for UI notification
- `FileManager.find_orphaned_tags()` for manual scan
- **OR/NOT tag filtering** - Extended `filter_files_by_tags()` with new parameters
- `any_of` - file must have at least one of these tags (OR)
- `must_not` - file must not have any of these tags (NOT)
- Fully backward compatible
- **ffprobe threading** - Video resolution detection now runs in background (`VideoResolutionWorker`)
- Status bar shows live progress: `Zjišťuji rozlišení (3/12)…`
- Menu action disabled during processing
- **Export to CSV** - Soubor → "Exportovat do CSV..."
- UTF-8 BOM encoding (Excel compatible)
- Columns: filename, path, date, tags, CSFD URL, size
- **Drag & drop** - Drag folder or file onto the app window to open it
### Changed
- **Global config location** - Moved from app directory to `~/.config/Tagger/`
- Automatic one-time migration from old location
- Fixes crash on read-only PyInstaller build directories
- **Python version** - Bumped to 3.14+
### Dependencies
- Removed: Pillow (unused)
- Moved: python-dotenv from dev to runtime dependencies
### Tests
- 274 tests (all passing)
## [1.1.0] - 2026-01-23
### Changed
- **GUI rewrite to PySide6/Qt6** - Complete UI rewrite from Tkinter to Qt
- Modern QMainWindow with menu bar, toolbar, and status bar
- QTreeWidget for tag sidebar with category colors
- QTableWidget for file list with sorting and filtering
- QSplitter for resizable sidebar
- Native Qt dialogs (QFileDialog, QInputDialog, QMessageBox)
- Keyboard shortcuts using QShortcut
- Window geometry persistence
- **UI utilities updated** - `src/ui/utils.py` now uses Qt (QIcon, QPixmap)
- **Python version restricted** - Requires Python >=3.13,<3.15 for PySide6 compatibility
### Dependencies
- Added PySide6 (>=6.10.1)
- Removed Tkinter dependency
## [1.0.5] - 2026-01-23
### Added
- **Tag and category renaming** - New context menu functionality
- Right-click on tag → "Rename tag"
- Automatic update of all files with the tag
- Support for renaming entire categories
- **Tag merging** - When renaming to an existing tag
- Confirmation dialog for merge
- Merge removes source tag and updates files
- **Tag.from_string()** - New class method for parsing tags
- Parses "category/name" format
- Eliminates duplicate code across the project
- **Dynamic version loading** - Version is loaded from pyproject.toml
- Fallback to `_version.py` if toml is not available
- DEBUG mode support from `.env` (adds " DEV" suffix)
- APP_NAME includes version: "Tagger v1.0.5 DEV"
- **UI utilities module** - `src/ui/utils.py`
- Moved `load_icon()` function from core to UI layer
### Changed
- **FileManager refactoring**
- New methods `assign_tag_to_files()` and `remove_tag_from_files()`
- Old methods kept as deprecated aliases
- `Tag` import at module level (eliminates duplicate imports)
- **Dead code removal**
- Deleted unused `ListManager` module
- Removed legacy functions `load_config()` and `save_config()` from config.py
- **Missing import fix** - Added `import subprocess` to media_utils.py
### Tests
- 274 tests (all passing)
- New tests for Tag.from_string() (6 tests)
- New tests for rename/merge tags (24 tests)
## [1.0.4] - 2025-12-29
### Added
- **CSFD.cz integration** - Fetching movie information
- `fetch_movie()` - load movie details from URL
- `search_movies()` - search for movies
- Automatic tag assignment (genres, year, country, director)
- **Close folder** - Safe folder closing with metadata saving
### Tests
- 249 tests covering CSFD integration
## [1.0.3] - 2025-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
- **Poetry** - Správa závislostí pomocí Poetry
### Added
- **Hardlink structure** - New functionality for creating directory structure using hardlinks
- `HardlinkManager` class in `src/core/hardlink_manager.py`
- Creating hardlinks based on file tags (e.g., `output/genre/Comedy/movie.mkv`)
- Structure synchronization - detection and removal of outdated hardlinks when tags change
- Support for filtering by categories
- Preview mode (dry run)
- **Menu items for hardlinks**
- "Set hardlink folder..." - configure output folder and categories (saved to `.tagger.json`)
- "Update hardlink structure" - quick sync with saved settings
- "Create hardlink structure..." - manual folder and category selection
- **Three-level configuration system**
- Global config (`config.json`) - application settings (window geometry, last folder)
- Folder config (`.tagger.json`) - project settings (ignore patterns, hardlink settings)
- File tags (`.filename.!tag`) - individual file metadata
- **Default tags**
- "Rating" category with stars (1-5 stars)
- "Color" category with color labels
- Exclusive selection in Rating category (only one tag)
- **Tests**
- 189 tests covering all modules
- Tests for hardlink manager including synchronization
- **Poetry** - Dependency management using Poetry
### 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
### Changed
- Modernized GUI inspired by qBittorrent
- Window geometry saved to global config
- Ignore patterns saved to folder config
## [1.0.2] - 2025-10-03
### 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** - Automatic 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
### Added
- **Modern GUI** - Redesigned interface in qBittorrent style
- Side panel with categories and tags
- File table with column sorting
- Context menus for files and tags
- Search field
- Status bar with file count and selection size
- **Bulk tag assignment** - Dialog for assigning tags to multiple files at once
- Tri-state checkboxes (checked/unchecked/mixed)
- Color-coded categories
- **Video resolution detection** - Automatic detection using ffprobe
- **Keyboard shortcuts**
- Ctrl+O - Open folder
- Ctrl+T - Assign tags
- Ctrl+D - Set date
- F5 - Refresh
- Delete - Remove from index
### Změněno
- Refaktorizace struktury projektu do modulů (`src/core/`, `src/ui/`)
- Použití dataclass pro Tag a File objekty
### Changed
- Project structure refactored into modules (`src/core/`, `src/ui/`)
- Using dataclass for Tag and File objects
## [1.0.0] - 2025-09-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
### Added
- Basic file tagging functionality
- Storing tags in hidden files (`.filename.!tag`)
- Category and tag management
- Recursive folder scanning
- Ignore patterns for file filtering
- Basic GUI in Tkinter

136
PROJECT.md Normal file
View File

@@ -0,0 +1,136 @@
# Tagger - Project Documentation
**Version:** 1.2.0 | **Status:** Stable | **GUI:** PySide6/Qt6
---
## About
Desktop app for organizing files using hierarchical tags (category/name).
**Features:** Folder scanning, tag filtering (AND/OR/NOT), rename/merge tags, CSFD.cz integration
(with local cache), hardlink structure, 3-level config (global/folder/file), orphaned sidecar detection.
---
## Structure
```
Tagger/
├── Tagger.py # Entry point
├── src/core/ # Business logic (NO UI imports!)
│ ├── tag.py # Tag value object (immutable)
│ ├── tag_manager.py # Tag/category management
│ ├── file.py # File with metadata + CSFD cache
│ ├── file_manager.py # File management, filtering, orphan detection
│ ├── config.py # 3-level config system
│ ├── hardlink_manager.py
│ ├── csfd.py # CSFD scraper + CSFDMovie serialization
│ ├── media_utils.py # ffprobe integration
│ ├── constants.py # APP_NAME, VERSION
│ └── _version.py # Version fallback for PyInstaller
├── src/ui/
│ ├── gui.py # Qt6 GUI (MainWindow)
│ └── utils.py # load_icon()
└── tests/ # 274 tests
```
---
## Architecture Rules
1. **UI must not contain business logic** — call FileManager/TagManager
2. **Core must not import UI** — no PySide6 in src/core/
3. **Dependency injection** — pass via constructor
4. **UTF-8 everywhere**`encoding='utf-8'`, `ensure_ascii=False`
---
## Config Files
| Level | File | Location | Contents |
|-------|------|----------|----------|
| Global | `.Tagger.!gtag` | `~/.config/Tagger/` | window geometry, last folder, recent folders |
| Folder | `.Tagger.!ftag` | project folder | ignore patterns, hardlink settings |
| File | `.filename.!tag` | same dir as file | tags, date, csfd_url, csfd_cache |
---
## Key Components
**Tag** — immutable, `Tag(category, name)`, `Tag.from_string("cat/name")`
**File**`file_path`, `tags[]`, `date`, `csfd_url`, `csfd_cache`, metadata in `.filename.!tag`
- `apply_csfd_tags()` — fetch + cache CSFD data
- `get_cached_movie()` — return CSFDMovie from cache (no network)
- `set_csfd_url()` — invalidates cache on URL change
**TagManager**`add_tag()`, `get_categories()`, `rename_tag()`, `merge_tag()`
**FileManager**`append(folder)`, `filter_files_by_tags()`, `close_folder()`
- `on_orphaned_tags` callback — fires when orphaned `.!tag` sidecars are found
- `find_orphaned_tags()` — manual scan for orphaned sidecars
**HardlinkManager**`create_structure_for_files()`, `sync_structure()`
**CSFDMovie**`to_dict()` / `from_dict()` for cache serialization
---
## Filtering
```python
# AND (default — all must match)
fm.filter_files_by_tags(["Žánr/Drama", "Rok/1990"])
# OR — at least one must match
fm.filter_files_by_tags(any_of=["Žánr/Drama", "Žánr/Thriller"])
# NOT — none of these
fm.filter_files_by_tags(must_not=["Stav/Nové"])
# Combined
fm.filter_files_by_tags(
must_have=["Žánr/Drama"],
any_of=["Rok/1990", "Rok/1991"],
must_not=["Stav/Nové"],
)
```
---
## Running
```bash
poetry run python Tagger.py # GUI
poetry run pytest -q # Tests
poetry run pyinstaller --onefile Tagger.py # Build
```
---
## Shortcuts
`Ctrl+O` Open | `Ctrl+T` Tags | `Ctrl+D` Date | `Ctrl+W` Close | `F5` Refresh | `Del` Remove
---
## Debugging
**Version 0.0.0 in build:** Run app once from source to update `_version.py`, then rebuild.
**Cannot import:** Use `poetry run python Tagger.py`
**Metadata corrupted:** Auto-recovers with defaults.
**Config not saved:** Check `~/.config/Tagger/` exists and is writable.
---
---
## Metrics
- **Tests:** 274 ✅
- **Python:** 3.14+
- **Dependencies:** PySide6, requests, beautifulsoup4, loguru, python-dotenv

View File

@@ -1,515 +0,0 @@
# Tagger - Centrální Poznámky Projektu
> **DŮLEŽITÉ:** Tento soubor obsahuje VŠE co potřebuji vědět o projektu.
> Pokud pracuji na Tagger, VŽDY nejdříve přečtu tento soubor!
**Poslední aktualizace:** 2025-12-28
**Verze:** 1.0.3
**Status:** Stable, v aktivním vývoji
---
## O projektu
**Tagger** je desktopová aplikace pro správu a organizaci souborů pomocí hierarchických tagů (štítků).
**Hlavní funkce:**
- Rekurzivní procházení složek
- Hierarchické tagy (kategorie/název)
- Filtrování podle tagů
- Metadata uložená v JSON souborech
- Automatická detekce rozlišení videí (ffprobe)
- Moderní GUI (qBittorrent-style)
- Hardlink struktura - vytváření adresářové struktury pomocí hardlinků podle tagů
- Tříúrovňový konfigurační systém (globální, složkový, souborový)
---
## Struktura projektu
```
Tagger/
├── Tagger.py # Entry point
├── PROJECT_NOTES.md # ← TENTO SOUBOR - HLAVNÍ ZDROJ PRAVDY
├── CHANGELOG.md # Historie verzí
├── pyproject.toml # Poetry konfigurace
├── poetry.lock # Zamčené verze závislostí
├── .gitignore # Git ignore pravidla
├── src/
│ ├── core/ # Jádro aplikace (ŽÁDNÉ UI!)
│ │ ├── tag.py # Tag value object (immutable)
│ │ ├── tag_manager.py # Správa tagů a kategorií
│ │ ├── file.py # File s metadaty
│ │ ├── file_manager.py # Správa souborů, filtrování
│ │ ├── config.py # Tříúrovňová konfigurace (global, folder, file)
│ │ ├── hardlink_manager.py # Správa hardlink struktury
│ │ ├── utils.py # list_files() - rekurzivní procházení
│ │ ├── media_utils.py # load_icon(), ffprobe
│ │ ├── constants.py # APP_NAME, VERSION, APP_VIEWPORT
│ │ └── list_manager.py # Třídění (málo používaný)
│ │
│ └── ui/
│ └── gui.py # Moderní qBittorrent-style GUI
├── tests/ # 189 testů, 100% core coverage
│ ├── __init__.py
│ ├── conftest.py # Pytest fixtures
│ ├── test_tag.py # 13 testů
│ ├── test_tag_manager.py # 31 testů
│ ├── test_file.py # 22 testů
│ ├── test_file_manager.py # 40 testů
│ ├── test_config.py # 33 testů
│ ├── test_hardlink_manager.py # 28 testů
│ ├── test_utils.py # 17 testů
│ └── test_media_utils.py # 3 testy
├── src/resources/
│ └── images/32/ # Ikony (32x32 PNG)
│ ├── 32_unchecked.png
│ ├── 32_checked.png
│ └── 32_tag.png
└── data/samples/ # Testovací data
```
---
## Architektura
### Vrstvová struktura
```
┌─────────────────────────────────┐
│ Presentation (UI) │ ← gui.py
│ - Tkinter GUI │ - NESMÍ obsahovat business logiku
│ - Jen zobrazení + interakce │ - NESMÍ importovat přímo z core
├─────────────────────────────────┤
│ Business Logic │ ← FileManager, TagManager, HardlinkManager
│ - Správa souborů/tagů │ - Callable z UI
│ - Filtrování, validace │ - Callback pattern pro notifikace
├─────────────────────────────────┤
│ Data Layer │ ← File, Tag (models)
│ - File, Tag třídy │ - Immutable kde je možné
│ - Validation logic │ - __eq__ a __hash__ správně
├─────────────────────────────────┤
│ Persistence │ ← config.py, .!tag soubory
│ - JSON soubory │ - UTF-8 encoding VŽDY
│ - Config management │ - ensure_ascii=False
└─────────────────────────────────┘
```
### Tříúrovňový konfigurační systém
1. **Globální config** (`.Tagger.!gtag` vedle Tagger.py)
- Geometrie okna, maximalizace
- Poslední otevřená složka
- Recent folders
2. **Složkový config** (`.Tagger.!ftag` v projekt složce)
- Ignore patterns
- Custom tagy pro složku
- Hardlink nastavení (output_dir, categories)
- Rekurzivní skenování
3. **Souborové tagy** (`.filename.!tag`)
- Tagy souboru
- Datum
- Stav (nové, ignorované)
### Klíčová pravidla
#### CO DĚLAT:
1. **UI NESMÍ obsahovat business logiku**
```python
# ❌ ŠPATNĚ
class GUI:
def save_file(self):
with open(file, 'w') as f:
json.dump(data, f)
# ✅ SPRÁVNĚ
class GUI:
def save_file(self):
self.filemanager.save_file(file)
```
2. **Core moduly NESMÍ importovat UI**
```python
# V src/core/*.py NIKDY:
import tkinter
from src.ui import anything
```
3. **Dependency Injection - předávat dependencies přes konstruktor**
```python
class FileManager:
def __init__(self, tagmanager: TagManager):
self.tagmanager = tagmanager
```
4. **UTF-8 encoding VŠUDE**
```python
with open(file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
```
5. **Type hints VŽDY**
```python
def filter_files(files: List[File], tags: List[Tag]) -> List[File]:
pass
```
#### CO NEDĚLAT:
1. **Globální stav** - NIKDY
2. **Magic numbers** - použít konstanty
3. **Ignorovat exceptions** - vždy logovat nebo ošetřit
4. **Hardcoded paths** - použít Path
---
## Klíčové komponenty
### 1. Tag (immutable value object)
```python
class Tag:
def __init__(self, category: str, name: str):
self.category = category # Nemění se po vytvoření!
self.name = name
@property
def full_path(self) -> str:
return f"{self.category}/{self.name}"
```
### 2. File (reprezentace souboru s metadaty)
```python
class File:
def __init__(self, file_path: Path, tagmanager=None):
self.file_path = file_path
self.filename = file_path.name
self.metadata_filename = parent / f".{filename}.!tag"
self.tags: list[Tag] = []
self.date: str | None = None
```
**Metadata format (.filename.!tag):**
```json
{
"new": false,
"ignored": false,
"tags": ["Stav/Nové", "Video/HD"],
"date": "2025-12-23"
}
```
### 3. TagManager (správa tagů)
```python
class TagManager:
def __init__(self):
self.tags_by_category = {} # {category: set(Tag)}
# Automaticky načte výchozí tagy (Hodnocení, Barva)
```
**Výchozí tagy:**
- Hodnocení: 1-5 hvězd (exkluzivní výběr)
- Barva: Červená, Modrá, Zelená, Žlutá, Oranžová
### 4. FileManager (správa souborů)
```python
class FileManager:
def __init__(self, tagmanager: TagManager):
self.filelist: list[File] = []
self.tagmanager = tagmanager
self.on_files_changed = None # CALLBACK pro UI!
self.global_config = load_global_config()
self.folder_config = {}
```
### 5. HardlinkManager (hardlink struktura)
```python
class HardlinkManager:
def __init__(self, output_dir: Path):
self.output_dir = output_dir
def create_structure_for_files(files, categories=None) -> (success, fail)
def find_obsolete_links(files, categories=None) -> List[(link, source)]
def remove_obsolete_links(files, categories=None) -> (count, paths)
def sync_structure(files, categories=None) -> (created, c_fail, removed, r_fail)
```
**Příklad struktury:**
```
output/
├── žánr/
│ ├── Komedie/
│ │ └── film.mkv (hardlink)
│ └── Akční/
│ └── film.mkv (hardlink)
└── rok/
└── 1988/
└── film.mkv (hardlink)
```
---
## GUI
### Moderní GUI (gui.py)
```
┌─────────────────────────────────────────────────────┐
│ 📁 Otevřít │ 🔄 │ 🏷️ │ 📅 🔍 [____] Toolbar
├────────────┬────────────────────────────────────────┤
│ 📂 Štítky │ ☐ Plná │ Třídění: [Název] [▲] │
│ ├─📁 Stav │ ┌──────────────────────────────────┐ │
│ │ ☑ Nové │ │ Název│Datum│Štítky│Velikost │ │
│ │ ☐ OK │ │file1 │2025 │HD │1.2 MB │ │
│ ├─📁 Video│ │file2 │ │4K │15 MB │ │
│ │ ☐ HD │ └──────────────────────────────────┘ │
│ │ ☐ 4K │ │
├────────────┴───────────────────────────────────────┤
│ Připraven 3 vybráno │ 125 souborů │
└─────────────────────────────────────────────────────┘
```
**Použít:** `poetry run python Tagger.py`
**Funkce:**
- Tabulka s 4 sloupci (Název, Datum, Štítky, Velikost)
- Toolbar s tlačítky
- Keyboard shortcuts (Ctrl+O, Ctrl+T, F5, Del...)
- Status bar se 3 sekcemi
- Hromadné přiřazování tagů
- Hardlink menu (Nástroje → Hardlink)
**Keyboard shortcuts:**
- `Ctrl+O` - Otevřít složku
- `Ctrl+Q` - Ukončit
- `Ctrl+T` - Přiřadit tagy
- `Ctrl+D` - Nastavit datum
- `Ctrl+F` - Focus search
- `F5` - Refresh
- `Del` - Smazat z indexu
---
## Vývoj
### Setup prostředí
```bash
# Poetry environment (VŽDY použij poetry!)
poetry install
poetry shell
# Nebo přímo:
poetry run python Tagger.py
```
### Testy
```bash
# Všechny testy (189 testů)
poetry run pytest tests/ -v
# S coverage
poetry run pytest tests/ --cov=src/core --cov-report=html
# Konkrétní modul
poetry run pytest tests/test_hardlink_manager.py -v
# Quick check
poetry run pytest tests/ -q
```
**Test coverage:** 100% core modulů
---
## Coding Standards
### Python Style
- **PEP 8** s výjimkami:
- Max line length: **120** (ne 79)
- Indentation: **4 mezery** (ne taby)
- **UTF-8** encoding všude
- **Type hints** povinné
- **Docstrings** pro public API
### Imports Order
```python
# 1. Standard library
import os
import sys
from pathlib import Path
# 2. Third-party
import tkinter as tk
from PIL import Image
# 3. Local
from src.core.file import File
from src.core.tag import Tag
```
---
## Git Workflow
### Branches
```
main/master ← Production (NE commity přímo!)
release ← Release candidate
devel ← Development integration
feature/* ← Feature branches ← VYVÍJÍME TADY
```
### Commit Messages
```
<type>: <subject>
[optional body]
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
```
**Types:**
- `feat:` - Nová funkce
- `fix:` - Bug fix
- `refactor:` - Refactoring
- `test:` - Testy
- `docs:` - Dokumentace
- `style:` - Formátování
- `chore:` - Build, dependencies
---
## Plánované features
- [ ] Progress bar pro dlouhé operace
- [ ] Undo/Redo mechanismus
- [ ] Export do CSV/Excel
- [ ] Dark mode theme
- [ ] Drag & drop souborů
- [ ] Image preview v sidebar
- [ ] SQLite fallback pro >10k souborů
- [ ] Full-text search
### Nice to have
- [ ] Plugin systém
- [ ] Web interface (Flask)
- [ ] Cloud sync (Dropbox, GDrive)
- [ ] Batch rename podle tagů
- [ ] Smart folder suggestions
---
## Metriky projektu
**Testy:** 189 (všechny ✅)
**Test coverage:** 100% core modulů
**Python verze:** 3.12+
**Dependencies:** Pillow (PIL)
**Vývojové prostředí:** Poetry
**Performance:**
- ✅ Dobré: <1000 souborů
- ⚠️ Přijatelné: 1000-5000 souborů
- ❌ Pomalé: >5000 souborů
---
## Debugování
### Časté problémy
**1. "Cannot import ImageTk"**
```bash
# Řešení: Použij poetry environment
poetry run python Tagger.py
```
**2. "Config file not found"**
```bash
# Normální při prvním spuštění
# Vytvoří se automaticky .Tagger.!gtag
```
**3. "Metadata corrupted"**
```python
# V config.py je graceful degradation
# Vrátí default config při chybě
```
---
## Dokumentace
**AKTUÁLNÍ:**
- **PROJECT_NOTES.md** - TENTO SOUBOR (single source of truth)
- **CHANGELOG.md** - Historie verzí
- Docstrings v kódu
---
## Pro AI asistenty (jako Claude)
### Když začínám práci na projektu:
1. **PŘEČTI TENTO SOUBOR CELÝ!**
2. Zkontroluj `git status`
3. Aktivuj poetry environment
4. Spusť testy (`poetry run pytest tests/`)
5. Dodržuj pravidla výše
### Při commitování:
1. Testy prošly (`pytest tests/`)
2. Type hints přidány
3. UTF-8 encoding
4. Žádné TODO/FIXME
5. Commit message formát správný
### Při přidání nové funkce:
1. Testy napsány PŘED implementací (TDD)
2. Dokumentace aktualizována (TENTO SOUBOR!)
3. Architecture decision zdokumentováno (pokud významné)
4. Type hints všude
5. Error handling přidán
---
## Kontakt & Help
**Autor:** honza
**Repository:** /home/honza/Documents/Tagger
**Python:** 3.12+
**OS:** Linux
**Pro pomoc:**
- Přečti TENTO soubor
- Podívej se do testů (`tests/`)
- Zkontroluj docstrings v kódu
---
**Last updated:** 2025-12-28
**Maintainer:** Claude Opus 4.5 + honza

183
README.md
View File

@@ -1,174 +1,103 @@
# 🏷️ Tagger
# Tagger
Desktopová aplikace pro správu a organizaci souborů pomocí hierarchických tagů (štítků).
Desktopová aplikace pro správu a organizaci souborů pomocí hierarchických tagů.
## Hlavní funkce
## Hlavní funkce
- 📁 Rekurzivní procházení složek
- 🏷️ Hierarchické tagy (kategorie/název)
- 🔍 Filtrování podle tagů a textu
- 💾 Metadata v JSON souborech (.!tag)
- 🎬 Automatická detekce rozlišení videí (ffprobe)
- 🎨 Dvě verze GUI: klasické a moderní (qBittorrent-style)
- Rekurzivní procházení složek
- Hierarchické tagy (kategorie/název)
- Filtrování podle tagů (AND / OR / NOT logika) a textu
- Metadata v JSON souborech (.!tag) — cestují se souborem
- Integrace s CSFD.cz (automatické načítání žánrů, roku, země)
- Tvorba hardlink struktury adresářů dle tagů
- Automatická detekce rozlišení videí (ffprobe)
## 🚀 Rychlý start
## Rychlý start
```bash
# Instalace závislostí
poetry install
# Spuštění (moderní GUI)
poetry run python Tagger_modern.py
# Nebo klasické GUI
# Spuštění
poetry run python Tagger.py
```
## 📸 Screenshot
## Klávesové zkratky
### Moderní GUI (qBittorrent-style)
```
┌─────────────────────────────────────────────────────┐
│ 📁 Otevřít │ 🔄 │ 🏷️ │ 📅 🔍 [____] Toolbar
├────────────┬────────────────────────────────────────┤
│ 📂 Štítky │ Název │Datum│Štítky│Velikost │
│ ├─📁 Stav │ file1.txt│2025 │HD │1.2 MB │
│ │ ☑ Nové │ file2.mp4│ │4K │15 MB │
│ ├─📁 Video│ file3.jpg│ │RAW │845 KB │
│ │ ☐ HD │ │
├────────────┴────────────────────────────────────────┤
│ Připraven 3 vybráno │ 125 souborů │
└─────────────────────────────────────────────────────┘
```
| Zkratka | Akce |
|---------|------|
| `Ctrl+O` | Otevřít složku |
| `Ctrl+T` | Přiřadit tagy |
| `Ctrl+D` | Nastavit datum |
| `Ctrl+W` | Zavřít složku |
| `F5` | Refresh |
| `Del` | Odebrat z indexu |
## 🎯 Použití
1. **Otevři složku** - Načti soubory ze složky (rekurzivně)
2. **Vytvoř tagy** - Hierarchická struktura (kategorie → tagy)
3. **Přiřaď tagy** - Označ soubory, vyber tagy
4. **Filtruj** - Klikni na tagy pro filtrování souborů
5. **Vyhledávej** - Textové vyhledávání v názvech
## ⌨️ Keyboard Shortcuts (moderní GUI)
- `Ctrl+O` - Otevřít složku
- `Ctrl+T` - Přiřadit tagy
- `Ctrl+D` - Nastavit datum
- `F5` - Refresh
- `Del` - Smazat z indexu
## 🏗️ Architektura
## Architektura
```
┌─────────────────────────────────┐
│ Presentation (UI) │ ← Tkinter GUI
│ Presentation (PySide6/Qt6) │ src/ui/gui.py
├─────────────────────────────────┤
│ Business Logic │ ← FileManager, TagManager
│ Business Logic │ src/core/ (bez UI importů)
├─────────────────────────────────┤
│ Data Layer │ File, Tag models
│ Data Layer │ File, Tag, TagManager, FileManager
├─────────────────────────────────┤
│ Persistence │ JSON .!tag soubory
│ Persistence │ JSON .!tag soubory
└─────────────────────────────────┘
```
## 📁 Struktura projektu
## Struktura projektu
```
Tagger/
├── Tagger.py # Entry point (klasické GUI)
├── Tagger_modern.py # Entry point (moderní GUI)
├── PROJECT_NOTES.md # ⭐ Kompletní dokumentace
├── Tagger.py # Entry point
├── src/
│ ├── core/ # Business logika
│ │ ├── file.py
│ ├── core/ # Business logika (žádné UI importy!)
│ │ ├── tag.py
│ │ ├── file.py
│ │ ├── file_manager.py
│ │ ── tag_manager.py
│ │ ── tag_manager.py
│ │ ├── config.py
│ │ ├── csfd.py
│ │ ├── hardlink_manager.py
│ │ └── media_utils.py
│ └── ui/
── gui.py # Klasické GUI
│ └── gui_modern.py # Moderní GUI
└── tests/ # 116 testů
── gui.py # Qt6 GUI
└── tests/ # 274 testů
```
## 🧪 Testování
## Testování
```bash
# Všechny testy (116 testů, 100% core coverage)
poetry run pytest tests/ -v
# S coverage report
poetry run pytest tests/ --cov=src/core --cov-report=html
poetry run pytest tests/ -q
```
## 📝 Dokumentace
## Technologie
**Veškerá dokumentace je v jednom souboru:**
👉 **[PROJECT_NOTES.md](PROJECT_NOTES.md)** ⭐
Obsahuje:
- Kompletní dokumentaci projektu
- Architektonická rozhodnutí (ADR)
- Coding standards
- Git workflow
- Known issues & TODO
- Debugování tipy
- Pravidla pro AI asistenty
## 🛠️ Technologie
- **Python:** 3.12
- **GUI:** Tkinter (standard library)
- **Dependencies:** Pillow (PIL)
- **Python:** 3.14+
- **GUI:** PySide6/Qt6
- **Dependencies:** requests, beautifulsoup4, loguru, python-dotenv
- **Package manager:** Poetry
- **Testing:** pytest
## 📊 Metriky
## Metriky
- **Řádky kódu:** ~1060 Python LOC
- **Testy:** 116 (všechny ✅)
- **Test coverage:** 100% core modulů
- **GUI verze:** 2 (klasická + moderní)
- **Testy:** 274 (100% core coverage)
- **Verze:** 1.1.0
## 🎯 Design Decisions
## Design Decisions
### Proč JSON místo databáze?
- Jednoduchý backup (copy složky)
- Git-friendly (plain text)
- ✅ Portable (žádné DB dependencies)
- ✅ Metadata zůstanou při přesunu souboru
- Jednoduchý backup (copy složky)
- Git-friendly (plain text, diffovatelné)
- Metadata zůstanou při přesunu souboru (sidecar)
- Portable — žádné DB závislosti
### Proč Tkinter?
- ✅ Standard library (žádné extra deps)
- ✅ Cross-platform
- ✅ Dobře dokumentované
### Proč sidecar soubory (.!tag)?
- Metadata cestují se souborem při přesunu/kopírování
- Čitelné i bez aplikace
- Každý soubor je nezávislý — žádný single point of failure
### Proč Poetry?
- ✅ Deterministické buildy (poetry.lock)
- ✅ Dev dependencies oddělené
- ✅ Moderní nástroj
## 🐛 Known Issues
- Git merge konflikty s poetry.lock při merge devel→feature
- Dlouhé operace (ffprobe) blokují UI - TODO: threading
## 🚀 Plánované features
- [ ] Progress bar pro dlouhé operace
- [ ] Undo/Redo mechanismus
- [ ] Export do CSV/Excel
- [ ] Dark mode theme
- [ ] Drag & drop souborů
## 📄 License
## License
MIT License
## 👤 Autor
honza
---
**Pro detailní dokumentaci viz [PROJECT_NOTES.md](PROJECT_NOTES.md)**

View File

@@ -1,18 +1,23 @@
# Imports
import tkinter as tk
from tkinter import ttk
"""
Entry point for Tagger application.
"""
import sys
from src.ui.gui import App
from src.core.file_manager import list_files, FileManager
from PySide6.QtWidgets import QApplication
from src.core.file_manager import 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 = App(self.filehandler, self.tagmanager)
from src.ui.main_window import MainWindow
STATE = State()
STATE.app.main()
def main() -> None:
tagmanager = TagManager()
filehandler = FileManager(tagmanager)
app = QApplication.instance() or QApplication(sys.argv)
window = MainWindow(filehandler, tagmanager)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -19,27 +19,20 @@ pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
exclude_binaries=True,
name='Tagger',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
onefile=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='Tagger',
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

634
poetry.lock generated
View File

@@ -1,4 +1,190 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "altgraph"
version = "0.17.5"
description = "Python graph (network) package"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597"},
{file = "altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7"},
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
groups = ["main"]
files = [
{file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"},
{file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"},
]
[package.dependencies]
soupsieve = ">=1.6.1"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "certifi"
version = "2026.2.25"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"},
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
{file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
{file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
{file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
{file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
{file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
{file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
]
[[package]]
name = "colorama"
@@ -6,147 +192,107 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "idna"
version = "3.11"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "loguru"
version = "0.7.3"
description = "Python logging made (stupidly) simple"
optional = false
python-versions = "<4.0,>=3.5"
groups = ["main"]
files = [
{file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"},
{file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"},
]
[package.dependencies]
colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
[[package]]
name = "macholib"
version = "1.16.4"
description = "Mach-O header analysis and editing"
optional = false
python-versions = "*"
groups = ["dev"]
markers = "sys_platform == \"darwin\""
files = [
{file = "macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea"},
{file = "macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362"},
]
[package.dependencies]
altgraph = ">=0.17"
[[package]]
name = "packaging"
version = "25.0"
version = "26.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
]
[[package]]
name = "pillow"
version = "12.0.0"
description = "Python Imaging Library (fork)"
name = "pefile"
version = "2024.8.26"
description = "Python PE parsing module"
optional = false
python-versions = ">=3.10"
python-versions = ">=3.6.0"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b"},
{file = "pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1"},
{file = "pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363"},
{file = "pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca"},
{file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e"},
{file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782"},
{file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10"},
{file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa"},
{file = "pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275"},
{file = "pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d"},
{file = "pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7"},
{file = "pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc"},
{file = "pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257"},
{file = "pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642"},
{file = "pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3"},
{file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c"},
{file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227"},
{file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b"},
{file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e"},
{file = "pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739"},
{file = "pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e"},
{file = "pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d"},
{file = "pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371"},
{file = "pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082"},
{file = "pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f"},
{file = "pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d"},
{file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953"},
{file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8"},
{file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79"},
{file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba"},
{file = "pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0"},
{file = "pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a"},
{file = "pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad"},
{file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643"},
{file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4"},
{file = "pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399"},
{file = "pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5"},
{file = "pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b"},
{file = "pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3"},
{file = "pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07"},
{file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e"},
{file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344"},
{file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27"},
{file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79"},
{file = "pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098"},
{file = "pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905"},
{file = "pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a"},
{file = "pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3"},
{file = "pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced"},
{file = "pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b"},
{file = "pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d"},
{file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a"},
{file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe"},
{file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee"},
{file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef"},
{file = "pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9"},
{file = "pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b"},
{file = "pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47"},
{file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9"},
{file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2"},
{file = "pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a"},
{file = "pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b"},
{file = "pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad"},
{file = "pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01"},
{file = "pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c"},
{file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e"},
{file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e"},
{file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9"},
{file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab"},
{file = "pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b"},
{file = "pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b"},
{file = "pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0"},
{file = "pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6"},
{file = "pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6"},
{file = "pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"},
{file = "pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e"},
{file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca"},
{file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925"},
{file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8"},
{file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4"},
{file = "pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52"},
{file = "pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a"},
{file = "pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7"},
{file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8"},
{file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a"},
{file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197"},
{file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c"},
{file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e"},
{file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76"},
{file = "pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5"},
{file = "pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353"},
{file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"},
{file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@@ -158,27 +304,137 @@ testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyinstaller"
version = "6.19.0"
description = "PyInstaller bundles a Python application and all its dependencies into a single package."
optional = false
python-versions = "<3.15,>=3.8"
groups = ["dev"]
files = [
{file = "pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134"},
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11"},
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf"},
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122"},
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63"},
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe"},
{file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83"},
{file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6"},
{file = "pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2"},
{file = "pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33"},
{file = "pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea"},
{file = "pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865"},
]
[package.dependencies]
altgraph = "*"
macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
packaging = ">=22.0"
pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
pyinstaller-hooks-contrib = ">=2026.0"
pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""}
setuptools = ">=42.0.0"
[package.extras]
completion = ["argcomplete"]
hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2026.4"
description = "Community maintained hooks for PyInstaller"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pyinstaller_hooks_contrib-2026.4-py3-none-any.whl", hash = "sha256:1de1a5e49a878122010b88c7e295502bc69776c157c4a4dc78741a4e6178b00f"},
{file = "pyinstaller_hooks_contrib-2026.4.tar.gz", hash = "sha256:766c281acb1ecc32e21c8c667056d7ebf5da0aabd5e30c219f9c2a283620eeaa"},
]
[package.dependencies]
packaging = ">=22.0"
setuptools = ">=42.0.0"
[[package]]
name = "pyside6"
version = "6.11.0"
description = "Python bindings for the Qt cross-platform application and UI framework"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f"},
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00"},
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e"},
{file = "pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc"},
{file = "pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e"},
]
[package.dependencies]
PySide6_Addons = "6.11.0"
PySide6_Essentials = "6.11.0"
shiboken6 = "6.11.0"
[[package]]
name = "pyside6-addons"
version = "6.11.0"
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182"},
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35"},
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753"},
{file = "pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e"},
{file = "pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8"},
]
[package.dependencies]
PySide6_Essentials = "6.11.0"
shiboken6 = "6.11.0"
[[package]]
name = "pyside6-essentials"
version = "6.11.0"
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187"},
]
[package.dependencies]
shiboken6 = "6.11.0"
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
]
[package.dependencies]
@@ -191,7 +447,151 @@ pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "python-dotenv"
version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
optional = false
python-versions = ">=3.6"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"},
{file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
]
[[package]]
name = "requests"
version = "2.33.1"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"},
{file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"},
]
[package.dependencies]
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "setuptools"
version = "82.0.1"
description = "Most extensible Python build backend with support for C/C++ extension modules"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"},
{file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"},
]
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""]
core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
enabler = ["pytest-enabler (>=2.2)"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"]
[[package]]
name = "shiboken6"
version = "6.11.0"
description = "Python/C++ bindings helper module"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b"},
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053"},
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9"},
{file = "shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1"},
{file = "shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4"},
]
[[package]]
name = "soupsieve"
version = "2.8.3"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"},
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "urllib3"
version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
]
[package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "win32-setctime"
version = "1.2.0"
description = "A small Python utility to set file creation time on Windows"
optional = false
python-versions = ">=3.5"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"},
{file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"},
]
[package.extras]
dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "d9b2c3a8467631e5de03f3a79ad641da445743ec08afb777b0fa7eef1b046045"
lock-version = "2.1"
python-versions = ">=3.13,<3.15"
content-hash = "4c8861d0f089fe0ce348b1dec692077f63b84201ba3afebfb349e4998bf0ef70"

66
prebuild.py Normal file
View File

@@ -0,0 +1,66 @@
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from src.constants import VERSION
load_dotenv()
print("=" * 50)
print("PREBUILD CONFIGURATION")
print("=" * 50)
# Check if running in virtual environment
project_root = Path(__file__).parent
expected_venv_path = project_root / ".venv"
current_executable = Path(sys.executable)
print(f"\nPython executable: {sys.executable}")
is_correct_venv = False
try:
current_executable.relative_to(expected_venv_path)
is_correct_venv = True
except ValueError:
is_correct_venv = False
if is_correct_venv:
print("✓ Correct environment selected for building")
else:
print("✗ Wrong environment selected")
print(f" Expected: {expected_venv_path}")
print(f" Current: {current_executable.parent.parent}")
print(f"✓ Version: {VERSION}")
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
console_mode = env_debug
default_spec = Path(__file__).parent.name + ".spec"
spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec)
print(f"\n{'-' * 50}")
print("BUILD SETTINGS")
print(f"{'-' * 50}")
print(f"ENV_DEBUG: {env_debug}")
print(f"Console mode: {console_mode}")
print(f"Spec file: {spec_filename}")
spec_path = Path(__file__).parent / spec_filename
if spec_path.exists():
with open(spec_path, "r", encoding="utf-8") as f:
spec_content = f.read()
if f"console={not console_mode}" in spec_content:
new_spec_content = spec_content.replace(
f"console={not console_mode}",
f"console={console_mode}"
)
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_spec_content)
print(f"✓ Updated {spec_filename}: console={console_mode}")
else:
print(f"{spec_filename} already configured: console={console_mode}")
else:
print(f"{spec_filename} not found!")
print(f"{'-' * 50}\n")

View File

@@ -1,22 +1,27 @@
[tool.poetry]
[project]
name = "tagger"
version = "1.0.3"
description = "Universal file tagging utility"
authors = ["Jan Doubravský <jan.doubravsky@gmail.com>"]
version = "1.2.0"
description = ""
authors = [
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
]
readme = "README.md"
requires-python = ">=3.14,<3.15"
dependencies = [
"requests (>=2.32.5,<3.0.0)",
"beautifulsoup4 (>=4.14.3,<5.0.0)",
"pyside6 (>=6.10.1,<7.0.0)",
"loguru (>=0.7.3,<0.8.0)",
"python-dotenv (>=1.2.2,<2.0.0)"
]
[tool.poetry]
package-mode = false
[tool.poetry.dependencies]
python = "^3.12"
pillow = "^12.0.0"
[tool.poetry.group.dev.dependencies]
pytest = "^9.0.2"
pyinstaller = "^6.18.0"
[build-system]
requires = ["poetry-core"]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

2
src/core/_version.py Normal file
View File

@@ -0,0 +1,2 @@
# Auto-generated — do not edit manually.
VERSION = "1.2.0"

View File

@@ -2,15 +2,20 @@
Configuration management for Tagger
Three levels of configuration:
1. Global config (.Tagger.!gtag next to Tagger.py) - app-wide settings
1. Global config (~/.config/Tagger/.Tagger.!gtag) - app-wide settings
2. Folder config (.Tagger.!ftag in project root) - folder-specific settings
3. File tags (.{filename}.!tag) - per-file metadata (handled in file.py)
"""
import json
from pathlib import Path
# Global config file (next to the main script)
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Tagger.!gtag"
# Global config file in XDG config directory (~/.config/Tagger/)
# Migrates automatically from the old location next to Tagger.py if found.
_XDG_CONFIG_DIR = Path.home() / ".config" / "Tagger"
GLOBAL_CONFIG_FILE = _XDG_CONFIG_DIR / ".Tagger.!gtag"
# Legacy location (next to Tagger.py) — used only for one-time migration
_LEGACY_GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Tagger.!gtag"
# Folder config filename
FOLDER_CONFIG_NAME = ".Tagger.!ftag"
@@ -29,13 +34,22 @@ DEFAULT_GLOBAL_CONFIG = {
}
def _migrate_legacy_config() -> None:
"""Migrate global config from old location (next to Tagger.py) to ~/.config/Tagger/."""
if GLOBAL_CONFIG_FILE.exists() or not _LEGACY_GLOBAL_CONFIG_FILE.exists():
return
_XDG_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
GLOBAL_CONFIG_FILE.write_bytes(_LEGACY_GLOBAL_CONFIG_FILE.read_bytes())
_LEGACY_GLOBAL_CONFIG_FILE.unlink()
def load_global_config() -> dict:
"""Load global application config"""
_migrate_legacy_config()
if GLOBAL_CONFIG_FILE.exists():
try:
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
@@ -47,6 +61,7 @@ def load_global_config() -> dict:
def save_global_config(cfg: dict):
"""Save global application config"""
_XDG_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
@@ -76,7 +91,6 @@ def load_folder_config(folder: Path) -> dict:
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
@@ -96,17 +110,3 @@ def save_folder_config(folder: Path, cfg: dict):
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):
"""Legacy function - saves global config"""
save_global_config(cfg)

View File

@@ -1,4 +1,59 @@
# src/core/constants.py
VERSION = "v1.0.3"
APP_NAME = "Tagger"
APP_VIEWPORT = "1000x700"
"""
Application constants with dynamic version loading.
Version loading priority:
1. pyproject.toml [project] version (preferred, uses tomllib)
2. src/core/_version.py VERSION (generated fallback for frozen/PyInstaller builds)
3. "0.0.0" (last resort)
Debug mode:
Controlled via .env: ENV_DEBUG=true
Accepted true-values: true, 1, yes (case-insensitive)
"""
import os
import tomllib
from pathlib import Path
_ROOT_DIR = Path(__file__).parent.parent.parent
_PYPROJECT_PATH = _ROOT_DIR / "pyproject.toml"
_VERSION_FILE = Path(__file__).parent / "_version.py"
def _load_version() -> str:
# 1. pyproject.toml
try:
with open(_PYPROJECT_PATH, "rb") as f:
version = tomllib.load(f)["project"]["version"]
# Write fallback for frozen/PyInstaller builds
_VERSION_FILE.write_text(
f'# Auto-generated — do not edit manually.\nVERSION = "{version}"\n',
encoding="utf-8",
)
return version
except (FileNotFoundError, KeyError, OSError):
pass
# 2. _version.py
try:
from src.core._version import VERSION as _ver # type: ignore[import]
return _ver
except ImportError:
pass
# 3. last resort
return "0.0.0"
def _load_debug() -> bool:
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
VERSION = _load_version()
DEBUG = _load_debug()
if DEBUG:
VERSION = f"{VERSION} DEV"
APP_NAME = f"Tagger v{VERSION}"
APP_VIEWPORT = "1000x700"

412
src/core/csfd.py Normal file
View File

@@ -0,0 +1,412 @@
"""
CSFD.cz scraper module for fetching movie information.
This module provides functionality to fetch movie data from CSFD.cz (Czech-Slovak Film Database).
"""
from __future__ import annotations
import re
import json
from dataclasses import dataclass, field
from typing import Optional, TYPE_CHECKING
from urllib.parse import urljoin
try:
import requests
from bs4 import BeautifulSoup
HAS_DEPENDENCIES = True
except ImportError:
HAS_DEPENDENCIES = False
requests = None # type: ignore
BeautifulSoup = None # type: ignore
if TYPE_CHECKING:
from bs4 import BeautifulSoup
CSFD_BASE_URL = "https://www.csfd.cz"
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
# User agent to avoid being blocked
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept-Language": "cs,en;q=0.9",
}
@dataclass
class CSFDMovie:
"""Represents movie data from CSFD.cz"""
title: str
url: str
year: Optional[int] = None
genres: list[str] = field(default_factory=list)
directors: list[str] = field(default_factory=list)
actors: list[str] = field(default_factory=list)
rating: Optional[int] = None # Percentage 0-100
rating_count: Optional[int] = None
duration: Optional[int] = None # Minutes
country: Optional[str] = None
poster_url: Optional[str] = None
plot: Optional[str] = None
csfd_id: Optional[int] = None
def to_dict(self) -> dict:
"""Serialize to a plain dict for storage in .!tag cache."""
return {
"title": self.title,
"url": self.url,
"year": self.year,
"genres": self.genres,
"directors": self.directors,
"actors": self.actors,
"rating": self.rating,
"rating_count": self.rating_count,
"duration": self.duration,
"country": self.country,
"poster_url": self.poster_url,
"plot": self.plot,
"csfd_id": self.csfd_id,
}
@classmethod
def from_dict(cls, data: dict) -> "CSFDMovie":
"""Deserialize from a plain dict (e.g. loaded from .!tag cache)."""
return cls(
title=data.get("title", ""),
url=data.get("url", ""),
year=data.get("year"),
genres=data.get("genres", []),
directors=data.get("directors", []),
actors=data.get("actors", []),
rating=data.get("rating"),
rating_count=data.get("rating_count"),
duration=data.get("duration"),
country=data.get("country"),
poster_url=data.get("poster_url"),
plot=data.get("plot"),
csfd_id=data.get("csfd_id"),
)
def __str__(self) -> str:
parts = [self.title]
if self.year:
parts[0] += f" ({self.year})"
if self.rating is not None:
parts.append(f"Hodnocení: {self.rating}%")
if self.genres:
parts.append(f"Žánr: {', '.join(self.genres)}")
if self.directors:
parts.append(f"Režie: {', '.join(self.directors)}")
return " | ".join(parts)
def _check_dependencies():
"""Check if required dependencies are installed."""
if not HAS_DEPENDENCIES:
raise ImportError(
"CSFD module requires 'requests' and 'beautifulsoup4' packages. "
"Install them with: pip install requests beautifulsoup4"
)
def _extract_csfd_id(url: str) -> Optional[int]:
"""Extract CSFD movie ID from URL."""
match = re.search(r"/film/(\d+)", url)
return int(match.group(1)) if match else None
def _parse_duration(duration_str: str) -> Optional[int]:
"""Parse ISO 8601 duration (PT97M) to minutes."""
match = re.search(r"PT(\d+)M", duration_str)
return int(match.group(1)) if match else None
def fetch_movie(url: str) -> CSFDMovie:
"""
Fetch movie information from CSFD.cz URL.
Args:
url: Full CSFD.cz movie URL (e.g., https://www.csfd.cz/film/9423-pane-vy-jste-vdova/)
Returns:
CSFDMovie object with extracted data
Raises:
ImportError: If required dependencies are not installed
requests.RequestException: If network request fails
ValueError: If URL is invalid or page cannot be parsed
"""
_check_dependencies()
response = requests.get(url, headers=HEADERS, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
# Try to extract JSON-LD structured data first (most reliable)
movie_data = _extract_json_ld(soup)
# Extract additional data from HTML
movie_data["url"] = url
movie_data["csfd_id"] = _extract_csfd_id(url)
# Get rating from HTML if not in JSON-LD
if movie_data.get("rating") is None:
movie_data["rating"] = _extract_rating(soup)
# Get poster URL
if movie_data.get("poster_url") is None:
movie_data["poster_url"] = _extract_poster(soup)
# Get plot summary
if movie_data.get("plot") is None:
movie_data["plot"] = _extract_plot(soup)
# Get country and year from origin info
origin_info = _extract_origin_info(soup)
if origin_info:
if movie_data.get("country") is None:
movie_data["country"] = origin_info.get("country")
if movie_data.get("year") is None:
movie_data["year"] = origin_info.get("year")
if movie_data.get("duration") is None:
movie_data["duration"] = origin_info.get("duration")
# Get genres from HTML if not in JSON-LD
if not movie_data.get("genres"):
movie_data["genres"] = _extract_genres(soup)
return CSFDMovie(**movie_data)
def _extract_json_ld(soup: BeautifulSoup) -> dict:
"""Extract movie data from JSON-LD structured data."""
data = {
"title": "",
"year": None,
"genres": [],
"directors": [],
"actors": [],
"rating": None,
"rating_count": None,
"duration": None,
"country": None,
"poster_url": None,
"plot": None,
}
# Find JSON-LD script
script_tags = soup.find_all("script", type="application/ld+json")
for script in script_tags:
try:
json_data = json.loads(script.string)
# Handle both single object and array
if isinstance(json_data, list):
for item in json_data:
if item.get("@type") == "Movie":
json_data = item
break
else:
continue
if json_data.get("@type") != "Movie":
continue
# Title
data["title"] = json_data.get("name", "")
# Genres
genre = json_data.get("genre", [])
if isinstance(genre, str):
data["genres"] = [genre]
else:
data["genres"] = list(genre)
# Directors
directors = json_data.get("director", [])
if isinstance(directors, dict):
directors = [directors]
data["directors"] = [d.get("name", "") for d in directors if d.get("name")]
# Actors
actors = json_data.get("actor", [])
if isinstance(actors, dict):
actors = [actors]
data["actors"] = [a.get("name", "") for a in actors if a.get("name")]
# Rating
agg_rating = json_data.get("aggregateRating", {})
if agg_rating:
rating_value = agg_rating.get("ratingValue")
if rating_value is not None:
data["rating"] = round(float(rating_value))
data["rating_count"] = agg_rating.get("ratingCount")
# Duration
duration_str = json_data.get("duration", "")
if duration_str:
data["duration"] = _parse_duration(duration_str)
# Poster
image = json_data.get("image")
if image:
if isinstance(image, str):
data["poster_url"] = image
elif isinstance(image, dict):
data["poster_url"] = image.get("url")
# Description
data["plot"] = json_data.get("description")
break # Found movie data
except (json.JSONDecodeError, KeyError, TypeError):
continue
return data
def _extract_rating(soup: BeautifulSoup) -> Optional[int]:
"""Extract rating percentage from HTML."""
# Look for rating box
rating_elem = soup.select_one(".film-rating-average")
if rating_elem:
text = rating_elem.get_text(strip=True)
match = re.search(r"(\d+)%", text)
if match:
return int(match.group(1))
return None
def _extract_poster(soup: BeautifulSoup) -> Optional[str]:
"""Extract poster image URL from HTML."""
# Look for poster image
poster = soup.select_one(".film-poster img")
if poster:
src = poster.get("src") or poster.get("data-src")
if src:
if src.startswith("//"):
return "https:" + src
return src
return None
def _extract_plot(soup: BeautifulSoup) -> Optional[str]:
"""Extract plot summary from HTML."""
# Look for plot/description section
plot_elem = soup.select_one(".plot-full p")
if plot_elem:
return plot_elem.get_text(strip=True)
# Alternative: shorter plot
plot_elem = soup.select_one(".plot-preview p")
if plot_elem:
return plot_elem.get_text(strip=True)
return None
def _extract_genres(soup: BeautifulSoup) -> list[str]:
"""Extract genres from HTML."""
genres = []
genre_links = soup.select(".genres a")
for link in genre_links:
genre = link.get_text(strip=True)
if genre:
genres.append(genre)
return genres
def _extract_origin_info(soup: BeautifulSoup) -> dict:
"""Extract country, year, duration from origin info line."""
info = {}
# Look for origin line like "Československo, 1970, 97 min"
origin_elem = soup.select_one(".origin")
if origin_elem:
text = origin_elem.get_text(strip=True)
# Extract year
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", text)
if year_match:
info["year"] = int(year_match.group(1))
# Extract duration
duration_match = re.search(r"(\d+)\s*min", text)
if duration_match:
info["duration"] = int(duration_match.group(1))
# Extract country (first part before comma)
parts = text.split(",")
if parts:
info["country"] = parts[0].strip()
return info
def search_movies(query: str, limit: int = 10) -> list[CSFDMovie]:
"""
Search for movies on CSFD.cz.
Args:
query: Search query string
limit: Maximum number of results to return
Returns:
List of CSFDMovie objects with basic info (title, url, year)
"""
_check_dependencies()
search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}"
response = requests.get(search_url, headers=HEADERS, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
results = []
# Find movie results
movie_items = soup.select(".film-title-name, .search-result-item a[href*='/film/']")
for item in movie_items[:limit]:
href = item.get("href", "")
if "/film/" not in href:
continue
title = item.get_text(strip=True)
url = urljoin(CSFD_BASE_URL, href)
# Try to get year from sibling/parent
year = None
parent = item.find_parent(class_="article-content")
if parent:
year_elem = parent.select_one(".info")
if year_elem:
year_match = re.search(r"\((\d{4})\)", year_elem.get_text())
if year_match:
year = int(year_match.group(1))
results.append(CSFDMovie(
title=title,
url=url,
year=year,
csfd_id=_extract_csfd_id(url)
))
return results
def fetch_movie_by_id(csfd_id: int) -> CSFDMovie:
"""
Fetch movie by CSFD ID.
Args:
csfd_id: CSFD movie ID number
Returns:
CSFDMovie object with full data
"""
url = f"{CSFD_BASE_URL}/film/{csfd_id}/"
return fetch_movie(url)

View File

@@ -2,6 +2,9 @@ from pathlib import Path
import json
from .tag import Tag
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
CSFD_CACHE_VERSION = 1
class File:
def __init__(self, file_path: Path, tagmanager=None) -> None:
self.file_path = file_path
@@ -11,8 +14,12 @@ class File:
self.ignored = False
self.tags: list[Tag] = []
self.tagmanager = tagmanager
# new: optional date string "YYYY-MM-DD" (assigned manually)
# optional date string "YYYY-MM-DD" (assigned manually)
self.date: str | None = None
# CSFD.cz URL for movie info
self.csfd_url: str | None = None
# Cached CSFD data — avoids re-fetching on every open
self.csfd_cache: dict | None = None
self.get_metadata()
def get_metadata(self) -> None:
@@ -21,6 +28,7 @@ class File:
self.ignored = False
self.tags = []
self.date = None
self.csfd_url = None
if self.tagmanager:
tag = self.tagmanager.add_tag("Stav", "Nové")
self.tags.append(tag)
@@ -32,11 +40,12 @@ class File:
data = {
"new": self.new,
"ignored": self.ignored,
# ukládáme full_path tagů
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
# date může být None
"date": self.date,
"csfd_url": self.csfd_url,
}
if self.csfd_cache is not None:
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
with open(self.metadata_filename, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
@@ -47,6 +56,12 @@ class File:
self.ignored = data.get("ignored", False)
self.tags = []
self.date = data.get("date", None)
self.csfd_url = data.get("csfd_url", None)
raw_cache = data.get("csfd_cache")
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION:
self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"}
else:
self.csfd_cache = None
if not self.tagmanager:
return
@@ -66,6 +81,74 @@ class File:
self.date = date_str
self.save_metadata()
def set_csfd_url(self, url: str | None):
"""Nastaví CSFD URL nebo None pro smazání. Invaliduje cache při změně URL."""
new_url = url if url else None
if new_url != self.csfd_url:
self.csfd_cache = None # URL changed — old cache is stale
self.csfd_url = new_url
self.save_metadata()
def get_cached_movie(self):
"""
Vrátí CSFDMovie z cache nebo None pokud cache není k dispozici.
Nevyžaduje síťové připojení.
"""
if self.csfd_cache is None:
return None
try:
from .csfd import CSFDMovie
return CSFDMovie.from_dict(self.csfd_cache)
except Exception:
return None
def apply_csfd_tags(self, add_genres: bool = True, add_year: bool = True, add_country: bool = True) -> dict:
"""
Načte informace z CSFD a přiřadí tagy (žánr, rok, země).
Returns:
dict s klíči 'success', 'movie', 'error', 'tags_added'
"""
if not self.csfd_url:
return {"success": False, "error": "CSFD URL není nastavena", "tags_added": []}
try:
from .csfd import fetch_movie
movie = fetch_movie(self.csfd_url)
self.csfd_cache = movie.to_dict()
except ImportError as e:
return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []}
except Exception as e:
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
tags_added = []
if add_genres and movie.genres:
for genre in movie.genres:
tag_obj = self.tagmanager.add_tag("Žánr", genre) if self.tagmanager else Tag("Žánr", genre)
if tag_obj not in self.tags:
self.tags.append(tag_obj)
tags_added.append(f"Žánr/{genre}")
if add_year and movie.year:
year_str = str(movie.year)
tag_obj = self.tagmanager.add_tag("Rok", year_str) if self.tagmanager else Tag("Rok", year_str)
if tag_obj not in self.tags:
self.tags.append(tag_obj)
tags_added.append(f"Rok/{year_str}")
if add_country and movie.country:
tag_obj = self.tagmanager.add_tag("Země", movie.country) if self.tagmanager else Tag("Země", movie.country)
if tag_obj not in self.tags:
self.tags.append(tag_obj)
tags_added.append(f"Země/{movie.country}")
if tags_added:
self.save_metadata()
return {"success": True, "movie": movie, "tags_added": tags_added}
def add_tag(self, tag):
# tag může být Tag nebo string
from .tag import Tag as TagClass

View File

@@ -1,14 +1,26 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
import fnmatch
from .file import File
from .tag import Tag
from .tag_manager import TagManager
from .utils import list_files
from typing import Iterable
import fnmatch
from src.core.config import (
load_global_config, save_global_config,
load_folder_config, save_folder_config
)
_MAX_UNDO = 50
@dataclass
class _UndoEntry:
description: str
undo: Callable[[], None]
redo: Callable[[], None]
class FileManager:
def __init__(self, tagmanager: TagManager):
@@ -16,9 +28,14 @@ class FileManager:
self.folders: list[Path] = []
self.tagmanager = tagmanager
self.on_files_changed = None # callback do GUI
self.on_tags_changed = None # callback do GUI po rename/merge operacích
# callback(orphans: list[Path]) — volán po append() pokud jsou nalezeny osiřelé .!tag
self.on_orphaned_tags = None
self.global_config = load_global_config()
self.folder_configs: dict[Path, dict] = {} # folder -> config
self.current_folder: Path | None = None
self._undo_stack: list[_UndoEntry] = []
self._redo_stack: list[_UndoEntry] = []
def append(self, folder: Path) -> None:
"""Add a folder to scan for files"""
@@ -45,6 +62,8 @@ class FileManager:
# Get ignore patterns from folder config
ignore_patterns = folder_config.get("ignore_patterns", [])
known_files: set[Path] = set()
for each in list_files(folder):
# Skip all Tagger metadata files
if each.name.endswith(".!tag"): # File tags: .filename.!tag
@@ -63,9 +82,45 @@ class FileManager:
):
continue
known_files.add(each)
file_obj = File(each, self.tagmanager)
self.filelist.append(file_obj)
# Detect orphaned .!tag files (sidecar without a matching media file).
# This happens when the original file was renamed or moved without its sidecar.
orphans = self._find_orphaned_tags(folder, known_files)
if orphans and self.on_orphaned_tags:
self.on_orphaned_tags(orphans)
def _find_orphaned_tags(self, folder: Path, known_files: set[Path]) -> list[Path]:
"""
Return .!tag sidecar files that have no matching media file.
A sidecar `.filename.!tag` is orphaned when `filename` is not in known_files.
"""
orphans = []
for tag_file in folder.rglob("*.!tag"):
# Sidecar name format: .{original_name}.!tag (hidden dot-file)
name = tag_file.name # e.g. ".film.mkv.!tag"
if not name.startswith("."):
continue
original_name = name[1:-len(".!tag")] # strip leading dot and suffix
expected = tag_file.parent / original_name
if expected not in known_files:
orphans.append(tag_file)
return orphans
def find_orphaned_tags(self, folder: Path = None) -> list[Path]:
"""
Public method: scan folder and return all orphaned .!tag sidecar paths.
Useful for manual cleanup or UI display.
"""
if folder is None:
folder = self.current_folder
if folder is None:
return []
known = {f.file_path for f in self.filelist}
return self._find_orphaned_tags(folder, known)
def get_folder_config(self, folder: Path = None) -> dict:
"""Get config for a folder (or current folder if not specified)"""
if folder is None:
@@ -98,68 +153,420 @@ class FileManager:
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:
if isinstance(tag, str):
if "/" in tag:
category, name = tag.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
else:
tag_obj = self.tagmanager.add_tag("default", tag)
else:
tag_obj = tag
if tag_obj not in f.tags:
f.tags.append(tag_obj)
f.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
# ==================================================
# UNDO / REDO
# ==================================================
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
"""Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů."""
for f in files_objs:
if isinstance(tag, str):
if "/" in tag:
category, name = tag.split("/", 1)
from .tag import Tag as TagClass
tag_obj = TagClass(category, name)
else:
from .tag import Tag as TagClass
tag_obj = TagClass("default", tag)
else:
tag_obj = tag
if tag_obj in f.tags:
f.tags.remove(tag_obj)
f.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
def _push_undo(self, entry: _UndoEntry) -> None:
self._undo_stack.append(entry)
if len(self._undo_stack) > _MAX_UNDO:
self._undo_stack.pop(0)
self._redo_stack.clear()
def filter_files_by_tags(self, tags: Iterable):
"""
Vrátí jen soubory, které obsahují všechny zadané tagy.
'tags' může být iterace Tag objektů nebo stringů 'category/name'.
"""
tags_list = list(tags) if tags is not None else []
if not tags_list:
return self.filelist
def can_undo(self) -> bool:
return bool(self._undo_stack)
target_full_paths = set()
from .tag import Tag as TagClass
for t in tags_list:
if isinstance(t, TagClass):
target_full_paths.add(t.full_path)
elif isinstance(t, str):
target_full_paths.add(t)
else:
def can_redo(self) -> bool:
return bool(self._redo_stack)
def undo(self) -> str | None:
"""Vrátí zpět poslední operaci. Vrací popis operace nebo None."""
if not self._undo_stack:
return None
entry = self._undo_stack.pop()
entry.undo()
self._redo_stack.append(entry)
return entry.description
def redo(self) -> str | None:
"""Zopakuje naposledy vrácenou operaci. Vrací popis operace nebo None."""
if not self._redo_stack:
return None
entry = self._redo_stack.pop()
entry.redo()
self._undo_stack.append(entry)
return entry.description
def _snapshot_files(self, files: list[File]) -> dict[Path, list[str]]:
"""Zaznamená aktuální tagy souborů jako full_path řetězce."""
return {f.file_path: [t.full_path for t in f.tags] for f in files}
def _restore_snapshot(self, snapshot: dict[Path, list[str]]) -> None:
"""Obnoví tagy souborů ze snapshotu. Zajistí existenci tagů v TagManageru."""
path_to_file = {f.file_path: f for f in self.filelist}
for path, tag_paths in snapshot.items():
f = path_to_file.get(path)
if f is None:
continue
new_tags = []
for fp in tag_paths:
if "/" not in fp:
continue
cat, name = fp.split("/", 1)
new_tags.append(self.tagmanager.add_tag(cat, name))
f.tags = new_tags
f.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
# ==================================================
# TAG OPERATIONS
# ==================================================
def assign_tag_to_files(self, files: list[File], tag):
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
if isinstance(tag, str):
parsed = Tag.from_string(tag)
tag_obj = self.tagmanager.add_tag(parsed.category, parsed.name)
else:
tag_obj = tag
# Only files that don't already have the tag will be changed
affected = [f for f in files if tag_obj not in f.tags]
snapshot_before = self._snapshot_files(affected)
for f in affected:
f.tags.append(tag_obj)
f.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
if affected:
snapshot_after = self._snapshot_files(affected)
self._push_undo(_UndoEntry(
description=f"Přiřadit tag {tag_obj.full_path}",
undo=lambda s=snapshot_before: self._restore_snapshot(s),
redo=lambda s=snapshot_after: self._restore_snapshot(s),
))
def remove_tag_from_files(self, files: list[File], tag):
"""Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů."""
if isinstance(tag, str):
tag_obj = Tag.from_string(tag)
else:
tag_obj = tag
affected = [f for f in files if tag_obj in f.tags]
snapshot_before = self._snapshot_files(affected)
for f in affected:
f.tags.remove(tag_obj)
f.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
if affected:
snapshot_after = self._snapshot_files(affected)
self._push_undo(_UndoEntry(
description=f"Odebrat tag {tag_obj.full_path}",
undo=lambda s=snapshot_before: self._restore_snapshot(s),
redo=lambda s=snapshot_after: self._restore_snapshot(s),
))
@staticmethod
def _to_full_paths(tags) -> set[str]:
"""Převede kolekci Tag objektů nebo stringů na sadu full_path řetězců."""
result = set()
if not tags:
return result
for t in tags:
if isinstance(t, Tag):
result.add(t.full_path)
elif isinstance(t, str):
result.add(t)
return result
def filter_files_by_tags(
self,
tags: Iterable = None,
*,
must_have: Iterable = None,
any_of: Iterable = None,
must_not: Iterable = None,
) -> list[File]:
"""
Vrátí soubory dle tagových podmínek.
Parametry lze kombinovat:
tags / must_have — soubor musí mít VŠECHNY tyto tagy (AND)
any_of — soubor musí mít ALESPOŇ JEDEN z těchto tagů (OR)
must_not — soubor nesmí mít ŽÁDNÝ z těchto tagů (NOT)
Zpětně kompatibilní: filter_files_by_tags(tags) funguje stejně jako dříve.
Příklad:
filter_files_by_tags(
any_of=["Žánr/Drama", "Žánr/Thriller"],
must_not=["Rok/2000", "Rok/2001"],
)
"""
# Backward compat: positional `tags` arg maps to must_have
must_have_paths = self._to_full_paths(tags) | self._to_full_paths(must_have)
any_of_paths = self._to_full_paths(any_of)
must_not_paths = self._to_full_paths(must_not)
# Fast path: no filters at all
if not must_have_paths and not any_of_paths and not must_not_paths:
return self.filelist
filtered = []
for f in self.filelist:
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)
if must_have_paths and not must_have_paths.issubset(file_tags):
continue
if any_of_paths and not any_of_paths.intersection(file_tags):
continue
if must_not_paths and must_not_paths.intersection(file_tags):
continue
filtered.append(f)
return filtered
# Backwards compatibility aliases
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
"""Deprecated: Use assign_tag_to_files instead."""
return self.assign_tag_to_files(files_objs, tag)
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
"""Deprecated: Use remove_tag_from_files instead."""
return self.remove_tag_from_files(files_objs, tag)
def close_folder(self):
"""
Safely close current folder - save all metadata and clear state.
This method:
1. Saves metadata for all files
2. Saves folder config
3. Clears file list, folders, and configs
4. Notifies GUI via callback
"""
if not self.current_folder:
return
# Save all file metadata
for f in self.filelist:
try:
f.save_metadata()
except Exception:
pass # Ignore errors during save
# Save folder config
if self.current_folder in self.folder_configs:
self.save_folder_config(self.current_folder)
# Clear state
self.filelist.clear()
self.folders.clear()
self.folder_configs.clear()
self.current_folder = None
self._undo_stack.clear()
self._redo_stack.clear()
# Notify GUI
if self.on_files_changed:
self.on_files_changed([])
def rename_tag_in_files(self, category: str, old_name: str, new_name: str) -> int:
old_tag = Tag(category, old_name)
affected = [f for f in self.filelist if old_tag in f.tags]
snapshot_before = self._snapshot_files(affected)
new_tag = self.tagmanager.rename_tag(category, old_name, new_name)
if new_tag is None:
return 0
for f in affected:
f.tags.remove(old_tag)
f.tags.append(new_tag)
f.save_metadata()
if affected and self.on_files_changed:
self.on_files_changed(self.filelist)
snapshot_after = self._snapshot_files(affected)
def _undo_rename_tag(snap=snapshot_before, cat=category, old=old_name, new=new_name):
self.tagmanager.rename_tag(cat, new, old)
self._restore_snapshot(snap)
if self.on_tags_changed:
self.on_tags_changed()
def _redo_rename_tag(snap=snapshot_after, cat=category, old=old_name, new=new_name):
self.tagmanager.rename_tag(cat, old, new)
self._restore_snapshot(snap)
if self.on_tags_changed:
self.on_tags_changed()
self._push_undo(_UndoEntry(
description=f"Přejmenovat štítek {category}/{old_name}{new_name}",
undo=_undo_rename_tag,
redo=_redo_rename_tag,
))
return len(affected)
def rename_category_in_files(self, old_category: str, new_category: str) -> int:
old_tags = self.tagmanager.get_tags_in_category(old_category)
if not old_tags:
return 0
affected = [f for f in self.filelist
if any(t.category == old_category for t in f.tags)]
snapshot_before = self._snapshot_files(affected)
if not self.tagmanager.rename_category(old_category, new_category):
return 0
for f in affected:
f.tags = [
Tag(new_category, t.name) if t.category == old_category else t
for t in f.tags
]
f.save_metadata()
if affected and self.on_files_changed:
self.on_files_changed(self.filelist)
snapshot_after = self._snapshot_files(affected)
def _undo_rename_cat(snap=snapshot_before, old=old_category, new=new_category):
self.tagmanager.rename_category(new, old)
self._restore_snapshot(snap)
if self.on_tags_changed:
self.on_tags_changed()
def _redo_rename_cat(snap=snapshot_after, old=old_category, new=new_category):
self.tagmanager.rename_category(old, new)
self._restore_snapshot(snap)
if self.on_tags_changed:
self.on_tags_changed()
self._push_undo(_UndoEntry(
description=f"Přejmenovat kategorii {old_category}{new_category}",
undo=_undo_rename_cat,
redo=_redo_rename_cat,
))
return len(affected)
def merge_tag_in_files(self, category: str, source_name: str, target_name: str) -> int:
source_tag = Tag(category, source_name)
target_tag = Tag(category, target_name)
affected = [f for f in self.filelist if source_tag in f.tags]
snapshot_before = self._snapshot_files(affected)
result_tag = self.tagmanager.merge_tag(category, source_name, target_name)
if result_tag is None:
return 0
for f in affected:
f.tags.remove(source_tag)
if target_tag not in f.tags:
f.tags.append(target_tag)
f.save_metadata()
if affected and self.on_files_changed:
self.on_files_changed(self.filelist)
snapshot_after = self._snapshot_files(affected)
def _undo_merge_tag(snap=snapshot_before, cat=category, src=source_name):
self.tagmanager.add_tag(cat, src) # re-add deleted source tag
self._restore_snapshot(snap)
if self.on_tags_changed:
self.on_tags_changed()
def _redo_merge_tag(snap=snapshot_after, cat=category, src=source_name):
self.tagmanager.remove_tag(cat, src) # re-remove source from TM
self._restore_snapshot(snap)
if self.on_tags_changed:
self.on_tags_changed()
self._push_undo(_UndoEntry(
description=f"Sloučit štítek {category}/{source_name}{target_name}",
undo=_undo_merge_tag,
redo=_redo_merge_tag,
))
return len(affected)
def merge_category_in_files(self, source_category: str, target_category: str) -> int:
source_tags = self.tagmanager.get_tags_in_category(source_category)
if not source_tags:
return 0
source_tag_names = [t.name for t in source_tags]
original_target_tag_names = {
t.name for t in self.tagmanager.get_tags_in_category(target_category)
}
affected = [f for f in self.filelist
if any(t.category == source_category for t in f.tags)]
snapshot_before = self._snapshot_files(affected)
if not self.tagmanager.merge_category(source_category, target_category):
return 0
updated_count = 0
for f in affected:
new_tags: list[Tag] = []
for tag in f.tags:
if tag.category == source_category:
new_tag = Tag(target_category, tag.name)
if new_tag not in new_tags:
new_tags.append(new_tag)
else:
if tag not in new_tags:
new_tags.append(tag)
f.tags = new_tags
f.save_metadata()
updated_count += 1
if updated_count > 0 and self.on_files_changed:
self.on_files_changed(self.filelist)
snapshot_after = self._snapshot_files(affected)
def _undo_merge_cat(
snap=snapshot_before,
src_cat=source_category,
tgt_cat=target_category,
src_names=source_tag_names,
orig_tgt=original_target_tag_names,
):
# Remove from target tags that came only from source
for name in src_names:
if name not in orig_tgt:
self.tagmanager.remove_tag(tgt_cat, name)
# Re-create source category
for name in src_names:
self.tagmanager.add_tag(src_cat, name)
self._restore_snapshot(snap)
if self.on_tags_changed:
self.on_tags_changed()
def _redo_merge_cat(
snap=snapshot_after,
src_cat=source_category,
tgt_cat=target_category,
src_names=source_tag_names,
):
for name in src_names:
self.tagmanager.add_tag(tgt_cat, name)
self.tagmanager.remove_category(src_cat)
self._restore_snapshot(snap)
if self.on_tags_changed:
self.on_tags_changed()
self._push_undo(_UndoEntry(
description=f"Sloučit kategorii {source_category}{target_category}",
undo=_undo_merge_cat,
redo=_redo_merge_cat,
))
return updated_count
# Legacy property for backwards compatibility
@property
def config(self):

View File

@@ -1,20 +0,0 @@
from typing import List
from .file import File
class ListManager:
def __init__(self):
# 'name' nebo 'date'
self.sort_mode = "name"
def set_sort(self, mode: str):
if mode in ("name", "date"):
self.sort_mode = mode
def sort_files(self, files: List[File]) -> List[File]:
if self.sort_mode == "name":
return sorted(files, key=lambda f: f.filename.lower())
else:
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
def date_key(f):
return (f.date is None, f.date or "")
return sorted(files, key=date_key)

View File

@@ -1,28 +1,21 @@
# Module header
import sys
import subprocess
from loguru import logger
from .file import File
from .tag_manager import TagManager
if __name__ == "__main__":
sys.exit("This module is not intended to be executed as the main program.")
# Imports
from PIL import Image, ImageTk
# Functions
def load_icon(path) -> ImageTk.PhotoImage:
img = Image.open(path)
img = img.resize((16, 16), Image.Resampling.LANCZOS)
return ImageTk.PhotoImage(img)
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager) -> None:
"""
Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p.
Vyžaduje ffprobe (FFmpeg).
Detect video vertical resolution and assign tag Resolution/{height}p.
Requires ffprobe (FFmpeg).
"""
path = str(file_obj.file_path)
try:
# ffprobe vrátí width a height ve formátu JSON
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path],
@@ -30,13 +23,13 @@ def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
text=True,
check=True
)
res = result.stdout.strip() # např. "1920x1080"
res = result.stdout.strip() # e.g. "1920x1080"
if "x" not in res:
return
width, height = map(int, res.split("x"))
tag_name = f"Rozlišení/{height}p"
tag_obj = tagmanager.add_tag("Rozlišení", f"{height}p")
file_obj.add_tag(tag_obj)
print(f"Přiřazen tag {tag_name} k {file_obj.filename}")
logger.info("Assigned tag {} to {}", tag_name, file_obj.filename)
except Exception as e:
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
logger.error("Failed to get video resolution for {}: {}", file_obj.filename, e)

View File

@@ -3,6 +3,27 @@ class Tag:
self.category = category
self.name = name
@classmethod
def from_string(cls, tag_str: str, default_category: str = "default") -> "Tag":
"""
Parse a tag from 'category/name' string format.
Args:
tag_str: Tag string in 'category/name' format
default_category: Category to use if no '/' in string
Returns:
Tag object
Examples:
Tag.from_string("Stav/Nové") -> Tag("Stav", "Nové")
Tag.from_string("simple") -> Tag("default", "simple")
"""
if "/" in tag_str:
category, name = tag_str.split("/", 1)
return cls(category, name)
return cls(default_category, tag_str)
@property
def full_path(self):
return f"{self.category}/{self.name}"

View File

@@ -64,4 +64,144 @@ class TagManager:
# Sort alphabetically for custom categories
tags.sort(key=lambda t: t.name)
return tags
return tags
def rename_tag(self, category: str, old_name: str, new_name: str) -> Tag | None:
"""
Rename a tag within a category.
Args:
category: The category containing the tag
old_name: Current name of the tag
new_name: New name for the tag
Returns:
The new Tag object if successful, None if tag not found or new name already exists
"""
if category not in self.tags_by_category:
return None
old_tag = Tag(category, old_name)
new_tag = Tag(category, new_name)
# Check if old tag exists
if old_tag not in self.tags_by_category[category]:
return None
# Check if new name already exists (and is different)
if old_name != new_name and new_tag in self.tags_by_category[category]:
return None
# Remove old tag and add new one
self.tags_by_category[category].discard(old_tag)
self.tags_by_category[category].add(new_tag)
return new_tag
def rename_category(self, old_category: str, new_category: str) -> bool:
"""
Rename a category.
Args:
old_category: Current name of the category
new_category: New name for the category
Returns:
True if successful, False if category not found or new name already exists
"""
if old_category not in self.tags_by_category:
return False
# Check if new category already exists (and is different)
if old_category != new_category and new_category in self.tags_by_category:
return False
# Get all tags from old category
old_tags = self.tags_by_category[old_category]
# Create new tags with new category
new_tags = {Tag(new_category, tag.name) for tag in old_tags}
# Remove old category and add new one
del self.tags_by_category[old_category]
self.tags_by_category[new_category] = new_tags
return True
def merge_tag(self, category: str, source_name: str, target_name: str) -> Tag | None:
"""
Merge source tag into target tag (removes source, keeps target).
Args:
category: The category containing both tags
source_name: Name of the tag to merge (will be removed)
target_name: Name of the tag to merge into (will be kept)
Returns:
The target Tag object if successful, None if either tag not found
"""
if category not in self.tags_by_category:
return None
source_tag = Tag(category, source_name)
target_tag = Tag(category, target_name)
# Check if source tag exists
if source_tag not in self.tags_by_category[category]:
return None
# Check if target tag exists
if target_tag not in self.tags_by_category[category]:
return None
# Remove source tag (target already exists)
self.tags_by_category[category].discard(source_tag)
# Clean up empty category
if not self.tags_by_category[category]:
self.remove_category(category)
return target_tag
def merge_category(self, source_category: str, target_category: str) -> bool:
"""
Merge source category into target category (moves all tags, removes source).
Args:
source_category: Category to merge (will be removed)
target_category: Category to merge into (will receive all tags)
Returns:
True if successful, False if either category not found
"""
if source_category not in self.tags_by_category:
return False
if target_category not in self.tags_by_category:
return False
if source_category == target_category:
return True # No-op
# Get all tags from source category
source_tags = self.tags_by_category[source_category]
# Create new tags with target category and add to target
for tag in source_tags:
new_tag = Tag(target_category, tag.name)
self.tags_by_category[target_category].add(new_tag)
# Remove source category
del self.tags_by_category[source_category]
return True
def tag_exists(self, category: str, name: str) -> bool:
"""Check if a tag exists in a category."""
if category not in self.tags_by_category:
return False
return Tag(category, name) in self.tags_by_category[category]
def category_exists(self, category: str) -> bool:
"""Check if a category exists."""
return category in self.tags_by_category

33
src/ui/constants.py Normal file
View File

@@ -0,0 +1,33 @@
"""
Shared UI constants for Tagger GUI.
"""
COLORS = {
"bg": "#ffffff",
"sidebar_bg": "#f5f5f5",
"toolbar_bg": "#f0f0f0",
"selected": "#0078d7",
"selected_text": "#ffffff",
"border": "#d0d0d0",
"status_bg": "#f8f8f8",
"text": "#000000",
}
TAG_COLORS = [
"#e74c3c", # red
"#3498db", # blue
"#2ecc71", # green
"#f39c12", # orange
"#9b59b6", # purple
"#1abc9c", # teal
"#e91e63", # pink
"#00bcd4", # cyan
]
DEFAULT_CATEGORY_COLORS = {
"Hodnocení": "#f1c40f", # gold/yellow for stars
"Barva": "#95a5a6", # gray for color category
}
# Categories where only one tag can be active at a time (radio-button behaviour)
EXCLUSIVE_CATEGORIES: set[str] = {"Hodnocení"}

211
src/ui/dialogs.py Normal file
View File

@@ -0,0 +1,211 @@
"""
Dialogs for Tagger GUI.
"""
from typing import List
from PySide6.QtWidgets import (
QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout,
QScrollArea, QWidget, QLabel, QCheckBox, QPushButton, QFrame,
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from src.core.file import File
from src.core.tag import Tag
from src.core.tag_manager import DEFAULT_TAG_ORDER
from src.ui.constants import EXCLUSIVE_CATEGORIES
class MultiFileTagAssignDialog(QDialog):
"""Dialog for bulk tag assignment to multiple files."""
def __init__(self, parent, all_tags: List[Tag], files: List[File],
category_colors: dict = None):
super().__init__(parent)
self.setWindowTitle("Přiřadit tagy k vybraným souborům")
self.setMinimumSize(500, 600)
self.result = None
self.tags_by_full = {t.full_path: t for t in all_tags}
self.files = files
self.category_colors = category_colors or {}
self.checkboxes: dict[str, QCheckBox] = {}
self.category_checkboxes: dict[str, list] = {}
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
header = QLabel(f"Vybráno souborů: {len(self.files)}")
header.setFont(QFont("Arial", 11, QFont.Bold))
header.setAlignment(Qt.AlignCenter)
layout.addWidget(header)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
content = QWidget()
content_layout = QVBoxLayout(content)
content_layout.setSpacing(2)
file_tag_sets = [{t.full_path for t in f.tags} for f in self.files]
tags_by_category: dict[str, list] = {}
for full_path, tag in self.tags_by_full.items():
tags_by_category.setdefault(tag.category, []).append((full_path, tag))
for category in tags_by_category:
if category in DEFAULT_TAG_ORDER:
order = DEFAULT_TAG_ORDER[category]
tags_by_category[category].sort(
key=lambda x: order.get(x[1].name, 999)
)
else:
tags_by_category[category].sort(key=lambda x: x[1].name)
for category in sorted(tags_by_category.keys()):
color = self.category_colors.get(category, "#333333")
is_exclusive = category in EXCLUSIVE_CATEGORIES
exclusive_note = " (pouze jedno)" if is_exclusive else ""
cat_label = QLabel(f"{category}{exclusive_note}")
cat_label.setFont(QFont("Arial", 10, QFont.Bold))
cat_label.setStyleSheet(f"color: {color}; margin-top: 12px;")
content_layout.addWidget(cat_label)
self.category_checkboxes[category] = []
for full_path, tag in tags_by_category[category]:
have_count = sum(1 for s in file_tag_sets if full_path in s)
if have_count == 0:
init_state = Qt.Unchecked
elif have_count == len(self.files):
init_state = Qt.Checked
else:
init_state = Qt.PartiallyChecked
cb = QCheckBox(f" {tag.name}")
cb.setTristate(True)
cb.setCheckState(init_state)
cb.setProperty("full_path", full_path)
cb.setProperty("category", category)
cb.setProperty("tag_color", color)
self._update_checkbox_style(cb)
cb.stateChanged.connect(lambda state, c=cb: self._on_state_changed(c))
content_layout.addWidget(cb)
self.checkboxes[full_path] = cb
self.category_checkboxes[category].append(cb)
content_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(self._on_ok)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
def _update_checkbox_style(self, cb: QCheckBox) -> None:
state = cb.checkState()
color = cb.property("tag_color") or "#333333"
if state == Qt.Unchecked:
cb.setStyleSheet("color: #666666;")
elif state == Qt.Checked:
cb.setStyleSheet(f"color: {color};")
else:
cb.setStyleSheet("color: #cc6600;")
def _on_state_changed(self, cb: QCheckBox) -> None:
category = cb.property("category")
if category in EXCLUSIVE_CATEGORIES and cb.checkState() == Qt.Checked:
for other_cb in self.category_checkboxes.get(category, []):
if other_cb != cb:
other_cb.blockSignals(True)
other_cb.setCheckState(Qt.Unchecked)
self._update_checkbox_style(other_cb)
other_cb.blockSignals(False)
self._update_checkbox_style(cb)
def _on_ok(self) -> None:
self.result = {}
for full_path, cb in self.checkboxes.items():
state = cb.checkState()
if state == Qt.Checked:
self.result[full_path] = 1
elif state == Qt.Unchecked:
self.result[full_path] = 0
else:
self.result[full_path] = 2 # mixed — don't change
self.accept()
class CategorySelectionDialog(QDialog):
"""Dialog for selecting categories for hardlink structure."""
def __init__(self, parent, categories: List[str], category_colors: dict,
preselected: List[str] | None = None):
super().__init__(parent)
self.setWindowTitle("Vybrat kategorie")
self.setMinimumSize(350, 400)
self.categories = categories
self.category_colors = category_colors
self.preselected = preselected
self.result = None
self.checkboxes: dict[str, QCheckBox] = {}
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
header = QLabel("Vyberte kategorie pro vytvoření struktury:")
header.setFont(QFont("Arial", 10, QFont.Bold))
layout.addWidget(header)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
content = QWidget()
content_layout = QVBoxLayout(content)
for category in sorted(self.categories):
initial_value = self.preselected is None or category in self.preselected
color = self.category_colors.get(category, "#333333")
cb = QCheckBox(category)
cb.setChecked(initial_value)
cb.setStyleSheet(f"color: {color};")
content_layout.addWidget(cb)
self.checkboxes[category] = cb
content_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
sel_layout = QHBoxLayout()
btn_all = QPushButton("Všechny")
btn_all.clicked.connect(self._select_all)
btn_none = QPushButton("Žádné")
btn_none.clicked.connect(self._select_none)
sel_layout.addWidget(btn_all)
sel_layout.addWidget(btn_none)
sel_layout.addStretch()
layout.addLayout(sel_layout)
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(self._on_ok)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
def _select_all(self) -> None:
for cb in self.checkboxes.values():
cb.setChecked(True)
def _select_none(self) -> None:
for cb in self.checkboxes.values():
cb.setChecked(False)
def _on_ok(self) -> None:
self.result = [cat for cat, cb in self.checkboxes.items() if cb.isChecked()]
self.accept()

File diff suppressed because it is too large Load Diff

1185
src/ui/main_window.py Normal file

File diff suppressed because it is too large Load Diff

22
src/ui/utils.py Normal file
View File

@@ -0,0 +1,22 @@
"""
UI utility functions for Tagger GUI (PySide6).
"""
from pathlib import Path
from PySide6.QtGui import QIcon, QPixmap
def load_icon(path: str | Path, size: int = 16) -> QIcon:
"""
Load an icon from file and optionally resize.
Args:
path: Path to the image file
size: Icon size in pixels (default 16)
Returns:
QIcon object
"""
pixmap = QPixmap(str(path))
if not pixmap.isNull():
pixmap = pixmap.scaled(size, size)
return QIcon(pixmap)

30
src/ui/workers.py Normal file
View File

@@ -0,0 +1,30 @@
"""
Background QThread workers for Tagger GUI.
"""
from PySide6.QtCore import QThread, Signal
from src.core.media_utils import add_video_resolution_tag
class VideoResolutionWorker(QThread):
"""Runs ffprobe on a list of files in a background thread."""
progress = Signal(int, int) # (current, total)
finished = Signal(int) # count of successfully tagged files
def __init__(self, files: list, tagmanager) -> None:
super().__init__()
self.files = files
self.tagmanager = tagmanager
def run(self) -> None:
count = 0
total = len(self.files)
for i, f in enumerate(self.files, 1):
try:
add_video_resolution_tag(f, self.tagmanager)
count += 1
except Exception:
pass
self.progress.emit(i, total)
self.finished.emit(count)

View File

@@ -4,8 +4,7 @@ from pathlib import Path
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
get_folder_config_path, folder_has_config, FOLDER_CONFIG_NAME
)
@@ -266,36 +265,6 @@ class TestFolderConfig:
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["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"""

262
tests/test_csfd.py Normal file
View File

@@ -0,0 +1,262 @@
"""Tests for CSFD.cz scraper module."""
import pytest
from unittest.mock import patch, MagicMock
from src.core.csfd import (
CSFDMovie,
fetch_movie,
search_movies,
fetch_movie_by_id,
_extract_csfd_id,
_parse_duration,
_extract_json_ld,
_extract_rating,
_extract_poster,
_extract_plot,
_extract_genres,
_extract_origin_info,
_check_dependencies,
)
# Sample HTML for testing
SAMPLE_JSON_LD = """
{
"@type": "Movie",
"name": "Test Movie",
"director": [{"@type": "Person", "name": "Test Director"}],
"actor": [{"@type": "Person", "name": "Actor 1"}, {"@type": "Person", "name": "Actor 2"}],
"aggregateRating": {"ratingValue": 85.5, "ratingCount": 1000},
"duration": "PT120M",
"description": "A test movie description."
}
"""
SAMPLE_HTML = """
<html>
<head>
<script type="application/ld+json">%s</script>
</head>
<body>
<div class="film-rating-average">85%%</div>
<div class="genres">
<a href="/zanry/1/">Drama</a> /
<a href="/zanry/2/">Thriller</a>
</div>
<div class="origin">Česko, 2020, 120 min</div>
<div class="film-poster">
<img src="//image.example.com/poster.jpg">
</div>
<div class="plot-full"><p>Full plot description.</p></div>
</body>
</html>
""" % SAMPLE_JSON_LD
class TestCSFDMovie:
"""Tests for CSFDMovie dataclass."""
def test_csfd_movie_basic(self):
"""Test basic CSFDMovie creation."""
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
assert movie.title == "Test"
assert movie.url == "https://csfd.cz/film/123/"
assert movie.year is None
assert movie.genres == []
assert movie.rating is None
def test_csfd_movie_full(self):
"""Test CSFDMovie with all fields."""
movie = CSFDMovie(
title="Test Movie",
url="https://csfd.cz/film/123/",
year=2020,
genres=["Drama", "Thriller"],
directors=["Director 1"],
actors=["Actor 1", "Actor 2"],
rating=85,
rating_count=1000,
duration=120,
country="Česko",
poster_url="https://image.example.com/poster.jpg",
plot="A test movie.",
csfd_id=123
)
assert movie.year == 2020
assert movie.genres == ["Drama", "Thriller"]
assert movie.rating == 85
assert movie.duration == 120
assert movie.csfd_id == 123
def test_csfd_movie_str(self):
"""Test CSFDMovie string representation."""
movie = CSFDMovie(
title="Test Movie",
url="https://csfd.cz/film/123/",
year=2020,
genres=["Drama"],
directors=["Director 1"],
rating=85
)
s = str(movie)
assert "Test Movie (2020)" in s
assert "85%" in s
assert "Drama" in s
assert "Director 1" in s
def test_csfd_movie_str_minimal(self):
"""Test CSFDMovie string with minimal data."""
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
s = str(movie)
assert "Test" in s
class TestHelperFunctions:
"""Tests for helper functions."""
def test_extract_csfd_id_valid(self):
"""Test extracting CSFD ID from valid URL."""
assert _extract_csfd_id("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/") == 9423
assert _extract_csfd_id("https://www.csfd.cz/film/123456/") == 123456
assert _extract_csfd_id("/film/999/prehled/") == 999
def test_extract_csfd_id_invalid(self):
"""Test extracting CSFD ID from invalid URL."""
assert _extract_csfd_id("https://www.csfd.cz/") is None
assert _extract_csfd_id("not-a-url") is None
def test_parse_duration_valid(self):
"""Test parsing ISO 8601 duration."""
assert _parse_duration("PT97M") == 97
assert _parse_duration("PT120M") == 120
assert _parse_duration("PT60M") == 60
def test_parse_duration_invalid(self):
"""Test parsing invalid duration."""
assert _parse_duration("") is None
assert _parse_duration("invalid") is None
assert _parse_duration("PT") is None
class TestHTMLExtraction:
"""Tests for HTML extraction functions."""
@pytest.fixture
def soup(self):
"""Create BeautifulSoup object from sample HTML."""
from bs4 import BeautifulSoup
return BeautifulSoup(SAMPLE_HTML, "html.parser")
def test_extract_json_ld(self, soup):
"""Test extracting data from JSON-LD."""
data = _extract_json_ld(soup)
assert data["title"] == "Test Movie"
assert data["directors"] == ["Test Director"]
assert data["actors"] == ["Actor 1", "Actor 2"]
assert data["rating"] == 86 # Rounded from 85.5
assert data["rating_count"] == 1000
assert data["duration"] == 120
def test_extract_rating(self, soup):
"""Test extracting rating from HTML."""
rating = _extract_rating(soup)
assert rating == 85
def test_extract_genres(self, soup):
"""Test extracting genres from HTML."""
genres = _extract_genres(soup)
assert "Drama" in genres
assert "Thriller" in genres
def test_extract_poster(self, soup):
"""Test extracting poster URL."""
poster = _extract_poster(soup)
assert poster == "https://image.example.com/poster.jpg"
def test_extract_plot(self, soup):
"""Test extracting plot."""
plot = _extract_plot(soup)
assert plot == "Full plot description."
def test_extract_origin_info(self, soup):
"""Test extracting origin info."""
info = _extract_origin_info(soup)
assert info["country"] == "Česko"
assert info["year"] == 2020
assert info["duration"] == 120
class TestFetchMovie:
"""Tests for fetch_movie function."""
@patch("src.core.csfd.requests")
def test_fetch_movie_success(self, mock_requests):
"""Test successful movie fetch."""
mock_response = MagicMock()
mock_response.text = SAMPLE_HTML
mock_response.raise_for_status = MagicMock()
mock_requests.get.return_value = mock_response
movie = fetch_movie("https://www.csfd.cz/film/123-test/")
assert movie.title == "Test Movie"
assert movie.csfd_id == 123
assert movie.rating == 86
assert "Drama" in movie.genres
mock_requests.get.assert_called_once()
@patch("src.core.csfd.requests")
def test_fetch_movie_network_error(self, mock_requests):
"""Test network error handling."""
import requests as real_requests
mock_requests.get.side_effect = real_requests.RequestException("Network error")
with pytest.raises(real_requests.RequestException):
fetch_movie("https://www.csfd.cz/film/123/")
class TestSearchMovies:
"""Tests for search_movies function."""
@patch("src.core.csfd.requests")
def test_search_movies(self, mock_requests):
"""Test movie search."""
search_html = """
<html><body>
<a href="/film/123-test/" class="film-title-name">Test Movie</a>
<a href="/film/456-another/" class="film-title-name">Another Movie</a>
</body></html>
"""
mock_response = MagicMock()
mock_response.text = search_html
mock_response.raise_for_status = MagicMock()
mock_requests.get.return_value = mock_response
mock_requests.utils.quote = lambda x: x
results = search_movies("test", limit=10)
assert len(results) >= 1
assert any(m.csfd_id == 123 for m in results)
class TestFetchMovieById:
"""Tests for fetch_movie_by_id function."""
@patch("src.core.csfd.fetch_movie")
def test_fetch_by_id(self, mock_fetch):
"""Test fetching movie by ID."""
mock_fetch.return_value = CSFDMovie(title="Test", url="https://csfd.cz/film/9423/")
movie = fetch_movie_by_id(9423)
mock_fetch.assert_called_once_with("https://www.csfd.cz/film/9423/")
assert movie.title == "Test"
class TestDependencyCheck:
"""Tests for dependency checking."""
def test_dependencies_available(self):
"""Test that dependencies are available (they should be in test env)."""
# Should not raise
_check_dependencies()

View File

@@ -263,3 +263,155 @@ class TestFile:
tag_paths2 = {tag.full_path for tag in file_obj2.tags}
assert tag_paths == tag_paths2
assert file_obj2.date == "2025-01-01"
class TestFileCSFDIntegration:
"""Testy pro CSFD integraci v File"""
@pytest.fixture
def temp_dir(self, tmp_path):
return tmp_path
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def test_file(self, temp_dir):
test_file = temp_dir / "film.mkv"
test_file.write_text("video content")
return test_file
def test_file_csfd_url_initial(self, test_file, tag_manager):
"""Test že csfd_url je None při vytvoření"""
file_obj = File(test_file, tag_manager)
assert file_obj.csfd_url is None
def test_file_set_csfd_url(self, test_file, tag_manager):
"""Test nastavení CSFD URL"""
file_obj = File(test_file, tag_manager)
file_obj.set_csfd_url("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/")
assert file_obj.csfd_url == "https://www.csfd.cz/film/9423-pane-vy-jste-vdova/"
def test_file_set_csfd_url_persistence(self, test_file, tag_manager):
"""Test že CSFD URL přežije reload"""
file_obj = File(test_file, tag_manager)
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
file_obj2 = File(test_file, tag_manager)
assert file_obj2.csfd_url == "https://www.csfd.cz/film/123/"
def test_file_set_csfd_url_none(self, test_file, tag_manager):
"""Test smazání CSFD URL"""
file_obj = File(test_file, tag_manager)
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
file_obj.set_csfd_url(None)
assert file_obj.csfd_url is None
def test_file_set_csfd_url_empty(self, test_file, tag_manager):
"""Test nastavení prázdného řetězce jako CSFD URL"""
file_obj = File(test_file, tag_manager)
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
file_obj.set_csfd_url("")
assert file_obj.csfd_url is None
def test_file_csfd_url_in_metadata(self, test_file, tag_manager):
"""Test že CSFD URL je uložena v metadatech"""
file_obj = File(test_file, tag_manager)
file_obj.set_csfd_url("https://www.csfd.cz/film/999/")
import json
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
data = json.load(f)
assert data["csfd_url"] == "https://www.csfd.cz/film/999/"
def test_apply_csfd_tags_no_url(self, test_file, tag_manager):
"""Test apply_csfd_tags bez nastaveného URL"""
file_obj = File(test_file, tag_manager)
result = file_obj.apply_csfd_tags()
assert result["success"] is False
assert "URL není nastavena" in result["error"]
assert result["tags_added"] == []
@pytest.fixture
def mock_csfd_movie(self):
"""Mock CSFDMovie pro testování"""
from unittest.mock import MagicMock
movie = MagicMock()
movie.title = "Test Film"
movie.year = 2020
movie.genres = ["Komedie", "Drama"]
movie.country = "Česko"
movie.rating = 85
return movie
def test_apply_csfd_tags_success(self, test_file, tag_manager, mock_csfd_movie):
"""Test úspěšného načtení tagů z CSFD"""
from unittest.mock import patch
file_obj = File(test_file, tag_manager)
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
with patch("src.core.csfd.fetch_movie", return_value=mock_csfd_movie):
result = file_obj.apply_csfd_tags()
assert result["success"] is True
assert "Žánr/Komedie" in result["tags_added"]
assert "Žánr/Drama" in result["tags_added"]
assert "Rok/2020" in result["tags_added"]
assert "Země/Česko" in result["tags_added"]
# Kontrola že tagy jsou opravdu přidány
tag_paths = {tag.full_path for tag in file_obj.tags}
assert "Žánr/Komedie" in tag_paths
assert "Žánr/Drama" in tag_paths
assert "Rok/2020" in tag_paths
assert "Země/Česko" in tag_paths
def test_apply_csfd_tags_genres_only(self, test_file, tag_manager, mock_csfd_movie):
"""Test načtení pouze žánrů"""
from unittest.mock import patch
file_obj = File(test_file, tag_manager)
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
with patch("src.core.csfd.fetch_movie", return_value=mock_csfd_movie):
result = file_obj.apply_csfd_tags(add_genres=True, add_year=False, add_country=False)
assert result["success"] is True
assert "Žánr/Komedie" in result["tags_added"]
assert "Rok/2020" not in result["tags_added"]
assert "Země/Česko" not in result["tags_added"]
def test_apply_csfd_tags_no_duplicate(self, test_file, tag_manager, mock_csfd_movie):
"""Test že duplicitní tagy nejsou přidány"""
from unittest.mock import patch
file_obj = File(test_file, tag_manager)
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
# Přidáme tag ručně
file_obj.add_tag("Žánr/Komedie")
with patch("src.core.csfd.fetch_movie", return_value=mock_csfd_movie):
result = file_obj.apply_csfd_tags()
# Komedie by neměla být v tags_added, protože už existuje
assert "Žánr/Komedie" not in result["tags_added"]
assert "Žánr/Drama" in result["tags_added"]
def test_apply_csfd_tags_network_error(self, test_file, tag_manager):
"""Test chyby při načítání z CSFD"""
from unittest.mock import patch
file_obj = File(test_file, tag_manager)
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
with patch("src.core.csfd.fetch_movie", side_effect=Exception("Network error")):
result = file_obj.apply_csfd_tags()
assert result["success"] is False
assert "error" in result
assert result["tags_added"] == []

View File

@@ -556,3 +556,433 @@ class TestFileManagerEdgeCases:
filenames = {f.filename for f in file_manager.filelist}
assert "soubor s mezerami.txt" in filenames
assert "čeština.txt" in filenames
class TestFileManagerCloseFolder:
"""Testy pro close_folder metodu"""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_dir = tmp_path / "config"
config_dir.mkdir()
config_path = config_dir / "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):
data_dir = tmp_path / "data"
data_dir.mkdir()
(data_dir / "file1.txt").write_text("content1")
(data_dir / "file2.txt").write_text("content2")
return data_dir
def test_close_folder_clears_state(self, file_manager, temp_dir):
"""Test že close_folder vymaže stav"""
file_manager.append(temp_dir)
assert len(file_manager.filelist) == 2
assert file_manager.current_folder == temp_dir
file_manager.close_folder()
assert len(file_manager.filelist) == 0
assert len(file_manager.folders) == 0
assert file_manager.current_folder is None
assert len(file_manager.folder_configs) == 0
def test_close_folder_saves_metadata(self, file_manager, temp_dir):
"""Test že close_folder uloží metadata"""
file_manager.append(temp_dir)
# Find file1.txt specifically
file = next(f for f in file_manager.filelist if f.filename == "file1.txt")
file.add_tag("Test/CloseTag")
file_manager.close_folder()
# Reload file and check tag persists
from src.core.file import File
reloaded = File(temp_dir / "file1.txt", file_manager.tagmanager)
tag_paths = {t.full_path for t in reloaded.tags}
assert "Test/CloseTag" in tag_paths
def test_close_folder_callback(self, file_manager, temp_dir):
"""Test že close_folder volá callback"""
file_manager.append(temp_dir)
callback_calls = []
def callback(filelist):
callback_calls.append(len(filelist))
file_manager.on_files_changed = callback
file_manager.close_folder()
assert len(callback_calls) == 1
assert callback_calls[0] == 0 # Empty list after close
def test_close_folder_no_folder_open(self, file_manager):
"""Test close_folder bez otevřené složky"""
# Should not raise
file_manager.close_folder()
assert file_manager.current_folder is None
def test_close_folder_preserves_global_config(self, file_manager, temp_dir):
"""Test že close_folder zachová global config"""
file_manager.append(temp_dir)
file_manager.global_config["test_key"] = "test_value"
file_manager.close_folder()
assert file_manager.global_config.get("test_key") == "test_value"
class TestFileManagerRenameTag:
"""Testy pro přejmenování tagů v souborech"""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_dir = tmp_path / "config"
config_dir.mkdir()
config_path = config_dir / "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):
data_dir = tmp_path / "data"
data_dir.mkdir()
(data_dir / "file1.txt").write_text("content1")
(data_dir / "file2.txt").write_text("content2")
(data_dir / "file3.txt").write_text("content3")
return data_dir
def test_rename_tag_in_files_success(self, file_manager, temp_dir):
"""Test úspěšného přejmenování tagu v souborech"""
file_manager.append(temp_dir)
# Přidat tag dvěma souborům
files_to_tag = file_manager.filelist[:2]
file_manager.assign_tag_to_file_objects(files_to_tag, "Video/HD")
# Přejmenovat tag
updated_count = file_manager.rename_tag_in_files("Video", "HD", "FullHD")
assert updated_count == 2
# Zkontrolovat že tagy jsou přejmenovány
for f in files_to_tag:
tag_paths = {t.full_path for t in f.tags}
assert "Video/FullHD" in tag_paths
assert "Video/HD" not in tag_paths
def test_rename_tag_in_files_persistence(self, file_manager, temp_dir):
"""Test že přejmenovaný tag přežije reload"""
file_manager.append(temp_dir)
file = file_manager.filelist[0]
file_manager.assign_tag_to_file_objects([file], "Video/HD")
file_manager.rename_tag_in_files("Video", "HD", "FullHD")
# Reload soubor
from src.core.file import File
reloaded = File(file.file_path, file_manager.tagmanager)
tag_paths = {t.full_path for t in reloaded.tags}
assert "Video/FullHD" in tag_paths
assert "Video/HD" not in tag_paths
def test_rename_tag_in_files_no_match(self, file_manager, temp_dir):
"""Test přejmenování tagu který žádný soubor nemá"""
file_manager.append(temp_dir)
file_manager.assign_tag_to_file_objects(file_manager.filelist[:1], "Video/HD")
updated_count = file_manager.rename_tag_in_files("Video", "4K", "UHD")
assert updated_count == 0
def test_rename_tag_in_files_nonexistent_category(self, file_manager, temp_dir):
"""Test přejmenování tagu v neexistující kategorii"""
file_manager.append(temp_dir)
updated_count = file_manager.rename_tag_in_files("NonExistent", "Tag", "NewTag")
assert updated_count == 0
def test_rename_tag_in_files_callback(self, file_manager, temp_dir):
"""Test že přejmenování tagu volá callback"""
file_manager.append(temp_dir)
file_manager.assign_tag_to_file_objects(file_manager.filelist[:1], "Video/HD")
callback_calls = []
def callback(filelist):
callback_calls.append(len(filelist))
file_manager.on_files_changed = callback
file_manager.rename_tag_in_files("Video", "HD", "FullHD")
assert len(callback_calls) == 1
def test_rename_tag_preserves_other_tags(self, file_manager, temp_dir):
"""Test že přejmenování jednoho tagu neovlivní ostatní tagy souboru"""
file_manager.append(temp_dir)
file = file_manager.filelist[0]
file_manager.assign_tag_to_file_objects([file], "Video/HD")
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
file_manager.assign_tag_to_file_objects([file], "Quality/High")
file_manager.rename_tag_in_files("Video", "HD", "FullHD")
tag_paths = {t.full_path for t in file.tags}
assert "Video/FullHD" in tag_paths
assert "Audio/Stereo" in tag_paths
assert "Quality/High" in tag_paths
def test_rename_category_in_files_success(self, file_manager, temp_dir):
"""Test úspěšného přejmenování kategorie v souborech"""
file_manager.append(temp_dir)
# Přidat tagy ze stejné kategorie
file_manager.assign_tag_to_file_objects(file_manager.filelist[:2], "Video/HD")
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/4K")
# Přejmenovat kategorii
updated_count = file_manager.rename_category_in_files("Video", "Rozlišení")
assert updated_count == 2
# Zkontrolovat že tagy mají novou kategorii
file1 = file_manager.filelist[0]
tag_paths = {t.full_path for t in file1.tags}
assert "Rozlišení/HD" in tag_paths
assert "Rozlišení/4K" in tag_paths
assert "Video/HD" not in tag_paths
assert "Video/4K" not in tag_paths
def test_rename_category_in_files_persistence(self, file_manager, temp_dir):
"""Test že přejmenovaná kategorie přežije reload"""
file_manager.append(temp_dir)
file = file_manager.filelist[0]
file_manager.assign_tag_to_file_objects([file], "Video/HD")
file_manager.rename_category_in_files("Video", "Rozlišení")
# Reload soubor
from src.core.file import File
reloaded = File(file.file_path, file_manager.tagmanager)
tag_paths = {t.full_path for t in reloaded.tags}
assert "Rozlišení/HD" in tag_paths
assert "Video/HD" not in tag_paths
def test_rename_category_in_files_no_match(self, file_manager, temp_dir):
"""Test přejmenování kategorie kterou žádný soubor nemá"""
file_manager.append(temp_dir)
file_manager.assign_tag_to_file_objects(file_manager.filelist[:1], "Video/HD")
updated_count = file_manager.rename_category_in_files("Audio", "Sound")
assert updated_count == 0
def test_rename_category_in_files_nonexistent(self, file_manager, temp_dir):
"""Test přejmenování neexistující kategorie"""
file_manager.append(temp_dir)
updated_count = file_manager.rename_category_in_files("NonExistent", "NewName")
assert updated_count == 0
def test_rename_category_preserves_other_categories(self, file_manager, temp_dir):
"""Test že přejmenování kategorie neovlivní jiné kategorie"""
file_manager.append(temp_dir)
file = file_manager.filelist[0]
file_manager.assign_tag_to_file_objects([file], "Video/HD")
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
file_manager.rename_category_in_files("Video", "Rozlišení")
tag_paths = {t.full_path for t in file.tags}
assert "Rozlišení/HD" in tag_paths
assert "Audio/Stereo" in tag_paths
class TestFileManagerMergeTag:
"""Testy pro slučování tagů v souborech"""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_dir = tmp_path / "config"
config_dir.mkdir()
config_path = config_dir / "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):
data_dir = tmp_path / "data"
data_dir.mkdir()
(data_dir / "file1.txt").write_text("content1")
(data_dir / "file2.txt").write_text("content2")
(data_dir / "file3.txt").write_text("content3")
return data_dir
def test_merge_tag_in_files_success(self, file_manager, temp_dir):
"""Test úspěšného sloučení tagů v souborech"""
file_manager.append(temp_dir)
# Přidat oba tagy - jeden soubor má HD, druhý má FullHD
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD")
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Video/FullHD")
# Sloučit HD do FullHD
updated_count = file_manager.merge_tag_in_files("Video", "HD", "FullHD")
assert updated_count == 1
# Soubor 0 by měl mít FullHD místo HD
tag_paths = {t.full_path for t in file_manager.filelist[0].tags}
assert "Video/FullHD" in tag_paths
assert "Video/HD" not in tag_paths
def test_merge_tag_in_files_file_has_both(self, file_manager, temp_dir):
"""Test sloučení když soubor má oba tagy"""
file_manager.append(temp_dir)
# Soubor má oba tagy
file = file_manager.filelist[0]
file_manager.assign_tag_to_file_objects([file], "Video/HD")
file_manager.assign_tag_to_file_objects([file], "Video/FullHD")
# Sloučit HD do FullHD - HD by měl být odstraněn
updated_count = file_manager.merge_tag_in_files("Video", "HD", "FullHD")
assert updated_count == 1
tag_paths = {t.full_path for t in file.tags}
assert "Video/FullHD" in tag_paths
assert "Video/HD" not in tag_paths
# FullHD by měl být jen jednou
fullhd_count = sum(1 for t in file.tags if t.full_path == "Video/FullHD")
assert fullhd_count == 1
def test_merge_tag_in_files_persistence(self, file_manager, temp_dir):
"""Test že sloučený tag přežije reload"""
file_manager.append(temp_dir)
file = file_manager.filelist[0]
file_manager.assign_tag_to_file_objects([file], "Video/HD")
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Video/FullHD")
file_manager.merge_tag_in_files("Video", "HD", "FullHD")
# Reload soubor
from src.core.file import File
reloaded = File(file.file_path, file_manager.tagmanager)
tag_paths = {t.full_path for t in reloaded.tags}
assert "Video/FullHD" in tag_paths
assert "Video/HD" not in tag_paths
def test_merge_tag_in_files_no_source(self, file_manager, temp_dir):
"""Test sloučení když žádný soubor nemá zdrojový tag"""
file_manager.append(temp_dir)
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/FullHD")
updated_count = file_manager.merge_tag_in_files("Video", "HD", "FullHD")
assert updated_count == 0
def test_merge_tag_preserves_other_tags(self, file_manager, temp_dir):
"""Test že sloučení neovlivní ostatní tagy"""
file_manager.append(temp_dir)
file = file_manager.filelist[0]
file_manager.assign_tag_to_file_objects([file], "Video/HD")
file_manager.assign_tag_to_file_objects([file], "Video/FullHD")
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
file_manager.merge_tag_in_files("Video", "HD", "FullHD")
tag_paths = {t.full_path for t in file.tags}
assert "Video/FullHD" in tag_paths
assert "Audio/Stereo" in tag_paths
def test_merge_category_in_files_success(self, file_manager, temp_dir):
"""Test úspěšného sloučení kategorií v souborech"""
file_manager.append(temp_dir)
# Přidat tagy z různých kategorií
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD")
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Rozlišení/4K")
# Sloučit Video do Rozlišení
updated_count = file_manager.merge_category_in_files("Video", "Rozlišení")
assert updated_count == 1
# Soubor 0 by měl mít Rozlišení/HD místo Video/HD
tag_paths = {t.full_path for t in file_manager.filelist[0].tags}
assert "Rozlišení/HD" in tag_paths
assert "Video/HD" not in tag_paths
def test_merge_category_in_files_persistence(self, file_manager, temp_dir):
"""Test že sloučená kategorie přežije reload"""
file_manager.append(temp_dir)
file = file_manager.filelist[0]
file_manager.assign_tag_to_file_objects([file], "Video/HD")
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Rozlišení/4K")
file_manager.merge_category_in_files("Video", "Rozlišení")
# Reload soubor
from src.core.file import File
reloaded = File(file.file_path, file_manager.tagmanager)
tag_paths = {t.full_path for t in reloaded.tags}
assert "Rozlišení/HD" in tag_paths
assert "Video/HD" not in tag_paths
def test_merge_category_no_source_files(self, file_manager, temp_dir):
"""Test sloučení když žádný soubor nemá zdrojovou kategorii"""
file_manager.append(temp_dir)
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Rozlišení/4K")
updated_count = file_manager.merge_category_in_files("Video", "Rozlišení")
assert updated_count == 0
def test_merge_category_preserves_other_categories(self, file_manager, temp_dir):
"""Test že sloučení kategorie neovlivní jiné kategorie"""
file_manager.append(temp_dir)
file = file_manager.filelist[0]
file_manager.assign_tag_to_file_objects([file], "Video/HD")
file_manager.assign_tag_to_file_objects([file], "Rozlišení/4K")
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
file_manager.merge_category_in_files("Video", "Rozlišení")
tag_paths = {t.full_path for t in file.tags}
assert "Rozlišení/HD" in tag_paths
assert "Rozlišení/4K" in tag_paths
assert "Audio/Stereo" in tag_paths
assert "Video/HD" not in tag_paths

View File

@@ -1,75 +1,90 @@
import tempfile
import struct
import zlib
from pathlib import Path
import pytest
import os
from src.core.media_utils import load_icon
from PIL import Image, ImageTk
import tkinter as tk
# Skip all tests if no display is available (CI environment)
pytestmark = pytest.mark.skipif(
os.environ.get("DISPLAY") is None and os.environ.get("WAYLAND_DISPLAY") is None,
reason="No display available for GUI tests"
)
@pytest.fixture(scope="module")
def tk_root():
"""Fixture pro inicializaci Tkinteru (nutné pro ImageTk)."""
root = tk.Tk()
yield root
root.destroy()
def qapp():
"""Fixture to initialize QApplication for Qt tests."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
app = QApplication([])
yield app
def test_load_icon_returns_photoimage(tk_root):
"""Test že load_icon vrací PhotoImage"""
# vytvoříme dočasný obrázek
def _make_png(path: Path, width: int = 32, height: int = 32) -> None:
"""Write a minimal valid PNG file without Pillow."""
def chunk(name: bytes, data: bytes) -> bytes:
c = struct.pack(">I", len(data)) + name + data
return c + struct.pack(">I", zlib.crc32(name + data) & 0xFFFFFFFF)
raw_rows = b"".join(b"\x00" + bytes([255, 0, 0] * width) for _ in range(height))
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
idat = zlib.compress(raw_rows)
png = (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", idat)
+ chunk(b"IEND", b"")
)
path.write_bytes(png)
def test_load_icon_returns_qicon(qapp):
"""Test that load_icon returns QIcon"""
from src.ui.utils import load_icon
from PySide6.QtGui import QIcon
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
# vytvoříme 100x100 červený obrázek
img = Image.new("RGB", (100, 100), color="red")
img.save(tmp_path)
_make_png(tmp_path, 100, 100)
icon = load_icon(tmp_path)
# musí být PhotoImage
assert isinstance(icon, ImageTk.PhotoImage)
# ověříme velikost 16x16
assert icon.width() == 16
assert icon.height() == 16
assert isinstance(icon, QIcon)
assert not icon.isNull()
finally:
tmp_path.unlink(missing_ok=True)
def test_load_icon_resizes_image(tk_root):
"""Test že load_icon správně změní velikost obrázku"""
def test_load_icon_custom_size(qapp):
"""Test that load_icon respects custom size parameter"""
from src.ui.utils import load_icon
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
# vytvoříme velký obrázek 500x500
img = Image.new("RGB", (500, 500), color="blue")
img.save(tmp_path)
icon = load_icon(tmp_path)
# i velký obrázek by měl být zmenšen na 16x16
assert icon.width() == 16
assert icon.height() == 16
_make_png(tmp_path, 500, 500)
icon = load_icon(tmp_path, size=32)
assert not icon.isNull()
sizes = icon.availableSizes()
assert len(sizes) > 0
finally:
tmp_path.unlink(missing_ok=True)
def test_load_icon_different_formats(tk_root):
"""Test načítání různých formátů obrázků"""
formats = [".png", ".jpg", ".bmp"]
def test_load_icon_different_formats(qapp):
"""Test loading different image formats"""
from src.ui.utils import load_icon
from PySide6.QtGui import QIcon
for fmt in formats:
with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
img = Image.new("RGB", (32, 32), color="green")
img.save(tmp_path)
icon = load_icon(tmp_path)
assert isinstance(icon, ImageTk.PhotoImage)
assert icon.width() == 16
assert icon.height() == 16
finally:
tmp_path.unlink(missing_ok=True)
# Only PNG is reliably producible without Pillow; BMP can be crafted too
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
_make_png(tmp_path, 32, 32)
icon = load_icon(tmp_path)
assert isinstance(icon, QIcon)
assert not icon.isNull()
finally:
tmp_path.unlink(missing_ok=True)

View File

@@ -104,3 +104,44 @@ class TestTag:
assert tag.category == "Kategorie"
assert tag.name == "Čeština"
assert tag.full_path == "Kategorie/Čeština"
class TestTagFromString:
"""Testy pro Tag.from_string() class method"""
def test_from_string_with_category(self):
"""Test parsování stringu s kategorií"""
tag = Tag.from_string("Stav/Nové")
assert tag.category == "Stav"
assert tag.name == "Nové"
def test_from_string_without_category(self):
"""Test parsování stringu bez kategorie - použije default"""
tag = Tag.from_string("simple")
assert tag.category == "default"
assert tag.name == "simple"
def test_from_string_custom_default_category(self):
"""Test parsování s vlastní default kategorií"""
tag = Tag.from_string("simple", default_category="Custom")
assert tag.category == "Custom"
assert tag.name == "simple"
def test_from_string_multiple_slashes(self):
"""Test parsování stringu s více lomítky"""
tag = Tag.from_string("Kategorie/Název/s/lomítky")
assert tag.category == "Kategorie"
assert tag.name == "Název/s/lomítky"
def test_from_string_unicode(self):
"""Test parsování unicode stringu"""
tag = Tag.from_string("Žánr/Komedie")
assert tag.category == "Žánr"
assert tag.name == "Komedie"
def test_from_string_equality(self):
"""Test že from_string vytváří ekvivalentní tag"""
tag1 = Tag("Stav", "Nové")
tag2 = Tag.from_string("Stav/Nové")
assert tag1 == tag2
assert hash(tag1) == hash(tag2)

View File

@@ -325,3 +325,287 @@ class TestDefaultTags:
tm.add_tag("Hodnocení", "Custom Rating")
assert len(tm.get_tags_in_category("Hodnocení")) == initial_count + 1
class TestRenameTag:
"""Testy pro přejmenování tagů a kategorií"""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def empty_tag_manager(self):
tm = TagManager()
for category in list(tm.tags_by_category.keys()):
tm.remove_category(category)
return tm
def test_rename_tag_success(self, empty_tag_manager):
"""Test úspěšného přejmenování tagu"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
new_tag = tm.rename_tag("Video", "HD", "FullHD")
assert new_tag is not None
assert new_tag.name == "FullHD"
assert new_tag.category == "Video"
# Old tag should not exist
tags = tm.get_tags_in_category("Video")
tag_names = [t.name for t in tags]
assert "HD" not in tag_names
assert "FullHD" in tag_names
def test_rename_tag_nonexistent_category(self, empty_tag_manager):
"""Test přejmenování tagu v neexistující kategorii"""
result = empty_tag_manager.rename_tag("Nonexistent", "Tag", "NewTag")
assert result is None
def test_rename_tag_nonexistent_tag(self, empty_tag_manager):
"""Test přejmenování neexistujícího tagu"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
result = tm.rename_tag("Video", "Nonexistent", "NewTag")
assert result is None
def test_rename_tag_to_existing_name(self, empty_tag_manager):
"""Test přejmenování tagu na existující název"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
tm.add_tag("Video", "4K")
result = tm.rename_tag("Video", "HD", "4K")
assert result is None
# Original tags should still exist
tags = tm.get_tags_in_category("Video")
tag_names = [t.name for t in tags]
assert "HD" in tag_names
assert "4K" in tag_names
def test_rename_tag_same_name(self, empty_tag_manager):
"""Test přejmenování tagu na stejný název (no-op)"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
new_tag = tm.rename_tag("Video", "HD", "HD")
# Should succeed but effectively be a no-op
assert new_tag is not None
assert new_tag.name == "HD"
def test_rename_category_success(self, empty_tag_manager):
"""Test úspěšného přejmenování kategorie"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
tm.add_tag("Video", "4K")
result = tm.rename_category("Video", "Rozlišení")
assert result is True
assert "Video" not in tm.get_categories()
assert "Rozlišení" in tm.get_categories()
# Tags should be moved to new category
tags = tm.get_tags_in_category("Rozlišení")
tag_names = [t.name for t in tags]
assert "HD" in tag_names
assert "4K" in tag_names
def test_rename_category_nonexistent(self, empty_tag_manager):
"""Test přejmenování neexistující kategorie"""
result = empty_tag_manager.rename_category("Nonexistent", "NewName")
assert result is False
def test_rename_category_to_existing_name(self, empty_tag_manager):
"""Test přejmenování kategorie na existující název"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
tm.add_tag("Audio", "MP3")
result = tm.rename_category("Video", "Audio")
assert result is False
# Original categories should still exist
assert "Video" in tm.get_categories()
assert "Audio" in tm.get_categories()
def test_rename_category_same_name(self, empty_tag_manager):
"""Test přejmenování kategorie na stejný název"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
result = tm.rename_category("Video", "Video")
# Should succeed but effectively be a no-op
assert result is True
assert "Video" in tm.get_categories()
def test_rename_tag_preserves_other_tags(self, empty_tag_manager):
"""Test že přejmenování jednoho tagu neovlivní ostatní"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
tm.add_tag("Video", "4K")
tm.add_tag("Video", "SD")
tm.rename_tag("Video", "HD", "FullHD")
tags = tm.get_tags_in_category("Video")
tag_names = [t.name for t in tags]
assert len(tag_names) == 3
assert "FullHD" in tag_names
assert "4K" in tag_names
assert "SD" in tag_names
assert "HD" not in tag_names
def test_rename_category_preserves_tags(self, empty_tag_manager):
"""Test že přejmenování kategorie zachová všechny tagy"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
tm.add_tag("Video", "4K")
tm.add_tag("Video", "SD")
tm.rename_category("Video", "Rozlišení")
tags = tm.get_tags_in_category("Rozlišení")
assert len(tags) == 3
tag_names = [t.name for t in tags]
assert "HD" in tag_names
assert "4K" in tag_names
assert "SD" in tag_names
class TestMergeTag:
"""Testy pro slučování tagů a kategorií"""
@pytest.fixture
def empty_tag_manager(self):
tm = TagManager()
for category in list(tm.tags_by_category.keys()):
tm.remove_category(category)
return tm
def test_merge_tag_success(self, empty_tag_manager):
"""Test úspěšného sloučení tagů"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
tm.add_tag("Video", "FullHD")
result = tm.merge_tag("Video", "HD", "FullHD")
assert result is not None
assert result.name == "FullHD"
tags = tm.get_tags_in_category("Video")
tag_names = [t.name for t in tags]
assert "FullHD" in tag_names
assert "HD" not in tag_names
assert len(tag_names) == 1
def test_merge_tag_nonexistent_source(self, empty_tag_manager):
"""Test sloučení neexistujícího zdrojového tagu"""
tm = empty_tag_manager
tm.add_tag("Video", "FullHD")
result = tm.merge_tag("Video", "HD", "FullHD")
assert result is None
def test_merge_tag_nonexistent_target(self, empty_tag_manager):
"""Test sloučení do neexistujícího cílového tagu"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
result = tm.merge_tag("Video", "HD", "FullHD")
assert result is None
def test_merge_tag_nonexistent_category(self, empty_tag_manager):
"""Test sloučení v neexistující kategorii"""
result = empty_tag_manager.merge_tag("Nonexistent", "HD", "FullHD")
assert result is None
def test_merge_tag_preserves_other_tags(self, empty_tag_manager):
"""Test že sloučení jednoho tagu neovlivní ostatní"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
tm.add_tag("Video", "4K")
tm.add_tag("Video", "SD")
tm.merge_tag("Video", "HD", "4K")
tags = tm.get_tags_in_category("Video")
tag_names = [t.name for t in tags]
assert len(tag_names) == 2
assert "4K" in tag_names
assert "SD" in tag_names
assert "HD" not in tag_names
def test_merge_category_success(self, empty_tag_manager):
"""Test úspěšného sloučení kategorií"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
tm.add_tag("Video", "4K")
tm.add_tag("Rozlišení", "SD")
result = tm.merge_category("Video", "Rozlišení")
assert result is True
assert "Video" not in tm.get_categories()
assert "Rozlišení" in tm.get_categories()
tags = tm.get_tags_in_category("Rozlišení")
tag_names = [t.name for t in tags]
assert "HD" in tag_names
assert "4K" in tag_names
assert "SD" in tag_names
def test_merge_category_nonexistent_source(self, empty_tag_manager):
"""Test sloučení neexistující zdrojové kategorie"""
tm = empty_tag_manager
tm.add_tag("Rozlišení", "HD")
result = tm.merge_category("Video", "Rozlišení")
assert result is False
def test_merge_category_nonexistent_target(self, empty_tag_manager):
"""Test sloučení do neexistující cílové kategorie"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
result = tm.merge_category("Video", "Rozlišení")
assert result is False
def test_merge_category_same_category(self, empty_tag_manager):
"""Test sloučení kategorie se sebou samou"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
result = tm.merge_category("Video", "Video")
assert result is True # No-op, should succeed
def test_merge_category_duplicate_tags(self, empty_tag_manager):
"""Test sloučení kategorií s duplicitními tagy"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
tm.add_tag("Video", "4K")
tm.add_tag("Rozlišení", "HD") # Same tag name in target
result = tm.merge_category("Video", "Rozlišení")
assert result is True
tags = tm.get_tags_in_category("Rozlišení")
tag_names = [t.name for t in tags]
# HD should appear only once (set deduplication)
assert tag_names.count("HD") == 1
assert "4K" in tag_names
def test_tag_exists(self, empty_tag_manager):
"""Test kontroly existence tagu"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
assert tm.tag_exists("Video", "HD") is True
assert tm.tag_exists("Video", "4K") is False
assert tm.tag_exists("Nonexistent", "HD") is False
def test_category_exists(self, empty_tag_manager):
"""Test kontroly existence kategorie"""
tm = empty_tag_manager
tm.add_tag("Video", "HD")
assert tm.category_exists("Video") is True
assert tm.category_exists("Nonexistent") is False

194
tests/test_undo_redo.py Normal file
View File

@@ -0,0 +1,194 @@
"""
Tests for FileManager undo/redo stack.
"""
import pytest
from pathlib import Path
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
@pytest.fixture
def config_dir(tmp_path, monkeypatch):
cfg = tmp_path / "cfg"
cfg.mkdir()
monkeypatch.setattr("src.core.config.GLOBAL_CONFIG_FILE", cfg / ".Tagger.!gtag")
monkeypatch.setattr("src.core.config._XDG_CONFIG_DIR", cfg)
@pytest.fixture
def fm(tmp_path, config_dir):
tm = TagManager()
manager = FileManager(tm)
# Two in-memory File objects (no real disk files needed for tag ops)
f1 = File.__new__(File)
f1.file_path = tmp_path / "a.txt"
f1.tags = []
f1.tagmanager = tm
f1.csfd_url = None
f1.date = None
f1.csfd_cache = None
f2 = File.__new__(File)
f2.file_path = tmp_path / "b.txt"
f2.tags = []
f2.tagmanager = tm
f2.csfd_url = None
f2.date = None
f2.csfd_cache = None
# Patch save_metadata to be a no-op
f1.save_metadata = lambda: None
f2.save_metadata = lambda: None
manager.filelist = [f1, f2]
manager._f1, manager._f2 = f1, f2
return manager
class TestUndoRedoAssign:
def test_assign_undo_redo(self, fm):
tag = fm.tagmanager.add_tag("Žánr", "Drama")
fm.assign_tag_to_files([fm._f1], tag)
assert tag in fm._f1.tags
assert fm.can_undo()
assert not fm.can_redo()
fm.undo()
assert tag not in fm._f1.tags
assert not fm.can_undo()
assert fm.can_redo()
fm.redo()
assert tag in fm._f1.tags
def test_remove_undo_redo(self, fm):
tag = fm.tagmanager.add_tag("Žánr", "Komedie")
fm._f1.tags = [tag]
fm.remove_tag_from_files([fm._f1], tag)
assert tag not in fm._f1.tags
fm.undo()
assert tag in fm._f1.tags
fm.redo()
assert tag not in fm._f1.tags
def test_assign_noop_not_pushed(self, fm):
"""Assign to file that already has tag should not push undo entry."""
tag = fm.tagmanager.add_tag("Žánr", "Drama")
fm._f1.tags = [tag]
fm.assign_tag_to_files([fm._f1], tag)
assert not fm.can_undo()
def test_redo_cleared_on_new_op(self, fm):
tag = fm.tagmanager.add_tag("Žánr", "Drama")
fm.assign_tag_to_files([fm._f1], tag)
fm.undo()
assert fm.can_redo()
tag2 = fm.tagmanager.add_tag("Žánr", "Thriller")
fm.assign_tag_to_files([fm._f1], tag2)
assert not fm.can_redo()
def test_undo_empty_returns_none(self, fm):
assert fm.undo() is None
def test_redo_empty_returns_none(self, fm):
assert fm.redo() is None
class TestUndoRedoRename:
def test_rename_tag_undo_redo(self, fm):
fm.tagmanager.add_tag("Žánr", "Drama")
tag_old = Tag("Žánr", "Drama")
fm._f1.tags = [tag_old]
fm._f2.tags = [tag_old]
count = fm.rename_tag_in_files("Žánr", "Drama", "Thriller")
assert count == 2
assert Tag("Žánr", "Thriller") in fm._f1.tags
assert fm.tagmanager.tag_exists("Žánr", "Thriller")
assert not fm.tagmanager.tag_exists("Žánr", "Drama")
fm.undo()
assert Tag("Žánr", "Drama") in fm._f1.tags
assert fm.tagmanager.tag_exists("Žánr", "Drama")
assert not fm.tagmanager.tag_exists("Žánr", "Thriller")
fm.redo()
assert Tag("Žánr", "Thriller") in fm._f1.tags
assert fm.tagmanager.tag_exists("Žánr", "Thriller")
def test_rename_category_undo_redo(self, fm):
fm.tagmanager.add_tag("StaráKat", "X")
tag = Tag("StaráKat", "X")
fm._f1.tags = [tag]
fm.rename_category_in_files("StaráKat", "NováKat")
assert Tag("NováKat", "X") in fm._f1.tags
assert fm.tagmanager.category_exists("NováKat")
assert not fm.tagmanager.category_exists("StaráKat")
fm.undo()
assert Tag("StaráKat", "X") in fm._f1.tags
assert fm.tagmanager.category_exists("StaráKat")
assert not fm.tagmanager.category_exists("NováKat")
fm.redo()
assert Tag("NováKat", "X") in fm._f1.tags
class TestUndoRedoMerge:
def test_merge_tag_undo_redo(self, fm):
fm.tagmanager.add_tag("Žánr", "Drama")
fm.tagmanager.add_tag("Žánr", "Thriller")
fm._f1.tags = [Tag("Žánr", "Drama")]
fm._f2.tags = [Tag("Žánr", "Drama"), Tag("Žánr", "Thriller")]
fm.merge_tag_in_files("Žánr", "Drama", "Thriller")
assert Tag("Žánr", "Thriller") in fm._f1.tags
assert Tag("Žánr", "Drama") not in fm._f1.tags
assert not fm.tagmanager.tag_exists("Žánr", "Drama")
fm.undo()
assert Tag("Žánr", "Drama") in fm._f1.tags
assert Tag("Žánr", "Drama") not in fm._f2.tags or Tag("Žánr", "Thriller") in fm._f2.tags
assert fm.tagmanager.tag_exists("Žánr", "Drama")
fm.redo()
assert Tag("Žánr", "Thriller") in fm._f1.tags
assert not fm.tagmanager.tag_exists("Žánr", "Drama")
def test_merge_category_undo_redo(self, fm):
fm.tagmanager.add_tag("SrcKat", "A")
fm.tagmanager.add_tag("TgtKat", "B")
fm._f1.tags = [Tag("SrcKat", "A")]
fm._f2.tags = [Tag("TgtKat", "B")]
fm.merge_category_in_files("SrcKat", "TgtKat")
assert Tag("TgtKat", "A") in fm._f1.tags
assert not fm.tagmanager.category_exists("SrcKat")
fm.undo()
assert Tag("SrcKat", "A") in fm._f1.tags
assert fm.tagmanager.category_exists("SrcKat")
assert not fm.tagmanager.tag_exists("TgtKat", "A")
fm.redo()
assert Tag("TgtKat", "A") in fm._f1.tags
assert not fm.tagmanager.category_exists("SrcKat")
class TestUndoLimit:
def test_max_undo_entries(self, fm):
from src.core.file_manager import _MAX_UNDO
tag = fm.tagmanager.add_tag("T", "x")
for _ in range(_MAX_UNDO + 5):
fm._f1.tags = []
fm.assign_tag_to_files([fm._f1], tag)
assert len(fm._undo_stack) == _MAX_UNDO