Merge branch 'devel' into release
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,4 +1,10 @@
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
build
|
||||
build
|
||||
.claude
|
||||
|
||||
# Config a temp soubory
|
||||
*.!tag
|
||||
*.!ftag
|
||||
*.!gtag
|
||||
68
CHANGELOG.md
Normal file
68
CHANGELOG.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Changelog
|
||||
|
||||
Všechny významné změny v projektu Tagger jsou dokumentovány v tomto souboru.
|
||||
|
||||
## [1.0.3] - 2025-12-28
|
||||
|
||||
### Přidáno
|
||||
- **Hardlink struktura** - Nová funkcionalita pro vytváření adresářové struktury pomocí hardlinků
|
||||
- `HardlinkManager` třída v `src/core/hardlink_manager.py`
|
||||
- Vytváření hardlinků podle tagů souborů (např. `output/žánr/Komedie/film.mkv`)
|
||||
- Synchronizace struktury - detekce a odstranění zastaralých hardlinků při změně tagů
|
||||
- Podpora filtrování podle kategorií
|
||||
- Preview režim (dry run)
|
||||
- **Menu položky pro hardlinky**
|
||||
- "Nastavit hardlink složku..." - konfigurace výstupní složky a kategorií (ukládá se do `.tagger.json`)
|
||||
- "Aktualizovat hardlink strukturu" - rychlá synchronizace s uloženým nastavením
|
||||
- "Vytvořit hardlink strukturu..." - ruční výběr složky a kategorií
|
||||
- **Tříúrovňový konfigurační systém**
|
||||
- Globální config (`config.json`) - nastavení aplikace (geometrie okna, poslední složka)
|
||||
- Složkový config (`.tagger.json`) - nastavení projektu (ignore patterns, hardlink nastavení)
|
||||
- Souborové tagy (`.filename.!tag`) - metadata jednotlivých souborů
|
||||
- **Výchozí tagy**
|
||||
- Kategorie "Hodnocení" s hvězdičkami (1-5 hvězd)
|
||||
- Kategorie "Barva" s barevnými štítky
|
||||
- Exkluzivní výběr v kategorii Hodnocení (pouze jeden tag)
|
||||
- **Testy**
|
||||
- 189 testů pokrývajících všechny moduly
|
||||
- Testy pro hardlink manager včetně synchronizace
|
||||
- **Poetry** - Správa závislostí pomocí Poetry
|
||||
|
||||
### Změněno
|
||||
- Modernizované GUI inspirované qBittorrentem
|
||||
- Ukládání geometrie okna do globálního configu
|
||||
- Ignore patterns se ukládají do složkového configu
|
||||
|
||||
## [1.0.2] - 2025-10-03
|
||||
|
||||
### Přidáno
|
||||
- **Moderní GUI** - Přepracované rozhraní ve stylu qBittorrent
|
||||
- Postranní panel s kategoriemi a tagy
|
||||
- Tabulka souborů s řazením podle sloupců
|
||||
- Kontextová menu pro soubory a tagy
|
||||
- Vyhledávací pole
|
||||
- Stavový řádek s počtem souborů a velikostí výběru
|
||||
- **Hromadné přiřazování tagů** - Dialog pro přiřazení tagů více souborům najednou
|
||||
- Třístav checkboxy (zaškrtnuto/nezaškrtnuto/smíšené)
|
||||
- Barevné rozlišení kategorií
|
||||
- **Detekce rozlišení videa** - Automatická detekce pomocí ffprobe
|
||||
- **Klávesové zkratky**
|
||||
- Ctrl+O - Otevřít složku
|
||||
- Ctrl+T - Přiřadit tagy
|
||||
- Ctrl+D - Nastavit datum
|
||||
- F5 - Obnovit
|
||||
- Delete - Odstranit z indexu
|
||||
|
||||
### Změněno
|
||||
- Refaktorizace struktury projektu do modulů (`src/core/`, `src/ui/`)
|
||||
- Použití dataclass pro Tag a File objekty
|
||||
|
||||
## [1.0.0] - 2025-09-03
|
||||
|
||||
### Přidáno
|
||||
- Základní funkcionalita tagování souborů
|
||||
- Ukládání tagů do skrytých souborů (`.filename.!tag`)
|
||||
- Správa kategorií a tagů
|
||||
- Rekurzivní skenování složek
|
||||
- Ignore patterns pro filtrování souborů
|
||||
- Základní GUI v Tkinter
|
||||
515
PROJECT_NOTES.md
Normal file
515
PROJECT_NOTES.md
Normal file
@@ -0,0 +1,515 @@
|
||||
# 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-28
|
||||
**Verze:** 1.0.3
|
||||
**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)
|
||||
- Moderní GUI (qBittorrent-style)
|
||||
- Hardlink struktura - vytváření adresářové struktury pomocí hardlinků podle tagů
|
||||
- Tříúrovňový konfigurační systém (globální, složkový, souborový)
|
||||
|
||||
---
|
||||
|
||||
## Struktura projektu
|
||||
|
||||
```
|
||||
Tagger/
|
||||
├── Tagger.py # Entry point
|
||||
├── PROJECT_NOTES.md # ← TENTO SOUBOR - HLAVNÍ ZDROJ PRAVDY
|
||||
├── CHANGELOG.md # Historie verzí
|
||||
├── pyproject.toml # Poetry konfigurace
|
||||
├── poetry.lock # Zamčené verze závislostí
|
||||
├── .gitignore # Git ignore pravidla
|
||||
│
|
||||
├── src/
|
||||
│ ├── core/ # Jádro aplikace (ŽÁDNÉ UI!)
|
||||
│ │ ├── tag.py # Tag value object (immutable)
|
||||
│ │ ├── tag_manager.py # Správa tagů a kategorií
|
||||
│ │ ├── file.py # File s metadaty
|
||||
│ │ ├── file_manager.py # Správa souborů, filtrování
|
||||
│ │ ├── config.py # Tříúrovňová konfigurace (global, folder, file)
|
||||
│ │ ├── hardlink_manager.py # Správa hardlink struktury
|
||||
│ │ ├── utils.py # list_files() - rekurzivní procházení
|
||||
│ │ ├── media_utils.py # load_icon(), ffprobe
|
||||
│ │ ├── constants.py # APP_NAME, VERSION, APP_VIEWPORT
|
||||
│ │ └── list_manager.py # Třídění (málo používaný)
|
||||
│ │
|
||||
│ └── ui/
|
||||
│ └── gui.py # Moderní qBittorrent-style GUI
|
||||
│
|
||||
├── tests/ # 189 testů, 100% core coverage
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Pytest fixtures
|
||||
│ ├── test_tag.py # 13 testů
|
||||
│ ├── test_tag_manager.py # 31 testů
|
||||
│ ├── test_file.py # 22 testů
|
||||
│ ├── test_file_manager.py # 40 testů
|
||||
│ ├── test_config.py # 33 testů
|
||||
│ ├── test_hardlink_manager.py # 28 testů
|
||||
│ ├── test_utils.py # 17 testů
|
||||
│ └── test_media_utils.py # 3 testy
|
||||
│
|
||||
├── src/resources/
|
||||
│ └── images/32/ # Ikony (32x32 PNG)
|
||||
│ ├── 32_unchecked.png
|
||||
│ ├── 32_checked.png
|
||||
│ └── 32_tag.png
|
||||
│
|
||||
└── data/samples/ # Testovací data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architektura
|
||||
|
||||
### Vrstvová struktura
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Presentation (UI) │ ← gui.py
|
||||
│ - Tkinter GUI │ - NESMÍ obsahovat business logiku
|
||||
│ - Jen zobrazení + interakce │ - NESMÍ importovat přímo z core
|
||||
├─────────────────────────────────┤
|
||||
│ Business Logic │ ← FileManager, TagManager, HardlinkManager
|
||||
│ - Správa souborů/tagů │ - Callable z UI
|
||||
│ - Filtrování, validace │ - Callback pattern pro notifikace
|
||||
├─────────────────────────────────┤
|
||||
│ Data Layer │ ← File, Tag (models)
|
||||
│ - File, Tag třídy │ - Immutable kde je možné
|
||||
│ - Validation logic │ - __eq__ a __hash__ správně
|
||||
├─────────────────────────────────┤
|
||||
│ Persistence │ ← config.py, .!tag soubory
|
||||
│ - JSON soubory │ - UTF-8 encoding VŽDY
|
||||
│ - Config management │ - ensure_ascii=False
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tříúrovňový konfigurační systém
|
||||
|
||||
1. **Globální config** (`.Tagger.!gtag` vedle Tagger.py)
|
||||
- Geometrie okna, maximalizace
|
||||
- Poslední otevřená složka
|
||||
- Recent folders
|
||||
|
||||
2. **Složkový config** (`.Tagger.!ftag` v projekt složce)
|
||||
- Ignore patterns
|
||||
- Custom tagy pro složku
|
||||
- Hardlink nastavení (output_dir, categories)
|
||||
- Rekurzivní skenování
|
||||
|
||||
3. **Souborové tagy** (`.filename.!tag`)
|
||||
- Tagy souboru
|
||||
- Datum
|
||||
- Stav (nové, ignorované)
|
||||
|
||||
### Klíčová pravidla
|
||||
|
||||
#### CO DĚLAT:
|
||||
|
||||
1. **UI NESMÍ obsahovat business logiku**
|
||||
```python
|
||||
# ❌ ŠPATNĚ
|
||||
class GUI:
|
||||
def save_file(self):
|
||||
with open(file, 'w') as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# ✅ SPRÁVNĚ
|
||||
class GUI:
|
||||
def save_file(self):
|
||||
self.filemanager.save_file(file)
|
||||
```
|
||||
|
||||
2. **Core moduly NESMÍ importovat UI**
|
||||
```python
|
||||
# V src/core/*.py NIKDY:
|
||||
import tkinter
|
||||
from src.ui import anything
|
||||
```
|
||||
|
||||
3. **Dependency Injection - předávat dependencies přes konstruktor**
|
||||
```python
|
||||
class FileManager:
|
||||
def __init__(self, tagmanager: TagManager):
|
||||
self.tagmanager = tagmanager
|
||||
```
|
||||
|
||||
4. **UTF-8 encoding VŠUDE**
|
||||
```python
|
||||
with open(file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
```
|
||||
|
||||
5. **Type hints VŽDY**
|
||||
```python
|
||||
def filter_files(files: List[File], tags: List[Tag]) -> List[File]:
|
||||
pass
|
||||
```
|
||||
|
||||
#### CO NEDĚLAT:
|
||||
|
||||
1. **Globální stav** - NIKDY
|
||||
2. **Magic numbers** - použít konstanty
|
||||
3. **Ignorovat exceptions** - vždy logovat nebo ošetřit
|
||||
4. **Hardcoded paths** - použít Path
|
||||
|
||||
---
|
||||
|
||||
## Klíčové komponenty
|
||||
|
||||
### 1. Tag (immutable value object)
|
||||
|
||||
```python
|
||||
class Tag:
|
||||
def __init__(self, category: str, name: str):
|
||||
self.category = category # Nemění se po vytvoření!
|
||||
self.name = name
|
||||
|
||||
@property
|
||||
def full_path(self) -> str:
|
||||
return f"{self.category}/{self.name}"
|
||||
```
|
||||
|
||||
### 2. File (reprezentace souboru s metadaty)
|
||||
|
||||
```python
|
||||
class File:
|
||||
def __init__(self, file_path: Path, tagmanager=None):
|
||||
self.file_path = file_path
|
||||
self.filename = file_path.name
|
||||
self.metadata_filename = parent / f".{filename}.!tag"
|
||||
self.tags: list[Tag] = []
|
||||
self.date: str | None = None
|
||||
```
|
||||
|
||||
**Metadata format (.filename.!tag):**
|
||||
```json
|
||||
{
|
||||
"new": false,
|
||||
"ignored": false,
|
||||
"tags": ["Stav/Nové", "Video/HD"],
|
||||
"date": "2025-12-23"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. TagManager (správa tagů)
|
||||
|
||||
```python
|
||||
class TagManager:
|
||||
def __init__(self):
|
||||
self.tags_by_category = {} # {category: set(Tag)}
|
||||
# Automaticky načte výchozí tagy (Hodnocení, Barva)
|
||||
```
|
||||
|
||||
**Výchozí tagy:**
|
||||
- Hodnocení: 1-5 hvězd (exkluzivní výběr)
|
||||
- Barva: Červená, Modrá, Zelená, Žlutá, Oranžová
|
||||
|
||||
### 4. FileManager (správa souborů)
|
||||
|
||||
```python
|
||||
class FileManager:
|
||||
def __init__(self, tagmanager: TagManager):
|
||||
self.filelist: list[File] = []
|
||||
self.tagmanager = tagmanager
|
||||
self.on_files_changed = None # CALLBACK pro UI!
|
||||
self.global_config = load_global_config()
|
||||
self.folder_config = {}
|
||||
```
|
||||
|
||||
### 5. HardlinkManager (hardlink struktura)
|
||||
|
||||
```python
|
||||
class HardlinkManager:
|
||||
def __init__(self, output_dir: Path):
|
||||
self.output_dir = output_dir
|
||||
|
||||
def create_structure_for_files(files, categories=None) -> (success, fail)
|
||||
def find_obsolete_links(files, categories=None) -> List[(link, source)]
|
||||
def remove_obsolete_links(files, categories=None) -> (count, paths)
|
||||
def sync_structure(files, categories=None) -> (created, c_fail, removed, r_fail)
|
||||
```
|
||||
|
||||
**Příklad struktury:**
|
||||
```
|
||||
output/
|
||||
├── žánr/
|
||||
│ ├── Komedie/
|
||||
│ │ └── film.mkv (hardlink)
|
||||
│ └── Akční/
|
||||
│ └── film.mkv (hardlink)
|
||||
└── rok/
|
||||
└── 1988/
|
||||
└── film.mkv (hardlink)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GUI
|
||||
|
||||
### Moderní GUI (gui.py)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 📁 Otevřít │ 🔄 │ 🏷️ │ 📅 🔍 [____] Toolbar
|
||||
├────────────┬────────────────────────────────────────┤
|
||||
│ 📂 Štítky │ ☐ Plná │ Třídění: [Název] [▲] │
|
||||
│ ├─📁 Stav │ ┌──────────────────────────────────┐ │
|
||||
│ │ ☑ Nové │ │ Název│Datum│Štítky│Velikost │ │
|
||||
│ │ ☐ OK │ │file1 │2025 │HD │1.2 MB │ │
|
||||
│ ├─📁 Video│ │file2 │ │4K │15 MB │ │
|
||||
│ │ ☐ HD │ └──────────────────────────────────┘ │
|
||||
│ │ ☐ 4K │ │
|
||||
├────────────┴───────────────────────────────────────┤
|
||||
│ Připraven 3 vybráno │ 125 souborů │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Použít:** `poetry run python Tagger.py`
|
||||
|
||||
**Funkce:**
|
||||
- Tabulka s 4 sloupci (Název, Datum, Štítky, Velikost)
|
||||
- Toolbar s tlačítky
|
||||
- Keyboard shortcuts (Ctrl+O, Ctrl+T, F5, Del...)
|
||||
- Status bar se 3 sekcemi
|
||||
- Hromadné přiřazování tagů
|
||||
- Hardlink menu (Nástroje → Hardlink)
|
||||
|
||||
**Keyboard shortcuts:**
|
||||
- `Ctrl+O` - Otevřít složku
|
||||
- `Ctrl+Q` - Ukončit
|
||||
- `Ctrl+T` - Přiřadit tagy
|
||||
- `Ctrl+D` - Nastavit datum
|
||||
- `Ctrl+F` - Focus search
|
||||
- `F5` - Refresh
|
||||
- `Del` - Smazat z indexu
|
||||
|
||||
---
|
||||
|
||||
## Vývoj
|
||||
|
||||
### Setup prostředí
|
||||
|
||||
```bash
|
||||
# Poetry environment (VŽDY použij poetry!)
|
||||
poetry install
|
||||
poetry shell
|
||||
|
||||
# Nebo přímo:
|
||||
poetry run python Tagger.py
|
||||
```
|
||||
|
||||
### Testy
|
||||
|
||||
```bash
|
||||
# Všechny testy (189 testů)
|
||||
poetry run pytest tests/ -v
|
||||
|
||||
# S coverage
|
||||
poetry run pytest tests/ --cov=src/core --cov-report=html
|
||||
|
||||
# Konkrétní modul
|
||||
poetry run pytest tests/test_hardlink_manager.py -v
|
||||
|
||||
# Quick check
|
||||
poetry run pytest tests/ -q
|
||||
```
|
||||
|
||||
**Test coverage:** 100% core modulů
|
||||
|
||||
---
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Python Style
|
||||
|
||||
- **PEP 8** s výjimkami:
|
||||
- Max line length: **120** (ne 79)
|
||||
- Indentation: **4 mezery** (ne taby)
|
||||
- **UTF-8** encoding všude
|
||||
- **Type hints** povinné
|
||||
- **Docstrings** pro public API
|
||||
|
||||
### Imports Order
|
||||
|
||||
```python
|
||||
# 1. Standard library
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 2. Third-party
|
||||
import tkinter as tk
|
||||
from PIL import Image
|
||||
|
||||
# 3. Local
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow
|
||||
|
||||
### Branches
|
||||
|
||||
```
|
||||
main/master ← Production (NE commity přímo!)
|
||||
↑
|
||||
release ← Release candidate
|
||||
↑
|
||||
devel ← Development integration
|
||||
↑
|
||||
feature/* ← Feature branches ← VYVÍJÍME TADY
|
||||
```
|
||||
|
||||
### Commit Messages
|
||||
|
||||
```
|
||||
<type>: <subject>
|
||||
|
||||
[optional body]
|
||||
|
||||
🤖 Generated with Claude Code
|
||||
Co-Authored-By: Claude Opus 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
|
||||
|
||||
---
|
||||
|
||||
## Plánované features
|
||||
|
||||
- [ ] Progress bar pro dlouhé operace
|
||||
- [ ] Undo/Redo mechanismus
|
||||
- [ ] Export do CSV/Excel
|
||||
- [ ] Dark mode theme
|
||||
- [ ] Drag & drop souborů
|
||||
- [ ] Image preview v sidebar
|
||||
- [ ] SQLite fallback pro >10k souborů
|
||||
- [ ] Full-text search
|
||||
|
||||
### Nice to have
|
||||
|
||||
- [ ] Plugin systém
|
||||
- [ ] Web interface (Flask)
|
||||
- [ ] Cloud sync (Dropbox, GDrive)
|
||||
- [ ] Batch rename podle tagů
|
||||
- [ ] Smart folder suggestions
|
||||
|
||||
---
|
||||
|
||||
## Metriky projektu
|
||||
|
||||
**Testy:** 189 (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.py
|
||||
```
|
||||
|
||||
**2. "Config file not found"**
|
||||
```bash
|
||||
# Normální při prvním spuštění
|
||||
# Vytvoří se automaticky .Tagger.!gtag
|
||||
```
|
||||
|
||||
**3. "Metadata corrupted"**
|
||||
```python
|
||||
# V config.py je graceful degradation
|
||||
# Vrátí default config při chybě
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dokumentace
|
||||
|
||||
**AKTUÁLNÍ:**
|
||||
- **PROJECT_NOTES.md** - TENTO SOUBOR (single source of truth)
|
||||
- **CHANGELOG.md** - Historie verzí
|
||||
- Docstrings v kódu
|
||||
|
||||
---
|
||||
|
||||
## Pro AI asistenty (jako Claude)
|
||||
|
||||
### Když začínám práci na projektu:
|
||||
|
||||
1. **PŘEČTI TENTO SOUBOR CELÝ!**
|
||||
2. Zkontroluj `git status`
|
||||
3. Aktivuj poetry environment
|
||||
4. Spusť testy (`poetry run pytest tests/`)
|
||||
5. Dodržuj pravidla výše
|
||||
|
||||
### Při commitování:
|
||||
|
||||
1. Testy prošly (`pytest tests/`)
|
||||
2. Type hints přidány
|
||||
3. UTF-8 encoding
|
||||
4. Žádné TODO/FIXME
|
||||
5. Commit message formát správný
|
||||
|
||||
### Při přidání nové funkce:
|
||||
|
||||
1. Testy napsány PŘED implementací (TDD)
|
||||
2. Dokumentace aktualizována (TENTO SOUBOR!)
|
||||
3. Architecture decision zdokumentováno (pokud významné)
|
||||
4. Type hints všude
|
||||
5. Error handling přidán
|
||||
|
||||
---
|
||||
|
||||
## Kontakt & Help
|
||||
|
||||
**Autor:** honza
|
||||
**Repository:** /home/honza/Documents/Tagger
|
||||
**Python:** 3.12+
|
||||
**OS:** Linux
|
||||
|
||||
**Pro pomoc:**
|
||||
- Přečti TENTO soubor
|
||||
- Podívej se do testů (`tests/`)
|
||||
- Zkontroluj docstrings v kódu
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** 2025-12-28
|
||||
**Maintainer:** Claude Opus 4.5 + honza
|
||||
176
README.md
176
README.md
@@ -1,2 +1,174 @@
|
||||
install required modules to enviroment:
|
||||
pip install -r requirements.txt
|
||||
# 🏷️ 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)**
|
||||
|
||||
@@ -10,10 +10,9 @@ from pathlib import Path
|
||||
class State():
|
||||
def __init__(self) -> None:
|
||||
self.tagmanager = TagManager()
|
||||
self.filehandler = FileManager(self.tagmanager)
|
||||
self.filehandler = FileManager(self.tagmanager)
|
||||
self.app = App(self.filehandler, self.tagmanager)
|
||||
|
||||
|
||||
|
||||
STATE = State()
|
||||
STATE.app.main()
|
||||
STATE.app.main()
|
||||
|
||||
12
config.json
12
config.json
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"ignore_patterns": [
|
||||
"*.png",
|
||||
"*.jpg",
|
||||
"*.mp3",
|
||||
"*/M/*",
|
||||
"*/L/*",
|
||||
"*/Ostatní/*",
|
||||
"*.hidden*"
|
||||
],
|
||||
"last_folder": "/media/veracrypt3"
|
||||
}
|
||||
BIN
data/HLS/Rozlišení/4K/50.png
Normal file
BIN
data/HLS/Rozlišení/4K/50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
210
data/HLS/Rozlišení/4K/DORMER_PRAMET.PDF
Normal file
210
data/HLS/Rozlišení/4K/DORMER_PRAMET.PDF
Normal file
File diff suppressed because one or more lines are too long
BIN
data/HLS/Rozlišení/FullHD/50.png
Normal file
BIN
data/HLS/Rozlišení/FullHD/50.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
@@ -2,8 +2,10 @@
|
||||
"new": true,
|
||||
"ignored": false,
|
||||
"tags": [
|
||||
"Rozlišení/4K",
|
||||
"Rozlišení/FullHD"
|
||||
"Rozlišení/FullHD",
|
||||
"Barva/🟠 Oranžová",
|
||||
"Barva/🟡 Žlutá",
|
||||
"Hodnocení/⭐⭐⭐⭐"
|
||||
],
|
||||
"date": null
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
"new": true,
|
||||
"ignored": false,
|
||||
"tags": [
|
||||
"Rozlišení/4K"
|
||||
"Rozlišení/4K",
|
||||
"Barva/🟣 Fialová",
|
||||
" Test/aha",
|
||||
"Hodnocení/⭐⭐⭐⭐⭐"
|
||||
],
|
||||
"date": "2025-09-15"
|
||||
}
|
||||
197
poetry.lock
generated
Normal file
197
poetry.lock
generated
Normal file
@@ -0,0 +1,197 @@
|
||||
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
||||
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.0.0"
|
||||
description = "Python Imaging Library (fork)"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e"},
|
||||
{file = "pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a"},
|
||||
{file = "pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905"},
|
||||
{file = "pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b"},
|
||||
{file = "pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b"},
|
||||
{file = "pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a"},
|
||||
{file = "pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7"},
|
||||
{file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8"},
|
||||
{file = "pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a"},
|
||||
{file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197"},
|
||||
{file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c"},
|
||||
{file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e"},
|
||||
{file = "pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76"},
|
||||
{file = "pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5"},
|
||||
{file = "pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
|
||||
fpx = ["olefile"]
|
||||
mic = ["olefile"]
|
||||
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
|
||||
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
|
||||
xmp = ["defusedxml"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
|
||||
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
|
||||
iniconfig = ">=1.0.1"
|
||||
packaging = ">=22"
|
||||
pluggy = ">=1.5,<2"
|
||||
pygments = ">=2.7.2"
|
||||
|
||||
[package.extras]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "d9b2c3a8467631e5de03f3a79ad641da445743ec08afb777b0fa7eef1b046045"
|
||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[tool.poetry]
|
||||
name = "tagger"
|
||||
version = "1.0.3"
|
||||
description = "Universal file tagging utility"
|
||||
authors = ["Jan Doubravský <jan.doubravsky@gmail.com>"]
|
||||
readme = "README.md"
|
||||
package-mode = false
|
||||
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
pillow = "^12.0.0"
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^9.0.2"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pillow
|
||||
@@ -1,22 +1,112 @@
|
||||
"""
|
||||
Configuration management for Tagger
|
||||
|
||||
Three levels of configuration:
|
||||
1. Global config (.Tagger.!gtag next to Tagger.py) - app-wide settings
|
||||
2. Folder config (.Tagger.!ftag in project root) - folder-specific settings
|
||||
3. File tags (.{filename}.!tag) - per-file metadata (handled in file.py)
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_FILE = Path("config.json")
|
||||
# Global config file (next to the main script)
|
||||
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Tagger.!gtag"
|
||||
|
||||
default_config = {
|
||||
"ignore_patterns": [],
|
||||
"last_folder": None
|
||||
# Folder config filename
|
||||
FOLDER_CONFIG_NAME = ".Tagger.!ftag"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GLOBAL CONFIG - Application settings
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_GLOBAL_CONFIG = {
|
||||
"window_geometry": "1200x800",
|
||||
"window_maximized": False,
|
||||
"last_folder": None,
|
||||
"sidebar_width": 250,
|
||||
"recent_folders": [],
|
||||
}
|
||||
|
||||
def load_config():
|
||||
if CONFIG_FILE.exists():
|
||||
|
||||
def load_global_config() -> dict:
|
||||
"""Load global application config"""
|
||||
if GLOBAL_CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
# Merge with defaults for any missing keys
|
||||
for key, value in DEFAULT_GLOBAL_CONFIG.items():
|
||||
if key not in config:
|
||||
config[key] = value
|
||||
return config
|
||||
except Exception:
|
||||
return default_config.copy()
|
||||
return default_config.copy()
|
||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||
|
||||
|
||||
def save_global_config(cfg: dict):
|
||||
"""Save global application config"""
|
||||
with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FOLDER CONFIG - Per-folder settings
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_FOLDER_CONFIG = {
|
||||
"ignore_patterns": [],
|
||||
"custom_tags": {}, # Additional tags specific to this folder
|
||||
"recursive": True, # Whether to scan subfolders
|
||||
"hardlink_output_dir": None, # Output directory for hardlink structure
|
||||
"hardlink_categories": None, # Categories to include in hardlink (None = all)
|
||||
}
|
||||
|
||||
|
||||
def get_folder_config_path(folder: Path) -> Path:
|
||||
"""Get path to folder config file"""
|
||||
return folder / FOLDER_CONFIG_NAME
|
||||
|
||||
|
||||
def load_folder_config(folder: Path) -> dict:
|
||||
"""Load folder-specific config"""
|
||||
config_path = get_folder_config_path(folder)
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
# Merge with defaults for any missing keys
|
||||
for key, value in DEFAULT_FOLDER_CONFIG.items():
|
||||
if key not in config:
|
||||
config[key] = value
|
||||
return config
|
||||
except Exception:
|
||||
return DEFAULT_FOLDER_CONFIG.copy()
|
||||
return DEFAULT_FOLDER_CONFIG.copy()
|
||||
|
||||
|
||||
def save_folder_config(folder: Path, cfg: dict):
|
||||
"""Save folder-specific config"""
|
||||
config_path = get_folder_config_path(folder)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def folder_has_config(folder: Path) -> bool:
|
||||
"""Check if folder has a tagger config"""
|
||||
return get_folder_config_path(folder).exists()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BACKWARDS COMPATIBILITY
|
||||
# =============================================================================
|
||||
|
||||
def load_config():
|
||||
"""Legacy function - returns global config"""
|
||||
return load_global_config()
|
||||
|
||||
|
||||
def save_config(cfg: dict):
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
"""Legacy function - saves global config"""
|
||||
save_global_config(cfg)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# src/core/constants.py
|
||||
VERSION = "v1.0.2"
|
||||
VERSION = "v1.0.3"
|
||||
APP_NAME = "Tagger"
|
||||
APP_VIEWPORT = "1000x700"
|
||||
@@ -4,7 +4,11 @@ from .tag_manager import TagManager
|
||||
from .utils import list_files
|
||||
from typing import Iterable
|
||||
import fnmatch
|
||||
from src.core.config import load_config, save_config
|
||||
from src.core.config import (
|
||||
load_global_config, save_global_config,
|
||||
load_folder_config, save_folder_config
|
||||
)
|
||||
|
||||
|
||||
class FileManager:
|
||||
def __init__(self, tagmanager: TagManager):
|
||||
@@ -12,21 +16,47 @@ class FileManager:
|
||||
self.folders: list[Path] = []
|
||||
self.tagmanager = tagmanager
|
||||
self.on_files_changed = None # callback do GUI
|
||||
self.config = load_config()
|
||||
self.global_config = load_global_config()
|
||||
self.folder_configs: dict[Path, dict] = {} # folder -> config
|
||||
self.current_folder: Path | None = None
|
||||
|
||||
def append(self, folder: Path) -> None:
|
||||
"""Add a folder to scan for files"""
|
||||
self.folders.append(folder)
|
||||
self.config["last_folder"] = str(folder)
|
||||
save_config(self.config)
|
||||
self.current_folder = folder
|
||||
|
||||
# Update global config with last folder
|
||||
self.global_config["last_folder"] = str(folder)
|
||||
|
||||
# Update recent folders list
|
||||
recent = self.global_config.get("recent_folders", [])
|
||||
folder_str = str(folder)
|
||||
if folder_str in recent:
|
||||
recent.remove(folder_str)
|
||||
recent.insert(0, folder_str)
|
||||
self.global_config["recent_folders"] = recent[:10] # Keep max 10
|
||||
|
||||
save_global_config(self.global_config)
|
||||
|
||||
# Load folder-specific config
|
||||
folder_config = load_folder_config(folder)
|
||||
self.folder_configs[folder] = folder_config
|
||||
|
||||
# Get ignore patterns from folder config
|
||||
ignore_patterns = folder_config.get("ignore_patterns", [])
|
||||
|
||||
ignore_patterns = self.config.get("ignore_patterns", [])
|
||||
for each in list_files(folder):
|
||||
if each.name.endswith(".!tag"):
|
||||
# Skip all Tagger metadata files
|
||||
if each.name.endswith(".!tag"): # File tags: .filename.!tag
|
||||
continue
|
||||
if each.name.endswith(".!ftag"): # Folder config: .Tagger.!ftag
|
||||
continue
|
||||
if each.name.endswith(".!gtag"): # Global config: .Tagger.!gtag
|
||||
continue
|
||||
|
||||
full_path = each.as_posix() # celá cesta jako string
|
||||
full_path = each.as_posix()
|
||||
|
||||
# kontrolujeme jméno i celou cestu
|
||||
# Check against ignore patterns
|
||||
if any(
|
||||
fnmatch.fnmatch(each.name, pat) or fnmatch.fnmatch(full_path, pat)
|
||||
for pat in ignore_patterns
|
||||
@@ -36,6 +66,38 @@ class FileManager:
|
||||
file_obj = File(each, self.tagmanager)
|
||||
self.filelist.append(file_obj)
|
||||
|
||||
def get_folder_config(self, folder: Path = None) -> dict:
|
||||
"""Get config for a folder (or current folder if not specified)"""
|
||||
if folder is None:
|
||||
folder = self.current_folder
|
||||
if folder is None:
|
||||
return {}
|
||||
if folder not in self.folder_configs:
|
||||
self.folder_configs[folder] = load_folder_config(folder)
|
||||
return self.folder_configs[folder]
|
||||
|
||||
def save_folder_config(self, folder: Path = None, config: dict = None):
|
||||
"""Save config for a folder"""
|
||||
if folder is None:
|
||||
folder = self.current_folder
|
||||
if folder is None:
|
||||
return
|
||||
if config is None:
|
||||
config = self.folder_configs.get(folder, {})
|
||||
self.folder_configs[folder] = config
|
||||
save_folder_config(folder, config)
|
||||
|
||||
def set_ignore_patterns(self, patterns: list[str], folder: Path = None):
|
||||
"""Set ignore patterns for a folder"""
|
||||
config = self.get_folder_config(folder)
|
||||
config["ignore_patterns"] = patterns
|
||||
self.save_folder_config(folder, config)
|
||||
|
||||
def get_ignore_patterns(self, folder: Path = None) -> list[str]:
|
||||
"""Get ignore patterns for a folder"""
|
||||
config = self.get_folder_config(folder)
|
||||
return config.get("ignore_patterns", [])
|
||||
|
||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||
for f in files_objs:
|
||||
@@ -44,7 +106,6 @@ class FileManager:
|
||||
category, name = tag.split("/", 1)
|
||||
tag_obj = self.tagmanager.add_tag(category, name)
|
||||
else:
|
||||
# pokud není uvedena kategorie, zařadíme pod "default"
|
||||
tag_obj = self.tagmanager.add_tag("default", tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
@@ -60,8 +121,6 @@ class FileManager:
|
||||
if isinstance(tag, str):
|
||||
if "/" in tag:
|
||||
category, name = tag.split("/", 1)
|
||||
tag_obj = File.__module__ # dummy to satisfy typing (we create Tag below)
|
||||
# use Tag class directly
|
||||
from .tag import Tag as TagClass
|
||||
tag_obj = TagClass(category, name)
|
||||
else:
|
||||
@@ -84,7 +143,6 @@ class FileManager:
|
||||
if not tags_list:
|
||||
return self.filelist
|
||||
|
||||
# normalizuj cílové tagy na full_path stringy
|
||||
target_full_paths = set()
|
||||
from .tag import Tag as TagClass
|
||||
for t in tags_list:
|
||||
@@ -93,7 +151,6 @@ class FileManager:
|
||||
elif isinstance(t, str):
|
||||
target_full_paths.add(t)
|
||||
else:
|
||||
# neznámý typ: ignorovat
|
||||
continue
|
||||
|
||||
filtered = []
|
||||
@@ -101,4 +158,10 @@ class FileManager:
|
||||
file_tags = {t.full_path for t in f.tags}
|
||||
if all(tag in file_tags for tag in target_full_paths):
|
||||
filtered.append(f)
|
||||
return filtered
|
||||
return filtered
|
||||
|
||||
# Legacy property for backwards compatibility
|
||||
@property
|
||||
def config(self):
|
||||
"""Legacy: returns global config"""
|
||||
return self.global_config
|
||||
|
||||
352
src/core/hardlink_manager.py
Normal file
352
src/core/hardlink_manager.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Hardlink Manager for Tagger
|
||||
|
||||
Creates directory structure based on file tags and creates hardlinks
|
||||
to organize files without duplicating them on disk.
|
||||
|
||||
Example:
|
||||
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
|
||||
|
||||
output/
|
||||
├── žánr/
|
||||
│ ├── Komedie/
|
||||
│ │ └── film.mkv (hardlink)
|
||||
│ └── Akční/
|
||||
│ └── film.mkv (hardlink)
|
||||
└── rok/
|
||||
└── 1988/
|
||||
└── film.mkv (hardlink)
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
from .file import File
|
||||
|
||||
|
||||
class HardlinkManager:
|
||||
"""Manager for creating hardlink-based directory structures from tagged files."""
|
||||
|
||||
def __init__(self, output_dir: Path):
|
||||
"""
|
||||
Initialize HardlinkManager.
|
||||
|
||||
Args:
|
||||
output_dir: Base directory where the tag-based structure will be created
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.created_links: List[Path] = []
|
||||
self.errors: List[Tuple[Path, str]] = []
|
||||
|
||||
def create_structure_for_files(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
dry_run: bool = False
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Create hardlink structure for given files based on their tags.
|
||||
|
||||
Args:
|
||||
files: List of File objects to process
|
||||
categories: Optional list of categories to include (None = all)
|
||||
dry_run: If True, only simulate without creating actual links
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_links, failed_links)
|
||||
"""
|
||||
self.created_links = []
|
||||
self.errors = []
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for file_obj in files:
|
||||
if not file_obj.tags:
|
||||
continue
|
||||
|
||||
for tag in file_obj.tags:
|
||||
# Skip if category filter is set and this category is not included
|
||||
if categories is not None and tag.category not in categories:
|
||||
continue
|
||||
|
||||
# Create target directory path: output/category/tag_name/
|
||||
target_dir = self.output_dir / tag.category / tag.name
|
||||
target_file = target_dir / file_obj.filename
|
||||
|
||||
try:
|
||||
if not dry_run:
|
||||
# Create directory structure
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Skip if link already exists
|
||||
if target_file.exists():
|
||||
# Check if it's already a hardlink to the same file
|
||||
if self._is_same_file(file_obj.file_path, target_file):
|
||||
continue
|
||||
else:
|
||||
# Different file exists, add suffix
|
||||
target_file = self._get_unique_name(target_file)
|
||||
|
||||
# Create hardlink
|
||||
os.link(file_obj.file_path, target_file)
|
||||
|
||||
self.created_links.append(target_file)
|
||||
success_count += 1
|
||||
|
||||
except OSError as e:
|
||||
self.errors.append((file_obj.file_path, str(e)))
|
||||
fail_count += 1
|
||||
|
||||
return success_count, fail_count
|
||||
|
||||
def _is_same_file(self, path1: Path, path2: Path) -> bool:
|
||||
"""Check if two paths point to the same file (same inode)."""
|
||||
try:
|
||||
return path1.stat().st_ino == path2.stat().st_ino
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _get_unique_name(self, path: Path) -> Path:
|
||||
"""Get a unique filename by adding a numeric suffix."""
|
||||
stem = path.stem
|
||||
suffix = path.suffix
|
||||
parent = path.parent
|
||||
counter = 1
|
||||
|
||||
while True:
|
||||
new_name = f"{stem}_{counter}{suffix}"
|
||||
new_path = parent / new_name
|
||||
if not new_path.exists():
|
||||
return new_path
|
||||
counter += 1
|
||||
|
||||
def remove_created_links(self) -> int:
|
||||
"""
|
||||
Remove all hardlinks created by the last operation.
|
||||
|
||||
Returns:
|
||||
Number of links removed
|
||||
"""
|
||||
removed = 0
|
||||
for link_path in self.created_links:
|
||||
try:
|
||||
if link_path.exists() and link_path.is_file():
|
||||
link_path.unlink()
|
||||
removed += 1
|
||||
|
||||
# Try to remove empty parent directories
|
||||
self._remove_empty_parents(link_path.parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
self.created_links = []
|
||||
return removed
|
||||
|
||||
def _remove_empty_parents(self, path: Path) -> None:
|
||||
"""Remove empty parent directories up to output_dir."""
|
||||
try:
|
||||
while path != self.output_dir and path.is_dir():
|
||||
if any(path.iterdir()):
|
||||
break # Directory not empty
|
||||
path.rmdir()
|
||||
path = path.parent
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def get_preview(self, files: List[File], categories: Optional[List[str]] = None) -> List[Tuple[Path, Path]]:
|
||||
"""
|
||||
Get a preview of what links would be created.
|
||||
|
||||
Args:
|
||||
files: List of File objects
|
||||
categories: Optional list of categories to include
|
||||
|
||||
Returns:
|
||||
List of tuples (source_path, target_path)
|
||||
"""
|
||||
preview = []
|
||||
|
||||
for file_obj in files:
|
||||
if not file_obj.tags:
|
||||
continue
|
||||
|
||||
for tag in file_obj.tags:
|
||||
if categories is not None and tag.category not in categories:
|
||||
continue
|
||||
|
||||
target_dir = self.output_dir / tag.category / tag.name
|
||||
target_file = target_dir / file_obj.filename
|
||||
|
||||
preview.append((file_obj.file_path, target_file))
|
||||
|
||||
return preview
|
||||
|
||||
def find_obsolete_links(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None
|
||||
) -> List[Tuple[Path, Path]]:
|
||||
"""
|
||||
Find hardlinks in the output directory that no longer match file tags.
|
||||
|
||||
Scans the output directory for hardlinks that point to source files,
|
||||
but whose category/tag path no longer matches the file's current tags.
|
||||
|
||||
Args:
|
||||
files: List of File objects (source files)
|
||||
categories: Optional list of categories to check (None = all)
|
||||
|
||||
Returns:
|
||||
List of tuples (link_path, source_path) for obsolete links
|
||||
"""
|
||||
obsolete = []
|
||||
|
||||
if not self.output_dir.exists():
|
||||
return obsolete
|
||||
|
||||
# Build a map of source file inodes to File objects
|
||||
inode_to_file: dict[int, File] = {}
|
||||
for file_obj in files:
|
||||
try:
|
||||
inode = file_obj.file_path.stat().st_ino
|
||||
inode_to_file[inode] = file_obj
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Build expected paths for each file based on current tags
|
||||
expected_paths: dict[int, set[Path]] = {}
|
||||
for file_obj in files:
|
||||
try:
|
||||
inode = file_obj.file_path.stat().st_ino
|
||||
expected_paths[inode] = set()
|
||||
|
||||
for tag in file_obj.tags:
|
||||
if categories is not None and tag.category not in categories:
|
||||
continue
|
||||
target = self.output_dir / tag.category / tag.name / file_obj.filename
|
||||
expected_paths[inode].add(target)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Scan output directory for existing hardlinks
|
||||
for category_dir in self.output_dir.iterdir():
|
||||
if not category_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Filter by categories if specified
|
||||
if categories is not None and category_dir.name not in categories:
|
||||
continue
|
||||
|
||||
for tag_dir in category_dir.iterdir():
|
||||
if not tag_dir.is_dir():
|
||||
continue
|
||||
|
||||
for link_file in tag_dir.iterdir():
|
||||
if not link_file.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
link_inode = link_file.stat().st_ino
|
||||
|
||||
# Check if this inode belongs to one of our source files
|
||||
if link_inode in inode_to_file:
|
||||
source_file = inode_to_file[link_inode]
|
||||
|
||||
# Check if this link path is expected
|
||||
if link_inode in expected_paths:
|
||||
if link_file not in expected_paths[link_inode]:
|
||||
# This link exists but tag was removed
|
||||
obsolete.append((link_file, source_file.file_path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return obsolete
|
||||
|
||||
def remove_obsolete_links(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
dry_run: bool = False
|
||||
) -> Tuple[int, List[Path]]:
|
||||
"""
|
||||
Remove hardlinks that no longer match file tags.
|
||||
|
||||
Args:
|
||||
files: List of File objects
|
||||
categories: Optional list of categories to check
|
||||
dry_run: If True, only return what would be removed
|
||||
|
||||
Returns:
|
||||
Tuple of (removed_count, list_of_removed_paths)
|
||||
"""
|
||||
obsolete = self.find_obsolete_links(files, categories)
|
||||
removed_paths = []
|
||||
|
||||
if dry_run:
|
||||
return len(obsolete), [link for link, _ in obsolete]
|
||||
|
||||
for link_path, _ in obsolete:
|
||||
try:
|
||||
link_path.unlink()
|
||||
removed_paths.append(link_path)
|
||||
|
||||
# Try to remove empty parent directories
|
||||
self._remove_empty_parents(link_path.parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return len(removed_paths), removed_paths
|
||||
|
||||
def sync_structure(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
dry_run: bool = False
|
||||
) -> Tuple[int, int, int, int]:
|
||||
"""
|
||||
Synchronize hardlink structure with current file tags.
|
||||
|
||||
This will:
|
||||
1. Remove hardlinks for removed tags
|
||||
2. Create new hardlinks for new tags
|
||||
|
||||
Args:
|
||||
files: List of File objects
|
||||
categories: Optional list of categories to sync
|
||||
dry_run: If True, only simulate
|
||||
|
||||
Returns:
|
||||
Tuple of (created, create_failed, removed, remove_failed)
|
||||
"""
|
||||
# First find how many obsolete links there are
|
||||
obsolete_count = len(self.find_obsolete_links(files, categories))
|
||||
|
||||
# Remove obsolete links
|
||||
removed, removed_paths = self.remove_obsolete_links(files, categories, dry_run)
|
||||
remove_failed = obsolete_count - removed if not dry_run else 0
|
||||
|
||||
# Then create new links
|
||||
created, create_failed = self.create_structure_for_files(files, categories, dry_run)
|
||||
|
||||
return created, create_failed, removed, remove_failed
|
||||
|
||||
|
||||
def create_hardlink_structure(
|
||||
files: List[File],
|
||||
output_dir: Path,
|
||||
categories: Optional[List[str]] = None
|
||||
) -> Tuple[int, int, List[Tuple[Path, str]]]:
|
||||
"""
|
||||
Convenience function to create hardlink structure.
|
||||
|
||||
Args:
|
||||
files: List of File objects to process
|
||||
output_dir: Base directory for output
|
||||
categories: Optional list of categories to include
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_count, failed_count, errors_list)
|
||||
"""
|
||||
manager = HardlinkManager(output_dir)
|
||||
success, fail = manager.create_structure_for_files(files, categories)
|
||||
return success, fail, manager.errors
|
||||
@@ -1,8 +1,28 @@
|
||||
from .tag import Tag
|
||||
|
||||
# Default tags that are always available (order in list = display order)
|
||||
DEFAULT_TAGS = {
|
||||
"Hodnocení": ["⭐", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"],
|
||||
"Barva": ["🔴 Červená", "🟠 Oranžová", "🟡 Žlutá", "🟢 Zelená", "🔵 Modrá", "🟣 Fialová"],
|
||||
}
|
||||
|
||||
# Tag sort order for default categories (preserves display order)
|
||||
DEFAULT_TAG_ORDER = {
|
||||
"Hodnocení": {name: i for i, name in enumerate(DEFAULT_TAGS["Hodnocení"])},
|
||||
"Barva": {name: i for i, name in enumerate(DEFAULT_TAGS["Barva"])},
|
||||
}
|
||||
|
||||
|
||||
class TagManager:
|
||||
def __init__(self):
|
||||
self.tags_by_category = {} # {category: set(Tag)}
|
||||
self._init_default_tags()
|
||||
|
||||
def _init_default_tags(self):
|
||||
"""Initialize default tags (ratings and colors)"""
|
||||
for category, tags in DEFAULT_TAGS.items():
|
||||
for tag_name in tags:
|
||||
self.add_tag(category, tag_name)
|
||||
|
||||
def add_category(self, category: str):
|
||||
if category not in self.tags_by_category:
|
||||
@@ -32,5 +52,16 @@ class TagManager:
|
||||
def get_categories(self):
|
||||
return list(self.tags_by_category.keys())
|
||||
|
||||
def get_tags_in_category(self, category: str):
|
||||
return list(self.tags_by_category.get(category, []))
|
||||
def get_tags_in_category(self, category: str) -> list[Tag]:
|
||||
"""Get tags in category, sorted by predefined order for default categories"""
|
||||
tags = list(self.tags_by_category.get(category, []))
|
||||
|
||||
# Use predefined order for default categories
|
||||
if category in DEFAULT_TAG_ORDER:
|
||||
order = DEFAULT_TAG_ORDER[category]
|
||||
tags.sort(key=lambda t: order.get(t.name, 999))
|
||||
else:
|
||||
# Sort alphabetically for custom categories
|
||||
tags.sort(key=lambda t: t.name)
|
||||
|
||||
return tags
|
||||
1717
src/ui/gui.py
1717
src/ui/gui.py
File diff suppressed because it is too large
Load Diff
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
28
tests/conftest.py
Normal file
28
tests/conftest.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Konfigurace pytest - sdílené fixtures a nastavení pro všechny testy
|
||||
"""
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session_temp_dir():
|
||||
"""Session-wide dočasný adresář"""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
yield temp_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_config_files():
|
||||
"""Automaticky vyčistí config.json soubory po každém testu"""
|
||||
yield
|
||||
# Cleanup po testu
|
||||
config_file = Path("config.json")
|
||||
if config_file.exists():
|
||||
try:
|
||||
config_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
411
tests/test_config.py
Normal file
411
tests/test_config.py
Normal file
@@ -0,0 +1,411 @@
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from src.core.config import (
|
||||
load_global_config, save_global_config, DEFAULT_GLOBAL_CONFIG,
|
||||
load_folder_config, save_folder_config, DEFAULT_FOLDER_CONFIG,
|
||||
get_folder_config_path, folder_has_config, FOLDER_CONFIG_NAME,
|
||||
load_config, save_config # Legacy functions
|
||||
)
|
||||
|
||||
|
||||
class TestGlobalConfig:
|
||||
"""Testy pro globální config"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný globální config soubor"""
|
||||
config_path = tmp_path / "config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
def test_default_global_config_structure(self):
|
||||
"""Test struktury defaultní globální konfigurace"""
|
||||
assert "window_geometry" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "window_maximized" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "last_folder" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "recent_folders" in DEFAULT_GLOBAL_CONFIG
|
||||
assert DEFAULT_GLOBAL_CONFIG["window_geometry"] == "1200x800"
|
||||
assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False
|
||||
assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None
|
||||
|
||||
def test_load_global_config_nonexistent_file(self, temp_global_config):
|
||||
"""Test načtení globální konfigurace když soubor neexistuje"""
|
||||
config = load_global_config()
|
||||
assert config == DEFAULT_GLOBAL_CONFIG
|
||||
|
||||
def test_save_global_config(self, temp_global_config):
|
||||
"""Test uložení globální konfigurace"""
|
||||
test_config = {
|
||||
"window_geometry": "800x600",
|
||||
"window_maximized": True,
|
||||
"last_folder": "/home/user/documents",
|
||||
"sidebar_width": 300,
|
||||
"recent_folders": ["/path1", "/path2"],
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
|
||||
assert temp_global_config.exists()
|
||||
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||
saved_data = json.load(f)
|
||||
assert saved_data == test_config
|
||||
|
||||
def test_load_global_config_existing_file(self, temp_global_config):
|
||||
"""Test načtení existující globální konfigurace"""
|
||||
test_config = {
|
||||
"window_geometry": "1920x1080",
|
||||
"window_maximized": False,
|
||||
"last_folder": "/test/path",
|
||||
"sidebar_width": 250,
|
||||
"recent_folders": [],
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded_config = load_global_config()
|
||||
|
||||
assert loaded_config == test_config
|
||||
|
||||
def test_load_global_config_merges_defaults(self, temp_global_config):
|
||||
"""Test že chybějící klíče jsou doplněny z defaultů"""
|
||||
partial_config = {"window_geometry": "800x600"}
|
||||
|
||||
with open(temp_global_config, "w", encoding="utf-8") as f:
|
||||
json.dump(partial_config, f)
|
||||
|
||||
loaded = load_global_config()
|
||||
assert loaded["window_geometry"] == "800x600"
|
||||
assert loaded["window_maximized"] == DEFAULT_GLOBAL_CONFIG["window_maximized"]
|
||||
assert loaded["sidebar_width"] == DEFAULT_GLOBAL_CONFIG["sidebar_width"]
|
||||
|
||||
def test_global_config_corrupted_file(self, temp_global_config):
|
||||
"""Test načtení poškozeného global config souboru"""
|
||||
with open(temp_global_config, "w") as f:
|
||||
f.write("{ invalid json }")
|
||||
|
||||
config = load_global_config()
|
||||
assert config == DEFAULT_GLOBAL_CONFIG
|
||||
|
||||
def test_global_config_utf8_encoding(self, temp_global_config):
|
||||
"""Test UTF-8 encoding s českými znaky"""
|
||||
test_config = {
|
||||
**DEFAULT_GLOBAL_CONFIG,
|
||||
"last_folder": "/cesta/s/českými/znaky",
|
||||
"recent_folders": ["/složka/čeština"],
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded_config = load_global_config()
|
||||
|
||||
assert loaded_config["last_folder"] == "/cesta/s/českými/znaky"
|
||||
assert loaded_config["recent_folders"] == ["/složka/čeština"]
|
||||
|
||||
def test_global_config_returns_new_dict(self, temp_global_config):
|
||||
"""Test že load_global_config vrací nový dictionary"""
|
||||
config1 = load_global_config()
|
||||
config2 = load_global_config()
|
||||
|
||||
assert config1 is not config2
|
||||
assert config1 == config2
|
||||
|
||||
def test_global_config_recent_folders(self, temp_global_config):
|
||||
"""Test ukládání recent_folders"""
|
||||
folders = ["/path/one", "/path/two", "/path/three"]
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["recent_folders"] == folders
|
||||
assert len(loaded["recent_folders"]) == 3
|
||||
|
||||
|
||||
class TestFolderConfig:
|
||||
"""Testy pro složkový config"""
|
||||
|
||||
def test_default_folder_config_structure(self):
|
||||
"""Test struktury defaultní složkové konfigurace"""
|
||||
assert "ignore_patterns" in DEFAULT_FOLDER_CONFIG
|
||||
assert "custom_tags" in DEFAULT_FOLDER_CONFIG
|
||||
assert "recursive" in DEFAULT_FOLDER_CONFIG
|
||||
assert isinstance(DEFAULT_FOLDER_CONFIG["ignore_patterns"], list)
|
||||
assert isinstance(DEFAULT_FOLDER_CONFIG["custom_tags"], dict)
|
||||
assert DEFAULT_FOLDER_CONFIG["recursive"] is True
|
||||
|
||||
def test_get_folder_config_path(self, tmp_path):
|
||||
"""Test získání cesty ke složkovému configu"""
|
||||
path = get_folder_config_path(tmp_path)
|
||||
assert path == tmp_path / FOLDER_CONFIG_NAME
|
||||
assert path.name == ".Tagger.!ftag"
|
||||
|
||||
def test_load_folder_config_nonexistent(self, tmp_path):
|
||||
"""Test načtení neexistujícího složkového configu"""
|
||||
config = load_folder_config(tmp_path)
|
||||
assert config == DEFAULT_FOLDER_CONFIG
|
||||
|
||||
def test_save_folder_config(self, tmp_path):
|
||||
"""Test uložení složkového configu"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["*.tmp", "*.log"],
|
||||
"custom_tags": {"Projekt": ["Web", "API"]},
|
||||
"recursive": False,
|
||||
}
|
||||
|
||||
save_folder_config(tmp_path, test_config)
|
||||
|
||||
config_path = get_folder_config_path(tmp_path)
|
||||
assert config_path.exists()
|
||||
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
saved_data = json.load(f)
|
||||
assert saved_data == test_config
|
||||
|
||||
def test_load_folder_config_existing(self, tmp_path):
|
||||
"""Test načtení existujícího složkového configu"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["*.pyc"],
|
||||
"custom_tags": {},
|
||||
"recursive": True,
|
||||
"hardlink_output_dir": None,
|
||||
"hardlink_categories": None,
|
||||
}
|
||||
|
||||
save_folder_config(tmp_path, test_config)
|
||||
loaded = load_folder_config(tmp_path)
|
||||
|
||||
assert loaded == test_config
|
||||
|
||||
def test_load_folder_config_merges_defaults(self, tmp_path):
|
||||
"""Test že chybějící klíče jsou doplněny z defaultů"""
|
||||
partial_config = {"ignore_patterns": ["*.tmp"]}
|
||||
|
||||
config_path = get_folder_config_path(tmp_path)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(partial_config, f)
|
||||
|
||||
loaded = load_folder_config(tmp_path)
|
||||
assert loaded["ignore_patterns"] == ["*.tmp"]
|
||||
assert loaded["custom_tags"] == DEFAULT_FOLDER_CONFIG["custom_tags"]
|
||||
assert loaded["recursive"] == DEFAULT_FOLDER_CONFIG["recursive"]
|
||||
|
||||
def test_folder_has_config_true(self, tmp_path):
|
||||
"""Test folder_has_config když config existuje"""
|
||||
save_folder_config(tmp_path, DEFAULT_FOLDER_CONFIG)
|
||||
assert folder_has_config(tmp_path) is True
|
||||
|
||||
def test_folder_has_config_false(self, tmp_path):
|
||||
"""Test folder_has_config když config neexistuje"""
|
||||
assert folder_has_config(tmp_path) is False
|
||||
|
||||
def test_folder_config_ignore_patterns(self, tmp_path):
|
||||
"""Test ukládání ignore patterns"""
|
||||
patterns = ["*.tmp", "*.log", "*.cache", "*/node_modules/*", "*.pyc"]
|
||||
test_config = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": patterns}
|
||||
|
||||
save_folder_config(tmp_path, test_config)
|
||||
loaded = load_folder_config(tmp_path)
|
||||
|
||||
assert loaded["ignore_patterns"] == patterns
|
||||
assert len(loaded["ignore_patterns"]) == 5
|
||||
|
||||
def test_folder_config_custom_tags(self, tmp_path):
|
||||
"""Test ukládání custom tagů"""
|
||||
custom_tags = {
|
||||
"Projekt": ["Frontend", "Backend", "API"],
|
||||
"Stav": ["Hotovo", "Rozpracováno"],
|
||||
}
|
||||
test_config = {**DEFAULT_FOLDER_CONFIG, "custom_tags": custom_tags}
|
||||
|
||||
save_folder_config(tmp_path, test_config)
|
||||
loaded = load_folder_config(tmp_path)
|
||||
|
||||
assert loaded["custom_tags"] == custom_tags
|
||||
|
||||
def test_folder_config_corrupted_file(self, tmp_path):
|
||||
"""Test načtení poškozeného folder config souboru"""
|
||||
config_path = get_folder_config_path(tmp_path)
|
||||
with open(config_path, "w") as f:
|
||||
f.write("{ invalid json }")
|
||||
|
||||
config = load_folder_config(tmp_path)
|
||||
assert config == DEFAULT_FOLDER_CONFIG
|
||||
|
||||
def test_folder_config_utf8_encoding(self, tmp_path):
|
||||
"""Test UTF-8 v folder configu"""
|
||||
test_config = {
|
||||
"ignore_patterns": ["*.čeština"],
|
||||
"custom_tags": {"Štítky": ["Červená", "Žlutá"]},
|
||||
"recursive": True,
|
||||
}
|
||||
|
||||
save_folder_config(tmp_path, test_config)
|
||||
loaded = load_folder_config(tmp_path)
|
||||
|
||||
assert loaded["ignore_patterns"] == ["*.čeština"]
|
||||
assert loaded["custom_tags"]["Štítky"] == ["Červená", "Žlutá"]
|
||||
|
||||
def test_multiple_folders_independent_configs(self, tmp_path):
|
||||
"""Test že různé složky mají nezávislé configy"""
|
||||
folder1 = tmp_path / "folder1"
|
||||
folder2 = tmp_path / "folder2"
|
||||
folder1.mkdir()
|
||||
folder2.mkdir()
|
||||
|
||||
config1 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.txt"]}
|
||||
config2 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.jpg"]}
|
||||
|
||||
save_folder_config(folder1, config1)
|
||||
save_folder_config(folder2, config2)
|
||||
|
||||
loaded1 = load_folder_config(folder1)
|
||||
loaded2 = load_folder_config(folder2)
|
||||
|
||||
assert loaded1["ignore_patterns"] == ["*.txt"]
|
||||
assert loaded2["ignore_patterns"] == ["*.jpg"]
|
||||
|
||||
|
||||
class TestLegacyFunctions:
|
||||
"""Testy pro zpětnou kompatibilitu"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný globální config soubor"""
|
||||
config_path = tmp_path / "config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
def test_load_config_legacy(self, temp_global_config):
|
||||
"""Test že load_config funguje jako alias pro load_global_config"""
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/test"}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_config()
|
||||
|
||||
assert loaded["last_folder"] == "/test"
|
||||
|
||||
def test_save_config_legacy(self, temp_global_config):
|
||||
"""Test že save_config funguje jako alias pro save_global_config"""
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/legacy"}
|
||||
|
||||
save_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["last_folder"] == "/legacy"
|
||||
|
||||
|
||||
class TestConfigEdgeCases:
|
||||
"""Testy pro edge cases"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný globální config soubor"""
|
||||
config_path = tmp_path / "config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
def test_config_path_with_spaces(self, temp_global_config):
|
||||
"""Test s cestou obsahující mezery"""
|
||||
test_config = {
|
||||
**DEFAULT_GLOBAL_CONFIG,
|
||||
"last_folder": "/path/with spaces/in name"
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["last_folder"] == "/path/with spaces/in name"
|
||||
|
||||
def test_config_long_path(self, temp_global_config):
|
||||
"""Test s dlouhou cestou"""
|
||||
long_path = "/very/long/path/" + "subdir/" * 50 + "final"
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": long_path}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["last_folder"] == long_path
|
||||
|
||||
def test_config_many_recent_folders(self, temp_global_config):
|
||||
"""Test s velkým počtem recent folders"""
|
||||
folders = [f"/path/folder{i}" for i in range(100)]
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert len(loaded["recent_folders"]) == 100
|
||||
|
||||
def test_folder_config_special_characters_in_patterns(self, tmp_path):
|
||||
"""Test se speciálními znaky v patterns"""
|
||||
test_config = {
|
||||
**DEFAULT_FOLDER_CONFIG,
|
||||
"ignore_patterns": ["*.tmp", "file[0-9].txt", "test?.log"]
|
||||
}
|
||||
|
||||
save_folder_config(tmp_path, test_config)
|
||||
loaded = load_folder_config(tmp_path)
|
||||
|
||||
assert loaded["ignore_patterns"] == test_config["ignore_patterns"]
|
||||
|
||||
def test_config_json_formatting(self, temp_global_config):
|
||||
"""Test že config je uložen ve správném JSON formátu s indentací"""
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG}
|
||||
|
||||
save_global_config(test_config)
|
||||
|
||||
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Mělo by být naformátováno s indentací
|
||||
assert " " in content
|
||||
|
||||
def test_config_ensure_ascii_false(self, temp_global_config):
|
||||
"""Test že ensure_ascii=False funguje správně"""
|
||||
test_config = {
|
||||
**DEFAULT_GLOBAL_CONFIG,
|
||||
"last_folder": "/cesta/čeština"
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
|
||||
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
assert "čeština" in content
|
||||
assert "\\u" not in content # Nemělo by být escapováno
|
||||
|
||||
def test_config_overwrite(self, temp_global_config):
|
||||
"""Test přepsání existující konfigurace"""
|
||||
config1 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path1"}
|
||||
config2 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path2"}
|
||||
|
||||
save_global_config(config1)
|
||||
save_global_config(config2)
|
||||
|
||||
loaded = load_global_config()
|
||||
assert loaded["last_folder"] == "/path2"
|
||||
|
||||
def test_folder_config_recursive_false(self, tmp_path):
|
||||
"""Test nastavení recursive na False"""
|
||||
test_config = {**DEFAULT_FOLDER_CONFIG, "recursive": False}
|
||||
|
||||
save_folder_config(tmp_path, test_config)
|
||||
loaded = load_folder_config(tmp_path)
|
||||
|
||||
assert loaded["recursive"] is False
|
||||
|
||||
def test_empty_folder_config(self, tmp_path):
|
||||
"""Test prázdného folder configu"""
|
||||
config_path = get_folder_config_path(tmp_path)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump({}, f)
|
||||
|
||||
loaded = load_folder_config(tmp_path)
|
||||
# Mělo by doplnit všechny defaulty
|
||||
assert loaded["ignore_patterns"] == []
|
||||
assert loaded["custom_tags"] == {}
|
||||
assert loaded["recursive"] is True
|
||||
265
tests/test_file.py
Normal file
265
tests/test_file.py
Normal file
@@ -0,0 +1,265 @@
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
|
||||
class TestFile:
|
||||
"""Testy pro třídu File"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
"""Fixture pro dočasný adresář"""
|
||||
return tmp_path
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
"""Fixture pro TagManager"""
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(self, temp_dir):
|
||||
"""Fixture pro testovací soubor"""
|
||||
test_file = temp_dir / "test.txt"
|
||||
test_file.write_text("test content")
|
||||
return test_file
|
||||
|
||||
def test_file_creation(self, test_file, tag_manager):
|
||||
"""Test vytvoření File objektu"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
assert file_obj.file_path == test_file
|
||||
assert file_obj.filename == "test.txt"
|
||||
assert file_obj.new == True
|
||||
|
||||
def test_file_metadata_filename(self, test_file, tag_manager):
|
||||
"""Test názvu metadata souboru"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
expected = test_file.parent / ".test.txt.!tag"
|
||||
assert file_obj.metadata_filename == expected
|
||||
|
||||
def test_file_initial_tags(self, test_file, tag_manager):
|
||||
"""Test že nový soubor má tag Stav/Nové"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
assert len(file_obj.tags) == 1
|
||||
assert file_obj.tags[0].full_path == "Stav/Nové"
|
||||
|
||||
def test_file_metadata_saved(self, test_file, tag_manager):
|
||||
"""Test že metadata jsou uložena při vytvoření"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
assert file_obj.metadata_filename.exists()
|
||||
|
||||
def test_file_save_metadata(self, test_file, tag_manager):
|
||||
"""Test uložení metadat"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.new = False
|
||||
file_obj.ignored = True
|
||||
file_obj.save_metadata()
|
||||
|
||||
# Načtení a kontrola
|
||||
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data["new"] == False
|
||||
assert data["ignored"] == True
|
||||
|
||||
def test_file_load_metadata(self, test_file, tag_manager):
|
||||
"""Test načtení metadat"""
|
||||
# Vytvoření a uložení metadat
|
||||
file_obj = File(test_file, tag_manager)
|
||||
tag = tag_manager.add_tag("Video", "HD")
|
||||
file_obj.tags.append(tag)
|
||||
file_obj.date = "2025-01-15"
|
||||
file_obj.save_metadata()
|
||||
|
||||
# Vytvoření nového objektu - měl by načíst metadata
|
||||
file_obj2 = File(test_file, tag_manager)
|
||||
assert len(file_obj2.tags) == 2 # Stav/Nové + Video/HD
|
||||
assert file_obj2.date == "2025-01-15"
|
||||
|
||||
# Kontrola že tagy obsahují správné hodnoty
|
||||
tag_paths = {tag.full_path for tag in file_obj2.tags}
|
||||
assert "Video/HD" in tag_paths
|
||||
assert "Stav/Nové" in tag_paths
|
||||
|
||||
def test_file_set_date(self, test_file, tag_manager):
|
||||
"""Test nastavení data"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_date("2025-12-25")
|
||||
assert file_obj.date == "2025-12-25"
|
||||
|
||||
# Kontrola že bylo uloženo
|
||||
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
assert data["date"] == "2025-12-25"
|
||||
|
||||
def test_file_set_date_to_none(self, test_file, tag_manager):
|
||||
"""Test smazání data"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_date("2025-12-25")
|
||||
file_obj.set_date(None)
|
||||
assert file_obj.date is None
|
||||
|
||||
def test_file_set_date_empty_string(self, test_file, tag_manager):
|
||||
"""Test nastavení prázdného řetězce jako datum"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_date("2025-12-25")
|
||||
file_obj.set_date("")
|
||||
assert file_obj.date is None
|
||||
|
||||
def test_file_add_tag_object(self, test_file, tag_manager):
|
||||
"""Test přidání Tag objektu"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
tag = Tag("Video", "4K")
|
||||
file_obj.add_tag(tag)
|
||||
|
||||
assert tag in file_obj.tags
|
||||
assert len(file_obj.tags) == 2 # Stav/Nové + Video/4K
|
||||
|
||||
def test_file_add_tag_string(self, test_file, tag_manager):
|
||||
"""Test přidání tagu jako string"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.add_tag("Audio/MP3")
|
||||
|
||||
tag_paths = {tag.full_path for tag in file_obj.tags}
|
||||
assert "Audio/MP3" in tag_paths
|
||||
|
||||
def test_file_add_tag_string_without_category(self, test_file, tag_manager):
|
||||
"""Test přidání tagu bez kategorie (použije 'default')"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.add_tag("SimpleTag")
|
||||
|
||||
tag_paths = {tag.full_path for tag in file_obj.tags}
|
||||
assert "default/SimpleTag" in tag_paths
|
||||
|
||||
def test_file_add_duplicate_tag(self, test_file, tag_manager):
|
||||
"""Test že duplicitní tag není přidán"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
tag = Tag("Video", "HD")
|
||||
file_obj.add_tag(tag)
|
||||
file_obj.add_tag(tag)
|
||||
|
||||
# Spočítáme kolikrát se tag vyskytuje
|
||||
count = sum(1 for t in file_obj.tags if t == tag)
|
||||
assert count == 1
|
||||
|
||||
def test_file_remove_tag_object(self, test_file, tag_manager):
|
||||
"""Test odstranění Tag objektu"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
tag = Tag("Video", "HD")
|
||||
file_obj.add_tag(tag)
|
||||
file_obj.remove_tag(tag)
|
||||
|
||||
assert tag not in file_obj.tags
|
||||
|
||||
def test_file_remove_tag_string(self, test_file, tag_manager):
|
||||
"""Test odstranění tagu jako string"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.add_tag("Video/HD")
|
||||
file_obj.remove_tag("Video/HD")
|
||||
|
||||
tag_paths = {tag.full_path for tag in file_obj.tags}
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_file_remove_tag_string_without_category(self, test_file, tag_manager):
|
||||
"""Test odstranění tagu bez kategorie"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.add_tag("SimpleTag")
|
||||
file_obj.remove_tag("SimpleTag")
|
||||
|
||||
tag_paths = {tag.full_path for tag in file_obj.tags}
|
||||
assert "default/SimpleTag" not in tag_paths
|
||||
|
||||
def test_file_remove_nonexistent_tag(self, test_file, tag_manager):
|
||||
"""Test odstranění neexistujícího tagu (nemělo by vyhodit výjimku)"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
initial_count = len(file_obj.tags)
|
||||
file_obj.remove_tag("Nonexistent/Tag")
|
||||
assert len(file_obj.tags) == initial_count
|
||||
|
||||
def test_file_without_tagmanager(self, test_file):
|
||||
"""Test File bez TagManager"""
|
||||
file_obj = File(test_file, tagmanager=None)
|
||||
assert file_obj.tagmanager is None
|
||||
assert len(file_obj.tags) == 0 # Bez TagManager se nepřidá Stav/Nové
|
||||
|
||||
def test_file_metadata_persistence(self, test_file, tag_manager):
|
||||
"""Test že metadata přežijí reload"""
|
||||
# Vytvoření a úprava souboru
|
||||
file_obj1 = File(test_file, tag_manager)
|
||||
file_obj1.add_tag("Video/HD")
|
||||
file_obj1.add_tag("Audio/Stereo")
|
||||
file_obj1.set_date("2025-01-01")
|
||||
file_obj1.new = False
|
||||
file_obj1.ignored = True
|
||||
file_obj1.save_metadata()
|
||||
|
||||
# Načtení nového objektu
|
||||
file_obj2 = File(test_file, tag_manager)
|
||||
|
||||
# Kontrola
|
||||
assert file_obj2.new == False
|
||||
assert file_obj2.ignored == True
|
||||
assert file_obj2.date == "2025-01-01"
|
||||
|
||||
tag_paths = {tag.full_path for tag in file_obj2.tags}
|
||||
assert "Video/HD" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
|
||||
def test_file_metadata_json_format(self, test_file, tag_manager):
|
||||
"""Test formátu JSON metadat"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.add_tag("Test/Tag")
|
||||
file_obj.set_date("2025-06-15")
|
||||
|
||||
# Kontrola obsahu JSON
|
||||
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert "new" in data
|
||||
assert "ignored" in data
|
||||
assert "tags" in data
|
||||
assert "date" in data
|
||||
assert isinstance(data["tags"], list)
|
||||
|
||||
def test_file_unicode_handling(self, temp_dir, tag_manager):
|
||||
"""Test správného zacházení s unicode znaky"""
|
||||
test_file = temp_dir / "český_soubor.txt"
|
||||
test_file.write_text("obsah")
|
||||
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.add_tag("Kategorie/Český tag")
|
||||
file_obj.save_metadata()
|
||||
|
||||
# Reload a kontrola
|
||||
file_obj2 = File(test_file, tag_manager)
|
||||
tag_paths = {tag.full_path for tag in file_obj2.tags}
|
||||
assert "Kategorie/Český tag" in tag_paths
|
||||
|
||||
def test_file_complex_scenario(self, test_file, tag_manager):
|
||||
"""Test komplexního scénáře použití"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
|
||||
# Přidání více tagů
|
||||
file_obj.add_tag("Video/HD")
|
||||
file_obj.add_tag("Video/Stereo")
|
||||
file_obj.add_tag("Stav/Zkontrolováno")
|
||||
file_obj.set_date("2025-01-01")
|
||||
|
||||
# Odstranění tagu
|
||||
file_obj.remove_tag("Stav/Nové")
|
||||
|
||||
# Kontrola stavu
|
||||
tag_paths = {tag.full_path for tag in file_obj.tags}
|
||||
assert "Video/HD" in tag_paths
|
||||
assert "Video/Stereo" in tag_paths
|
||||
assert "Stav/Zkontrolováno" in tag_paths
|
||||
assert "Stav/Nové" not in tag_paths
|
||||
assert file_obj.date == "2025-01-01"
|
||||
|
||||
# Reload a kontrola persistence
|
||||
file_obj2 = File(test_file, tag_manager)
|
||||
tag_paths2 = {tag.full_path for tag in file_obj2.tags}
|
||||
assert tag_paths == tag_paths2
|
||||
assert file_obj2.date == "2025-01-01"
|
||||
558
tests/test_file_manager.py
Normal file
558
tests/test_file_manager.py
Normal file
@@ -0,0 +1,558 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
|
||||
|
||||
class TestFileManager:
|
||||
"""Testy pro třídu FileManager"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
"""Fixture pro TagManager"""
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, tag_manager, temp_global_config):
|
||||
"""Fixture pro FileManager"""
|
||||
return FileManager(tag_manager)
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
"""Fixture pro dočasný adresář s testovacími soubory"""
|
||||
# Vytvoření struktury souborů
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.txt").write_text("content2")
|
||||
(tmp_path / "file3.jpg").write_text("image")
|
||||
|
||||
# Podsložka
|
||||
subdir = tmp_path / "subdir"
|
||||
subdir.mkdir()
|
||||
(subdir / "file4.txt").write_text("content4")
|
||||
|
||||
return tmp_path
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný global config soubor"""
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
def test_file_manager_creation(self, file_manager, tag_manager):
|
||||
"""Test vytvoření FileManager"""
|
||||
assert file_manager.filelist == []
|
||||
assert file_manager.folders == []
|
||||
assert file_manager.tagmanager == tag_manager
|
||||
assert file_manager.global_config is not None
|
||||
assert file_manager.folder_configs == {}
|
||||
assert file_manager.current_folder is None
|
||||
|
||||
def test_file_manager_append_folder(self, file_manager, temp_dir):
|
||||
"""Test přidání složky"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
assert temp_dir in file_manager.folders
|
||||
assert len(file_manager.filelist) > 0
|
||||
assert file_manager.current_folder == temp_dir
|
||||
|
||||
def test_file_manager_append_folder_finds_all_files(self, file_manager, temp_dir):
|
||||
"""Test že append najde všechny soubory včetně podsložek"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Měli bychom najít file1.txt, file2.txt, file3.jpg, subdir/file4.txt
|
||||
# (ne .!tag soubory)
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert "file1.txt" in filenames
|
||||
assert "file2.txt" in filenames
|
||||
assert "file3.jpg" in filenames
|
||||
assert "file4.txt" in filenames
|
||||
|
||||
def test_file_manager_ignores_tag_files(self, file_manager, temp_dir):
|
||||
"""Test že .!tag soubory jsou ignorovány"""
|
||||
# Vytvoření .!tag souboru
|
||||
(temp_dir / ".file1.txt.!tag").write_text('{"tags": []}')
|
||||
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert ".file1.txt.!tag" not in filenames
|
||||
|
||||
def test_file_manager_ignores_tagger_config_files(self, file_manager, temp_dir):
|
||||
"""Test že Tagger config soubory jsou ignorovány"""
|
||||
(temp_dir / ".Tagger.!ftag").write_text('{}') # Folder config
|
||||
(temp_dir / ".Tagger.!gtag").write_text('{}') # Global config
|
||||
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert ".Tagger.!ftag" not in filenames
|
||||
assert ".Tagger.!gtag" not in filenames
|
||||
|
||||
def test_file_manager_updates_last_folder(self, file_manager, temp_dir):
|
||||
"""Test aktualizace last_folder v global configu"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
assert file_manager.global_config["last_folder"] == str(temp_dir)
|
||||
|
||||
def test_file_manager_updates_recent_folders(self, file_manager, temp_dir):
|
||||
"""Test aktualizace recent_folders"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
assert str(temp_dir) in file_manager.global_config["recent_folders"]
|
||||
assert file_manager.global_config["recent_folders"][0] == str(temp_dir)
|
||||
|
||||
def test_file_manager_recent_folders_max_10(self, file_manager, tmp_path):
|
||||
"""Test že recent_folders má max 10 položek"""
|
||||
for i in range(15):
|
||||
folder = tmp_path / f"folder{i}"
|
||||
folder.mkdir()
|
||||
(folder / "file.txt").write_text("content")
|
||||
file_manager.append(folder)
|
||||
|
||||
assert len(file_manager.global_config["recent_folders"]) <= 10
|
||||
|
||||
def test_file_manager_loads_folder_config(self, file_manager, temp_dir):
|
||||
"""Test že se načte folder config při append"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
assert temp_dir in file_manager.folder_configs
|
||||
assert "ignore_patterns" in file_manager.folder_configs[temp_dir]
|
||||
|
||||
|
||||
class TestFileManagerIgnorePatterns:
|
||||
"""Testy pro ignore patterns"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, tag_manager, temp_global_config):
|
||||
return FileManager(tag_manager)
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.txt").write_text("content2")
|
||||
(tmp_path / "file3.jpg").write_text("image")
|
||||
subdir = tmp_path / "subdir"
|
||||
subdir.mkdir()
|
||||
(subdir / "file4.txt").write_text("content4")
|
||||
return tmp_path
|
||||
|
||||
def test_ignore_patterns_by_extension(self, file_manager, temp_dir):
|
||||
"""Test ignorování souborů podle přípony"""
|
||||
from src.core.config import save_folder_config
|
||||
save_folder_config(temp_dir, {"ignore_patterns": ["*.jpg"], "custom_tags": {}, "recursive": True})
|
||||
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert "file3.jpg" not in filenames
|
||||
assert "file1.txt" in filenames
|
||||
|
||||
def test_ignore_patterns_path(self, file_manager, temp_dir):
|
||||
"""Test ignorování podle celé cesty"""
|
||||
from src.core.config import save_folder_config
|
||||
save_folder_config(temp_dir, {"ignore_patterns": ["*/subdir/*"], "custom_tags": {}, "recursive": True})
|
||||
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert "file4.txt" not in filenames
|
||||
assert "file1.txt" in filenames
|
||||
|
||||
def test_multiple_ignore_patterns(self, file_manager, temp_dir):
|
||||
"""Test více ignore patternů najednou"""
|
||||
from src.core.config import save_folder_config
|
||||
save_folder_config(temp_dir, {"ignore_patterns": ["*.jpg", "*/subdir/*"], "custom_tags": {}, "recursive": True})
|
||||
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert "file3.jpg" not in filenames
|
||||
assert "file4.txt" not in filenames
|
||||
assert "file1.txt" in filenames
|
||||
assert "file2.txt" in filenames
|
||||
|
||||
def test_set_ignore_patterns(self, file_manager, temp_dir):
|
||||
"""Test nastavení ignore patterns přes metodu"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.set_ignore_patterns(["*.tmp", "*.log"])
|
||||
|
||||
patterns = file_manager.get_ignore_patterns()
|
||||
assert patterns == ["*.tmp", "*.log"]
|
||||
|
||||
def test_get_ignore_patterns_empty(self, file_manager, temp_dir):
|
||||
"""Test získání prázdných ignore patterns"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
patterns = file_manager.get_ignore_patterns()
|
||||
assert patterns == []
|
||||
|
||||
|
||||
class TestFileManagerFolderConfig:
|
||||
"""Testy pro folder config management"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, tag_manager, temp_global_config):
|
||||
return FileManager(tag_manager)
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
(tmp_path / "file1.txt").write_text("content")
|
||||
return tmp_path
|
||||
|
||||
def test_get_folder_config_current(self, file_manager, temp_dir):
|
||||
"""Test získání configu pro aktuální složku"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
config = file_manager.get_folder_config()
|
||||
assert "ignore_patterns" in config
|
||||
|
||||
def test_get_folder_config_specific(self, file_manager, temp_dir, tmp_path):
|
||||
"""Test získání configu pro specifickou složku"""
|
||||
folder2 = tmp_path / "folder2"
|
||||
folder2.mkdir()
|
||||
(folder2 / "file.txt").write_text("content")
|
||||
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.append(folder2)
|
||||
|
||||
config = file_manager.get_folder_config(temp_dir)
|
||||
assert config is not None
|
||||
|
||||
def test_get_folder_config_no_current(self, file_manager):
|
||||
"""Test získání configu když není current folder"""
|
||||
config = file_manager.get_folder_config()
|
||||
assert config == {}
|
||||
|
||||
def test_save_folder_config(self, file_manager, temp_dir):
|
||||
"""Test uložení folder configu"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
new_config = {"ignore_patterns": ["*.test"], "custom_tags": {}, "recursive": False}
|
||||
file_manager.save_folder_config(config=new_config)
|
||||
|
||||
loaded = file_manager.get_folder_config()
|
||||
assert loaded["ignore_patterns"] == ["*.test"]
|
||||
assert loaded["recursive"] is False
|
||||
|
||||
|
||||
class TestFileManagerTagOperations:
|
||||
"""Testy pro operace s tagy"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, tag_manager, temp_global_config):
|
||||
return FileManager(tag_manager)
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.txt").write_text("content2")
|
||||
(tmp_path / "file3.txt").write_text("content3")
|
||||
return tmp_path
|
||||
|
||||
def test_assign_tag_to_file_objects_tag_object(self, file_manager, temp_dir):
|
||||
"""Test přiřazení Tag objektu k souborům"""
|
||||
file_manager.append(temp_dir)
|
||||
files = file_manager.filelist[:2]
|
||||
tag = Tag("Video", "HD")
|
||||
|
||||
file_manager.assign_tag_to_file_objects(files, tag)
|
||||
|
||||
for f in files:
|
||||
assert tag in f.tags
|
||||
|
||||
def test_assign_tag_string_with_category(self, file_manager, temp_dir):
|
||||
"""Test přiřazení tagu jako string s kategorií"""
|
||||
file_manager.append(temp_dir)
|
||||
files = file_manager.filelist[:1]
|
||||
|
||||
file_manager.assign_tag_to_file_objects(files, "Video/4K")
|
||||
|
||||
tag_paths = {tag.full_path for tag in files[0].tags}
|
||||
assert "Video/4K" in tag_paths
|
||||
|
||||
def test_assign_tag_string_without_category(self, file_manager, temp_dir):
|
||||
"""Test přiřazení tagu bez kategorie (default)"""
|
||||
file_manager.append(temp_dir)
|
||||
files = file_manager.filelist[:1]
|
||||
|
||||
file_manager.assign_tag_to_file_objects(files, "SimpleTag")
|
||||
|
||||
tag_paths = {tag.full_path for tag in files[0].tags}
|
||||
assert "default/SimpleTag" in tag_paths
|
||||
|
||||
def test_assign_tag_no_duplicate(self, file_manager, temp_dir):
|
||||
"""Test že tag není přidán dvakrát"""
|
||||
file_manager.append(temp_dir)
|
||||
files = file_manager.filelist[:1]
|
||||
tag = Tag("Video", "HD")
|
||||
|
||||
file_manager.assign_tag_to_file_objects(files, tag)
|
||||
file_manager.assign_tag_to_file_objects(files, tag)
|
||||
|
||||
count = sum(1 for t in files[0].tags if t == tag)
|
||||
assert count == 1
|
||||
|
||||
def test_remove_tag_from_file_objects(self, file_manager, temp_dir):
|
||||
"""Test odstranění tagu ze souborů"""
|
||||
file_manager.append(temp_dir)
|
||||
files = file_manager.filelist[:2]
|
||||
tag = Tag("Video", "HD")
|
||||
|
||||
file_manager.assign_tag_to_file_objects(files, tag)
|
||||
file_manager.remove_tag_from_file_objects(files, tag)
|
||||
|
||||
for f in files:
|
||||
assert tag not in f.tags
|
||||
|
||||
def test_remove_tag_string(self, file_manager, temp_dir):
|
||||
"""Test odstranění tagu jako string"""
|
||||
file_manager.append(temp_dir)
|
||||
files = file_manager.filelist[:1]
|
||||
|
||||
file_manager.assign_tag_to_file_objects(files, "Video/HD")
|
||||
file_manager.remove_tag_from_file_objects(files, "Video/HD")
|
||||
|
||||
tag_paths = {tag.full_path for tag in files[0].tags}
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_callback_on_tag_change(self, file_manager, temp_dir):
|
||||
"""Test callback při změně tagů"""
|
||||
file_manager.append(temp_dir)
|
||||
callback_calls = []
|
||||
|
||||
def callback(filelist):
|
||||
callback_calls.append(len(filelist))
|
||||
|
||||
file_manager.on_files_changed = callback
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], Tag("Test", "Tag"))
|
||||
|
||||
assert len(callback_calls) == 1
|
||||
|
||||
|
||||
class TestFileManagerFiltering:
|
||||
"""Testy pro filtrování souborů"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, tag_manager, temp_global_config):
|
||||
return FileManager(tag_manager)
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.txt").write_text("content2")
|
||||
(tmp_path / "file3.txt").write_text("content3")
|
||||
return tmp_path
|
||||
|
||||
def test_filter_empty_tags_returns_all(self, file_manager, temp_dir):
|
||||
"""Test filtrace bez tagů vrací všechny soubory"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
filtered = file_manager.filter_files_by_tags([])
|
||||
assert len(filtered) == len(file_manager.filelist)
|
||||
|
||||
def test_filter_none_returns_all(self, file_manager, temp_dir):
|
||||
"""Test filtrace s None vrací všechny soubory"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
filtered = file_manager.filter_files_by_tags(None)
|
||||
assert len(filtered) == len(file_manager.filelist)
|
||||
|
||||
def test_filter_by_single_tag(self, file_manager, temp_dir):
|
||||
"""Test filtrace podle jednoho tagu"""
|
||||
file_manager.append(temp_dir)
|
||||
tag = Tag("Video", "HD")
|
||||
files_to_tag = file_manager.filelist[:2]
|
||||
file_manager.assign_tag_to_file_objects(files_to_tag, tag)
|
||||
|
||||
filtered = file_manager.filter_files_by_tags([tag])
|
||||
assert len(filtered) == 2
|
||||
for f in filtered:
|
||||
assert tag in f.tags
|
||||
|
||||
def test_filter_by_multiple_tags_and_logic(self, file_manager, temp_dir):
|
||||
"""Test filtrace podle více tagů (AND logika)"""
|
||||
file_manager.append(temp_dir)
|
||||
tag1 = Tag("Video", "HD")
|
||||
tag2 = Tag("Audio", "Stereo")
|
||||
|
||||
# První soubor má oba tagy
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag1)
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag2)
|
||||
|
||||
# Druhý soubor má jen první tag
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], tag1)
|
||||
|
||||
filtered = file_manager.filter_files_by_tags([tag1, tag2])
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0] == file_manager.filelist[0]
|
||||
|
||||
def test_filter_by_tag_strings(self, file_manager, temp_dir):
|
||||
"""Test filtrace podle tagů jako stringy"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD")
|
||||
|
||||
filtered = file_manager.filter_files_by_tags(["Video/HD"])
|
||||
assert len(filtered) == 1
|
||||
|
||||
def test_filter_no_match(self, file_manager, temp_dir):
|
||||
"""Test filtrace když nic neodpovídá"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
filtered = file_manager.filter_files_by_tags([Tag("NonExistent", "Tag")])
|
||||
assert len(filtered) == 0
|
||||
|
||||
|
||||
class TestFileManagerLegacy:
|
||||
"""Testy pro zpětnou kompatibilitu"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, tag_manager, temp_global_config):
|
||||
return FileManager(tag_manager)
|
||||
|
||||
def test_config_property_returns_global(self, file_manager):
|
||||
"""Test že property config vrací global_config"""
|
||||
assert file_manager.config is file_manager.global_config
|
||||
|
||||
def test_config_property_modifiable(self, file_manager):
|
||||
"""Test že změny přes config property se projeví"""
|
||||
file_manager.config["test_key"] = "test_value"
|
||||
assert file_manager.global_config["test_key"] == "test_value"
|
||||
|
||||
|
||||
class TestFileManagerEdgeCases:
|
||||
"""Testy pro edge cases"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_path = tmp_path / "test_config.json"
|
||||
import src.core.config as config_module
|
||||
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
|
||||
return config_path
|
||||
|
||||
@pytest.fixture
|
||||
def file_manager(self, tag_manager, temp_global_config):
|
||||
return FileManager(tag_manager)
|
||||
|
||||
def test_empty_filelist_operations(self, file_manager):
|
||||
"""Test operací s prázdným filelistem"""
|
||||
filtered = file_manager.filter_files_by_tags([Tag("Video", "HD")])
|
||||
assert filtered == []
|
||||
|
||||
# Přiřazení tagů na prázdný seznam
|
||||
file_manager.assign_tag_to_file_objects([], Tag("Video", "HD"))
|
||||
assert len(file_manager.filelist) == 0
|
||||
|
||||
def test_assign_tag_to_empty_list(self, file_manager):
|
||||
"""Test přiřazení tagu prázdnému seznamu souborů"""
|
||||
file_manager.assign_tag_to_file_objects([], Tag("Test", "Tag"))
|
||||
# Nemělo by vyhodit výjimku
|
||||
|
||||
def test_remove_nonexistent_tag(self, file_manager, tmp_path):
|
||||
"""Test odstranění neexistujícího tagu"""
|
||||
(tmp_path / "file.txt").write_text("content")
|
||||
file_manager.append(tmp_path)
|
||||
|
||||
# Nemělo by vyhodit výjimku
|
||||
file_manager.remove_tag_from_file_objects(file_manager.filelist, Tag("NonExistent", "Tag"))
|
||||
|
||||
def test_multiple_folders(self, file_manager, tmp_path):
|
||||
"""Test práce s více složkami"""
|
||||
folder1 = tmp_path / "folder1"
|
||||
folder2 = tmp_path / "folder2"
|
||||
folder1.mkdir()
|
||||
folder2.mkdir()
|
||||
(folder1 / "file1.txt").write_text("content1")
|
||||
(folder2 / "file2.txt").write_text("content2")
|
||||
|
||||
file_manager.append(folder1)
|
||||
file_manager.append(folder2)
|
||||
|
||||
assert len(file_manager.folders) == 2
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert "file1.txt" in filenames
|
||||
assert "file2.txt" in filenames
|
||||
|
||||
def test_folder_with_special_characters(self, file_manager, tmp_path):
|
||||
"""Test složky se speciálními znaky v názvu"""
|
||||
special_folder = tmp_path / "složka s českou diakritikou"
|
||||
special_folder.mkdir()
|
||||
(special_folder / "soubor.txt").write_text("obsah")
|
||||
|
||||
file_manager.append(special_folder)
|
||||
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert "soubor.txt" in filenames
|
||||
|
||||
def test_file_with_special_characters(self, file_manager, tmp_path):
|
||||
"""Test souboru se speciálními znaky v názvu"""
|
||||
(tmp_path / "soubor s mezerami.txt").write_text("content")
|
||||
(tmp_path / "čeština.txt").write_text("obsah")
|
||||
|
||||
file_manager.append(tmp_path)
|
||||
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert "soubor s mezerami.txt" in filenames
|
||||
assert "čeština.txt" in filenames
|
||||
585
tests/test_hardlink_manager.py
Normal file
585
tests/test_hardlink_manager.py
Normal file
@@ -0,0 +1,585 @@
|
||||
import pytest
|
||||
import os
|
||||
from pathlib import Path
|
||||
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
|
||||
class TestHardlinkManager:
|
||||
"""Testy pro HardlinkManager"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
"""Fixture pro TagManager"""
|
||||
tm = TagManager()
|
||||
# Remove default tags for cleaner tests
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
@pytest.fixture
|
||||
def temp_source_dir(self, tmp_path):
|
||||
"""Fixture pro zdrojovou složku s testovacími soubory"""
|
||||
source_dir = tmp_path / "source"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "file1.txt").write_text("content1")
|
||||
(source_dir / "file2.txt").write_text("content2")
|
||||
(source_dir / "file3.txt").write_text("content3")
|
||||
return source_dir
|
||||
|
||||
@pytest.fixture
|
||||
def temp_output_dir(self, tmp_path):
|
||||
"""Fixture pro výstupní složku"""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
return output_dir
|
||||
|
||||
@pytest.fixture
|
||||
def files_with_tags(self, temp_source_dir, tag_manager):
|
||||
"""Fixture pro soubory s tagy"""
|
||||
files = []
|
||||
|
||||
# File 1 with multiple tags
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear() # Remove default "Stav/Nové" tag
|
||||
f1.add_tag(Tag("žánr", "Komedie"))
|
||||
f1.add_tag(Tag("žánr", "Akční"))
|
||||
f1.add_tag(Tag("rok", "1988"))
|
||||
files.append(f1)
|
||||
|
||||
# File 2 with one tag
|
||||
f2 = File(temp_source_dir / "file2.txt", tag_manager)
|
||||
f2.tags.clear() # Remove default "Stav/Nové" tag
|
||||
f2.add_tag(Tag("žánr", "Drama"))
|
||||
files.append(f2)
|
||||
|
||||
# File 3 with no tags
|
||||
f3 = File(temp_source_dir / "file3.txt", tag_manager)
|
||||
f3.tags.clear() # Remove default "Stav/Nové" tag
|
||||
files.append(f3)
|
||||
|
||||
return files
|
||||
|
||||
def test_hardlink_manager_creation(self, temp_output_dir):
|
||||
"""Test vytvoření HardlinkManager"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
assert manager.output_dir == temp_output_dir
|
||||
assert manager.created_links == []
|
||||
assert manager.errors == []
|
||||
|
||||
def test_create_structure_basic(self, files_with_tags, temp_output_dir):
|
||||
"""Test základního vytvoření struktury"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
# File1 has 3 tags, File2 has 1 tag, File3 has 0 tags
|
||||
# Should create 4 hardlinks total
|
||||
assert success == 4
|
||||
assert fail == 0
|
||||
|
||||
# Check directory structure
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "žánr" / "Akční" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "rok" / "1988" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "žánr" / "Drama" / "file2.txt").exists()
|
||||
|
||||
def test_hardlinks_are_same_inode(self, files_with_tags, temp_output_dir, temp_source_dir):
|
||||
"""Test že vytvořené soubory jsou opravdu hardlinky (stejný inode)"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
original = temp_source_dir / "file1.txt"
|
||||
hardlink = temp_output_dir / "žánr" / "Komedie" / "file1.txt"
|
||||
|
||||
# Same inode = hardlink
|
||||
assert original.stat().st_ino == hardlink.stat().st_ino
|
||||
|
||||
def test_create_structure_with_category_filter(self, files_with_tags, temp_output_dir):
|
||||
"""Test vytvoření struktury jen pro vybrané kategorie"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files(files_with_tags, categories=["žánr"])
|
||||
|
||||
# Only "žánr" tags should be processed (3 links)
|
||||
assert success == 3
|
||||
assert fail == 0
|
||||
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
assert not (temp_output_dir / "rok").exists()
|
||||
|
||||
def test_dry_run(self, files_with_tags, temp_output_dir):
|
||||
"""Test dry run (bez skutečného vytváření)"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files(files_with_tags, dry_run=True)
|
||||
|
||||
assert success == 4
|
||||
assert fail == 0
|
||||
|
||||
# No actual files should be created
|
||||
assert not (temp_output_dir / "žánr").exists()
|
||||
|
||||
def test_get_preview(self, files_with_tags, temp_output_dir):
|
||||
"""Test náhledu co bude vytvořeno"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
preview = manager.get_preview(files_with_tags)
|
||||
|
||||
assert len(preview) == 4
|
||||
|
||||
# Check that preview contains expected paths
|
||||
targets = [p[1] for p in preview]
|
||||
assert temp_output_dir / "žánr" / "Komedie" / "file1.txt" in targets
|
||||
assert temp_output_dir / "žánr" / "Drama" / "file2.txt" in targets
|
||||
|
||||
def test_get_preview_with_category_filter(self, files_with_tags, temp_output_dir):
|
||||
"""Test náhledu s filtrem kategorií"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
preview = manager.get_preview(files_with_tags, categories=["rok"])
|
||||
|
||||
assert len(preview) == 1
|
||||
assert preview[0][1] == temp_output_dir / "rok" / "1988" / "file1.txt"
|
||||
|
||||
def test_remove_created_links(self, files_with_tags, temp_output_dir):
|
||||
"""Test odstranění vytvořených hardlinků"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
# Verify links exist
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
|
||||
# Remove links
|
||||
removed = manager.remove_created_links()
|
||||
assert removed == 4
|
||||
|
||||
# Links should be gone
|
||||
assert not (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
|
||||
# Empty directories should also be removed
|
||||
assert not (temp_output_dir / "žánr" / "Komedie").exists()
|
||||
|
||||
def test_empty_files_list(self, temp_output_dir):
|
||||
"""Test s prázdným seznamem souborů"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([])
|
||||
|
||||
assert success == 0
|
||||
assert fail == 0
|
||||
|
||||
def test_files_without_tags(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||
"""Test se soubory bez tagů"""
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear() # Remove default tags
|
||||
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([f1])
|
||||
|
||||
assert success == 0
|
||||
assert fail == 0
|
||||
|
||||
def test_duplicate_link_same_file(self, files_with_tags, temp_output_dir):
|
||||
"""Test že existující hardlink na stejný soubor je přeskočen"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
|
||||
# Create first time
|
||||
success1, _ = manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
# Create second time - should skip existing
|
||||
manager2 = HardlinkManager(temp_output_dir)
|
||||
success2, fail2 = manager2.create_structure_for_files(files_with_tags)
|
||||
|
||||
# All should be skipped (same inode)
|
||||
assert success2 == 0
|
||||
assert fail2 == 0
|
||||
|
||||
def test_unique_name_on_conflict(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||
"""Test že při konfliktu (jiný soubor) se použije unikátní jméno"""
|
||||
# Create first file
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear()
|
||||
f1.add_tag(Tag("test", "tag"))
|
||||
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files([f1])
|
||||
|
||||
# Create different file with same name in different location
|
||||
source2 = temp_source_dir / "subdir"
|
||||
source2.mkdir()
|
||||
(source2 / "file1.txt").write_text("different content")
|
||||
|
||||
f2 = File(source2 / "file1.txt", tag_manager)
|
||||
f2.tags.clear()
|
||||
f2.add_tag(Tag("test", "tag"))
|
||||
|
||||
# Should create file1_1.txt
|
||||
manager2 = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager2.create_structure_for_files([f2])
|
||||
|
||||
assert success == 1
|
||||
assert (temp_output_dir / "test" / "tag" / "file1_1.txt").exists()
|
||||
|
||||
def test_czech_characters_in_tags(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||
"""Test českých znaků v názvech tagů"""
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear()
|
||||
f1.add_tag(Tag("Žánr", "Česká komedie"))
|
||||
f1.add_tag(Tag("Štítky", "Příběh"))
|
||||
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([f1])
|
||||
|
||||
assert success == 2
|
||||
assert fail == 0
|
||||
assert (temp_output_dir / "Žánr" / "Česká komedie" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "Štítky" / "Příběh" / "file1.txt").exists()
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
"""Testy pro convenience funkci create_hardlink_structure"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
tm = TagManager()
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
@pytest.fixture
|
||||
def temp_files(self, tmp_path, tag_manager):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
return [f]
|
||||
|
||||
def test_create_hardlink_structure_function(self, temp_files, tmp_path):
|
||||
"""Test convenience funkce"""
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
success, fail, errors = create_hardlink_structure(temp_files, output)
|
||||
|
||||
assert success == 1
|
||||
assert fail == 0
|
||||
assert len(errors) == 0
|
||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||
|
||||
def test_create_hardlink_structure_with_categories(self, tmp_path, tag_manager):
|
||||
"""Test convenience funkce s filtrem kategorií"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("include", "yes"))
|
||||
f.add_tag(Tag("exclude", "no"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
success, fail, errors = create_hardlink_structure([f], output, categories=["include"])
|
||||
|
||||
assert success == 1
|
||||
assert (output / "include" / "yes" / "file.txt").exists()
|
||||
assert not (output / "exclude").exists()
|
||||
|
||||
|
||||
class TestSyncStructure:
|
||||
"""Testy pro synchronizaci hardlink struktury"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
tm = TagManager()
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
@pytest.fixture
|
||||
def setup_dirs(self, tmp_path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
return source, output
|
||||
|
||||
def test_find_obsolete_links_empty_output(self, setup_dirs, tag_manager):
|
||||
"""Test find_obsolete_links s prázdným výstupem"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
obsolete = manager.find_obsolete_links([f])
|
||||
|
||||
assert obsolete == []
|
||||
|
||||
def test_find_obsolete_links_detects_removed_tag(self, setup_dirs, tag_manager):
|
||||
"""Test že find_obsolete_links najde hardlink pro odebraný tag"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
f.add_tag(Tag("cat", "tag2"))
|
||||
|
||||
# Create structure with both tags
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
assert (output / "cat" / "tag1" / "file.txt").exists()
|
||||
assert (output / "cat" / "tag2" / "file.txt").exists()
|
||||
|
||||
# Remove one tag from file
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1")) # Only tag1 remains
|
||||
|
||||
# Find obsolete
|
||||
obsolete = manager.find_obsolete_links([f])
|
||||
|
||||
assert len(obsolete) == 1
|
||||
assert obsolete[0][0] == output / "cat" / "tag2" / "file.txt"
|
||||
|
||||
def test_remove_obsolete_links(self, setup_dirs, tag_manager):
|
||||
"""Test odstranění zastaralých hardlinků"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
f.add_tag(Tag("cat", "tag2"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Remove tag2
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
|
||||
# Remove obsolete links
|
||||
removed, paths = manager.remove_obsolete_links([f])
|
||||
|
||||
assert removed == 1
|
||||
assert not (output / "cat" / "tag2" / "file.txt").exists()
|
||||
assert (output / "cat" / "tag1" / "file.txt").exists()
|
||||
|
||||
def test_remove_obsolete_links_dry_run(self, setup_dirs, tag_manager):
|
||||
"""Test dry run pro remove_obsolete_links"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
f.add_tag(Tag("cat", "tag2"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
|
||||
removed, paths = manager.remove_obsolete_links([f], dry_run=True)
|
||||
|
||||
assert removed == 1
|
||||
# File should still exist (dry run)
|
||||
assert (output / "cat" / "tag2" / "file.txt").exists()
|
||||
|
||||
def test_sync_structure_creates_and_removes(self, setup_dirs, tag_manager):
|
||||
"""Test sync_structure vytvoří nové a odstraní staré hardlinky"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "old_tag"))
|
||||
|
||||
# Create initial structure
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
assert (output / "cat" / "old_tag" / "file.txt").exists()
|
||||
|
||||
# Change tags
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "new_tag"))
|
||||
|
||||
# Sync
|
||||
created, c_fail, removed, r_fail = manager.sync_structure([f])
|
||||
|
||||
assert created == 1
|
||||
assert removed == 1
|
||||
assert c_fail == 0
|
||||
assert r_fail == 0
|
||||
assert not (output / "cat" / "old_tag").exists()
|
||||
assert (output / "cat" / "new_tag" / "file.txt").exists()
|
||||
|
||||
def test_sync_structure_no_changes_needed(self, setup_dirs, tag_manager):
|
||||
"""Test sync_structure když není potřeba žádná změna"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Sync again without changes
|
||||
created, c_fail, removed, r_fail = manager.sync_structure([f])
|
||||
|
||||
# Nothing should change (existing links are skipped)
|
||||
assert removed == 0
|
||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||
|
||||
def test_find_obsolete_with_category_filter(self, setup_dirs, tag_manager):
|
||||
"""Test find_obsolete_links s filtrem kategorií"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat1", "tag"))
|
||||
f.add_tag(Tag("cat2", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Remove both tags
|
||||
f.tags.clear()
|
||||
|
||||
# Find obsolete only in cat1
|
||||
obsolete = manager.find_obsolete_links([f], categories=["cat1"])
|
||||
|
||||
assert len(obsolete) == 1
|
||||
assert obsolete[0][0] == output / "cat1" / "tag" / "file.txt"
|
||||
|
||||
def test_removes_empty_directories(self, setup_dirs, tag_manager):
|
||||
"""Test že prázdné adresáře jsou odstraněny po sync"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("category", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Remove all tags
|
||||
f.tags.clear()
|
||||
|
||||
manager.remove_obsolete_links([f])
|
||||
|
||||
# Directory should be gone
|
||||
assert not (output / "category" / "tag").exists()
|
||||
assert not (output / "category").exists()
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Testy pro okrajové případy"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
tm = TagManager()
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
def test_nonexistent_output_dir_created(self, tmp_path, tag_manager):
|
||||
"""Test že výstupní složka je vytvořena pokud neexistuje"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
output = tmp_path / "output" / "nested" / "deep"
|
||||
# output doesn't exist
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
success, fail = manager.create_structure_for_files([f])
|
||||
|
||||
assert success == 1
|
||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||
|
||||
def test_special_characters_in_filename(self, tmp_path, tag_manager):
|
||||
"""Test souboru se speciálními znaky v názvu"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file with spaces (2024).txt").write_text("content")
|
||||
|
||||
f = File(source / "file with spaces (2024).txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("test", "tag"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
success, fail = manager.create_structure_for_files([f])
|
||||
|
||||
assert success == 1
|
||||
assert (output / "test" / "tag" / "file with spaces (2024).txt").exists()
|
||||
|
||||
def test_empty_category_filter(self, tmp_path, tag_manager):
|
||||
"""Test s prázdným seznamem kategorií"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
# Empty list = no categories = no links
|
||||
success, fail = manager.create_structure_for_files([f], categories=[])
|
||||
|
||||
assert success == 0
|
||||
|
||||
def test_is_same_file_method(self, tmp_path):
|
||||
"""Test metody _is_same_file"""
|
||||
file1 = tmp_path / "file1.txt"
|
||||
file1.write_text("content")
|
||||
|
||||
link = tmp_path / "link.txt"
|
||||
os.link(file1, link)
|
||||
|
||||
file2 = tmp_path / "file2.txt"
|
||||
file2.write_text("different")
|
||||
|
||||
manager = HardlinkManager(tmp_path)
|
||||
|
||||
# Same inode
|
||||
assert manager._is_same_file(file1, link) is True
|
||||
|
||||
# Different inode
|
||||
assert manager._is_same_file(file1, file2) is False
|
||||
|
||||
# Non-existent file
|
||||
assert manager._is_same_file(file1, tmp_path / "nonexistent") is False
|
||||
|
||||
def test_get_unique_name_method(self, tmp_path):
|
||||
"""Test metody _get_unique_name"""
|
||||
(tmp_path / "file.txt").write_text("1")
|
||||
(tmp_path / "file_1.txt").write_text("2")
|
||||
(tmp_path / "file_2.txt").write_text("3")
|
||||
|
||||
manager = HardlinkManager(tmp_path)
|
||||
unique = manager._get_unique_name(tmp_path / "file.txt")
|
||||
|
||||
assert unique == tmp_path / "file_3.txt"
|
||||
@@ -1,40 +0,0 @@
|
||||
import sys, os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
# přidáme src do sys.path (pokud nespouštíš pytest s -m nebo PYTHONPATH=src)
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
|
||||
|
||||
from core.image import load_icon
|
||||
from PIL import Image, ImageTk
|
||||
import tkinter as tk
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tk_root():
|
||||
"""Fixture pro inicializaci Tkinteru (nutné pro ImageTk)."""
|
||||
root = tk.Tk()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
|
||||
def test_load_icon_returns_photoimage(tk_root):
|
||||
# vytvoříme dočasný obrázek
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
# vytvoříme 100x100 červený obrázek
|
||||
img = Image.new("RGB", (100, 100), color="red")
|
||||
img.save(tmp_path)
|
||||
|
||||
icon = load_icon(tmp_path)
|
||||
|
||||
# musí být PhotoImage
|
||||
assert isinstance(icon, ImageTk.PhotoImage)
|
||||
|
||||
# ověříme velikost 16x16
|
||||
assert icon.width() == 16
|
||||
assert icon.height() == 16
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
75
tests/test_media_utils.py
Normal file
75
tests/test_media_utils.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from src.core.media_utils import load_icon
|
||||
from PIL import Image, ImageTk
|
||||
import tkinter as tk
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tk_root():
|
||||
"""Fixture pro inicializaci Tkinteru (nutné pro ImageTk)."""
|
||||
root = tk.Tk()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
|
||||
def test_load_icon_returns_photoimage(tk_root):
|
||||
"""Test že load_icon vrací PhotoImage"""
|
||||
# vytvoříme dočasný obrázek
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
# vytvoříme 100x100 červený obrázek
|
||||
img = Image.new("RGB", (100, 100), color="red")
|
||||
img.save(tmp_path)
|
||||
|
||||
icon = load_icon(tmp_path)
|
||||
|
||||
# musí být PhotoImage
|
||||
assert isinstance(icon, ImageTk.PhotoImage)
|
||||
|
||||
# ověříme velikost 16x16
|
||||
assert icon.width() == 16
|
||||
assert icon.height() == 16
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_load_icon_resizes_image(tk_root):
|
||||
"""Test že load_icon správně změní velikost obrázku"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
# vytvoříme velký obrázek 500x500
|
||||
img = Image.new("RGB", (500, 500), color="blue")
|
||||
img.save(tmp_path)
|
||||
|
||||
icon = load_icon(tmp_path)
|
||||
|
||||
# i velký obrázek by měl být zmenšen na 16x16
|
||||
assert icon.width() == 16
|
||||
assert icon.height() == 16
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_load_icon_different_formats(tk_root):
|
||||
"""Test načítání různých formátů obrázků"""
|
||||
formats = [".png", ".jpg", ".bmp"]
|
||||
|
||||
for fmt in formats:
|
||||
with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
img = Image.new("RGB", (32, 32), color="green")
|
||||
img.save(tmp_path)
|
||||
|
||||
icon = load_icon(tmp_path)
|
||||
|
||||
assert isinstance(icon, ImageTk.PhotoImage)
|
||||
assert icon.width() == 16
|
||||
assert icon.height() == 16
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
106
tests/test_tag.py
Normal file
106
tests/test_tag.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import pytest
|
||||
from src.core.tag import Tag
|
||||
|
||||
|
||||
class TestTag:
|
||||
"""Testy pro třídu Tag"""
|
||||
|
||||
def test_tag_creation(self):
|
||||
"""Test vytvoření tagu"""
|
||||
tag = Tag("Kategorie", "Název")
|
||||
assert tag.category == "Kategorie"
|
||||
assert tag.name == "Název"
|
||||
|
||||
def test_tag_full_path(self):
|
||||
"""Test full_path property"""
|
||||
tag = Tag("Video", "HD")
|
||||
assert tag.full_path == "Video/HD"
|
||||
|
||||
def test_tag_str_representation(self):
|
||||
"""Test string reprezentace"""
|
||||
tag = Tag("Foto", "Dovolená")
|
||||
assert str(tag) == "Foto/Dovolená"
|
||||
|
||||
def test_tag_repr(self):
|
||||
"""Test repr reprezentace"""
|
||||
tag = Tag("Audio", "Hudba")
|
||||
assert repr(tag) == "Tag(Audio/Hudba)"
|
||||
|
||||
def test_tag_equality_same_tags(self):
|
||||
"""Test rovnosti stejných tagů"""
|
||||
tag1 = Tag("Kategorie", "Název")
|
||||
tag2 = Tag("Kategorie", "Název")
|
||||
assert tag1 == tag2
|
||||
|
||||
def test_tag_equality_different_tags(self):
|
||||
"""Test nerovnosti různých tagů"""
|
||||
tag1 = Tag("Kategorie1", "Název")
|
||||
tag2 = Tag("Kategorie2", "Název")
|
||||
assert tag1 != tag2
|
||||
|
||||
tag3 = Tag("Kategorie", "Název1")
|
||||
tag4 = Tag("Kategorie", "Název2")
|
||||
assert tag3 != tag4
|
||||
|
||||
def test_tag_equality_with_non_tag(self):
|
||||
"""Test porovnání s ne-Tag objektem"""
|
||||
tag = Tag("Kategorie", "Název")
|
||||
assert tag != "Kategorie/Název"
|
||||
assert tag != 123
|
||||
assert tag != None
|
||||
|
||||
def test_tag_hash(self):
|
||||
"""Test hashování - důležité pro použití v set/dict"""
|
||||
tag1 = Tag("Kategorie", "Název")
|
||||
tag2 = Tag("Kategorie", "Název")
|
||||
tag3 = Tag("Jiná", "Název")
|
||||
|
||||
# Stejné tagy mají stejný hash
|
||||
assert hash(tag1) == hash(tag2)
|
||||
# Různé tagy mají různý hash (většinou)
|
||||
assert hash(tag1) != hash(tag3)
|
||||
|
||||
def test_tag_in_set(self):
|
||||
"""Test použití tagů v set"""
|
||||
tag1 = Tag("Kategorie", "Název")
|
||||
tag2 = Tag("Kategorie", "Název")
|
||||
tag3 = Tag("Jiná", "Název")
|
||||
|
||||
tag_set = {tag1, tag2, tag3}
|
||||
# tag1 a tag2 jsou stejné, takže set obsahuje pouze 2 prvky
|
||||
assert len(tag_set) == 2
|
||||
assert tag1 in tag_set
|
||||
assert tag3 in tag_set
|
||||
|
||||
def test_tag_in_dict(self):
|
||||
"""Test použití tagů jako klíčů v dict"""
|
||||
tag1 = Tag("Kategorie", "Název")
|
||||
tag2 = Tag("Kategorie", "Název")
|
||||
|
||||
tag_dict = {tag1: "hodnota1"}
|
||||
tag_dict[tag2] = "hodnota2"
|
||||
|
||||
# tag1 a tag2 jsou stejné, takže dict má 1 klíč
|
||||
assert len(tag_dict) == 1
|
||||
assert tag_dict[tag1] == "hodnota2"
|
||||
|
||||
def test_tag_with_special_characters(self):
|
||||
"""Test tagů se speciálními znaky"""
|
||||
tag = Tag("Kategorie/Složitá", "Název s mezerami")
|
||||
assert tag.category == "Kategorie/Složitá"
|
||||
assert tag.name == "Název s mezerami"
|
||||
assert tag.full_path == "Kategorie/Složitá/Název s mezerami"
|
||||
|
||||
def test_tag_with_empty_strings(self):
|
||||
"""Test tagů s prázdnými řetězci"""
|
||||
tag = Tag("", "")
|
||||
assert tag.category == ""
|
||||
assert tag.name == ""
|
||||
assert tag.full_path == "/"
|
||||
|
||||
def test_tag_unicode(self):
|
||||
"""Test tagů s unicode znaky"""
|
||||
tag = Tag("Kategorie", "Čeština")
|
||||
assert tag.category == "Kategorie"
|
||||
assert tag.name == "Čeština"
|
||||
assert tag.full_path == "Kategorie/Čeština"
|
||||
327
tests/test_tag_manager.py
Normal file
327
tests/test_tag_manager.py
Normal file
@@ -0,0 +1,327 @@
|
||||
import pytest
|
||||
from src.core.tag_manager import TagManager, DEFAULT_TAGS
|
||||
from src.core.tag import Tag
|
||||
|
||||
|
||||
class TestTagManager:
|
||||
"""Testy pro třídu TagManager"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
"""Fixture pro vytvoření TagManager instance"""
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def empty_tag_manager(self):
|
||||
"""Fixture pro prázdný TagManager (bez default tagů)"""
|
||||
tm = TagManager()
|
||||
# Odstranit default tagy pro testy které potřebují prázdný manager
|
||||
for category in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(category)
|
||||
return tm
|
||||
|
||||
def test_tag_manager_creation_has_defaults(self, tag_manager):
|
||||
"""Test vytvoření TagManager obsahuje default tagy"""
|
||||
assert "Hodnocení" in tag_manager.tags_by_category
|
||||
assert "Barva" in tag_manager.tags_by_category
|
||||
|
||||
def test_tag_manager_default_tags_count(self, tag_manager):
|
||||
"""Test počtu default tagů"""
|
||||
# Hodnocení má 5 hvězdiček
|
||||
assert len(tag_manager.tags_by_category["Hodnocení"]) == 5
|
||||
# Barva má 6 barev
|
||||
assert len(tag_manager.tags_by_category["Barva"]) == 6
|
||||
|
||||
def test_add_category(self, tag_manager):
|
||||
"""Test přidání kategorie"""
|
||||
tag_manager.add_category("Video")
|
||||
assert "Video" in tag_manager.tags_by_category
|
||||
assert tag_manager.tags_by_category["Video"] == set()
|
||||
|
||||
def test_add_category_duplicate(self, empty_tag_manager):
|
||||
"""Test přidání duplicitní kategorie"""
|
||||
empty_tag_manager.add_category("Video")
|
||||
empty_tag_manager.add_category("Video")
|
||||
assert len(empty_tag_manager.tags_by_category) == 1
|
||||
|
||||
def test_remove_category(self, tag_manager):
|
||||
"""Test odstranění kategorie"""
|
||||
tag_manager.add_category("Video")
|
||||
tag_manager.remove_category("Video")
|
||||
assert "Video" not in tag_manager.tags_by_category
|
||||
|
||||
def test_remove_nonexistent_category(self, tag_manager):
|
||||
"""Test odstranění neexistující kategorie"""
|
||||
# Nemělo by vyhodit výjimku
|
||||
tag_manager.remove_category("Neexistující")
|
||||
assert "Neexistující" not in tag_manager.tags_by_category
|
||||
|
||||
def test_add_tag(self, tag_manager):
|
||||
"""Test přidání tagu"""
|
||||
tag = tag_manager.add_tag("Video", "HD")
|
||||
assert isinstance(tag, Tag)
|
||||
assert tag.category == "Video"
|
||||
assert tag.name == "HD"
|
||||
assert "Video" in tag_manager.tags_by_category
|
||||
assert tag in tag_manager.tags_by_category["Video"]
|
||||
|
||||
def test_add_tag_creates_category(self, tag_manager):
|
||||
"""Test že add_tag vytvoří kategorii pokud neexistuje"""
|
||||
tag = tag_manager.add_tag("NovaKategorie", "Tag")
|
||||
assert "NovaKategorie" in tag_manager.tags_by_category
|
||||
|
||||
def test_add_multiple_tags_same_category(self, tag_manager):
|
||||
"""Test přidání více tagů do stejné kategorie"""
|
||||
tag1 = tag_manager.add_tag("Video", "HD")
|
||||
tag2 = tag_manager.add_tag("Video", "4K")
|
||||
tag3 = tag_manager.add_tag("Video", "SD")
|
||||
|
||||
assert len(tag_manager.tags_by_category["Video"]) == 3
|
||||
assert tag1 in tag_manager.tags_by_category["Video"]
|
||||
assert tag2 in tag_manager.tags_by_category["Video"]
|
||||
assert tag3 in tag_manager.tags_by_category["Video"]
|
||||
|
||||
def test_add_duplicate_tag(self, tag_manager):
|
||||
"""Test přidání duplicitního tagu (set zabrání duplicitám)"""
|
||||
tag1 = tag_manager.add_tag("Video", "HD")
|
||||
tag2 = tag_manager.add_tag("Video", "HD")
|
||||
|
||||
assert len(tag_manager.tags_by_category["Video"]) == 1
|
||||
assert tag1 == tag2
|
||||
|
||||
def test_remove_tag(self, tag_manager):
|
||||
"""Test odstranění tagu - když je poslední, kategorie se smaže"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.remove_tag("Video", "HD")
|
||||
|
||||
# Kategorie by měla být smazána (podle implementace v tag_manager.py)
|
||||
assert "Video" not in tag_manager.tags_by_category
|
||||
|
||||
def test_remove_tag_removes_empty_category(self, tag_manager):
|
||||
"""Test že odstranění posledního tagu odstraní i kategorii"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.remove_tag("Video", "HD")
|
||||
|
||||
assert "Video" not in tag_manager.tags_by_category
|
||||
|
||||
def test_remove_tag_keeps_category_with_other_tags(self, tag_manager):
|
||||
"""Test že odstranění tagu neodstraní kategorii s dalšími tagy"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "4K")
|
||||
tag_manager.remove_tag("Video", "HD")
|
||||
|
||||
assert "Video" in tag_manager.tags_by_category
|
||||
assert len(tag_manager.tags_by_category["Video"]) == 1
|
||||
|
||||
def test_remove_nonexistent_tag(self, tag_manager):
|
||||
"""Test odstranění neexistujícího tagu"""
|
||||
tag_manager.add_category("Video")
|
||||
# Nemělo by vyhodit výjimku
|
||||
tag_manager.remove_tag("Video", "Neexistující")
|
||||
|
||||
def test_remove_tag_from_nonexistent_category(self, tag_manager):
|
||||
"""Test odstranění tagu z neexistující kategorie"""
|
||||
# Nemělo by vyhodit výjimku
|
||||
tag_manager.remove_tag("Neexistující", "Tag")
|
||||
|
||||
def test_get_all_tags_empty(self, empty_tag_manager):
|
||||
"""Test získání všech tagů (prázdný manager)"""
|
||||
tags = empty_tag_manager.get_all_tags()
|
||||
assert tags == []
|
||||
|
||||
def test_get_all_tags(self, empty_tag_manager):
|
||||
"""Test získání všech tagů"""
|
||||
empty_tag_manager.add_tag("Video", "HD")
|
||||
empty_tag_manager.add_tag("Video", "4K")
|
||||
empty_tag_manager.add_tag("Audio", "MP3")
|
||||
|
||||
tags = empty_tag_manager.get_all_tags()
|
||||
assert len(tags) == 3
|
||||
assert "Video/HD" in tags
|
||||
assert "Video/4K" in tags
|
||||
assert "Audio/MP3" in tags
|
||||
|
||||
def test_get_all_tags_includes_defaults(self, tag_manager):
|
||||
"""Test že get_all_tags obsahuje default tagy"""
|
||||
tags = tag_manager.get_all_tags()
|
||||
# Minimálně 11 default tagů (5 hodnocení + 6 barev)
|
||||
assert len(tags) >= 11
|
||||
|
||||
def test_get_categories_empty(self, empty_tag_manager):
|
||||
"""Test získání kategorií (prázdný manager)"""
|
||||
categories = empty_tag_manager.get_categories()
|
||||
assert categories == []
|
||||
|
||||
def test_get_categories(self, empty_tag_manager):
|
||||
"""Test získání kategorií"""
|
||||
empty_tag_manager.add_tag("Video", "HD")
|
||||
empty_tag_manager.add_tag("Audio", "MP3")
|
||||
empty_tag_manager.add_tag("Foto", "RAW")
|
||||
|
||||
categories = empty_tag_manager.get_categories()
|
||||
assert len(categories) == 3
|
||||
assert "Video" in categories
|
||||
assert "Audio" in categories
|
||||
assert "Foto" in categories
|
||||
|
||||
def test_get_categories_includes_defaults(self, tag_manager):
|
||||
"""Test že get_categories obsahuje default kategorie"""
|
||||
categories = tag_manager.get_categories()
|
||||
assert "Hodnocení" in categories
|
||||
assert "Barva" in categories
|
||||
|
||||
def test_get_tags_in_category_empty(self, tag_manager):
|
||||
"""Test získání tagů z prázdné kategorie"""
|
||||
tag_manager.add_category("Video")
|
||||
tags = tag_manager.get_tags_in_category("Video")
|
||||
assert tags == []
|
||||
|
||||
def test_get_tags_in_category(self, tag_manager):
|
||||
"""Test získání tagů z kategorie"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "4K")
|
||||
tag_manager.add_tag("Audio", "MP3")
|
||||
|
||||
video_tags = tag_manager.get_tags_in_category("Video")
|
||||
assert len(video_tags) == 2
|
||||
|
||||
# Kontrola že obsahují správné tagy (pořadí není garantováno)
|
||||
tag_names = {tag.name for tag in video_tags}
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
|
||||
def test_get_tags_in_nonexistent_category(self, tag_manager):
|
||||
"""Test získání tagů z neexistující kategorie"""
|
||||
tags = tag_manager.get_tags_in_category("Neexistující")
|
||||
assert tags == []
|
||||
|
||||
def test_complex_scenario(self, empty_tag_manager):
|
||||
"""Test komplexního scénáře použití"""
|
||||
tm = empty_tag_manager
|
||||
|
||||
# Přidání několika kategorií a tagů
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Audio", "MP3")
|
||||
tm.add_tag("Audio", "FLAC")
|
||||
tm.add_tag("Foto", "RAW")
|
||||
|
||||
# Kontrola stavu
|
||||
assert len(tm.get_categories()) == 3
|
||||
assert len(tm.get_all_tags()) == 5
|
||||
|
||||
# Odstranění některých tagů
|
||||
tm.remove_tag("Video", "HD")
|
||||
assert len(tm.get_tags_in_category("Video")) == 1
|
||||
|
||||
# Odstranění celé kategorie
|
||||
tm.remove_category("Foto")
|
||||
assert "Foto" not in tm.get_categories()
|
||||
assert len(tm.get_all_tags()) == 3
|
||||
|
||||
def test_tag_uniqueness_in_set(self, tag_manager):
|
||||
"""Test že tagy jsou správně ukládány jako set (bez duplicit)"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
|
||||
# I když přidáme 3x, v setu je jen 1
|
||||
assert len(tag_manager.tags_by_category["Video"]) == 1
|
||||
|
||||
|
||||
class TestDefaultTags:
|
||||
"""Testy pro defaultní tagy"""
|
||||
|
||||
def test_default_tags_constant_exists(self):
|
||||
"""Test že DEFAULT_TAGS konstanta existuje"""
|
||||
assert DEFAULT_TAGS is not None
|
||||
assert isinstance(DEFAULT_TAGS, dict)
|
||||
|
||||
def test_default_tags_has_hodnoceni(self):
|
||||
"""Test že DEFAULT_TAGS obsahuje Hodnocení"""
|
||||
assert "Hodnocení" in DEFAULT_TAGS
|
||||
assert len(DEFAULT_TAGS["Hodnocení"]) == 5
|
||||
|
||||
def test_default_tags_has_barva(self):
|
||||
"""Test že DEFAULT_TAGS obsahuje Barva"""
|
||||
assert "Barva" in DEFAULT_TAGS
|
||||
assert len(DEFAULT_TAGS["Barva"]) == 6
|
||||
|
||||
def test_hodnoceni_stars_content(self):
|
||||
"""Test obsahu hvězdiček v Hodnocení"""
|
||||
stars = DEFAULT_TAGS["Hodnocení"]
|
||||
assert "⭐" in stars
|
||||
assert "⭐⭐⭐⭐⭐" in stars
|
||||
|
||||
def test_barva_colors_content(self):
|
||||
"""Test obsahu barev v Barva"""
|
||||
colors = DEFAULT_TAGS["Barva"]
|
||||
# Kontrolujeme že obsahuje některé barvy
|
||||
color_names = " ".join(colors)
|
||||
assert "Červená" in color_names
|
||||
assert "Zelená" in color_names
|
||||
assert "Modrá" in color_names
|
||||
|
||||
def test_tag_manager_loads_all_default_tags(self):
|
||||
"""Test že TagManager načte všechny default tagy"""
|
||||
tm = TagManager()
|
||||
|
||||
for category, tag_names in DEFAULT_TAGS.items():
|
||||
assert category in tm.tags_by_category
|
||||
tags_in_category = tm.get_tags_in_category(category)
|
||||
assert len(tags_in_category) == len(tag_names)
|
||||
|
||||
def test_can_add_custom_tags_alongside_defaults(self):
|
||||
"""Test že lze přidat vlastní tagy vedle defaultních"""
|
||||
tm = TagManager()
|
||||
initial_count = len(tm.get_all_tags())
|
||||
|
||||
tm.add_tag("Custom", "MyTag")
|
||||
|
||||
assert len(tm.get_all_tags()) == initial_count + 1
|
||||
assert "Custom" in tm.get_categories()
|
||||
|
||||
def test_can_remove_default_category(self):
|
||||
"""Test že lze odstranit default kategorii"""
|
||||
tm = TagManager()
|
||||
tm.remove_category("Hodnocení")
|
||||
|
||||
assert "Hodnocení" not in tm.tags_by_category
|
||||
|
||||
def test_hodnoceni_tags_are_sorted_by_stars(self):
|
||||
"""Test že tagy v Hodnocení jsou seřazeny od 1 do 5 hvězd"""
|
||||
tm = TagManager()
|
||||
tags = tm.get_tags_in_category("Hodnocení")
|
||||
|
||||
tag_names = [t.name for t in tags]
|
||||
assert tag_names == ["⭐", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"]
|
||||
|
||||
def test_barva_tags_are_sorted_in_predefined_order(self):
|
||||
"""Test že tagy v Barva jsou seřazeny v předdefinovaném pořadí"""
|
||||
tm = TagManager()
|
||||
tags = tm.get_tags_in_category("Barva")
|
||||
|
||||
tag_names = [t.name for t in tags]
|
||||
expected = ["🔴 Červená", "🟠 Oranžová", "🟡 Žlutá", "🟢 Zelená", "🔵 Modrá", "🟣 Fialová"]
|
||||
assert tag_names == expected
|
||||
|
||||
def test_custom_category_tags_sorted_alphabetically(self):
|
||||
"""Test že tagy v custom kategorii jsou seřazeny abecedně"""
|
||||
tm = TagManager()
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Video", "SD")
|
||||
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
|
||||
assert tag_names == ["4K", "HD", "SD"]
|
||||
|
||||
def test_can_add_tag_to_default_category(self):
|
||||
"""Test že lze přidat tag do default kategorie"""
|
||||
tm = TagManager()
|
||||
initial_count = len(tm.get_tags_in_category("Hodnocení"))
|
||||
|
||||
tm.add_tag("Hodnocení", "Custom Rating")
|
||||
|
||||
assert len(tm.get_tags_in_category("Hodnocení")) == initial_count + 1
|
||||
178
tests/test_utils.py
Normal file
178
tests/test_utils.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from src.core.utils import list_files
|
||||
|
||||
|
||||
class TestUtils:
|
||||
"""Testy pro utils funkce"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
"""Fixture pro dočasný adresář s testovací strukturou"""
|
||||
# Vytvoření souborů v root
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.jpg").write_text("image")
|
||||
|
||||
# Podsložka
|
||||
subdir1 = tmp_path / "subdir1"
|
||||
subdir1.mkdir()
|
||||
(subdir1 / "file3.txt").write_text("content3")
|
||||
(subdir1 / "file4.png").write_text("image2")
|
||||
|
||||
# Vnořená podsložka
|
||||
subdir2 = subdir1 / "subdir2"
|
||||
subdir2.mkdir()
|
||||
(subdir2 / "file5.txt").write_text("content5")
|
||||
|
||||
# Prázdná složka
|
||||
empty_dir = tmp_path / "empty"
|
||||
empty_dir.mkdir()
|
||||
|
||||
return tmp_path
|
||||
|
||||
def test_list_files_basic(self, temp_dir):
|
||||
"""Test základního listování souborů"""
|
||||
files = list_files(temp_dir)
|
||||
assert isinstance(files, list)
|
||||
assert len(files) > 0
|
||||
assert all(isinstance(f, Path) for f in files)
|
||||
|
||||
def test_list_files_finds_all_files(self, temp_dir):
|
||||
"""Test že najde všechny soubory včetně vnořených"""
|
||||
files = list_files(temp_dir)
|
||||
filenames = {f.name for f in files}
|
||||
|
||||
assert "file1.txt" in filenames
|
||||
assert "file2.jpg" in filenames
|
||||
assert "file3.txt" in filenames
|
||||
assert "file4.png" in filenames
|
||||
assert "file5.txt" in filenames
|
||||
assert len(filenames) == 5
|
||||
|
||||
def test_list_files_recursive(self, temp_dir):
|
||||
"""Test rekurzivního procházení složek"""
|
||||
files = list_files(temp_dir)
|
||||
|
||||
# Kontrola cest - měly by obsahovat subdir1 a subdir2
|
||||
file_paths = [str(f) for f in files]
|
||||
assert any("subdir1" in path for path in file_paths)
|
||||
assert any("subdir2" in path for path in file_paths)
|
||||
|
||||
def test_list_files_only_files_no_directories(self, temp_dir):
|
||||
"""Test že vrací pouze soubory, ne složky"""
|
||||
files = list_files(temp_dir)
|
||||
|
||||
# Všechny výsledky by měly být soubory
|
||||
assert all(f.is_file() for f in files)
|
||||
|
||||
# Složky by neměly být ve výsledcích
|
||||
filenames = {f.name for f in files}
|
||||
assert "subdir1" not in filenames
|
||||
assert "subdir2" not in filenames
|
||||
assert "empty" not in filenames
|
||||
|
||||
def test_list_files_with_string_path(self, temp_dir):
|
||||
"""Test s cestou jako string"""
|
||||
files = list_files(str(temp_dir))
|
||||
assert len(files) == 5
|
||||
|
||||
def test_list_files_with_path_object(self, temp_dir):
|
||||
"""Test s cestou jako Path objekt"""
|
||||
files = list_files(temp_dir)
|
||||
assert len(files) == 5
|
||||
|
||||
def test_list_files_empty_directory(self, temp_dir):
|
||||
"""Test prázdné složky"""
|
||||
empty_dir = temp_dir / "empty"
|
||||
files = list_files(empty_dir)
|
||||
assert files == []
|
||||
|
||||
def test_list_files_nonexistent_directory(self):
|
||||
"""Test neexistující složky"""
|
||||
with pytest.raises(NotADirectoryError) as exc_info:
|
||||
list_files("/nonexistent/path")
|
||||
assert "není platná složka" in str(exc_info.value)
|
||||
|
||||
def test_list_files_file_not_directory(self, temp_dir):
|
||||
"""Test když je zadán soubor místo složky"""
|
||||
file_path = temp_dir / "file1.txt"
|
||||
with pytest.raises(NotADirectoryError) as exc_info:
|
||||
list_files(file_path)
|
||||
assert "není platná složka" in str(exc_info.value)
|
||||
|
||||
def test_list_files_returns_absolute_paths(self, temp_dir):
|
||||
"""Test že vrací absolutní cesty"""
|
||||
files = list_files(temp_dir)
|
||||
assert all(f.is_absolute() for f in files)
|
||||
|
||||
def test_list_files_different_extensions(self, temp_dir):
|
||||
"""Test s různými příponami"""
|
||||
files = list_files(temp_dir)
|
||||
extensions = {f.suffix for f in files}
|
||||
|
||||
assert ".txt" in extensions
|
||||
assert ".jpg" in extensions
|
||||
assert ".png" in extensions
|
||||
|
||||
def test_list_files_hidden_files(self, temp_dir):
|
||||
"""Test se skrytými soubory (začínající tečkou)"""
|
||||
# Vytvoření skrytého souboru
|
||||
(temp_dir / ".hidden").write_text("hidden content")
|
||||
|
||||
files = list_files(temp_dir)
|
||||
filenames = {f.name for f in files}
|
||||
|
||||
# Skryté soubory by měly být také nalezeny
|
||||
assert ".hidden" in filenames
|
||||
|
||||
def test_list_files_special_characters_in_names(self, temp_dir):
|
||||
"""Test se speciálními znaky v názvech"""
|
||||
# Vytvoření souborů se spec. znaky
|
||||
(temp_dir / "soubor s mezerami.txt").write_text("content")
|
||||
(temp_dir / "český_název.txt").write_text("content")
|
||||
|
||||
files = list_files(temp_dir)
|
||||
filenames = {f.name for f in files}
|
||||
|
||||
assert "soubor s mezerami.txt" in filenames
|
||||
assert "český_název.txt" in filenames
|
||||
|
||||
def test_list_files_symlinks(self, temp_dir):
|
||||
"""Test se symbolickými linky (pokud OS podporuje)"""
|
||||
try:
|
||||
# Vytvoření symlinku
|
||||
target = temp_dir / "file1.txt"
|
||||
link = temp_dir / "link_to_file1.txt"
|
||||
link.symlink_to(target)
|
||||
|
||||
files = list_files(temp_dir)
|
||||
# Symlink by měl být také nalezen a považován za soubor
|
||||
filenames = {f.name for f in files}
|
||||
assert "link_to_file1.txt" in filenames or "file1.txt" in filenames
|
||||
except OSError:
|
||||
# Pokud OS nepodporuje symlinky, přeskočíme
|
||||
pytest.skip("OS does not support symlinks")
|
||||
|
||||
def test_list_files_large_directory_structure(self, tmp_path):
|
||||
"""Test s větší strukturou složek"""
|
||||
# Vytvoření více vnořených úrovní
|
||||
for i in range(3):
|
||||
level_dir = tmp_path / f"level{i}"
|
||||
level_dir.mkdir()
|
||||
for j in range(5):
|
||||
(level_dir / f"file_{i}_{j}.txt").write_text(f"content {i} {j}")
|
||||
|
||||
files = list_files(tmp_path)
|
||||
# Měli bychom najít 3 * 5 = 15 souborů
|
||||
assert len(files) == 15
|
||||
|
||||
def test_list_files_preserves_path_structure(self, temp_dir):
|
||||
"""Test že zachovává strukturu cest"""
|
||||
files = list_files(temp_dir)
|
||||
|
||||
# Najdeme soubor v subdir2
|
||||
file5 = [f for f in files if f.name == "file5.txt"][0]
|
||||
|
||||
# Cesta by měla obsahovat obě složky
|
||||
assert "subdir1" in str(file5)
|
||||
assert "subdir2" in str(file5)
|
||||
Reference in New Issue
Block a user