From 2bcd5b1f4b1dcfd267c17a75952dd486aaad19d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Sat, 24 Jan 2026 07:50:19 +0100 Subject: [PATCH] GUI rework to Qt6 --- .gitignore | 21 +- CHANGELOG.md | 175 ++- PROJECT.md | 101 ++ PROJECT_NOTES.md | 562 -------- Tagger.spec | 15 +- data/HLS/Rozlišení/4K/50.png | Bin 9053 -> 0 bytes data/HLS/Rozlišení/4K/DORMER_PRAMET.PDF | 210 --- data/HLS/Rozlišení/FullHD/50.png | Bin 9053 -> 0 bytes poetry.lock | 410 ++++-- pyproject.toml | 32 +- src/core/_version.py | 3 + src/core/constants.py | 108 +- src/core/media_utils.py | 21 +- src/ui/gui.py | 1733 ++++++++++++----------- src/ui/utils.py | 19 +- tests/test_media_utils.py | 73 +- 16 files changed, 1613 insertions(+), 1870 deletions(-) create mode 100644 PROJECT.md delete mode 100644 PROJECT_NOTES.md delete mode 100644 data/HLS/Rozlišení/4K/50.png delete mode 100644 data/HLS/Rozlišení/4K/DORMER_PRAMET.PDF delete mode 100644 data/HLS/Rozlišení/FullHD/50.png create mode 100644 src/core/_version.py diff --git a/.gitignore b/.gitignore index 91fdaf0..1568ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,19 @@ -.venv -__pycache__ -.pytest_cache -build -.claude +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +build/ +.claude/ +.env # Config a temp soubory *.!tag *.!ftag -*.!gtag \ No newline at end of file +*.!gtag + +# Documentation not to commit +DESIGN_DOCUMENT.md +AGENTS.md +.claudeignore +TEMPLATE.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0d5c7..5d21896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,68 +1,133 @@ # 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.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** - Automatická detekce pomocí ffprobe -- **Klávesové zkratky** - - Ctrl+O - Otevřít složku - - Ctrl+T - Přiřadit tagy - - Ctrl+D - Nastavit datum - - F5 - Obnovit - - Delete - Odstranit z indexu +### 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 diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..512136b --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,101 @@ +# Tagger - Project Documentation + +**Version:** 1.1.0 | **Status:** Stable | **GUI:** PySide6/Qt6 + +--- + +## About + +Desktop app for organizing files using hierarchical tags (category/name). + +**Features:** Folder scanning, tag filtering, rename/merge tags, CSFD.cz integration, hardlink structure, 3-level config (global/folder/file). + +--- + +## 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 +│ ├── file_manager.py # File management, filtering +│ ├── config.py # 3-level config system +│ ├── hardlink_manager.py +│ ├── csfd.py # CSFD scraper +│ ├── 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/tkinter in src/core/ +3. **Dependency injection** - pass via constructor +4. **UTF-8 everywhere** - `encoding='utf-8'`, `ensure_ascii=False` + +--- + +## Config Files + +| Level | File | Contents | +|-------|------|----------| +| Global | `.Tagger.!gtag` | window geometry, last folder | +| Folder | `.Tagger.!ftag` | ignore patterns, hardlink settings | +| File | `.filename.!tag` | tags, date, state | + +--- + +## Key Components + +**Tag** - immutable, `Tag(category, name)`, `Tag.from_string("cat/name")` + +**File** - `file_path`, `tags[]`, `date`, `csfd_url`, metadata in `.filename.!tag` + +**TagManager** - `add_tag()`, `get_categories()`, `rename_tag()`, `merge_tag()` + +**FileManager** - `append(folder)`, `filter_files_by_tags()`, `close_folder()` + +**HardlinkManager** - `create_structure_for_files()`, `sync_structure()` + +--- + +## 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 | `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. + +--- + +## Metrics + +- **Tests:** 274 ✅ +- **Python:** 3.13+ +- **Dependencies:** PySide6, Pillow, requests, beautifulsoup4 diff --git a/PROJECT_NOTES.md b/PROJECT_NOTES.md deleted file mode 100644 index 77dd6e7..0000000 --- a/PROJECT_NOTES.md +++ /dev/null @@ -1,562 +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-29 -**Verze:** 1.0.4 -**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ů -- Přejmenování tagů a kategorií (včetně aktualizace všech souborů) -- 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ý) -- CSFD.cz integrace - získávání informací o filmech z české filmové databáze - ---- - -## 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, from_string parser) -│ │ ├── 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 # add_video_resolution_tag (ffprobe) -│ │ ├── csfd.py # CSFD.cz scraper (fetch_movie, search_movies) -│ │ └── constants.py # APP_NAME, VERSION, APP_VIEWPORT -│ │ -│ └── ui/ -│ ├── gui.py # Moderní qBittorrent-style GUI -│ └── utils.py # load_icon() - GUI utility pro ikony -│ -├── tests/ # 274 testů, 100% core coverage -│ ├── __init__.py -│ ├── conftest.py # Pytest fixtures -│ ├── test_tag.py # 19 testů (včetně Tag.from_string) -│ ├── test_tag_manager.py # 55 testů (včetně rename/merge tagů/kategorií) -│ ├── test_file.py # 33 testů (včetně CSFD integrace) -│ ├── test_file_manager.py # 78 testů (close_folder, rename/merge v souborech) -│ ├── test_config.py # 31 testů -│ ├── test_hardlink_manager.py # 28 testů -│ ├── test_utils.py # 17 testů -│ ├── test_media_utils.py # 3 testy (load_icon v src/ui/utils.py) -│ └── test_csfd.py # 19 testů -│ -├── 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. CSFD Scraper (filmové informace) - -```python -from src.core.csfd import fetch_movie, search_movies, CSFDMovie - -# Načtení informací o filmu z URL -movie = fetch_movie("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/") -print(movie.title) # „Pane, vy jste vdova!" -print(movie.year) # 1970 -print(movie.rating) # 82 -print(movie.genres) # ['Komedie', 'Sci-Fi'] -print(movie.directors) # ['Václav Vorlíček'] -print(movie.actors) # ['Iva Janžurová', ...] - -# Vyhledávání filmů -results = search_movies("Pelíšky") -for m in results: - print(m.title, m.csfd_id) -``` - -**CSFDMovie atributy:** -- `title` - název filmu -- `url` - CSFD URL -- `year` - rok vydání -- `genres` - seznam žánrů -- `directors` - seznam režisérů -- `actors` - seznam herců -- `rating` - hodnocení v % -- `rating_count` - počet hodnocení -- `duration` - délka v minutách -- `country` - země původu -- `poster_url` - URL plakátu -- `plot` - popis děje -- `csfd_id` - ID filmu na CSFD - -**Závislosti:** `requests`, `beautifulsoup4` (instalace: `poetry add requests beautifulsoup4`) - -### 6. 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 - -``` -: - -[optional body] - -🤖 Generated with Claude Code -Co-Authored-By: Claude Opus 4.5 -``` - -**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:** 274 (všechny ✅) -**Test coverage:** 100% core modulů -**Python verze:** 3.12+ -**Dependencies:** Pillow (PIL), requests, beautifulsoup4 -**Vývojové prostředí:** Poetry - -**Performance:** -- ✅ Dobré: <1000 souborů -- ⚠️ Přijatelné: 1000-5000 souborů -- ❌ Pomalé: >5000 souborů - ---- - -## Debugování - -### Časté problémy - -**1. TreeView tagy se nezobrazují správně po načtení z CSFD** -``` -# Opraveno: přidán update_idletasks() po refresh_sidebar() -# Pokud stále přetrvává, zkuste F5 nebo znovu otevřít složku -``` - -**2. "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-29 -**Maintainer:** Claude Opus 4.5 + honza diff --git a/Tagger.spec b/Tagger.spec index 84df364..f593d67 100644 --- a/Tagger.spec +++ b/Tagger.spec @@ -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, + upx_exclude=[], + runtime_tmpdir=None, console=False, - onefile=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', -) diff --git a/data/HLS/Rozlišení/4K/50.png b/data/HLS/Rozlišení/4K/50.png deleted file mode 100644 index 802fe335b643fae96b4073814b00017bc8381546..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9053 zcmeAS@N?(olHy`uVBq!ia0y~yU~gbxV6os}0*a(>3=?5sU~J8Fb`J1#c2+1T%1_J8 zNo8Qrm{>c}*5j~)%+dJZrHiKWl?e!*36MR~w4!5i*g_6bueBFeHJ@A>e_@HDXJMg8 z3^!k$PMB=WoYK+)QI>=J0`BVSuHu>>>pd5`_OKuSTT%YL@H?NttJGz)WFI%IigeSG z6E?mXYWSgxp-teBMwLq1tKyR>O!FSUmb2S;&$V*e_nr3c>n9mYJ`nCqpP3hNMEc0( z+0)Kd&bK(zHOZ;Q&nE2xYp489z1@F~?R>D|fbn5BD^q_NH8Jj7o$ZF&Qd%EH3S9zC zPl+4pY0ueivHGc2_Pxpb4Q5;po3_)hSfEfRiPN)VLZB;WYDCASRTGnxTz?z#|8IEg zKgnAyoU=!Udxb;mlOG5FKPcaxR#$Y$m+M%=Iq}>7B-ogCJ1kDSegF8jyB3Up&L5b1 z?Nj)x183AenVz|m6g$=@4J%P#ouIi2U^#6KpjHF6d9i}O!0)oyqAKI?XL zvHWs=hgrM4?l7>oAMj}0blvve-K+O*?@yog_b)?5VbRLN1`fr*c;PJYhy>Dl${@^G zvDCf{D9B#o>Fdh=j9s2zTh4HU-$kI1WQl7;iF1B#Zfaf$gL6@8Vo7R>LV0FMhJw4N zZ$Nk>pEv`90)wZEV@SoVH#ZC!85DR97_`-&Kg{I6-^JQrve;o^u zWMB~C$pF(rY$jlulUWKxH?%PFf@p_B4QwD0q>ZFxor-rGwGt!D#aUm<&dn2cyjcP;o$|=D~VdYcY^<5>N7NyJ}knbyy(7yWEgr z-2+aL;oJ=ZkYU_WY4B+GXt0bX2T-mV%_ZQ;p3%}_w9o|RhS5@Sv{W1|6^B+f2vp6l l0j~700<8rYEyTzu#F%(ia~JSEG!+D;d{0+Dmvv4FO#u7Fip~H4 diff --git a/data/HLS/Rozlišení/4K/DORMER_PRAMET.PDF b/data/HLS/Rozlišení/4K/DORMER_PRAMET.PDF deleted file mode 100644 index df832a2..0000000 --- a/data/HLS/Rozlišení/4K/DORMER_PRAMET.PDF +++ /dev/null @@ -1,210 +0,0 @@ -%PDF-1.6 % -1 0 obj <>/OCGs[5 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream - - - - - application/pdf - - - DORMER PRAMET TM logo black - - - - - Pavel Remeš - Pavel Remes - - - - - Marketing Dpt. 787 53 Sumperk, Czech Republic - - - Adobe Illustrator 27.6 (Windows) - 2024-11-19T12:48:57+02:00 - 2024-11-19T12:48:57+01:00 - 2024-11-19T12:48:57+01:00 - - - - 256 - 72 - JPEG - /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgASAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A8z5JodirsVdirsVdirsV dir6V/5wq/47Hmn/AJh7T/icmAs4Pbvz7/8AJPeaf+YP/mYmAM5cn5+ZJpZB5U8/ecfKd2l15f1a 4sWRgzQo5ML0NaSQtWNx/rLikF9wfkt+bNl+Y/lc33prbaxYssOrWaklVkYVWSOu/pyUPGu4oRvS pi2g28Z/5yO/5yB1mHWbryZ5Su3sorImLV9TgYrM8w+1BE43RY+jsNy23QbkMZSfNL3E7zGd5Hac nkZWYl+XjyO9cLW98/5x5/P7XtM8wWflXzPfSX+h6hItvaXVy5eW0mc8Y/3jmpiY0Uqx+HqKCoIZ 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 6UuDxcFTT0oexwFnHk9swMnYq7FX/9k= - - - - xmp.did:d8460b81-f0b4-b54f-90cb-f1169ae4002d - uuid:04d3f7ee-b4f3-4080-b4f4-fc39258cc8b8 - proof:pdf - uuid:6CC3FC99C2BFDE11AFC3CA46B930BAE5 - - uuid:ae1ad9e8-25ce-45bc-851b-942f5049d62f - xmp.did:351d9a2d-71f1-3d42-ad11-b0eef367c74f - uuid:6CC3FC99C2BFDE11AFC3CA46B930BAE5 - default - - - - - saved - xmp.iid:5872C63803E8E411A4A09BB2B09E70BA - 2015-04-21T11:05:19+02:00 - Adobe Illustrator CS6 (Windows) - / - - - saved - xmp.iid:5FBFBBE305E8E41193D7858916604D92 - 2015-04-21T11:07:52+02:00 - Adobe Bridge CS6 (Windows) - /metadata - - - saved - xmp.iid:D0FCA8F16226E711A2EEF3A1EB1A0515 - 2017-04-21T09:20:06+02:00 - Adobe Illustrator CS6 (Windows) - / - - - saved - xmp.iid:eafbe254-adf0-4c49-ad3a-1734bef02370 - 2019-01-02T14:50:24+01:00 - Adobe Illustrator CC 23.0 (Windows) - / - - - saved - xmp.iid:d4d72b2d-9304-034c-9ced-4fba2904b342 - 2019-01-02T15:06:17+01:00 - Adobe Bridge CC 2017 (Windows) - /metadata - - - saved - xmp.iid:4ccde57e-9c46-2043-a80e-a8cb4f87ab08 - 2019-04-09T13:49:07+02:00 - Adobe Illustrator CC 23.0 (Windows) - / - - - saved - xmp.iid:d8460b81-f0b4-b54f-90cb-f1169ae4002d - 2024-11-19T12:48:09+01:00 - Adobe Illustrator 27.6 (Windows) - / - - - - 1 - False - True - - 106.814775 - 46.000000 - Millimeters - - - - Cyan - Magenta - Yellow - Black - - - - - - Výchozí skupina vzorků - 0 - - - - Adobe PDF library 17.00 - Chief Graphic Designer - Adobe Illustrator - - Unicovska 7 - Šumperk - Czech Republic - 787 53 - +420 583 381 553 - pavel.remes@dormerpramet.com - Czech Republic - - - - - - - - - - - - - - - - - - - - - - - - - - -endstream endobj 3 0 obj <> endobj 7 0 obj <>/Properties<>>>/TrimBox[0.0 0.0 302.782 130.394]/Type/Page>> endobj 8 0 obj <>stream -HlWK$ )֏0f1x݀_ 0GR^ѯ,)з?=yy3ʨg9tc9#vP:s|o?aEKgjFv~<<,G'+?R38%IJiP o+,٭bsR豟G>4[-ā$BU*IτQˆ(BL!fABld4e!4`/?qP -l 56/J[ (| xaIaJ[k篢ON(bG --:l异Q* GVPGyZӽxcP+wѾ@{U׽v>{?cW<tEoH#sg9{Kj,~peQ/|SVa/J,!$_ eYCAjz,7IxS Efm?nj؈Šj2+??s%N#/NEZf9 -x ZЂPb/ 6M%DJ<uA(:Jea; -2ﵮ$Ke!Q;P*oȅ]d#/jm#H?giD!WLd -:"H-0XWVq9lXёh ,8PS"V$- ylՈVҞ19ڃa -jN8T56Qq03g`0+X`W@%;o.lLG! ?v>cOuXS1xYJ<;[[7<1xW,9b1 -am?lOPz$v0m).QHOjܴ܎&le֏GTu}qŎ}+#vձnzVY-g8%tj9rat"vq^ʀU5.'^UM>*3.:N'{7 -Jѱl@5-sv;:hVxDvڊH/eW?¼P5-/l o#h dZ1g@_[\Mhpy6zg^PU5g,kG #Dzl"yWm뮗+FU[yB[&"q3ԂC$[܊{~ls#/VZ}g͕ꣶlNU$>l궠:|4ZxAZy7B5}!M 'jWec``1`Ir)Tnc:+#ɘ>QbƛTI#%2QBkݝ>@uL“[ɕkl;[ynh -܀bޏFhǴIvZˡS`bѤh!1r2ûQ5oNVo ^wgq9mkN]9HoO&aTdOl6% A'2inr(W5]i3v TYҪA.g89W{vc7:G -bۤztx&A. tJ-aod078)IaU;Ku$i =1_#l:> endobj 10 0 obj [/View/Design] endobj 11 0 obj <>>> endobj 9 0 obj <> endobj 6 0 obj [5 0 R] endobj 12 0 obj <> endobj xref -0 13 -0000000000 65535 f -0000000016 00000 n -0000000144 00000 n -0000021179 00000 n -0000000000 00000 f -0000024127 00000 n -0000024423 00000 n -0000021230 00000 n -0000021563 00000 n -0000024313 00000 n -0000024197 00000 n -0000024228 00000 n -0000024446 00000 n -trailer -<<8886194BA5A7D34E82FD2511260ACF59>]>> -startxref -24726 -%%EOF diff --git a/data/HLS/Rozlišení/FullHD/50.png b/data/HLS/Rozlišení/FullHD/50.png deleted file mode 100644 index 802fe335b643fae96b4073814b00017bc8381546..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9053 zcmeAS@N?(olHy`uVBq!ia0y~yU~gbxV6os}0*a(>3=?5sU~J8Fb`J1#c2+1T%1_J8 zNo8Qrm{>c}*5j~)%+dJZrHiKWl?e!*36MR~w4!5i*g_6bueBFeHJ@A>e_@HDXJMg8 z3^!k$PMB=WoYK+)QI>=J0`BVSuHu>>>pd5`_OKuSTT%YL@H?NttJGz)WFI%IigeSG z6E?mXYWSgxp-teBMwLq1tKyR>O!FSUmb2S;&$V*e_nr3c>n9mYJ`nCqpP3hNMEc0( z+0)Kd&bK(zHOZ;Q&nE2xYp489z1@F~?R>D|fbn5BD^q_NH8Jj7o$ZF&Qd%EH3S9zC zPl+4pY0ueivHGc2_Pxpb4Q5;po3_)hSfEfRiPN)VLZB;WYDCASRTGnxTz?z#|8IEg zKgnAyoU=!Udxb;mlOG5FKPcaxR#$Y$m+M%=Iq}>7B-ogCJ1kDSegF8jyB3Up&L5b1 z?Nj)x183AenVz|m6g$=@4J%P#ouIi2U^#6KpjHF6d9i}O!0)oyqAKI?XL zvHWs=hgrM4?l7>oAMj}0blvve-K+O*?@yog_b)?5VbRLN1`fr*c;PJYhy>Dl${@^G zvDCf{D9B#o>Fdh=j9s2zTh4HU-$kI1WQl7;iF1B#Zfaf$gL6@8Vo7R>LV0FMhJw4N zZ$Nk>pEv`90)wZEV@SoVH#ZC!85DR97_`-&Kg{I6-^JQrve;o^u zWMB~C$pF(rY$jlulUWKxH?%PFf@p_B4QwD0q>ZFxor-rGwGt!D#aUm<&dn2cyjcP;o$|=D~VdYcY^<5>N7NyJ}knbyy(7yWEgr z-2+aL;oJ=ZkYU_WY4B+GXt0bX2T-mV%_ZQ;p3%}_w9o|RhS5@Sv{W1|6^B+f2vp6l l0j~700<8rYEyTzu#F%(ia~JSEG!+D;d{0+Dmvv4FO#u7Fip~H4 diff --git a/poetry.lock b/poetry.lock index 36aa9a0..7d78c6e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,17 @@ # This file is automatically @generated by Poetry 2.1.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" @@ -25,14 +37,14 @@ lxml = ["lxml"] [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, - {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] @@ -198,117 +210,146 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[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 = "pefile" +version = "2024.8.26" +description = "Python PE parsing module" +optional = false +python-versions = ">=3.6.0" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"}, + {file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"}, ] [[package]] name = "pillow" -version = "12.0.0" +version = "12.1.0" description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" groups = ["main"] 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 = "pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd"}, + {file = "pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda"}, + {file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7"}, + {file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a"}, + {file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef"}, + {file = "pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09"}, + {file = "pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91"}, + {file = "pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea"}, + {file = "pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3"}, + {file = "pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84"}, + {file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0"}, + {file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b"}, + {file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18"}, + {file = "pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64"}, + {file = "pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75"}, + {file = "pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304"}, + {file = "pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b"}, + {file = "pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661"}, + {file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17"}, + {file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670"}, + {file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616"}, + {file = "pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7"}, + {file = "pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d"}, + {file = "pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179"}, + {file = "pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0"}, + {file = "pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587"}, + {file = "pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c"}, + {file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc"}, + {file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644"}, + {file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c"}, + {file = "pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171"}, + {file = "pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a"}, + {file = "pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45"}, + {file = "pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d"}, + {file = "pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82"}, + {file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4"}, + {file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0"}, + {file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b"}, + {file = "pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65"}, + {file = "pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0"}, + {file = "pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796"}, + {file = "pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd"}, + {file = "pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13"}, + {file = "pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de"}, + {file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9"}, + {file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a"}, + {file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a"}, + {file = "pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030"}, + {file = "pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94"}, + {file = "pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4"}, + {file = "pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2"}, + {file = "pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14"}, + {file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8"}, + {file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924"}, + {file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef"}, + {file = "pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988"}, + {file = "pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6"}, + {file = "pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a"}, + {file = "pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19"}, + {file = "pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9"}, ] [package.extras] @@ -350,6 +391,114 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyinstaller" +version = "6.18.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.18.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:cb7aa5a71bfa7c0af17a4a4e21855663c89e4bd7c40f1d337c8370636d8847c3"}, + {file = "pyinstaller-6.18.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:07785459b3bf8a48889eac0b4d0667ade84aef8930ce030bc7cbb32f41283b33"}, + {file = "pyinstaller-6.18.0-py3-none-manylinux2014_i686.whl", hash = "sha256:f998675b7ccb2dabbb1dc2d6f18af61d55428ad6d38e6c4d700417411b697d37"}, + {file = "pyinstaller-6.18.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:779817a0cf69604cddcdb5be1fd4959dc2ce048d6355c73e5da97884df2f3387"}, + {file = "pyinstaller-6.18.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:31b5d109f8405be0b7cddcede43e7b074792bc9a5bbd54ec000a3e779183c2af"}, + {file = "pyinstaller-6.18.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4328c9837f1aef4fe1a127d4ff1b09a12ce53c827ce87c94117628b0e1fd098b"}, + {file = "pyinstaller-6.18.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:3638fc81eb948e5e5eab1d4ad8f216e3fec6d4a350648304f0adb227b746ee5e"}, + {file = "pyinstaller-6.18.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe59da34269e637f97fd3c43024f764586fc319141d245ff1a2e9af1036aa3"}, + {file = "pyinstaller-6.18.0-py3-none-win32.whl", hash = "sha256:496205e4fa92ec944f9696eb597962a83aef4d4c3479abfab83d730e1edf016b"}, + {file = "pyinstaller-6.18.0-py3-none-win_amd64.whl", hash = "sha256:976fabd90ecfbda47571c87055ad73413ec615ff7dea35e12a4304174de78de9"}, + {file = "pyinstaller-6.18.0-py3-none-win_arm64.whl", hash = "sha256:dba4b70e3c9ba09aab51152c72a08e58a751851548f77ad35944d32a300c8381"}, + {file = "pyinstaller-6.18.0.tar.gz", hash = "sha256:cdc507542783511cad4856fce582fdc37e9f29665ca596889c663c83ec8c6ec9"}, +] + +[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 = ">=2025.9" +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.0" +description = "Community maintained hooks for PyInstaller" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5"}, + {file = "pyinstaller_hooks_contrib-2026.0.tar.gz", hash = "sha256:0120893de491a000845470ca9c0b39284731ac6bace26f6849dea9627aaed48e"}, +] + +[package.dependencies] +packaging = ">=22.0" +setuptools = ">=42.0.0" + +[[package]] +name = "pyside6" +version = "6.10.1" +description = "Python bindings for the Qt cross-platform application and UI framework" +optional = false +python-versions = "<3.15,>=3.9" +groups = ["main"] +files = [ + {file = "pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516"}, + {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa"}, + {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c"}, + {file = "pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e"}, + {file = "pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437"}, +] + +[package.dependencies] +PySide6_Addons = "6.10.1" +PySide6_Essentials = "6.10.1" +shiboken6 = "6.10.1" + +[[package]] +name = "pyside6-addons" +version = "6.10.1" +description = "Python bindings for the Qt cross-platform application and UI framework (Addons)" +optional = false +python-versions = "<3.15,>=3.9" +groups = ["main"] +files = [ + {file = "pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341"}, + {file = "pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db"}, +] + +[package.dependencies] +PySide6_Essentials = "6.10.1" +shiboken6 = "6.10.1" + +[[package]] +name = "pyside6-essentials" +version = "6.10.1" +description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)" +optional = false +python-versions = "<3.15,>=3.9" +groups = ["main"] +files = [ + {file = "pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674"}, + {file = "pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c"}, +] + +[package.dependencies] +shiboken6 = "6.10.1" + [[package]] name = "pytest" version = "9.0.2" @@ -372,6 +521,19 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[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.32.5" @@ -394,16 +556,52 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "setuptools" +version = "80.10.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e"}, + {file = "setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.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)", "platformdirs (>=4.2.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)", "pyobjc (<12) ; sys_platform == \"darwin\" and python_version <= \"3.9\"", "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.14.*)", "pytest-mypy"] + +[[package]] +name = "shiboken6" +version = "6.10.1" +description = "Python/C++ bindings helper module" +optional = false +python-versions = "<3.15,>=3.9" +groups = ["main"] +files = [ + {file = "shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71"}, + {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4"}, + {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0"}, + {file = "shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e"}, + {file = "shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9"}, +] + [[package]] name = "soupsieve" -version = "2.8.1" +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.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434"}, - {file = "soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350"}, + {file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"}, + {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"}, ] [[package]] @@ -420,14 +618,14 @@ files = [ [[package]] name = "urllib3" -version = "2.6.2" +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.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, - {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] @@ -438,5 +636,5 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" -python-versions = "^3.12" -content-hash = "f6001ed675bdcc993bafb4f3170aa7bfc8aa86d75d370acf0063329fccfa7dd9" +python-versions = ">=3.13,<3.15" +content-hash = "81a84a97aa8532b37af24fe1ec6398f0a4cef1993e80e83ff16a5b571df344c6" diff --git a/pyproject.toml b/pyproject.toml index 67f4141..e094b86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,26 @@ -[tool.poetry] +[project] name = "tagger" -version = "1.0.4" -description = "Universal file tagging utility" -authors = ["Jan Doubravský "] +version = "1.1.0" +description = "" +authors = [ + {name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"} +] readme = "README.md" +requires-python = ">=3.13,<3.15" +dependencies = [ + "pillow (>=12.1.0,<13.0.0)", + "requests (>=2.32.5,<3.0.0)", + "beautifulsoup4 (>=4.14.3,<5.0.0)", + "pyside6 (>=6.10.1,<7.0.0)" +] + +[tool.poetry] package-mode = false - -[tool.poetry.dependencies] -python = "^3.12" -pillow = "^12.0.0" -requests = "^2.32.5" -beautifulsoup4 = "^4.14.3" - - [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" - - diff --git a/src/core/_version.py b/src/core/_version.py new file mode 100644 index 0000000..24ce740 --- /dev/null +++ b/src/core/_version.py @@ -0,0 +1,3 @@ +# Auto-generated version file - do not edit manually +# This file is updated from pyproject.toml when available +VERSION = "1.1.0" diff --git a/src/core/constants.py b/src/core/constants.py index e506f2d..7fa3460 100644 --- a/src/core/constants.py +++ b/src/core/constants.py @@ -1,4 +1,106 @@ # src/core/constants.py -VERSION = "v1.0.4" -APP_NAME = "Tagger" -APP_VIEWPORT = "1000x700" \ No newline at end of file +""" +Application constants with dynamic version loading. + +Version is loaded from pyproject.toml if available, otherwise from _version.py. +If ENV_DEBUG=true in .env, " DEV" suffix is added to version. +""" +from pathlib import Path + +# Paths +_ROOT_DIR = Path(__file__).parent.parent.parent +_PYPROJECT_PATH = _ROOT_DIR / "pyproject.toml" +_VERSION_FILE = Path(__file__).parent / "_version.py" +_ENV_FILE = _ROOT_DIR / ".env" + + +def _load_env_debug() -> bool: + """Load ENV_DEBUG from .env file.""" + if not _ENV_FILE.exists(): + return False + try: + with open(_ENV_FILE, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line.startswith("ENV_DEBUG="): + value = line.split("=", 1)[1].strip().lower() + return value in ("true", "1", "yes") + except Exception: + pass + return False + + +def _extract_version_from_toml() -> str | None: + """Extract version from pyproject.toml.""" + if not _PYPROJECT_PATH.exists(): + return None + try: + with open(_PYPROJECT_PATH, "r", encoding="utf-8") as f: + content = f.read() + # Simple parsing - find version = "x.x.x" in [project] section + in_project = False + for line in content.split("\n"): + line = line.strip() + if line == "[project]": + in_project = True + elif line.startswith("[") and in_project: + break + elif in_project and line.startswith("version"): + # version = "1.0.4" + if "=" in line: + value = line.split("=", 1)[1].strip().strip('"').strip("'") + return value + except Exception: + pass + return None + + +def _load_version_from_file() -> str: + """Load version from _version module.""" + try: + from src.core._version import VERSION as _ver + return _ver + except ImportError: + pass + return "0.0.0" + + +def _save_version_to_file(version: str) -> None: + """Save version to _version.py for fallback.""" + try: + content = f'''# Auto-generated version file - do not edit manually +# This file is updated from pyproject.toml when available +VERSION = "{version}" +''' + with open(_VERSION_FILE, "w", encoding="utf-8") as f: + f.write(content) + except Exception: + pass + + +def _get_version() -> str: + """Get version from pyproject.toml or fallback to _version.py.""" + # Try to get from pyproject.toml + toml_version = _extract_version_from_toml() + if toml_version: + # Update _version.py for cases when toml is not available + _save_version_to_file(toml_version) + return toml_version + + # Fallback to _version.py + return _load_version_from_file() + + +# Load configuration +DEBUG = _load_env_debug() +VERSION = _get_version() + +# Add DEV suffix if debug mode +if DEBUG: + VERSION = f"{VERSION} DEV" + +# Application name with version +APP_NAME = f"Tagger v{VERSION}" + +# Default window size +APP_VIEWPORT = "1000x700" diff --git a/src/core/media_utils.py b/src/core/media_utils.py index 3ed9546..b77a7a0 100644 --- a/src/core/media_utils.py +++ b/src/core/media_utils.py @@ -1,6 +1,7 @@ # Module header import sys import subprocess +from loguru import logger from .file import File from .tag_manager import TagManager @@ -8,21 +9,13 @@ if __name__ == "__main__": sys.exit("This module is not intended to be executed as the main program.") -# Backwards compatibility: load_icon moved to src/ui/utils.py -def load_icon(path): - """Deprecated: Use src.ui.utils.load_icon instead.""" - from src.ui.utils import load_icon as _load_icon - return _load_icon(path) - - -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}") \ No newline at end of file + logger.error("Failed to get video resolution for {}: {}", file_obj.filename, e) diff --git a/src/ui/gui.py b/src/ui/gui.py index c96933a..c0e7373 100644 --- a/src/ui/gui.py +++ b/src/ui/gui.py @@ -1,26 +1,34 @@ """ -Modern qBittorrent-style GUI for Tagger +Modern PySide6/Qt6 GUI for Tagger """ import os import sys import subprocess -import tkinter as tk -from tkinter import ttk, simpledialog, messagebox, filedialog +import re from pathlib import Path from typing import List -from src.ui.utils import load_icon +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QSplitter, QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem, + QHeaderView, QMenu, QMenuBar, QToolBar, QStatusBar, QLabel, + QPushButton, QLineEdit, QCheckBox, QDialog, QDialogButtonBox, + QScrollArea, QFrame, QMessageBox, QInputDialog, QFileDialog, + QAbstractItemView, QSizePolicy +) +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QAction, QIcon, QPixmap, QFont, QColor + from src.core.file_manager import FileManager from src.core.tag_manager import TagManager, DEFAULT_TAG_ORDER from src.core.file import File from src.core.tag import Tag -# ListManager removed - sorting implemented directly in GUI -from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT +from src.core.constants import APP_NAME, APP_VIEWPORT from src.core.config import save_global_config from src.core.hardlink_manager import HardlinkManager -# qBittorrent-inspired color scheme +# Color scheme COLORS = { "bg": "#ffffff", "sidebar_bg": "#f5f5f5", @@ -50,64 +58,47 @@ DEFAULT_CATEGORY_COLORS = { "Barva": "#95a5a6", # gray for color category } - # Categories where only one tag can be selected (exclusive/radio behavior) EXCLUSIVE_CATEGORIES = {"Hodnocení"} -class MultiFileTagAssignDialog(tk.Toplevel): +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): + + def __init__(self, parent, all_tags: List[Tag], files: List[File], + category_colors: dict = None): super().__init__(parent) - self.title("Přiřadit tagy k vybraným souborům") + self.setWindowTitle("Přiřadit tagy k vybraným souborům") + self.setMinimumSize(500, 600) self.result = None - self.vars: dict[str, int] = {} - self.checkbuttons: dict[str, tk.Checkbutton] = {} self.tags_by_full = {t.full_path: t for t in all_tags} self.files = files self.category_colors = category_colors or {} - self.category_checkbuttons: dict[str, list] = {} # category -> list of checkbuttons + self.checkboxes: dict[str, QCheckBox] = {} + self.category_checkboxes: dict[str, list] = {} - self.geometry("500x600") - self.minsize(400, 400) - self.configure(bg=COLORS["bg"]) + self._setup_ui() - tk.Label(self, text=f"Vybráno souborů: {len(files)}", - bg=COLORS["bg"], font=("Arial", 11, "bold")).pack(pady=10) + def _setup_ui(self): + layout = QVBoxLayout(self) - # Scrollable frame - canvas = tk.Canvas(self, bg=COLORS["bg"]) - scrollbar = ttk.Scrollbar(self, orient="vertical", command=canvas.yview) - frame = tk.Frame(canvas, bg=COLORS["bg"]) + # Header + header = QLabel(f"Vybráno souborů: {len(self.files)}") + header.setFont(QFont("Arial", 11, QFont.Bold)) + header.setAlignment(Qt.AlignCenter) + layout.addWidget(header) - frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) + # Scrollable content + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) - canvas.pack(side="left", fill="both", expand=True, padx=10) - scrollbar.pack(side="right", fill="y") + content = QWidget() + content_layout = QVBoxLayout(content) + content_layout.setSpacing(2) - # Enable mousewheel scrolling (only when dialog is active) - def on_mousewheel(event): - if canvas.winfo_exists(): - canvas.yview_scroll(int(-1*(event.delta/120)), "units") - - def on_scroll_up(event): - if canvas.winfo_exists(): - canvas.yview_scroll(-1, "units") - - def on_scroll_down(event): - if canvas.winfo_exists(): - canvas.yview_scroll(1, "units") - - canvas.bind("", on_mousewheel) - canvas.bind("", on_scroll_up) - canvas.bind("", on_scroll_down) - frame.bind("", on_mousewheel) - frame.bind("", on_scroll_up) - frame.bind("", on_scroll_down) - - file_tag_sets = [{t.full_path for t in f.tags} for f in files] + # Calculate tag states + file_tag_sets = [{t.full_path for t in f.tags} for f in self.files] # Group by category tags_by_category = {} @@ -120,149 +111,208 @@ class MultiFileTagAssignDialog(tk.Toplevel): 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)) + 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) + # Create category sections for category in sorted(tags_by_category.keys()): color = self.category_colors.get(category, "#333333") is_exclusive = category in EXCLUSIVE_CATEGORIES exclusive_note = " (pouze jedno)" if is_exclusive else "" - cat_label = tk.Label(frame, text=f"▸ {category}{exclusive_note}", bg=COLORS["bg"], - fg=color, font=("Arial", 10, "bold")) - cat_label.pack(fill="x", anchor="w", pady=(12, 4)) + # Category header + 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_checkbuttons[category] = [] + 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 = 0 - elif have_count == len(files): - init = 1 + init_state = Qt.Unchecked + elif have_count == len(self.files): + init_state = Qt.Checked else: - init = 2 # mixed + init_state = Qt.PartiallyChecked - cb = tk.Checkbutton(frame, text=f" {tag.name}", anchor="w", bg=COLORS["bg"], - font=("Arial", 10)) - cb.state_value = init - cb.full_path = full_path - cb.tag_color = color - cb.category = category - cb.pack(fill="x", anchor="w", padx=20) - cb.bind("", self._on_toggle) + 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_look(cb) - self.checkbuttons[full_path] = cb - self.vars[full_path] = init - self.category_checkbuttons[category].append(cb) + # Style based on state + self._update_checkbox_style(cb) + cb.stateChanged.connect(lambda state, c=cb: self._on_state_changed(c)) - btn_frame = tk.Frame(self, bg=COLORS["bg"]) - btn_frame.pack(pady=15) - tk.Button(btn_frame, text="OK", command=self.on_ok, width=12, - font=("Arial", 10)).pack(side="left", padx=5) - tk.Button(btn_frame, text="Zrušit", command=self.destroy, width=12, - font=("Arial", 10)).pack(side="left", padx=5) + content_layout.addWidget(cb) + self.checkboxes[full_path] = cb + self.category_checkboxes[category].append(cb) - self.transient(parent) - self.grab_set() - parent.wait_window(self) + content_layout.addStretch() + scroll.setWidget(content) + layout.addWidget(scroll) - def _on_toggle(self, event): - cb: tk.Checkbutton = event.widget - category = cb.category - cur = cb.state_value + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + button_box.accepted.connect(self._on_ok) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) - # For exclusive categories, uncheck others first + def _update_checkbox_style(self, cb: QCheckBox): + 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: # PartiallyChecked + cb.setStyleSheet("color: #cc6600;") + + def _on_state_changed(self, cb: QCheckBox): + category = cb.property("category") + + # Handle exclusive categories if category in EXCLUSIVE_CATEGORIES: - if cur == 0 or cur == 2: # turning on + if cb.checkState() == Qt.Checked: # Uncheck all others in this category - for other_cb in self.category_checkbuttons.get(category, []): - if other_cb != cb and other_cb.state_value != 0: - other_cb.state_value = 0 - self._update_checkbox_look(other_cb) - cb.state_value = 1 - else: # turning off - cb.state_value = 0 - else: - # Normal toggle behavior - if cur == 0: - cb.state_value = 1 - elif cur == 1: - cb.state_value = 0 - elif cur == 2: - cb.state_value = 1 + 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_look(cb) - return "break" + self._update_checkbox_style(cb) - def _update_checkbox_look(self, cb: tk.Checkbutton): - v = cb.state_value - color = getattr(cb, 'tag_color', '#333333') - if v == 0: - cb.deselect() - cb.config(fg="#666666") - elif v == 1: - cb.select() - cb.config(fg=color) - elif v == 2: - cb.deselect() - cb.config(fg="#cc6600") # orange for mixed - - def on_ok(self): - self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()} - self.destroy() + def _on_ok(self): + 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 App: +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): + layout = QVBoxLayout(self) + + # Header + header = QLabel("Vyberte kategorie pro vytvoření struktury:") + header.setFont(QFont("Arial", 10, QFont.Bold)) + layout.addWidget(header) + + # Scrollable content + 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) + + # Selection buttons + 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) + + # Dialog buttons + 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): + for cb in self.checkboxes.values(): + cb.setChecked(True) + + def _select_none(self): + for cb in self.checkboxes.values(): + cb.setChecked(False) + + def _on_ok(self): + self.result = [cat for cat, cb in self.checkboxes.items() if cb.isChecked()] + self.accept() + + +class MainWindow(QMainWindow): + """Main application window""" + def __init__(self, filehandler: FileManager, tagmanager: TagManager): + super().__init__() self.filehandler = filehandler self.tagmanager = tagmanager + # State - self.states = {} - self.file_items = {} # Treeview item_id -> File object mapping - self.selected_tree_item_for_context = None - self.hide_ignored_var = None + self.tag_states: dict[str, bool] = {} # tag full_path -> checked + self.file_items: dict[int, File] = {} # row -> File mapping + self.tag_tree_items: dict[str, tuple] = {} # full_path -> (item, name) self.filter_text = "" self.show_full_path = False - self.sort_mode = "name" - self.sort_order = "asc" - self.category_colors = {} # category -> color mapping - self.show_csfd_column = True # CSFD column visibility + self.sort_column = 0 + self.sort_order = Qt.AscendingOrder + self.category_colors: dict[str, str] = {} + self.show_csfd_column = True + self.hide_ignored = False self.filehandler.on_files_changed = self.update_files_from_manager - def _on_close(self): - """Save window geometry and close""" - # Check if maximized - is_maximized = self.root.state() == 'zoomed' - self.filehandler.global_config["window_maximized"] = is_maximized - - # Save geometry only when not maximized - if not is_maximized: - self.filehandler.global_config["window_geometry"] = self.root.geometry() - - save_global_config(self.filehandler.global_config) - self.root.destroy() - - def main(self): - root = tk.Tk() - root.title(f"{APP_NAME} {VERSION}") - - # Load window geometry from global config - geometry = self.filehandler.global_config.get("window_geometry", APP_VIEWPORT) - root.geometry(geometry) - if self.filehandler.global_config.get("window_maximized", False): - root.state('zoomed') - - root.configure(bg=COLORS["bg"]) - self.root = root - - # Bind window close to save geometry - root.protocol("WM_DELETE_WINDOW", self._on_close) - - self.hide_ignored_var = tk.BooleanVar(value=False, master=root) + self._setup_window() + self._create_menu() + self._create_toolbar() + self._create_main_layout() + self._create_status_bar() + self._setup_shortcuts() # Load last folder last = self.filehandler.global_config.get("last_folder") @@ -272,265 +322,297 @@ class App: except Exception: pass - # Load icons - self._load_icons() - - # Build UI - self._create_menu() - self._create_toolbar() - self._create_main_layout() - self._create_status_bar() - self._create_context_menus() - self._bind_shortcuts() - # Initial refresh self.refresh_sidebar() self.update_files_from_manager(self.filehandler.filelist) - root.mainloop() + def _setup_window(self): + self.setWindowTitle(APP_NAME) - def _load_icons(self): - """Load application icons""" + # Parse viewport size try: - unchecked = load_icon("src/resources/images/32/32_unchecked.png") - checked = load_icon("src/resources/images/32/32_checked.png") - tag_icon = load_icon("src/resources/images/32/32_tag.png") - self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon} - self.root.unchecked_img = unchecked - self.root.checked_img = checked - self.root.tag_img = tag_icon - except Exception as e: - print(f"Warning: Could not load icons: {e}") - self.icons = {"unchecked": None, "checked": None, "tag": None} + w, h = APP_VIEWPORT.split("x") + self.resize(int(w), int(h)) + except: + self.resize(1000, 700) + + # Load saved geometry + geometry = self.filehandler.global_config.get("window_geometry") + if geometry: + try: + parts = geometry.split("x") + if len(parts) >= 2: + w = int(parts[0]) + h_pos = parts[1].split("+") + h = int(h_pos[0]) + self.resize(w, h) + if len(h_pos) >= 3: + self.move(int(h_pos[1]), int(h_pos[2])) + except: + pass + + if self.filehandler.global_config.get("window_maximized", False): + self.showMaximized() def _create_menu(self): - """Create menu bar""" - menu_bar = tk.Menu(self.root) - self.root.config(menu=menu_bar) + menubar = self.menuBar() # File menu - file_menu = tk.Menu(menu_bar, tearoff=0) - file_menu.add_command(label="Open Folder... (Ctrl+O)", command=self.open_folder_dialog) - file_menu.add_command(label="Zavřít složku (Ctrl+W)", command=self.close_folder) - file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns) - file_menu.add_separator() - file_menu.add_command(label="Exit (Ctrl+Q)", command=self.root.quit) + file_menu = menubar.addMenu("Soubor") + + open_action = QAction("Otevřít složku... (Ctrl+O)", self) + open_action.triggered.connect(self.open_folder_dialog) + file_menu.addAction(open_action) + + close_action = QAction("Zavřít složku (Ctrl+W)", self) + close_action.triggered.connect(self.close_folder) + file_menu.addAction(close_action) + + ignore_action = QAction("Nastavit ignorované vzory", self) + ignore_action.triggered.connect(self.set_ignore_patterns) + file_menu.addAction(ignore_action) + + file_menu.addSeparator() + + exit_action = QAction("Ukončit (Ctrl+Q)", self) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) # View menu - view_menu = tk.Menu(menu_bar, tearoff=0) - view_menu.add_checkbutton( - label="Skrýt ignorované", - variable=self.hide_ignored_var, - command=self.toggle_hide_ignored - ) - self.show_csfd_var = tk.BooleanVar(value=True, master=self.root) - view_menu.add_checkbutton( - label="Zobrazit CSFD sloupec", - variable=self.show_csfd_var, - command=self.toggle_csfd_column - ) - view_menu.add_command(label="Refresh (F5)", command=self.refresh_all) + view_menu = menubar.addMenu("Pohled") + + self.hide_ignored_action = QAction("Skrýt ignorované", self) + self.hide_ignored_action.setCheckable(True) + self.hide_ignored_action.triggered.connect(self.toggle_hide_ignored) + view_menu.addAction(self.hide_ignored_action) + + self.csfd_column_action = QAction("Zobrazit CSFD sloupec", self) + self.csfd_column_action.setCheckable(True) + self.csfd_column_action.setChecked(True) + self.csfd_column_action.triggered.connect(self.toggle_csfd_column) + view_menu.addAction(self.csfd_column_action) + + refresh_action = QAction("Obnovit (F5)", self) + refresh_action.triggered.connect(self.refresh_all) + view_menu.addAction(refresh_action) # Tools menu - tools_menu = tk.Menu(menu_bar, tearoff=0) - tools_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) - tools_menu.add_command(label="Detekovat rozlišení videí", command=self.detect_video_resolution) - tools_menu.add_command(label="Přiřadit tagy (Ctrl+T)", command=self.assign_tag_to_selected_bulk) - tools_menu.add_separator() - tools_menu.add_command(label="Nastavit CSFD URL...", command=self.set_csfd_url_for_selected) - tools_menu.add_command(label="Načíst tagy z CSFD", command=self.apply_csfd_tags_for_selected) - tools_menu.add_separator() - tools_menu.add_command(label="Nastavit hardlink složku...", command=self.configure_hardlink_folder) - tools_menu.add_command(label="Aktualizovat hardlink strukturu", command=self.update_hardlink_structure) - tools_menu.add_command(label="Vytvořit hardlink strukturu...", command=self.create_hardlink_structure) + tools_menu = menubar.addMenu("Nástroje") - menu_bar.add_cascade(label="Soubor", menu=file_menu) - menu_bar.add_cascade(label="Pohled", menu=view_menu) - menu_bar.add_cascade(label="Nástroje", menu=tools_menu) + date_action = QAction("Nastavit datum (Ctrl+D)", self) + date_action.triggered.connect(self.set_date_for_selected) + tools_menu.addAction(date_action) + + resolution_action = QAction("Detekovat rozlišení videí", self) + resolution_action.triggered.connect(self.detect_video_resolution) + tools_menu.addAction(resolution_action) + + tags_action = QAction("Přiřadit tagy (Ctrl+T)", self) + tags_action.triggered.connect(self.assign_tag_to_selected_bulk) + tools_menu.addAction(tags_action) + + tools_menu.addSeparator() + + csfd_url_action = QAction("Nastavit CSFD URL...", self) + csfd_url_action.triggered.connect(self.set_csfd_url_for_selected) + tools_menu.addAction(csfd_url_action) + + csfd_tags_action = QAction("Načíst tagy z CSFD", self) + csfd_tags_action.triggered.connect(self.apply_csfd_tags_for_selected) + tools_menu.addAction(csfd_tags_action) + + tools_menu.addSeparator() + + hardlink_config_action = QAction("Nastavit hardlink složku...", self) + hardlink_config_action.triggered.connect(self.configure_hardlink_folder) + tools_menu.addAction(hardlink_config_action) + + hardlink_update_action = QAction("Aktualizovat hardlink strukturu", self) + hardlink_update_action.triggered.connect(self.update_hardlink_structure) + tools_menu.addAction(hardlink_update_action) + + hardlink_create_action = QAction("Vytvořit hardlink strukturu...", self) + hardlink_create_action.triggered.connect(self.create_hardlink_structure) + tools_menu.addAction(hardlink_create_action) def _create_toolbar(self): - """Create toolbar with buttons""" - toolbar = tk.Frame(self.root, bg=COLORS["toolbar_bg"], height=40, relief=tk.RAISED, bd=1) - toolbar.pack(side=tk.TOP, fill=tk.X) + toolbar = QToolBar() + toolbar.setMovable(False) + self.addToolBar(toolbar) - # Buttons - tk.Button(toolbar, text="📁 Otevřít složku", command=self.open_folder_dialog, - relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=5, pady=5) + # Open folder button + open_btn = QPushButton("📁 Otevřít složku") + open_btn.setFlat(True) + open_btn.clicked.connect(self.open_folder_dialog) + toolbar.addWidget(open_btn) - tk.Button(toolbar, text="🔄 Obnovit", command=self.refresh_all, - relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + # Refresh button + refresh_btn = QPushButton("🔄 Obnovit") + refresh_btn.setFlat(True) + refresh_btn.clicked.connect(self.refresh_all) + toolbar.addWidget(refresh_btn) - ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) + toolbar.addSeparator() - tk.Button(toolbar, text="🏷️ Nový tag", command=lambda: self.tree_add_tag(background=True), - relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + # New tag button + tag_btn = QPushButton("🏷️ Nový tag") + tag_btn.setFlat(True) + tag_btn.clicked.connect(lambda: self.tree_add_tag(background=True)) + toolbar.addWidget(tag_btn) - tk.Button(toolbar, text="📅 Nastavit datum", command=self.set_date_for_selected, - relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + # Set date button + date_btn = QPushButton("📅 Nastavit datum") + date_btn.setFlat(True) + date_btn.clicked.connect(self.set_date_for_selected) + toolbar.addWidget(date_btn) - ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) + toolbar.addSeparator() - # Search box - search_frame = tk.Frame(toolbar, bg=COLORS["toolbar_bg"]) - search_frame.pack(side=tk.RIGHT, padx=10, pady=5) + # Spacer + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + toolbar.addWidget(spacer) - tk.Label(search_frame, text="🔍", bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT) - self.search_var = tk.StringVar() - self.search_var.trace('w', lambda *args: self.on_filter_changed()) - search_entry = tk.Entry(search_frame, textvariable=self.search_var, width=25) - search_entry.pack(side=tk.LEFT, padx=5) + # Search + search_label = QLabel("🔍 ") + toolbar.addWidget(search_label) + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Hledat...") + self.search_input.setFixedWidth(200) + self.search_input.textChanged.connect(self.on_filter_changed) + toolbar.addWidget(self.search_input) def _create_main_layout(self): - """Create main split layout""" - # Main container - main_container = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, sashwidth=5, bg=COLORS["border"]) - main_container.pack(fill=tk.BOTH, expand=True) + central = QWidget() + self.setCentralWidget(central) - # Left sidebar (tags) - self._create_sidebar(main_container) + layout = QHBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) - # Right panel (files table) - self._create_file_panel(main_container) + # Splitter for sidebar and main content + splitter = QSplitter(Qt.Horizontal) - def _create_sidebar(self, parent): - """Create left sidebar with tag tree""" - sidebar_frame = tk.Frame(parent, bg=COLORS["sidebar_bg"], width=250) + # Left sidebar + sidebar = self._create_sidebar() + splitter.addWidget(sidebar) - # Sidebar header - header = tk.Frame(sidebar_frame, bg=COLORS["sidebar_bg"]) - header.pack(fill=tk.X, padx=5, pady=5) + # Right panel (file table) + file_panel = self._create_file_panel() + splitter.addWidget(file_panel) - tk.Label(header, text="📂 Štítky", font=("Arial", 10, "bold"), - bg=COLORS["sidebar_bg"]).pack(side=tk.LEFT) + # Set initial sizes + splitter.setSizes([250, 750]) + + layout.addWidget(splitter) + + def _create_sidebar(self) -> QWidget: + sidebar = QWidget() + sidebar.setMinimumWidth(200) + sidebar.setMaximumWidth(400) + + layout = QVBoxLayout(sidebar) + layout.setContentsMargins(5, 5, 5, 5) + + # Header + header = QLabel("📂 Štítky") + header.setFont(QFont("Arial", 10, QFont.Bold)) + layout.addWidget(header) # Tag tree - tree_frame = tk.Frame(sidebar_frame) - tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + self.tag_tree = QTreeWidget() + self.tag_tree.setHeaderHidden(True) + self.tag_tree.setSelectionMode(QAbstractItemView.SingleSelection) + self.tag_tree.itemClicked.connect(self.on_tree_item_clicked) + self.tag_tree.setContextMenuPolicy(Qt.CustomContextMenu) + self.tag_tree.customContextMenuRequested.connect(self.on_tree_context_menu) - self.tag_tree = ttk.Treeview(tree_frame, selectmode="browse", show="tree") - self.tag_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + layout.addWidget(self.tag_tree) - tree_scroll = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tag_tree.yview) - tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) - self.tag_tree.config(yscrollcommand=tree_scroll.set) + return sidebar - # Bind events - self.tag_tree.bind("", self.on_tree_left_click) - self.tag_tree.bind("", self.on_tree_right_click) - - parent.add(sidebar_frame) - - def _create_file_panel(self, parent): - """Create right panel with file table""" - file_frame = tk.Frame(parent, bg=COLORS["bg"]) + def _create_file_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.setContentsMargins(5, 5, 5, 5) # Control panel - control_frame = tk.Frame(file_frame, bg=COLORS["bg"]) - control_frame.pack(fill=tk.X, padx=5, pady=5) + control_layout = QHBoxLayout() - # View options - tk.Checkbutton(control_frame, text="Plná cesta", variable=tk.BooleanVar(), - command=self.toggle_show_path, bg=COLORS["bg"]).pack(side=tk.LEFT, padx=5) + self.full_path_cb = QCheckBox("Plná cesta") + self.full_path_cb.toggled.connect(self.toggle_show_path) + control_layout.addWidget(self.full_path_cb) + control_layout.addStretch() + layout.addLayout(control_layout) # File table - table_frame = tk.Frame(file_frame) - table_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + self.file_table = QTableWidget() + self.file_table.setColumnCount(5) + self.file_table.setHorizontalHeaderLabels( + ["📄 Název", "📅 Datum", "🏷️ Štítky", "🎬 CSFD", "💾 Velikost"] + ) + self.file_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.file_table.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.file_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.file_table.setSortingEnabled(True) + self.file_table.horizontalHeader().setSectionResizeMode( + 0, QHeaderView.Stretch + ) + self.file_table.horizontalHeader().setSectionResizeMode( + 1, QHeaderView.ResizeToContents + ) + self.file_table.horizontalHeader().setSectionResizeMode( + 2, QHeaderView.ResizeToContents + ) + self.file_table.horizontalHeader().setSectionResizeMode( + 3, QHeaderView.ResizeToContents + ) + self.file_table.horizontalHeader().setSectionResizeMode( + 4, QHeaderView.ResizeToContents + ) + self.file_table.verticalHeader().setVisible(False) - # Define columns (including CSFD) - columns = ("name", "date", "tags", "csfd", "size") - self.file_table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended") + self.file_table.doubleClicked.connect(self.on_file_double_click) + self.file_table.setContextMenuPolicy(Qt.CustomContextMenu) + self.file_table.customContextMenuRequested.connect(self.on_file_context_menu) + self.file_table.selectionModel().selectionChanged.connect( + self.on_selection_changed + ) + self.file_table.horizontalHeader().sortIndicatorChanged.connect( + self.on_sort_changed + ) - # Column headers with sort commands - self.file_table.heading("name", text="📄 Název ▲", command=lambda: self.sort_by_column("name")) - self.file_table.heading("date", text="📅 Datum", command=lambda: self.sort_by_column("date")) - self.file_table.heading("tags", text="🏷️ Štítky") - self.file_table.heading("csfd", text="🎬 CSFD") - self.file_table.heading("size", text="💾 Velikost", command=lambda: self.sort_by_column("size")) + layout.addWidget(self.file_table) - # Column widths - self.file_table.column("name", width=300) - self.file_table.column("date", width=100) - self.file_table.column("tags", width=200) - self.file_table.column("csfd", width=50) - self.file_table.column("size", width=80) - - # Load CSFD column visibility from folder config - self._update_csfd_column_visibility() - - # Scrollbars - vsb = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.file_table.yview) - hsb = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.file_table.xview) - self.file_table.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) - - self.file_table.grid(row=0, column=0, sticky="nsew") - vsb.grid(row=0, column=1, sticky="ns") - hsb.grid(row=1, column=0, sticky="ew") - - table_frame.grid_rowconfigure(0, weight=1) - table_frame.grid_columnconfigure(0, weight=1) - - # Bind events - self.file_table.bind("", self.on_file_double_click) - self.file_table.bind("", self.on_file_right_click) - self.file_table.bind("<>", self.on_selection_changed) - - parent.add(file_frame) + return panel def _create_status_bar(self): - """Create status bar at bottom""" - status_frame = tk.Frame(self.root, bg=COLORS["status_bg"], relief=tk.SUNKEN, bd=1) - status_frame.pack(side=tk.BOTTOM, fill=tk.X) + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) - # Left side - status message - self.status_label = tk.Label(status_frame, text="Připraven", anchor=tk.W, - bg=COLORS["status_bg"], padx=10) - self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) + self.status_label = QLabel("Připraven") + self.status_bar.addWidget(self.status_label, 1) - # Right side - file count - self.file_count_label = tk.Label(status_frame, text="0 souborů", anchor=tk.E, - bg=COLORS["status_bg"], padx=10) - self.file_count_label.pack(side=tk.RIGHT) + self.selected_count_label = QLabel("") + self.status_bar.addPermanentWidget(self.selected_count_label) - # Selected size - self.selected_size_label = tk.Label(status_frame, text="", anchor=tk.E, - bg=COLORS["status_bg"], padx=10) - self.selected_size_label.pack(side=tk.RIGHT) + self.selected_size_label = QLabel("") + self.status_bar.addPermanentWidget(self.selected_size_label) - # Selected count - self.selected_count_label = tk.Label(status_frame, text="", anchor=tk.E, - bg=COLORS["status_bg"], padx=10) - self.selected_count_label.pack(side=tk.RIGHT) + self.file_count_label = QLabel("0 souborů") + self.status_bar.addPermanentWidget(self.file_count_label) - def _create_context_menus(self): - """Create context menus""" - # Tag context menu - self.tag_menu = tk.Menu(self.root, tearoff=0) - self.tag_menu.add_command(label="Nový štítek", command=self.tree_add_tag) - self.tag_menu.add_command(label="Přejmenovat štítek", command=self.tree_rename_tag) - self.tag_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag) + def _setup_shortcuts(self): + from PySide6.QtGui import QShortcut, QKeySequence - # File context menu - self.file_menu = tk.Menu(self.root, tearoff=0) - self.file_menu.add_command(label="Otevřít soubor", command=self.open_selected_files) - self.file_menu.add_command(label="Přiřadit štítky (Ctrl+T)", command=self.assign_tag_to_selected_bulk) - self.file_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) - self.file_menu.add_separator() - self.file_menu.add_command(label="Nastavit CSFD URL...", command=self.set_csfd_url_for_selected) - self.file_menu.add_command(label="Načíst tagy z CSFD", command=self.apply_csfd_tags_for_selected) - self.file_menu.add_separator() - self.file_menu.add_command(label="Smazat z indexu (Del)", command=self.remove_selected_files) - - def _bind_shortcuts(self): - """Bind keyboard shortcuts""" - self.root.bind("", lambda e: self.open_folder_dialog()) - self.root.bind("", lambda e: self.close_folder()) - self.root.bind("", lambda e: self.root.quit()) - self.root.bind("", lambda e: self.assign_tag_to_selected_bulk()) - self.root.bind("", lambda e: self.set_date_for_selected()) - self.root.bind("", lambda e: self.search_var.get()) # Focus search - self.root.bind("", lambda e: self.refresh_all()) - self.root.bind("", lambda e: self.remove_selected_files()) + QShortcut(QKeySequence("Ctrl+O"), self, self.open_folder_dialog) + QShortcut(QKeySequence("Ctrl+W"), self, self.close_folder) + QShortcut(QKeySequence("Ctrl+Q"), self, self.close) + QShortcut(QKeySequence("Ctrl+T"), self, self.assign_tag_to_selected_bulk) + QShortcut(QKeySequence("Ctrl+D"), self, self.set_date_for_selected) + QShortcut(QKeySequence("F5"), self, self.refresh_all) + QShortcut(QKeySequence("Delete"), self, self.remove_selected_files) # ================================================== # SIDEBAR / TAG TREE METHODS @@ -538,305 +620,268 @@ class App: def refresh_sidebar(self): """Refresh tag tree in sidebar""" - # Clear tree - for item in self.tag_tree.get_children(): - self.tag_tree.delete(item) + self.tag_tree.clear() + self.tag_tree_items.clear() + self.tag_states.clear() - # Reset tag item mapping - self.tag_tree_items = {} # full_path -> tree item_id - - # Count files per tag (from all files) + # Count files per tag tag_counts = {} for f in self.filehandler.filelist: for t in f.tags: tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 - # Add root + # Root item total_files = len(self.filehandler.filelist) - root_id = self.tag_tree.insert("", "end", text=f"📂 Všechny soubory ({total_files})", image=self.icons.get("tag")) - self.tag_tree.item(root_id, open=True) - self.root_tag_id = root_id + root = QTreeWidgetItem(self.tag_tree) + root.setText(0, f"📂 Všechny soubory ({total_files})") + root.setExpanded(True) + self.root_item = root # Assign colors to categories categories = self.tagmanager.get_categories() color_index = 0 for category in categories: if category not in self.category_colors: - # Use predefined color for default categories, otherwise cycle through TAG_COLORS if category in DEFAULT_CATEGORY_COLORS: self.category_colors[category] = DEFAULT_CATEGORY_COLORS[category] else: - self.category_colors[category] = TAG_COLORS[color_index % len(TAG_COLORS)] + self.category_colors[category] = TAG_COLORS[ + color_index % len(TAG_COLORS) + ] color_index += 1 # Add categories and tags for category in categories: color = self.category_colors.get(category, "#333333") - cat_id = self.tag_tree.insert(root_id, "end", text=f"📁 {category}", image=self.icons.get("tag"), - tags=(f"cat_{category}",)) - self.states[cat_id] = False + + cat_item = QTreeWidgetItem(root) + cat_item.setText(0, f"📁 {category}") + cat_item.setForeground(0, QColor(color)) + cat_item.setData(0, Qt.UserRole, {"type": "category", "name": category}) for tag in self.tagmanager.get_tags_in_category(category): count = tag_counts.get(tag.full_path, 0) count_str = f" ({count})" if count > 0 else "" - tag_id = self.tag_tree.insert(cat_id, "end", text=f" {tag.name}{count_str}", - image=self.icons.get("unchecked"), - tags=(f"tag_{category}",)) - self.states[tag_id] = False - self.tag_tree_items[tag.full_path] = (tag_id, tag.name) - # Apply color to category tags - self.tag_tree.tag_configure(f"cat_{category}", foreground=color) - self.tag_tree.tag_configure(f"tag_{category}", foreground=color) + tag_item = QTreeWidgetItem(cat_item) + tag_item.setText(0, f"☐ {tag.name}{count_str}") + tag_item.setForeground(0, QColor(color)) + tag_item.setData(0, Qt.UserRole, { + "type": "tag", + "full_path": tag.full_path, + "name": tag.name, + "category": category + }) - # Force tree update - self.tag_tree.update_idletasks() + self.tag_tree_items[tag.full_path] = (tag_item, tag.name) + self.tag_states[tag.full_path] = False - def update_tag_counts(self, filtered_files): - """Update tag counts in sidebar based on filtered files""" - if not hasattr(self, 'tag_tree_items'): - return - - # Count files per tag from filtered files + def update_tag_counts(self, filtered_files: List[File]): + """Update tag counts in sidebar""" tag_counts = {} for f in filtered_files: for t in f.tags: tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 - # Update each tag item text - for full_path, (item_id, tag_name) in self.tag_tree_items.items(): + for full_path, (item, tag_name) in self.tag_tree_items.items(): count = tag_counts.get(full_path, 0) count_str = f" ({count})" if count > 0 else "" - # Preserve the checkbox state - current_text = f" {tag_name}{count_str}" - self.tag_tree.item(item_id, text=current_text) + checked = "☑" if self.tag_states.get(full_path, False) else "☐" + item.setText(0, f"{checked} {tag_name}{count_str}") # Update root count total = len(filtered_files) - self.tag_tree.item(self.root_tag_id, text=f"📂 Všechny soubory ({total})") + self.root_item.setText(0, f"📂 Všechny soubory ({total})") - def on_tree_left_click(self, event): - """Handle left click on tag tree""" - region = self.tag_tree.identify("region", event.x, event.y) - if region not in ("tree", "icon"): + def on_tree_item_clicked(self, item: QTreeWidgetItem, column: int): + """Handle click on tag tree item""" + data = item.data(0, Qt.UserRole) + if not data: return - item_id = self.tag_tree.identify_row(event.y) - if not item_id: + if data.get("type") == "tag": + full_path = data["full_path"] + self.tag_states[full_path] = not self.tag_states.get(full_path, False) + + # Update checkbox visual + tag_name = data["name"] + count_match = re.search(r'\((\d+)\)$', item.text(0)) + count_str = f" ({count_match.group(1)})" if count_match else "" + checked = "☑" if self.tag_states[full_path] else "☐" + item.setText(0, f"{checked} {tag_name}{count_str}") + + self.update_files_from_manager(self.filehandler.filelist) + + def on_tree_context_menu(self, pos): + """Show context menu for tag tree""" + item = self.tag_tree.itemAt(pos) + if not item: return - parent_id = self.tag_tree.parent(item_id) + self.selected_tree_item = item + data = item.data(0, Qt.UserRole) - # Toggle folder open/close - if parent_id == "" or parent_id == self.root_tag_id: - is_open = self.tag_tree.item(item_id, "open") - self.tag_tree.item(item_id, open=not is_open) - return + menu = QMenu(self) - # Toggle tag checkbox - self.states[item_id] = not self.states.get(item_id, False) - self.tag_tree.item(item_id, image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]) + add_action = QAction("Nový štítek", self) + add_action.triggered.connect(self.tree_add_tag) + menu.addAction(add_action) - # Update file list - self.update_files_from_manager(self.filehandler.filelist) + if data and data.get("type") in ("tag", "category"): + rename_action = QAction("Přejmenovat", self) + rename_action.triggered.connect(self.tree_rename_tag) + menu.addAction(rename_action) - def on_tree_right_click(self, event): - """Handle right click on tag tree""" - item_id = self.tag_tree.identify_row(event.y) - if item_id: - self.selected_tree_item_for_context = item_id - self.tag_tree.selection_set(item_id) - self.tag_menu.tk_popup(event.x_root, event.y_root) + delete_action = QAction("Smazat", self) + delete_action.triggered.connect(self.tree_delete_tag) + menu.addAction(delete_action) + + menu.exec(self.tag_tree.mapToGlobal(pos)) def tree_add_tag(self, background=False): """Add new tag""" - name = simpledialog.askstring("Nový tag", "Název tagu:") - if not name: + name, ok = QInputDialog.getText(self, "Nový tag", "Název tagu:") + if not ok or not name: return - parent = self.selected_tree_item_for_context if not background else self.root_tag_id - new_id = self.tag_tree.insert(parent, "end", text=f" {name}", image=self.icons["unchecked"]) - self.states[new_id] = False + item = getattr(self, 'selected_tree_item', None) if not background else None + data = item.data(0, Qt.UserRole) if item else None - if parent == self.root_tag_id: - self.tagmanager.add_category(name) - self.tag_tree.item(new_id, image=self.icons["tag"]) - else: - category = self.tag_tree.item(parent, "text").replace("📁 ", "") + if data and data.get("type") == "category": + category = data["name"] self.tagmanager.add_tag(category, name) + else: + self.tagmanager.add_category(name) - self.status_label.config(text=f"Vytvořen tag: {name}") + self.refresh_sidebar() + self.status_label.setText(f"Vytvořen tag: {name}") def tree_delete_tag(self): """Delete selected tag""" - item = self.selected_tree_item_for_context + item = getattr(self, 'selected_tree_item', None) if not item: return - name = self.tag_tree.item(item, "text").strip() - ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{name}'?") - if not ans: + data = item.data(0, Qt.UserRole) + if not data: return - parent_id = self.tag_tree.parent(item) - self.tag_tree.delete(item) - self.states.pop(item, None) - - if parent_id == self.root_tag_id: - self.tagmanager.remove_category(name.replace("📁 ", "")) - else: - category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") - self.tagmanager.remove_tag(category, name) + if data.get("type") == "category": + name = data["name"] + reply = QMessageBox.question( + self, "Smazat kategorii", + f"Opravdu chcete smazat kategorii '{name}'?" + ) + if reply == QMessageBox.Yes: + self.tagmanager.remove_category(name) + elif data.get("type") == "tag": + name = data["name"] + category = data["category"] + reply = QMessageBox.question( + self, "Smazat štítek", + f"Opravdu chcete smazat štítek '{name}'?" + ) + if reply == QMessageBox.Yes: + self.tagmanager.remove_tag(category, name) + self.refresh_sidebar() self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text=f"Smazán tag: {name}") def tree_rename_tag(self): """Rename selected tag or category""" - item = self.selected_tree_item_for_context + item = getattr(self, 'selected_tree_item', None) if not item: return - # Don't allow renaming root - if item == self.root_tag_id: + data = item.data(0, Qt.UserRole) + if not data: return - parent_id = self.tag_tree.parent(item) - current_text = self.tag_tree.item(item, "text").strip() - - # Check if this is a category (parent is root) or a tag - is_category = (parent_id == self.root_tag_id) - - if is_category: - # Renaming a category - current_name = current_text.replace("📁 ", "") - new_name = simpledialog.askstring( - "Přejmenovat kategorii", + if data.get("type") == "category": + current_name = data["name"] + new_name, ok = QInputDialog.getText( + self, "Přejmenovat kategorii", f"Nový název kategorie '{current_name}':", - initialvalue=current_name + text=current_name ) - if not new_name or new_name == current_name: + if not ok or not new_name or new_name == current_name: return - # Check if new name already exists - offer merge + # Check for existing category - offer merge if new_name in self.tagmanager.get_categories(): - merge = messagebox.askyesno( - "Kategorie existuje", + reply = QMessageBox.question( + self, "Kategorie existuje", f"Kategorie '{new_name}' již existuje.\n\n" - f"Chcete sloučit kategorii '{current_name}' do '{new_name}'?\n\n" - f"Všechny štítky z '{current_name}' budou přesunuty do '{new_name}'.", - icon="question" + f"Chcete sloučit '{current_name}' do '{new_name}'?" ) - if not merge: + if reply != QMessageBox.Yes: return - - # Merge category in all files - updated_count = self.filehandler.merge_category_in_files(current_name, new_name) - - # Refresh sidebar - self.refresh_sidebar() - self.root.update_idletasks() - self.update_files_from_manager(self.filehandler.filelist) - - self.status_label.config( - text=f"Kategorie sloučena: {current_name} → {new_name} ({updated_count} souborů)" + updated = self.filehandler.merge_category_in_files( + current_name, new_name ) + self.status_label.setText( + f"Kategorie sloučena: {current_name} → {new_name} " + f"({updated} souborů)" + ) + else: + updated = self.filehandler.rename_category_in_files( + current_name, new_name + ) + self.status_label.setText( + f"Kategorie přejmenována: {current_name} → {new_name} " + f"({updated} souborů)" + ) + + elif data.get("type") == "tag": + current_name = data["name"] + category = data["category"] + new_name, ok = QInputDialog.getText( + self, "Přejmenovat štítek", + f"Nový název štítku '{current_name}':", + text=current_name + ) + if not ok or not new_name or new_name == current_name: return - # Rename category in all files - updated_count = self.filehandler.rename_category_in_files(current_name, new_name) - - # Refresh sidebar - self.refresh_sidebar() - self.root.update_idletasks() - self.update_files_from_manager(self.filehandler.filelist) - - self.status_label.config( - text=f"Kategorie přejmenována: {current_name} → {new_name} ({updated_count} souborů)" - ) - else: - # Renaming a tag - # Get tag name (without count suffix) - # Find the tag name from the mapping - tag_name = None - for full_path, (item_id, name) in self.tag_tree_items.items(): - if item_id == item: - tag_name = name - break - - if tag_name is None: - # Fallback: parse from text (remove leading spaces and count) - tag_name = current_text.lstrip() - # Remove count suffix like " (5)" - import re - tag_name = re.sub(r'\s*\(\d+\)\s*$', '', tag_name) - - category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") - - new_name = simpledialog.askstring( - "Přejmenovat štítek", - f"Nový název štítku '{tag_name}':", - initialvalue=tag_name - ) - if not new_name or new_name == tag_name: - return - - # Check if new name already exists in this category - offer merge - existing_tags = [t.name for t in self.tagmanager.get_tags_in_category(category)] - if new_name in existing_tags: - merge = messagebox.askyesno( - "Štítek existuje", + # Check for existing tag - offer merge + existing = [t.name for t in + self.tagmanager.get_tags_in_category(category)] + if new_name in existing: + reply = QMessageBox.question( + self, "Štítek existuje", f"Štítek '{new_name}' v kategorii '{category}' již existuje.\n\n" - f"Chcete sloučit '{tag_name}' do '{new_name}'?\n\n" - f"Všechny soubory s '{tag_name}' budou mít tento štítek nahrazen za '{new_name}'.", - icon="question" + f"Chcete sloučit '{current_name}' do '{new_name}'?" ) - if not merge: + if reply != QMessageBox.Yes: return - - # Merge tag in all files - updated_count = self.filehandler.merge_tag_in_files(category, tag_name, new_name) - - # Refresh sidebar - self.refresh_sidebar() - self.root.update_idletasks() - self.update_files_from_manager(self.filehandler.filelist) - - self.status_label.config( - text=f"Štítek sloučen: {category}/{tag_name} → {category}/{new_name} ({updated_count} souborů)" + updated = self.filehandler.merge_tag_in_files( + category, current_name, new_name + ) + self.status_label.setText( + f"Štítek sloučen: {category}/{current_name} → " + f"{category}/{new_name} ({updated} souborů)" + ) + else: + updated = self.filehandler.rename_tag_in_files( + category, current_name, new_name + ) + self.status_label.setText( + f"Štítek přejmenován: {category}/{current_name} → " + f"{category}/{new_name} ({updated} souborů)" ) - return - # Rename tag in all files - updated_count = self.filehandler.rename_tag_in_files(category, tag_name, new_name) - - # Refresh sidebar - self.refresh_sidebar() - self.root.update_idletasks() - self.update_files_from_manager(self.filehandler.filelist) - - self.status_label.config( - text=f"Štítek přejmenován: {category}/{tag_name} → {category}/{new_name} ({updated_count} souborů)" - ) + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) def get_checked_tags(self) -> List[Tag]: """Get list of checked tags""" tags = [] - for item_id, checked in self.states.items(): - if not checked: - continue - parent_id = self.tag_tree.parent(item_id) - if parent_id == "" or parent_id == self.root_tag_id: - continue - category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") - # Get tag name from stored mapping (not from text which includes count) - tag_name = None - for full_path, (stored_id, stored_name) in self.tag_tree_items.items(): - if stored_id == item_id: - tag_name = stored_name - break - if tag_name: - tags.append(Tag(category, tag_name)) + for full_path, checked in self.tag_states.items(): + if checked and full_path in self.tag_tree_items: + item, name = self.tag_tree_items[full_path] + data = item.data(0, Qt.UserRole) + if data and data.get("type") == "tag": + tags.append(Tag(data["category"], data["name"])) return tags # ================================================== @@ -853,7 +898,7 @@ class App: filtered_files = self.filehandler.filter_files_by_tags(checked_tags) # Filter by search text - search_text = self.search_var.get().lower() if hasattr(self, 'search_var') else "" + search_text = self.search_input.text().lower() if hasattr(self, 'search_input') else "" if search_text: filtered_files = [ f for f in filtered_files @@ -862,35 +907,26 @@ class App: ] # Filter ignored - if self.hide_ignored_var and self.hide_ignored_var.get(): + if self.hide_ignored: filtered_files = [ f for f in filtered_files if "Stav/Ignorované" not in {t.full_path for t in f.tags} ] - # Sort - reverse = (self.sort_order == "desc") - if self.sort_mode == "name": - filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse) - elif self.sort_mode == "date": - filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse) - elif self.sort_mode == "size": - filtered_files.sort(key=lambda f: f.file_path.stat().st_size if f.file_path.exists() else 0, reverse=reverse) - # Clear table - for item in self.file_table.get_children(): - self.file_table.delete(item) + self.file_table.setSortingEnabled(False) + self.file_table.setRowCount(0) self.file_items.clear() # Populate table - for f in filtered_files: + for row, f in enumerate(filtered_files): + self.file_table.insertRow(row) + name = str(f.file_path) if self.show_full_path else f.filename date = f.date or "" - tags = ", ".join([t.name for t in f.tags[:3]]) # Show first 3 tags + tags = ", ".join([t.name for t in f.tags[:3]]) if len(f.tags) > 3: tags += f" +{len(f.tags) - 3}" - - # CSFD indicator csfd = "✓" if f.csfd_url else "" try: @@ -899,17 +935,27 @@ class App: except: size_str = "?" - item_id = self.file_table.insert("", "end", values=(name, date, tags, csfd, size_str)) - self.file_items[item_id] = f + self.file_table.setItem(row, 0, QTableWidgetItem(name)) + self.file_table.setItem(row, 1, QTableWidgetItem(date)) + self.file_table.setItem(row, 2, QTableWidgetItem(tags)) + self.file_table.setItem(row, 3, QTableWidgetItem(csfd)) + self.file_table.setItem(row, 4, QTableWidgetItem(size_str)) + + self.file_items[row] = f + + self.file_table.setSortingEnabled(True) + + # Update CSFD column visibility + self.file_table.setColumnHidden(3, not self.show_csfd_column) # Update status - self.file_count_label.config(text=f"{len(filtered_files)} souborů") - self.status_label.config(text=f"Zobrazeno {len(filtered_files)} souborů") + self.file_count_label.setText(f"{len(filtered_files)} souborů") + self.status_label.setText(f"Zobrazeno {len(filtered_files)} souborů") - # Update tag counts in sidebar + # Update tag counts self.update_tag_counts(filtered_files) - def _format_size(self, size_bytes): + def _format_size(self, size_bytes: int) -> str: """Format file size""" for unit in ['B', 'KB', 'MB', 'GB']: if size_bytes < 1024.0: @@ -919,47 +965,84 @@ class App: def get_selected_files(self) -> List[File]: """Get selected files from table""" - selected_items = self.file_table.selection() - return [self.file_items[item] for item in selected_items if item in self.file_items] + files = [] + for index in self.file_table.selectionModel().selectedRows(): + row = index.row() + # Find the file by matching the filename in the visible row + name_item = self.file_table.item(row, 0) + if name_item: + name = name_item.text() + for f in self.filehandler.filelist: + display_name = str(f.file_path) if self.show_full_path else f.filename + if display_name == name: + files.append(f) + break + return files - def on_selection_changed(self, event=None): + def on_selection_changed(self): """Update status bar when selection changes""" files = self.get_selected_files() count = len(files) if count == 0: - self.selected_count_label.config(text="") - self.selected_size_label.config(text="") + self.selected_count_label.setText("") + self.selected_size_label.setText("") else: - self.selected_count_label.config(text=f"{count} vybráno") + self.selected_count_label.setText(f"{count} vybráno") total_size = 0 for f in files: try: total_size += f.file_path.stat().st_size except: pass - self.selected_size_label.config(text=f"[{self._format_size(total_size)}]") + self.selected_size_label.setText(f"[{self._format_size(total_size)}]") - def on_file_double_click(self, event): + def on_file_double_click(self, index): """Handle double click on file""" files = self.get_selected_files() for f in files: self.open_file(f.file_path) - def on_file_right_click(self, event): - """Handle right click on file""" - # Select item under cursor if not selected - item = self.file_table.identify_row(event.y) - if item and item not in self.file_table.selection(): - self.file_table.selection_set(item) + def on_file_context_menu(self, pos): + """Show context menu for files""" + menu = QMenu(self) - # Update selected count - count = len(self.file_table.selection()) - self.selected_count_label.config(text=f"{count} vybráno" if count > 0 else "") + open_action = QAction("Otevřít soubor", self) + open_action.triggered.connect(self.open_selected_files) + menu.addAction(open_action) - self.file_menu.tk_popup(event.x_root, event.y_root) + tags_action = QAction("Přiřadit štítky (Ctrl+T)", self) + tags_action.triggered.connect(self.assign_tag_to_selected_bulk) + menu.addAction(tags_action) - def open_file(self, path): + date_action = QAction("Nastavit datum (Ctrl+D)", self) + date_action.triggered.connect(self.set_date_for_selected) + menu.addAction(date_action) + + menu.addSeparator() + + csfd_url_action = QAction("Nastavit CSFD URL...", self) + csfd_url_action.triggered.connect(self.set_csfd_url_for_selected) + menu.addAction(csfd_url_action) + + csfd_tags_action = QAction("Načíst tagy z CSFD", self) + csfd_tags_action.triggered.connect(self.apply_csfd_tags_for_selected) + menu.addAction(csfd_tags_action) + + menu.addSeparator() + + remove_action = QAction("Smazat z indexu (Del)", self) + remove_action.triggered.connect(self.remove_selected_files) + menu.addAction(remove_action) + + menu.exec(self.file_table.mapToGlobal(pos)) + + def on_sort_changed(self, column: int, order: Qt.SortOrder): + """Handle sort indicator change""" + self.sort_column = column + self.sort_order = order + + def open_file(self, path: Path): """Open file with default application""" try: if sys.platform.startswith("win"): @@ -968,9 +1051,9 @@ class App: subprocess.call(["open", path]) else: subprocess.call(["xdg-open", path]) - self.status_label.config(text=f"Otevírám: {path.name}") + self.status_label.setText(f"Otevírám: {path.name}") except Exception as e: - messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}") + QMessageBox.critical(self, "Chyba", f"Nelze otevřít {path}: {e}") # ================================================== # ACTIONS @@ -978,7 +1061,9 @@ class App: def open_folder_dialog(self): """Open folder selection dialog""" - folder = filedialog.askdirectory(title="Vyber složku pro sledování") + folder = QFileDialog.getExistingDirectory( + self, "Vyber složku pro sledování" + ) if not folder: return @@ -990,32 +1075,27 @@ class App: for t in f.tags: f.tagmanager.add_tag(t.category, t.name) - self.status_label.config(text=f"Přidána složka: {folder_path}") - self._update_csfd_column_visibility() # Load CSFD column setting for new folder + self.status_label.setText(f"Přidána složka: {folder_path}") + self._update_csfd_column_visibility() self.refresh_sidebar() self.update_files_from_manager(self.filehandler.filelist) except Exception as e: - messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}") + QMessageBox.critical(self, "Chyba", f"Nelze přidat složku {folder}: {e}") def close_folder(self): """Close current folder safely""" if not self.filehandler.current_folder: - self.status_label.config(text="Žádná složka není otevřena") + self.status_label.setText("Žádná složka není otevřena") return folder_name = self.filehandler.current_folder.name - - # Close folder (saves metadata and clears state) self.filehandler.close_folder() - - # Refresh UI self.refresh_sidebar() - self.status_label.config(text=f"Složka zavřena: {folder_name}") + self.status_label.setText(f"Složka zavřena: {folder_name}") def open_selected_files(self): """Open selected files""" - files = self.get_selected_files() - for f in files: + for f in self.get_selected_files(): self.open_file(f.file_path) def remove_selected_files(self): @@ -1024,19 +1104,22 @@ class App: if not files: return - ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?") - if ans: + reply = QMessageBox.question( + self, "Smazat z indexu", + f"Odstranit {len(files)} souborů z indexu?" + ) + if reply == QMessageBox.Yes: for f in files: if f in self.filehandler.filelist: self.filehandler.filelist.remove(f) self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text=f"Odstraněno {len(files)} souborů z indexu") + self.status_label.setText(f"Odstraněno {len(files)} souborů z indexu") def assign_tag_to_selected_bulk(self): - """Assign tags to selected files (bulk mode)""" + """Assign tags to selected files""" files = self.get_selected_files() if not files: - self.status_label.config(text="Nebyly vybrány žádné soubory") + self.status_label.setText("Nebyly vybrány žádné soubory") return all_tags = [] @@ -1045,17 +1128,17 @@ class App: all_tags.append(tag) if not all_tags: - messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") + QMessageBox.warning(self, "Chyba", "Žádné tagy nejsou definovány") return - dialog = MultiFileTagAssignDialog(self.root, all_tags, files, self.category_colors) - result = dialog.result - - if result is None: - self.status_label.config(text="Přiřazení zrušeno") + dialog = MultiFileTagAssignDialog( + self, all_tags, files, self.category_colors + ) + if dialog.exec() != QDialog.Accepted or dialog.result is None: + self.status_label.setText("Přiřazení zrušeno") return - for full_path, state in result.items(): + for full_path, state in dialog.result.items(): if state == 1: if "/" in full_path: category, name = full_path.split("/", 1) @@ -1068,31 +1151,33 @@ class App: self.filehandler.remove_tag_from_file_objects(files, tag_obj) self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text="Hromadné přiřazení tagů dokončeno") + self.status_label.setText("Hromadné přiřazení tagů dokončeno") def set_date_for_selected(self): """Set date for selected files""" files = self.get_selected_files() if not files: - self.status_label.config(text="Nebyly vybrány žádné soubory") + self.status_label.setText("Nebyly vybrány žádné soubory") return - prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):" - date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root) - if date_str is None: + date_str, ok = QInputDialog.getText( + self, "Nastavit datum", + "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):" + ) + if not ok: return for f in files: f.set_date(date_str if date_str != "" else None) self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)") + self.status_label.setText(f"Nastaveno datum pro {len(files)} soubor(ů)") def detect_video_resolution(self): """Detect video resolution using ffprobe""" files = self.get_selected_files() if not files: - self.status_label.config(text="Nebyly vybrány žádné soubory") + self.status_label.setText("Nebyly vybrány žádné soubory") return count = 0 @@ -1114,98 +1199,97 @@ class App: tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name) f.add_tag(tag_obj) count += 1 - except Exception as e: - print(f"Chyba u {f.filename}: {e}") + except Exception: + pass self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text=f"Přiřazeno rozlišení tagů k {count} souborům") + self.status_label.setText(f"Přiřazeno rozlišení tagů k {count} souborům") def set_ignore_patterns(self): - """Set ignore patterns for current folder""" + """Set ignore patterns""" current = ", ".join(self.filehandler.get_ignore_patterns()) - s = simpledialog.askstring("Ignore patterns", - "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", - initialvalue=current) - if s is None: + patterns, ok = QInputDialog.getText( + self, "Ignore patterns", + "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", + text=current + ) + if not ok: return - patterns = [p.strip() for p in s.split(",") if p.strip()] - self.filehandler.set_ignore_patterns(patterns) + pattern_list = [p.strip() for p in patterns.split(",") if p.strip()] + self.filehandler.set_ignore_patterns(pattern_list) self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text="Ignore patterns aktualizovány") + self.status_label.setText("Ignore patterns aktualizovány") def toggle_hide_ignored(self): """Toggle hiding ignored files""" + self.hide_ignored = self.hide_ignored_action.isChecked() self.update_files_from_manager(self.filehandler.filelist) def toggle_show_path(self): """Toggle showing full path""" - self.show_full_path = not self.show_full_path + self.show_full_path = self.full_path_cb.isChecked() self.update_files_from_manager(self.filehandler.filelist) def toggle_csfd_column(self): """Toggle CSFD column visibility""" - self.show_csfd_column = self.show_csfd_var.get() - self._update_csfd_column_visibility() + self.show_csfd_column = self.csfd_column_action.isChecked() + self.file_table.setColumnHidden(3, not self.show_csfd_column) - # Save to folder config if self.filehandler.current_folder: folder_config = self.filehandler.get_folder_config() folder_config["show_csfd_column"] = self.show_csfd_column self.filehandler.save_folder_config(config=folder_config) def _update_csfd_column_visibility(self): - """Update CSFD column width based on visibility setting""" - # Load from folder config if available + """Update CSFD column from folder config""" if self.filehandler.current_folder: folder_config = self.filehandler.get_folder_config() self.show_csfd_column = folder_config.get("show_csfd_column", True) - if hasattr(self, 'show_csfd_var'): - self.show_csfd_var.set(self.show_csfd_column) - - # Update column width - if hasattr(self, 'file_table'): - if self.show_csfd_column: - self.file_table.column("csfd", width=50) - else: - self.file_table.column("csfd", width=0) + self.csfd_column_action.setChecked(self.show_csfd_column) + self.file_table.setColumnHidden(3, not self.show_csfd_column) def set_csfd_url_for_selected(self): """Set CSFD URL for selected files""" files = self.get_selected_files() if not files: - self.status_label.config(text="Nebyly vybrány žádné soubory") + self.status_label.setText("Nebyly vybrány žádné soubory") return - # Get current URL from first file current_url = files[0].csfd_url or "" - - prompt = "Zadej CSFD URL (např. https://www.csfd.cz/film/9423-pane-vy-jste-vdova/):" - url = simpledialog.askstring("Nastavit CSFD URL", prompt, initialvalue=current_url, parent=self.root) - if url is None: + url, ok = QInputDialog.getText( + self, "Nastavit CSFD URL", + "Zadej CSFD URL:", + text=current_url + ) + if not ok: return for f in files: f.set_csfd_url(url if url != "" else None) self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text=f"CSFD URL nastaveno pro {len(files)} soubor(ů)") + self.status_label.setText(f"CSFD URL nastaveno pro {len(files)} soubor(ů)") def apply_csfd_tags_for_selected(self): - """Load tags from CSFD for selected files""" + """Load tags from CSFD""" files = self.get_selected_files() if not files: - self.status_label.config(text="Nebyly vybrány žádné soubory") + self.status_label.setText("Nebyly vybrány žádné soubory") return - # Filter files with CSFD URL files_with_url = [f for f in files if f.csfd_url] if not files_with_url: - messagebox.showwarning("Upozornění", "Žádný z vybraných souborů nemá nastavenou CSFD URL") + QMessageBox.warning( + self, "Upozornění", + "Žádný z vybraných souborů nemá nastavenou CSFD URL" + ) return - self.status_label.config(text=f"Načítám tagy z CSFD pro {len(files_with_url)} souborů...") - self.root.update() + self.status_label.setText( + f"Načítám tagy z CSFD pro {len(files_with_url)} souborů..." + ) + QApplication.processEvents() success_count = 0 error_count = 0 @@ -1219,46 +1303,20 @@ class App: else: error_count += 1 - # Refresh sidebar to show new categories self.refresh_sidebar() - self.root.update_idletasks() # Force UI refresh self.update_files_from_manager(self.filehandler.filelist) - # Show result if error_count > 0: - messagebox.showwarning("Dokončeno s chybami", + QMessageBox.warning( + self, "Dokončeno s chybami", f"Úspěšně: {success_count}, Chyby: {error_count}\n" - f"Přidáno {len(all_tags_added)} tagů") + f"Přidáno {len(all_tags_added)} tagů" + ) else: - self.status_label.config( - text=f"Načteno z CSFD: {success_count} souborů, přidáno {len(all_tags_added)} tagů") - - def sort_by_column(self, column: str): - """Sort by column header click""" - if self.sort_mode == column: - self.sort_order = "desc" if self.sort_order == "asc" else "asc" - else: - self.sort_mode = column - self.sort_order = "asc" - - self._update_sort_indicators() - self.update_files_from_manager(self.filehandler.filelist) - - def _update_sort_indicators(self): - """Update column header sort indicators""" - arrow = "▲" if self.sort_order == "asc" else "▼" - - headers = { - "name": "📄 Název", - "date": "📅 Datum", - "size": "💾 Velikost" - } - - for col, base_text in headers.items(): - if col == self.sort_mode: - self.file_table.heading(col, text=f"{base_text} {arrow}") - else: - self.file_table.heading(col, text=base_text) + self.status_label.setText( + f"Načteno z CSFD: {success_count} souborů, " + f"přidáno {len(all_tags_added)} tagů" + ) def on_filter_changed(self): """Handle search/filter change""" @@ -1268,81 +1326,75 @@ class App: """Refresh everything""" self.refresh_sidebar() self.update_files_from_manager(self.filehandler.filelist) - self.status_label.config(text="Obnoveno") + self.status_label.setText("Obnoveno") def configure_hardlink_folder(self): - """Configure hardlink output folder for current project""" + """Configure hardlink output folder""" if not self.filehandler.current_folder: - messagebox.showwarning("Upozornění", "Nejprve otevřete složku") + QMessageBox.warning(self, "Upozornění", "Nejprve otevřete složku") return - # Get current settings folder_config = self.filehandler.get_folder_config() current_dir = folder_config.get("hardlink_output_dir") current_categories = folder_config.get("hardlink_categories") - # Ask for output directory initial_dir = current_dir if current_dir else str(self.filehandler.current_folder) - output_dir = filedialog.askdirectory( - title="Vyber cílovou složku pro hardlink strukturu", - initialdir=initial_dir, - mustexist=False + output_dir = QFileDialog.getExistingDirectory( + self, "Vyber cílovou složku pro hardlink strukturu", + initial_dir ) if not output_dir: return - # Get available categories categories = self.tagmanager.get_categories() if not categories: - messagebox.showwarning("Upozornění", "Žádné kategorie tagů") + QMessageBox.warning(self, "Upozornění", "Žádné kategorie tagů") return - # Show category selection dialog - selected_categories = self._show_category_selection_dialog( - categories, - preselected=current_categories + dialog = CategorySelectionDialog( + self, categories, self.category_colors, current_categories ) - if selected_categories is None: - return # Cancelled + if dialog.exec() != QDialog.Accepted: + return - # Save to folder config folder_config["hardlink_output_dir"] = output_dir - folder_config["hardlink_categories"] = selected_categories if selected_categories else None + folder_config["hardlink_categories"] = dialog.result if dialog.result else None self.filehandler.save_folder_config(config=folder_config) - messagebox.showinfo("Hotovo", f"Hardlink složka nastavena:\n{output_dir}") - self.status_label.config(text=f"Hardlink složka nastavena: {output_dir}") + QMessageBox.information( + self, "Hotovo", f"Hardlink složka nastavena:\n{output_dir}" + ) + self.status_label.setText(f"Hardlink složka nastavena: {output_dir}") def update_hardlink_structure(self): - """Quick update hardlink structure using saved settings""" + """Quick update hardlink structure""" if not self.filehandler.current_folder: - messagebox.showwarning("Upozornění", "Nejprve otevřete složku") + QMessageBox.warning(self, "Upozornění", "Nejprve otevřete složku") return - # Get saved settings folder_config = self.filehandler.get_folder_config() output_dir = folder_config.get("hardlink_output_dir") saved_categories = folder_config.get("hardlink_categories") if not output_dir: - messagebox.showinfo("Info", "Hardlink složka není nastavena.\nPoužijte 'Nastavit hardlink složku...' pro konfiguraci.") + QMessageBox.information( + self, "Info", + "Hardlink složka není nastavena.\n" + "Použijte 'Nastavit hardlink složku...' pro konfiguraci." + ) return output_path = Path(output_dir) files = self.filehandler.filelist if not files: - messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") + QMessageBox.warning(self, "Upozornění", "Žádné soubory k zpracování") return - # Create manager and analyze manager = HardlinkManager(output_path) - - # Find what needs to be created and removed preview_create = manager.get_preview(files, saved_categories) obsolete = manager.find_obsolete_links(files, saved_categories) - # Filter out already existing links from preview to_create = [] for source, target in preview_create: if not target.exists(): @@ -1351,10 +1403,12 @@ class App: to_create.append((source, target)) if not to_create and not obsolete: - messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") + QMessageBox.information( + self, "Info", + "Struktura je již synchronizovaná, žádné změny nejsou potřeba" + ) return - # Build confirmation message confirm_lines = [] if to_create: confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") @@ -1363,16 +1417,19 @@ class App: confirm_lines.append(f"\nCílová složka: {output_path}") confirm_lines.append("\nPokračovat?") - if not messagebox.askyesno("Potvrdit aktualizaci", "\n".join(confirm_lines)): + reply = QMessageBox.question( + self, "Potvrdit aktualizaci", "\n".join(confirm_lines) + ) + if reply != QMessageBox.Yes: return - # Perform sync - self.status_label.config(text="Aktualizuji hardlink strukturu...") - self.root.update() + self.status_label.setText("Aktualizuji hardlink strukturu...") + QApplication.processEvents() - created, create_fail, removed, remove_fail = manager.sync_structure(files, saved_categories) + created, create_fail, removed, remove_fail = manager.sync_structure( + files, saved_categories + ) - # Show result result_lines = [] if created > 0: result_lines.append(f"Vytvořeno: {created} hardlinků") @@ -1384,50 +1441,52 @@ class App: result_lines.append(f"Selhalo vytvoření: {create_fail}") if remove_fail > 0: result_lines.append(f"Selhalo odebrání: {remove_fail}") - messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) + QMessageBox.warning( + self, "Dokončeno s chybami", "\n".join(result_lines) + ) else: - messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") + QMessageBox.information( + self, "Hotovo", + "\n".join(result_lines) if result_lines else "Žádné změny" + ) - self.status_label.config(text=f"Hardlink struktura aktualizována (vytvořeno: {created}, odebráno: {removed})") + self.status_label.setText( + f"Hardlink struktura aktualizována " + f"(vytvořeno: {created}, odebráno: {removed})" + ) def create_hardlink_structure(self): - """Create hardlink directory structure based on file tags (manual selection)""" + """Create hardlink structure with manual selection""" files = self.filehandler.filelist if not files: - messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") + QMessageBox.warning(self, "Upozornění", "Žádné soubory k zpracování") return - # Ask for output directory - output_dir = filedialog.askdirectory( - title="Vyber cílovou složku pro hardlink strukturu", - mustexist=False + output_dir = QFileDialog.getExistingDirectory( + self, "Vyber cílovou složku pro hardlink strukturu" ) if not output_dir: return output_path = Path(output_dir) - # Get available categories categories = self.tagmanager.get_categories() if not categories: - messagebox.showwarning("Upozornění", "Žádné kategorie tagů") + QMessageBox.warning(self, "Upozornění", "Žádné kategorie tagů") return - # Show category selection dialog - selected_categories = self._show_category_selection_dialog(categories) - if selected_categories is None: - return # Cancelled + dialog = CategorySelectionDialog( + self, categories, self.category_colors + ) + if dialog.exec() != QDialog.Accepted: + return - cat_filter = selected_categories if selected_categories else None + cat_filter = dialog.result if dialog.result else None - # Create manager and analyze manager = HardlinkManager(output_path) - - # Find what needs to be created and removed preview_create = manager.get_preview(files, cat_filter) obsolete = manager.find_obsolete_links(files, cat_filter) - # Filter out already existing links from preview to_create = [] for source, target in preview_create: if not target.exists(): @@ -1436,10 +1495,12 @@ class App: to_create.append((source, target)) if not to_create and not obsolete: - messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") + QMessageBox.information( + self, "Info", + "Struktura je již synchronizovaná, žádné změny nejsou potřeba" + ) return - # Build confirmation message confirm_lines = [] if to_create: confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") @@ -1448,16 +1509,19 @@ class App: confirm_lines.append(f"\nCílová složka: {output_path}") confirm_lines.append("\nPokračovat?") - if not messagebox.askyesno("Potvrdit synchronizaci", "\n".join(confirm_lines)): + reply = QMessageBox.question( + self, "Potvrdit synchronizaci", "\n".join(confirm_lines) + ) + if reply != QMessageBox.Yes: return - # Perform sync - self.status_label.config(text="Synchronizuji hardlink strukturu...") - self.root.update() + self.status_label.setText("Synchronizuji hardlink strukturu...") + QApplication.processEvents() - created, create_fail, removed, remove_fail = manager.sync_structure(files, cat_filter) + created, create_fail, removed, remove_fail = manager.sync_structure( + files, cat_filter + ) - # Show result result_lines = [] if created > 0 or create_fail > 0: result_lines.append(f"Vytvořeno: {created} hardlinků") @@ -1474,82 +1538,51 @@ class App: for path, err in manager.errors[:5]: result_lines.append(f"- {path.name}: {err}") if len(manager.errors) > 5: - result_lines.append(f"... a dalších {len(manager.errors) - 5} chyb") - messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) + result_lines.append( + f"... a dalších {len(manager.errors) - 5} chyb" + ) + QMessageBox.warning( + self, "Dokončeno s chybami", "\n".join(result_lines) + ) else: - messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") + QMessageBox.information( + self, "Hotovo", + "\n".join(result_lines) if result_lines else "Žádné změny" + ) - self.status_label.config(text=f"Hardlink struktura synchronizována (vytvořeno: {created}, odebráno: {removed})") + self.status_label.setText( + f"Hardlink struktura synchronizována " + f"(vytvořeno: {created}, odebráno: {removed})" + ) - def _show_category_selection_dialog(self, categories: List[str], preselected: List[str] | None = None) -> List[str] | None: - """Show dialog to select which categories to include in hardlink structure + def closeEvent(self, event): + """Save window state on close""" + is_maximized = self.isMaximized() + self.filehandler.global_config["window_maximized"] = is_maximized - Args: - categories: List of available category names - preselected: Optional list of categories to pre-check (None = all checked) - """ - dialog = tk.Toplevel(self.root) - dialog.title("Vybrat kategorie") - dialog.geometry("350x400") - dialog.transient(self.root) - dialog.grab_set() + if not is_maximized: + geo = self.geometry() + self.filehandler.global_config["window_geometry"] = ( + f"{geo.width()}x{geo.height()}+{geo.x()}+{geo.y()}" + ) - result = {"categories": None} + save_global_config(self.filehandler.global_config) + event.accept() - tk.Label(dialog, text="Vyberte kategorie pro vytvoření struktury:", - font=("Arial", 10, "bold")).pack(pady=10) - # Scrollable frame for checkboxes - frame = tk.Frame(dialog) - frame.pack(fill=tk.BOTH, expand=True, padx=10) +class App: + """Application wrapper for compatibility with existing entry point""" - canvas = tk.Canvas(frame) - scrollbar = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) - scrollable_frame = tk.Frame(canvas) + def __init__(self, filehandler: FileManager, tagmanager: TagManager): + self.filehandler = filehandler + self.tagmanager = tagmanager - scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) + def main(self): + app = QApplication.instance() + if app is None: + app = QApplication(sys.argv) - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") + window = MainWindow(self.filehandler, self.tagmanager) + window.show() - # Category checkboxes - category_vars = {} - for category in sorted(categories): - # If preselected is None, check all; otherwise check only those in preselected - initial_value = preselected is None or category in preselected - var = tk.BooleanVar(value=initial_value) - category_vars[category] = var - color = self.category_colors.get(category, "#333333") - cb = tk.Checkbutton(scrollable_frame, text=category, variable=var, - fg=color, font=("Arial", 10), anchor="w") - cb.pack(fill="x", pady=2) - - # Buttons - btn_frame = tk.Frame(dialog) - btn_frame.pack(pady=10) - - def on_ok(): - result["categories"] = [cat for cat, var in category_vars.items() if var.get()] - dialog.destroy() - - def on_cancel(): - result["categories"] = None - dialog.destroy() - - def select_all(): - for var in category_vars.values(): - var.set(True) - - def select_none(): - for var in category_vars.values(): - var.set(False) - - tk.Button(btn_frame, text="Všechny", command=select_all, width=8).pack(side=tk.LEFT, padx=2) - tk.Button(btn_frame, text="Žádné", command=select_none, width=8).pack(side=tk.LEFT, padx=2) - tk.Button(btn_frame, text="OK", command=on_ok, width=10).pack(side=tk.LEFT, padx=10) - tk.Button(btn_frame, text="Zrušit", command=on_cancel, width=10).pack(side=tk.LEFT, padx=2) - - self.root.wait_window(dialog) - return result["categories"] + app.exec() diff --git a/src/ui/utils.py b/src/ui/utils.py index f9a6d43..01a0ba6 100644 --- a/src/ui/utils.py +++ b/src/ui/utils.py @@ -1,19 +1,22 @@ """ -UI utility functions for Tagger GUI. +UI utility functions for Tagger GUI (PySide6). """ -from PIL import Image, ImageTk +from pathlib import Path +from PySide6.QtGui import QIcon, QPixmap -def load_icon(path) -> ImageTk.PhotoImage: +def load_icon(path: str | Path, size: int = 16) -> QIcon: """ - Load an icon from file and resize to 16x16. + Load an icon from file and optionally resize. Args: path: Path to the image file + size: Icon size in pixels (default 16) Returns: - ImageTk.PhotoImage resized to 16x16 pixels + QIcon object """ - img = Image.open(path) - img = img.resize((16, 16), Image.Resampling.LANCZOS) - return ImageTk.PhotoImage(img) + pixmap = QPixmap(str(path)) + if not pixmap.isNull(): + pixmap = pixmap.scaled(size, size) + return QIcon(pixmap) diff --git a/tests/test_media_utils.py b/tests/test_media_utils.py index f5bbda8..6e1dfd8 100644 --- a/tests/test_media_utils.py +++ b/tests/test_media_utils.py @@ -1,62 +1,76 @@ import tempfile from pathlib import Path import pytest +import os -from src.ui.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 test_load_icon_returns_qicon(qapp): + """Test that load_icon returns QIcon""" + from src.ui.utils import load_icon + from PySide6.QtGui import QIcon + from PIL import Image + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: tmp_path = Path(tmp.name) try: - # vytvoříme 100x100 červený obrázek + # Create 100x100 red image img = Image.new("RGB", (100, 100), color="red") img.save(tmp_path) 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 + # Must be QIcon + assert isinstance(icon, QIcon) + # Icon should not be null + 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 + from PIL import Image + 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) + icon = load_icon(tmp_path, size=32) - # i velký obrázek by měl být zmenšen na 16x16 - assert icon.width() == 16 - assert icon.height() == 16 + # Icon should be created successfully + assert not icon.isNull() + # Available sizes should include the requested size + 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ů""" +def test_load_icon_different_formats(qapp): + """Test loading different image formats""" + from src.ui.utils import load_icon + from PySide6.QtGui import QIcon + from PIL import Image + formats = [".png", ".jpg", ".bmp"] for fmt in formats: @@ -68,8 +82,7 @@ def test_load_icon_different_formats(tk_root): icon = load_icon(tmp_path) - assert isinstance(icon, ImageTk.PhotoImage) - assert icon.width() == 16 - assert icon.height() == 16 + assert isinstance(icon, QIcon) + assert not icon.isNull() finally: tmp_path.unlink(missing_ok=True)