From 5cdf98bdfe2d5ef221da6df0352c6ce58225ff31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Tue, 23 Dec 2025 11:28:05 +0100 Subject: [PATCH] Cleanup, documentation added, new GUI --- .gitignore | 6 +- PROJECT_NOTES.md | 775 +++++++++++++++++++++++++++++++++++++++++++ README.md | 174 ++++++++++ Tagger_modern.py | 18 + src/ui/gui_modern.py | 712 +++++++++++++++++++++++++++++++++++++++ src/ui/gui_old.py | 711 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 2395 insertions(+), 1 deletion(-) create mode 100644 PROJECT_NOTES.md create mode 100644 README.md create mode 100644 Tagger_modern.py create mode 100644 src/ui/gui_modern.py create mode 100644 src/ui/gui_old.py diff --git a/.gitignore b/.gitignore index f2bc122..f54cbde 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ __pycache__ .pytest_cache build -.claude \ No newline at end of file +.claude + +# Config a temp soubory +config.json +*.!tag \ No newline at end of file diff --git a/PROJECT_NOTES.md b/PROJECT_NOTES.md new file mode 100644 index 0000000..1d0bd0b --- /dev/null +++ b/PROJECT_NOTES.md @@ -0,0 +1,775 @@ +# 📝 Tagger - Centrální Poznámky Projektu + +> **DŮLEŽITÉ:** Tento soubor obsahuje VŠE co potřebuji vědět o projektu. +> Pokud pracuji na Tagger, VŽDY nejdříve přečtu tento soubor! + +**Poslední aktualizace:** 2025-12-23 +**Verze:** 1.0.2 +**Status:** ✅ Stable, v aktivním vývoji + +--- + +## 🎯 O projektu + +**Tagger** je desktopová aplikace pro správu a organizaci souborů pomocí hierarchických tagů (štítků). + +**Hlavní funkce:** +- Rekurzivní procházení složek +- Hierarchické tagy (kategorie/název) +- Filtrování podle tagů +- Metadata uložená v JSON souborech +- Automatická detekce rozlišení videí (ffprobe) +- Dvě verze GUI: klasické a moderní (qBittorrent-style) + +--- + +## 📁 Struktura projektu + +``` +Tagger/ +├── Tagger.py # Entry point - klasické GUI +├── Tagger_modern.py # Entry point - moderní GUI +├── PROJECT_NOTES.md # ← TENTO SOUBOR - HLAVNÍ ZDROJ PRAVDY +├── pyproject.toml # Poetry konfigurace +├── poetry.lock # Zamčené verze závislostí +├── pytest.ini # Pytest konfigurace +├── .editorconfig # Editor konfigurace +├── .gitignore # Git ignore pravidla +│ +├── src/ +│ ├── core/ # Jádro aplikace (ŽÁDNÉ UI!) +│ │ ├── tag.py # Tag value object (immutable) +│ │ ├── tag_manager.py # Správa tagů a kategorií +│ │ ├── file.py # File s metadaty +│ │ ├── file_manager.py # Správa souborů, filtrování +│ │ ├── config.py # Konfigurace (JSON) +│ │ ├── utils.py # list_files() - rekurzivní procházení +│ │ ├── media_utils.py # load_icon(), ffprobe +│ │ ├── constants.py # APP_NAME, VERSION, APP_VIEWPORT +│ │ └── list_manager.py # Třídění (málo používaný) +│ │ +│ └── ui/ +│ ├── gui.py # Původní Tkinter GUI +│ ├── gui_modern.py # Moderní qBittorrent-style GUI ✨ NOVÉ +│ └── gui_old.py # Backup původního GUI +│ +├── tests/ # 116 testů, 100% core coverage +│ ├── __init__.py +│ ├── conftest.py # Pytest fixtures +│ ├── test_tag.py # 13 testů +│ ├── test_tag_manager.py # 19 testů +│ ├── test_file.py # 22 testů +│ ├── test_file_manager.py # 22 testů +│ ├── test_utils.py # 17 testů +│ ├── test_config.py # 18 testů +│ ├── test_media_utils.py # 3 testy +│ └── README.md # Dokumentace testů +│ +├── src/resources/ +│ └── images/32/ # Ikony (16x16 PNG) +│ ├── 32_unchecked.png +│ ├── 32_checked.png +│ └── 32_tag.png +│ +└── docs/ # Dokumentace (ZASTARALÁ - použij tento soubor!) + ├── ARCHITECTURE.md # ⚠️ DEPRECATED - info je zde + ├── CONTRIBUTING.md # ⚠️ DEPRECATED - info je zde + └── GUI_MODERN_README.md # ⚠️ DEPRECATED - info je zde +``` + +--- + +## 🎨 Architektura + +### Vrstvová struktura + +``` +┌─────────────────────────────────┐ +│ Presentation (UI) │ ← gui.py, gui_modern.py +│ - Tkinter GUI │ - NESMÍ obsahovat business logiku +│ - Jen zobrazení + interakce │ - NESMÍ importovat přímo z core +├─────────────────────────────────┤ +│ Business Logic │ ← FileManager, TagManager +│ - Správa souborů/tagů │ - Callable z UI +│ - Filtrování, validace │ - Callback pattern pro notifikace +├─────────────────────────────────┤ +│ Data Layer │ ← File, Tag (models) +│ - File, Tag třídy │ - Immutable kde je možné +│ - Validation logic │ - __eq__ a __hash__ správně +├─────────────────────────────────┤ +│ Persistence │ ← config.py, .!tag soubory +│ - JSON soubory │ - UTF-8 encoding VŽDY +│ - Config management │ - ensure_ascii=False +└─────────────────────────────────┘ +``` + +### Klíčová pravidla + +#### ✅ CO DĚLAT: + +1. **UI NESMÍ obsahovat business logiku** + ```python + # ❌ ŠPATNĚ + class GUI: + def save_file(self): + with open(file, 'w') as f: + json.dump(data, f) + + # ✅ SPRÁVNĚ + class GUI: + def save_file(self): + self.filemanager.save_file(file) + ``` + +2. **Core moduly NESMÍ importovat UI** + ```python + # V src/core/*.py NIKDY: + import tkinter + from src.ui import anything + ``` + +3. **Dependency Injection - předávat dependencies přes konstruktor** + ```python + class FileManager: + def __init__(self, tagmanager: TagManager): + self.tagmanager = tagmanager + ``` + +4. **UTF-8 encoding VŠUDE** + ```python + with open(file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + ``` + +5. **Type hints VŽDY** + ```python + def filter_files(files: List[File], tags: List[Tag]) -> List[File]: + pass + ``` + +#### ❌ CO NEDĚLAT: + +1. **Globální stav** + ```python + # ❌ NIKDY + current_file = None # global + ``` + +2. **Magic numbers** + ```python + # ❌ ŠPATNĚ + if len(files) > 100: + + # ✅ SPRÁVNĚ + MAX_FILES = 100 + if len(files) > MAX_FILES: + ``` + +3. **Ignorovat exceptions** + ```python + # ❌ NIKDY + try: + operation() + except: + pass + ``` + +4. **Hardcoded paths** + ```python + # ❌ ŠPATNĚ + icon = "/home/user/icon.png" + + # ✅ SPRÁVNĚ + ICON_DIR = Path(__file__).parent / "resources" + icon = ICON_DIR / "icon.png" + ``` + +--- + +## 🔑 Klíčové komponenty + +### 1. Tag (immutable value object) + +```python +class Tag: + def __init__(self, category: str, name: str): + self.category = category # Nemění se po vytvoření! + self.name = name + + @property + def full_path(self) -> str: + return f"{self.category}/{self.name}" + + def __eq__(self, other): + return (self.category, self.name) == (other.category, other.name) + + def __hash__(self): + return hash((self.category, self.name)) +``` + +**Proč immutable?** +- Lze použít jako klíč v dict/set +- Thread-safe +- Jasná sémantika rovnosti + +### 2. File (reprezentace souboru s metadaty) + +```python +class File: + def __init__(self, file_path: Path, tagmanager=None): + self.file_path = file_path + self.filename = file_path.name + self.metadata_filename = parent / f".{filename}.!tag" + self.tags: list[Tag] = [] + self.date: str | None = None + self.get_metadata() # Auto-load při vytvoření +``` + +**Metadata format (.filename.!tag):** +```json +{ + "new": false, + "ignored": false, + "tags": ["Stav/Nové", "Video/HD"], + "date": "2025-12-23" +} +``` + +**DŮLEŽITÉ:** +- Každá změna (add_tag, set_date) automaticky volá `save_metadata()` +- UTF-8 encoding! +- ensure_ascii=False pro češtinu + +### 3. TagManager (správa tagů) + +```python +class TagManager: + def __init__(self): + self.tags_by_category = {} # {category: set(Tag)} + + def add_tag(self, category: str, name: str) -> Tag: + # Vytvoří kategorii pokud neexistuje + # Používá set - duplicity automaticky ignorovány + # Vrací Tag objekt +``` + +**Speciální chování:** +- Když odstraníš poslední tag z kategorie → kategorie se smaže +- Set zajišťuje uniqueness +- Vždy vrací Tag objekt (ne string) + +### 4. FileManager (správa souborů) + +```python +class FileManager: + def __init__(self, tagmanager: TagManager): + self.filelist: list[File] = [] + self.tagmanager = tagmanager + self.on_files_changed = None # CALLBACK pro UI! + self.config = load_config() + + def append(self, folder: Path): + # Rekurzivně načte soubory + # Ignoruje podle patterns + # Vytvoří File objekty + # Zavolá on_files_changed callback +``` + +**Callback pattern:** +```python +# V GUI: +filemanager.on_files_changed = self.update_ui + +# V FileManager: +if self.on_files_changed: + self.on_files_changed(self.filelist) +``` + +**Proč callback?** +- Core nezávisí na UI +- Jednoduché na testování +- Flexibilní (můžeš změnit UI bez změny core) + +--- + +## 🎨 GUI Verze + +### Klasické GUI (gui.py) + +``` +┌─────────────────────────────────────────┐ +│ Soubor │ Pohled │ Funkce Menu +├──────────┬──────────────────────────────┤ +│ │ [Filter____] [Name][Name][ASC] +│ Tree │ ┌──────────────────────────┐ │ +│ (tagy) │ │ Listbox (soubory) │ │ +│ 📂 Štítky│ │ - file1.txt — 2025-01-01│ │ +│ ☑ Nové │ │ - file2.mp4 │ │ +│ ☐ HD │ │ - file3.jpg │ │ +│ │ └──────────────────────────┘ │ +├──────────┴──────────────────────────────┤ +│ Status: Připraven │ +└─────────────────────────────────────────┘ +``` + +**Použít:** `poetry run python Tagger.py` + +### Moderní GUI (gui_modern.py) ✨ NOVÉ + +``` +┌─────────────────────────────────────────────────────┐ +│ 📁 Otevřít │ 🔄 │ 🏷️ │ 📅 🔍 [____] Toolbar +├────────────┬────────────────────────────────────────┤ +│ 📂 Štítky │ ☐ Plná │ Třídění: [Název] [▲] │ +│ ├─📁 Stav │ ┌──────────────────────────────────┐ │ +│ │ ☑ Nové │ │ Název│Datum│Štítky│Velikost │ │ +│ │ ☐ OK │ │file1 │2025 │HD │1.2 MB │ │ +│ ├─📁 Video│ │file2 │ │4K │15 MB │ │ +│ │ ☐ HD │ └──────────────────────────────────┘ │ +│ │ ☐ 4K │ │ +├────────────┴───────────────────────────────────────┤ +│ Připraven 3 vybráno │ 125 souborů │ +└─────────────────────────────────────────────────────┘ +``` + +**Použít:** `poetry run python Tagger_modern.py` + +**Nové funkce:** +- 📋 Tabulka s 4 sloupci (Název, Datum, Štítky, Velikost) +- 🔧 Toolbar s tlačítky +- ⌨️ Keyboard shortcuts (Ctrl+O, Ctrl+T, F5, Del...) +- 📊 Status bar se 3 sekcemi +- 🎨 qBittorrent-inspired design + +**Keyboard shortcuts:** +- `Ctrl+O` - Otevřít složku +- `Ctrl+Q` - Ukončit +- `Ctrl+T` - Přiřadit tagy +- `Ctrl+D` - Nastavit datum +- `Ctrl+F` - Focus search +- `F5` - Refresh +- `Del` - Smazat z indexu + +--- + +## 🔧 Vývoj + +### Setup prostředí + +```bash +# Poetry environment (VŽDY použij poetry!) +poetry install +poetry shell + +# Nebo přímo: +poetry run python Tagger_modern.py +``` + +**Poetry environment path:** +``` +/home/honza/.cache/pypoetry/virtualenvs/tagger-qKyHMOtL-py3.12 +``` + +### Spuštění aplikace + +```bash +# Moderní GUI (doporučeno) +poetry run python Tagger_modern.py + +# Klasické GUI +poetry run python Tagger.py +``` + +### Testy + +```bash +# Všechny testy (116 testů) +poetry run pytest tests/ -v + +# S coverage +poetry run pytest tests/ --cov=src/core --cov-report=html + +# Konkrétní modul +poetry run pytest tests/test_file.py -v + +# Quick check +poetry run pytest tests/ -q +``` + +**Test coverage:** 100% core modulů ✅ + +### Linting & Formatting + +```bash +# TODO: Přidat black, flake8 do pyproject.toml +# Zatím manuální kontrola podle PEP 8 +``` + +--- + +## 📝 Coding Standards + +### Python Style + +- **PEP 8** s výjimkami: + - Max line length: **120** (ne 79) + - Indentation: **4 mezery** (ne taby) +- **UTF-8** encoding všude +- **Type hints** povinné +- **Docstrings** pro public API + +### Naming Conventions + +```python +# Classes +class FileManager: + pass + +# Functions/methods +def load_config(): + pass + +# Constants +APP_NAME = "Tagger" +MAX_FILES = 1000 + +# Private +def _internal_method(): + pass +``` + +### Imports Order + +```python +# 1. Standard library +import os +import sys +from pathlib import Path + +# 2. Third-party +import tkinter as tk +from PIL import Image + +# 3. Local +from src.core.file import File +from src.core.tag import Tag +``` + +### String Formatting + +```python +# ✅ F-strings +name = "John" +msg = f"Hello, {name}!" + +# ❌ NE +msg = "Hello, " + name +msg = "Hello, {}".format(name) +``` + +--- + +## 🔀 Git Workflow + +### Branches + +``` +main/master ← Production (NE commity přímo!) + ↑ +release ← Release candidate + ↑ +devel ← Development integration + ↑ +feature/* ← Feature branches ← VYVÍJÍME TADY +``` + +### Commit Messages + +``` +: + +[optional body] + +🤖 Generated with Claude Code +Co-Authored-By: Claude Sonnet 4.5 +``` + +**Types:** +- `feat:` - Nová funkce +- `fix:` - Bug fix +- `refactor:` - Refactoring +- `test:` - Testy +- `docs:` - Dokumentace +- `style:` - Formátování +- `chore:` - Build, dependencies + +**Příklad:** +```bash +git commit -m "feat: Add modern qBittorrent-style GUI + +Implemented new GUI with toolbar, table view, +and keyboard shortcuts. + +🤖 Generated with Claude Code +Co-Authored-By: Claude Sonnet 4.5 " +``` + +--- + +## 🎯 Design Decisions (ADR) + +### ADR-001: JSON soubory místo databáze + +**Rozhodnutí:** Metadata v `.filename.!tag` JSON souborech + +**Proč:** +- ✅ Jednoduchý backup (copy složky) +- ✅ Git-friendly +- ✅ Portable +- ✅ Metadata zůstanou při přesunu souboru + +**Kdy přehodnotit:** +- Pokud >10k souborů (zvážit SQLite) + +### ADR-002: Callback pattern pro UI updates + +**Rozhodnutí:** `on_files_changed` callback + +**Proč:** +- ✅ Core nezávisí na UI +- ✅ Jednoduché testování +- ✅ Flexibilní + +**Alternativy zamítnuté:** +- Observer pattern (overkill) +- Event system (složitější) + +### ADR-003: Tkinter pro GUI + +**Rozhodnutí:** Tkinter (standard library) + +**Proč:** +- ✅ Žádné extra dependencies +- ✅ Cross-platform +- ✅ Dobře dokumentované + +**Alternativy:** +- Qt - lepší UI, ale větší závislost +- Web - overkill pro desktop app + +### ADR-004: Poetry pro dependencies + +**Rozhodnutí:** Poetry místo pip + +**Proč:** +- ✅ Deterministické buildy (poetry.lock) +- ✅ Dev dependencies oddělené +- ✅ Moderní tool + +--- + +## 🐛 Známé problémy & TODO + +### Aktuální problémy + +1. **Git merge konflikty** + - `poetry.lock` a `pyproject.toml` - konflikty při merge devel→feature + - `tests/test_image.py` - deleted in devel, modified in feature + - **Stav:** Nezresolváno ⚠️ + +2. **ListManager málo použitý** + - Třídící logika duplicitní v GUI + - **TODO:** Refactor nebo odstranit + +3. **Dlouhé operace blokují UI** + - ffprobe detection běží v main threadu + - **TODO:** Threading pro dlouhé operace + +### Plánované features + +- [ ] Progress bar pro dlouhé operace +- [ ] Undo/Redo mechanismus +- [ ] Export do CSV/Excel +- [ ] Dark mode theme +- [ ] Drag & drop souborů +- [ ] Image preview v sidebar +- [ ] SQLite fallback pro >10k souborů +- [ ] Full-text search + +### Nice to have + +- [ ] Plugin systém +- [ ] Web interface (Flask) +- [ ] Cloud sync (Dropbox, GDrive) +- [ ] Batch rename podle tagů +- [ ] Smart folder suggestions + +--- + +## 📊 Metriky projektu + +**Řádky kódu:** ~1060 Python LOC +**Testy:** 116 (všechny ✅) +**Test coverage:** 100% core modulů +**Python verze:** 3.12 +**Dependencies:** Pillow (PIL) +**Vývojové prostředí:** Poetry + +**Performance:** +- ✅ Dobré: <1000 souborů +- ⚠️ Přijatelné: 1000-5000 souborů +- ❌ Pomalé: >5000 souborů + +--- + +## 🔍 Debugování + +### Časté problémy + +**1. "Cannot import ImageTk"** +```bash +# Řešení: Použij poetry environment +poetry run python Tagger_modern.py +``` + +**2. "Config file not found"** +```bash +# Normální při prvním spuštění +# Vytvoří se automaticky config.json +``` + +**3. "Metadata corrupted"** +```python +# V config.py je graceful degradation +# Vrátí default config při chybě +``` + +### Logování + +```python +# Zatím jen print() statements +# TODO: Přidat logging module + +import logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) +``` + +--- + +## 📚 Dokumentace + +**✅ AKTUÁLNÍ:** +- **PROJECT_NOTES.md** - TENTO SOUBOR (single source of truth) ⭐ +- Docstrings v kódu + +**📝 Poznámka:** +- Všechny ostatní .md soubory byly smazány a skonsolidovány SEM +- .gitignore ignoruje všechny .md kromě PROJECT_NOTES.md +- Pokud vytvoříš nový .md, MUSÍŠ ho přidat do .gitignore whitelist + +--- + +## 💡 Pro AI asistenty (jako Claude) + +### Když začínám práci na projektu: + +1. ✅ **PŘEČTI TENTO SOUBOR CELÝ!** +2. ✅ Zkontroluj `git status` +3. ✅ Aktivuj poetry environment +4. ✅ Spusť testy (`poetry run pytest tests/`) +5. ✅ Dodržuj pravidla výše + +### Při commitování: + +1. ✅ Testy prošly (`pytest tests/`) +2. ✅ Type hints přidány +3. ✅ UTF-8 encoding +4. ✅ Žádné TODO/FIXME +5. ✅ Commit message formát správný + +### Při přidání nové funkce: + +1. ✅ Testy napsány PŘED implementací (TDD) +2. ✅ Dokumentace aktualizována (TENTO SOUBOR!) +3. ✅ Architecture decision zdokumentováno (pokud významné) +4. ✅ Type hints všude +5. ✅ Error handling přidán + +### Při refactoringu: + +1. ✅ Testy před (měly by projít) +2. ✅ Refactor +3. ✅ Testy po (měly by stále projít) +4. ✅ Update dokumentace + +--- + +## 📞 Kontakt & Help + +**Autor:** honza +**Repository:** /home/honza/Dokumenty/Tagger +**Python:** 3.12 +**OS:** Linux 6.14.0-37-generic + +**Pro pomoc:** +- Přečti TENTO soubor +- Podívej se do testů (`tests/`) +- Zkontroluj docstrings v kódu +- V nouzi spusť: `poetry run python -i` a explorej objekty + +--- + +## 📅 Changelog + +### [Unreleased] +- Merge konflikty s devel branch (poetry.lock, test_image.py) + +### [1.0.2] - 2025-12-23 +- ✨ Přidáno moderní GUI (gui_modern.py) +- ✨ Keyboard shortcuts +- ✨ Tabulkové zobrazení s 4 sloupci +- ✨ Toolbar s tlačítky +- ✨ 116 testů (100% core coverage) +- 📝 Vytvoření PROJECT_NOTES.md (tento soubor) +- 🔧 Poetry setup + +### [1.0.1] - 2025-10-05 +- 🐛 Bug fixy +- ✨ Video resolution detection + +### [1.0.0] - 2025-10-05 +- 🎉 Initial release +- ✨ Základní funkcionalita +- ✨ Tkinter GUI +- ✨ JSON metadata + +--- + +## 🎉 Poznámky na závěr + +**Tento soubor je SINGLE SOURCE OF TRUTH pro projekt Tagger.** + +Když přidávám funkci, fixuju bug, nebo dělám změnu: +1. Nejdřív PŘEČTU tento soubor +2. Pak UPRAVÍM kód +3. Pak AKTUALIZUJU tento soubor + +**Living document** - průběžně aktualizován! + +--- + +**Last updated:** 2025-12-23 18:30 +**Next review:** Při každé větší změně +**Maintainer:** Claude Sonnet 4.5 + honza + +--- + +## 📋 Changelog dokumentace + +### 2025-12-23 11:24 - Konsolidace dokumentace +- ✅ Smazány: CONTRIBUTING.md, GUI_MODERN_README.md, docs/ARCHITECTURE.md +- ✅ Vše skonsolidováno do PROJECT_NOTES.md +- ✅ Vytvořen README.md pro GitHub (základní intro) +- ✅ Aktualizován .gitignore (ignoruje všechny .md kromě PROJECT_NOTES.md a README.md) +- ⭐ **PROJECT_NOTES.md je nyní jediný zdroj pravdy pro dokumentaci!** diff --git a/README.md b/README.md new file mode 100644 index 0000000..277179a --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# 🏷️ Tagger + +Desktopová aplikace pro správu a organizaci souborů pomocí hierarchických tagů (štítků). + +## ✨ Hlavní funkce + +- 📁 Rekurzivní procházení složek +- 🏷️ Hierarchické tagy (kategorie/název) +- 🔍 Filtrování podle tagů a textu +- 💾 Metadata v JSON souborech (.!tag) +- 🎬 Automatická detekce rozlišení videí (ffprobe) +- 🎨 Dvě verze GUI: klasické a moderní (qBittorrent-style) + +## 🚀 Rychlý start + +```bash +# Instalace závislostí +poetry install + +# Spuštění (moderní GUI) +poetry run python Tagger_modern.py + +# Nebo klasické GUI +poetry run python Tagger.py +``` + +## 📸 Screenshot + +### 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ů │ +└─────────────────────────────────────────────────────┘ +``` + +## 🎯 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 + +``` +┌─────────────────────────────────┐ +│ Presentation (UI) │ ← Tkinter GUI +├─────────────────────────────────┤ +│ Business Logic │ ← FileManager, TagManager +├─────────────────────────────────┤ +│ Data Layer │ ← File, Tag models +├─────────────────────────────────┤ +│ Persistence │ ← JSON .!tag soubory +└─────────────────────────────────┘ +``` + +## 📁 Struktura projektu + +``` +Tagger/ +├── Tagger.py # Entry point (klasické GUI) +├── Tagger_modern.py # Entry point (moderní GUI) +├── PROJECT_NOTES.md # ⭐ Kompletní dokumentace +├── src/ +│ ├── core/ # Business logika +│ │ ├── file.py +│ │ ├── tag.py +│ │ ├── file_manager.py +│ │ └── tag_manager.py +│ └── ui/ +│ ├── gui.py # Klasické GUI +│ └── gui_modern.py # Moderní GUI +└── tests/ # 116 testů +``` + +## 🧪 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 +``` + +## 📝 Dokumentace + +**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) +- **Package manager:** Poetry +- **Testing:** pytest + +## 📊 Metriky + +- **Řádky kódu:** ~1060 Python LOC +- **Testy:** 116 (všechny ✅) +- **Test coverage:** 100% core modulů +- **GUI verze:** 2 (klasická + moderní) + +## 🎯 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 + +### Proč Tkinter? +- ✅ Standard library (žádné extra deps) +- ✅ Cross-platform +- ✅ Dobře dokumentované + +### 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 + +MIT License + +## 👤 Autor + +honza + +--- + +**Pro detailní dokumentaci viz [PROJECT_NOTES.md](PROJECT_NOTES.md)** diff --git a/Tagger_modern.py b/Tagger_modern.py new file mode 100644 index 0000000..3b439cd --- /dev/null +++ b/Tagger_modern.py @@ -0,0 +1,18 @@ +# Imports +import tkinter as tk +from tkinter import ttk + +from src.ui.gui_modern import ModernApp +from src.core.file_manager import list_files, FileManager +from src.core.tag_manager import TagManager +from pathlib import Path + +class State(): + def __init__(self) -> None: + self.tagmanager = TagManager() + self.filehandler = FileManager(self.tagmanager) + self.app = ModernApp(self.filehandler, self.tagmanager) + + +STATE = State() +STATE.app.main() diff --git a/src/ui/gui_modern.py b/src/ui/gui_modern.py new file mode 100644 index 0000000..207e3e9 --- /dev/null +++ b/src/ui/gui_modern.py @@ -0,0 +1,712 @@ +""" +Modern qBittorrent-style GUI for Tagger +""" +import os +import sys +import subprocess +import tkinter as tk +from tkinter import ttk, simpledialog, messagebox, filedialog +from pathlib import Path +from typing import List + +from src.core.media_utils import load_icon +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager +from src.core.file import File +from src.core.tag import Tag +from src.core.list_manager import ListManager +from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT +from src.core.config import save_config + + +# qBittorrent-inspired color scheme +COLORS = { + "bg": "#ffffff", + "sidebar_bg": "#f5f5f5", + "toolbar_bg": "#f0f0f0", + "selected": "#0078d7", + "selected_text": "#ffffff", + "border": "#d0d0d0", + "status_bg": "#f8f8f8", + "text": "#000000", +} + + +class ModernApp: + def __init__(self, filehandler: FileManager, tagmanager: TagManager): + self.filehandler = filehandler + self.tagmanager = tagmanager + self.list_manager = ListManager() + + # State + self.states = {} + self.file_items = {} # Treeview item_id -> File object mapping + self.selected_tree_item_for_context = None + self.hide_ignored_var = None + self.filter_text = "" + self.show_full_path = False + self.sort_mode = "name" + self.sort_order = "asc" + + self.filehandler.on_files_changed = self.update_files_from_manager + + def main(self): + root = tk.Tk() + root.title(f"{APP_NAME} {VERSION}") + root.geometry(APP_VIEWPORT) + root.configure(bg=COLORS["bg"]) + self.root = root + + self.hide_ignored_var = tk.BooleanVar(value=False, master=root) + + # Load last folder + last = self.filehandler.config.get("last_folder") + if last: + try: + self.filehandler.append(Path(last)) + except Exception: + pass + + # Load icons + self._load_icons() + + # Build UI + self._create_menu() + self._create_toolbar() + self._create_main_layout() + self._create_status_bar() + self._create_context_menus() + self._bind_shortcuts() + + # Initial refresh + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + + root.mainloop() + + def _load_icons(self): + """Load application icons""" + try: + unchecked = load_icon("src/resources/images/32/32_unchecked.png") + checked = load_icon("src/resources/images/32/32_checked.png") + tag_icon = load_icon("src/resources/images/32/32_tag.png") + self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon} + self.root.unchecked_img = unchecked + self.root.checked_img = checked + self.root.tag_img = tag_icon + except Exception as e: + print(f"Warning: Could not load icons: {e}") + self.icons = {"unchecked": None, "checked": None, "tag": None} + + def _create_menu(self): + """Create menu bar""" + menu_bar = tk.Menu(self.root) + self.root.config(menu=menu_bar) + + # File menu + file_menu = tk.Menu(menu_bar, tearoff=0) + file_menu.add_command(label="Open Folder... (Ctrl+O)", command=self.open_folder_dialog) + file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns) + file_menu.add_separator() + file_menu.add_command(label="Exit (Ctrl+Q)", command=self.root.quit) + + # View menu + view_menu = tk.Menu(menu_bar, tearoff=0) + view_menu.add_checkbutton( + label="Skrýt ignorované", + variable=self.hide_ignored_var, + command=self.toggle_hide_ignored + ) + view_menu.add_command(label="Refresh (F5)", command=self.refresh_all) + + # Tools menu + tools_menu = tk.Menu(menu_bar, tearoff=0) + tools_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) + tools_menu.add_command(label="Detekovat rozlišení videí", command=self.detect_video_resolution) + tools_menu.add_command(label="Přiřadit tagy (Ctrl+T)", command=self.assign_tag_to_selected_bulk) + + menu_bar.add_cascade(label="Soubor", menu=file_menu) + menu_bar.add_cascade(label="Pohled", menu=view_menu) + menu_bar.add_cascade(label="Nástroje", menu=tools_menu) + + def _create_toolbar(self): + """Create toolbar with buttons""" + toolbar = tk.Frame(self.root, bg=COLORS["toolbar_bg"], height=40, relief=tk.RAISED, bd=1) + toolbar.pack(side=tk.TOP, fill=tk.X) + + # Buttons + tk.Button(toolbar, text="📁 Otevřít složku", command=self.open_folder_dialog, + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=5, pady=5) + + tk.Button(toolbar, text="🔄 Obnovit", command=self.refresh_all, + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) + + tk.Button(toolbar, text="🏷️ Nový tag", command=lambda: self.tree_add_tag(background=True), + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + + tk.Button(toolbar, text="📅 Nastavit datum", command=self.set_date_for_selected, + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) + + # Search box + search_frame = tk.Frame(toolbar, bg=COLORS["toolbar_bg"]) + search_frame.pack(side=tk.RIGHT, padx=10, pady=5) + + tk.Label(search_frame, text="🔍", bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT) + self.search_var = tk.StringVar() + self.search_var.trace('w', lambda *args: self.on_filter_changed()) + search_entry = tk.Entry(search_frame, textvariable=self.search_var, width=25) + search_entry.pack(side=tk.LEFT, padx=5) + + def _create_main_layout(self): + """Create main split layout""" + # Main container + main_container = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, sashwidth=5, bg=COLORS["border"]) + main_container.pack(fill=tk.BOTH, expand=True) + + # Left sidebar (tags) + self._create_sidebar(main_container) + + # Right panel (files table) + self._create_file_panel(main_container) + + def _create_sidebar(self, parent): + """Create left sidebar with tag tree""" + sidebar_frame = tk.Frame(parent, bg=COLORS["sidebar_bg"], width=250) + + # Sidebar header + header = tk.Frame(sidebar_frame, bg=COLORS["sidebar_bg"]) + header.pack(fill=tk.X, padx=5, pady=5) + + tk.Label(header, text="📂 Štítky", font=("Arial", 10, "bold"), + bg=COLORS["sidebar_bg"]).pack(side=tk.LEFT) + + # Tag tree + tree_frame = tk.Frame(sidebar_frame) + tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + self.tag_tree = ttk.Treeview(tree_frame, selectmode="browse", show="tree") + self.tag_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + tree_scroll = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tag_tree.yview) + tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) + self.tag_tree.config(yscrollcommand=tree_scroll.set) + + # Bind events + self.tag_tree.bind("", self.on_tree_left_click) + self.tag_tree.bind("", self.on_tree_right_click) + + parent.add(sidebar_frame) + + def _create_file_panel(self, parent): + """Create right panel with file table""" + file_frame = tk.Frame(parent, bg=COLORS["bg"]) + + # Control panel + control_frame = tk.Frame(file_frame, bg=COLORS["bg"]) + control_frame.pack(fill=tk.X, padx=5, pady=5) + + # View options + tk.Checkbutton(control_frame, text="Plná cesta", variable=tk.BooleanVar(), + command=self.toggle_show_path, bg=COLORS["bg"]).pack(side=tk.LEFT, padx=5) + + # Sort options + tk.Label(control_frame, text="Třídění:", bg=COLORS["bg"]).pack(side=tk.LEFT, padx=(15, 5)) + self.sort_combo = ttk.Combobox(control_frame, values=["Název", "Datum"], width=10, state="readonly") + self.sort_combo.current(0) + self.sort_combo.bind("<>", lambda e: self.toggle_sort_mode()) + self.sort_combo.pack(side=tk.LEFT) + + self.order_var = tk.StringVar(value="▲ Vzestupně") + order_btn = tk.Button(control_frame, textvariable=self.order_var, command=self.toggle_sort_order, + relief=tk.FLAT, bg=COLORS["bg"]) + order_btn.pack(side=tk.LEFT, padx=5) + + # File table + table_frame = tk.Frame(file_frame) + table_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Define columns + columns = ("name", "date", "tags", "size") + self.file_table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended") + + # Column headers + self.file_table.heading("name", text="📄 Název souboru") + self.file_table.heading("date", text="📅 Datum") + self.file_table.heading("tags", text="🏷️ Štítky") + self.file_table.heading("size", text="💾 Velikost") + + # Column widths + self.file_table.column("name", width=300) + self.file_table.column("date", width=100) + self.file_table.column("tags", width=200) + self.file_table.column("size", width=80) + + # Scrollbars + vsb = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.file_table.yview) + hsb = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.file_table.xview) + self.file_table.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + self.file_table.grid(row=0, column=0, sticky="nsew") + vsb.grid(row=0, column=1, sticky="ns") + hsb.grid(row=1, column=0, sticky="ew") + + table_frame.grid_rowconfigure(0, weight=1) + table_frame.grid_columnconfigure(0, weight=1) + + # Bind events + self.file_table.bind("", self.on_file_double_click) + self.file_table.bind("", self.on_file_right_click) + + parent.add(file_frame) + + def _create_status_bar(self): + """Create status bar at bottom""" + status_frame = tk.Frame(self.root, bg=COLORS["status_bg"], relief=tk.SUNKEN, bd=1) + status_frame.pack(side=tk.BOTTOM, fill=tk.X) + + # Left side - status message + self.status_label = tk.Label(status_frame, text="Připraven", anchor=tk.W, + bg=COLORS["status_bg"], padx=10) + self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Right side - file count + self.file_count_label = tk.Label(status_frame, text="0 souborů", anchor=tk.E, + bg=COLORS["status_bg"], padx=10) + self.file_count_label.pack(side=tk.RIGHT) + + # Middle - selected count + self.selected_count_label = tk.Label(status_frame, text="", anchor=tk.E, + bg=COLORS["status_bg"], padx=10) + self.selected_count_label.pack(side=tk.RIGHT) + + def _create_context_menus(self): + """Create context menus""" + # Tag context menu + self.tag_menu = tk.Menu(self.root, tearoff=0) + self.tag_menu.add_command(label="Nový štítek", command=self.tree_add_tag) + self.tag_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag) + + # File context menu + self.file_menu = tk.Menu(self.root, tearoff=0) + self.file_menu.add_command(label="Otevřít soubor", command=self.open_selected_files) + self.file_menu.add_command(label="Přiřadit štítky (Ctrl+T)", command=self.assign_tag_to_selected_bulk) + self.file_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) + self.file_menu.add_separator() + self.file_menu.add_command(label="Smazat z indexu (Del)", command=self.remove_selected_files) + + def _bind_shortcuts(self): + """Bind keyboard shortcuts""" + self.root.bind("", lambda e: self.open_folder_dialog()) + self.root.bind("", lambda e: self.root.quit()) + self.root.bind("", lambda e: self.assign_tag_to_selected_bulk()) + self.root.bind("", lambda e: self.set_date_for_selected()) + self.root.bind("", lambda e: self.search_var.get()) # Focus search + self.root.bind("", lambda e: self.refresh_all()) + self.root.bind("", lambda e: self.remove_selected_files()) + + # ================================================== + # SIDEBAR / TAG TREE METHODS + # ================================================== + + def refresh_sidebar(self): + """Refresh tag tree in sidebar""" + # Clear tree + for item in self.tag_tree.get_children(): + self.tag_tree.delete(item) + + # Add root + root_id = self.tag_tree.insert("", "end", text="📂 Všechny tagy", image=self.icons.get("tag")) + self.tag_tree.item(root_id, open=True) + self.root_tag_id = root_id + + # Add categories and tags + for category in self.tagmanager.get_categories(): + cat_id = self.tag_tree.insert(root_id, "end", text=f"📁 {category}", image=self.icons.get("tag")) + self.states[cat_id] = False + + for tag in self.tagmanager.get_tags_in_category(category): + tag_id = self.tag_tree.insert(cat_id, "end", text=f" {tag.name}", + image=self.icons.get("unchecked")) + self.states[tag_id] = False + + def on_tree_left_click(self, event): + """Handle left click on tag tree""" + region = self.tag_tree.identify("region", event.x, event.y) + if region not in ("tree", "icon"): + return + + item_id = self.tag_tree.identify_row(event.y) + if not item_id: + return + + parent_id = self.tag_tree.parent(item_id) + + # Toggle folder open/close + if parent_id == "" or parent_id == self.root_tag_id: + is_open = self.tag_tree.item(item_id, "open") + self.tag_tree.item(item_id, open=not is_open) + return + + # Toggle tag checkbox + self.states[item_id] = not self.states.get(item_id, False) + self.tag_tree.item(item_id, image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]) + + # Update file list + self.update_files_from_manager(self.filehandler.filelist) + + def on_tree_right_click(self, event): + """Handle right click on tag tree""" + item_id = self.tag_tree.identify_row(event.y) + if item_id: + self.selected_tree_item_for_context = item_id + self.tag_tree.selection_set(item_id) + self.tag_menu.tk_popup(event.x_root, event.y_root) + + def tree_add_tag(self, background=False): + """Add new tag""" + name = simpledialog.askstring("Nový tag", "Název tagu:") + if not name: + return + + parent = self.selected_tree_item_for_context if not background else self.root_tag_id + new_id = self.tag_tree.insert(parent, "end", text=f" {name}", image=self.icons["unchecked"]) + self.states[new_id] = False + + if parent == self.root_tag_id: + self.tagmanager.add_category(name) + self.tag_tree.item(new_id, image=self.icons["tag"]) + else: + category = self.tag_tree.item(parent, "text").replace("📁 ", "") + self.tagmanager.add_tag(category, name) + + self.status_label.config(text=f"Vytvořen tag: {name}") + + def tree_delete_tag(self): + """Delete selected tag""" + item = self.selected_tree_item_for_context + if not item: + return + + name = self.tag_tree.item(item, "text").strip() + ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{name}'?") + if not ans: + return + + parent_id = self.tag_tree.parent(item) + self.tag_tree.delete(item) + self.states.pop(item, None) + + if parent_id == self.root_tag_id: + self.tagmanager.remove_category(name.replace("📁 ", "")) + else: + category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") + self.tagmanager.remove_tag(category, name) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Smazán tag: {name}") + + def get_checked_tags(self) -> List[Tag]: + """Get list of checked tags""" + tags = [] + for item_id, checked in self.states.items(): + if not checked: + continue + parent_id = self.tag_tree.parent(item_id) + if parent_id == "" or parent_id == self.root_tag_id: + continue + category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") + name = self.tag_tree.item(item_id, "text").strip() + tags.append(Tag(category, name)) + return tags + + # ================================================== + # FILE TABLE METHODS + # ================================================== + + def update_files_from_manager(self, filelist=None): + """Update file table""" + if filelist is None: + filelist = self.filehandler.filelist + + # Filter by checked tags + checked_tags = self.get_checked_tags() + filtered_files = self.filehandler.filter_files_by_tags(checked_tags) + + # Filter by search text + search_text = self.search_var.get().lower() if hasattr(self, 'search_var') else "" + if search_text: + filtered_files = [ + f for f in filtered_files + if search_text in f.filename.lower() or + (self.show_full_path and search_text in str(f.file_path).lower()) + ] + + # Filter ignored + if self.hide_ignored_var and self.hide_ignored_var.get(): + filtered_files = [ + f for f in filtered_files + if "Stav/Ignorované" not in {t.full_path for t in f.tags} + ] + + # Sort + reverse = (self.sort_order == "desc") + if self.sort_mode == "name": + filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse) + elif self.sort_mode == "date": + filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse) + + # Clear table + for item in self.file_table.get_children(): + self.file_table.delete(item) + self.file_items.clear() + + # Populate table + for f in filtered_files: + name = str(f.file_path) if self.show_full_path else f.filename + date = f.date or "" + tags = ", ".join([t.name for t in f.tags[:3]]) # Show first 3 tags + if len(f.tags) > 3: + tags += f" +{len(f.tags) - 3}" + + try: + size = f.file_path.stat().st_size + size_str = self._format_size(size) + except: + size_str = "?" + + item_id = self.file_table.insert("", "end", values=(name, date, tags, size_str)) + self.file_items[item_id] = f + + # Update status + self.file_count_label.config(text=f"{len(filtered_files)} souborů") + self.status_label.config(text=f"Zobrazeno {len(filtered_files)} souborů") + + def _format_size(self, size_bytes): + """Format file size""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + def get_selected_files(self) -> List[File]: + """Get selected files from table""" + selected_items = self.file_table.selection() + return [self.file_items[item] for item in selected_items if item in self.file_items] + + def on_file_double_click(self, event): + """Handle double click on file""" + files = self.get_selected_files() + for f in files: + self.open_file(f.file_path) + + def on_file_right_click(self, event): + """Handle right click on file""" + # Select item under cursor if not selected + item = self.file_table.identify_row(event.y) + if item and item not in self.file_table.selection(): + self.file_table.selection_set(item) + + # Update selected count + count = len(self.file_table.selection()) + self.selected_count_label.config(text=f"{count} vybráno" if count > 0 else "") + + self.file_menu.tk_popup(event.x_root, event.y_root) + + def open_file(self, path): + """Open file with default application""" + try: + if sys.platform.startswith("win"): + os.startfile(path) + elif sys.platform.startswith("darwin"): + subprocess.call(["open", path]) + else: + subprocess.call(["xdg-open", path]) + self.status_label.config(text=f"Otevírám: {path.name}") + except Exception as e: + messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}") + + # ================================================== + # ACTIONS + # ================================================== + + def open_folder_dialog(self): + """Open folder selection dialog""" + folder = filedialog.askdirectory(title="Vyber složku pro sledování") + if not folder: + return + + folder_path = Path(folder) + try: + self.filehandler.append(folder_path) + for f in self.filehandler.filelist: + if f.tags and f.tagmanager: + for t in f.tags: + f.tagmanager.add_tag(t.category, t.name) + + self.status_label.config(text=f"Přidána složka: {folder_path}") + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + except Exception as e: + messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}") + + def open_selected_files(self): + """Open selected files""" + files = self.get_selected_files() + for f in files: + self.open_file(f.file_path) + + def remove_selected_files(self): + """Remove selected files from index""" + files = self.get_selected_files() + if not files: + return + + ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?") + if ans: + for f in files: + if f in self.filehandler.filelist: + self.filehandler.filelist.remove(f) + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Odstraněno {len(files)} souborů z indexu") + + def assign_tag_to_selected_bulk(self): + """Assign tags to selected files (bulk mode)""" + files = self.get_selected_files() + if not files: + self.status_label.config(text="Nebyly vybrány žádné soubory") + return + + # Import the dialog from old GUI + from src.ui.gui_old import MultiFileTagAssignDialog + + all_tags = [] + for category in self.tagmanager.get_categories(): + for tag in self.tagmanager.get_tags_in_category(category): + all_tags.append(tag) + + if not all_tags: + messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") + return + + dialog = MultiFileTagAssignDialog(self.root, all_tags, files) + result = getattr(dialog, "result", None) + + if result is None: + self.status_label.config(text="Přiřazení zrušeno") + return + + for full_path, state in result.items(): + if state == 1: + if "/" in full_path: + category, name = full_path.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + self.filehandler.assign_tag_to_file_objects(files, tag_obj) + elif state == 0: + if "/" in full_path: + category, name = full_path.split("/", 1) + tag_obj = Tag(category, name) + self.filehandler.remove_tag_from_file_objects(files, tag_obj) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text="Hromadné přiřazení tagů dokončeno") + + def set_date_for_selected(self): + """Set date for selected files""" + files = self.get_selected_files() + if not files: + self.status_label.config(text="Nebyly vybrány žádné soubory") + return + + prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):" + date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root) + if date_str is None: + return + + for f in files: + f.set_date(date_str if date_str != "" else None) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)") + + def detect_video_resolution(self): + """Detect video resolution using ffprobe""" + files = self.get_selected_files() + if not files: + self.status_label.config(text="Nebyly vybrány žádné soubory") + return + + count = 0 + for f in files: + try: + path = str(f.file_path) + result = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=height", "-of", "csv=p=0", path], + capture_output=True, + text=True, + check=True + ) + height_str = result.stdout.strip() + if not height_str.isdigit(): + continue + height = int(height_str) + tag_name = f"{height}p" + tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name) + f.add_tag(tag_obj) + count += 1 + except Exception as e: + print(f"Chyba u {f.filename}: {e}") + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Přiřazeno rozlišení tagů k {count} souborům") + + def set_ignore_patterns(self): + """Set ignore patterns""" + current = ", ".join(self.filehandler.config.get("ignore_patterns", [])) + s = simpledialog.askstring("Ignore patterns", + "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", + initialvalue=current) + if s is None: + return + + patterns = [p.strip() for p in s.split(",") if p.strip()] + self.filehandler.config["ignore_patterns"] = patterns + save_config(self.filehandler.config) + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text="Ignore patterns aktualizovány") + + def toggle_hide_ignored(self): + """Toggle hiding ignored files""" + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_show_path(self): + """Toggle showing full path""" + self.show_full_path = not self.show_full_path + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_sort_mode(self): + """Toggle sort mode""" + selected = self.sort_combo.get() + self.sort_mode = "date" if selected == "Datum" else "name" + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_sort_order(self): + """Toggle sort order""" + self.sort_order = "desc" if self.sort_order == "asc" else "asc" + self.order_var.set("▼ Sestupně" if self.sort_order == "desc" else "▲ Vzestupně") + self.update_files_from_manager(self.filehandler.filelist) + + def on_filter_changed(self): + """Handle search/filter change""" + self.update_files_from_manager(self.filehandler.filelist) + + def refresh_all(self): + """Refresh everything""" + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text="Obnoveno") diff --git a/src/ui/gui_old.py b/src/ui/gui_old.py new file mode 100644 index 0000000..7a529e0 --- /dev/null +++ b/src/ui/gui_old.py @@ -0,0 +1,711 @@ +import os +import sys +import subprocess +import tkinter as tk +from tkinter import ttk, simpledialog, messagebox, filedialog +from pathlib import Path +from typing import List + +from src.core.media_utils import load_icon +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager +from src.core.file import File +from src.core.tag import Tag +from src.core.list_manager import ListManager +from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT +from src.core.config import save_config # <-- doplněno + + + + +class TagSelectionDialog(tk.Toplevel): + """ + Jednoduchý dialog pro výběr tagů (původní, používán jinde). + (tento třída zůstává pro jednobodové použití) + """ + def __init__(self, parent, tags: list[str]): + super().__init__(parent) + self.title("Vyber tagy") + self.selected_tags = [] + self.vars = {} + + tk.Label(self, text="Vyber tagy k přiřazení:").pack(pady=5) + + frame = tk.Frame(self) + frame.pack(padx=10, pady=5) + + for tag in tags: + var = tk.BooleanVar(value=False) + chk = tk.Checkbutton(frame, text=tag, variable=var) + chk.pack(anchor="w") + self.vars[tag] = var + + btn_frame = tk.Frame(self) + btn_frame.pack(pady=5) + tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5) + tk.Button(btn_frame, text="Zrušit", command=self.destroy).pack(side="left", padx=5) + + self.transient(parent) + self.grab_set() + parent.wait_window(self) + + def on_ok(self): + self.selected_tags = [tag for tag, var in self.vars.items() if var.get()] + self.destroy() + + +class MultiFileTagAssignDialog(tk.Toplevel): + def __init__(self, parent, all_tags: List[Tag], files: List[File]): + super().__init__(parent) + self.title("Přiřadit tagy k vybraným souborům") + self.vars: dict[str, int] = {} + self.checkbuttons: dict[str, tk.Checkbutton] = {} + self.tags_by_full = {t.full_path: t for t in all_tags} + self.files = files + + tk.Label(self, text=f"Vybráno souborů: {len(files)}").pack(pady=5) + + frame = tk.Frame(self) + frame.pack(padx=10, pady=5, fill="both", expand=True) + + file_tag_sets = [{t.full_path for t in f.tags} for f in files] + + for full_path, tag in sorted(self.tags_by_full.items()): + have_count = sum(1 for s in file_tag_sets if full_path in s) + if have_count == 0: + init = 0 + elif have_count == len(files): + init = 1 + else: + init = 2 # mixed + + cb = tk.Checkbutton(frame, text=full_path, anchor="w") + cb.state_value = init + cb.full_path = full_path + cb.pack(fill="x", anchor="w") + cb.bind("", self._on_toggle) + + self._update_checkbox_look(cb) + self.checkbuttons[full_path] = cb + self.vars[full_path] = init + + btn_frame = tk.Frame(self) + btn_frame.pack(pady=5) + tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5) + tk.Button(btn_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + self.transient(parent) + self.grab_set() + parent.wait_window(self) + + def _on_toggle(self, event): + cb: tk.Checkbutton = event.widget + cur = cb.state_value + if cur == 0: # OFF → ON + cb.state_value = 1 + elif cur == 1: # ON → OFF + cb.state_value = 0 + elif cur == 2: # MIXED → ON + cb.state_value = 1 + self._update_checkbox_look(cb) + return "break" + + def _update_checkbox_look(self, cb: tk.Checkbutton): + """Aktualizuje vizuál podle stavu.""" + v = cb.state_value + if v == 0: + cb.deselect() + cb.config(fg="black") + elif v == 1: + cb.select() + cb.config(fg="blue") + elif v == 2: + cb.deselect() # mixed = nezaškrtnuté, ale červený text + cb.config(fg="red") + + def on_ok(self): + self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()} + self.destroy() + + +class App: + def __init__(self, filehandler: FileManager, tagmanager: TagManager): + self.states = {} + self.listbox_map: dict[int, list[File]] = {} + self.selected_tree_item_for_context = None + self.selected_list_index_for_context = None + self.filehandler = filehandler + self.tagmanager = tagmanager + self.list_manager = ListManager() + + # tady jen připravíme proměnnou, ale nevytváříme BooleanVar! + self.hide_ignored_var = None + + self.filter_text = "" + self.show_full_path = False + self.sort_mode = "name" + self.sort_order = "asc" + + self.filehandler.on_files_changed = self.update_files_from_manager + + def detect_video_resolution(self): + files = self.get_selected_files_objects() + if not files: + self.status_bar.config(text="Nebyly vybrány žádné soubory") + return + + count = 0 + for f in files: + try: + path = str(f.file_path) + result = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=height", "-of", "csv=p=0", path], + capture_output=True, + text=True, + check=True + ) + height_str = result.stdout.strip() + if not height_str.isdigit(): + continue + height = int(height_str) + tag_name = f"{height}p" + tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name) + f.add_tag(tag_obj) + count += 1 + except Exception as e: + print(f"Chyba u {f.filename}: {e}") + + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Přiřazeno rozlišení tagů k {count} souborům") + + + # ================================================== + # MAIN GUI + # ================================================== + def main(self): + root = tk.Tk() + root.title(APP_NAME + " " + VERSION) + root.geometry(APP_VIEWPORT) + self.root = root + + # teď už máme root, takže můžeme vytvořit BooleanVar + self.hide_ignored_var = tk.BooleanVar(value=False, master=root) + + last = self.filehandler.config.get("last_folder") + if last: + try: + self.filehandler.append(Path(last)) + except Exception: + pass + + # ---- Ikony + unchecked = load_icon("src/resources/images/32/32_unchecked.png") + checked = load_icon("src/resources/images/32/32_checked.png") + tag_icon = load_icon("src/resources/images/32/32_tag.png") + self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon} + root.unchecked_img = unchecked + root.checked_img = checked + root.tag_img = tag_icon + + # ---- Layout + menu_bar = tk.Menu(root) + root.config(menu=menu_bar) + + file_menu = tk.Menu(menu_bar, tearoff=0) + file_menu.add_command(label="Open Folder...", command=self.open_folder_dialog) + file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns) + file_menu.add_separator() + file_menu.add_command(label="Exit", command=root.quit) + + view_menu = tk.Menu(menu_bar, tearoff=0) + view_menu.add_checkbutton( + label="Skrýt ignorované", + variable=self.hide_ignored_var, + command=self.toggle_hide_ignored + ) + function_menu = tk.Menu(menu_bar, tearoff=0) + function_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected) + function_menu.add_command(label="Detekovat rozlišení u videí", command=self.detect_video_resolution) + + + menu_bar.add_cascade(label="Soubor", menu=file_menu) + menu_bar.add_cascade(label="Pohled", menu=view_menu) + menu_bar.add_cascade(label="Funkce", menu=function_menu) + + main_frame = tk.Frame(root) + main_frame.pack(fill="both", expand=True) + main_frame.columnconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=2) + main_frame.rowconfigure(0, weight=1) + + # ---- Tree (left) + self.tree = ttk.Treeview(main_frame) + self.tree.grid(row=0, column=0, sticky="nsew", padx=4, pady=4) + self.tree.bind("", self.on_tree_left_click) + self.tree.bind("", self.on_tree_right_click) + + # ---- Right side (filter + listbox) + right_frame = tk.Frame(main_frame) + right_frame.grid(row=0, column=1, sticky="nsew", padx=4, pady=4) + right_frame.rowconfigure(1, weight=1) + right_frame.columnconfigure(0, weight=1) + + # Filter + buttons row + filter_frame = tk.Frame(right_frame) + filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 4)) + filter_frame.columnconfigure(0, weight=1) + + self.filter_entry = tk.Entry(filter_frame) + self.filter_entry.grid(row=0, column=0, sticky="ew") + self.filter_entry.bind("", lambda e: self.on_filter_changed()) + + self.btn_toggle_path = tk.Button(filter_frame, text="Name", width=6, command=self.toggle_show_path) + self.btn_toggle_path.grid(row=0, column=1, padx=2) + + self.btn_toggle_sortmode = tk.Button(filter_frame, text="Sort:Name", width=8, command=self.toggle_sort_mode) + self.btn_toggle_sortmode.grid(row=0, column=2, padx=2) + + self.btn_toggle_order = tk.Button(filter_frame, text="ASC", width=5, command=self.toggle_sort_order) + self.btn_toggle_order.grid(row=0, column=3, padx=2) + + # Listbox + scrollbar + self.listbox = tk.Listbox(right_frame, selectmode="extended") + self.listbox.grid(row=1, column=0, sticky="nsew") + self.listbox.bind("", self.on_list_double) + self.listbox.bind("", self.on_list_right_click) + + lb_scroll = tk.Scrollbar(right_frame, orient="vertical", command=self.listbox.yview) + lb_scroll.grid(row=1, column=1, sticky="ns") + self.listbox.config(yscrollcommand=lb_scroll.set) + + # ---- Status bar + self.status_bar = tk.Label(root, text="Připraven", anchor="w", relief="sunken") + self.status_bar.pack(side="bottom", fill="x") + + # ---- Context menus + self.tree_menu = tk.Menu(root, tearoff=0) + self.tree_menu.add_command(label="Nový štítek", command=self.tree_add_tag) + self.tree_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag) + + self.list_menu = tk.Menu(root, tearoff=0) + self.list_menu.add_command(label="Otevřít soubor", command=self.list_open_file) + self.list_menu.add_command(label="Smazat z indexu", command=self.list_remove_file) + self.list_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected) + self.list_menu.add_command(label="Přiřadit štítek", command=self.assign_tag_to_selected_bulk) + + # ---- Root node + root_id = self.tree.insert("", "end", text="Štítky", image=self.icons["tag"]) + self.tree.item(root_id, open=True) + self.root_id = root_id + + # ⚡ refresh při startu + self.refresh_tree_tags() + self.update_files_from_manager(self.filehandler.filelist) + + root.mainloop() + + + # ================================================== + # FILTER + SORT TOGGLES + # ================================================== + def set_ignore_patterns(self): + current = ", ".join(self.filehandler.config.get("ignore_patterns", [])) + s = simpledialog.askstring("Ignore patterns", "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", initialvalue=current) + if s is None: + return + patterns = [p.strip() for p in s.split(",") if p.strip()] + self.filehandler.config["ignore_patterns"] = patterns + save_config(self.filehandler.config) + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_hide_ignored(self): + self.update_files_from_manager(self.filehandler.filelist) + + def on_filter_changed(self): + self.filter_text = self.filter_entry.get().strip().lower() + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_show_path(self): + self.show_full_path = not self.show_full_path + self.btn_toggle_path.config(text="Path" if self.show_full_path else "Name") + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_sort_mode(self): + self.sort_mode = "date" if self.sort_mode == "name" else "name" + self.btn_toggle_sortmode.config(text=f"Sort:{self.sort_mode.capitalize()}") + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_sort_order(self): + self.sort_order = "desc" if self.sort_order == "asc" else "asc" + self.btn_toggle_order.config(text=self.sort_order.upper()) + self.update_files_from_manager(self.filehandler.filelist) + + # ================================================== + # FILE REFRESH + MAP + # ================================================== + def update_files_from_manager(self, filelist=None): + if filelist is None: + filelist = self.filehandler.filelist + + # filtr tagy + checked_tags = self.get_checked_tags() + filtered_files = self.filehandler.filter_files_by_tags(checked_tags) + + # filtr text + if self.filter_text: + filtered_files = [ + f for f in filtered_files + if self.filter_text in f.filename.lower() or + (self.show_full_path and self.filter_text in str(f.file_path).lower()) + ] + + if self.hide_ignored_var and self.hide_ignored_var.get(): + filtered_files = [ + f for f in filtered_files + if "Stav/Ignorované" not in {t.full_path for t in f.tags} + ] + + + + # řazení + reverse = (self.sort_order == "desc") + if self.sort_mode == "name": + filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse) + elif self.sort_mode == "date": + filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse) + + # naplníme listbox + self.listbox.delete(0, "end") + self.listbox_map = {} + + for i, f in enumerate(filtered_files): + if self.show_full_path: + display = str(f.file_path) + else: + display = f.filename + if f.date: + display = f"{display} — {f.date}" + self.listbox.insert("end", display) + self.listbox_map[i] = [f] + + self.status_bar.config(text=f"Zobrazeno {self.listbox.size()} položek") + + # ================================================== + # GET SELECTED FILES + # ================================================== + def get_selected_files_objects(self): + indices = self.listbox.curselection() + files = [] + for idx in indices: + files.extend(self.listbox_map.get(idx, [])) + return files + + # ================================================== + # ASSIGN TAG (jednoduchý) + # ================================================== + def assign_tag_to_selected(self): + files = self.get_selected_files_objects() + if not files: + self.status_bar.config(text="Nebyly vybrány žádné soubory") + return + + all_tags: List[Tag] = [] + for category in self.tagmanager.get_categories(): + for tag in self.tagmanager.get_tags_in_category(category): + all_tags.append(tag) + + if not all_tags: + messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") + return + + tag_strings = [tag.full_path for tag in all_tags] + dialog = TagSelectionDialog(self.root, tag_strings) + selected_tag_strings = dialog.selected_tags + + if not selected_tag_strings: + self.status_bar.config(text="Nebyl vybrán žádný tag") + return + + selected_tags: list[Tag] = [] + for full_tag in selected_tag_strings: + if "/" in full_tag: + category, name = full_tag.split("/", 1) + selected_tags.append(self.tagmanager.add_tag(category, name)) + + for tag in selected_tags: + self.filehandler.assign_tag_to_file_objects(files, tag) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Přiřazeny tagy: {', '.join(selected_tag_strings)}") + + # ================================================== + # ASSIGN TAG (pokročilé pro více souborů - tri-state) + # ================================================== + def assign_tag_to_selected_bulk(self): + files = self.get_selected_files_objects() + if not files: + self.status_bar.config(text="Nebyly vybrány žádné soubory") + return + + all_tags: List[Tag] = [] + for category in self.tagmanager.get_categories(): + for tag in self.tagmanager.get_tags_in_category(category): + all_tags.append(tag) + + if not all_tags: + messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") + return + + dialog = MultiFileTagAssignDialog(self.root, all_tags, files) + result = getattr(dialog, "result", None) + if result is None: + self.status_bar.config(text="Přiřazení zrušeno") + return + + for full_path, state in result.items(): + if state == 1: + if "/" in full_path: + category, name = full_path.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + self.filehandler.assign_tag_to_file_objects(files, tag_obj) + elif state == 0: + if "/" in full_path: + category, name = full_path.split("/", 1) + from src.core.tag import Tag as TagClass + tag_obj = TagClass(category, name) + self.filehandler.remove_tag_from_file_objects(files, tag_obj) + else: + continue + + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text="Hromadné přiřazení tagů dokončeno") + + # ================================================== + # SET DATE FOR SELECTED FILES + # ================================================== + def set_date_for_selected(self): + files = self.get_selected_files_objects() + if not files: + self.status_bar.config(text="Nebyly vybrány žádné soubory") + return + prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):" + date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root) + if date_str is None: + return + for f in files: + f.set_date(date_str if date_str != "" else None) + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)") + + # ================================================== + # DOUBLE CLICK OPEN + # ================================================== + def on_list_double(self, event): + for f in self.get_selected_files_objects(): + self.open_file(f.file_path) + + # ================================================== + # OPEN FILE + # ================================================== + def open_file(self, path): + try: + if sys.platform.startswith("win"): + os.startfile(path) + elif sys.platform.startswith("darwin"): + subprocess.call(["open", path]) + else: + subprocess.call(["xdg-open", path]) + self.status_bar.config(text=f"Otevírám: {path}") + except Exception as e: + messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}") + + # ================================================== + # LIST CONTEXT MENU + # ================================================== + def on_list_right_click(self, event): + idx = self.listbox.nearest(event.y) + if idx is None: + return + + # pokud položka není součástí aktuálního výběru, přidáme ji + if idx not in self.listbox.curselection(): + self.listbox.selection_set(idx) + + self.selected_list_index_for_context = idx + self.list_menu.tk_popup(event.x_root, event.y_root) + + + def list_open_file(self): + for f in self.get_selected_files_objects(): + self.open_file(f.file_path) + + def list_remove_file(self): + files = self.get_selected_files_objects() + if not files: + return + ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?") + if ans: + for f in files: + if f in self.filehandler.filelist: + self.filehandler.filelist.remove(f) + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Odstraněno {len(files)} souborů z indexu") + + # ================================================== + # OPEN FOLDER + # ================================================== + def open_folder_dialog(self): + folder = filedialog.askdirectory(title="Vyber složku pro sledování") + if not folder: + return + folder_path = Path(folder) + try: + self.filehandler.append(folder_path) + for f in self.filehandler.filelist: + if f.tags and f.tagmanager: + for t in f.tags: + f.tagmanager.add_tag(t.category, t.name) + self.status_bar.config(text=f"Přidána složka: {folder_path}") + self.refresh_tree_tags() + self.update_files_from_manager(self.filehandler.filelist) + except Exception as e: + messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}") + + # ================================================== + # TREE EVENTS + # ================================================== + def on_tree_left_click(self, event): + region = self.tree.identify("region", event.x, event.y) + if region not in ("tree", "icon"): + return + + item_id = self.tree.identify_row(event.y) + if not item_id: + return + + parent_id = self.tree.parent(item_id) + if parent_id == "" or parent_id == self.root_id: + is_open = self.tree.item(item_id, "open") + self.tree.item(item_id, open=not is_open) + return + + self.states[item_id] = not self.states.get(item_id, False) + self.tree.item( + item_id, + image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"] + ) + self.status_bar.config( + text=f"Tag {'checked' if self.states[item_id] else 'unchecked'}: {self.build_full_tag(item_id)}" + ) + + checked_tags = self.get_checked_tags() + filtered_files = self.filehandler.filter_files_by_tags(checked_tags) + self.update_files_from_manager(filtered_files) + + def on_tree_right_click(self, event): + item_id = self.tree.identify_row(event.y) + if item_id: + self.selected_tree_item_for_context = item_id + self.tree.selection_set(item_id) + self.tree_menu.tk_popup(event.x_root, event.y_root) + else: + menu = tk.Menu(self.root, tearoff=0) + menu.add_command(label="Nový top-level tag", command=lambda: self.tree_add_tag(background=True)) + menu.tk_popup(event.x_root, event.y_root) + + # ================================================== + # TREE TAG CRUD + # ================================================== + def tree_add_tag(self, background=False): + name = simpledialog.askstring("Nový tag", "Název tagu:") + if not name: + return + parent = self.selected_tree_item_for_context if not background else self.root_id + new_id = self.tree.insert(parent, "end", text=name, image=self.icons["unchecked"]) + self.states[new_id] = False + + if parent == self.root_id: + category = name + self.tagmanager.add_category(category) + self.tree.item(new_id, image=self.icons["tag"]) + else: + category = self.tree.item(parent, "text") + self.tagmanager.add_tag(category, name) + + self.status_bar.config(text=f"Vytvořen tag: {self.build_full_tag(new_id)}") + + def tree_delete_tag(self): + item = self.selected_tree_item_for_context + if not item: + return + full = self.build_full_tag(item) + ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{full}'?") + if not ans: + return + tag_name = self.tree.item(item, "text") + parent_id = self.tree.parent(item) + self.tree.delete(item) + self.states.pop(item, None) + + if parent_id == self.root_id: + self.tagmanager.remove_category(tag_name) + else: + category = self.tree.item(parent_id, "text") + self.tagmanager.remove_tag(category, tag_name) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Smazán tag: {full}") + + # ================================================== + # TREE HELPERS + # ================================================== + def build_full_tag(self, item_id): + parts = [] + cur = item_id + while cur and cur != self.root_id: + parts.append(self.tree.item(cur, "text")) + cur = self.tree.parent(cur) + parts.reverse() + return "/".join(parts) if parts else "" + + def get_checked_full_tags(self): + return {self.build_full_tag(i) for i, v in self.states.items() if v} + + def refresh_tree_tags(self): + for child in self.tree.get_children(self.root_id): + self.tree.delete(child) + + for category in self.tagmanager.get_categories(): + cat_id = self.tree.insert(self.root_id, "end", text=category, image=self.icons["tag"]) + self.states[cat_id] = False + for tag in self.tagmanager.get_tags_in_category(category): + tag_id = self.tree.insert(cat_id, "end", text=tag.name, image=self.icons["unchecked"]) + self.states[tag_id] = False + + self.tree.item(self.root_id, open=True) + + def get_checked_tags(self) -> List[Tag]: + tags: List[Tag] = [] + for item_id, checked in self.states.items(): + if not checked: + continue + parent_id = self.tree.parent(item_id) + if parent_id == self.root_id: + continue + category = self.tree.item(parent_id, "text") + name = self.tree.item(item_id, "text") + tags.append(Tag(category, name)) + return tags + + def _get_checked_recursive(self, item): + tags = [] + if self.states.get(item, False): + parent = self.tree.parent(item) + if parent and parent != self.root_id: + parent_text = self.tree.item(parent, "text") + text = self.tree.item(item, "text") + tags.append(f"{parent_text}/{text}") + for child in self.tree.get_children(item): + tags.extend(self._get_checked_recursive(child)) + return tags