Cleanup, documentation added, new GUI

This commit is contained in:
2025-12-23 11:28:05 +01:00
parent 05ca250872
commit 5cdf98bdfe
6 changed files with 2395 additions and 1 deletions

6
.gitignore vendored
View File

@@ -2,4 +2,8 @@
__pycache__
.pytest_cache
build
.claude
.claude
# Config a temp soubory
config.json
*.!tag

775
PROJECT_NOTES.md Normal file
View File

@@ -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
```
<type>: <subject>
[optional body]
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
```
**Types:**
- `feat:` - Nová funkce
- `fix:` - Bug fix
- `refactor:` - Refactoring
- `test:` - Testy
- `docs:` - Dokumentace
- `style:` - Formátování
- `chore:` - Build, dependencies
**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 <noreply@anthropic.com>"
```
---
## 🎯 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!**

174
README.md Normal file
View File

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

18
Tagger_modern.py Normal file
View File

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

712
src/ui/gui_modern.py Normal file
View File

@@ -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("<Button-1>", self.on_tree_left_click)
self.tag_tree.bind("<Button-3>", self.on_tree_right_click)
parent.add(sidebar_frame)
def _create_file_panel(self, parent):
"""Create right panel with file table"""
file_frame = tk.Frame(parent, bg=COLORS["bg"])
# Control panel
control_frame = tk.Frame(file_frame, bg=COLORS["bg"])
control_frame.pack(fill=tk.X, padx=5, pady=5)
# View options
tk.Checkbutton(control_frame, text="Plná cesta", variable=tk.BooleanVar(),
command=self.toggle_show_path, bg=COLORS["bg"]).pack(side=tk.LEFT, padx=5)
# Sort options
tk.Label(control_frame, text="Třídění:", bg=COLORS["bg"]).pack(side=tk.LEFT, padx=(15, 5))
self.sort_combo = ttk.Combobox(control_frame, values=["Název", "Datum"], width=10, state="readonly")
self.sort_combo.current(0)
self.sort_combo.bind("<<ComboboxSelected>>", lambda e: self.toggle_sort_mode())
self.sort_combo.pack(side=tk.LEFT)
self.order_var = tk.StringVar(value="▲ Vzestupně")
order_btn = tk.Button(control_frame, textvariable=self.order_var, command=self.toggle_sort_order,
relief=tk.FLAT, bg=COLORS["bg"])
order_btn.pack(side=tk.LEFT, padx=5)
# File table
table_frame = tk.Frame(file_frame)
table_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# Define columns
columns = ("name", "date", "tags", "size")
self.file_table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended")
# Column headers
self.file_table.heading("name", text="📄 Název souboru")
self.file_table.heading("date", text="📅 Datum")
self.file_table.heading("tags", text="🏷️ Štítky")
self.file_table.heading("size", text="💾 Velikost")
# Column widths
self.file_table.column("name", width=300)
self.file_table.column("date", width=100)
self.file_table.column("tags", width=200)
self.file_table.column("size", width=80)
# Scrollbars
vsb = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.file_table.yview)
hsb = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.file_table.xview)
self.file_table.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.file_table.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
table_frame.grid_rowconfigure(0, weight=1)
table_frame.grid_columnconfigure(0, weight=1)
# Bind events
self.file_table.bind("<Double-1>", self.on_file_double_click)
self.file_table.bind("<Button-3>", self.on_file_right_click)
parent.add(file_frame)
def _create_status_bar(self):
"""Create status bar at bottom"""
status_frame = tk.Frame(self.root, bg=COLORS["status_bg"], relief=tk.SUNKEN, bd=1)
status_frame.pack(side=tk.BOTTOM, fill=tk.X)
# Left side - status message
self.status_label = tk.Label(status_frame, text="Připraven", anchor=tk.W,
bg=COLORS["status_bg"], padx=10)
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Right side - file count
self.file_count_label = tk.Label(status_frame, text="0 souborů", anchor=tk.E,
bg=COLORS["status_bg"], padx=10)
self.file_count_label.pack(side=tk.RIGHT)
# Middle - selected count
self.selected_count_label = tk.Label(status_frame, text="", anchor=tk.E,
bg=COLORS["status_bg"], padx=10)
self.selected_count_label.pack(side=tk.RIGHT)
def _create_context_menus(self):
"""Create context menus"""
# Tag context menu
self.tag_menu = tk.Menu(self.root, tearoff=0)
self.tag_menu.add_command(label="Nový štítek", command=self.tree_add_tag)
self.tag_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag)
# File context menu
self.file_menu = tk.Menu(self.root, tearoff=0)
self.file_menu.add_command(label="Otevřít soubor", command=self.open_selected_files)
self.file_menu.add_command(label="Přiřadit štítky (Ctrl+T)", command=self.assign_tag_to_selected_bulk)
self.file_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected)
self.file_menu.add_separator()
self.file_menu.add_command(label="Smazat z indexu (Del)", command=self.remove_selected_files)
def _bind_shortcuts(self):
"""Bind keyboard shortcuts"""
self.root.bind("<Control-o>", lambda e: self.open_folder_dialog())
self.root.bind("<Control-q>", lambda e: self.root.quit())
self.root.bind("<Control-t>", lambda e: self.assign_tag_to_selected_bulk())
self.root.bind("<Control-d>", lambda e: self.set_date_for_selected())
self.root.bind("<Control-f>", lambda e: self.search_var.get()) # Focus search
self.root.bind("<F5>", lambda e: self.refresh_all())
self.root.bind("<Delete>", lambda e: self.remove_selected_files())
# ==================================================
# SIDEBAR / TAG TREE METHODS
# ==================================================
def refresh_sidebar(self):
"""Refresh tag tree in sidebar"""
# Clear tree
for item in self.tag_tree.get_children():
self.tag_tree.delete(item)
# Add root
root_id = self.tag_tree.insert("", "end", text="📂 Všechny tagy", image=self.icons.get("tag"))
self.tag_tree.item(root_id, open=True)
self.root_tag_id = root_id
# Add categories and tags
for category in self.tagmanager.get_categories():
cat_id = self.tag_tree.insert(root_id, "end", text=f"📁 {category}", image=self.icons.get("tag"))
self.states[cat_id] = False
for tag in self.tagmanager.get_tags_in_category(category):
tag_id = self.tag_tree.insert(cat_id, "end", text=f" {tag.name}",
image=self.icons.get("unchecked"))
self.states[tag_id] = False
def on_tree_left_click(self, event):
"""Handle left click on tag tree"""
region = self.tag_tree.identify("region", event.x, event.y)
if region not in ("tree", "icon"):
return
item_id = self.tag_tree.identify_row(event.y)
if not item_id:
return
parent_id = self.tag_tree.parent(item_id)
# Toggle folder open/close
if parent_id == "" or parent_id == self.root_tag_id:
is_open = self.tag_tree.item(item_id, "open")
self.tag_tree.item(item_id, open=not is_open)
return
# Toggle tag checkbox
self.states[item_id] = not self.states.get(item_id, False)
self.tag_tree.item(item_id, image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"])
# Update file list
self.update_files_from_manager(self.filehandler.filelist)
def on_tree_right_click(self, event):
"""Handle right click on tag tree"""
item_id = self.tag_tree.identify_row(event.y)
if item_id:
self.selected_tree_item_for_context = item_id
self.tag_tree.selection_set(item_id)
self.tag_menu.tk_popup(event.x_root, event.y_root)
def tree_add_tag(self, background=False):
"""Add new tag"""
name = simpledialog.askstring("Nový tag", "Název tagu:")
if not name:
return
parent = self.selected_tree_item_for_context if not background else self.root_tag_id
new_id = self.tag_tree.insert(parent, "end", text=f" {name}", image=self.icons["unchecked"])
self.states[new_id] = False
if parent == self.root_tag_id:
self.tagmanager.add_category(name)
self.tag_tree.item(new_id, image=self.icons["tag"])
else:
category = self.tag_tree.item(parent, "text").replace("📁 ", "")
self.tagmanager.add_tag(category, name)
self.status_label.config(text=f"Vytvořen tag: {name}")
def tree_delete_tag(self):
"""Delete selected tag"""
item = self.selected_tree_item_for_context
if not item:
return
name = self.tag_tree.item(item, "text").strip()
ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{name}'?")
if not ans:
return
parent_id = self.tag_tree.parent(item)
self.tag_tree.delete(item)
self.states.pop(item, None)
if parent_id == self.root_tag_id:
self.tagmanager.remove_category(name.replace("📁 ", ""))
else:
category = self.tag_tree.item(parent_id, "text").replace("📁 ", "")
self.tagmanager.remove_tag(category, name)
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.config(text=f"Smazán tag: {name}")
def get_checked_tags(self) -> List[Tag]:
"""Get list of checked tags"""
tags = []
for item_id, checked in self.states.items():
if not checked:
continue
parent_id = self.tag_tree.parent(item_id)
if parent_id == "" or parent_id == self.root_tag_id:
continue
category = self.tag_tree.item(parent_id, "text").replace("📁 ", "")
name = self.tag_tree.item(item_id, "text").strip()
tags.append(Tag(category, name))
return tags
# ==================================================
# FILE TABLE METHODS
# ==================================================
def update_files_from_manager(self, filelist=None):
"""Update file table"""
if filelist is None:
filelist = self.filehandler.filelist
# Filter by checked tags
checked_tags = self.get_checked_tags()
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
# Filter by search text
search_text = self.search_var.get().lower() if hasattr(self, 'search_var') else ""
if search_text:
filtered_files = [
f for f in filtered_files
if search_text in f.filename.lower() or
(self.show_full_path and search_text in str(f.file_path).lower())
]
# Filter ignored
if self.hide_ignored_var and self.hide_ignored_var.get():
filtered_files = [
f for f in filtered_files
if "Stav/Ignorované" not in {t.full_path for t in f.tags}
]
# Sort
reverse = (self.sort_order == "desc")
if self.sort_mode == "name":
filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse)
elif self.sort_mode == "date":
filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse)
# Clear table
for item in self.file_table.get_children():
self.file_table.delete(item)
self.file_items.clear()
# Populate table
for f in filtered_files:
name = str(f.file_path) if self.show_full_path else f.filename
date = f.date or ""
tags = ", ".join([t.name for t in f.tags[:3]]) # Show first 3 tags
if len(f.tags) > 3:
tags += f" +{len(f.tags) - 3}"
try:
size = f.file_path.stat().st_size
size_str = self._format_size(size)
except:
size_str = "?"
item_id = self.file_table.insert("", "end", values=(name, date, tags, size_str))
self.file_items[item_id] = f
# Update status
self.file_count_label.config(text=f"{len(filtered_files)} souborů")
self.status_label.config(text=f"Zobrazeno {len(filtered_files)} souborů")
def _format_size(self, size_bytes):
"""Format file size"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
def get_selected_files(self) -> List[File]:
"""Get selected files from table"""
selected_items = self.file_table.selection()
return [self.file_items[item] for item in selected_items if item in self.file_items]
def on_file_double_click(self, event):
"""Handle double click on file"""
files = self.get_selected_files()
for f in files:
self.open_file(f.file_path)
def on_file_right_click(self, event):
"""Handle right click on file"""
# Select item under cursor if not selected
item = self.file_table.identify_row(event.y)
if item and item not in self.file_table.selection():
self.file_table.selection_set(item)
# Update selected count
count = len(self.file_table.selection())
self.selected_count_label.config(text=f"{count} vybráno" if count > 0 else "")
self.file_menu.tk_popup(event.x_root, event.y_root)
def open_file(self, path):
"""Open file with default application"""
try:
if sys.platform.startswith("win"):
os.startfile(path)
elif sys.platform.startswith("darwin"):
subprocess.call(["open", path])
else:
subprocess.call(["xdg-open", path])
self.status_label.config(text=f"Otevírám: {path.name}")
except Exception as e:
messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}")
# ==================================================
# ACTIONS
# ==================================================
def open_folder_dialog(self):
"""Open folder selection dialog"""
folder = filedialog.askdirectory(title="Vyber složku pro sledování")
if not folder:
return
folder_path = Path(folder)
try:
self.filehandler.append(folder_path)
for f in self.filehandler.filelist:
if f.tags and f.tagmanager:
for t in f.tags:
f.tagmanager.add_tag(t.category, t.name)
self.status_label.config(text=f"Přidána složka: {folder_path}")
self.refresh_sidebar()
self.update_files_from_manager(self.filehandler.filelist)
except Exception as e:
messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}")
def open_selected_files(self):
"""Open selected files"""
files = self.get_selected_files()
for f in files:
self.open_file(f.file_path)
def remove_selected_files(self):
"""Remove selected files from index"""
files = self.get_selected_files()
if not files:
return
ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?")
if ans:
for f in files:
if f in self.filehandler.filelist:
self.filehandler.filelist.remove(f)
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.config(text=f"Odstraněno {len(files)} souborů z indexu")
def assign_tag_to_selected_bulk(self):
"""Assign tags to selected files (bulk mode)"""
files = self.get_selected_files()
if not files:
self.status_label.config(text="Nebyly vybrány žádné soubory")
return
# Import the dialog from old GUI
from src.ui.gui_old import MultiFileTagAssignDialog
all_tags = []
for category in self.tagmanager.get_categories():
for tag in self.tagmanager.get_tags_in_category(category):
all_tags.append(tag)
if not all_tags:
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
return
dialog = MultiFileTagAssignDialog(self.root, all_tags, files)
result = getattr(dialog, "result", None)
if result is None:
self.status_label.config(text="Přiřazení zrušeno")
return
for full_path, state in result.items():
if state == 1:
if "/" in full_path:
category, name = full_path.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
self.filehandler.assign_tag_to_file_objects(files, tag_obj)
elif state == 0:
if "/" in full_path:
category, name = full_path.split("/", 1)
tag_obj = Tag(category, name)
self.filehandler.remove_tag_from_file_objects(files, tag_obj)
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.config(text="Hromadné přiřazení tagů dokončeno")
def set_date_for_selected(self):
"""Set date for selected files"""
files = self.get_selected_files()
if not files:
self.status_label.config(text="Nebyly vybrány žádné soubory")
return
prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):"
date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root)
if date_str is None:
return
for f in files:
f.set_date(date_str if date_str != "" else None)
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)")
def detect_video_resolution(self):
"""Detect video resolution using ffprobe"""
files = self.get_selected_files()
if not files:
self.status_label.config(text="Nebyly vybrány žádné soubory")
return
count = 0
for f in files:
try:
path = str(f.file_path)
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=height", "-of", "csv=p=0", path],
capture_output=True,
text=True,
check=True
)
height_str = result.stdout.strip()
if not height_str.isdigit():
continue
height = int(height_str)
tag_name = f"{height}p"
tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name)
f.add_tag(tag_obj)
count += 1
except Exception as e:
print(f"Chyba u {f.filename}: {e}")
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.config(text=f"Přiřazeno rozlišení tagů k {count} souborům")
def set_ignore_patterns(self):
"""Set ignore patterns"""
current = ", ".join(self.filehandler.config.get("ignore_patterns", []))
s = simpledialog.askstring("Ignore patterns",
"Zadej patterny oddělené čárkou (např. *.png, *.tmp):",
initialvalue=current)
if s is None:
return
patterns = [p.strip() for p in s.split(",") if p.strip()]
self.filehandler.config["ignore_patterns"] = patterns
save_config(self.filehandler.config)
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.config(text="Ignore patterns aktualizovány")
def toggle_hide_ignored(self):
"""Toggle hiding ignored files"""
self.update_files_from_manager(self.filehandler.filelist)
def toggle_show_path(self):
"""Toggle showing full path"""
self.show_full_path = not self.show_full_path
self.update_files_from_manager(self.filehandler.filelist)
def toggle_sort_mode(self):
"""Toggle sort mode"""
selected = self.sort_combo.get()
self.sort_mode = "date" if selected == "Datum" else "name"
self.update_files_from_manager(self.filehandler.filelist)
def toggle_sort_order(self):
"""Toggle sort order"""
self.sort_order = "desc" if self.sort_order == "asc" else "asc"
self.order_var.set("▼ Sestupně" if self.sort_order == "desc" else "▲ Vzestupně")
self.update_files_from_manager(self.filehandler.filelist)
def on_filter_changed(self):
"""Handle search/filter change"""
self.update_files_from_manager(self.filehandler.filelist)
def refresh_all(self):
"""Refresh everything"""
self.refresh_sidebar()
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.config(text="Obnoveno")

711
src/ui/gui_old.py Normal file
View File

@@ -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("<Button-1>", self._on_toggle)
self._update_checkbox_look(cb)
self.checkbuttons[full_path] = cb
self.vars[full_path] = init
btn_frame = tk.Frame(self)
btn_frame.pack(pady=5)
tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5)
tk.Button(btn_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5)
self.transient(parent)
self.grab_set()
parent.wait_window(self)
def _on_toggle(self, event):
cb: tk.Checkbutton = event.widget
cur = cb.state_value
if cur == 0: # OFF → ON
cb.state_value = 1
elif cur == 1: # ON → OFF
cb.state_value = 0
elif cur == 2: # MIXED → ON
cb.state_value = 1
self._update_checkbox_look(cb)
return "break"
def _update_checkbox_look(self, cb: tk.Checkbutton):
"""Aktualizuje vizuál podle stavu."""
v = cb.state_value
if v == 0:
cb.deselect()
cb.config(fg="black")
elif v == 1:
cb.select()
cb.config(fg="blue")
elif v == 2:
cb.deselect() # mixed = nezaškrtnuté, ale červený text
cb.config(fg="red")
def on_ok(self):
self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()}
self.destroy()
class App:
def __init__(self, filehandler: FileManager, tagmanager: TagManager):
self.states = {}
self.listbox_map: dict[int, list[File]] = {}
self.selected_tree_item_for_context = None
self.selected_list_index_for_context = None
self.filehandler = filehandler
self.tagmanager = tagmanager
self.list_manager = ListManager()
# tady jen připravíme proměnnou, ale nevytváříme BooleanVar!
self.hide_ignored_var = None
self.filter_text = ""
self.show_full_path = False
self.sort_mode = "name"
self.sort_order = "asc"
self.filehandler.on_files_changed = self.update_files_from_manager
def detect_video_resolution(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
count = 0
for f in files:
try:
path = str(f.file_path)
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=height", "-of", "csv=p=0", path],
capture_output=True,
text=True,
check=True
)
height_str = result.stdout.strip()
if not height_str.isdigit():
continue
height = int(height_str)
tag_name = f"{height}p"
tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name)
f.add_tag(tag_obj)
count += 1
except Exception as e:
print(f"Chyba u {f.filename}: {e}")
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Přiřazeno rozlišení tagů k {count} souborům")
# ==================================================
# MAIN GUI
# ==================================================
def main(self):
root = tk.Tk()
root.title(APP_NAME + " " + VERSION)
root.geometry(APP_VIEWPORT)
self.root = root
# teď už máme root, takže můžeme vytvořit BooleanVar
self.hide_ignored_var = tk.BooleanVar(value=False, master=root)
last = self.filehandler.config.get("last_folder")
if last:
try:
self.filehandler.append(Path(last))
except Exception:
pass
# ---- Ikony
unchecked = load_icon("src/resources/images/32/32_unchecked.png")
checked = load_icon("src/resources/images/32/32_checked.png")
tag_icon = load_icon("src/resources/images/32/32_tag.png")
self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon}
root.unchecked_img = unchecked
root.checked_img = checked
root.tag_img = tag_icon
# ---- Layout
menu_bar = tk.Menu(root)
root.config(menu=menu_bar)
file_menu = tk.Menu(menu_bar, tearoff=0)
file_menu.add_command(label="Open Folder...", command=self.open_folder_dialog)
file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=root.quit)
view_menu = tk.Menu(menu_bar, tearoff=0)
view_menu.add_checkbutton(
label="Skrýt ignorované",
variable=self.hide_ignored_var,
command=self.toggle_hide_ignored
)
function_menu = tk.Menu(menu_bar, tearoff=0)
function_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
function_menu.add_command(label="Detekovat rozlišení u videí", command=self.detect_video_resolution)
menu_bar.add_cascade(label="Soubor", menu=file_menu)
menu_bar.add_cascade(label="Pohled", menu=view_menu)
menu_bar.add_cascade(label="Funkce", menu=function_menu)
main_frame = tk.Frame(root)
main_frame.pack(fill="both", expand=True)
main_frame.columnconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=2)
main_frame.rowconfigure(0, weight=1)
# ---- Tree (left)
self.tree = ttk.Treeview(main_frame)
self.tree.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
self.tree.bind("<Button-1>", self.on_tree_left_click)
self.tree.bind("<Button-3>", self.on_tree_right_click)
# ---- Right side (filter + listbox)
right_frame = tk.Frame(main_frame)
right_frame.grid(row=0, column=1, sticky="nsew", padx=4, pady=4)
right_frame.rowconfigure(1, weight=1)
right_frame.columnconfigure(0, weight=1)
# Filter + buttons row
filter_frame = tk.Frame(right_frame)
filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 4))
filter_frame.columnconfigure(0, weight=1)
self.filter_entry = tk.Entry(filter_frame)
self.filter_entry.grid(row=0, column=0, sticky="ew")
self.filter_entry.bind("<KeyRelease>", lambda e: self.on_filter_changed())
self.btn_toggle_path = tk.Button(filter_frame, text="Name", width=6, command=self.toggle_show_path)
self.btn_toggle_path.grid(row=0, column=1, padx=2)
self.btn_toggle_sortmode = tk.Button(filter_frame, text="Sort:Name", width=8, command=self.toggle_sort_mode)
self.btn_toggle_sortmode.grid(row=0, column=2, padx=2)
self.btn_toggle_order = tk.Button(filter_frame, text="ASC", width=5, command=self.toggle_sort_order)
self.btn_toggle_order.grid(row=0, column=3, padx=2)
# Listbox + scrollbar
self.listbox = tk.Listbox(right_frame, selectmode="extended")
self.listbox.grid(row=1, column=0, sticky="nsew")
self.listbox.bind("<Double-1>", self.on_list_double)
self.listbox.bind("<Button-3>", self.on_list_right_click)
lb_scroll = tk.Scrollbar(right_frame, orient="vertical", command=self.listbox.yview)
lb_scroll.grid(row=1, column=1, sticky="ns")
self.listbox.config(yscrollcommand=lb_scroll.set)
# ---- Status bar
self.status_bar = tk.Label(root, text="Připraven", anchor="w", relief="sunken")
self.status_bar.pack(side="bottom", fill="x")
# ---- Context menus
self.tree_menu = tk.Menu(root, tearoff=0)
self.tree_menu.add_command(label="Nový štítek", command=self.tree_add_tag)
self.tree_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag)
self.list_menu = tk.Menu(root, tearoff=0)
self.list_menu.add_command(label="Otevřít soubor", command=self.list_open_file)
self.list_menu.add_command(label="Smazat z indexu", command=self.list_remove_file)
self.list_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
self.list_menu.add_command(label="Přiřadit štítek", command=self.assign_tag_to_selected_bulk)
# ---- Root node
root_id = self.tree.insert("", "end", text="Štítky", image=self.icons["tag"])
self.tree.item(root_id, open=True)
self.root_id = root_id
# ⚡ refresh při startu
self.refresh_tree_tags()
self.update_files_from_manager(self.filehandler.filelist)
root.mainloop()
# ==================================================
# FILTER + SORT TOGGLES
# ==================================================
def set_ignore_patterns(self):
current = ", ".join(self.filehandler.config.get("ignore_patterns", []))
s = simpledialog.askstring("Ignore patterns", "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", initialvalue=current)
if s is None:
return
patterns = [p.strip() for p in s.split(",") if p.strip()]
self.filehandler.config["ignore_patterns"] = patterns
save_config(self.filehandler.config)
self.update_files_from_manager(self.filehandler.filelist)
def toggle_hide_ignored(self):
self.update_files_from_manager(self.filehandler.filelist)
def on_filter_changed(self):
self.filter_text = self.filter_entry.get().strip().lower()
self.update_files_from_manager(self.filehandler.filelist)
def toggle_show_path(self):
self.show_full_path = not self.show_full_path
self.btn_toggle_path.config(text="Path" if self.show_full_path else "Name")
self.update_files_from_manager(self.filehandler.filelist)
def toggle_sort_mode(self):
self.sort_mode = "date" if self.sort_mode == "name" else "name"
self.btn_toggle_sortmode.config(text=f"Sort:{self.sort_mode.capitalize()}")
self.update_files_from_manager(self.filehandler.filelist)
def toggle_sort_order(self):
self.sort_order = "desc" if self.sort_order == "asc" else "asc"
self.btn_toggle_order.config(text=self.sort_order.upper())
self.update_files_from_manager(self.filehandler.filelist)
# ==================================================
# FILE REFRESH + MAP
# ==================================================
def update_files_from_manager(self, filelist=None):
if filelist is None:
filelist = self.filehandler.filelist
# filtr tagy
checked_tags = self.get_checked_tags()
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
# filtr text
if self.filter_text:
filtered_files = [
f for f in filtered_files
if self.filter_text in f.filename.lower() or
(self.show_full_path and self.filter_text in str(f.file_path).lower())
]
if self.hide_ignored_var and self.hide_ignored_var.get():
filtered_files = [
f for f in filtered_files
if "Stav/Ignorované" not in {t.full_path for t in f.tags}
]
# řazení
reverse = (self.sort_order == "desc")
if self.sort_mode == "name":
filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse)
elif self.sort_mode == "date":
filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse)
# naplníme listbox
self.listbox.delete(0, "end")
self.listbox_map = {}
for i, f in enumerate(filtered_files):
if self.show_full_path:
display = str(f.file_path)
else:
display = f.filename
if f.date:
display = f"{display}{f.date}"
self.listbox.insert("end", display)
self.listbox_map[i] = [f]
self.status_bar.config(text=f"Zobrazeno {self.listbox.size()} položek")
# ==================================================
# GET SELECTED FILES
# ==================================================
def get_selected_files_objects(self):
indices = self.listbox.curselection()
files = []
for idx in indices:
files.extend(self.listbox_map.get(idx, []))
return files
# ==================================================
# ASSIGN TAG (jednoduchý)
# ==================================================
def assign_tag_to_selected(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
all_tags: List[Tag] = []
for category in self.tagmanager.get_categories():
for tag in self.tagmanager.get_tags_in_category(category):
all_tags.append(tag)
if not all_tags:
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
return
tag_strings = [tag.full_path for tag in all_tags]
dialog = TagSelectionDialog(self.root, tag_strings)
selected_tag_strings = dialog.selected_tags
if not selected_tag_strings:
self.status_bar.config(text="Nebyl vybrán žádný tag")
return
selected_tags: list[Tag] = []
for full_tag in selected_tag_strings:
if "/" in full_tag:
category, name = full_tag.split("/", 1)
selected_tags.append(self.tagmanager.add_tag(category, name))
for tag in selected_tags:
self.filehandler.assign_tag_to_file_objects(files, tag)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Přiřazeny tagy: {', '.join(selected_tag_strings)}")
# ==================================================
# ASSIGN TAG (pokročilé pro více souborů - tri-state)
# ==================================================
def assign_tag_to_selected_bulk(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
all_tags: List[Tag] = []
for category in self.tagmanager.get_categories():
for tag in self.tagmanager.get_tags_in_category(category):
all_tags.append(tag)
if not all_tags:
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
return
dialog = MultiFileTagAssignDialog(self.root, all_tags, files)
result = getattr(dialog, "result", None)
if result is None:
self.status_bar.config(text="Přiřazení zrušeno")
return
for full_path, state in result.items():
if state == 1:
if "/" in full_path:
category, name = full_path.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
self.filehandler.assign_tag_to_file_objects(files, tag_obj)
elif state == 0:
if "/" in full_path:
category, name = full_path.split("/", 1)
from src.core.tag import Tag as TagClass
tag_obj = TagClass(category, name)
self.filehandler.remove_tag_from_file_objects(files, tag_obj)
else:
continue
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text="Hromadné přiřazení tagů dokončeno")
# ==================================================
# SET DATE FOR SELECTED FILES
# ==================================================
def set_date_for_selected(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):"
date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root)
if date_str is None:
return
for f in files:
f.set_date(date_str if date_str != "" else None)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)")
# ==================================================
# DOUBLE CLICK OPEN
# ==================================================
def on_list_double(self, event):
for f in self.get_selected_files_objects():
self.open_file(f.file_path)
# ==================================================
# OPEN FILE
# ==================================================
def open_file(self, path):
try:
if sys.platform.startswith("win"):
os.startfile(path)
elif sys.platform.startswith("darwin"):
subprocess.call(["open", path])
else:
subprocess.call(["xdg-open", path])
self.status_bar.config(text=f"Otevírám: {path}")
except Exception as e:
messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}")
# ==================================================
# LIST CONTEXT MENU
# ==================================================
def on_list_right_click(self, event):
idx = self.listbox.nearest(event.y)
if idx is None:
return
# pokud položka není součástí aktuálního výběru, přidáme ji
if idx not in self.listbox.curselection():
self.listbox.selection_set(idx)
self.selected_list_index_for_context = idx
self.list_menu.tk_popup(event.x_root, event.y_root)
def list_open_file(self):
for f in self.get_selected_files_objects():
self.open_file(f.file_path)
def list_remove_file(self):
files = self.get_selected_files_objects()
if not files:
return
ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?")
if ans:
for f in files:
if f in self.filehandler.filelist:
self.filehandler.filelist.remove(f)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Odstraněno {len(files)} souborů z indexu")
# ==================================================
# OPEN FOLDER
# ==================================================
def open_folder_dialog(self):
folder = filedialog.askdirectory(title="Vyber složku pro sledování")
if not folder:
return
folder_path = Path(folder)
try:
self.filehandler.append(folder_path)
for f in self.filehandler.filelist:
if f.tags and f.tagmanager:
for t in f.tags:
f.tagmanager.add_tag(t.category, t.name)
self.status_bar.config(text=f"Přidána složka: {folder_path}")
self.refresh_tree_tags()
self.update_files_from_manager(self.filehandler.filelist)
except Exception as e:
messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}")
# ==================================================
# TREE EVENTS
# ==================================================
def on_tree_left_click(self, event):
region = self.tree.identify("region", event.x, event.y)
if region not in ("tree", "icon"):
return
item_id = self.tree.identify_row(event.y)
if not item_id:
return
parent_id = self.tree.parent(item_id)
if parent_id == "" or parent_id == self.root_id:
is_open = self.tree.item(item_id, "open")
self.tree.item(item_id, open=not is_open)
return
self.states[item_id] = not self.states.get(item_id, False)
self.tree.item(
item_id,
image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]
)
self.status_bar.config(
text=f"Tag {'checked' if self.states[item_id] else 'unchecked'}: {self.build_full_tag(item_id)}"
)
checked_tags = self.get_checked_tags()
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
self.update_files_from_manager(filtered_files)
def on_tree_right_click(self, event):
item_id = self.tree.identify_row(event.y)
if item_id:
self.selected_tree_item_for_context = item_id
self.tree.selection_set(item_id)
self.tree_menu.tk_popup(event.x_root, event.y_root)
else:
menu = tk.Menu(self.root, tearoff=0)
menu.add_command(label="Nový top-level tag", command=lambda: self.tree_add_tag(background=True))
menu.tk_popup(event.x_root, event.y_root)
# ==================================================
# TREE TAG CRUD
# ==================================================
def tree_add_tag(self, background=False):
name = simpledialog.askstring("Nový tag", "Název tagu:")
if not name:
return
parent = self.selected_tree_item_for_context if not background else self.root_id
new_id = self.tree.insert(parent, "end", text=name, image=self.icons["unchecked"])
self.states[new_id] = False
if parent == self.root_id:
category = name
self.tagmanager.add_category(category)
self.tree.item(new_id, image=self.icons["tag"])
else:
category = self.tree.item(parent, "text")
self.tagmanager.add_tag(category, name)
self.status_bar.config(text=f"Vytvořen tag: {self.build_full_tag(new_id)}")
def tree_delete_tag(self):
item = self.selected_tree_item_for_context
if not item:
return
full = self.build_full_tag(item)
ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{full}'?")
if not ans:
return
tag_name = self.tree.item(item, "text")
parent_id = self.tree.parent(item)
self.tree.delete(item)
self.states.pop(item, None)
if parent_id == self.root_id:
self.tagmanager.remove_category(tag_name)
else:
category = self.tree.item(parent_id, "text")
self.tagmanager.remove_tag(category, tag_name)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Smazán tag: {full}")
# ==================================================
# TREE HELPERS
# ==================================================
def build_full_tag(self, item_id):
parts = []
cur = item_id
while cur and cur != self.root_id:
parts.append(self.tree.item(cur, "text"))
cur = self.tree.parent(cur)
parts.reverse()
return "/".join(parts) if parts else ""
def get_checked_full_tags(self):
return {self.build_full_tag(i) for i, v in self.states.items() if v}
def refresh_tree_tags(self):
for child in self.tree.get_children(self.root_id):
self.tree.delete(child)
for category in self.tagmanager.get_categories():
cat_id = self.tree.insert(self.root_id, "end", text=category, image=self.icons["tag"])
self.states[cat_id] = False
for tag in self.tagmanager.get_tags_in_category(category):
tag_id = self.tree.insert(cat_id, "end", text=tag.name, image=self.icons["unchecked"])
self.states[tag_id] = False
self.tree.item(self.root_id, open=True)
def get_checked_tags(self) -> List[Tag]:
tags: List[Tag] = []
for item_id, checked in self.states.items():
if not checked:
continue
parent_id = self.tree.parent(item_id)
if parent_id == self.root_id:
continue
category = self.tree.item(parent_id, "text")
name = self.tree.item(item_id, "text")
tags.append(Tag(category, name))
return tags
def _get_checked_recursive(self, item):
tags = []
if self.states.get(item, False):
parent = self.tree.parent(item)
if parent and parent != self.root_id:
parent_text = self.tree.item(parent, "text")
text = self.tree.item(item, "text")
tags.append(f"{parent_text}/{text}")
for child in self.tree.get_children(item):
tags.extend(self._get_checked_recursive(child))
return tags