diff --git a/.gitignore b/.gitignore index 1568ac6..590fffe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,48 @@ -.venv/ +# Python __pycache__/ -*.pyc +*.py[cod] +*.pyo +*.pyd + +# Virtual environments +.venv/ +venv/ +env/ + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +*.spec.bak + +# Testing .pytest_cache/ .mypy_cache/ -build/ -.claude/ -.env +.coverage +htmlcov/ -# Config a temp soubory +# Environment +.env +*.env.local + +# IDE +.vscode/settings.json +.idea/ + +# Claude +.claude/ +.claudeignore + +# App temp / tag soubory *.!tag *.!ftag *.!gtag -# Documentation not to commit +# Data samples (binary/media, not source) +data/samples/ + +# Documentation not for commit DESIGN_DOCUMENT.md AGENTS.md -.claudeignore -TEMPLATE.md \ No newline at end of file +TEMPLATE.md +CLAUDE.md diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/.vscode/settings.json b/.vscode/settings.json index 6d3c95f..15fad6f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,7 @@ "tests" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "python-envs.defaultEnvManager": "ms-python.python:poetry", + "python-envs.defaultPackageManager": "ms-python.python:poetry" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d21896..e488af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to the Tagger project are documented in this file. +## [1.2.0] - 2026-04-09 + +### Added +- **Undo/redo** - `Ctrl+Z` / `Ctrl+Y` vrátí/zopakuje tag operace (assign, remove, rename, merge) + - Zásobník 50 kroků, čistí se při zavření složky + - Edit menu zobrazuje popis poslední operace + - Pokrývá: přiřazení tagu, odebrání tagu, přejmenování tagu/kategorie, sloučení tagu/kategorie +- **CSFD cache** - Movie data cached in `.!tag` after first fetch, no re-fetching on reopen + - `CSFDMovie.to_dict()` / `from_dict()` for serialization + - `File.get_cached_movie()` - returns cached data without network access + - Cache versioning (`CSFD_CACHE_VERSION`) for future schema invalidation + - Cache invalidated automatically when CSFD URL changes +- **Orphaned sidecar detection** - On folder scan, `.!tag` files without a matching media file are reported + - `FileManager.on_orphaned_tags` callback for UI notification + - `FileManager.find_orphaned_tags()` for manual scan +- **OR/NOT tag filtering** - Extended `filter_files_by_tags()` with new parameters + - `any_of` - file must have at least one of these tags (OR) + - `must_not` - file must not have any of these tags (NOT) + - Fully backward compatible +- **ffprobe threading** - Video resolution detection now runs in background (`VideoResolutionWorker`) + - Status bar shows live progress: `Zjišťuji rozlišení (3/12)…` + - Menu action disabled during processing +- **Export to CSV** - Soubor → "Exportovat do CSV..." + - UTF-8 BOM encoding (Excel compatible) + - Columns: filename, path, date, tags, CSFD URL, size +- **Drag & drop** - Drag folder or file onto the app window to open it + +### Changed +- **Global config location** - Moved from app directory to `~/.config/Tagger/` + - Automatic one-time migration from old location + - Fixes crash on read-only PyInstaller build directories +- **Python version** - Bumped to 3.14+ + +### Dependencies +- Removed: Pillow (unused) +- Moved: python-dotenv from dev to runtime dependencies + +### Tests +- 274 tests (all passing) + ## [1.1.0] - 2026-01-23 ### Changed diff --git a/PROJECT.md b/PROJECT.md index 512136b..59583b0 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,6 +1,6 @@ # Tagger - Project Documentation -**Version:** 1.1.0 | **Status:** Stable | **GUI:** PySide6/Qt6 +**Version:** 1.2.0 | **Status:** Stable | **GUI:** PySide6/Qt6 --- @@ -8,7 +8,8 @@ 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). +**Features:** Folder scanning, tag filtering (AND/OR/NOT), rename/merge tags, CSFD.cz integration +(with local cache), hardlink structure, 3-level config (global/folder/file), orphaned sidecar detection. --- @@ -20,11 +21,12 @@ Tagger/ ├── 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 +│ ├── file.py # File with metadata + CSFD cache +│ ├── file_manager.py # File management, filtering, orphan detection │ ├── config.py # 3-level config system │ ├── hardlink_manager.py -│ ├── csfd.py # CSFD scraper +│ ├── csfd.py # CSFD scraper + CSFDMovie serialization +│ ├── media_utils.py # ffprobe integration │ ├── constants.py # APP_NAME, VERSION │ └── _version.py # Version fallback for PyInstaller ├── src/ui/ @@ -37,34 +39,63 @@ Tagger/ ## 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` +1. **UI must not contain business logic** — call FileManager/TagManager +2. **Core must not import UI** — no PySide6 in src/core/ +3. **Dependency injection** — pass via constructor +4. **UTF-8 everywhere** — `encoding='utf-8'`, `ensure_ascii=False` --- ## Config Files -| Level | File | Contents | -|-------|------|----------| -| Global | `.Tagger.!gtag` | window geometry, last folder | -| Folder | `.Tagger.!ftag` | ignore patterns, hardlink settings | -| File | `.filename.!tag` | tags, date, state | +| Level | File | Location | Contents | +|-------|------|----------|----------| +| Global | `.Tagger.!gtag` | `~/.config/Tagger/` | window geometry, last folder, recent folders | +| Folder | `.Tagger.!ftag` | project folder | ignore patterns, hardlink settings | +| File | `.filename.!tag` | same dir as file | tags, date, csfd_url, csfd_cache | --- ## Key Components -**Tag** - immutable, `Tag(category, name)`, `Tag.from_string("cat/name")` +**Tag** — immutable, `Tag(category, name)`, `Tag.from_string("cat/name")` -**File** - `file_path`, `tags[]`, `date`, `csfd_url`, metadata in `.filename.!tag` +**File** — `file_path`, `tags[]`, `date`, `csfd_url`, `csfd_cache`, metadata in `.filename.!tag` +- `apply_csfd_tags()` — fetch + cache CSFD data +- `get_cached_movie()` — return CSFDMovie from cache (no network) +- `set_csfd_url()` — invalidates cache on URL change -**TagManager** - `add_tag()`, `get_categories()`, `rename_tag()`, `merge_tag()` +**TagManager** — `add_tag()`, `get_categories()`, `rename_tag()`, `merge_tag()` -**FileManager** - `append(folder)`, `filter_files_by_tags()`, `close_folder()` +**FileManager** — `append(folder)`, `filter_files_by_tags()`, `close_folder()` +- `on_orphaned_tags` callback — fires when orphaned `.!tag` sidecars are found +- `find_orphaned_tags()` — manual scan for orphaned sidecars -**HardlinkManager** - `create_structure_for_files()`, `sync_structure()` +**HardlinkManager** — `create_structure_for_files()`, `sync_structure()` + +**CSFDMovie** — `to_dict()` / `from_dict()` for cache serialization + +--- + +## Filtering + +```python +# AND (default — all must match) +fm.filter_files_by_tags(["Žánr/Drama", "Rok/1990"]) + +# OR — at least one must match +fm.filter_files_by_tags(any_of=["Žánr/Drama", "Žánr/Thriller"]) + +# NOT — none of these +fm.filter_files_by_tags(must_not=["Stav/Nové"]) + +# Combined +fm.filter_files_by_tags( + must_have=["Žánr/Drama"], + any_of=["Rok/1990", "Rok/1991"], + must_not=["Stav/Nové"], +) +``` --- @@ -80,7 +111,7 @@ poetry run pyinstaller --onefile Tagger.py # Build ## Shortcuts -`Ctrl+O` Open | `Ctrl+T` Tags | `Ctrl+D` Date | `F5` Refresh | `Del` Remove +`Ctrl+O` Open | `Ctrl+T` Tags | `Ctrl+D` Date | `Ctrl+W` Close | `F5` Refresh | `Del` Remove --- @@ -92,10 +123,14 @@ poetry run pyinstaller --onefile Tagger.py # Build **Metadata corrupted:** Auto-recovers with defaults. +**Config not saved:** Check `~/.config/Tagger/` exists and is writable. + +--- + --- ## Metrics - **Tests:** 274 ✅ -- **Python:** 3.13+ -- **Dependencies:** PySide6, Pillow, requests, beautifulsoup4 +- **Python:** 3.14+ +- **Dependencies:** PySide6, requests, beautifulsoup4, loguru, python-dotenv diff --git a/README.md b/README.md index 277179a..11b07ac 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,103 @@ -# 🏷️ Tagger +# Tagger -Desktopová aplikace pro správu a organizaci souborů pomocí hierarchických tagů (štítků). +Desktopová aplikace pro správu a organizaci souborů pomocí hierarchických tagů. -## ✨ Hlavní funkce +## Hlavní funkce -- 📁 Rekurzivní procházení složek -- 🏷️ Hierarchické tagy (kategorie/název) -- 🔍 Filtrování podle tagů a textu -- 💾 Metadata v JSON souborech (.!tag) -- 🎬 Automatická detekce rozlišení videí (ffprobe) -- 🎨 Dvě verze GUI: klasické a moderní (qBittorrent-style) +- Rekurzivní procházení složek +- Hierarchické tagy (kategorie/název) +- Filtrování podle tagů (AND / OR / NOT logika) a textu +- Metadata v JSON souborech (.!tag) — cestují se souborem +- Integrace s CSFD.cz (automatické načítání žánrů, roku, země) +- Tvorba hardlink struktury adresářů dle tagů +- Automatická detekce rozlišení videí (ffprobe) -## 🚀 Rychlý start +## Rychlý start ```bash # Instalace závislostí poetry install -# Spuštění (moderní GUI) -poetry run python Tagger_modern.py - -# Nebo klasické GUI +# Spuštění poetry run python Tagger.py ``` -## 📸 Screenshot +## Klávesové zkratky -### Moderní GUI (qBittorrent-style) -``` -┌─────────────────────────────────────────────────────┐ -│ 📁 Otevřít │ 🔄 │ 🏷️ │ 📅 🔍 [____] Toolbar -├────────────┬────────────────────────────────────────┤ -│ 📂 Štítky │ Název │Datum│Štítky│Velikost │ -│ ├─📁 Stav │ file1.txt│2025 │HD │1.2 MB │ -│ │ ☑ Nové │ file2.mp4│ │4K │15 MB │ -│ ├─📁 Video│ file3.jpg│ │RAW │845 KB │ -│ │ ☐ HD │ │ -├────────────┴────────────────────────────────────────┤ -│ Připraven 3 vybráno │ 125 souborů │ -└─────────────────────────────────────────────────────┘ -``` +| Zkratka | Akce | +|---------|------| +| `Ctrl+O` | Otevřít složku | +| `Ctrl+T` | Přiřadit tagy | +| `Ctrl+D` | Nastavit datum | +| `Ctrl+W` | Zavřít složku | +| `F5` | Refresh | +| `Del` | Odebrat z indexu | -## 🎯 Použití - -1. **Otevři složku** - Načti soubory ze složky (rekurzivně) -2. **Vytvoř tagy** - Hierarchická struktura (kategorie → tagy) -3. **Přiřaď tagy** - Označ soubory, vyber tagy -4. **Filtruj** - Klikni na tagy pro filtrování souborů -5. **Vyhledávej** - Textové vyhledávání v názvech - -## ⌨️ Keyboard Shortcuts (moderní GUI) - -- `Ctrl+O` - Otevřít složku -- `Ctrl+T` - Přiřadit tagy -- `Ctrl+D` - Nastavit datum -- `F5` - Refresh -- `Del` - Smazat z indexu - -## 🏗️ Architektura +## Architektura ``` ┌─────────────────────────────────┐ -│ Presentation (UI) │ ← Tkinter GUI +│ Presentation (PySide6/Qt6) │ src/ui/gui.py ├─────────────────────────────────┤ -│ Business Logic │ ← FileManager, TagManager +│ Business Logic │ src/core/ (bez UI importů) ├─────────────────────────────────┤ -│ Data Layer │ ← File, Tag models +│ Data Layer │ File, Tag, TagManager, FileManager ├─────────────────────────────────┤ -│ Persistence │ ← JSON .!tag soubory +│ Persistence │ JSON .!tag soubory └─────────────────────────────────┘ ``` -## 📁 Struktura projektu +## Struktura projektu ``` Tagger/ -├── Tagger.py # Entry point (klasické GUI) -├── Tagger_modern.py # Entry point (moderní GUI) -├── PROJECT_NOTES.md # ⭐ Kompletní dokumentace +├── Tagger.py # Entry point ├── src/ -│ ├── core/ # Business logika -│ │ ├── file.py +│ ├── core/ # Business logika (žádné UI importy!) │ │ ├── tag.py +│ │ ├── file.py │ │ ├── file_manager.py -│ │ └── tag_manager.py +│ │ ├── tag_manager.py +│ │ ├── config.py +│ │ ├── csfd.py +│ │ ├── hardlink_manager.py +│ │ └── media_utils.py │ └── ui/ -│ ├── gui.py # Klasické GUI -│ └── gui_modern.py # Moderní GUI -└── tests/ # 116 testů +│ └── gui.py # Qt6 GUI +└── tests/ # 274 testů ``` -## 🧪 Testování +## Testování ```bash -# Všechny testy (116 testů, 100% core coverage) -poetry run pytest tests/ -v - -# S coverage report -poetry run pytest tests/ --cov=src/core --cov-report=html +poetry run pytest tests/ -q ``` -## 📝 Dokumentace +## Technologie -**Veškerá dokumentace je v jednom souboru:** - -👉 **[PROJECT_NOTES.md](PROJECT_NOTES.md)** ⭐ - -Obsahuje: -- Kompletní dokumentaci projektu -- Architektonická rozhodnutí (ADR) -- Coding standards -- Git workflow -- Known issues & TODO -- Debugování tipy -- Pravidla pro AI asistenty - -## 🛠️ Technologie - -- **Python:** 3.12 -- **GUI:** Tkinter (standard library) -- **Dependencies:** Pillow (PIL) +- **Python:** 3.14+ +- **GUI:** PySide6/Qt6 +- **Dependencies:** requests, beautifulsoup4, loguru, python-dotenv - **Package manager:** Poetry -- **Testing:** pytest -## 📊 Metriky +## Metriky -- **Řádky kódu:** ~1060 Python LOC -- **Testy:** 116 (všechny ✅) -- **Test coverage:** 100% core modulů -- **GUI verze:** 2 (klasická + moderní) +- **Testy:** 274 (100% core coverage) +- **Verze:** 1.1.0 -## 🎯 Design Decisions +## Design Decisions ### Proč JSON místo databáze? -- ✅ Jednoduchý backup (copy složky) -- ✅ Git-friendly (plain text) -- ✅ Portable (žádné DB dependencies) -- ✅ Metadata zůstanou při přesunu souboru +- Jednoduchý backup (copy složky) +- Git-friendly (plain text, diffovatelné) +- Metadata zůstanou při přesunu souboru (sidecar) +- Portable — žádné DB závislosti -### Proč Tkinter? -- ✅ Standard library (žádné extra deps) -- ✅ Cross-platform -- ✅ Dobře dokumentované +### Proč sidecar soubory (.!tag)? +- Metadata cestují se souborem při přesunu/kopírování +- Čitelné i bez aplikace +- Každý soubor je nezávislý — žádný single point of failure -### Proč Poetry? -- ✅ Deterministické buildy (poetry.lock) -- ✅ Dev dependencies oddělené -- ✅ Moderní nástroj - -## 🐛 Known Issues - -- Git merge konflikty s poetry.lock při merge devel→feature -- Dlouhé operace (ffprobe) blokují UI - TODO: threading - -## 🚀 Plánované features - -- [ ] Progress bar pro dlouhé operace -- [ ] Undo/Redo mechanismus -- [ ] Export do CSV/Excel -- [ ] Dark mode theme -- [ ] Drag & drop souborů - -## 📄 License +## License MIT License - -## 👤 Autor - -honza - ---- - -**Pro detailní dokumentaci viz [PROJECT_NOTES.md](PROJECT_NOTES.md)** diff --git a/Tagger.py b/Tagger.py index 27fa464..fd08f25 100644 --- a/Tagger.py +++ b/Tagger.py @@ -1,18 +1,23 @@ -# Imports -import tkinter as tk -from tkinter import ttk +""" +Entry point for Tagger application. +""" +import sys -from src.ui.gui import App -from src.core.file_manager import list_files, FileManager +from PySide6.QtWidgets import QApplication + +from src.core.file_manager import FileManager from src.core.tag_manager import TagManager -from pathlib import Path - -class State(): - def __init__(self) -> None: - self.tagmanager = TagManager() - self.filehandler = FileManager(self.tagmanager) - self.app = App(self.filehandler, self.tagmanager) +from src.ui.main_window import MainWindow -STATE = State() -STATE.app.main() +def main() -> None: + tagmanager = TagManager() + filehandler = FileManager(tagmanager) + app = QApplication.instance() or QApplication(sys.argv) + window = MainWindow(filehandler, tagmanager) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/Tagger.spec b/Tagger.spec index f593d67..3667502 100644 --- a/Tagger.spec +++ b/Tagger.spec @@ -29,7 +29,7 @@ exe = EXE( upx=True, upx_exclude=[], runtime_tmpdir=None, - console=False, + console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, diff --git a/poetry.lock b/poetry.lock index 7d78c6e..e77352d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "altgraph" @@ -37,137 +37,153 @@ lxml = ["lxml"] [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, - {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, ] [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] [[package]] @@ -176,7 +192,7 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] +groups = ["main", "dev"] markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -210,6 +226,25 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +groups = ["main"] +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] + [[package]] name = "macholib" version = "1.16.4" @@ -251,115 +286,6 @@ files = [ {file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"}, ] -[[package]] -name = "pillow" -version = "12.1.0" -description = "Python Imaging Library (fork)" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {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] -docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -xmp = ["defusedxml"] - [[package]] name = "pluggy" version = "1.6.0" @@ -378,14 +304,14 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] @@ -393,24 +319,24 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "6.18.0" +version = "6.19.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false python-versions = "<3.15,>=3.8" groups = ["dev"] files = [ - {file = "pyinstaller-6.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"}, + {file = "pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe"}, + {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83"}, + {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6"}, + {file = "pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2"}, + {file = "pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33"}, + {file = "pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea"}, + {file = "pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865"}, ] [package.dependencies] @@ -418,7 +344,7 @@ 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" +pyinstaller-hooks-contrib = ">=2026.0" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" @@ -428,14 +354,14 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2026.0" +version = "2026.4" 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"}, + {file = "pyinstaller_hooks_contrib-2026.4-py3-none-any.whl", hash = "sha256:1de1a5e49a878122010b88c7e295502bc69776c157c4a4dc78741a4e6178b00f"}, + {file = "pyinstaller_hooks_contrib-2026.4.tar.gz", hash = "sha256:766c281acb1ecc32e21c8c667056d7ebf5da0aabd5e30c219f9c2a283620eeaa"}, ] [package.dependencies] @@ -444,71 +370,71 @@ setuptools = ">=42.0.0" [[package]] name = "pyside6" -version = "6.10.1" +version = "6.11.0" description = "Python bindings for the Qt cross-platform application and UI framework" optional = false -python-versions = "<3.15,>=3.9" +python-versions = "<3.15,>=3.10" 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"}, + {file = "pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f"}, + {file = "pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00"}, + {file = "pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e"}, + {file = "pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc"}, + {file = "pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e"}, ] [package.dependencies] -PySide6_Addons = "6.10.1" -PySide6_Essentials = "6.10.1" -shiboken6 = "6.10.1" +PySide6_Addons = "6.11.0" +PySide6_Essentials = "6.11.0" +shiboken6 = "6.11.0" [[package]] name = "pyside6-addons" -version = "6.10.1" +version = "6.11.0" description = "Python bindings for the Qt cross-platform application and UI framework (Addons)" optional = false -python-versions = "<3.15,>=3.9" +python-versions = "<3.15,>=3.10" 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"}, + {file = "pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182"}, + {file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35"}, + {file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753"}, + {file = "pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e"}, + {file = "pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8"}, ] [package.dependencies] -PySide6_Essentials = "6.10.1" -shiboken6 = "6.10.1" +PySide6_Essentials = "6.11.0" +shiboken6 = "6.11.0" [[package]] name = "pyside6-essentials" -version = "6.10.1" +version = "6.11.0" description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)" optional = false -python-versions = "<3.15,>=3.9" +python-versions = "<3.15,>=3.10" 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"}, + {file = "pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01"}, + {file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849"}, + {file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e"}, + {file = "pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf"}, + {file = "pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187"}, ] [package.dependencies] -shiboken6 = "6.10.1" +shiboken6 = "6.11.0" [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, - {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, ] [package.dependencies] @@ -521,6 +447,21 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "python-dotenv" +version = "1.2.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -536,60 +477,60 @@ files = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, + {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, + {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "setuptools" -version = "80.10.1" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +version = "82.0.1" +description = "Most extensible Python build backend with support for C/C++ extension modules" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e"}, - {file = "setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a"}, + {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, + {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.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)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "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"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] [[package]] name = "shiboken6" -version = "6.10.1" +version = "6.11.0" description = "Python/C++ bindings helper module" optional = false -python-versions = "<3.15,>=3.9" +python-versions = "<3.15,>=3.10" 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"}, + {file = "shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b"}, + {file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053"}, + {file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9"}, + {file = "shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1"}, + {file = "shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4"}, ] [[package]] @@ -634,7 +575,23 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] + [metadata] lock-version = "2.1" python-versions = ">=3.13,<3.15" -content-hash = "81a84a97aa8532b37af24fe1ec6398f0a4cef1993e80e83ff16a5b571df344c6" +content-hash = "4c8861d0f089fe0ce348b1dec692077f63b84201ba3afebfb349e4998bf0ef70" diff --git a/prebuild.py b/prebuild.py new file mode 100644 index 0000000..84de3ae --- /dev/null +++ b/prebuild.py @@ -0,0 +1,66 @@ +import os +import sys +from pathlib import Path +from dotenv import load_dotenv +from src.constants import VERSION + +load_dotenv() + +print("=" * 50) +print("PREBUILD CONFIGURATION") +print("=" * 50) + +# Check if running in virtual environment +project_root = Path(__file__).parent +expected_venv_path = project_root / ".venv" +current_executable = Path(sys.executable) + +print(f"\nPython executable: {sys.executable}") + +is_correct_venv = False +try: + current_executable.relative_to(expected_venv_path) + is_correct_venv = True +except ValueError: + is_correct_venv = False + +if is_correct_venv: + print("✓ Correct environment selected for building") +else: + print("✗ Wrong environment selected") + print(f" Expected: {expected_venv_path}") + print(f" Current: {current_executable.parent.parent}") + +print(f"✓ Version: {VERSION}") + +env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true" +console_mode = env_debug +default_spec = Path(__file__).parent.name + ".spec" +spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec) + +print(f"\n{'-' * 50}") +print("BUILD SETTINGS") +print(f"{'-' * 50}") +print(f"ENV_DEBUG: {env_debug}") +print(f"Console mode: {console_mode}") +print(f"Spec file: {spec_filename}") + +spec_path = Path(__file__).parent / spec_filename +if spec_path.exists(): + with open(spec_path, "r", encoding="utf-8") as f: + spec_content = f.read() + + if f"console={not console_mode}" in spec_content: + new_spec_content = spec_content.replace( + f"console={not console_mode}", + f"console={console_mode}" + ) + with open(spec_path, "w", encoding="utf-8") as f: + f.write(new_spec_content) + print(f"✓ Updated {spec_filename}: console={console_mode}") + else: + print(f"✓ {spec_filename} already configured: console={console_mode}") +else: + print(f"✗ {spec_filename} not found!") + +print(f"{'-' * 50}\n") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e094b86..7384748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,18 @@ [project] name = "tagger" -version = "1.1.0" +version = "1.2.0" description = "" authors = [ {name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"} ] readme = "README.md" -requires-python = ">=3.13,<3.15" +requires-python = ">=3.14,<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)" + "pyside6 (>=6.10.1,<7.0.0)", + "loguru (>=0.7.3,<0.8.0)", + "python-dotenv (>=1.2.2,<2.0.0)" ] [tool.poetry] diff --git a/src/core/_version.py b/src/core/_version.py index 24ce740..08650f5 100644 --- a/src/core/_version.py +++ b/src/core/_version.py @@ -1,3 +1,2 @@ -# Auto-generated version file - do not edit manually -# This file is updated from pyproject.toml when available -VERSION = "1.1.0" +# Auto-generated — do not edit manually. +VERSION = "1.2.0" diff --git a/src/core/config.py b/src/core/config.py index e727380..ff1e564 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -2,15 +2,20 @@ Configuration management for Tagger Three levels of configuration: -1. Global config (.Tagger.!gtag next to Tagger.py) - app-wide settings +1. Global config (~/.config/Tagger/.Tagger.!gtag) - app-wide settings 2. Folder config (.Tagger.!ftag in project root) - folder-specific settings 3. File tags (.{filename}.!tag) - per-file metadata (handled in file.py) """ import json from pathlib import Path -# Global config file (next to the main script) -GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Tagger.!gtag" +# Global config file in XDG config directory (~/.config/Tagger/) +# Migrates automatically from the old location next to Tagger.py if found. +_XDG_CONFIG_DIR = Path.home() / ".config" / "Tagger" +GLOBAL_CONFIG_FILE = _XDG_CONFIG_DIR / ".Tagger.!gtag" + +# Legacy location (next to Tagger.py) — used only for one-time migration +_LEGACY_GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Tagger.!gtag" # Folder config filename FOLDER_CONFIG_NAME = ".Tagger.!ftag" @@ -29,13 +34,22 @@ DEFAULT_GLOBAL_CONFIG = { } +def _migrate_legacy_config() -> None: + """Migrate global config from old location (next to Tagger.py) to ~/.config/Tagger/.""" + if GLOBAL_CONFIG_FILE.exists() or not _LEGACY_GLOBAL_CONFIG_FILE.exists(): + return + _XDG_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + GLOBAL_CONFIG_FILE.write_bytes(_LEGACY_GLOBAL_CONFIG_FILE.read_bytes()) + _LEGACY_GLOBAL_CONFIG_FILE.unlink() + + def load_global_config() -> dict: """Load global application config""" + _migrate_legacy_config() if GLOBAL_CONFIG_FILE.exists(): try: with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f) - # Merge with defaults for any missing keys for key, value in DEFAULT_GLOBAL_CONFIG.items(): if key not in config: config[key] = value @@ -47,6 +61,7 @@ def load_global_config() -> dict: def save_global_config(cfg: dict): """Save global application config""" + _XDG_CONFIG_DIR.mkdir(parents=True, exist_ok=True) with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=2, ensure_ascii=False) @@ -76,7 +91,6 @@ def load_folder_config(folder: Path) -> dict: try: with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) - # Merge with defaults for any missing keys for key, value in DEFAULT_FOLDER_CONFIG.items(): if key not in config: config[key] = value diff --git a/src/core/constants.py b/src/core/constants.py index 7fa3460..6846fe4 100644 --- a/src/core/constants.py +++ b/src/core/constants.py @@ -2,105 +2,58 @@ """ 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. +Version loading priority: + 1. pyproject.toml [project] version (preferred, uses tomllib) + 2. src/core/_version.py VERSION (generated fallback for frozen/PyInstaller builds) + 3. "0.0.0" (last resort) + +Debug mode: + Controlled via .env: ENV_DEBUG=true + Accepted true-values: true, 1, yes (case-insensitive) """ +import os +import tomllib from pathlib import Path -# 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 +def _load_version() -> str: + # 1. pyproject.toml 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: + with open(_PYPROJECT_PATH, "rb") as f: + version = tomllib.load(f)["project"]["version"] + # Write fallback for frozen/PyInstaller builds + _VERSION_FILE.write_text( + f'# Auto-generated — do not edit manually.\nVERSION = "{version}"\n', + encoding="utf-8", + ) + return version + except (FileNotFoundError, KeyError, OSError): pass - return False - -def _extract_version_from_toml() -> str | None: - """Extract version from pyproject.toml.""" - if not _PYPROJECT_PATH.exists(): - return None + # 2. _version.py 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 + from src.core._version import VERSION as _ver # type: ignore[import] return _ver except ImportError: pass + + # 3. last resort 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 _load_debug() -> bool: + return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes") -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 +VERSION = _load_version() +DEBUG = _load_debug() - # 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/csfd.py b/src/core/csfd.py index 39e99e4..622102e 100644 --- a/src/core/csfd.py +++ b/src/core/csfd.py @@ -52,6 +52,43 @@ class CSFDMovie: plot: Optional[str] = None csfd_id: Optional[int] = None + def to_dict(self) -> dict: + """Serialize to a plain dict for storage in .!tag cache.""" + return { + "title": self.title, + "url": self.url, + "year": self.year, + "genres": self.genres, + "directors": self.directors, + "actors": self.actors, + "rating": self.rating, + "rating_count": self.rating_count, + "duration": self.duration, + "country": self.country, + "poster_url": self.poster_url, + "plot": self.plot, + "csfd_id": self.csfd_id, + } + + @classmethod + def from_dict(cls, data: dict) -> "CSFDMovie": + """Deserialize from a plain dict (e.g. loaded from .!tag cache).""" + return cls( + title=data.get("title", ""), + url=data.get("url", ""), + year=data.get("year"), + genres=data.get("genres", []), + directors=data.get("directors", []), + actors=data.get("actors", []), + rating=data.get("rating"), + rating_count=data.get("rating_count"), + duration=data.get("duration"), + country=data.get("country"), + poster_url=data.get("poster_url"), + plot=data.get("plot"), + csfd_id=data.get("csfd_id"), + ) + def __str__(self) -> str: parts = [self.title] if self.year: diff --git a/src/core/file.py b/src/core/file.py index 8baa67b..3caf112 100644 --- a/src/core/file.py +++ b/src/core/file.py @@ -2,6 +2,9 @@ from pathlib import Path import json from .tag import Tag +# Bump this when the csfd_cache schema changes to force re-fetch on next open. +CSFD_CACHE_VERSION = 1 + class File: def __init__(self, file_path: Path, tagmanager=None) -> None: self.file_path = file_path @@ -11,10 +14,12 @@ class File: self.ignored = False self.tags: list[Tag] = [] self.tagmanager = tagmanager - # new: optional date string "YYYY-MM-DD" (assigned manually) + # optional date string "YYYY-MM-DD" (assigned manually) self.date: str | None = None # CSFD.cz URL for movie info self.csfd_url: str | None = None + # Cached CSFD data — avoids re-fetching on every open + self.csfd_cache: dict | None = None self.get_metadata() def get_metadata(self) -> None: @@ -35,13 +40,12 @@ class File: data = { "new": self.new, "ignored": self.ignored, - # ukládáme full_path tagů "tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags], - # date může být None "date": self.date, - # CSFD URL "csfd_url": self.csfd_url, } + if self.csfd_cache is not None: + data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache} with open(self.metadata_filename, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) @@ -53,6 +57,11 @@ class File: self.tags = [] self.date = data.get("date", None) self.csfd_url = data.get("csfd_url", None) + raw_cache = data.get("csfd_cache") + if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION: + self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"} + else: + self.csfd_cache = None if not self.tagmanager: return @@ -73,19 +82,33 @@ class File: self.save_metadata() def set_csfd_url(self, url: str | None): - """Nastaví CSFD URL nebo None pro smazání.""" - if url is None or url == "": - self.csfd_url = None - else: - self.csfd_url = url + """Nastaví CSFD URL nebo None pro smazání. Invaliduje cache při změně URL.""" + new_url = url if url else None + if new_url != self.csfd_url: + self.csfd_cache = None # URL changed — old cache is stale + self.csfd_url = new_url self.save_metadata() + def get_cached_movie(self): + """ + Vrátí CSFDMovie z cache nebo None pokud cache není k dispozici. + Nevyžaduje síťové připojení. + """ + if self.csfd_cache is None: + return None + try: + from .csfd import CSFDMovie + return CSFDMovie.from_dict(self.csfd_cache) + except Exception: + return None + def apply_csfd_tags(self, add_genres: bool = True, add_year: bool = True, add_country: bool = True) -> dict: """ Načte informace z CSFD a přiřadí tagy (žánr, rok, země). Returns: dict s klíči 'success', 'movie', 'error', 'tags_added' + """ if not self.csfd_url: return {"success": False, "error": "CSFD URL není nastavena", "tags_added": []} @@ -93,6 +116,7 @@ class File: try: from .csfd import fetch_movie movie = fetch_movie(self.csfd_url) + self.csfd_cache = movie.to_dict() except ImportError as e: return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []} except Exception as e: diff --git a/src/core/file_manager.py b/src/core/file_manager.py index 99a5009..6611ea9 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -1,15 +1,26 @@ +from dataclasses import dataclass from pathlib import Path +from typing import Callable, Iterable +import fnmatch + from .file import File from .tag import Tag from .tag_manager import TagManager from .utils import list_files -from typing import Iterable -import fnmatch from src.core.config import ( load_global_config, save_global_config, load_folder_config, save_folder_config ) +_MAX_UNDO = 50 + + +@dataclass +class _UndoEntry: + description: str + undo: Callable[[], None] + redo: Callable[[], None] + class FileManager: def __init__(self, tagmanager: TagManager): @@ -17,9 +28,14 @@ class FileManager: self.folders: list[Path] = [] self.tagmanager = tagmanager self.on_files_changed = None # callback do GUI + self.on_tags_changed = None # callback do GUI po rename/merge operacích + # callback(orphans: list[Path]) — volán po append() pokud jsou nalezeny osiřelé .!tag + self.on_orphaned_tags = None self.global_config = load_global_config() self.folder_configs: dict[Path, dict] = {} # folder -> config self.current_folder: Path | None = None + self._undo_stack: list[_UndoEntry] = [] + self._redo_stack: list[_UndoEntry] = [] def append(self, folder: Path) -> None: """Add a folder to scan for files""" @@ -46,6 +62,8 @@ class FileManager: # Get ignore patterns from folder config ignore_patterns = folder_config.get("ignore_patterns", []) + known_files: set[Path] = set() + for each in list_files(folder): # Skip all Tagger metadata files if each.name.endswith(".!tag"): # File tags: .filename.!tag @@ -64,9 +82,45 @@ class FileManager: ): continue + known_files.add(each) file_obj = File(each, self.tagmanager) self.filelist.append(file_obj) + # Detect orphaned .!tag files (sidecar without a matching media file). + # This happens when the original file was renamed or moved without its sidecar. + orphans = self._find_orphaned_tags(folder, known_files) + if orphans and self.on_orphaned_tags: + self.on_orphaned_tags(orphans) + + def _find_orphaned_tags(self, folder: Path, known_files: set[Path]) -> list[Path]: + """ + Return .!tag sidecar files that have no matching media file. + A sidecar `.filename.!tag` is orphaned when `filename` is not in known_files. + """ + orphans = [] + for tag_file in folder.rglob("*.!tag"): + # Sidecar name format: .{original_name}.!tag (hidden dot-file) + name = tag_file.name # e.g. ".film.mkv.!tag" + if not name.startswith("."): + continue + original_name = name[1:-len(".!tag")] # strip leading dot and suffix + expected = tag_file.parent / original_name + if expected not in known_files: + orphans.append(tag_file) + return orphans + + def find_orphaned_tags(self, folder: Path = None) -> list[Path]: + """ + Public method: scan folder and return all orphaned .!tag sidecar paths. + Useful for manual cleanup or UI display. + """ + if folder is None: + folder = self.current_folder + if folder is None: + return [] + known = {f.file_path for f in self.filelist} + return self._find_orphaned_tags(folder, known) + def get_folder_config(self, folder: Path = None) -> dict: """Get config for a folder (or current folder if not specified)""" if folder is None: @@ -99,6 +153,66 @@ class FileManager: config = self.get_folder_config(folder) return config.get("ignore_patterns", []) + # ================================================== + # UNDO / REDO + # ================================================== + + def _push_undo(self, entry: _UndoEntry) -> None: + self._undo_stack.append(entry) + if len(self._undo_stack) > _MAX_UNDO: + self._undo_stack.pop(0) + self._redo_stack.clear() + + def can_undo(self) -> bool: + return bool(self._undo_stack) + + def can_redo(self) -> bool: + return bool(self._redo_stack) + + def undo(self) -> str | None: + """Vrátí zpět poslední operaci. Vrací popis operace nebo None.""" + if not self._undo_stack: + return None + entry = self._undo_stack.pop() + entry.undo() + self._redo_stack.append(entry) + return entry.description + + def redo(self) -> str | None: + """Zopakuje naposledy vrácenou operaci. Vrací popis operace nebo None.""" + if not self._redo_stack: + return None + entry = self._redo_stack.pop() + entry.redo() + self._undo_stack.append(entry) + return entry.description + + def _snapshot_files(self, files: list[File]) -> dict[Path, list[str]]: + """Zaznamená aktuální tagy souborů jako full_path řetězce.""" + return {f.file_path: [t.full_path for t in f.tags] for f in files} + + def _restore_snapshot(self, snapshot: dict[Path, list[str]]) -> None: + """Obnoví tagy souborů ze snapshotu. Zajistí existenci tagů v TagManageru.""" + path_to_file = {f.file_path: f for f in self.filelist} + for path, tag_paths in snapshot.items(): + f = path_to_file.get(path) + if f is None: + continue + new_tags = [] + for fp in tag_paths: + if "/" not in fp: + continue + cat, name = fp.split("/", 1) + new_tags.append(self.tagmanager.add_tag(cat, name)) + f.tags = new_tags + f.save_metadata() + if self.on_files_changed: + self.on_files_changed(self.filelist) + + # ================================================== + # TAG OPERATIONS + # ================================================== + def assign_tag_to_files(self, files: list[File], tag): """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" if isinstance(tag, str): @@ -107,14 +221,25 @@ class FileManager: else: tag_obj = tag - for f in files: - if tag_obj not in f.tags: - f.tags.append(tag_obj) - f.save_metadata() + # Only files that don't already have the tag will be changed + affected = [f for f in files if tag_obj not in f.tags] + snapshot_before = self._snapshot_files(affected) + + for f in affected: + f.tags.append(tag_obj) + f.save_metadata() if self.on_files_changed: self.on_files_changed(self.filelist) + if affected: + snapshot_after = self._snapshot_files(affected) + self._push_undo(_UndoEntry( + description=f"Přiřadit tag {tag_obj.full_path}", + undo=lambda s=snapshot_before: self._restore_snapshot(s), + redo=lambda s=snapshot_after: self._restore_snapshot(s), + )) + def remove_tag_from_files(self, files: list[File], tag): """Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů.""" if isinstance(tag, str): @@ -122,35 +247,82 @@ class FileManager: else: tag_obj = tag - for f in files: - if tag_obj in f.tags: - f.tags.remove(tag_obj) - f.save_metadata() + affected = [f for f in files if tag_obj in f.tags] + snapshot_before = self._snapshot_files(affected) + + for f in affected: + f.tags.remove(tag_obj) + f.save_metadata() if self.on_files_changed: self.on_files_changed(self.filelist) - def filter_files_by_tags(self, tags: Iterable): - """ - Vrátí jen soubory, které obsahují všechny zadané tagy. - 'tags' může být iterace Tag objektů nebo stringů 'category/name'. - """ - tags_list = list(tags) if tags is not None else [] - if not tags_list: - return self.filelist + if affected: + snapshot_after = self._snapshot_files(affected) + self._push_undo(_UndoEntry( + description=f"Odebrat tag {tag_obj.full_path}", + undo=lambda s=snapshot_before: self._restore_snapshot(s), + redo=lambda s=snapshot_after: self._restore_snapshot(s), + )) - target_full_paths = set() - for t in tags_list: + @staticmethod + def _to_full_paths(tags) -> set[str]: + """Převede kolekci Tag objektů nebo stringů na sadu full_path řetězců.""" + result = set() + if not tags: + return result + for t in tags: if isinstance(t, Tag): - target_full_paths.add(t.full_path) + result.add(t.full_path) elif isinstance(t, str): - target_full_paths.add(t) + result.add(t) + return result + + def filter_files_by_tags( + self, + tags: Iterable = None, + *, + must_have: Iterable = None, + any_of: Iterable = None, + must_not: Iterable = None, + ) -> list[File]: + """ + Vrátí soubory dle tagových podmínek. + + Parametry lze kombinovat: + tags / must_have — soubor musí mít VŠECHNY tyto tagy (AND) + any_of — soubor musí mít ALESPOŇ JEDEN z těchto tagů (OR) + must_not — soubor nesmí mít ŽÁDNÝ z těchto tagů (NOT) + + Zpětně kompatibilní: filter_files_by_tags(tags) funguje stejně jako dříve. + + Příklad: + filter_files_by_tags( + any_of=["Žánr/Drama", "Žánr/Thriller"], + must_not=["Rok/2000", "Rok/2001"], + ) + """ + # Backward compat: positional `tags` arg maps to must_have + must_have_paths = self._to_full_paths(tags) | self._to_full_paths(must_have) + any_of_paths = self._to_full_paths(any_of) + must_not_paths = self._to_full_paths(must_not) + + # Fast path: no filters at all + if not must_have_paths and not any_of_paths and not must_not_paths: + return self.filelist filtered = [] for f in self.filelist: file_tags = {t.full_path for t in f.tags} - if all(tag in file_tags for tag in target_full_paths): - filtered.append(f) + + if must_have_paths and not must_have_paths.issubset(file_tags): + continue + if any_of_paths and not any_of_paths.intersection(file_tags): + continue + if must_not_paths and must_not_paths.intersection(file_tags): + continue + + filtered.append(f) return filtered # Backwards compatibility aliases @@ -191,170 +363,208 @@ class FileManager: self.folders.clear() self.folder_configs.clear() self.current_folder = None + self._undo_stack.clear() + self._redo_stack.clear() # Notify GUI if self.on_files_changed: self.on_files_changed([]) def rename_tag_in_files(self, category: str, old_name: str, new_name: str) -> int: - """ - Rename a tag in all files that have it. - - Args: - category: The category containing the tag - old_name: Current name of the tag - new_name: New name for the tag - - Returns: - Number of files updated - """ old_tag = Tag(category, old_name) - new_tag = self.tagmanager.rename_tag(category, old_name, new_name) + affected = [f for f in self.filelist if old_tag in f.tags] + snapshot_before = self._snapshot_files(affected) + new_tag = self.tagmanager.rename_tag(category, old_name, new_name) if new_tag is None: return 0 - updated_count = 0 - for f in self.filelist: - if old_tag in f.tags: - # Remove old tag and add new one - f.tags.remove(old_tag) - f.tags.append(new_tag) - f.save_metadata() - updated_count += 1 + for f in affected: + f.tags.remove(old_tag) + f.tags.append(new_tag) + f.save_metadata() - if updated_count > 0 and self.on_files_changed: + if affected and self.on_files_changed: self.on_files_changed(self.filelist) - return updated_count + snapshot_after = self._snapshot_files(affected) + + def _undo_rename_tag(snap=snapshot_before, cat=category, old=old_name, new=new_name): + self.tagmanager.rename_tag(cat, new, old) + self._restore_snapshot(snap) + if self.on_tags_changed: + self.on_tags_changed() + + def _redo_rename_tag(snap=snapshot_after, cat=category, old=old_name, new=new_name): + self.tagmanager.rename_tag(cat, old, new) + self._restore_snapshot(snap) + if self.on_tags_changed: + self.on_tags_changed() + + self._push_undo(_UndoEntry( + description=f"Přejmenovat štítek {category}/{old_name} → {new_name}", + undo=_undo_rename_tag, + redo=_redo_rename_tag, + )) + return len(affected) def rename_category_in_files(self, old_category: str, new_category: str) -> int: - """ - Rename a category in all files that have tags from it. - - Args: - old_category: Current name of the category - new_category: New name for the category - - Returns: - Number of files updated - """ - # Get all tags in old category before renaming old_tags = self.tagmanager.get_tags_in_category(old_category) if not old_tags: return 0 - # Rename the category in TagManager + affected = [f for f in self.filelist + if any(t.category == old_category for t in f.tags)] + snapshot_before = self._snapshot_files(affected) + if not self.tagmanager.rename_category(old_category, new_category): return 0 - updated_count = 0 - for f in self.filelist: - file_updated = False - new_tags = [] - for tag in f.tags: - if tag.category == old_category: - # Replace with new category tag - new_tags.append(Tag(new_category, tag.name)) - file_updated = True - else: - new_tags.append(tag) + for f in affected: + f.tags = [ + Tag(new_category, t.name) if t.category == old_category else t + for t in f.tags + ] + f.save_metadata() - if file_updated: - f.tags = new_tags - f.save_metadata() - updated_count += 1 - - if updated_count > 0 and self.on_files_changed: + if affected and self.on_files_changed: self.on_files_changed(self.filelist) - return updated_count + snapshot_after = self._snapshot_files(affected) + + def _undo_rename_cat(snap=snapshot_before, old=old_category, new=new_category): + self.tagmanager.rename_category(new, old) + self._restore_snapshot(snap) + if self.on_tags_changed: + self.on_tags_changed() + + def _redo_rename_cat(snap=snapshot_after, old=old_category, new=new_category): + self.tagmanager.rename_category(old, new) + self._restore_snapshot(snap) + if self.on_tags_changed: + self.on_tags_changed() + + self._push_undo(_UndoEntry( + description=f"Přejmenovat kategorii {old_category} → {new_category}", + undo=_undo_rename_cat, + redo=_redo_rename_cat, + )) + return len(affected) def merge_tag_in_files(self, category: str, source_name: str, target_name: str) -> int: - """ - Merge source tag into target tag in all files. - Files with source tag will have it replaced by target tag. - Files that already have target tag will just have source tag removed. - - Args: - category: The category containing both tags - source_name: Name of the tag to merge (will be removed) - target_name: Name of the tag to merge into (will be kept) - - Returns: - Number of files updated - """ source_tag = Tag(category, source_name) target_tag = Tag(category, target_name) - # Merge in TagManager first + affected = [f for f in self.filelist if source_tag in f.tags] + snapshot_before = self._snapshot_files(affected) + result_tag = self.tagmanager.merge_tag(category, source_name, target_name) if result_tag is None: return 0 - updated_count = 0 - for f in self.filelist: - if source_tag in f.tags: - # Remove source tag - f.tags.remove(source_tag) + for f in affected: + f.tags.remove(source_tag) + if target_tag not in f.tags: + f.tags.append(target_tag) + f.save_metadata() - # Add target tag if not already present - if target_tag not in f.tags: - f.tags.append(target_tag) - - f.save_metadata() - updated_count += 1 - - if updated_count > 0 and self.on_files_changed: + if affected and self.on_files_changed: self.on_files_changed(self.filelist) - return updated_count + snapshot_after = self._snapshot_files(affected) + + def _undo_merge_tag(snap=snapshot_before, cat=category, src=source_name): + self.tagmanager.add_tag(cat, src) # re-add deleted source tag + self._restore_snapshot(snap) + if self.on_tags_changed: + self.on_tags_changed() + + def _redo_merge_tag(snap=snapshot_after, cat=category, src=source_name): + self.tagmanager.remove_tag(cat, src) # re-remove source from TM + self._restore_snapshot(snap) + if self.on_tags_changed: + self.on_tags_changed() + + self._push_undo(_UndoEntry( + description=f"Sloučit štítek {category}/{source_name} → {target_name}", + undo=_undo_merge_tag, + redo=_redo_merge_tag, + )) + return len(affected) def merge_category_in_files(self, source_category: str, target_category: str) -> int: - """ - Merge source category into target category in all files. - All tags from source category will be moved to target category. - - Args: - source_category: Category to merge (will be removed) - target_category: Category to merge into (will receive all tags) - - Returns: - Number of files updated - """ - # Get all tags in source category before merging source_tags = self.tagmanager.get_tags_in_category(source_category) if not source_tags: return 0 - # Merge in TagManager first + source_tag_names = [t.name for t in source_tags] + original_target_tag_names = { + t.name for t in self.tagmanager.get_tags_in_category(target_category) + } + + affected = [f for f in self.filelist + if any(t.category == source_category for t in f.tags)] + snapshot_before = self._snapshot_files(affected) + if not self.tagmanager.merge_category(source_category, target_category): return 0 updated_count = 0 - for f in self.filelist: - file_updated = False - new_tags = [] + for f in affected: + new_tags: list[Tag] = [] for tag in f.tags: if tag.category == source_category: - # Replace with target category tag new_tag = Tag(target_category, tag.name) - # Only add if not already present if new_tag not in new_tags: new_tags.append(new_tag) - file_updated = True else: if tag not in new_tags: new_tags.append(tag) - - if file_updated: - f.tags = new_tags - f.save_metadata() - updated_count += 1 + f.tags = new_tags + f.save_metadata() + updated_count += 1 if updated_count > 0 and self.on_files_changed: self.on_files_changed(self.filelist) + snapshot_after = self._snapshot_files(affected) + + def _undo_merge_cat( + snap=snapshot_before, + src_cat=source_category, + tgt_cat=target_category, + src_names=source_tag_names, + orig_tgt=original_target_tag_names, + ): + # Remove from target tags that came only from source + for name in src_names: + if name not in orig_tgt: + self.tagmanager.remove_tag(tgt_cat, name) + # Re-create source category + for name in src_names: + self.tagmanager.add_tag(src_cat, name) + self._restore_snapshot(snap) + if self.on_tags_changed: + self.on_tags_changed() + + def _redo_merge_cat( + snap=snapshot_after, + src_cat=source_category, + tgt_cat=target_category, + src_names=source_tag_names, + ): + for name in src_names: + self.tagmanager.add_tag(tgt_cat, name) + self.tagmanager.remove_category(src_cat) + self._restore_snapshot(snap) + if self.on_tags_changed: + self.on_tags_changed() + + self._push_undo(_UndoEntry( + description=f"Sloučit kategorii {source_category} → {target_category}", + undo=_undo_merge_cat, + redo=_redo_merge_cat, + )) return updated_count # Legacy property for backwards compatibility diff --git a/src/ui/constants.py b/src/ui/constants.py new file mode 100644 index 0000000..ed92dac --- /dev/null +++ b/src/ui/constants.py @@ -0,0 +1,33 @@ +""" +Shared UI constants for Tagger GUI. +""" + +COLORS = { + "bg": "#ffffff", + "sidebar_bg": "#f5f5f5", + "toolbar_bg": "#f0f0f0", + "selected": "#0078d7", + "selected_text": "#ffffff", + "border": "#d0d0d0", + "status_bg": "#f8f8f8", + "text": "#000000", +} + +TAG_COLORS = [ + "#e74c3c", # red + "#3498db", # blue + "#2ecc71", # green + "#f39c12", # orange + "#9b59b6", # purple + "#1abc9c", # teal + "#e91e63", # pink + "#00bcd4", # cyan +] + +DEFAULT_CATEGORY_COLORS = { + "Hodnocení": "#f1c40f", # gold/yellow for stars + "Barva": "#95a5a6", # gray for color category +} + +# Categories where only one tag can be active at a time (radio-button behaviour) +EXCLUSIVE_CATEGORIES: set[str] = {"Hodnocení"} diff --git a/src/ui/dialogs.py b/src/ui/dialogs.py new file mode 100644 index 0000000..9130ae1 --- /dev/null +++ b/src/ui/dialogs.py @@ -0,0 +1,211 @@ +""" +Dialogs for Tagger GUI. +""" +from typing import List + +from PySide6.QtWidgets import ( + QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, + QScrollArea, QWidget, QLabel, QCheckBox, QPushButton, QFrame, +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +from src.core.file import File +from src.core.tag import Tag +from src.core.tag_manager import DEFAULT_TAG_ORDER +from src.ui.constants import EXCLUSIVE_CATEGORIES + + +class MultiFileTagAssignDialog(QDialog): + """Dialog for bulk tag assignment to multiple files.""" + + def __init__(self, parent, all_tags: List[Tag], files: List[File], + category_colors: dict = None): + super().__init__(parent) + self.setWindowTitle("Přiřadit tagy k vybraným souborům") + self.setMinimumSize(500, 600) + self.result = None + self.tags_by_full = {t.full_path: t for t in all_tags} + self.files = files + self.category_colors = category_colors or {} + self.checkboxes: dict[str, QCheckBox] = {} + self.category_checkboxes: dict[str, list] = {} + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + header = QLabel(f"Vybráno souborů: {len(self.files)}") + header.setFont(QFont("Arial", 11, QFont.Bold)) + header.setAlignment(Qt.AlignCenter) + layout.addWidget(header) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + + content = QWidget() + content_layout = QVBoxLayout(content) + content_layout.setSpacing(2) + + file_tag_sets = [{t.full_path for t in f.tags} for f in self.files] + + tags_by_category: dict[str, list] = {} + for full_path, tag in self.tags_by_full.items(): + tags_by_category.setdefault(tag.category, []).append((full_path, tag)) + + for category in tags_by_category: + if category in DEFAULT_TAG_ORDER: + order = DEFAULT_TAG_ORDER[category] + tags_by_category[category].sort( + key=lambda x: order.get(x[1].name, 999) + ) + else: + tags_by_category[category].sort(key=lambda x: x[1].name) + + for category in sorted(tags_by_category.keys()): + color = self.category_colors.get(category, "#333333") + is_exclusive = category in EXCLUSIVE_CATEGORIES + exclusive_note = " (pouze jedno)" if is_exclusive else "" + + cat_label = QLabel(f"▸ {category}{exclusive_note}") + cat_label.setFont(QFont("Arial", 10, QFont.Bold)) + cat_label.setStyleSheet(f"color: {color}; margin-top: 12px;") + content_layout.addWidget(cat_label) + + self.category_checkboxes[category] = [] + + for full_path, tag in tags_by_category[category]: + have_count = sum(1 for s in file_tag_sets if full_path in s) + if have_count == 0: + init_state = Qt.Unchecked + elif have_count == len(self.files): + init_state = Qt.Checked + else: + init_state = Qt.PartiallyChecked + + cb = QCheckBox(f" {tag.name}") + cb.setTristate(True) + cb.setCheckState(init_state) + cb.setProperty("full_path", full_path) + cb.setProperty("category", category) + cb.setProperty("tag_color", color) + + self._update_checkbox_style(cb) + cb.stateChanged.connect(lambda state, c=cb: self._on_state_changed(c)) + + content_layout.addWidget(cb) + self.checkboxes[full_path] = cb + self.category_checkboxes[category].append(cb) + + content_layout.addStretch() + scroll.setWidget(content) + layout.addWidget(scroll) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self._on_ok) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def _update_checkbox_style(self, cb: QCheckBox) -> None: + state = cb.checkState() + color = cb.property("tag_color") or "#333333" + if state == Qt.Unchecked: + cb.setStyleSheet("color: #666666;") + elif state == Qt.Checked: + cb.setStyleSheet(f"color: {color};") + else: + cb.setStyleSheet("color: #cc6600;") + + def _on_state_changed(self, cb: QCheckBox) -> None: + category = cb.property("category") + if category in EXCLUSIVE_CATEGORIES and cb.checkState() == Qt.Checked: + for other_cb in self.category_checkboxes.get(category, []): + if other_cb != cb: + other_cb.blockSignals(True) + other_cb.setCheckState(Qt.Unchecked) + self._update_checkbox_style(other_cb) + other_cb.blockSignals(False) + self._update_checkbox_style(cb) + + def _on_ok(self) -> None: + self.result = {} + for full_path, cb in self.checkboxes.items(): + state = cb.checkState() + if state == Qt.Checked: + self.result[full_path] = 1 + elif state == Qt.Unchecked: + self.result[full_path] = 0 + else: + self.result[full_path] = 2 # mixed — don't change + self.accept() + + +class CategorySelectionDialog(QDialog): + """Dialog for selecting categories for hardlink structure.""" + + def __init__(self, parent, categories: List[str], category_colors: dict, + preselected: List[str] | None = None): + super().__init__(parent) + self.setWindowTitle("Vybrat kategorie") + self.setMinimumSize(350, 400) + self.categories = categories + self.category_colors = category_colors + self.preselected = preselected + self.result = None + self.checkboxes: dict[str, QCheckBox] = {} + + self._setup_ui() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + + header = QLabel("Vyberte kategorie pro vytvoření struktury:") + header.setFont(QFont("Arial", 10, QFont.Bold)) + layout.addWidget(header) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + content = QWidget() + content_layout = QVBoxLayout(content) + + for category in sorted(self.categories): + initial_value = self.preselected is None or category in self.preselected + color = self.category_colors.get(category, "#333333") + cb = QCheckBox(category) + cb.setChecked(initial_value) + cb.setStyleSheet(f"color: {color};") + content_layout.addWidget(cb) + self.checkboxes[category] = cb + + content_layout.addStretch() + scroll.setWidget(content) + layout.addWidget(scroll) + + sel_layout = QHBoxLayout() + btn_all = QPushButton("Všechny") + btn_all.clicked.connect(self._select_all) + btn_none = QPushButton("Žádné") + btn_none.clicked.connect(self._select_none) + sel_layout.addWidget(btn_all) + sel_layout.addWidget(btn_none) + sel_layout.addStretch() + layout.addLayout(sel_layout) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self._on_ok) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def _select_all(self) -> None: + for cb in self.checkboxes.values(): + cb.setChecked(True) + + def _select_none(self) -> None: + for cb in self.checkboxes.values(): + cb.setChecked(False) + + def _on_ok(self) -> None: + self.result = [cat for cat, cb in self.checkboxes.items() if cb.isChecked()] + self.accept() diff --git a/src/ui/gui.py b/src/ui/gui.py index c0e7373..197b152 100644 --- a/src/ui/gui.py +++ b/src/ui/gui.py @@ -1,1588 +1,20 @@ """ -Modern PySide6/Qt6 GUI for Tagger +Thin re-export shim — the GUI has been split into sub-modules. +Import directly from the sub-modules for new code. """ -import os -import sys -import subprocess -import re -from pathlib import Path -from typing import List +from src.ui.main_window import App, MainWindow +from src.ui.dialogs import CategorySelectionDialog, MultiFileTagAssignDialog +from src.ui.workers import VideoResolutionWorker +from src.ui.constants import COLORS, TAG_COLORS, DEFAULT_CATEGORY_COLORS, EXCLUSIVE_CATEGORIES -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 -from src.core.constants import APP_NAME, APP_VIEWPORT -from src.core.config import save_global_config -from src.core.hardlink_manager import HardlinkManager - - -# Color scheme -COLORS = { - "bg": "#ffffff", - "sidebar_bg": "#f5f5f5", - "toolbar_bg": "#f0f0f0", - "selected": "#0078d7", - "selected_text": "#ffffff", - "border": "#d0d0d0", - "status_bg": "#f8f8f8", - "text": "#000000", -} - -# Tag category colors -TAG_COLORS = [ - "#e74c3c", # red - "#3498db", # blue - "#2ecc71", # green - "#f39c12", # orange - "#9b59b6", # purple - "#1abc9c", # teal - "#e91e63", # pink - "#00bcd4", # cyan +__all__ = [ + "App", + "MainWindow", + "CategorySelectionDialog", + "MultiFileTagAssignDialog", + "VideoResolutionWorker", + "COLORS", + "TAG_COLORS", + "DEFAULT_CATEGORY_COLORS", + "EXCLUSIVE_CATEGORIES", ] - -# Fixed colors for default categories -DEFAULT_CATEGORY_COLORS = { - "Hodnocení": "#f1c40f", # gold/yellow for stars - "Barva": "#95a5a6", # gray for color category -} - -# Categories where only one tag can be selected (exclusive/radio behavior) -EXCLUSIVE_CATEGORIES = {"Hodnocení"} - - -class MultiFileTagAssignDialog(QDialog): - """Dialog for bulk tag assignment to multiple files""" - - def __init__(self, parent, all_tags: List[Tag], files: List[File], - category_colors: dict = None): - super().__init__(parent) - self.setWindowTitle("Přiřadit tagy k vybraným souborům") - self.setMinimumSize(500, 600) - self.result = None - self.tags_by_full = {t.full_path: t for t in all_tags} - self.files = files - self.category_colors = category_colors or {} - self.checkboxes: dict[str, QCheckBox] = {} - self.category_checkboxes: dict[str, list] = {} - - self._setup_ui() - - def _setup_ui(self): - layout = QVBoxLayout(self) - - # Header - header = QLabel(f"Vybráno souborů: {len(self.files)}") - header.setFont(QFont("Arial", 11, QFont.Bold)) - header.setAlignment(Qt.AlignCenter) - layout.addWidget(header) - - # Scrollable content - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.NoFrame) - - content = QWidget() - content_layout = QVBoxLayout(content) - content_layout.setSpacing(2) - - # 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 = {} - for full_path, tag in self.tags_by_full.items(): - if tag.category not in tags_by_category: - tags_by_category[tag.category] = [] - tags_by_category[tag.category].append((full_path, tag)) - - # Sort tags within each category - for category in tags_by_category: - if category in DEFAULT_TAG_ORDER: - order = DEFAULT_TAG_ORDER[category] - tags_by_category[category].sort( - key=lambda x: order.get(x[1].name, 999) - ) - else: - tags_by_category[category].sort(key=lambda x: x[1].name) - - # 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 "" - - # 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_checkboxes[category] = [] - - for full_path, tag in tags_by_category[category]: - have_count = sum(1 for s in file_tag_sets if full_path in s) - if have_count == 0: - init_state = Qt.Unchecked - elif have_count == len(self.files): - init_state = Qt.Checked - else: - init_state = Qt.PartiallyChecked - - cb = QCheckBox(f" {tag.name}") - cb.setTristate(True) - cb.setCheckState(init_state) - cb.setProperty("full_path", full_path) - cb.setProperty("category", category) - cb.setProperty("tag_color", color) - - # Style based on state - self._update_checkbox_style(cb) - cb.stateChanged.connect(lambda state, c=cb: self._on_state_changed(c)) - - content_layout.addWidget(cb) - self.checkboxes[full_path] = cb - self.category_checkboxes[category].append(cb) - - content_layout.addStretch() - scroll.setWidget(content) - layout.addWidget(scroll) - - # 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 _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 cb.checkState() == Qt.Checked: - # Uncheck all others in this category - for other_cb in self.category_checkboxes.get(category, []): - if other_cb != cb: - other_cb.blockSignals(True) - other_cb.setCheckState(Qt.Unchecked) - self._update_checkbox_style(other_cb) - other_cb.blockSignals(False) - - self._update_checkbox_style(cb) - - def _on_ok(self): - self.result = {} - for full_path, cb in self.checkboxes.items(): - state = cb.checkState() - if state == Qt.Checked: - self.result[full_path] = 1 - elif state == Qt.Unchecked: - self.result[full_path] = 0 - else: - self.result[full_path] = 2 # mixed - don't change - self.accept() - - -class CategorySelectionDialog(QDialog): - """Dialog for selecting categories for hardlink structure""" - - def __init__(self, parent, categories: List[str], category_colors: dict, - preselected: List[str] | None = None): - super().__init__(parent) - self.setWindowTitle("Vybrat kategorie") - self.setMinimumSize(350, 400) - self.categories = categories - self.category_colors = category_colors - self.preselected = preselected - self.result = None - self.checkboxes: dict[str, QCheckBox] = {} - - self._setup_ui() - - def _setup_ui(self): - 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.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_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 - - 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") - if last: - try: - self.filehandler.append(Path(last)) - except Exception: - pass - - # Initial refresh - self.refresh_sidebar() - self.update_files_from_manager(self.filehandler.filelist) - - def _setup_window(self): - self.setWindowTitle(APP_NAME) - - # Parse viewport size - try: - 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): - menubar = self.menuBar() - - # File menu - 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 = 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 = menubar.addMenu("Nástroje") - - 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): - toolbar = QToolBar() - toolbar.setMovable(False) - self.addToolBar(toolbar) - - # 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) - - # Refresh button - refresh_btn = QPushButton("🔄 Obnovit") - refresh_btn.setFlat(True) - refresh_btn.clicked.connect(self.refresh_all) - toolbar.addWidget(refresh_btn) - - toolbar.addSeparator() - - # 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) - - # 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) - - toolbar.addSeparator() - - # Spacer - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - toolbar.addWidget(spacer) - - # 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): - central = QWidget() - self.setCentralWidget(central) - - layout = QHBoxLayout(central) - layout.setContentsMargins(0, 0, 0, 0) - - # Splitter for sidebar and main content - splitter = QSplitter(Qt.Horizontal) - - # Left sidebar - sidebar = self._create_sidebar() - splitter.addWidget(sidebar) - - # Right panel (file table) - file_panel = self._create_file_panel() - splitter.addWidget(file_panel) - - # 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 - 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) - - layout.addWidget(self.tag_tree) - - return sidebar - - def _create_file_panel(self) -> QWidget: - panel = QWidget() - layout = QVBoxLayout(panel) - layout.setContentsMargins(5, 5, 5, 5) - - # Control panel - control_layout = QHBoxLayout() - - 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 - 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) - - 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 - ) - - layout.addWidget(self.file_table) - - return panel - - def _create_status_bar(self): - self.status_bar = QStatusBar() - self.setStatusBar(self.status_bar) - - self.status_label = QLabel("Připraven") - self.status_bar.addWidget(self.status_label, 1) - - self.selected_count_label = QLabel("") - self.status_bar.addPermanentWidget(self.selected_count_label) - - self.selected_size_label = QLabel("") - self.status_bar.addPermanentWidget(self.selected_size_label) - - self.file_count_label = QLabel("0 souborů") - self.status_bar.addPermanentWidget(self.file_count_label) - - def _setup_shortcuts(self): - from PySide6.QtGui import QShortcut, QKeySequence - - 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 - # ================================================== - - def refresh_sidebar(self): - """Refresh tag tree in sidebar""" - self.tag_tree.clear() - self.tag_tree_items.clear() - self.tag_states.clear() - - # 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 - - # Root item - total_files = len(self.filehandler.filelist) - 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: - if category in DEFAULT_CATEGORY_COLORS: - self.category_colors[category] = DEFAULT_CATEGORY_COLORS[category] - else: - self.category_colors[category] = TAG_COLORS[ - color_index % len(TAG_COLORS) - ] - color_index += 1 - - # Add categories and tags - for category in categories: - color = self.category_colors.get(category, "#333333") - - cat_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_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 - }) - - 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: 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 - - 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 "" - 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.root_item.setText(0, f"📂 Všechny soubory ({total})") - - 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 - - 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 - - self.selected_tree_item = item - data = item.data(0, Qt.UserRole) - - menu = QMenu(self) - - add_action = QAction("Nový štítek", self) - add_action.triggered.connect(self.tree_add_tag) - menu.addAction(add_action) - - 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) - - 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, ok = QInputDialog.getText(self, "Nový tag", "Název tagu:") - if not ok or not name: - return - - item = getattr(self, 'selected_tree_item', None) if not background else None - data = item.data(0, Qt.UserRole) if item else None - - if data and data.get("type") == "category": - category = data["name"] - self.tagmanager.add_tag(category, name) - else: - self.tagmanager.add_category(name) - - self.refresh_sidebar() - self.status_label.setText(f"Vytvořen tag: {name}") - - def tree_delete_tag(self): - """Delete selected tag""" - item = getattr(self, 'selected_tree_item', None) - if not item: - return - - data = item.data(0, Qt.UserRole) - if not data: - return - - 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) - - def tree_rename_tag(self): - """Rename selected tag or category""" - item = getattr(self, 'selected_tree_item', None) - if not item: - return - - data = item.data(0, Qt.UserRole) - if not data: - return - - 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}':", - text=current_name - ) - if not ok or not new_name or new_name == current_name: - return - - # Check for existing category - offer merge - if new_name in self.tagmanager.get_categories(): - reply = QMessageBox.question( - self, "Kategorie existuje", - f"Kategorie '{new_name}' již existuje.\n\n" - f"Chcete sloučit '{current_name}' do '{new_name}'?" - ) - if reply != QMessageBox.Yes: - return - 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 - - # 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 '{current_name}' do '{new_name}'?" - ) - if reply != QMessageBox.Yes: - return - 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ů)" - ) - - 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 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 - - # ================================================== - # FILE TABLE METHODS - # ================================================== - - def update_files_from_manager(self, filelist=None): - """Update file table""" - if filelist is None: - filelist = self.filehandler.filelist - - # Filter by checked tags - checked_tags = self.get_checked_tags() - filtered_files = self.filehandler.filter_files_by_tags(checked_tags) - - # Filter by search text - search_text = self.search_input.text().lower() if hasattr(self, 'search_input') else "" - if search_text: - filtered_files = [ - f for f in filtered_files - if search_text in f.filename.lower() or - (self.show_full_path and search_text in str(f.file_path).lower()) - ] - - # Filter ignored - if self.hide_ignored: - filtered_files = [ - f for f in filtered_files - if "Stav/Ignorované" not in {t.full_path for t in f.tags} - ] - - # Clear table - self.file_table.setSortingEnabled(False) - self.file_table.setRowCount(0) - self.file_items.clear() - - # Populate table - 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]]) - if len(f.tags) > 3: - tags += f" +{len(f.tags) - 3}" - csfd = "✓" if f.csfd_url else "" - - try: - size = f.file_path.stat().st_size - size_str = self._format_size(size) - except: - size_str = "?" - - 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.setText(f"{len(filtered_files)} souborů") - self.status_label.setText(f"Zobrazeno {len(filtered_files)} souborů") - - # Update tag counts - self.update_tag_counts(filtered_files) - - def _format_size(self, size_bytes: int) -> str: - """Format file size""" - for unit in ['B', 'KB', 'MB', 'GB']: - if size_bytes < 1024.0: - return f"{size_bytes:.1f} {unit}" - size_bytes /= 1024.0 - return f"{size_bytes:.1f} TB" - - def get_selected_files(self) -> List[File]: - """Get selected files from table""" - 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): - """Update status bar when selection changes""" - files = self.get_selected_files() - count = len(files) - - if count == 0: - self.selected_count_label.setText("") - self.selected_size_label.setText("") - else: - 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.setText(f"[{self._format_size(total_size)}]") - - 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_context_menu(self, pos): - """Show context menu for files""" - menu = QMenu(self) - - open_action = QAction("Otevřít soubor", self) - open_action.triggered.connect(self.open_selected_files) - menu.addAction(open_action) - - 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) - - 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"): - os.startfile(path) - elif sys.platform.startswith("darwin"): - subprocess.call(["open", path]) - else: - subprocess.call(["xdg-open", path]) - self.status_label.setText(f"Otevírám: {path.name}") - except Exception as e: - QMessageBox.critical(self, "Chyba", f"Nelze otevřít {path}: {e}") - - # ================================================== - # ACTIONS - # ================================================== - - def open_folder_dialog(self): - """Open folder selection dialog""" - folder = QFileDialog.getExistingDirectory( - self, "Vyber složku pro sledování" - ) - if not folder: - return - - folder_path = Path(folder) - try: - self.filehandler.append(folder_path) - for f in self.filehandler.filelist: - if f.tags and f.tagmanager: - for t in f.tags: - f.tagmanager.add_tag(t.category, t.name) - - self.status_label.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: - 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.setText("Žádná složka není otevřena") - return - - folder_name = self.filehandler.current_folder.name - self.filehandler.close_folder() - self.refresh_sidebar() - self.status_label.setText(f"Složka zavřena: {folder_name}") - - def open_selected_files(self): - """Open selected files""" - for f in self.get_selected_files(): - self.open_file(f.file_path) - - def remove_selected_files(self): - """Remove selected files from index""" - files = self.get_selected_files() - if not files: - return - - 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.setText(f"Odstraněno {len(files)} souborů z indexu") - - def assign_tag_to_selected_bulk(self): - """Assign tags to selected files""" - files = self.get_selected_files() - if not files: - self.status_label.setText("Nebyly vybrány žádné soubory") - return - - all_tags = [] - for category in self.tagmanager.get_categories(): - for tag in self.tagmanager.get_tags_in_category(category): - all_tags.append(tag) - - if not all_tags: - QMessageBox.warning(self, "Chyba", "Žádné tagy nejsou definovány") - return - - 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 dialog.result.items(): - if state == 1: - if "/" in full_path: - category, name = full_path.split("/", 1) - tag_obj = self.tagmanager.add_tag(category, name) - self.filehandler.assign_tag_to_file_objects(files, tag_obj) - elif state == 0: - if "/" in full_path: - category, name = full_path.split("/", 1) - tag_obj = Tag(category, name) - self.filehandler.remove_tag_from_file_objects(files, tag_obj) - - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.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.setText("Nebyly vybrány žádné soubory") - return - - 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.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.setText("Nebyly vybrány žádné soubory") - return - - count = 0 - for f in files: - try: - path = str(f.file_path) - result = subprocess.run( - ["ffprobe", "-v", "error", "-select_streams", "v:0", - "-show_entries", "stream=height", "-of", "csv=p=0", path], - capture_output=True, - text=True, - check=True - ) - height_str = result.stdout.strip() - if not height_str.isdigit(): - continue - height = int(height_str) - tag_name = f"{height}p" - tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name) - f.add_tag(tag_obj) - count += 1 - except Exception: - pass - - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.setText(f"Přiřazeno rozlišení tagů k {count} souborům") - - def set_ignore_patterns(self): - """Set ignore patterns""" - current = ", ".join(self.filehandler.get_ignore_patterns()) - patterns, ok = QInputDialog.getText( - self, "Ignore patterns", - "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", - text=current - ) - if not ok: - return - - 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.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 = 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.csfd_column_action.isChecked() - self.file_table.setColumnHidden(3, not self.show_csfd_column) - - 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 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) - 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.setText("Nebyly vybrány žádné soubory") - return - - current_url = files[0].csfd_url or "" - 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.setText(f"CSFD URL nastaveno pro {len(files)} soubor(ů)") - - def apply_csfd_tags_for_selected(self): - """Load tags from CSFD""" - files = self.get_selected_files() - if not files: - self.status_label.setText("Nebyly vybrány žádné soubory") - return - - files_with_url = [f for f in files if f.csfd_url] - if not files_with_url: - QMessageBox.warning( - self, "Upozornění", - "Žádný z vybraných souborů nemá nastavenou CSFD URL" - ) - return - - 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 - all_tags_added = [] - - for f in files_with_url: - result = f.apply_csfd_tags() - if result["success"]: - success_count += 1 - all_tags_added.extend(result["tags_added"]) - else: - error_count += 1 - - self.refresh_sidebar() - self.update_files_from_manager(self.filehandler.filelist) - - if error_count > 0: - 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ů" - ) - else: - 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""" - self.update_files_from_manager(self.filehandler.filelist) - - def refresh_all(self): - """Refresh everything""" - self.refresh_sidebar() - self.update_files_from_manager(self.filehandler.filelist) - self.status_label.setText("Obnoveno") - - def configure_hardlink_folder(self): - """Configure hardlink output folder""" - if not self.filehandler.current_folder: - QMessageBox.warning(self, "Upozornění", "Nejprve otevřete složku") - return - - folder_config = self.filehandler.get_folder_config() - current_dir = folder_config.get("hardlink_output_dir") - current_categories = folder_config.get("hardlink_categories") - - initial_dir = current_dir if current_dir else str(self.filehandler.current_folder) - output_dir = QFileDialog.getExistingDirectory( - self, "Vyber cílovou složku pro hardlink strukturu", - initial_dir - ) - if not output_dir: - return - - categories = self.tagmanager.get_categories() - if not categories: - QMessageBox.warning(self, "Upozornění", "Žádné kategorie tagů") - return - - dialog = CategorySelectionDialog( - self, categories, self.category_colors, current_categories - ) - if dialog.exec() != QDialog.Accepted: - return - - folder_config["hardlink_output_dir"] = output_dir - folder_config["hardlink_categories"] = dialog.result if dialog.result else None - self.filehandler.save_folder_config(config=folder_config) - - 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""" - if not self.filehandler.current_folder: - QMessageBox.warning(self, "Upozornění", "Nejprve otevřete složku") - return - - 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: - 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: - QMessageBox.warning(self, "Upozornění", "Žádné soubory k zpracování") - return - - manager = HardlinkManager(output_path) - preview_create = manager.get_preview(files, saved_categories) - obsolete = manager.find_obsolete_links(files, saved_categories) - - to_create = [] - for source, target in preview_create: - if not target.exists(): - to_create.append((source, target)) - elif not manager._is_same_file(source, target): - to_create.append((source, target)) - - if not to_create and not obsolete: - QMessageBox.information( - self, "Info", - "Struktura je již synchronizovaná, žádné změny nejsou potřeba" - ) - return - - confirm_lines = [] - if to_create: - confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") - if obsolete: - confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") - confirm_lines.append(f"\nCílová složka: {output_path}") - confirm_lines.append("\nPokračovat?") - - reply = QMessageBox.question( - self, "Potvrdit aktualizaci", "\n".join(confirm_lines) - ) - if reply != QMessageBox.Yes: - return - - self.status_label.setText("Aktualizuji hardlink strukturu...") - QApplication.processEvents() - - created, create_fail, removed, remove_fail = manager.sync_structure( - files, saved_categories - ) - - result_lines = [] - if created > 0: - result_lines.append(f"Vytvořeno: {created} hardlinků") - if removed > 0: - result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") - - if create_fail > 0 or remove_fail > 0: - if create_fail > 0: - result_lines.append(f"Selhalo vytvoření: {create_fail}") - if remove_fail > 0: - result_lines.append(f"Selhalo odebrání: {remove_fail}") - QMessageBox.warning( - self, "Dokončeno s chybami", "\n".join(result_lines) - ) - else: - QMessageBox.information( - self, "Hotovo", - "\n".join(result_lines) if result_lines else "Žádné změny" - ) - - self.status_label.setText( - f"Hardlink struktura aktualizována " - f"(vytvořeno: {created}, odebráno: {removed})" - ) - - def create_hardlink_structure(self): - """Create hardlink structure with manual selection""" - files = self.filehandler.filelist - if not files: - QMessageBox.warning(self, "Upozornění", "Žádné soubory k zpracování") - return - - output_dir = QFileDialog.getExistingDirectory( - self, "Vyber cílovou složku pro hardlink strukturu" - ) - if not output_dir: - return - - output_path = Path(output_dir) - - categories = self.tagmanager.get_categories() - if not categories: - QMessageBox.warning(self, "Upozornění", "Žádné kategorie tagů") - return - - dialog = CategorySelectionDialog( - self, categories, self.category_colors - ) - if dialog.exec() != QDialog.Accepted: - return - - cat_filter = dialog.result if dialog.result else None - - manager = HardlinkManager(output_path) - preview_create = manager.get_preview(files, cat_filter) - obsolete = manager.find_obsolete_links(files, cat_filter) - - to_create = [] - for source, target in preview_create: - if not target.exists(): - to_create.append((source, target)) - elif not manager._is_same_file(source, target): - to_create.append((source, target)) - - if not to_create and not obsolete: - QMessageBox.information( - self, "Info", - "Struktura je již synchronizovaná, žádné změny nejsou potřeba" - ) - return - - confirm_lines = [] - if to_create: - confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") - if obsolete: - confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") - confirm_lines.append(f"\nCílová složka: {output_path}") - confirm_lines.append("\nPokračovat?") - - reply = QMessageBox.question( - self, "Potvrdit synchronizaci", "\n".join(confirm_lines) - ) - if reply != QMessageBox.Yes: - return - - self.status_label.setText("Synchronizuji hardlink strukturu...") - QApplication.processEvents() - - created, create_fail, removed, remove_fail = manager.sync_structure( - files, cat_filter - ) - - result_lines = [] - if created > 0 or create_fail > 0: - result_lines.append(f"Vytvořeno: {created} hardlinků") - if create_fail > 0: - result_lines.append(f"Selhalo vytvoření: {create_fail}") - if removed > 0 or remove_fail > 0: - result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") - if remove_fail > 0: - result_lines.append(f"Selhalo odebrání: {remove_fail}") - - if create_fail > 0 or remove_fail > 0: - if manager.errors: - result_lines.append("\nChyby:") - for path, err in manager.errors[:5]: - result_lines.append(f"- {path.name}: {err}") - if len(manager.errors) > 5: - result_lines.append( - f"... a dalších {len(manager.errors) - 5} chyb" - ) - QMessageBox.warning( - self, "Dokončeno s chybami", "\n".join(result_lines) - ) - else: - QMessageBox.information( - self, "Hotovo", - "\n".join(result_lines) if result_lines else "Žádné změny" - ) - - self.status_label.setText( - f"Hardlink struktura synchronizována " - f"(vytvořeno: {created}, odebráno: {removed})" - ) - - def closeEvent(self, event): - """Save window state on close""" - is_maximized = self.isMaximized() - self.filehandler.global_config["window_maximized"] = is_maximized - - if not is_maximized: - geo = self.geometry() - self.filehandler.global_config["window_geometry"] = ( - f"{geo.width()}x{geo.height()}+{geo.x()}+{geo.y()}" - ) - - save_global_config(self.filehandler.global_config) - event.accept() - - -class App: - """Application wrapper for compatibility with existing entry point""" - - def __init__(self, filehandler: FileManager, tagmanager: TagManager): - self.filehandler = filehandler - self.tagmanager = tagmanager - - def main(self): - app = QApplication.instance() - if app is None: - app = QApplication(sys.argv) - - window = MainWindow(self.filehandler, self.tagmanager) - window.show() - - app.exec() diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..b18be51 --- /dev/null +++ b/src/ui/main_window.py @@ -0,0 +1,1185 @@ +""" +MainWindow and App for Tagger (PySide6/Qt6). +""" +import csv +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import List + +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QAction, QColor, QFont, QIcon, QPixmap +from PySide6.QtWidgets import ( + QAbstractItemView, QApplication, QCheckBox, QDialog, QDialogButtonBox, + QFileDialog, QFrame, QHeaderView, QInputDialog, QLabel, QLineEdit, + QMainWindow, QMenu, QMessageBox, QPushButton, QScrollArea, QSizePolicy, + QSplitter, QStatusBar, QTableWidget, QTableWidgetItem, QToolBar, + QTreeWidget, QTreeWidgetItem, QVBoxLayout, QHBoxLayout, QWidget, +) + +from src.core.config import save_global_config +from src.core.constants import APP_NAME, APP_VIEWPORT +from src.core.file import File +from src.core.file_manager import FileManager +from src.core.hardlink_manager import HardlinkManager +from src.core.tag import Tag +from src.core.tag_manager import TagManager +from src.ui.constants import DEFAULT_CATEGORY_COLORS, EXCLUSIVE_CATEGORIES, TAG_COLORS +from src.ui.dialogs import CategorySelectionDialog, MultiFileTagAssignDialog +from src.ui.workers import VideoResolutionWorker + + +class MainWindow(QMainWindow): + """Main application window.""" + + def __init__(self, filehandler: FileManager, tagmanager: TagManager): + super().__init__() + self.filehandler = filehandler + self.tagmanager = tagmanager + + self.tag_states: dict[str, bool] = {} + self.file_items: dict[int, File] = {} + self.tag_tree_items: dict[str, tuple] = {} + self.filter_text = "" + self.show_full_path = False + 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 + self.filehandler.on_tags_changed = self._on_tags_changed + + self.setAcceptDrops(True) + self._setup_window() + self._create_menu() + self._create_toolbar() + self._create_main_layout() + self._create_status_bar() + self._setup_shortcuts() + + last = self.filehandler.global_config.get("last_folder") + if last: + try: + self.filehandler.append(Path(last)) + except Exception: + pass + + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + + # ================================================== + # SETUP + # ================================================== + + def _setup_window(self) -> None: + self.setWindowTitle(APP_NAME) + try: + w, h = APP_VIEWPORT.split("x") + self.resize(int(w), int(h)) + except Exception: + self.resize(1000, 700) + + 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 Exception: + pass + + if self.filehandler.global_config.get("window_maximized", False): + self.showMaximized() + + def _create_menu(self) -> None: + menubar = self.menuBar() + + 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) + + export_action = QAction("Exportovat do CSV...", self) + export_action.triggered.connect(self.export_to_csv) + file_menu.addAction(export_action) + + file_menu.addSeparator() + + exit_action = QAction("Ukončit (Ctrl+Q)", self) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + edit_menu = menubar.addMenu("Úpravy") + + self._undo_action = QAction("Zpět (Ctrl+Z)", self) + self._undo_action.setEnabled(False) + self._undo_action.triggered.connect(self._do_undo) + edit_menu.addAction(self._undo_action) + + self._redo_action = QAction("Znovu (Ctrl+Y)", self) + self._redo_action.setEnabled(False) + self._redo_action.triggered.connect(self._do_redo) + edit_menu.addAction(self._redo_action) + + 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 = menubar.addMenu("Nástroje") + + date_action = QAction("Nastavit datum (Ctrl+D)", self) + date_action.triggered.connect(self.set_date_for_selected) + tools_menu.addAction(date_action) + + self.detect_resolution_action = QAction("Detekovat rozlišení videí", self) + self.detect_resolution_action.triggered.connect(self.detect_video_resolution) + tools_menu.addAction(self.detect_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) -> None: + toolbar = QToolBar() + toolbar.setMovable(False) + self.addToolBar(toolbar) + + open_btn = QPushButton("📁 Otevřít složku") + open_btn.setFlat(True) + open_btn.clicked.connect(self.open_folder_dialog) + toolbar.addWidget(open_btn) + + refresh_btn = QPushButton("🔄 Obnovit") + refresh_btn.setFlat(True) + refresh_btn.clicked.connect(self.refresh_all) + toolbar.addWidget(refresh_btn) + + toolbar.addSeparator() + + tag_btn = QPushButton("🏷️ Nový tag") + tag_btn.setFlat(True) + tag_btn.clicked.connect(lambda: self.tree_add_tag(background=True)) + toolbar.addWidget(tag_btn) + + date_btn = QPushButton("📅 Nastavit datum") + date_btn.setFlat(True) + date_btn.clicked.connect(self.set_date_for_selected) + toolbar.addWidget(date_btn) + + toolbar.addSeparator() + + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + toolbar.addWidget(spacer) + + 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) -> None: + central = QWidget() + self.setCentralWidget(central) + + layout = QHBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + + splitter = QSplitter(Qt.Horizontal) + splitter.addWidget(self._create_sidebar()) + splitter.addWidget(self._create_file_panel()) + 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 = QLabel("📂 Štítky") + header.setFont(QFont("Arial", 10, QFont.Bold)) + layout.addWidget(header) + + 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) + layout.addWidget(self.tag_tree) + + return sidebar + + def _create_file_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.setContentsMargins(5, 5, 5, 5) + + control_layout = QHBoxLayout() + 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) + + 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) + for col in range(1, 5): + self.file_table.horizontalHeader().setSectionResizeMode( + col, QHeaderView.ResizeToContents + ) + self.file_table.verticalHeader().setVisible(False) + 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) + layout.addWidget(self.file_table) + + return panel + + def _create_status_bar(self) -> None: + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + + self.status_label = QLabel("Připraven") + self.status_bar.addWidget(self.status_label, 1) + + self.selected_count_label = QLabel("") + self.status_bar.addPermanentWidget(self.selected_count_label) + + self.selected_size_label = QLabel("") + self.status_bar.addPermanentWidget(self.selected_size_label) + + self.file_count_label = QLabel("0 souborů") + self.status_bar.addPermanentWidget(self.file_count_label) + + def _setup_shortcuts(self) -> None: + from PySide6.QtGui import QShortcut, QKeySequence + 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("Ctrl+Z"), self, self._do_undo) + QShortcut(QKeySequence("Ctrl+Y"), self, self._do_redo) + QShortcut(QKeySequence("F5"), self, self.refresh_all) + QShortcut(QKeySequence("Delete"), self, self.remove_selected_files) + + # ================================================== + # UNDO / REDO + # ================================================== + + def _update_undo_redo_actions(self) -> None: + self._undo_action.setEnabled(self.filehandler.can_undo()) + self._redo_action.setEnabled(self.filehandler.can_redo()) + if self.filehandler.can_undo(): + desc = self.filehandler._undo_stack[-1].description + self._undo_action.setText(f"Zpět: {desc} (Ctrl+Z)") + else: + self._undo_action.setText("Zpět (Ctrl+Z)") + if self.filehandler.can_redo(): + desc = self.filehandler._redo_stack[-1].description + self._redo_action.setText(f"Znovu: {desc} (Ctrl+Y)") + else: + self._redo_action.setText("Znovu (Ctrl+Y)") + + def _do_undo(self) -> None: + desc = self.filehandler.undo() + if desc: + self.refresh_sidebar() + self.status_label.setText(f"Zpět: {desc}") + self._update_undo_redo_actions() + + def _do_redo(self) -> None: + desc = self.filehandler.redo() + if desc: + self.refresh_sidebar() + self.status_label.setText(f"Znovu: {desc}") + self._update_undo_redo_actions() + + def _on_tags_changed(self) -> None: + """Callback when tag/category structure changes (rename/merge via undo/redo).""" + self.refresh_sidebar() + self._update_undo_redo_actions() + + # ================================================== + # SIDEBAR / TAG TREE + # ================================================== + + def refresh_sidebar(self) -> None: + self.tag_tree.clear() + self.tag_tree_items.clear() + self.tag_states.clear() + + tag_counts: dict[str, int] = {} + for f in self.filehandler.filelist: + for t in f.tags: + tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 + + total_files = len(self.filehandler.filelist) + root = QTreeWidgetItem(self.tag_tree) + root.setText(0, f"📂 Všechny soubory ({total_files})") + root.setExpanded(True) + self.root_item = root + + color_index = 0 + for category in self.tagmanager.get_categories(): + if category not in self.category_colors: + if category in DEFAULT_CATEGORY_COLORS: + self.category_colors[category] = DEFAULT_CATEGORY_COLORS[category] + else: + self.category_colors[category] = TAG_COLORS[color_index % len(TAG_COLORS)] + color_index += 1 + + for category in self.tagmanager.get_categories(): + color = self.category_colors.get(category, "#333333") + + 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_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, + }) + 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: List[File]) -> None: + tag_counts: dict[str, int] = {} + for f in filtered_files: + for t in f.tags: + tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 + + 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 "" + checked = "☑" if self.tag_states.get(full_path, False) else "☐" + item.setText(0, f"{checked} {tag_name}{count_str}") + + self.root_item.setText(0, f"📂 Všechny soubory ({len(filtered_files)})") + + def on_tree_item_clicked(self, item: QTreeWidgetItem, column: int) -> None: + data = item.data(0, Qt.UserRole) + if not data or data.get("type") != "tag": + return + + full_path = data["full_path"] + self.tag_states[full_path] = not self.tag_states.get(full_path, False) + + 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) -> None: + item = self.tag_tree.itemAt(pos) + if not item: + return + + self.selected_tree_item = item + data = item.data(0, Qt.UserRole) + menu = QMenu(self) + + add_action = QAction("Nový štítek", self) + add_action.triggered.connect(self.tree_add_tag) + menu.addAction(add_action) + + 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) + + 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: bool = False) -> None: + name, ok = QInputDialog.getText(self, "Nový tag", "Název tagu:") + if not ok or not name: + return + + item = getattr(self, "selected_tree_item", None) if not background else None + data = item.data(0, Qt.UserRole) if item else None + + if data and data.get("type") == "category": + self.tagmanager.add_tag(data["name"], name) + else: + self.tagmanager.add_category(name) + + self.refresh_sidebar() + self.status_label.setText(f"Vytvořen tag: {name}") + + def tree_delete_tag(self) -> None: + item = getattr(self, "selected_tree_item", None) + if not item: + return + data = item.data(0, Qt.UserRole) + if not data: + return + + if data.get("type") == "category": + name = data["name"] + if QMessageBox.question( + self, "Smazat kategorii", f"Opravdu chcete smazat kategorii '{name}'?" + ) == QMessageBox.Yes: + self.tagmanager.remove_category(name) + elif data.get("type") == "tag": + name, category = data["name"], data["category"] + if QMessageBox.question( + self, "Smazat štítek", f"Opravdu chcete smazat štítek '{name}'?" + ) == QMessageBox.Yes: + self.tagmanager.remove_tag(category, name) + + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + + def tree_rename_tag(self) -> None: + item = getattr(self, "selected_tree_item", None) + if not item: + return + data = item.data(0, Qt.UserRole) + if not data: + return + + 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}':", text=current_name + ) + if not ok or not new_name or new_name == current_name: + return + + if new_name in self.tagmanager.get_categories(): + if QMessageBox.question( + self, "Kategorie existuje", + f"Kategorie '{new_name}' již existuje.\n\n" + f"Chcete sloučit '{current_name}' do '{new_name}'?" + ) != QMessageBox.Yes: + return + updated = self.filehandler.merge_category_in_files(current_name, new_name) + self.status_label.setText( + f"Kategorie sloučena: {current_name} → {new_name} ({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} ({updated} souborů)" + ) + + elif data.get("type") == "tag": + current_name, category = data["name"], 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 + + existing = [t.name for t in self.tagmanager.get_tags_in_category(category)] + if new_name in existing: + if QMessageBox.question( + self, "Štítek existuje", + f"Štítek '{new_name}' v kategorii '{category}' již existuje.\n\n" + f"Chcete sloučit '{current_name}' do '{new_name}'?" + ) != QMessageBox.Yes: + return + 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ů)" + ) + + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + self._update_undo_redo_actions() + + def get_checked_tags(self) -> List[Tag]: + tags = [] + 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 + + # ================================================== + # FILE TABLE + # ================================================== + + def update_files_from_manager(self, filelist=None) -> None: + checked_tags = self.get_checked_tags() + filtered_files = self.filehandler.filter_files_by_tags(checked_tags) + + search_text = self.search_input.text().lower() if hasattr(self, "search_input") else "" + if search_text: + filtered_files = [ + f for f in filtered_files + if search_text in f.filename.lower() or + (self.show_full_path and search_text in str(f.file_path).lower()) + ] + + 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} + ] + + self.file_table.setSortingEnabled(False) + self.file_table.setRowCount(0) + self.file_items.clear() + + 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 + tags = ", ".join([t.name for t in f.tags[:3]]) + if len(f.tags) > 3: + tags += f" +{len(f.tags) - 3}" + try: + size_str = self._format_size(f.file_path.stat().st_size) + except Exception: + size_str = "?" + + self.file_table.setItem(row, 0, QTableWidgetItem(name)) + self.file_table.setItem(row, 1, QTableWidgetItem(f.date or "")) + self.file_table.setItem(row, 2, QTableWidgetItem(tags)) + self.file_table.setItem(row, 3, QTableWidgetItem("✓" if f.csfd_url else "")) + self.file_table.setItem(row, 4, QTableWidgetItem(size_str)) + self.file_items[row] = f + + self.file_table.setSortingEnabled(True) + self.file_table.setColumnHidden(3, not self.show_csfd_column) + self.file_count_label.setText(f"{len(filtered_files)} souborů") + self.status_label.setText(f"Zobrazeno {len(filtered_files)} souborů") + self.update_tag_counts(filtered_files) + + def _format_size(self, size_bytes: int) -> str: + for unit in ["B", "KB", "MB", "GB"]: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + def get_selected_files(self) -> List[File]: + files = [] + for index in self.file_table.selectionModel().selectedRows(): + row = index.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) -> None: + files = self.get_selected_files() + if not files: + self.selected_count_label.setText("") + self.selected_size_label.setText("") + return + self.selected_count_label.setText(f"{len(files)} vybráno") + total_size = sum( + f.file_path.stat().st_size for f in files + if f.file_path.exists() + ) + self.selected_size_label.setText(f"[{self._format_size(total_size)}]") + + def on_file_double_click(self, index) -> None: + for f in self.get_selected_files(): + self.open_file(f.file_path) + + def on_file_context_menu(self, pos) -> None: + menu = QMenu(self) + menu.addAction(QAction("Otevřít soubor", self, + triggered=self.open_selected_files)) + menu.addAction(QAction("Přiřadit štítky (Ctrl+T)", self, + triggered=self.assign_tag_to_selected_bulk)) + menu.addAction(QAction("Nastavit datum (Ctrl+D)", self, + triggered=self.set_date_for_selected)) + menu.addSeparator() + menu.addAction(QAction("Nastavit CSFD URL...", self, + triggered=self.set_csfd_url_for_selected)) + menu.addAction(QAction("Načíst tagy z CSFD", self, + triggered=self.apply_csfd_tags_for_selected)) + menu.addSeparator() + menu.addAction(QAction("Smazat z indexu (Del)", self, + triggered=self.remove_selected_files)) + menu.exec(self.file_table.mapToGlobal(pos)) + + def on_sort_changed(self, column: int, order: Qt.SortOrder) -> None: + self.sort_column = column + self.sort_order = order + + def open_file(self, path: Path) -> None: + try: + if sys.platform.startswith("win"): + os.startfile(path) + elif sys.platform.startswith("darwin"): + subprocess.call(["open", path]) + else: + subprocess.call(["xdg-open", path]) + self.status_label.setText(f"Otevírám: {path.name}") + except Exception as e: + QMessageBox.critical(self, "Chyba", f"Nelze otevřít {path}: {e}") + + # ================================================== + # ACTIONS + # ================================================== + + def dragEnterEvent(self, event) -> None: + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event) -> None: + for url in event.mimeData().urls(): + path = Path(url.toLocalFile()) + folder = path if path.is_dir() else path.parent + if folder.is_dir(): + self._open_folder(folder) + event.acceptProposedAction() + + def open_folder_dialog(self) -> None: + folder = QFileDialog.getExistingDirectory(self, "Vyber složku pro sledování") + if folder: + self._open_folder(Path(folder)) + + def _open_folder(self, folder_path: Path) -> None: + try: + self.filehandler.append(folder_path) + for f in self.filehandler.filelist: + if f.tags and f.tagmanager: + for t in f.tags: + f.tagmanager.add_tag(t.category, t.name) + self.status_label.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: + QMessageBox.critical(self, "Chyba", f"Nelze přidat složku {folder_path}: {e}") + + def close_folder(self) -> None: + if not self.filehandler.current_folder: + self.status_label.setText("Žádná složka není otevřena") + return + folder_name = self.filehandler.current_folder.name + self.filehandler.close_folder() + self.refresh_sidebar() + self.status_label.setText(f"Složka zavřena: {folder_name}") + + def open_selected_files(self) -> None: + for f in self.get_selected_files(): + self.open_file(f.file_path) + + def remove_selected_files(self) -> None: + files = self.get_selected_files() + if not files: + return + if QMessageBox.question( + self, "Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?" + ) == 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.setText(f"Odstraněno {len(files)} souborů z indexu") + + def assign_tag_to_selected_bulk(self) -> None: + files = self.get_selected_files() + if not files: + self.status_label.setText("Nebyly vybrány žádné soubory") + return + + all_tags = [ + tag + for category in self.tagmanager.get_categories() + for tag in self.tagmanager.get_tags_in_category(category) + ] + if not all_tags: + QMessageBox.warning(self, "Chyba", "Žádné tagy nejsou definovány") + return + + 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 dialog.result.items(): + if "/" not in full_path: + continue + category, name = full_path.split("/", 1) + if state == 1: + tag_obj = self.tagmanager.add_tag(category, name) + self.filehandler.assign_tag_to_file_objects(files, tag_obj) + elif state == 0: + self.filehandler.remove_tag_from_file_objects(files, Tag(category, name)) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.setText("Hromadné přiřazení tagů dokončeno") + self._update_undo_redo_actions() + + def set_date_for_selected(self) -> None: + files = self.get_selected_files() + if not files: + self.status_label.setText("Nebyly vybrány žádné soubory") + return + + 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.setText(f"Nastaveno datum pro {len(files)} soubor(ů)") + + def detect_video_resolution(self) -> None: + files = self.get_selected_files() + if not files: + self.status_label.setText("Nebyly vybrány žádné soubory") + return + + self._resolution_worker = VideoResolutionWorker(files, self.tagmanager) + self._resolution_worker.progress.connect(self._on_resolution_progress) + self._resolution_worker.finished.connect(self._on_resolution_finished) + self.detect_resolution_action.setEnabled(False) + self.status_label.setText(f"Zjišťuji rozlišení (0/{len(files)})…") + self._resolution_worker.start() + + def _on_resolution_progress(self, current: int, total: int) -> None: + self.status_label.setText(f"Zjišťuji rozlišení ({current}/{total})…") + + def _on_resolution_finished(self, count: int) -> None: + self.detect_resolution_action.setEnabled(True) + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.setText(f"Přiřazeno rozlišení tagů k {count} souborům") + + def export_to_csv(self) -> None: + files = self.filehandler.filelist + if not files: + self.status_label.setText("Žádné soubory k exportu") + return + + path, _ = QFileDialog.getSaveFileName( + self, "Exportovat do CSV", "tagger_export.csv", "CSV soubory (*.csv)" + ) + if not path: + return + + with open(path, "w", newline="", encoding="utf-8-sig") as fh: + writer = csv.writer(fh) + writer.writerow(["Název", "Cesta", "Datum", "Tagy", "CSFD URL", "Velikost (B)"]) + for file_obj in files: + tags_str = "; ".join(t.full_path for t in file_obj.tags) + try: + size = file_obj.file_path.stat().st_size + except OSError: + size = "" + writer.writerow([ + file_obj.filename, + str(file_obj.file_path), + file_obj.date or "", + tags_str, + file_obj.csfd_url or "", + size, + ]) + + self.status_label.setText(f"Exportováno {len(files)} souborů → {Path(path).name}") + + def set_ignore_patterns(self) -> None: + current = ", ".join(self.filehandler.get_ignore_patterns()) + patterns, ok = QInputDialog.getText( + self, "Ignore patterns", + "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", + text=current + ) + if not ok: + return + self.filehandler.set_ignore_patterns( + [p.strip() for p in patterns.split(",") if p.strip()] + ) + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.setText("Ignore patterns aktualizovány") + + def toggle_hide_ignored(self) -> None: + self.hide_ignored = self.hide_ignored_action.isChecked() + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_show_path(self) -> None: + self.show_full_path = self.full_path_cb.isChecked() + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_csfd_column(self) -> None: + self.show_csfd_column = self.csfd_column_action.isChecked() + self.file_table.setColumnHidden(3, not self.show_csfd_column) + 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) -> None: + if self.filehandler.current_folder: + folder_config = self.filehandler.get_folder_config() + self.show_csfd_column = folder_config.get("show_csfd_column", True) + 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) -> None: + files = self.get_selected_files() + if not files: + self.status_label.setText("Nebyly vybrány žádné soubory") + return + + url, ok = QInputDialog.getText( + self, "Nastavit CSFD URL", "Zadej CSFD URL:", + text=files[0].csfd_url or "" + ) + 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.setText(f"CSFD URL nastaveno pro {len(files)} soubor(ů)") + + def apply_csfd_tags_for_selected(self) -> None: + files = self.get_selected_files() + if not files: + self.status_label.setText("Nebyly vybrány žádné soubory") + return + + files_with_url = [f for f in files if f.csfd_url] + if not files_with_url: + QMessageBox.warning(self, "Upozornění", + "Žádný z vybraných souborů nemá nastavenou CSFD URL") + return + + self.status_label.setText(f"Načítám tagy z CSFD pro {len(files_with_url)} souborů...") + QApplication.processEvents() + + success_count = error_count = 0 + all_tags_added: list[str] = [] + for f in files_with_url: + result = f.apply_csfd_tags() + if result["success"]: + success_count += 1 + all_tags_added.extend(result["tags_added"]) + else: + error_count += 1 + + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + + if error_count > 0: + 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ů" + ) + else: + self.status_label.setText( + f"Načteno z CSFD: {success_count} souborů, přidáno {len(all_tags_added)} tagů" + ) + + def on_filter_changed(self) -> None: + self.update_files_from_manager(self.filehandler.filelist) + + def refresh_all(self) -> None: + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.setText("Obnoveno") + + def configure_hardlink_folder(self) -> None: + if not self.filehandler.current_folder: + QMessageBox.warning(self, "Upozornění", "Nejprve otevřete složku") + return + + folder_config = self.filehandler.get_folder_config() + current_dir = folder_config.get("hardlink_output_dir") + current_categories = folder_config.get("hardlink_categories") + + initial_dir = current_dir or str(self.filehandler.current_folder) + output_dir = QFileDialog.getExistingDirectory( + self, "Vyber cílovou složku pro hardlink strukturu", initial_dir + ) + if not output_dir: + return + + categories = self.tagmanager.get_categories() + if not categories: + QMessageBox.warning(self, "Upozornění", "Žádné kategorie tagů") + return + + dialog = CategorySelectionDialog( + self, categories, self.category_colors, current_categories + ) + if dialog.exec() != QDialog.Accepted: + return + + folder_config["hardlink_output_dir"] = output_dir + folder_config["hardlink_categories"] = dialog.result or None + self.filehandler.save_folder_config(config=folder_config) + + 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) -> None: + if not self.filehandler.current_folder: + QMessageBox.warning(self, "Upozornění", "Nejprve otevřete složku") + return + + 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: + QMessageBox.information(self, "Info", + "Hardlink složka není nastavena.\n" + "Použijte 'Nastavit hardlink složku...' pro konfiguraci.") + return + + files = self.filehandler.filelist + if not files: + QMessageBox.warning(self, "Upozornění", "Žádné soubory k zpracování") + return + + output_path = Path(output_dir) + manager = HardlinkManager(output_path) + to_create = [ + (s, t) for s, t in manager.get_preview(files, saved_categories) + if not t.exists() or not manager._is_same_file(s, t) + ] + obsolete = manager.find_obsolete_links(files, saved_categories) + + if not to_create and not obsolete: + QMessageBox.information(self, "Info", + "Struktura je již synchronizovaná, žádné změny nejsou potřeba") + return + + lines = [] + if to_create: + lines.append(f"Vytvořit: {len(to_create)} hardlinků") + if obsolete: + lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") + lines += [f"\nCílová složka: {output_path}", "\nPokračovat?"] + + if QMessageBox.question(self, "Potvrdit aktualizaci", "\n".join(lines)) != QMessageBox.Yes: + return + + self.status_label.setText("Aktualizuji hardlink strukturu...") + QApplication.processEvents() + + created, create_fail, removed, remove_fail = manager.sync_structure(files, saved_categories) + self._show_hardlink_result(created, create_fail, removed, remove_fail, output_path) + + def create_hardlink_structure(self) -> None: + files = self.filehandler.filelist + if not files: + QMessageBox.warning(self, "Upozornění", "Žádné soubory k zpracování") + return + + output_dir = QFileDialog.getExistingDirectory( + self, "Vyber cílovou složku pro hardlink strukturu" + ) + if not output_dir: + return + + categories = self.tagmanager.get_categories() + if not categories: + QMessageBox.warning(self, "Upozornění", "Žádné kategorie tagů") + return + + dialog = CategorySelectionDialog(self, categories, self.category_colors) + if dialog.exec() != QDialog.Accepted: + return + + output_path = Path(output_dir) + cat_filter = dialog.result or None + manager = HardlinkManager(output_path) + to_create = [ + (s, t) for s, t in manager.get_preview(files, cat_filter) + if not t.exists() or not manager._is_same_file(s, t) + ] + obsolete = manager.find_obsolete_links(files, cat_filter) + + if not to_create and not obsolete: + QMessageBox.information(self, "Info", + "Struktura je již synchronizovaná, žádné změny nejsou potřeba") + return + + lines = [] + if to_create: + lines.append(f"Vytvořit: {len(to_create)} hardlinků") + if obsolete: + lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") + lines += [f"\nCílová složka: {output_path}", "\nPokračovat?"] + + if QMessageBox.question(self, "Potvrdit synchronizaci", "\n".join(lines)) != QMessageBox.Yes: + return + + self.status_label.setText("Synchronizuji hardlink strukturu...") + QApplication.processEvents() + + created, create_fail, removed, remove_fail = manager.sync_structure(files, cat_filter) + self._show_hardlink_result(created, create_fail, removed, remove_fail, output_path, + show_errors=True, manager=manager) + + def _show_hardlink_result(self, created: int, create_fail: int, removed: int, + remove_fail: int, output_path: Path, + show_errors: bool = False, manager=None) -> None: + lines = [] + if created: + lines.append(f"Vytvořeno: {created} hardlinků") + if removed: + lines.append(f"Odebráno: {removed} zastaralých hardlinků") + if create_fail: + lines.append(f"Selhalo vytvoření: {create_fail}") + if remove_fail: + lines.append(f"Selhalo odebrání: {remove_fail}") + + if show_errors and manager and manager.errors: + lines.append("\nChyby:") + for path, err in manager.errors[:5]: + lines.append(f"- {path.name}: {err}") + if len(manager.errors) > 5: + lines.append(f"... a dalších {len(manager.errors) - 5} chyb") + + msg = "\n".join(lines) if lines else "Žádné změny" + if create_fail or remove_fail: + QMessageBox.warning(self, "Dokončeno s chybami", msg) + else: + QMessageBox.information(self, "Hotovo", msg) + + self.status_label.setText( + f"Hardlink struktura aktualizována (vytvořeno: {created}, odebráno: {removed})" + ) + + def closeEvent(self, event) -> None: + is_maximized = self.isMaximized() + self.filehandler.global_config["window_maximized"] = is_maximized + if not is_maximized: + geo = self.geometry() + self.filehandler.global_config["window_geometry"] = ( + f"{geo.width()}x{geo.height()}+{geo.x()}+{geo.y()}" + ) + save_global_config(self.filehandler.global_config) + event.accept() + + +class App: + """Application entry point.""" + + def __init__(self, filehandler: FileManager, tagmanager: TagManager): + self.filehandler = filehandler + self.tagmanager = tagmanager + + def main(self) -> None: + app = QApplication.instance() + if app is None: + app = QApplication(sys.argv) + window = MainWindow(self.filehandler, self.tagmanager) + window.show() + app.exec() diff --git a/src/ui/workers.py b/src/ui/workers.py new file mode 100644 index 0000000..030505d --- /dev/null +++ b/src/ui/workers.py @@ -0,0 +1,30 @@ +""" +Background QThread workers for Tagger GUI. +""" +from PySide6.QtCore import QThread, Signal + +from src.core.media_utils import add_video_resolution_tag + + +class VideoResolutionWorker(QThread): + """Runs ffprobe on a list of files in a background thread.""" + + progress = Signal(int, int) # (current, total) + finished = Signal(int) # count of successfully tagged files + + def __init__(self, files: list, tagmanager) -> None: + super().__init__() + self.files = files + self.tagmanager = tagmanager + + def run(self) -> None: + count = 0 + total = len(self.files) + for i, f in enumerate(self.files, 1): + try: + add_video_resolution_tag(f, self.tagmanager) + count += 1 + except Exception: + pass + self.progress.emit(i, total) + self.finished.emit(count) diff --git a/tests/test_media_utils.py b/tests/test_media_utils.py index 6e1dfd8..9765809 100644 --- a/tests/test_media_utils.py +++ b/tests/test_media_utils.py @@ -1,4 +1,6 @@ import tempfile +import struct +import zlib from pathlib import Path import pytest import os @@ -20,24 +22,36 @@ def qapp(): yield app +def _make_png(path: Path, width: int = 32, height: int = 32) -> None: + """Write a minimal valid PNG file without Pillow.""" + def chunk(name: bytes, data: bytes) -> bytes: + c = struct.pack(">I", len(data)) + name + data + return c + struct.pack(">I", zlib.crc32(name + data) & 0xFFFFFFFF) + + raw_rows = b"".join(b"\x00" + bytes([255, 0, 0] * width) for _ in range(height)) + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + idat = zlib.compress(raw_rows) + + png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", idat) + + chunk(b"IEND", b"") + ) + path.write_bytes(png) + + def test_load_icon_returns_qicon(qapp): """Test that load_icon returns QIcon""" from src.ui.utils import load_icon from PySide6.QtGui import QIcon - from PIL import Image with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: tmp_path = Path(tmp.name) try: - # Create 100x100 red image - img = Image.new("RGB", (100, 100), color="red") - img.save(tmp_path) - + _make_png(tmp_path, 100, 100) icon = load_icon(tmp_path) - - # Must be QIcon assert isinstance(icon, QIcon) - # Icon should not be null assert not icon.isNull() finally: tmp_path.unlink(missing_ok=True) @@ -46,19 +60,13 @@ def test_load_icon_returns_qicon(qapp): 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: - img = Image.new("RGB", (500, 500), color="blue") - img.save(tmp_path) - + _make_png(tmp_path, 500, 500) icon = load_icon(tmp_path, size=32) - - # Icon should be created successfully assert not icon.isNull() - # Available sizes should include the requested size sizes = icon.availableSizes() assert len(sizes) > 0 finally: @@ -69,20 +77,14 @@ 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: - with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp: - tmp_path = Path(tmp.name) - try: - img = Image.new("RGB", (32, 32), color="green") - img.save(tmp_path) - - icon = load_icon(tmp_path) - - assert isinstance(icon, QIcon) - assert not icon.isNull() - finally: - tmp_path.unlink(missing_ok=True) + # Only PNG is reliably producible without Pillow; BMP can be crafted too + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + _make_png(tmp_path, 32, 32) + icon = load_icon(tmp_path) + assert isinstance(icon, QIcon) + assert not icon.isNull() + finally: + tmp_path.unlink(missing_ok=True) diff --git a/tests/test_undo_redo.py b/tests/test_undo_redo.py new file mode 100644 index 0000000..9862270 --- /dev/null +++ b/tests/test_undo_redo.py @@ -0,0 +1,194 @@ +""" +Tests for FileManager undo/redo stack. +""" +import pytest +from pathlib import Path + +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager +from src.core.file import File +from src.core.tag import Tag + + +@pytest.fixture +def config_dir(tmp_path, monkeypatch): + cfg = tmp_path / "cfg" + cfg.mkdir() + monkeypatch.setattr("src.core.config.GLOBAL_CONFIG_FILE", cfg / ".Tagger.!gtag") + monkeypatch.setattr("src.core.config._XDG_CONFIG_DIR", cfg) + + +@pytest.fixture +def fm(tmp_path, config_dir): + tm = TagManager() + manager = FileManager(tm) + # Two in-memory File objects (no real disk files needed for tag ops) + f1 = File.__new__(File) + f1.file_path = tmp_path / "a.txt" + f1.tags = [] + f1.tagmanager = tm + f1.csfd_url = None + f1.date = None + f1.csfd_cache = None + + f2 = File.__new__(File) + f2.file_path = tmp_path / "b.txt" + f2.tags = [] + f2.tagmanager = tm + f2.csfd_url = None + f2.date = None + f2.csfd_cache = None + + # Patch save_metadata to be a no-op + f1.save_metadata = lambda: None + f2.save_metadata = lambda: None + + manager.filelist = [f1, f2] + manager._f1, manager._f2 = f1, f2 + return manager + + +class TestUndoRedoAssign: + def test_assign_undo_redo(self, fm): + tag = fm.tagmanager.add_tag("Žánr", "Drama") + fm.assign_tag_to_files([fm._f1], tag) + + assert tag in fm._f1.tags + assert fm.can_undo() + assert not fm.can_redo() + + fm.undo() + assert tag not in fm._f1.tags + assert not fm.can_undo() + assert fm.can_redo() + + fm.redo() + assert tag in fm._f1.tags + + def test_remove_undo_redo(self, fm): + tag = fm.tagmanager.add_tag("Žánr", "Komedie") + fm._f1.tags = [tag] + + fm.remove_tag_from_files([fm._f1], tag) + assert tag not in fm._f1.tags + + fm.undo() + assert tag in fm._f1.tags + + fm.redo() + assert tag not in fm._f1.tags + + def test_assign_noop_not_pushed(self, fm): + """Assign to file that already has tag should not push undo entry.""" + tag = fm.tagmanager.add_tag("Žánr", "Drama") + fm._f1.tags = [tag] + fm.assign_tag_to_files([fm._f1], tag) + assert not fm.can_undo() + + def test_redo_cleared_on_new_op(self, fm): + tag = fm.tagmanager.add_tag("Žánr", "Drama") + fm.assign_tag_to_files([fm._f1], tag) + fm.undo() + assert fm.can_redo() + + tag2 = fm.tagmanager.add_tag("Žánr", "Thriller") + fm.assign_tag_to_files([fm._f1], tag2) + assert not fm.can_redo() + + def test_undo_empty_returns_none(self, fm): + assert fm.undo() is None + + def test_redo_empty_returns_none(self, fm): + assert fm.redo() is None + + +class TestUndoRedoRename: + def test_rename_tag_undo_redo(self, fm): + fm.tagmanager.add_tag("Žánr", "Drama") + tag_old = Tag("Žánr", "Drama") + fm._f1.tags = [tag_old] + fm._f2.tags = [tag_old] + + count = fm.rename_tag_in_files("Žánr", "Drama", "Thriller") + assert count == 2 + assert Tag("Žánr", "Thriller") in fm._f1.tags + assert fm.tagmanager.tag_exists("Žánr", "Thriller") + assert not fm.tagmanager.tag_exists("Žánr", "Drama") + + fm.undo() + assert Tag("Žánr", "Drama") in fm._f1.tags + assert fm.tagmanager.tag_exists("Žánr", "Drama") + assert not fm.tagmanager.tag_exists("Žánr", "Thriller") + + fm.redo() + assert Tag("Žánr", "Thriller") in fm._f1.tags + assert fm.tagmanager.tag_exists("Žánr", "Thriller") + + def test_rename_category_undo_redo(self, fm): + fm.tagmanager.add_tag("StaráKat", "X") + tag = Tag("StaráKat", "X") + fm._f1.tags = [tag] + + fm.rename_category_in_files("StaráKat", "NováKat") + assert Tag("NováKat", "X") in fm._f1.tags + assert fm.tagmanager.category_exists("NováKat") + assert not fm.tagmanager.category_exists("StaráKat") + + fm.undo() + assert Tag("StaráKat", "X") in fm._f1.tags + assert fm.tagmanager.category_exists("StaráKat") + assert not fm.tagmanager.category_exists("NováKat") + + fm.redo() + assert Tag("NováKat", "X") in fm._f1.tags + + +class TestUndoRedoMerge: + def test_merge_tag_undo_redo(self, fm): + fm.tagmanager.add_tag("Žánr", "Drama") + fm.tagmanager.add_tag("Žánr", "Thriller") + fm._f1.tags = [Tag("Žánr", "Drama")] + fm._f2.tags = [Tag("Žánr", "Drama"), Tag("Žánr", "Thriller")] + + fm.merge_tag_in_files("Žánr", "Drama", "Thriller") + assert Tag("Žánr", "Thriller") in fm._f1.tags + assert Tag("Žánr", "Drama") not in fm._f1.tags + assert not fm.tagmanager.tag_exists("Žánr", "Drama") + + fm.undo() + assert Tag("Žánr", "Drama") in fm._f1.tags + assert Tag("Žánr", "Drama") not in fm._f2.tags or Tag("Žánr", "Thriller") in fm._f2.tags + assert fm.tagmanager.tag_exists("Žánr", "Drama") + + fm.redo() + assert Tag("Žánr", "Thriller") in fm._f1.tags + assert not fm.tagmanager.tag_exists("Žánr", "Drama") + + def test_merge_category_undo_redo(self, fm): + fm.tagmanager.add_tag("SrcKat", "A") + fm.tagmanager.add_tag("TgtKat", "B") + fm._f1.tags = [Tag("SrcKat", "A")] + fm._f2.tags = [Tag("TgtKat", "B")] + + fm.merge_category_in_files("SrcKat", "TgtKat") + assert Tag("TgtKat", "A") in fm._f1.tags + assert not fm.tagmanager.category_exists("SrcKat") + + fm.undo() + assert Tag("SrcKat", "A") in fm._f1.tags + assert fm.tagmanager.category_exists("SrcKat") + assert not fm.tagmanager.tag_exists("TgtKat", "A") + + fm.redo() + assert Tag("TgtKat", "A") in fm._f1.tags + assert not fm.tagmanager.category_exists("SrcKat") + + +class TestUndoLimit: + def test_max_undo_entries(self, fm): + from src.core.file_manager import _MAX_UNDO + tag = fm.tagmanager.add_tag("T", "x") + for _ in range(_MAX_UNDO + 5): + fm._f1.tags = [] + fm.assign_tag_to_files([fm._f1], tag) + assert len(fm._undo_stack) == _MAX_UNDO