Cleanup, documentation added, new GUI
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,4 +2,8 @@
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
build
|
||||
.claude
|
||||
.claude
|
||||
|
||||
# Config a temp soubory
|
||||
config.json
|
||||
*.!tag
|
||||
775
PROJECT_NOTES.md
Normal file
775
PROJECT_NOTES.md
Normal 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
174
README.md
Normal 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
18
Tagger_modern.py
Normal 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
712
src/ui/gui_modern.py
Normal 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
711
src/ui/gui_old.py
Normal 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
|
||||
Reference in New Issue
Block a user