Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db280fb5c2 | |||
| 2bcd5b1f4b | |||
| 47b39aadfe | |||
| 028c6606e0 | |||
| ad4a785fa2 | |||
| 86c4bf61c8 | |||
| 7e02e57397 | |||
| 5cdf98bdfe | |||
| d60c7e2e2f | |||
| aab50864c3 | |||
| fe529ecfdd | |||
| 05ca250872 | |||
| 9882bede8c | |||
| 9f107a2950 | |||
| 6a49d78f48 | |||
| 1b3181b559 | |||
| d3137f5e88 |
52
.gitignore
vendored
52
.gitignore
vendored
@@ -1,4 +1,48 @@
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
build
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Distribution / packaging
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
*.spec.bak
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/settings.json
|
||||
.idea/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
.claudeignore
|
||||
|
||||
# App temp / tag soubory
|
||||
*.!tag
|
||||
*.!ftag
|
||||
*.!gtag
|
||||
|
||||
# Data samples (binary/media, not source)
|
||||
data/samples/
|
||||
|
||||
# Documentation not for commit
|
||||
DESIGN_DOCUMENT.md
|
||||
AGENTS.md
|
||||
TEMPLATE.md
|
||||
CLAUDE.md
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -6,5 +6,7 @@
|
||||
"tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python-envs.defaultEnvManager": "ms-python.python:poetry",
|
||||
"python-envs.defaultPackageManager": "ms-python.python:poetry"
|
||||
}
|
||||
173
CHANGELOG.md
Normal file
173
CHANGELOG.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Tagger project are documented in this file.
|
||||
|
||||
## [1.2.0] - 2026-04-09
|
||||
|
||||
### Added
|
||||
- **Undo/redo** - `Ctrl+Z` / `Ctrl+Y` vrátí/zopakuje tag operace (assign, remove, rename, merge)
|
||||
- Zásobník 50 kroků, čistí se při zavření složky
|
||||
- Edit menu zobrazuje popis poslední operace
|
||||
- Pokrývá: přiřazení tagu, odebrání tagu, přejmenování tagu/kategorie, sloučení tagu/kategorie
|
||||
- **CSFD cache** - Movie data cached in `.!tag` after first fetch, no re-fetching on reopen
|
||||
- `CSFDMovie.to_dict()` / `from_dict()` for serialization
|
||||
- `File.get_cached_movie()` - returns cached data without network access
|
||||
- Cache versioning (`CSFD_CACHE_VERSION`) for future schema invalidation
|
||||
- Cache invalidated automatically when CSFD URL changes
|
||||
- **Orphaned sidecar detection** - On folder scan, `.!tag` files without a matching media file are reported
|
||||
- `FileManager.on_orphaned_tags` callback for UI notification
|
||||
- `FileManager.find_orphaned_tags()` for manual scan
|
||||
- **OR/NOT tag filtering** - Extended `filter_files_by_tags()` with new parameters
|
||||
- `any_of` - file must have at least one of these tags (OR)
|
||||
- `must_not` - file must not have any of these tags (NOT)
|
||||
- Fully backward compatible
|
||||
- **ffprobe threading** - Video resolution detection now runs in background (`VideoResolutionWorker`)
|
||||
- Status bar shows live progress: `Zjišťuji rozlišení (3/12)…`
|
||||
- Menu action disabled during processing
|
||||
- **Export to CSV** - Soubor → "Exportovat do CSV..."
|
||||
- UTF-8 BOM encoding (Excel compatible)
|
||||
- Columns: filename, path, date, tags, CSFD URL, size
|
||||
- **Drag & drop** - Drag folder or file onto the app window to open it
|
||||
|
||||
### Changed
|
||||
- **Global config location** - Moved from app directory to `~/.config/Tagger/`
|
||||
- Automatic one-time migration from old location
|
||||
- Fixes crash on read-only PyInstaller build directories
|
||||
- **Python version** - Bumped to 3.14+
|
||||
|
||||
### Dependencies
|
||||
- Removed: Pillow (unused)
|
||||
- Moved: python-dotenv from dev to runtime dependencies
|
||||
|
||||
### Tests
|
||||
- 274 tests (all passing)
|
||||
|
||||
## [1.1.0] - 2026-01-23
|
||||
|
||||
### Changed
|
||||
- **GUI rewrite to PySide6/Qt6** - Complete UI rewrite from Tkinter to Qt
|
||||
- Modern QMainWindow with menu bar, toolbar, and status bar
|
||||
- QTreeWidget for tag sidebar with category colors
|
||||
- QTableWidget for file list with sorting and filtering
|
||||
- QSplitter for resizable sidebar
|
||||
- Native Qt dialogs (QFileDialog, QInputDialog, QMessageBox)
|
||||
- Keyboard shortcuts using QShortcut
|
||||
- Window geometry persistence
|
||||
- **UI utilities updated** - `src/ui/utils.py` now uses Qt (QIcon, QPixmap)
|
||||
- **Python version restricted** - Requires Python >=3.13,<3.15 for PySide6 compatibility
|
||||
|
||||
### Dependencies
|
||||
- Added PySide6 (>=6.10.1)
|
||||
- Removed Tkinter dependency
|
||||
|
||||
## [1.0.5] - 2026-01-23
|
||||
|
||||
### Added
|
||||
- **Tag and category renaming** - New context menu functionality
|
||||
- Right-click on tag → "Rename tag"
|
||||
- Automatic update of all files with the tag
|
||||
- Support for renaming entire categories
|
||||
- **Tag merging** - When renaming to an existing tag
|
||||
- Confirmation dialog for merge
|
||||
- Merge removes source tag and updates files
|
||||
- **Tag.from_string()** - New class method for parsing tags
|
||||
- Parses "category/name" format
|
||||
- Eliminates duplicate code across the project
|
||||
- **Dynamic version loading** - Version is loaded from pyproject.toml
|
||||
- Fallback to `_version.py` if toml is not available
|
||||
- DEBUG mode support from `.env` (adds " DEV" suffix)
|
||||
- APP_NAME includes version: "Tagger v1.0.5 DEV"
|
||||
- **UI utilities module** - `src/ui/utils.py`
|
||||
- Moved `load_icon()` function from core to UI layer
|
||||
|
||||
### Changed
|
||||
- **FileManager refactoring**
|
||||
- New methods `assign_tag_to_files()` and `remove_tag_from_files()`
|
||||
- Old methods kept as deprecated aliases
|
||||
- `Tag` import at module level (eliminates duplicate imports)
|
||||
- **Dead code removal**
|
||||
- Deleted unused `ListManager` module
|
||||
- Removed legacy functions `load_config()` and `save_config()` from config.py
|
||||
- **Missing import fix** - Added `import subprocess` to media_utils.py
|
||||
|
||||
### Tests
|
||||
- 274 tests (all passing)
|
||||
- New tests for Tag.from_string() (6 tests)
|
||||
- New tests for rename/merge tags (24 tests)
|
||||
|
||||
## [1.0.4] - 2025-12-29
|
||||
|
||||
### Added
|
||||
- **CSFD.cz integration** - Fetching movie information
|
||||
- `fetch_movie()` - load movie details from URL
|
||||
- `search_movies()` - search for movies
|
||||
- Automatic tag assignment (genres, year, country, director)
|
||||
- **Close folder** - Safe folder closing with metadata saving
|
||||
|
||||
### Tests
|
||||
- 249 tests covering CSFD integration
|
||||
|
||||
## [1.0.3] - 2025-12-28
|
||||
|
||||
### Added
|
||||
- **Hardlink structure** - New functionality for creating directory structure using hardlinks
|
||||
- `HardlinkManager` class in `src/core/hardlink_manager.py`
|
||||
- Creating hardlinks based on file tags (e.g., `output/genre/Comedy/movie.mkv`)
|
||||
- Structure synchronization - detection and removal of outdated hardlinks when tags change
|
||||
- Support for filtering by categories
|
||||
- Preview mode (dry run)
|
||||
- **Menu items for hardlinks**
|
||||
- "Set hardlink folder..." - configure output folder and categories (saved to `.tagger.json`)
|
||||
- "Update hardlink structure" - quick sync with saved settings
|
||||
- "Create hardlink structure..." - manual folder and category selection
|
||||
- **Three-level configuration system**
|
||||
- Global config (`config.json`) - application settings (window geometry, last folder)
|
||||
- Folder config (`.tagger.json`) - project settings (ignore patterns, hardlink settings)
|
||||
- File tags (`.filename.!tag`) - individual file metadata
|
||||
- **Default tags**
|
||||
- "Rating" category with stars (1-5 stars)
|
||||
- "Color" category with color labels
|
||||
- Exclusive selection in Rating category (only one tag)
|
||||
- **Tests**
|
||||
- 189 tests covering all modules
|
||||
- Tests for hardlink manager including synchronization
|
||||
- **Poetry** - Dependency management using Poetry
|
||||
|
||||
### Changed
|
||||
- Modernized GUI inspired by qBittorrent
|
||||
- Window geometry saved to global config
|
||||
- Ignore patterns saved to folder config
|
||||
|
||||
## [1.0.2] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- **Modern GUI** - Redesigned interface in qBittorrent style
|
||||
- Side panel with categories and tags
|
||||
- File table with column sorting
|
||||
- Context menus for files and tags
|
||||
- Search field
|
||||
- Status bar with file count and selection size
|
||||
- **Bulk tag assignment** - Dialog for assigning tags to multiple files at once
|
||||
- Tri-state checkboxes (checked/unchecked/mixed)
|
||||
- Color-coded categories
|
||||
- **Video resolution detection** - Automatic detection using ffprobe
|
||||
- **Keyboard shortcuts**
|
||||
- Ctrl+O - Open folder
|
||||
- Ctrl+T - Assign tags
|
||||
- Ctrl+D - Set date
|
||||
- F5 - Refresh
|
||||
- Delete - Remove from index
|
||||
|
||||
### Changed
|
||||
- Project structure refactored into modules (`src/core/`, `src/ui/`)
|
||||
- Using dataclass for Tag and File objects
|
||||
|
||||
## [1.0.0] - 2025-09-03
|
||||
|
||||
### Added
|
||||
- Basic file tagging functionality
|
||||
- Storing tags in hidden files (`.filename.!tag`)
|
||||
- Category and tag management
|
||||
- Recursive folder scanning
|
||||
- Ignore patterns for file filtering
|
||||
- Basic GUI in Tkinter
|
||||
136
PROJECT.md
Normal file
136
PROJECT.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Tagger - Project Documentation
|
||||
|
||||
**Version:** 1.2.0 | **Status:** Stable | **GUI:** PySide6/Qt6
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
Desktop app for organizing files using hierarchical tags (category/name).
|
||||
|
||||
**Features:** Folder scanning, tag filtering (AND/OR/NOT), rename/merge tags, CSFD.cz integration
|
||||
(with local cache), hardlink structure, 3-level config (global/folder/file), orphaned sidecar detection.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
Tagger/
|
||||
├── Tagger.py # Entry point
|
||||
├── src/core/ # Business logic (NO UI imports!)
|
||||
│ ├── tag.py # Tag value object (immutable)
|
||||
│ ├── tag_manager.py # Tag/category management
|
||||
│ ├── file.py # File with metadata + CSFD cache
|
||||
│ ├── file_manager.py # File management, filtering, orphan detection
|
||||
│ ├── config.py # 3-level config system
|
||||
│ ├── hardlink_manager.py
|
||||
│ ├── csfd.py # CSFD scraper + CSFDMovie serialization
|
||||
│ ├── media_utils.py # ffprobe integration
|
||||
│ ├── constants.py # APP_NAME, VERSION
|
||||
│ └── _version.py # Version fallback for PyInstaller
|
||||
├── src/ui/
|
||||
│ ├── gui.py # Qt6 GUI (MainWindow)
|
||||
│ └── utils.py # load_icon()
|
||||
└── tests/ # 274 tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
1. **UI must not contain business logic** — call FileManager/TagManager
|
||||
2. **Core must not import UI** — no PySide6 in src/core/
|
||||
3. **Dependency injection** — pass via constructor
|
||||
4. **UTF-8 everywhere** — `encoding='utf-8'`, `ensure_ascii=False`
|
||||
|
||||
---
|
||||
|
||||
## Config Files
|
||||
|
||||
| Level | File | Location | Contents |
|
||||
|-------|------|----------|----------|
|
||||
| Global | `.Tagger.!gtag` | `~/.config/Tagger/` | window geometry, last folder, recent folders |
|
||||
| Folder | `.Tagger.!ftag` | project folder | ignore patterns, hardlink settings |
|
||||
| File | `.filename.!tag` | same dir as file | tags, date, csfd_url, csfd_cache |
|
||||
|
||||
---
|
||||
|
||||
## Key Components
|
||||
|
||||
**Tag** — immutable, `Tag(category, name)`, `Tag.from_string("cat/name")`
|
||||
|
||||
**File** — `file_path`, `tags[]`, `date`, `csfd_url`, `csfd_cache`, metadata in `.filename.!tag`
|
||||
- `apply_csfd_tags()` — fetch + cache CSFD data
|
||||
- `get_cached_movie()` — return CSFDMovie from cache (no network)
|
||||
- `set_csfd_url()` — invalidates cache on URL change
|
||||
|
||||
**TagManager** — `add_tag()`, `get_categories()`, `rename_tag()`, `merge_tag()`
|
||||
|
||||
**FileManager** — `append(folder)`, `filter_files_by_tags()`, `close_folder()`
|
||||
- `on_orphaned_tags` callback — fires when orphaned `.!tag` sidecars are found
|
||||
- `find_orphaned_tags()` — manual scan for orphaned sidecars
|
||||
|
||||
**HardlinkManager** — `create_structure_for_files()`, `sync_structure()`
|
||||
|
||||
**CSFDMovie** — `to_dict()` / `from_dict()` for cache serialization
|
||||
|
||||
---
|
||||
|
||||
## Filtering
|
||||
|
||||
```python
|
||||
# AND (default — all must match)
|
||||
fm.filter_files_by_tags(["Žánr/Drama", "Rok/1990"])
|
||||
|
||||
# OR — at least one must match
|
||||
fm.filter_files_by_tags(any_of=["Žánr/Drama", "Žánr/Thriller"])
|
||||
|
||||
# NOT — none of these
|
||||
fm.filter_files_by_tags(must_not=["Stav/Nové"])
|
||||
|
||||
# Combined
|
||||
fm.filter_files_by_tags(
|
||||
must_have=["Žánr/Drama"],
|
||||
any_of=["Rok/1990", "Rok/1991"],
|
||||
must_not=["Stav/Nové"],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
poetry run python Tagger.py # GUI
|
||||
poetry run pytest -q # Tests
|
||||
poetry run pyinstaller --onefile Tagger.py # Build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shortcuts
|
||||
|
||||
`Ctrl+O` Open | `Ctrl+T` Tags | `Ctrl+D` Date | `Ctrl+W` Close | `F5` Refresh | `Del` Remove
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
**Version 0.0.0 in build:** Run app once from source to update `_version.py`, then rebuild.
|
||||
|
||||
**Cannot import:** Use `poetry run python Tagger.py`
|
||||
|
||||
**Metadata corrupted:** Auto-recovers with defaults.
|
||||
|
||||
**Config not saved:** Check `~/.config/Tagger/` exists and is writable.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
- **Tests:** 274 ✅
|
||||
- **Python:** 3.14+
|
||||
- **Dependencies:** PySide6, requests, beautifulsoup4, loguru, python-dotenv
|
||||
105
README.md
105
README.md
@@ -1,2 +1,103 @@
|
||||
install required modules to enviroment:
|
||||
pip install -r requirements.txt
|
||||
# Tagger
|
||||
|
||||
Desktopová aplikace pro správu a organizaci souborů pomocí hierarchických tagů.
|
||||
|
||||
## Hlavní funkce
|
||||
|
||||
- Rekurzivní procházení složek
|
||||
- Hierarchické tagy (kategorie/název)
|
||||
- Filtrování podle tagů (AND / OR / NOT logika) a textu
|
||||
- Metadata v JSON souborech (.!tag) — cestují se souborem
|
||||
- Integrace s CSFD.cz (automatické načítání žánrů, roku, země)
|
||||
- Tvorba hardlink struktury adresářů dle tagů
|
||||
- Automatická detekce rozlišení videí (ffprobe)
|
||||
|
||||
## Rychlý start
|
||||
|
||||
```bash
|
||||
# Instalace závislostí
|
||||
poetry install
|
||||
|
||||
# Spuštění
|
||||
poetry run python Tagger.py
|
||||
```
|
||||
|
||||
## Klávesové zkratky
|
||||
|
||||
| Zkratka | Akce |
|
||||
|---------|------|
|
||||
| `Ctrl+O` | Otevřít složku |
|
||||
| `Ctrl+T` | Přiřadit tagy |
|
||||
| `Ctrl+D` | Nastavit datum |
|
||||
| `Ctrl+W` | Zavřít složku |
|
||||
| `F5` | Refresh |
|
||||
| `Del` | Odebrat z indexu |
|
||||
|
||||
## Architektura
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Presentation (PySide6/Qt6) │ src/ui/gui.py
|
||||
├─────────────────────────────────┤
|
||||
│ Business Logic │ src/core/ (bez UI importů)
|
||||
├─────────────────────────────────┤
|
||||
│ Data Layer │ File, Tag, TagManager, FileManager
|
||||
├─────────────────────────────────┤
|
||||
│ Persistence │ JSON .!tag soubory
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Struktura projektu
|
||||
|
||||
```
|
||||
Tagger/
|
||||
├── Tagger.py # Entry point
|
||||
├── src/
|
||||
│ ├── core/ # Business logika (žádné UI importy!)
|
||||
│ │ ├── tag.py
|
||||
│ │ ├── file.py
|
||||
│ │ ├── file_manager.py
|
||||
│ │ ├── tag_manager.py
|
||||
│ │ ├── config.py
|
||||
│ │ ├── csfd.py
|
||||
│ │ ├── hardlink_manager.py
|
||||
│ │ └── media_utils.py
|
||||
│ └── ui/
|
||||
│ └── gui.py # Qt6 GUI
|
||||
└── tests/ # 274 testů
|
||||
```
|
||||
|
||||
## Testování
|
||||
|
||||
```bash
|
||||
poetry run pytest tests/ -q
|
||||
```
|
||||
|
||||
## Technologie
|
||||
|
||||
- **Python:** 3.14+
|
||||
- **GUI:** PySide6/Qt6
|
||||
- **Dependencies:** requests, beautifulsoup4, loguru, python-dotenv
|
||||
- **Package manager:** Poetry
|
||||
|
||||
## Metriky
|
||||
|
||||
- **Testy:** 274 (100% core coverage)
|
||||
- **Verze:** 1.1.0
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Proč JSON místo databáze?
|
||||
- Jednoduchý backup (copy složky)
|
||||
- Git-friendly (plain text, diffovatelné)
|
||||
- Metadata zůstanou při přesunu souboru (sidecar)
|
||||
- Portable — žádné DB závislosti
|
||||
|
||||
### Proč sidecar soubory (.!tag)?
|
||||
- Metadata cestují se souborem při přesunu/kopírování
|
||||
- Čitelné i bez aplikace
|
||||
- Každý soubor je nezávislý — žádný single point of failure
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
32
Tagger.py
32
Tagger.py
@@ -1,19 +1,23 @@
|
||||
# Imports
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
"""
|
||||
Entry point for Tagger application.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from src.ui.gui import App
|
||||
from src.core.file_manager import list_files, FileManager
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
from pathlib import Path
|
||||
|
||||
class State():
|
||||
def __init__(self) -> None:
|
||||
self.tagmanager = TagManager()
|
||||
self.filehandler = FileManager(self.tagmanager)
|
||||
self.app = App(self.filehandler, self.tagmanager)
|
||||
from src.ui.main_window import MainWindow
|
||||
|
||||
|
||||
def main() -> None:
|
||||
tagmanager = TagManager()
|
||||
filehandler = FileManager(tagmanager)
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
window = MainWindow(filehandler, tagmanager)
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
STATE = State()
|
||||
STATE.app.main()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
17
Tagger.spec
17
Tagger.spec
@@ -19,27 +19,20 @@ pyz = PYZ(a.pure)
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='Tagger',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False,
|
||||
onefile=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
name='Tagger',
|
||||
)
|
||||
|
||||
12
config.json
12
config.json
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"ignore_patterns": [
|
||||
"*.png",
|
||||
"*.jpg",
|
||||
"*.mp3",
|
||||
"*/M/*",
|
||||
"*/L/*",
|
||||
"*/Ostatní/*",
|
||||
"*.hidden*"
|
||||
],
|
||||
"last_folder": "/media/veracrypt3"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
597
poetry.lock
generated
Normal file
597
poetry.lock
generated
Normal file
@@ -0,0 +1,597 @@
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "altgraph"
|
||||
version = "0.17.5"
|
||||
description = "Python graph (network) package"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597"},
|
||||
{file = "altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.14.3"
|
||||
description = "Screen-scraping library"
|
||||
optional = false
|
||||
python-versions = ">=3.7.0"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"},
|
||||
{file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
soupsieve = ">=1.6.1"
|
||||
typing-extensions = ">=4.0.0"
|
||||
|
||||
[package.extras]
|
||||
cchardet = ["cchardet"]
|
||||
chardet = ["chardet"]
|
||||
charset-normalizer = ["charset-normalizer"]
|
||||
html5lib = ["html5lib"]
|
||||
lxml = ["lxml"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"},
|
||||
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
|
||||
{file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
|
||||
{file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
|
||||
{file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
|
||||
{file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
|
||||
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
|
||||
{file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
|
||||
{file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
|
||||
{file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
|
||||
{file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
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"
|
||||
groups = ["main", "dev"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
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 = "idna"
|
||||
version = "3.11"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
|
||||
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
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 = "loguru"
|
||||
version = "0.7.3"
|
||||
description = "Python logging made (stupidly) simple"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.5"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"},
|
||||
{file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
|
||||
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
|
||||
|
||||
[package.extras]
|
||||
dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
|
||||
|
||||
[[package]]
|
||||
name = "macholib"
|
||||
version = "1.16.4"
|
||||
description = "Mach-O header analysis and editing"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
markers = "sys_platform == \"darwin\""
|
||||
files = [
|
||||
{file = "macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea"},
|
||||
{file = "macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
altgraph = ">=0.17"
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
|
||||
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pefile"
|
||||
version = "2024.8.26"
|
||||
description = "Python PE parsing module"
|
||||
optional = false
|
||||
python-versions = ">=3.6.0"
|
||||
groups = ["dev"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"},
|
||||
{file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
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.20.0"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
|
||||
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "6.19.0"
|
||||
description = "PyInstaller bundles a Python application and all its dependencies into a single package."
|
||||
optional = false
|
||||
python-versions = "<3.15,>=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33"},
|
||||
{file = "pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea"},
|
||||
{file = "pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
altgraph = "*"
|
||||
macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
|
||||
packaging = ">=22.0"
|
||||
pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
|
||||
pyinstaller-hooks-contrib = ">=2026.0"
|
||||
pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""}
|
||||
setuptools = ">=42.0.0"
|
||||
|
||||
[package.extras]
|
||||
completion = ["argcomplete"]
|
||||
hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller-hooks-contrib"
|
||||
version = "2026.4"
|
||||
description = "Community maintained hooks for PyInstaller"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pyinstaller_hooks_contrib-2026.4-py3-none-any.whl", hash = "sha256:1de1a5e49a878122010b88c7e295502bc69776c157c4a4dc78741a4e6178b00f"},
|
||||
{file = "pyinstaller_hooks_contrib-2026.4.tar.gz", hash = "sha256:766c281acb1ecc32e21c8c667056d7ebf5da0aabd5e30c219f9c2a283620eeaa"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
packaging = ">=22.0"
|
||||
setuptools = ">=42.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "pyside6"
|
||||
version = "6.11.0"
|
||||
description = "Python bindings for the Qt cross-platform application and UI framework"
|
||||
optional = false
|
||||
python-versions = "<3.15,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f"},
|
||||
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00"},
|
||||
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e"},
|
||||
{file = "pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc"},
|
||||
{file = "pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
PySide6_Addons = "6.11.0"
|
||||
PySide6_Essentials = "6.11.0"
|
||||
shiboken6 = "6.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-addons"
|
||||
version = "6.11.0"
|
||||
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
|
||||
optional = false
|
||||
python-versions = "<3.15,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182"},
|
||||
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35"},
|
||||
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753"},
|
||||
{file = "pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e"},
|
||||
{file = "pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
PySide6_Essentials = "6.11.0"
|
||||
shiboken6 = "6.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-essentials"
|
||||
version = "6.11.0"
|
||||
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
|
||||
optional = false
|
||||
python-versions = "<3.15,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01"},
|
||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849"},
|
||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e"},
|
||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf"},
|
||||
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
shiboken6 = "6.11.0"
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
|
||||
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
|
||||
]
|
||||
|
||||
[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"]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
description = "Read key-value pairs from a .env file and set them as environment variables"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
|
||||
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
cli = ["click (>=5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["dev"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"},
|
||||
{file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"},
|
||||
{file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2023.5.7"
|
||||
charset_normalizer = ">=2,<4"
|
||||
idna = ">=2.5,<4"
|
||||
urllib3 = ">=1.26,<3"
|
||||
|
||||
[package.extras]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "82.0.1"
|
||||
description = "Most extensible Python build backend with support for C/C++ extension modules"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"},
|
||||
{file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""]
|
||||
core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
|
||||
cover = ["pytest-cov"]
|
||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
|
||||
enabler = ["pytest-enabler (>=2.2)"]
|
||||
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
|
||||
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"]
|
||||
|
||||
[[package]]
|
||||
name = "shiboken6"
|
||||
version = "6.11.0"
|
||||
description = "Python/C++ bindings helper module"
|
||||
optional = false
|
||||
python-versions = "<3.15,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b"},
|
||||
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053"},
|
||||
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9"},
|
||||
{file = "shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1"},
|
||||
{file = "shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.3"
|
||||
description = "A modern CSS selector implementation for Beautiful Soup."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95"},
|
||||
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
|
||||
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
|
||||
h2 = ["h2 (>=4,<5)"]
|
||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.2.0"
|
||||
description = "A small Python utility to set file creation time on Windows"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
groups = ["main"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"},
|
||||
{file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.13,<3.15"
|
||||
content-hash = "4c8861d0f089fe0ce348b1dec692077f63b84201ba3afebfb349e4998bf0ef70"
|
||||
66
prebuild.py
Normal file
66
prebuild.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from src.constants import VERSION
|
||||
|
||||
load_dotenv()
|
||||
|
||||
print("=" * 50)
|
||||
print("PREBUILD CONFIGURATION")
|
||||
print("=" * 50)
|
||||
|
||||
# Check if running in virtual environment
|
||||
project_root = Path(__file__).parent
|
||||
expected_venv_path = project_root / ".venv"
|
||||
current_executable = Path(sys.executable)
|
||||
|
||||
print(f"\nPython executable: {sys.executable}")
|
||||
|
||||
is_correct_venv = False
|
||||
try:
|
||||
current_executable.relative_to(expected_venv_path)
|
||||
is_correct_venv = True
|
||||
except ValueError:
|
||||
is_correct_venv = False
|
||||
|
||||
if is_correct_venv:
|
||||
print("✓ Correct environment selected for building")
|
||||
else:
|
||||
print("✗ Wrong environment selected")
|
||||
print(f" Expected: {expected_venv_path}")
|
||||
print(f" Current: {current_executable.parent.parent}")
|
||||
|
||||
print(f"✓ Version: {VERSION}")
|
||||
|
||||
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
|
||||
console_mode = env_debug
|
||||
default_spec = Path(__file__).parent.name + ".spec"
|
||||
spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec)
|
||||
|
||||
print(f"\n{'-' * 50}")
|
||||
print("BUILD SETTINGS")
|
||||
print(f"{'-' * 50}")
|
||||
print(f"ENV_DEBUG: {env_debug}")
|
||||
print(f"Console mode: {console_mode}")
|
||||
print(f"Spec file: {spec_filename}")
|
||||
|
||||
spec_path = Path(__file__).parent / spec_filename
|
||||
if spec_path.exists():
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
spec_content = f.read()
|
||||
|
||||
if f"console={not console_mode}" in spec_content:
|
||||
new_spec_content = spec_content.replace(
|
||||
f"console={not console_mode}",
|
||||
f"console={console_mode}"
|
||||
)
|
||||
with open(spec_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_spec_content)
|
||||
print(f"✓ Updated {spec_filename}: console={console_mode}")
|
||||
else:
|
||||
print(f"✓ {spec_filename} already configured: console={console_mode}")
|
||||
else:
|
||||
print(f"✗ {spec_filename} not found!")
|
||||
|
||||
print(f"{'-' * 50}\n")
|
||||
27
pyproject.toml
Normal file
27
pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "tagger"
|
||||
version = "1.2.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14,<3.15"
|
||||
dependencies = [
|
||||
"requests (>=2.32.5,<3.0.0)",
|
||||
"beautifulsoup4 (>=4.14.3,<5.0.0)",
|
||||
"pyside6 (>=6.10.1,<7.0.0)",
|
||||
"loguru (>=0.7.3,<0.8.0)",
|
||||
"python-dotenv (>=1.2.2,<2.0.0)"
|
||||
]
|
||||
|
||||
[tool.poetry]
|
||||
package-mode = false
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^9.0.2"
|
||||
pyinstaller = "^6.18.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
@@ -1 +0,0 @@
|
||||
pillow
|
||||
2
src/core/_version.py
Normal file
2
src/core/_version.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto-generated — do not edit manually.
|
||||
VERSION = "1.2.0"
|
||||
@@ -1,22 +1,112 @@
|
||||
"""
|
||||
Configuration management for Tagger
|
||||
|
||||
Three levels of configuration:
|
||||
1. Global config (~/.config/Tagger/.Tagger.!gtag) - app-wide settings
|
||||
2. Folder config (.Tagger.!ftag in project root) - folder-specific settings
|
||||
3. File tags (.{filename}.!tag) - per-file metadata (handled in file.py)
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_FILE = Path("config.json")
|
||||
# Global config file in XDG config directory (~/.config/Tagger/)
|
||||
# Migrates automatically from the old location next to Tagger.py if found.
|
||||
_XDG_CONFIG_DIR = Path.home() / ".config" / "Tagger"
|
||||
GLOBAL_CONFIG_FILE = _XDG_CONFIG_DIR / ".Tagger.!gtag"
|
||||
|
||||
default_config = {
|
||||
"ignore_patterns": [],
|
||||
"last_folder": None
|
||||
# Legacy location (next to Tagger.py) — used only for one-time migration
|
||||
_LEGACY_GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Tagger.!gtag"
|
||||
|
||||
# Folder config filename
|
||||
FOLDER_CONFIG_NAME = ".Tagger.!ftag"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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():
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return default_config.copy()
|
||||
return default_config.copy()
|
||||
|
||||
def save_config(cfg: dict):
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
def _migrate_legacy_config() -> None:
|
||||
"""Migrate global config from old location (next to Tagger.py) to ~/.config/Tagger/."""
|
||||
if GLOBAL_CONFIG_FILE.exists() or not _LEGACY_GLOBAL_CONFIG_FILE.exists():
|
||||
return
|
||||
_XDG_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
GLOBAL_CONFIG_FILE.write_bytes(_LEGACY_GLOBAL_CONFIG_FILE.read_bytes())
|
||||
_LEGACY_GLOBAL_CONFIG_FILE.unlink()
|
||||
|
||||
|
||||
def load_global_config() -> dict:
|
||||
"""Load global application config"""
|
||||
_migrate_legacy_config()
|
||||
if GLOBAL_CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
for key, value in DEFAULT_GLOBAL_CONFIG.items():
|
||||
if key not in config:
|
||||
config[key] = value
|
||||
return config
|
||||
except Exception:
|
||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||
|
||||
|
||||
def save_global_config(cfg: dict):
|
||||
"""Save global application config"""
|
||||
_XDG_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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)
|
||||
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()
|
||||
|
||||
@@ -1,4 +1,59 @@
|
||||
# src/core/constants.py
|
||||
VERSION = "v1.0.2"
|
||||
APP_NAME = "Tagger"
|
||||
APP_VIEWPORT = "1000x700"
|
||||
"""
|
||||
Application constants with dynamic version loading.
|
||||
|
||||
Version loading priority:
|
||||
1. pyproject.toml [project] version (preferred, uses tomllib)
|
||||
2. src/core/_version.py VERSION (generated fallback for frozen/PyInstaller builds)
|
||||
3. "0.0.0" (last resort)
|
||||
|
||||
Debug mode:
|
||||
Controlled via .env: ENV_DEBUG=true
|
||||
Accepted true-values: true, 1, yes (case-insensitive)
|
||||
"""
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT_DIR = Path(__file__).parent.parent.parent
|
||||
_PYPROJECT_PATH = _ROOT_DIR / "pyproject.toml"
|
||||
_VERSION_FILE = Path(__file__).parent / "_version.py"
|
||||
|
||||
|
||||
def _load_version() -> str:
|
||||
# 1. pyproject.toml
|
||||
try:
|
||||
with open(_PYPROJECT_PATH, "rb") as f:
|
||||
version = tomllib.load(f)["project"]["version"]
|
||||
# Write fallback for frozen/PyInstaller builds
|
||||
_VERSION_FILE.write_text(
|
||||
f'# Auto-generated — do not edit manually.\nVERSION = "{version}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return version
|
||||
except (FileNotFoundError, KeyError, OSError):
|
||||
pass
|
||||
|
||||
# 2. _version.py
|
||||
try:
|
||||
from src.core._version import VERSION as _ver # type: ignore[import]
|
||||
return _ver
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 3. last resort
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _load_debug() -> bool:
|
||||
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
VERSION = _load_version()
|
||||
DEBUG = _load_debug()
|
||||
|
||||
if DEBUG:
|
||||
VERSION = f"{VERSION} DEV"
|
||||
|
||||
APP_NAME = f"Tagger v{VERSION}"
|
||||
APP_VIEWPORT = "1000x700"
|
||||
|
||||
412
src/core/csfd.py
Normal file
412
src/core/csfd.py
Normal file
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
CSFD.cz scraper module for fetching movie information.
|
||||
|
||||
This module provides functionality to fetch movie data from CSFD.cz (Czech-Slovak Film Database).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from urllib.parse import urljoin
|
||||
|
||||
try:
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
HAS_DEPENDENCIES = True
|
||||
except ImportError:
|
||||
HAS_DEPENDENCIES = False
|
||||
requests = None # type: ignore
|
||||
BeautifulSoup = None # type: ignore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
CSFD_BASE_URL = "https://www.csfd.cz"
|
||||
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
|
||||
|
||||
# User agent to avoid being blocked
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept-Language": "cs,en;q=0.9",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSFDMovie:
|
||||
"""Represents movie data from CSFD.cz"""
|
||||
title: str
|
||||
url: str
|
||||
year: Optional[int] = None
|
||||
genres: list[str] = field(default_factory=list)
|
||||
directors: list[str] = field(default_factory=list)
|
||||
actors: list[str] = field(default_factory=list)
|
||||
rating: Optional[int] = None # Percentage 0-100
|
||||
rating_count: Optional[int] = None
|
||||
duration: Optional[int] = None # Minutes
|
||||
country: Optional[str] = None
|
||||
poster_url: Optional[str] = None
|
||||
plot: Optional[str] = None
|
||||
csfd_id: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize to a plain dict for storage in .!tag cache."""
|
||||
return {
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
"year": self.year,
|
||||
"genres": self.genres,
|
||||
"directors": self.directors,
|
||||
"actors": self.actors,
|
||||
"rating": self.rating,
|
||||
"rating_count": self.rating_count,
|
||||
"duration": self.duration,
|
||||
"country": self.country,
|
||||
"poster_url": self.poster_url,
|
||||
"plot": self.plot,
|
||||
"csfd_id": self.csfd_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CSFDMovie":
|
||||
"""Deserialize from a plain dict (e.g. loaded from .!tag cache)."""
|
||||
return cls(
|
||||
title=data.get("title", ""),
|
||||
url=data.get("url", ""),
|
||||
year=data.get("year"),
|
||||
genres=data.get("genres", []),
|
||||
directors=data.get("directors", []),
|
||||
actors=data.get("actors", []),
|
||||
rating=data.get("rating"),
|
||||
rating_count=data.get("rating_count"),
|
||||
duration=data.get("duration"),
|
||||
country=data.get("country"),
|
||||
poster_url=data.get("poster_url"),
|
||||
plot=data.get("plot"),
|
||||
csfd_id=data.get("csfd_id"),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
parts = [self.title]
|
||||
if self.year:
|
||||
parts[0] += f" ({self.year})"
|
||||
if self.rating is not None:
|
||||
parts.append(f"Hodnocení: {self.rating}%")
|
||||
if self.genres:
|
||||
parts.append(f"Žánr: {', '.join(self.genres)}")
|
||||
if self.directors:
|
||||
parts.append(f"Režie: {', '.join(self.directors)}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _check_dependencies():
|
||||
"""Check if required dependencies are installed."""
|
||||
if not HAS_DEPENDENCIES:
|
||||
raise ImportError(
|
||||
"CSFD module requires 'requests' and 'beautifulsoup4' packages. "
|
||||
"Install them with: pip install requests beautifulsoup4"
|
||||
)
|
||||
|
||||
|
||||
def _extract_csfd_id(url: str) -> Optional[int]:
|
||||
"""Extract CSFD movie ID from URL."""
|
||||
match = re.search(r"/film/(\d+)", url)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _parse_duration(duration_str: str) -> Optional[int]:
|
||||
"""Parse ISO 8601 duration (PT97M) to minutes."""
|
||||
match = re.search(r"PT(\d+)M", duration_str)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def fetch_movie(url: str) -> CSFDMovie:
|
||||
"""
|
||||
Fetch movie information from CSFD.cz URL.
|
||||
|
||||
Args:
|
||||
url: Full CSFD.cz movie URL (e.g., https://www.csfd.cz/film/9423-pane-vy-jste-vdova/)
|
||||
|
||||
Returns:
|
||||
CSFDMovie object with extracted data
|
||||
|
||||
Raises:
|
||||
ImportError: If required dependencies are not installed
|
||||
requests.RequestException: If network request fails
|
||||
ValueError: If URL is invalid or page cannot be parsed
|
||||
"""
|
||||
_check_dependencies()
|
||||
|
||||
response = requests.get(url, headers=HEADERS, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# Try to extract JSON-LD structured data first (most reliable)
|
||||
movie_data = _extract_json_ld(soup)
|
||||
|
||||
# Extract additional data from HTML
|
||||
movie_data["url"] = url
|
||||
movie_data["csfd_id"] = _extract_csfd_id(url)
|
||||
|
||||
# Get rating from HTML if not in JSON-LD
|
||||
if movie_data.get("rating") is None:
|
||||
movie_data["rating"] = _extract_rating(soup)
|
||||
|
||||
# Get poster URL
|
||||
if movie_data.get("poster_url") is None:
|
||||
movie_data["poster_url"] = _extract_poster(soup)
|
||||
|
||||
# Get plot summary
|
||||
if movie_data.get("plot") is None:
|
||||
movie_data["plot"] = _extract_plot(soup)
|
||||
|
||||
# Get country and year from origin info
|
||||
origin_info = _extract_origin_info(soup)
|
||||
if origin_info:
|
||||
if movie_data.get("country") is None:
|
||||
movie_data["country"] = origin_info.get("country")
|
||||
if movie_data.get("year") is None:
|
||||
movie_data["year"] = origin_info.get("year")
|
||||
if movie_data.get("duration") is None:
|
||||
movie_data["duration"] = origin_info.get("duration")
|
||||
|
||||
# Get genres from HTML if not in JSON-LD
|
||||
if not movie_data.get("genres"):
|
||||
movie_data["genres"] = _extract_genres(soup)
|
||||
|
||||
return CSFDMovie(**movie_data)
|
||||
|
||||
|
||||
def _extract_json_ld(soup: BeautifulSoup) -> dict:
|
||||
"""Extract movie data from JSON-LD structured data."""
|
||||
data = {
|
||||
"title": "",
|
||||
"year": None,
|
||||
"genres": [],
|
||||
"directors": [],
|
||||
"actors": [],
|
||||
"rating": None,
|
||||
"rating_count": None,
|
||||
"duration": None,
|
||||
"country": None,
|
||||
"poster_url": None,
|
||||
"plot": None,
|
||||
}
|
||||
|
||||
# Find JSON-LD script
|
||||
script_tags = soup.find_all("script", type="application/ld+json")
|
||||
for script in script_tags:
|
||||
try:
|
||||
json_data = json.loads(script.string)
|
||||
|
||||
# Handle both single object and array
|
||||
if isinstance(json_data, list):
|
||||
for item in json_data:
|
||||
if item.get("@type") == "Movie":
|
||||
json_data = item
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
if json_data.get("@type") != "Movie":
|
||||
continue
|
||||
|
||||
# Title
|
||||
data["title"] = json_data.get("name", "")
|
||||
|
||||
# Genres
|
||||
genre = json_data.get("genre", [])
|
||||
if isinstance(genre, str):
|
||||
data["genres"] = [genre]
|
||||
else:
|
||||
data["genres"] = list(genre)
|
||||
|
||||
# Directors
|
||||
directors = json_data.get("director", [])
|
||||
if isinstance(directors, dict):
|
||||
directors = [directors]
|
||||
data["directors"] = [d.get("name", "") for d in directors if d.get("name")]
|
||||
|
||||
# Actors
|
||||
actors = json_data.get("actor", [])
|
||||
if isinstance(actors, dict):
|
||||
actors = [actors]
|
||||
data["actors"] = [a.get("name", "") for a in actors if a.get("name")]
|
||||
|
||||
# Rating
|
||||
agg_rating = json_data.get("aggregateRating", {})
|
||||
if agg_rating:
|
||||
rating_value = agg_rating.get("ratingValue")
|
||||
if rating_value is not None:
|
||||
data["rating"] = round(float(rating_value))
|
||||
data["rating_count"] = agg_rating.get("ratingCount")
|
||||
|
||||
# Duration
|
||||
duration_str = json_data.get("duration", "")
|
||||
if duration_str:
|
||||
data["duration"] = _parse_duration(duration_str)
|
||||
|
||||
# Poster
|
||||
image = json_data.get("image")
|
||||
if image:
|
||||
if isinstance(image, str):
|
||||
data["poster_url"] = image
|
||||
elif isinstance(image, dict):
|
||||
data["poster_url"] = image.get("url")
|
||||
|
||||
# Description
|
||||
data["plot"] = json_data.get("description")
|
||||
|
||||
break # Found movie data
|
||||
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
continue
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _extract_rating(soup: BeautifulSoup) -> Optional[int]:
|
||||
"""Extract rating percentage from HTML."""
|
||||
# Look for rating box
|
||||
rating_elem = soup.select_one(".film-rating-average")
|
||||
if rating_elem:
|
||||
text = rating_elem.get_text(strip=True)
|
||||
match = re.search(r"(\d+)%", text)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_poster(soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extract poster image URL from HTML."""
|
||||
# Look for poster image
|
||||
poster = soup.select_one(".film-poster img")
|
||||
if poster:
|
||||
src = poster.get("src") or poster.get("data-src")
|
||||
if src:
|
||||
if src.startswith("//"):
|
||||
return "https:" + src
|
||||
return src
|
||||
return None
|
||||
|
||||
|
||||
def _extract_plot(soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extract plot summary from HTML."""
|
||||
# Look for plot/description section
|
||||
plot_elem = soup.select_one(".plot-full p")
|
||||
if plot_elem:
|
||||
return plot_elem.get_text(strip=True)
|
||||
|
||||
# Alternative: shorter plot
|
||||
plot_elem = soup.select_one(".plot-preview p")
|
||||
if plot_elem:
|
||||
return plot_elem.get_text(strip=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_genres(soup: BeautifulSoup) -> list[str]:
|
||||
"""Extract genres from HTML."""
|
||||
genres = []
|
||||
genre_links = soup.select(".genres a")
|
||||
for link in genre_links:
|
||||
genre = link.get_text(strip=True)
|
||||
if genre:
|
||||
genres.append(genre)
|
||||
return genres
|
||||
|
||||
|
||||
def _extract_origin_info(soup: BeautifulSoup) -> dict:
|
||||
"""Extract country, year, duration from origin info line."""
|
||||
info = {}
|
||||
|
||||
# Look for origin line like "Československo, 1970, 97 min"
|
||||
origin_elem = soup.select_one(".origin")
|
||||
if origin_elem:
|
||||
text = origin_elem.get_text(strip=True)
|
||||
|
||||
# Extract year
|
||||
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", text)
|
||||
if year_match:
|
||||
info["year"] = int(year_match.group(1))
|
||||
|
||||
# Extract duration
|
||||
duration_match = re.search(r"(\d+)\s*min", text)
|
||||
if duration_match:
|
||||
info["duration"] = int(duration_match.group(1))
|
||||
|
||||
# Extract country (first part before comma)
|
||||
parts = text.split(",")
|
||||
if parts:
|
||||
info["country"] = parts[0].strip()
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def search_movies(query: str, limit: int = 10) -> list[CSFDMovie]:
|
||||
"""
|
||||
Search for movies on CSFD.cz.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of CSFDMovie objects with basic info (title, url, year)
|
||||
"""
|
||||
_check_dependencies()
|
||||
|
||||
search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}"
|
||||
response = requests.get(search_url, headers=HEADERS, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
results = []
|
||||
|
||||
# Find movie results
|
||||
movie_items = soup.select(".film-title-name, .search-result-item a[href*='/film/']")
|
||||
|
||||
for item in movie_items[:limit]:
|
||||
href = item.get("href", "")
|
||||
if "/film/" not in href:
|
||||
continue
|
||||
|
||||
title = item.get_text(strip=True)
|
||||
url = urljoin(CSFD_BASE_URL, href)
|
||||
|
||||
# Try to get year from sibling/parent
|
||||
year = None
|
||||
parent = item.find_parent(class_="article-content")
|
||||
if parent:
|
||||
year_elem = parent.select_one(".info")
|
||||
if year_elem:
|
||||
year_match = re.search(r"\((\d{4})\)", year_elem.get_text())
|
||||
if year_match:
|
||||
year = int(year_match.group(1))
|
||||
|
||||
results.append(CSFDMovie(
|
||||
title=title,
|
||||
url=url,
|
||||
year=year,
|
||||
csfd_id=_extract_csfd_id(url)
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def fetch_movie_by_id(csfd_id: int) -> CSFDMovie:
|
||||
"""
|
||||
Fetch movie by CSFD ID.
|
||||
|
||||
Args:
|
||||
csfd_id: CSFD movie ID number
|
||||
|
||||
Returns:
|
||||
CSFDMovie object with full data
|
||||
"""
|
||||
url = f"{CSFD_BASE_URL}/film/{csfd_id}/"
|
||||
return fetch_movie(url)
|
||||
@@ -2,6 +2,9 @@ from pathlib import Path
|
||||
import json
|
||||
from .tag import Tag
|
||||
|
||||
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
|
||||
CSFD_CACHE_VERSION = 1
|
||||
|
||||
class File:
|
||||
def __init__(self, file_path: Path, tagmanager=None) -> None:
|
||||
self.file_path = file_path
|
||||
@@ -11,8 +14,12 @@ class File:
|
||||
self.ignored = False
|
||||
self.tags: list[Tag] = []
|
||||
self.tagmanager = tagmanager
|
||||
# new: optional date string "YYYY-MM-DD" (assigned manually)
|
||||
# optional date string "YYYY-MM-DD" (assigned manually)
|
||||
self.date: str | None = None
|
||||
# CSFD.cz URL for movie info
|
||||
self.csfd_url: str | None = None
|
||||
# Cached CSFD data — avoids re-fetching on every open
|
||||
self.csfd_cache: dict | None = None
|
||||
self.get_metadata()
|
||||
|
||||
def get_metadata(self) -> None:
|
||||
@@ -21,6 +28,7 @@ class File:
|
||||
self.ignored = False
|
||||
self.tags = []
|
||||
self.date = None
|
||||
self.csfd_url = None
|
||||
if self.tagmanager:
|
||||
tag = self.tagmanager.add_tag("Stav", "Nové")
|
||||
self.tags.append(tag)
|
||||
@@ -32,11 +40,12 @@ class File:
|
||||
data = {
|
||||
"new": self.new,
|
||||
"ignored": self.ignored,
|
||||
# ukládáme full_path tagů
|
||||
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
|
||||
# date může být None
|
||||
"date": self.date,
|
||||
"csfd_url": self.csfd_url,
|
||||
}
|
||||
if self.csfd_cache is not None:
|
||||
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
|
||||
with open(self.metadata_filename, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -47,6 +56,12 @@ class File:
|
||||
self.ignored = data.get("ignored", False)
|
||||
self.tags = []
|
||||
self.date = data.get("date", None)
|
||||
self.csfd_url = data.get("csfd_url", None)
|
||||
raw_cache = data.get("csfd_cache")
|
||||
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION:
|
||||
self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"}
|
||||
else:
|
||||
self.csfd_cache = None
|
||||
|
||||
if not self.tagmanager:
|
||||
return
|
||||
@@ -66,6 +81,74 @@ class File:
|
||||
self.date = date_str
|
||||
self.save_metadata()
|
||||
|
||||
def set_csfd_url(self, url: str | None):
|
||||
"""Nastaví CSFD URL nebo None pro smazání. Invaliduje cache při změně URL."""
|
||||
new_url = url if url else None
|
||||
if new_url != self.csfd_url:
|
||||
self.csfd_cache = None # URL changed — old cache is stale
|
||||
self.csfd_url = new_url
|
||||
self.save_metadata()
|
||||
|
||||
def get_cached_movie(self):
|
||||
"""
|
||||
Vrátí CSFDMovie z cache nebo None pokud cache není k dispozici.
|
||||
Nevyžaduje síťové připojení.
|
||||
"""
|
||||
if self.csfd_cache is None:
|
||||
return None
|
||||
try:
|
||||
from .csfd import CSFDMovie
|
||||
return CSFDMovie.from_dict(self.csfd_cache)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def apply_csfd_tags(self, add_genres: bool = True, add_year: bool = True, add_country: bool = True) -> dict:
|
||||
"""
|
||||
Načte informace z CSFD a přiřadí tagy (žánr, rok, země).
|
||||
|
||||
Returns:
|
||||
dict s klíči 'success', 'movie', 'error', 'tags_added'
|
||||
|
||||
"""
|
||||
if not self.csfd_url:
|
||||
return {"success": False, "error": "CSFD URL není nastavena", "tags_added": []}
|
||||
|
||||
try:
|
||||
from .csfd import fetch_movie
|
||||
movie = fetch_movie(self.csfd_url)
|
||||
self.csfd_cache = movie.to_dict()
|
||||
except ImportError as e:
|
||||
return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
|
||||
|
||||
tags_added = []
|
||||
|
||||
if add_genres and movie.genres:
|
||||
for genre in movie.genres:
|
||||
tag_obj = self.tagmanager.add_tag("Žánr", genre) if self.tagmanager else Tag("Žánr", genre)
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(f"Žánr/{genre}")
|
||||
|
||||
if add_year and movie.year:
|
||||
year_str = str(movie.year)
|
||||
tag_obj = self.tagmanager.add_tag("Rok", year_str) if self.tagmanager else Tag("Rok", year_str)
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(f"Rok/{year_str}")
|
||||
|
||||
if add_country and movie.country:
|
||||
tag_obj = self.tagmanager.add_tag("Země", movie.country) if self.tagmanager else Tag("Země", movie.country)
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(f"Země/{movie.country}")
|
||||
|
||||
if tags_added:
|
||||
self.save_metadata()
|
||||
|
||||
return {"success": True, "movie": movie, "tags_added": tags_added}
|
||||
|
||||
def add_tag(self, tag):
|
||||
# tag může být Tag nebo string
|
||||
from .tag import Tag as TagClass
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
import fnmatch
|
||||
|
||||
from .file import File
|
||||
from .tag import Tag
|
||||
from .tag_manager import TagManager
|
||||
from .utils import list_files
|
||||
from typing import Iterable
|
||||
import fnmatch
|
||||
from src.core.config import load_config, save_config
|
||||
from src.core.config import (
|
||||
load_global_config, save_global_config,
|
||||
load_folder_config, save_folder_config
|
||||
)
|
||||
|
||||
_MAX_UNDO = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
class _UndoEntry:
|
||||
description: str
|
||||
undo: Callable[[], None]
|
||||
redo: Callable[[], None]
|
||||
|
||||
|
||||
class FileManager:
|
||||
def __init__(self, tagmanager: TagManager):
|
||||
@@ -12,93 +28,547 @@ class FileManager:
|
||||
self.folders: list[Path] = []
|
||||
self.tagmanager = tagmanager
|
||||
self.on_files_changed = None # callback do GUI
|
||||
self.config = load_config()
|
||||
self.on_tags_changed = None # callback do GUI po rename/merge operacích
|
||||
# callback(orphans: list[Path]) — volán po append() pokud jsou nalezeny osiřelé .!tag
|
||||
self.on_orphaned_tags = None
|
||||
self.global_config = load_global_config()
|
||||
self.folder_configs: dict[Path, dict] = {} # folder -> config
|
||||
self.current_folder: Path | None = None
|
||||
self._undo_stack: list[_UndoEntry] = []
|
||||
self._redo_stack: list[_UndoEntry] = []
|
||||
|
||||
def append(self, folder: Path) -> None:
|
||||
"""Add a folder to scan for files"""
|
||||
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", [])
|
||||
|
||||
known_files: set[Path] = set()
|
||||
|
||||
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
|
||||
):
|
||||
continue
|
||||
|
||||
known_files.add(each)
|
||||
file_obj = File(each, self.tagmanager)
|
||||
self.filelist.append(file_obj)
|
||||
|
||||
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:
|
||||
if isinstance(tag, str):
|
||||
if "/" in tag:
|
||||
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
|
||||
if tag_obj not in f.tags:
|
||||
f.tags.append(tag_obj)
|
||||
f.save_metadata()
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
# Detect orphaned .!tag files (sidecar without a matching media file).
|
||||
# This happens when the original file was renamed or moved without its sidecar.
|
||||
orphans = self._find_orphaned_tags(folder, known_files)
|
||||
if orphans and self.on_orphaned_tags:
|
||||
self.on_orphaned_tags(orphans)
|
||||
|
||||
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
|
||||
"""Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů."""
|
||||
for f in files_objs:
|
||||
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:
|
||||
from .tag import Tag as TagClass
|
||||
tag_obj = TagClass("default", tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
if tag_obj in f.tags:
|
||||
f.tags.remove(tag_obj)
|
||||
f.save_metadata()
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
def filter_files_by_tags(self, tags: Iterable):
|
||||
def _find_orphaned_tags(self, folder: Path, known_files: set[Path]) -> list[Path]:
|
||||
"""
|
||||
Vrátí jen soubory, které obsahují všechny zadané tagy.
|
||||
'tags' může být iterace Tag objektů nebo stringů 'category/name'.
|
||||
Return .!tag sidecar files that have no matching media file.
|
||||
A sidecar `.filename.!tag` is orphaned when `filename` is not in known_files.
|
||||
"""
|
||||
tags_list = list(tags) if tags is not None else []
|
||||
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:
|
||||
if isinstance(t, TagClass):
|
||||
target_full_paths.add(t.full_path)
|
||||
elif isinstance(t, str):
|
||||
target_full_paths.add(t)
|
||||
else:
|
||||
# neznámý typ: ignorovat
|
||||
orphans = []
|
||||
for tag_file in folder.rglob("*.!tag"):
|
||||
# Sidecar name format: .{original_name}.!tag (hidden dot-file)
|
||||
name = tag_file.name # e.g. ".film.mkv.!tag"
|
||||
if not name.startswith("."):
|
||||
continue
|
||||
original_name = name[1:-len(".!tag")] # strip leading dot and suffix
|
||||
expected = tag_file.parent / original_name
|
||||
if expected not in known_files:
|
||||
orphans.append(tag_file)
|
||||
return orphans
|
||||
|
||||
def find_orphaned_tags(self, folder: Path = None) -> list[Path]:
|
||||
"""
|
||||
Public method: scan folder and return all orphaned .!tag sidecar paths.
|
||||
Useful for manual cleanup or UI display.
|
||||
"""
|
||||
if folder is None:
|
||||
folder = self.current_folder
|
||||
if folder is None:
|
||||
return []
|
||||
known = {f.file_path for f in self.filelist}
|
||||
return self._find_orphaned_tags(folder, known)
|
||||
|
||||
def get_folder_config(self, folder: Path = None) -> dict:
|
||||
"""Get config for a folder (or current folder if not specified)"""
|
||||
if folder is None:
|
||||
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", [])
|
||||
|
||||
# ==================================================
|
||||
# UNDO / REDO
|
||||
# ==================================================
|
||||
|
||||
def _push_undo(self, entry: _UndoEntry) -> None:
|
||||
self._undo_stack.append(entry)
|
||||
if len(self._undo_stack) > _MAX_UNDO:
|
||||
self._undo_stack.pop(0)
|
||||
self._redo_stack.clear()
|
||||
|
||||
def can_undo(self) -> bool:
|
||||
return bool(self._undo_stack)
|
||||
|
||||
def can_redo(self) -> bool:
|
||||
return bool(self._redo_stack)
|
||||
|
||||
def undo(self) -> str | None:
|
||||
"""Vrátí zpět poslední operaci. Vrací popis operace nebo None."""
|
||||
if not self._undo_stack:
|
||||
return None
|
||||
entry = self._undo_stack.pop()
|
||||
entry.undo()
|
||||
self._redo_stack.append(entry)
|
||||
return entry.description
|
||||
|
||||
def redo(self) -> str | None:
|
||||
"""Zopakuje naposledy vrácenou operaci. Vrací popis operace nebo None."""
|
||||
if not self._redo_stack:
|
||||
return None
|
||||
entry = self._redo_stack.pop()
|
||||
entry.redo()
|
||||
self._undo_stack.append(entry)
|
||||
return entry.description
|
||||
|
||||
def _snapshot_files(self, files: list[File]) -> dict[Path, list[str]]:
|
||||
"""Zaznamená aktuální tagy souborů jako full_path řetězce."""
|
||||
return {f.file_path: [t.full_path for t in f.tags] for f in files}
|
||||
|
||||
def _restore_snapshot(self, snapshot: dict[Path, list[str]]) -> None:
|
||||
"""Obnoví tagy souborů ze snapshotu. Zajistí existenci tagů v TagManageru."""
|
||||
path_to_file = {f.file_path: f for f in self.filelist}
|
||||
for path, tag_paths in snapshot.items():
|
||||
f = path_to_file.get(path)
|
||||
if f is None:
|
||||
continue
|
||||
new_tags = []
|
||||
for fp in tag_paths:
|
||||
if "/" not in fp:
|
||||
continue
|
||||
cat, name = fp.split("/", 1)
|
||||
new_tags.append(self.tagmanager.add_tag(cat, name))
|
||||
f.tags = new_tags
|
||||
f.save_metadata()
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
# ==================================================
|
||||
# TAG OPERATIONS
|
||||
# ==================================================
|
||||
|
||||
def assign_tag_to_files(self, files: list[File], tag):
|
||||
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||
if isinstance(tag, str):
|
||||
parsed = Tag.from_string(tag)
|
||||
tag_obj = self.tagmanager.add_tag(parsed.category, parsed.name)
|
||||
else:
|
||||
tag_obj = tag
|
||||
|
||||
# Only files that don't already have the tag will be changed
|
||||
affected = [f for f in files if tag_obj not in f.tags]
|
||||
snapshot_before = self._snapshot_files(affected)
|
||||
|
||||
for f in affected:
|
||||
f.tags.append(tag_obj)
|
||||
f.save_metadata()
|
||||
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
if affected:
|
||||
snapshot_after = self._snapshot_files(affected)
|
||||
self._push_undo(_UndoEntry(
|
||||
description=f"Přiřadit tag {tag_obj.full_path}",
|
||||
undo=lambda s=snapshot_before: self._restore_snapshot(s),
|
||||
redo=lambda s=snapshot_after: self._restore_snapshot(s),
|
||||
))
|
||||
|
||||
def remove_tag_from_files(self, files: list[File], tag):
|
||||
"""Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů."""
|
||||
if isinstance(tag, str):
|
||||
tag_obj = Tag.from_string(tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
|
||||
affected = [f for f in files if tag_obj in f.tags]
|
||||
snapshot_before = self._snapshot_files(affected)
|
||||
|
||||
for f in affected:
|
||||
f.tags.remove(tag_obj)
|
||||
f.save_metadata()
|
||||
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
if affected:
|
||||
snapshot_after = self._snapshot_files(affected)
|
||||
self._push_undo(_UndoEntry(
|
||||
description=f"Odebrat tag {tag_obj.full_path}",
|
||||
undo=lambda s=snapshot_before: self._restore_snapshot(s),
|
||||
redo=lambda s=snapshot_after: self._restore_snapshot(s),
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _to_full_paths(tags) -> set[str]:
|
||||
"""Převede kolekci Tag objektů nebo stringů na sadu full_path řetězců."""
|
||||
result = set()
|
||||
if not tags:
|
||||
return result
|
||||
for t in tags:
|
||||
if isinstance(t, Tag):
|
||||
result.add(t.full_path)
|
||||
elif isinstance(t, str):
|
||||
result.add(t)
|
||||
return result
|
||||
|
||||
def filter_files_by_tags(
|
||||
self,
|
||||
tags: Iterable = None,
|
||||
*,
|
||||
must_have: Iterable = None,
|
||||
any_of: Iterable = None,
|
||||
must_not: Iterable = None,
|
||||
) -> list[File]:
|
||||
"""
|
||||
Vrátí soubory dle tagových podmínek.
|
||||
|
||||
Parametry lze kombinovat:
|
||||
tags / must_have — soubor musí mít VŠECHNY tyto tagy (AND)
|
||||
any_of — soubor musí mít ALESPOŇ JEDEN z těchto tagů (OR)
|
||||
must_not — soubor nesmí mít ŽÁDNÝ z těchto tagů (NOT)
|
||||
|
||||
Zpětně kompatibilní: filter_files_by_tags(tags) funguje stejně jako dříve.
|
||||
|
||||
Příklad:
|
||||
filter_files_by_tags(
|
||||
any_of=["Žánr/Drama", "Žánr/Thriller"],
|
||||
must_not=["Rok/2000", "Rok/2001"],
|
||||
)
|
||||
"""
|
||||
# Backward compat: positional `tags` arg maps to must_have
|
||||
must_have_paths = self._to_full_paths(tags) | self._to_full_paths(must_have)
|
||||
any_of_paths = self._to_full_paths(any_of)
|
||||
must_not_paths = self._to_full_paths(must_not)
|
||||
|
||||
# Fast path: no filters at all
|
||||
if not must_have_paths and not any_of_paths and not must_not_paths:
|
||||
return self.filelist
|
||||
|
||||
filtered = []
|
||||
for f in self.filelist:
|
||||
file_tags = {t.full_path for t in f.tags}
|
||||
if all(tag in file_tags for tag in target_full_paths):
|
||||
filtered.append(f)
|
||||
return filtered
|
||||
|
||||
if must_have_paths and not must_have_paths.issubset(file_tags):
|
||||
continue
|
||||
if any_of_paths and not any_of_paths.intersection(file_tags):
|
||||
continue
|
||||
if must_not_paths and must_not_paths.intersection(file_tags):
|
||||
continue
|
||||
|
||||
filtered.append(f)
|
||||
return filtered
|
||||
|
||||
# Backwards compatibility aliases
|
||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||
"""Deprecated: Use assign_tag_to_files instead."""
|
||||
return self.assign_tag_to_files(files_objs, tag)
|
||||
|
||||
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
|
||||
"""Deprecated: Use remove_tag_from_files instead."""
|
||||
return self.remove_tag_from_files(files_objs, tag)
|
||||
|
||||
def close_folder(self):
|
||||
"""
|
||||
Safely close current folder - save all metadata and clear state.
|
||||
|
||||
This method:
|
||||
1. Saves metadata for all files
|
||||
2. Saves folder config
|
||||
3. Clears file list, folders, and configs
|
||||
4. Notifies GUI via callback
|
||||
"""
|
||||
if not self.current_folder:
|
||||
return
|
||||
|
||||
# Save all file metadata
|
||||
for f in self.filelist:
|
||||
try:
|
||||
f.save_metadata()
|
||||
except Exception:
|
||||
pass # Ignore errors during save
|
||||
|
||||
# Save folder config
|
||||
if self.current_folder in self.folder_configs:
|
||||
self.save_folder_config(self.current_folder)
|
||||
|
||||
# Clear state
|
||||
self.filelist.clear()
|
||||
self.folders.clear()
|
||||
self.folder_configs.clear()
|
||||
self.current_folder = None
|
||||
self._undo_stack.clear()
|
||||
self._redo_stack.clear()
|
||||
|
||||
# Notify GUI
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed([])
|
||||
|
||||
def rename_tag_in_files(self, category: str, old_name: str, new_name: str) -> int:
|
||||
old_tag = Tag(category, old_name)
|
||||
affected = [f for f in self.filelist if old_tag in f.tags]
|
||||
snapshot_before = self._snapshot_files(affected)
|
||||
|
||||
new_tag = self.tagmanager.rename_tag(category, old_name, new_name)
|
||||
if new_tag is None:
|
||||
return 0
|
||||
|
||||
for f in affected:
|
||||
f.tags.remove(old_tag)
|
||||
f.tags.append(new_tag)
|
||||
f.save_metadata()
|
||||
|
||||
if affected and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
snapshot_after = self._snapshot_files(affected)
|
||||
|
||||
def _undo_rename_tag(snap=snapshot_before, cat=category, old=old_name, new=new_name):
|
||||
self.tagmanager.rename_tag(cat, new, old)
|
||||
self._restore_snapshot(snap)
|
||||
if self.on_tags_changed:
|
||||
self.on_tags_changed()
|
||||
|
||||
def _redo_rename_tag(snap=snapshot_after, cat=category, old=old_name, new=new_name):
|
||||
self.tagmanager.rename_tag(cat, old, new)
|
||||
self._restore_snapshot(snap)
|
||||
if self.on_tags_changed:
|
||||
self.on_tags_changed()
|
||||
|
||||
self._push_undo(_UndoEntry(
|
||||
description=f"Přejmenovat štítek {category}/{old_name} → {new_name}",
|
||||
undo=_undo_rename_tag,
|
||||
redo=_redo_rename_tag,
|
||||
))
|
||||
return len(affected)
|
||||
|
||||
def rename_category_in_files(self, old_category: str, new_category: str) -> int:
|
||||
old_tags = self.tagmanager.get_tags_in_category(old_category)
|
||||
if not old_tags:
|
||||
return 0
|
||||
|
||||
affected = [f for f in self.filelist
|
||||
if any(t.category == old_category for t in f.tags)]
|
||||
snapshot_before = self._snapshot_files(affected)
|
||||
|
||||
if not self.tagmanager.rename_category(old_category, new_category):
|
||||
return 0
|
||||
|
||||
for f in affected:
|
||||
f.tags = [
|
||||
Tag(new_category, t.name) if t.category == old_category else t
|
||||
for t in f.tags
|
||||
]
|
||||
f.save_metadata()
|
||||
|
||||
if affected and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
snapshot_after = self._snapshot_files(affected)
|
||||
|
||||
def _undo_rename_cat(snap=snapshot_before, old=old_category, new=new_category):
|
||||
self.tagmanager.rename_category(new, old)
|
||||
self._restore_snapshot(snap)
|
||||
if self.on_tags_changed:
|
||||
self.on_tags_changed()
|
||||
|
||||
def _redo_rename_cat(snap=snapshot_after, old=old_category, new=new_category):
|
||||
self.tagmanager.rename_category(old, new)
|
||||
self._restore_snapshot(snap)
|
||||
if self.on_tags_changed:
|
||||
self.on_tags_changed()
|
||||
|
||||
self._push_undo(_UndoEntry(
|
||||
description=f"Přejmenovat kategorii {old_category} → {new_category}",
|
||||
undo=_undo_rename_cat,
|
||||
redo=_redo_rename_cat,
|
||||
))
|
||||
return len(affected)
|
||||
|
||||
def merge_tag_in_files(self, category: str, source_name: str, target_name: str) -> int:
|
||||
source_tag = Tag(category, source_name)
|
||||
target_tag = Tag(category, target_name)
|
||||
|
||||
affected = [f for f in self.filelist if source_tag in f.tags]
|
||||
snapshot_before = self._snapshot_files(affected)
|
||||
|
||||
result_tag = self.tagmanager.merge_tag(category, source_name, target_name)
|
||||
if result_tag is None:
|
||||
return 0
|
||||
|
||||
for f in affected:
|
||||
f.tags.remove(source_tag)
|
||||
if target_tag not in f.tags:
|
||||
f.tags.append(target_tag)
|
||||
f.save_metadata()
|
||||
|
||||
if affected and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
snapshot_after = self._snapshot_files(affected)
|
||||
|
||||
def _undo_merge_tag(snap=snapshot_before, cat=category, src=source_name):
|
||||
self.tagmanager.add_tag(cat, src) # re-add deleted source tag
|
||||
self._restore_snapshot(snap)
|
||||
if self.on_tags_changed:
|
||||
self.on_tags_changed()
|
||||
|
||||
def _redo_merge_tag(snap=snapshot_after, cat=category, src=source_name):
|
||||
self.tagmanager.remove_tag(cat, src) # re-remove source from TM
|
||||
self._restore_snapshot(snap)
|
||||
if self.on_tags_changed:
|
||||
self.on_tags_changed()
|
||||
|
||||
self._push_undo(_UndoEntry(
|
||||
description=f"Sloučit štítek {category}/{source_name} → {target_name}",
|
||||
undo=_undo_merge_tag,
|
||||
redo=_redo_merge_tag,
|
||||
))
|
||||
return len(affected)
|
||||
|
||||
def merge_category_in_files(self, source_category: str, target_category: str) -> int:
|
||||
source_tags = self.tagmanager.get_tags_in_category(source_category)
|
||||
if not source_tags:
|
||||
return 0
|
||||
|
||||
source_tag_names = [t.name for t in source_tags]
|
||||
original_target_tag_names = {
|
||||
t.name for t in self.tagmanager.get_tags_in_category(target_category)
|
||||
}
|
||||
|
||||
affected = [f for f in self.filelist
|
||||
if any(t.category == source_category for t in f.tags)]
|
||||
snapshot_before = self._snapshot_files(affected)
|
||||
|
||||
if not self.tagmanager.merge_category(source_category, target_category):
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
for f in affected:
|
||||
new_tags: list[Tag] = []
|
||||
for tag in f.tags:
|
||||
if tag.category == source_category:
|
||||
new_tag = Tag(target_category, tag.name)
|
||||
if new_tag not in new_tags:
|
||||
new_tags.append(new_tag)
|
||||
else:
|
||||
if tag not in new_tags:
|
||||
new_tags.append(tag)
|
||||
f.tags = new_tags
|
||||
f.save_metadata()
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0 and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
snapshot_after = self._snapshot_files(affected)
|
||||
|
||||
def _undo_merge_cat(
|
||||
snap=snapshot_before,
|
||||
src_cat=source_category,
|
||||
tgt_cat=target_category,
|
||||
src_names=source_tag_names,
|
||||
orig_tgt=original_target_tag_names,
|
||||
):
|
||||
# Remove from target tags that came only from source
|
||||
for name in src_names:
|
||||
if name not in orig_tgt:
|
||||
self.tagmanager.remove_tag(tgt_cat, name)
|
||||
# Re-create source category
|
||||
for name in src_names:
|
||||
self.tagmanager.add_tag(src_cat, name)
|
||||
self._restore_snapshot(snap)
|
||||
if self.on_tags_changed:
|
||||
self.on_tags_changed()
|
||||
|
||||
def _redo_merge_cat(
|
||||
snap=snapshot_after,
|
||||
src_cat=source_category,
|
||||
tgt_cat=target_category,
|
||||
src_names=source_tag_names,
|
||||
):
|
||||
for name in src_names:
|
||||
self.tagmanager.add_tag(tgt_cat, name)
|
||||
self.tagmanager.remove_category(src_cat)
|
||||
self._restore_snapshot(snap)
|
||||
if self.on_tags_changed:
|
||||
self.on_tags_changed()
|
||||
|
||||
self._push_undo(_UndoEntry(
|
||||
description=f"Sloučit kategorii {source_category} → {target_category}",
|
||||
undo=_undo_merge_cat,
|
||||
redo=_redo_merge_cat,
|
||||
))
|
||||
return updated_count
|
||||
|
||||
# Legacy property for backwards compatibility
|
||||
@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,20 +0,0 @@
|
||||
from typing import List
|
||||
from .file import File
|
||||
|
||||
class ListManager:
|
||||
def __init__(self):
|
||||
# 'name' nebo 'date'
|
||||
self.sort_mode = "name"
|
||||
|
||||
def set_sort(self, mode: str):
|
||||
if mode in ("name", "date"):
|
||||
self.sort_mode = mode
|
||||
|
||||
def sort_files(self, files: List[File]) -> List[File]:
|
||||
if self.sort_mode == "name":
|
||||
return sorted(files, key=lambda f: f.filename.lower())
|
||||
else:
|
||||
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
|
||||
def date_key(f):
|
||||
return (f.date is None, f.date or "")
|
||||
return sorted(files, key=date_key)
|
||||
@@ -1,28 +1,21 @@
|
||||
# Module header
|
||||
import sys
|
||||
import subprocess
|
||||
from loguru import logger
|
||||
from .file import File
|
||||
from .tag_manager import TagManager
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit("This module is not intended to be executed as the main program.")
|
||||
|
||||
# Imports
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
# Functions
|
||||
def load_icon(path) -> ImageTk.PhotoImage:
|
||||
img = Image.open(path)
|
||||
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||
return ImageTk.PhotoImage(img)
|
||||
|
||||
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
|
||||
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager) -> None:
|
||||
"""
|
||||
Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p.
|
||||
Vyžaduje ffprobe (FFmpeg).
|
||||
Detect video vertical resolution and assign tag Resolution/{height}p.
|
||||
Requires ffprobe (FFmpeg).
|
||||
"""
|
||||
path = str(file_obj.file_path)
|
||||
try:
|
||||
# ffprobe vrátí width a height ve formátu JSON
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path],
|
||||
@@ -30,13 +23,13 @@ def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
res = result.stdout.strip() # např. "1920x1080"
|
||||
res = result.stdout.strip() # e.g. "1920x1080"
|
||||
if "x" not in res:
|
||||
return
|
||||
width, height = map(int, res.split("x"))
|
||||
tag_name = f"Rozlišení/{height}p"
|
||||
tag_obj = tagmanager.add_tag("Rozlišení", f"{height}p")
|
||||
file_obj.add_tag(tag_obj)
|
||||
print(f"Přiřazen tag {tag_name} k {file_obj.filename}")
|
||||
logger.info("Assigned tag {} to {}", tag_name, file_obj.filename)
|
||||
except Exception as e:
|
||||
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
|
||||
logger.error("Failed to get video resolution for {}: {}", file_obj.filename, e)
|
||||
|
||||
@@ -3,6 +3,27 @@ class Tag:
|
||||
self.category = category
|
||||
self.name = name
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, tag_str: str, default_category: str = "default") -> "Tag":
|
||||
"""
|
||||
Parse a tag from 'category/name' string format.
|
||||
|
||||
Args:
|
||||
tag_str: Tag string in 'category/name' format
|
||||
default_category: Category to use if no '/' in string
|
||||
|
||||
Returns:
|
||||
Tag object
|
||||
|
||||
Examples:
|
||||
Tag.from_string("Stav/Nové") -> Tag("Stav", "Nové")
|
||||
Tag.from_string("simple") -> Tag("default", "simple")
|
||||
"""
|
||||
if "/" in tag_str:
|
||||
category, name = tag_str.split("/", 1)
|
||||
return cls(category, name)
|
||||
return cls(default_category, tag_str)
|
||||
|
||||
@property
|
||||
def full_path(self):
|
||||
return f"{self.category}/{self.name}"
|
||||
|
||||
@@ -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,156 @@ 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
|
||||
|
||||
def rename_tag(self, category: str, old_name: str, new_name: str) -> Tag | None:
|
||||
"""
|
||||
Rename a tag within a category.
|
||||
|
||||
Args:
|
||||
category: The category containing the tag
|
||||
old_name: Current name of the tag
|
||||
new_name: New name for the tag
|
||||
|
||||
Returns:
|
||||
The new Tag object if successful, None if tag not found or new name already exists
|
||||
"""
|
||||
if category not in self.tags_by_category:
|
||||
return None
|
||||
|
||||
old_tag = Tag(category, old_name)
|
||||
new_tag = Tag(category, new_name)
|
||||
|
||||
# Check if old tag exists
|
||||
if old_tag not in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Check if new name already exists (and is different)
|
||||
if old_name != new_name and new_tag in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Remove old tag and add new one
|
||||
self.tags_by_category[category].discard(old_tag)
|
||||
self.tags_by_category[category].add(new_tag)
|
||||
|
||||
return new_tag
|
||||
|
||||
def rename_category(self, old_category: str, new_category: str) -> bool:
|
||||
"""
|
||||
Rename a category.
|
||||
|
||||
Args:
|
||||
old_category: Current name of the category
|
||||
new_category: New name for the category
|
||||
|
||||
Returns:
|
||||
True if successful, False if category not found or new name already exists
|
||||
"""
|
||||
if old_category not in self.tags_by_category:
|
||||
return False
|
||||
|
||||
# Check if new category already exists (and is different)
|
||||
if old_category != new_category and new_category in self.tags_by_category:
|
||||
return False
|
||||
|
||||
# Get all tags from old category
|
||||
old_tags = self.tags_by_category[old_category]
|
||||
|
||||
# Create new tags with new category
|
||||
new_tags = {Tag(new_category, tag.name) for tag in old_tags}
|
||||
|
||||
# Remove old category and add new one
|
||||
del self.tags_by_category[old_category]
|
||||
self.tags_by_category[new_category] = new_tags
|
||||
|
||||
return True
|
||||
|
||||
def merge_tag(self, category: str, source_name: str, target_name: str) -> Tag | None:
|
||||
"""
|
||||
Merge source tag into target tag (removes source, keeps target).
|
||||
|
||||
Args:
|
||||
category: The category containing both tags
|
||||
source_name: Name of the tag to merge (will be removed)
|
||||
target_name: Name of the tag to merge into (will be kept)
|
||||
|
||||
Returns:
|
||||
The target Tag object if successful, None if either tag not found
|
||||
"""
|
||||
if category not in self.tags_by_category:
|
||||
return None
|
||||
|
||||
source_tag = Tag(category, source_name)
|
||||
target_tag = Tag(category, target_name)
|
||||
|
||||
# Check if source tag exists
|
||||
if source_tag not in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Check if target tag exists
|
||||
if target_tag not in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Remove source tag (target already exists)
|
||||
self.tags_by_category[category].discard(source_tag)
|
||||
|
||||
# Clean up empty category
|
||||
if not self.tags_by_category[category]:
|
||||
self.remove_category(category)
|
||||
|
||||
return target_tag
|
||||
|
||||
def merge_category(self, source_category: str, target_category: str) -> bool:
|
||||
"""
|
||||
Merge source category into target category (moves all tags, removes source).
|
||||
|
||||
Args:
|
||||
source_category: Category to merge (will be removed)
|
||||
target_category: Category to merge into (will receive all tags)
|
||||
|
||||
Returns:
|
||||
True if successful, False if either category not found
|
||||
"""
|
||||
if source_category not in self.tags_by_category:
|
||||
return False
|
||||
|
||||
if target_category not in self.tags_by_category:
|
||||
return False
|
||||
|
||||
if source_category == target_category:
|
||||
return True # No-op
|
||||
|
||||
# Get all tags from source category
|
||||
source_tags = self.tags_by_category[source_category]
|
||||
|
||||
# Create new tags with target category and add to target
|
||||
for tag in source_tags:
|
||||
new_tag = Tag(target_category, tag.name)
|
||||
self.tags_by_category[target_category].add(new_tag)
|
||||
|
||||
# Remove source category
|
||||
del self.tags_by_category[source_category]
|
||||
|
||||
return True
|
||||
|
||||
def tag_exists(self, category: str, name: str) -> bool:
|
||||
"""Check if a tag exists in a category."""
|
||||
if category not in self.tags_by_category:
|
||||
return False
|
||||
return Tag(category, name) in self.tags_by_category[category]
|
||||
|
||||
def category_exists(self, category: str) -> bool:
|
||||
"""Check if a category exists."""
|
||||
return category in self.tags_by_category
|
||||
33
src/ui/constants.py
Normal file
33
src/ui/constants.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Shared UI constants for Tagger GUI.
|
||||
"""
|
||||
|
||||
COLORS = {
|
||||
"bg": "#ffffff",
|
||||
"sidebar_bg": "#f5f5f5",
|
||||
"toolbar_bg": "#f0f0f0",
|
||||
"selected": "#0078d7",
|
||||
"selected_text": "#ffffff",
|
||||
"border": "#d0d0d0",
|
||||
"status_bg": "#f8f8f8",
|
||||
"text": "#000000",
|
||||
}
|
||||
|
||||
TAG_COLORS = [
|
||||
"#e74c3c", # red
|
||||
"#3498db", # blue
|
||||
"#2ecc71", # green
|
||||
"#f39c12", # orange
|
||||
"#9b59b6", # purple
|
||||
"#1abc9c", # teal
|
||||
"#e91e63", # pink
|
||||
"#00bcd4", # cyan
|
||||
]
|
||||
|
||||
DEFAULT_CATEGORY_COLORS = {
|
||||
"Hodnocení": "#f1c40f", # gold/yellow for stars
|
||||
"Barva": "#95a5a6", # gray for color category
|
||||
}
|
||||
|
||||
# Categories where only one tag can be active at a time (radio-button behaviour)
|
||||
EXCLUSIVE_CATEGORIES: set[str] = {"Hodnocení"}
|
||||
211
src/ui/dialogs.py
Normal file
211
src/ui/dialogs.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Dialogs for Tagger GUI.
|
||||
"""
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout,
|
||||
QScrollArea, QWidget, QLabel, QCheckBox, QPushButton, QFrame,
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
from src.core.tag_manager import DEFAULT_TAG_ORDER
|
||||
from src.ui.constants import EXCLUSIVE_CATEGORIES
|
||||
|
||||
|
||||
class MultiFileTagAssignDialog(QDialog):
|
||||
"""Dialog for bulk tag assignment to multiple files."""
|
||||
|
||||
def __init__(self, parent, all_tags: List[Tag], files: List[File],
|
||||
category_colors: dict = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Přiřadit tagy k vybraným souborům")
|
||||
self.setMinimumSize(500, 600)
|
||||
self.result = None
|
||||
self.tags_by_full = {t.full_path: t for t in all_tags}
|
||||
self.files = files
|
||||
self.category_colors = category_colors or {}
|
||||
self.checkboxes: dict[str, QCheckBox] = {}
|
||||
self.category_checkboxes: dict[str, list] = {}
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
header = QLabel(f"Vybráno souborů: {len(self.files)}")
|
||||
header.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(header)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.NoFrame)
|
||||
|
||||
content = QWidget()
|
||||
content_layout = QVBoxLayout(content)
|
||||
content_layout.setSpacing(2)
|
||||
|
||||
file_tag_sets = [{t.full_path for t in f.tags} for f in self.files]
|
||||
|
||||
tags_by_category: dict[str, list] = {}
|
||||
for full_path, tag in self.tags_by_full.items():
|
||||
tags_by_category.setdefault(tag.category, []).append((full_path, tag))
|
||||
|
||||
for category in tags_by_category:
|
||||
if category in DEFAULT_TAG_ORDER:
|
||||
order = DEFAULT_TAG_ORDER[category]
|
||||
tags_by_category[category].sort(
|
||||
key=lambda x: order.get(x[1].name, 999)
|
||||
)
|
||||
else:
|
||||
tags_by_category[category].sort(key=lambda x: x[1].name)
|
||||
|
||||
for category in sorted(tags_by_category.keys()):
|
||||
color = self.category_colors.get(category, "#333333")
|
||||
is_exclusive = category in EXCLUSIVE_CATEGORIES
|
||||
exclusive_note = " (pouze jedno)" if is_exclusive else ""
|
||||
|
||||
cat_label = QLabel(f"▸ {category}{exclusive_note}")
|
||||
cat_label.setFont(QFont("Arial", 10, QFont.Bold))
|
||||
cat_label.setStyleSheet(f"color: {color}; margin-top: 12px;")
|
||||
content_layout.addWidget(cat_label)
|
||||
|
||||
self.category_checkboxes[category] = []
|
||||
|
||||
for full_path, tag in tags_by_category[category]:
|
||||
have_count = sum(1 for s in file_tag_sets if full_path in s)
|
||||
if have_count == 0:
|
||||
init_state = Qt.Unchecked
|
||||
elif have_count == len(self.files):
|
||||
init_state = Qt.Checked
|
||||
else:
|
||||
init_state = Qt.PartiallyChecked
|
||||
|
||||
cb = QCheckBox(f" {tag.name}")
|
||||
cb.setTristate(True)
|
||||
cb.setCheckState(init_state)
|
||||
cb.setProperty("full_path", full_path)
|
||||
cb.setProperty("category", category)
|
||||
cb.setProperty("tag_color", color)
|
||||
|
||||
self._update_checkbox_style(cb)
|
||||
cb.stateChanged.connect(lambda state, c=cb: self._on_state_changed(c))
|
||||
|
||||
content_layout.addWidget(cb)
|
||||
self.checkboxes[full_path] = cb
|
||||
self.category_checkboxes[category].append(cb)
|
||||
|
||||
content_layout.addStretch()
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self._on_ok)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def _update_checkbox_style(self, cb: QCheckBox) -> None:
|
||||
state = cb.checkState()
|
||||
color = cb.property("tag_color") or "#333333"
|
||||
if state == Qt.Unchecked:
|
||||
cb.setStyleSheet("color: #666666;")
|
||||
elif state == Qt.Checked:
|
||||
cb.setStyleSheet(f"color: {color};")
|
||||
else:
|
||||
cb.setStyleSheet("color: #cc6600;")
|
||||
|
||||
def _on_state_changed(self, cb: QCheckBox) -> None:
|
||||
category = cb.property("category")
|
||||
if category in EXCLUSIVE_CATEGORIES and cb.checkState() == Qt.Checked:
|
||||
for other_cb in self.category_checkboxes.get(category, []):
|
||||
if other_cb != cb:
|
||||
other_cb.blockSignals(True)
|
||||
other_cb.setCheckState(Qt.Unchecked)
|
||||
self._update_checkbox_style(other_cb)
|
||||
other_cb.blockSignals(False)
|
||||
self._update_checkbox_style(cb)
|
||||
|
||||
def _on_ok(self) -> None:
|
||||
self.result = {}
|
||||
for full_path, cb in self.checkboxes.items():
|
||||
state = cb.checkState()
|
||||
if state == Qt.Checked:
|
||||
self.result[full_path] = 1
|
||||
elif state == Qt.Unchecked:
|
||||
self.result[full_path] = 0
|
||||
else:
|
||||
self.result[full_path] = 2 # mixed — don't change
|
||||
self.accept()
|
||||
|
||||
|
||||
class CategorySelectionDialog(QDialog):
|
||||
"""Dialog for selecting categories for hardlink structure."""
|
||||
|
||||
def __init__(self, parent, categories: List[str], category_colors: dict,
|
||||
preselected: List[str] | None = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Vybrat kategorie")
|
||||
self.setMinimumSize(350, 400)
|
||||
self.categories = categories
|
||||
self.category_colors = category_colors
|
||||
self.preselected = preselected
|
||||
self.result = None
|
||||
self.checkboxes: dict[str, QCheckBox] = {}
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
header = QLabel("Vyberte kategorie pro vytvoření struktury:")
|
||||
header.setFont(QFont("Arial", 10, QFont.Bold))
|
||||
layout.addWidget(header)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget()
|
||||
content_layout = QVBoxLayout(content)
|
||||
|
||||
for category in sorted(self.categories):
|
||||
initial_value = self.preselected is None or category in self.preselected
|
||||
color = self.category_colors.get(category, "#333333")
|
||||
cb = QCheckBox(category)
|
||||
cb.setChecked(initial_value)
|
||||
cb.setStyleSheet(f"color: {color};")
|
||||
content_layout.addWidget(cb)
|
||||
self.checkboxes[category] = cb
|
||||
|
||||
content_layout.addStretch()
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
|
||||
sel_layout = QHBoxLayout()
|
||||
btn_all = QPushButton("Všechny")
|
||||
btn_all.clicked.connect(self._select_all)
|
||||
btn_none = QPushButton("Žádné")
|
||||
btn_none.clicked.connect(self._select_none)
|
||||
sel_layout.addWidget(btn_all)
|
||||
sel_layout.addWidget(btn_none)
|
||||
sel_layout.addStretch()
|
||||
layout.addLayout(sel_layout)
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self._on_ok)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def _select_all(self) -> None:
|
||||
for cb in self.checkboxes.values():
|
||||
cb.setChecked(True)
|
||||
|
||||
def _select_none(self) -> None:
|
||||
for cb in self.checkboxes.values():
|
||||
cb.setChecked(False)
|
||||
|
||||
def _on_ok(self) -> None:
|
||||
self.result = [cat for cat, cb in self.checkboxes.items() if cb.isChecked()]
|
||||
self.accept()
|
||||
731
src/ui/gui.py
731
src/ui/gui.py
@@ -1,711 +1,20 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, simpledialog, messagebox, filedialog
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from src.core.media_utils import load_icon
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
from src.core.list_manager import ListManager
|
||||
from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT
|
||||
from src.core.config import save_config # <-- doplněno
|
||||
|
||||
|
||||
|
||||
|
||||
class TagSelectionDialog(tk.Toplevel):
|
||||
"""
|
||||
Jednoduchý dialog pro výběr tagů (původní, používán jinde).
|
||||
(tento třída zůstává pro jednobodové použití)
|
||||
"""
|
||||
def __init__(self, parent, tags: list[str]):
|
||||
super().__init__(parent)
|
||||
self.title("Vyber tagy")
|
||||
self.selected_tags = []
|
||||
self.vars = {}
|
||||
|
||||
tk.Label(self, text="Vyber tagy k přiřazení:").pack(pady=5)
|
||||
|
||||
frame = tk.Frame(self)
|
||||
frame.pack(padx=10, pady=5)
|
||||
|
||||
for tag in tags:
|
||||
var = tk.BooleanVar(value=False)
|
||||
chk = tk.Checkbutton(frame, text=tag, variable=var)
|
||||
chk.pack(anchor="w")
|
||||
self.vars[tag] = var
|
||||
|
||||
btn_frame = tk.Frame(self)
|
||||
btn_frame.pack(pady=5)
|
||||
tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5)
|
||||
tk.Button(btn_frame, text="Zrušit", command=self.destroy).pack(side="left", padx=5)
|
||||
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
parent.wait_window(self)
|
||||
|
||||
def on_ok(self):
|
||||
self.selected_tags = [tag for tag, var in self.vars.items() if var.get()]
|
||||
self.destroy()
|
||||
|
||||
|
||||
class MultiFileTagAssignDialog(tk.Toplevel):
|
||||
def __init__(self, parent, all_tags: List[Tag], files: List[File]):
|
||||
super().__init__(parent)
|
||||
self.title("Přiřadit tagy k vybraným souborům")
|
||||
self.vars: dict[str, int] = {}
|
||||
self.checkbuttons: dict[str, tk.Checkbutton] = {}
|
||||
self.tags_by_full = {t.full_path: t for t in all_tags}
|
||||
self.files = files
|
||||
|
||||
tk.Label(self, text=f"Vybráno souborů: {len(files)}").pack(pady=5)
|
||||
|
||||
frame = tk.Frame(self)
|
||||
frame.pack(padx=10, pady=5, fill="both", expand=True)
|
||||
|
||||
file_tag_sets = [{t.full_path for t in f.tags} for f in files]
|
||||
|
||||
for full_path, tag in sorted(self.tags_by_full.items()):
|
||||
have_count = sum(1 for s in file_tag_sets if full_path in s)
|
||||
if have_count == 0:
|
||||
init = 0
|
||||
elif have_count == len(files):
|
||||
init = 1
|
||||
else:
|
||||
init = 2 # mixed
|
||||
|
||||
cb = tk.Checkbutton(frame, text=full_path, anchor="w")
|
||||
cb.state_value = init
|
||||
cb.full_path = full_path
|
||||
cb.pack(fill="x", anchor="w")
|
||||
cb.bind("<Button-1>", self._on_toggle)
|
||||
|
||||
self._update_checkbox_look(cb)
|
||||
self.checkbuttons[full_path] = cb
|
||||
self.vars[full_path] = init
|
||||
|
||||
btn_frame = tk.Frame(self)
|
||||
btn_frame.pack(pady=5)
|
||||
tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5)
|
||||
tk.Button(btn_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5)
|
||||
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
parent.wait_window(self)
|
||||
|
||||
def _on_toggle(self, event):
|
||||
cb: tk.Checkbutton = event.widget
|
||||
cur = cb.state_value
|
||||
if cur == 0: # OFF → ON
|
||||
cb.state_value = 1
|
||||
elif cur == 1: # ON → OFF
|
||||
cb.state_value = 0
|
||||
elif cur == 2: # MIXED → ON
|
||||
cb.state_value = 1
|
||||
self._update_checkbox_look(cb)
|
||||
return "break"
|
||||
|
||||
def _update_checkbox_look(self, cb: tk.Checkbutton):
|
||||
"""Aktualizuje vizuál podle stavu."""
|
||||
v = cb.state_value
|
||||
if v == 0:
|
||||
cb.deselect()
|
||||
cb.config(fg="black")
|
||||
elif v == 1:
|
||||
cb.select()
|
||||
cb.config(fg="blue")
|
||||
elif v == 2:
|
||||
cb.deselect() # mixed = nezaškrtnuté, ale červený text
|
||||
cb.config(fg="red")
|
||||
|
||||
def on_ok(self):
|
||||
self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()}
|
||||
self.destroy()
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, filehandler: FileManager, tagmanager: TagManager):
|
||||
self.states = {}
|
||||
self.listbox_map: dict[int, list[File]] = {}
|
||||
self.selected_tree_item_for_context = None
|
||||
self.selected_list_index_for_context = None
|
||||
self.filehandler = filehandler
|
||||
self.tagmanager = tagmanager
|
||||
self.list_manager = ListManager()
|
||||
|
||||
# tady jen připravíme proměnnou, ale nevytváříme BooleanVar!
|
||||
self.hide_ignored_var = None
|
||||
|
||||
self.filter_text = ""
|
||||
self.show_full_path = False
|
||||
self.sort_mode = "name"
|
||||
self.sort_order = "asc"
|
||||
|
||||
self.filehandler.on_files_changed = self.update_files_from_manager
|
||||
|
||||
def detect_video_resolution(self):
|
||||
files = self.get_selected_files_objects()
|
||||
if not files:
|
||||
self.status_bar.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
|
||||
count = 0
|
||||
for f in files:
|
||||
try:
|
||||
path = str(f.file_path)
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=height", "-of", "csv=p=0", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
height_str = result.stdout.strip()
|
||||
if not height_str.isdigit():
|
||||
continue
|
||||
height = int(height_str)
|
||||
tag_name = f"{height}p"
|
||||
tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name)
|
||||
f.add_tag(tag_obj)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f"Chyba u {f.filename}: {e}")
|
||||
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_bar.config(text=f"Přiřazeno rozlišení tagů k {count} souborům")
|
||||
|
||||
|
||||
# ==================================================
|
||||
# MAIN GUI
|
||||
# ==================================================
|
||||
def main(self):
|
||||
root = tk.Tk()
|
||||
root.title(APP_NAME + " " + VERSION)
|
||||
root.geometry(APP_VIEWPORT)
|
||||
self.root = root
|
||||
|
||||
# teď už máme root, takže můžeme vytvořit BooleanVar
|
||||
self.hide_ignored_var = tk.BooleanVar(value=False, master=root)
|
||||
|
||||
last = self.filehandler.config.get("last_folder")
|
||||
if last:
|
||||
try:
|
||||
self.filehandler.append(Path(last))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- Ikony
|
||||
unchecked = load_icon("src/resources/images/32/32_unchecked.png")
|
||||
checked = load_icon("src/resources/images/32/32_checked.png")
|
||||
tag_icon = load_icon("src/resources/images/32/32_tag.png")
|
||||
self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon}
|
||||
root.unchecked_img = unchecked
|
||||
root.checked_img = checked
|
||||
root.tag_img = tag_icon
|
||||
|
||||
# ---- Layout
|
||||
menu_bar = tk.Menu(root)
|
||||
root.config(menu=menu_bar)
|
||||
|
||||
file_menu = tk.Menu(menu_bar, tearoff=0)
|
||||
file_menu.add_command(label="Open Folder...", command=self.open_folder_dialog)
|
||||
file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_command(label="Exit", command=root.quit)
|
||||
|
||||
view_menu = tk.Menu(menu_bar, tearoff=0)
|
||||
view_menu.add_checkbutton(
|
||||
label="Skrýt ignorované",
|
||||
variable=self.hide_ignored_var,
|
||||
command=self.toggle_hide_ignored
|
||||
)
|
||||
function_menu = tk.Menu(menu_bar, tearoff=0)
|
||||
function_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
|
||||
function_menu.add_command(label="Detekovat rozlišení u videí", command=self.detect_video_resolution)
|
||||
|
||||
|
||||
menu_bar.add_cascade(label="Soubor", menu=file_menu)
|
||||
menu_bar.add_cascade(label="Pohled", menu=view_menu)
|
||||
menu_bar.add_cascade(label="Funkce", menu=function_menu)
|
||||
|
||||
main_frame = tk.Frame(root)
|
||||
main_frame.pack(fill="both", expand=True)
|
||||
main_frame.columnconfigure(0, weight=1)
|
||||
main_frame.columnconfigure(1, weight=2)
|
||||
main_frame.rowconfigure(0, weight=1)
|
||||
|
||||
# ---- Tree (left)
|
||||
self.tree = ttk.Treeview(main_frame)
|
||||
self.tree.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
|
||||
self.tree.bind("<Button-1>", self.on_tree_left_click)
|
||||
self.tree.bind("<Button-3>", self.on_tree_right_click)
|
||||
|
||||
# ---- Right side (filter + listbox)
|
||||
right_frame = tk.Frame(main_frame)
|
||||
right_frame.grid(row=0, column=1, sticky="nsew", padx=4, pady=4)
|
||||
right_frame.rowconfigure(1, weight=1)
|
||||
right_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Filter + buttons row
|
||||
filter_frame = tk.Frame(right_frame)
|
||||
filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 4))
|
||||
filter_frame.columnconfigure(0, weight=1)
|
||||
|
||||
self.filter_entry = tk.Entry(filter_frame)
|
||||
self.filter_entry.grid(row=0, column=0, sticky="ew")
|
||||
self.filter_entry.bind("<KeyRelease>", lambda e: self.on_filter_changed())
|
||||
|
||||
self.btn_toggle_path = tk.Button(filter_frame, text="Name", width=6, command=self.toggle_show_path)
|
||||
self.btn_toggle_path.grid(row=0, column=1, padx=2)
|
||||
|
||||
self.btn_toggle_sortmode = tk.Button(filter_frame, text="Sort:Name", width=8, command=self.toggle_sort_mode)
|
||||
self.btn_toggle_sortmode.grid(row=0, column=2, padx=2)
|
||||
|
||||
self.btn_toggle_order = tk.Button(filter_frame, text="ASC", width=5, command=self.toggle_sort_order)
|
||||
self.btn_toggle_order.grid(row=0, column=3, padx=2)
|
||||
|
||||
# Listbox + scrollbar
|
||||
self.listbox = tk.Listbox(right_frame, selectmode="extended")
|
||||
self.listbox.grid(row=1, column=0, sticky="nsew")
|
||||
self.listbox.bind("<Double-1>", self.on_list_double)
|
||||
self.listbox.bind("<Button-3>", self.on_list_right_click)
|
||||
|
||||
lb_scroll = tk.Scrollbar(right_frame, orient="vertical", command=self.listbox.yview)
|
||||
lb_scroll.grid(row=1, column=1, sticky="ns")
|
||||
self.listbox.config(yscrollcommand=lb_scroll.set)
|
||||
|
||||
# ---- Status bar
|
||||
self.status_bar = tk.Label(root, text="Připraven", anchor="w", relief="sunken")
|
||||
self.status_bar.pack(side="bottom", fill="x")
|
||||
|
||||
# ---- Context menus
|
||||
self.tree_menu = tk.Menu(root, tearoff=0)
|
||||
self.tree_menu.add_command(label="Nový štítek", command=self.tree_add_tag)
|
||||
self.tree_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag)
|
||||
|
||||
self.list_menu = tk.Menu(root, tearoff=0)
|
||||
self.list_menu.add_command(label="Otevřít soubor", command=self.list_open_file)
|
||||
self.list_menu.add_command(label="Smazat z indexu", command=self.list_remove_file)
|
||||
self.list_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
|
||||
self.list_menu.add_command(label="Přiřadit štítek", command=self.assign_tag_to_selected_bulk)
|
||||
|
||||
# ---- Root node
|
||||
root_id = self.tree.insert("", "end", text="Štítky", image=self.icons["tag"])
|
||||
self.tree.item(root_id, open=True)
|
||||
self.root_id = root_id
|
||||
|
||||
# ⚡ refresh při startu
|
||||
self.refresh_tree_tags()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
root.mainloop()
|
||||
|
||||
|
||||
# ==================================================
|
||||
# FILTER + SORT TOGGLES
|
||||
# ==================================================
|
||||
def set_ignore_patterns(self):
|
||||
current = ", ".join(self.filehandler.config.get("ignore_patterns", []))
|
||||
s = simpledialog.askstring("Ignore patterns", "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", initialvalue=current)
|
||||
if s is None:
|
||||
return
|
||||
patterns = [p.strip() for p in s.split(",") if p.strip()]
|
||||
self.filehandler.config["ignore_patterns"] = patterns
|
||||
save_config(self.filehandler.config)
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_hide_ignored(self):
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def on_filter_changed(self):
|
||||
self.filter_text = self.filter_entry.get().strip().lower()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_show_path(self):
|
||||
self.show_full_path = not self.show_full_path
|
||||
self.btn_toggle_path.config(text="Path" if self.show_full_path else "Name")
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_sort_mode(self):
|
||||
self.sort_mode = "date" if self.sort_mode == "name" else "name"
|
||||
self.btn_toggle_sortmode.config(text=f"Sort:{self.sort_mode.capitalize()}")
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_sort_order(self):
|
||||
self.sort_order = "desc" if self.sort_order == "asc" else "asc"
|
||||
self.btn_toggle_order.config(text=self.sort_order.upper())
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
# ==================================================
|
||||
# FILE REFRESH + MAP
|
||||
# ==================================================
|
||||
def update_files_from_manager(self, filelist=None):
|
||||
if filelist is None:
|
||||
filelist = self.filehandler.filelist
|
||||
|
||||
# filtr tagy
|
||||
checked_tags = self.get_checked_tags()
|
||||
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
|
||||
|
||||
# filtr text
|
||||
if self.filter_text:
|
||||
filtered_files = [
|
||||
f for f in filtered_files
|
||||
if self.filter_text in f.filename.lower() or
|
||||
(self.show_full_path and self.filter_text in str(f.file_path).lower())
|
||||
]
|
||||
|
||||
if self.hide_ignored_var and self.hide_ignored_var.get():
|
||||
filtered_files = [
|
||||
f for f in filtered_files
|
||||
if "Stav/Ignorované" not in {t.full_path for t in f.tags}
|
||||
]
|
||||
|
||||
|
||||
|
||||
# řazení
|
||||
reverse = (self.sort_order == "desc")
|
||||
if self.sort_mode == "name":
|
||||
filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse)
|
||||
elif self.sort_mode == "date":
|
||||
filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse)
|
||||
|
||||
# naplníme listbox
|
||||
self.listbox.delete(0, "end")
|
||||
self.listbox_map = {}
|
||||
|
||||
for i, f in enumerate(filtered_files):
|
||||
if self.show_full_path:
|
||||
display = str(f.file_path)
|
||||
else:
|
||||
display = f.filename
|
||||
if f.date:
|
||||
display = f"{display} — {f.date}"
|
||||
self.listbox.insert("end", display)
|
||||
self.listbox_map[i] = [f]
|
||||
|
||||
self.status_bar.config(text=f"Zobrazeno {self.listbox.size()} položek")
|
||||
|
||||
# ==================================================
|
||||
# GET SELECTED FILES
|
||||
# ==================================================
|
||||
def get_selected_files_objects(self):
|
||||
indices = self.listbox.curselection()
|
||||
files = []
|
||||
for idx in indices:
|
||||
files.extend(self.listbox_map.get(idx, []))
|
||||
return files
|
||||
|
||||
# ==================================================
|
||||
# ASSIGN TAG (jednoduchý)
|
||||
# ==================================================
|
||||
def assign_tag_to_selected(self):
|
||||
files = self.get_selected_files_objects()
|
||||
if not files:
|
||||
self.status_bar.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
|
||||
all_tags: List[Tag] = []
|
||||
for category in self.tagmanager.get_categories():
|
||||
for tag in self.tagmanager.get_tags_in_category(category):
|
||||
all_tags.append(tag)
|
||||
|
||||
if not all_tags:
|
||||
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
|
||||
return
|
||||
|
||||
tag_strings = [tag.full_path for tag in all_tags]
|
||||
dialog = TagSelectionDialog(self.root, tag_strings)
|
||||
selected_tag_strings = dialog.selected_tags
|
||||
|
||||
if not selected_tag_strings:
|
||||
self.status_bar.config(text="Nebyl vybrán žádný tag")
|
||||
return
|
||||
|
||||
selected_tags: list[Tag] = []
|
||||
for full_tag in selected_tag_strings:
|
||||
if "/" in full_tag:
|
||||
category, name = full_tag.split("/", 1)
|
||||
selected_tags.append(self.tagmanager.add_tag(category, name))
|
||||
|
||||
for tag in selected_tags:
|
||||
self.filehandler.assign_tag_to_file_objects(files, tag)
|
||||
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_bar.config(text=f"Přiřazeny tagy: {', '.join(selected_tag_strings)}")
|
||||
|
||||
# ==================================================
|
||||
# ASSIGN TAG (pokročilé pro více souborů - tri-state)
|
||||
# ==================================================
|
||||
def assign_tag_to_selected_bulk(self):
|
||||
files = self.get_selected_files_objects()
|
||||
if not files:
|
||||
self.status_bar.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
|
||||
all_tags: List[Tag] = []
|
||||
for category in self.tagmanager.get_categories():
|
||||
for tag in self.tagmanager.get_tags_in_category(category):
|
||||
all_tags.append(tag)
|
||||
|
||||
if not all_tags:
|
||||
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
|
||||
return
|
||||
|
||||
dialog = MultiFileTagAssignDialog(self.root, all_tags, files)
|
||||
result = getattr(dialog, "result", None)
|
||||
if result is None:
|
||||
self.status_bar.config(text="Přiřazení zrušeno")
|
||||
return
|
||||
|
||||
for full_path, state in result.items():
|
||||
if state == 1:
|
||||
if "/" in full_path:
|
||||
category, name = full_path.split("/", 1)
|
||||
tag_obj = self.tagmanager.add_tag(category, name)
|
||||
self.filehandler.assign_tag_to_file_objects(files, tag_obj)
|
||||
elif state == 0:
|
||||
if "/" in full_path:
|
||||
category, name = full_path.split("/", 1)
|
||||
from src.core.tag import Tag as TagClass
|
||||
tag_obj = TagClass(category, name)
|
||||
self.filehandler.remove_tag_from_file_objects(files, tag_obj)
|
||||
else:
|
||||
continue
|
||||
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_bar.config(text="Hromadné přiřazení tagů dokončeno")
|
||||
|
||||
# ==================================================
|
||||
# SET DATE FOR SELECTED FILES
|
||||
# ==================================================
|
||||
def set_date_for_selected(self):
|
||||
files = self.get_selected_files_objects()
|
||||
if not files:
|
||||
self.status_bar.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):"
|
||||
date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root)
|
||||
if date_str is None:
|
||||
return
|
||||
for f in files:
|
||||
f.set_date(date_str if date_str != "" else None)
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_bar.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)")
|
||||
|
||||
# ==================================================
|
||||
# DOUBLE CLICK OPEN
|
||||
# ==================================================
|
||||
def on_list_double(self, event):
|
||||
for f in self.get_selected_files_objects():
|
||||
self.open_file(f.file_path)
|
||||
|
||||
# ==================================================
|
||||
# OPEN FILE
|
||||
# ==================================================
|
||||
def open_file(self, path):
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
os.startfile(path)
|
||||
elif sys.platform.startswith("darwin"):
|
||||
subprocess.call(["open", path])
|
||||
else:
|
||||
subprocess.call(["xdg-open", path])
|
||||
self.status_bar.config(text=f"Otevírám: {path}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}")
|
||||
|
||||
# ==================================================
|
||||
# LIST CONTEXT MENU
|
||||
# ==================================================
|
||||
def on_list_right_click(self, event):
|
||||
idx = self.listbox.nearest(event.y)
|
||||
if idx is None:
|
||||
return
|
||||
|
||||
# pokud položka není součástí aktuálního výběru, přidáme ji
|
||||
if idx not in self.listbox.curselection():
|
||||
self.listbox.selection_set(idx)
|
||||
|
||||
self.selected_list_index_for_context = idx
|
||||
self.list_menu.tk_popup(event.x_root, event.y_root)
|
||||
|
||||
|
||||
def list_open_file(self):
|
||||
for f in self.get_selected_files_objects():
|
||||
self.open_file(f.file_path)
|
||||
|
||||
def list_remove_file(self):
|
||||
files = self.get_selected_files_objects()
|
||||
if not files:
|
||||
return
|
||||
ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?")
|
||||
if ans:
|
||||
for f in files:
|
||||
if f in self.filehandler.filelist:
|
||||
self.filehandler.filelist.remove(f)
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_bar.config(text=f"Odstraněno {len(files)} souborů z indexu")
|
||||
|
||||
# ==================================================
|
||||
# OPEN FOLDER
|
||||
# ==================================================
|
||||
def open_folder_dialog(self):
|
||||
folder = filedialog.askdirectory(title="Vyber složku pro sledování")
|
||||
if not folder:
|
||||
return
|
||||
folder_path = Path(folder)
|
||||
try:
|
||||
self.filehandler.append(folder_path)
|
||||
for f in self.filehandler.filelist:
|
||||
if f.tags and f.tagmanager:
|
||||
for t in f.tags:
|
||||
f.tagmanager.add_tag(t.category, t.name)
|
||||
self.status_bar.config(text=f"Přidána složka: {folder_path}")
|
||||
self.refresh_tree_tags()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}")
|
||||
|
||||
# ==================================================
|
||||
# TREE EVENTS
|
||||
# ==================================================
|
||||
def on_tree_left_click(self, event):
|
||||
region = self.tree.identify("region", event.x, event.y)
|
||||
if region not in ("tree", "icon"):
|
||||
return
|
||||
|
||||
item_id = self.tree.identify_row(event.y)
|
||||
if not item_id:
|
||||
return
|
||||
|
||||
parent_id = self.tree.parent(item_id)
|
||||
if parent_id == "" or parent_id == self.root_id:
|
||||
is_open = self.tree.item(item_id, "open")
|
||||
self.tree.item(item_id, open=not is_open)
|
||||
return
|
||||
|
||||
self.states[item_id] = not self.states.get(item_id, False)
|
||||
self.tree.item(
|
||||
item_id,
|
||||
image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]
|
||||
)
|
||||
self.status_bar.config(
|
||||
text=f"Tag {'checked' if self.states[item_id] else 'unchecked'}: {self.build_full_tag(item_id)}"
|
||||
)
|
||||
|
||||
checked_tags = self.get_checked_tags()
|
||||
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
|
||||
self.update_files_from_manager(filtered_files)
|
||||
|
||||
def on_tree_right_click(self, event):
|
||||
item_id = self.tree.identify_row(event.y)
|
||||
if item_id:
|
||||
self.selected_tree_item_for_context = item_id
|
||||
self.tree.selection_set(item_id)
|
||||
self.tree_menu.tk_popup(event.x_root, event.y_root)
|
||||
else:
|
||||
menu = tk.Menu(self.root, tearoff=0)
|
||||
menu.add_command(label="Nový top-level tag", command=lambda: self.tree_add_tag(background=True))
|
||||
menu.tk_popup(event.x_root, event.y_root)
|
||||
|
||||
# ==================================================
|
||||
# TREE TAG CRUD
|
||||
# ==================================================
|
||||
def tree_add_tag(self, background=False):
|
||||
name = simpledialog.askstring("Nový tag", "Název tagu:")
|
||||
if not name:
|
||||
return
|
||||
parent = self.selected_tree_item_for_context if not background else self.root_id
|
||||
new_id = self.tree.insert(parent, "end", text=name, image=self.icons["unchecked"])
|
||||
self.states[new_id] = False
|
||||
|
||||
if parent == self.root_id:
|
||||
category = name
|
||||
self.tagmanager.add_category(category)
|
||||
self.tree.item(new_id, image=self.icons["tag"])
|
||||
else:
|
||||
category = self.tree.item(parent, "text")
|
||||
self.tagmanager.add_tag(category, name)
|
||||
|
||||
self.status_bar.config(text=f"Vytvořen tag: {self.build_full_tag(new_id)}")
|
||||
|
||||
def tree_delete_tag(self):
|
||||
item = self.selected_tree_item_for_context
|
||||
if not item:
|
||||
return
|
||||
full = self.build_full_tag(item)
|
||||
ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{full}'?")
|
||||
if not ans:
|
||||
return
|
||||
tag_name = self.tree.item(item, "text")
|
||||
parent_id = self.tree.parent(item)
|
||||
self.tree.delete(item)
|
||||
self.states.pop(item, None)
|
||||
|
||||
if parent_id == self.root_id:
|
||||
self.tagmanager.remove_category(tag_name)
|
||||
else:
|
||||
category = self.tree.item(parent_id, "text")
|
||||
self.tagmanager.remove_tag(category, tag_name)
|
||||
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_bar.config(text=f"Smazán tag: {full}")
|
||||
|
||||
# ==================================================
|
||||
# TREE HELPERS
|
||||
# ==================================================
|
||||
def build_full_tag(self, item_id):
|
||||
parts = []
|
||||
cur = item_id
|
||||
while cur and cur != self.root_id:
|
||||
parts.append(self.tree.item(cur, "text"))
|
||||
cur = self.tree.parent(cur)
|
||||
parts.reverse()
|
||||
return "/".join(parts) if parts else ""
|
||||
|
||||
def get_checked_full_tags(self):
|
||||
return {self.build_full_tag(i) for i, v in self.states.items() if v}
|
||||
|
||||
def refresh_tree_tags(self):
|
||||
for child in self.tree.get_children(self.root_id):
|
||||
self.tree.delete(child)
|
||||
|
||||
for category in self.tagmanager.get_categories():
|
||||
cat_id = self.tree.insert(self.root_id, "end", text=category, image=self.icons["tag"])
|
||||
self.states[cat_id] = False
|
||||
for tag in self.tagmanager.get_tags_in_category(category):
|
||||
tag_id = self.tree.insert(cat_id, "end", text=tag.name, image=self.icons["unchecked"])
|
||||
self.states[tag_id] = False
|
||||
|
||||
self.tree.item(self.root_id, open=True)
|
||||
|
||||
def get_checked_tags(self) -> List[Tag]:
|
||||
tags: List[Tag] = []
|
||||
for item_id, checked in self.states.items():
|
||||
if not checked:
|
||||
continue
|
||||
parent_id = self.tree.parent(item_id)
|
||||
if parent_id == self.root_id:
|
||||
continue
|
||||
category = self.tree.item(parent_id, "text")
|
||||
name = self.tree.item(item_id, "text")
|
||||
tags.append(Tag(category, name))
|
||||
return tags
|
||||
|
||||
def _get_checked_recursive(self, item):
|
||||
tags = []
|
||||
if self.states.get(item, False):
|
||||
parent = self.tree.parent(item)
|
||||
if parent and parent != self.root_id:
|
||||
parent_text = self.tree.item(parent, "text")
|
||||
text = self.tree.item(item, "text")
|
||||
tags.append(f"{parent_text}/{text}")
|
||||
for child in self.tree.get_children(item):
|
||||
tags.extend(self._get_checked_recursive(child))
|
||||
return tags
|
||||
"""
|
||||
Thin re-export shim — the GUI has been split into sub-modules.
|
||||
Import directly from the sub-modules for new code.
|
||||
"""
|
||||
from src.ui.main_window import App, MainWindow
|
||||
from src.ui.dialogs import CategorySelectionDialog, MultiFileTagAssignDialog
|
||||
from src.ui.workers import VideoResolutionWorker
|
||||
from src.ui.constants import COLORS, TAG_COLORS, DEFAULT_CATEGORY_COLORS, EXCLUSIVE_CATEGORIES
|
||||
|
||||
__all__ = [
|
||||
"App",
|
||||
"MainWindow",
|
||||
"CategorySelectionDialog",
|
||||
"MultiFileTagAssignDialog",
|
||||
"VideoResolutionWorker",
|
||||
"COLORS",
|
||||
"TAG_COLORS",
|
||||
"DEFAULT_CATEGORY_COLORS",
|
||||
"EXCLUSIVE_CATEGORIES",
|
||||
]
|
||||
|
||||
1185
src/ui/main_window.py
Normal file
1185
src/ui/main_window.py
Normal file
File diff suppressed because it is too large
Load Diff
22
src/ui/utils.py
Normal file
22
src/ui/utils.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
UI utility functions for Tagger GUI (PySide6).
|
||||
"""
|
||||
from pathlib import Path
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
|
||||
|
||||
def load_icon(path: str | Path, size: int = 16) -> QIcon:
|
||||
"""
|
||||
Load an icon from file and optionally resize.
|
||||
|
||||
Args:
|
||||
path: Path to the image file
|
||||
size: Icon size in pixels (default 16)
|
||||
|
||||
Returns:
|
||||
QIcon object
|
||||
"""
|
||||
pixmap = QPixmap(str(path))
|
||||
if not pixmap.isNull():
|
||||
pixmap = pixmap.scaled(size, size)
|
||||
return QIcon(pixmap)
|
||||
30
src/ui/workers.py
Normal file
30
src/ui/workers.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Background QThread workers for Tagger GUI.
|
||||
"""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from src.core.media_utils import add_video_resolution_tag
|
||||
|
||||
|
||||
class VideoResolutionWorker(QThread):
|
||||
"""Runs ffprobe on a list of files in a background thread."""
|
||||
|
||||
progress = Signal(int, int) # (current, total)
|
||||
finished = Signal(int) # count of successfully tagged files
|
||||
|
||||
def __init__(self, files: list, tagmanager) -> None:
|
||||
super().__init__()
|
||||
self.files = files
|
||||
self.tagmanager = tagmanager
|
||||
|
||||
def run(self) -> None:
|
||||
count = 0
|
||||
total = len(self.files)
|
||||
for i, f in enumerate(self.files, 1):
|
||||
try:
|
||||
add_video_resolution_tag(f, self.tagmanager)
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
self.progress.emit(i, total)
|
||||
self.finished.emit(count)
|
||||
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
|
||||
380
tests/test_config.py
Normal file
380
tests/test_config.py
Normal file
@@ -0,0 +1,380 @@
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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 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
|
||||
262
tests/test_csfd.py
Normal file
262
tests/test_csfd.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Tests for CSFD.cz scraper module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from src.core.csfd import (
|
||||
CSFDMovie,
|
||||
fetch_movie,
|
||||
search_movies,
|
||||
fetch_movie_by_id,
|
||||
_extract_csfd_id,
|
||||
_parse_duration,
|
||||
_extract_json_ld,
|
||||
_extract_rating,
|
||||
_extract_poster,
|
||||
_extract_plot,
|
||||
_extract_genres,
|
||||
_extract_origin_info,
|
||||
_check_dependencies,
|
||||
)
|
||||
|
||||
|
||||
# Sample HTML for testing
|
||||
SAMPLE_JSON_LD = """
|
||||
{
|
||||
"@type": "Movie",
|
||||
"name": "Test Movie",
|
||||
"director": [{"@type": "Person", "name": "Test Director"}],
|
||||
"actor": [{"@type": "Person", "name": "Actor 1"}, {"@type": "Person", "name": "Actor 2"}],
|
||||
"aggregateRating": {"ratingValue": 85.5, "ratingCount": 1000},
|
||||
"duration": "PT120M",
|
||||
"description": "A test movie description."
|
||||
}
|
||||
"""
|
||||
|
||||
SAMPLE_HTML = """
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/ld+json">%s</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="film-rating-average">85%%</div>
|
||||
<div class="genres">
|
||||
<a href="/zanry/1/">Drama</a> /
|
||||
<a href="/zanry/2/">Thriller</a>
|
||||
</div>
|
||||
<div class="origin">Česko, 2020, 120 min</div>
|
||||
<div class="film-poster">
|
||||
<img src="//image.example.com/poster.jpg">
|
||||
</div>
|
||||
<div class="plot-full"><p>Full plot description.</p></div>
|
||||
</body>
|
||||
</html>
|
||||
""" % SAMPLE_JSON_LD
|
||||
|
||||
|
||||
class TestCSFDMovie:
|
||||
"""Tests for CSFDMovie dataclass."""
|
||||
|
||||
def test_csfd_movie_basic(self):
|
||||
"""Test basic CSFDMovie creation."""
|
||||
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
|
||||
assert movie.title == "Test"
|
||||
assert movie.url == "https://csfd.cz/film/123/"
|
||||
assert movie.year is None
|
||||
assert movie.genres == []
|
||||
assert movie.rating is None
|
||||
|
||||
def test_csfd_movie_full(self):
|
||||
"""Test CSFDMovie with all fields."""
|
||||
movie = CSFDMovie(
|
||||
title="Test Movie",
|
||||
url="https://csfd.cz/film/123/",
|
||||
year=2020,
|
||||
genres=["Drama", "Thriller"],
|
||||
directors=["Director 1"],
|
||||
actors=["Actor 1", "Actor 2"],
|
||||
rating=85,
|
||||
rating_count=1000,
|
||||
duration=120,
|
||||
country="Česko",
|
||||
poster_url="https://image.example.com/poster.jpg",
|
||||
plot="A test movie.",
|
||||
csfd_id=123
|
||||
)
|
||||
assert movie.year == 2020
|
||||
assert movie.genres == ["Drama", "Thriller"]
|
||||
assert movie.rating == 85
|
||||
assert movie.duration == 120
|
||||
assert movie.csfd_id == 123
|
||||
|
||||
def test_csfd_movie_str(self):
|
||||
"""Test CSFDMovie string representation."""
|
||||
movie = CSFDMovie(
|
||||
title="Test Movie",
|
||||
url="https://csfd.cz/film/123/",
|
||||
year=2020,
|
||||
genres=["Drama"],
|
||||
directors=["Director 1"],
|
||||
rating=85
|
||||
)
|
||||
s = str(movie)
|
||||
assert "Test Movie (2020)" in s
|
||||
assert "85%" in s
|
||||
assert "Drama" in s
|
||||
assert "Director 1" in s
|
||||
|
||||
def test_csfd_movie_str_minimal(self):
|
||||
"""Test CSFDMovie string with minimal data."""
|
||||
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
|
||||
s = str(movie)
|
||||
assert "Test" in s
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Tests for helper functions."""
|
||||
|
||||
def test_extract_csfd_id_valid(self):
|
||||
"""Test extracting CSFD ID from valid URL."""
|
||||
assert _extract_csfd_id("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/") == 9423
|
||||
assert _extract_csfd_id("https://www.csfd.cz/film/123456/") == 123456
|
||||
assert _extract_csfd_id("/film/999/prehled/") == 999
|
||||
|
||||
def test_extract_csfd_id_invalid(self):
|
||||
"""Test extracting CSFD ID from invalid URL."""
|
||||
assert _extract_csfd_id("https://www.csfd.cz/") is None
|
||||
assert _extract_csfd_id("not-a-url") is None
|
||||
|
||||
def test_parse_duration_valid(self):
|
||||
"""Test parsing ISO 8601 duration."""
|
||||
assert _parse_duration("PT97M") == 97
|
||||
assert _parse_duration("PT120M") == 120
|
||||
assert _parse_duration("PT60M") == 60
|
||||
|
||||
def test_parse_duration_invalid(self):
|
||||
"""Test parsing invalid duration."""
|
||||
assert _parse_duration("") is None
|
||||
assert _parse_duration("invalid") is None
|
||||
assert _parse_duration("PT") is None
|
||||
|
||||
|
||||
class TestHTMLExtraction:
|
||||
"""Tests for HTML extraction functions."""
|
||||
|
||||
@pytest.fixture
|
||||
def soup(self):
|
||||
"""Create BeautifulSoup object from sample HTML."""
|
||||
from bs4 import BeautifulSoup
|
||||
return BeautifulSoup(SAMPLE_HTML, "html.parser")
|
||||
|
||||
def test_extract_json_ld(self, soup):
|
||||
"""Test extracting data from JSON-LD."""
|
||||
data = _extract_json_ld(soup)
|
||||
assert data["title"] == "Test Movie"
|
||||
assert data["directors"] == ["Test Director"]
|
||||
assert data["actors"] == ["Actor 1", "Actor 2"]
|
||||
assert data["rating"] == 86 # Rounded from 85.5
|
||||
assert data["rating_count"] == 1000
|
||||
assert data["duration"] == 120
|
||||
|
||||
def test_extract_rating(self, soup):
|
||||
"""Test extracting rating from HTML."""
|
||||
rating = _extract_rating(soup)
|
||||
assert rating == 85
|
||||
|
||||
def test_extract_genres(self, soup):
|
||||
"""Test extracting genres from HTML."""
|
||||
genres = _extract_genres(soup)
|
||||
assert "Drama" in genres
|
||||
assert "Thriller" in genres
|
||||
|
||||
def test_extract_poster(self, soup):
|
||||
"""Test extracting poster URL."""
|
||||
poster = _extract_poster(soup)
|
||||
assert poster == "https://image.example.com/poster.jpg"
|
||||
|
||||
def test_extract_plot(self, soup):
|
||||
"""Test extracting plot."""
|
||||
plot = _extract_plot(soup)
|
||||
assert plot == "Full plot description."
|
||||
|
||||
def test_extract_origin_info(self, soup):
|
||||
"""Test extracting origin info."""
|
||||
info = _extract_origin_info(soup)
|
||||
assert info["country"] == "Česko"
|
||||
assert info["year"] == 2020
|
||||
assert info["duration"] == 120
|
||||
|
||||
|
||||
class TestFetchMovie:
|
||||
"""Tests for fetch_movie function."""
|
||||
|
||||
@patch("src.core.csfd.requests")
|
||||
def test_fetch_movie_success(self, mock_requests):
|
||||
"""Test successful movie fetch."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = SAMPLE_HTML
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
movie = fetch_movie("https://www.csfd.cz/film/123-test/")
|
||||
|
||||
assert movie.title == "Test Movie"
|
||||
assert movie.csfd_id == 123
|
||||
assert movie.rating == 86
|
||||
assert "Drama" in movie.genres
|
||||
mock_requests.get.assert_called_once()
|
||||
|
||||
@patch("src.core.csfd.requests")
|
||||
def test_fetch_movie_network_error(self, mock_requests):
|
||||
"""Test network error handling."""
|
||||
import requests as real_requests
|
||||
mock_requests.get.side_effect = real_requests.RequestException("Network error")
|
||||
|
||||
with pytest.raises(real_requests.RequestException):
|
||||
fetch_movie("https://www.csfd.cz/film/123/")
|
||||
|
||||
|
||||
class TestSearchMovies:
|
||||
"""Tests for search_movies function."""
|
||||
|
||||
@patch("src.core.csfd.requests")
|
||||
def test_search_movies(self, mock_requests):
|
||||
"""Test movie search."""
|
||||
search_html = """
|
||||
<html><body>
|
||||
<a href="/film/123-test/" class="film-title-name">Test Movie</a>
|
||||
<a href="/film/456-another/" class="film-title-name">Another Movie</a>
|
||||
</body></html>
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = search_html
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_requests.get.return_value = mock_response
|
||||
mock_requests.utils.quote = lambda x: x
|
||||
|
||||
results = search_movies("test", limit=10)
|
||||
|
||||
assert len(results) >= 1
|
||||
assert any(m.csfd_id == 123 for m in results)
|
||||
|
||||
|
||||
class TestFetchMovieById:
|
||||
"""Tests for fetch_movie_by_id function."""
|
||||
|
||||
@patch("src.core.csfd.fetch_movie")
|
||||
def test_fetch_by_id(self, mock_fetch):
|
||||
"""Test fetching movie by ID."""
|
||||
mock_fetch.return_value = CSFDMovie(title="Test", url="https://csfd.cz/film/9423/")
|
||||
|
||||
movie = fetch_movie_by_id(9423)
|
||||
|
||||
mock_fetch.assert_called_once_with("https://www.csfd.cz/film/9423/")
|
||||
assert movie.title == "Test"
|
||||
|
||||
|
||||
class TestDependencyCheck:
|
||||
"""Tests for dependency checking."""
|
||||
|
||||
def test_dependencies_available(self):
|
||||
"""Test that dependencies are available (they should be in test env)."""
|
||||
# Should not raise
|
||||
_check_dependencies()
|
||||
417
tests/test_file.py
Normal file
417
tests/test_file.py
Normal file
@@ -0,0 +1,417 @@
|
||||
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"
|
||||
|
||||
|
||||
class TestFileCSFDIntegration:
|
||||
"""Testy pro CSFD integraci v File"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
return tmp_path
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(self, temp_dir):
|
||||
test_file = temp_dir / "film.mkv"
|
||||
test_file.write_text("video content")
|
||||
return test_file
|
||||
|
||||
def test_file_csfd_url_initial(self, test_file, tag_manager):
|
||||
"""Test že csfd_url je None při vytvoření"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
assert file_obj.csfd_url is None
|
||||
|
||||
def test_file_set_csfd_url(self, test_file, tag_manager):
|
||||
"""Test nastavení CSFD URL"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/")
|
||||
assert file_obj.csfd_url == "https://www.csfd.cz/film/9423-pane-vy-jste-vdova/"
|
||||
|
||||
def test_file_set_csfd_url_persistence(self, test_file, tag_manager):
|
||||
"""Test že CSFD URL přežije reload"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
file_obj2 = File(test_file, tag_manager)
|
||||
assert file_obj2.csfd_url == "https://www.csfd.cz/film/123/"
|
||||
|
||||
def test_file_set_csfd_url_none(self, test_file, tag_manager):
|
||||
"""Test smazání CSFD URL"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
file_obj.set_csfd_url(None)
|
||||
assert file_obj.csfd_url is None
|
||||
|
||||
def test_file_set_csfd_url_empty(self, test_file, tag_manager):
|
||||
"""Test nastavení prázdného řetězce jako CSFD URL"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
file_obj.set_csfd_url("")
|
||||
assert file_obj.csfd_url is None
|
||||
|
||||
def test_file_csfd_url_in_metadata(self, test_file, tag_manager):
|
||||
"""Test že CSFD URL je uložena v metadatech"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/999/")
|
||||
|
||||
import json
|
||||
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data["csfd_url"] == "https://www.csfd.cz/film/999/"
|
||||
|
||||
def test_apply_csfd_tags_no_url(self, test_file, tag_manager):
|
||||
"""Test apply_csfd_tags bez nastaveného URL"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
result = file_obj.apply_csfd_tags()
|
||||
|
||||
assert result["success"] is False
|
||||
assert "URL není nastavena" in result["error"]
|
||||
assert result["tags_added"] == []
|
||||
|
||||
@pytest.fixture
|
||||
def mock_csfd_movie(self):
|
||||
"""Mock CSFDMovie pro testování"""
|
||||
from unittest.mock import MagicMock
|
||||
movie = MagicMock()
|
||||
movie.title = "Test Film"
|
||||
movie.year = 2020
|
||||
movie.genres = ["Komedie", "Drama"]
|
||||
movie.country = "Česko"
|
||||
movie.rating = 85
|
||||
return movie
|
||||
|
||||
def test_apply_csfd_tags_success(self, test_file, tag_manager, mock_csfd_movie):
|
||||
"""Test úspěšného načtení tagů z CSFD"""
|
||||
from unittest.mock import patch
|
||||
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
with patch("src.core.csfd.fetch_movie", return_value=mock_csfd_movie):
|
||||
result = file_obj.apply_csfd_tags()
|
||||
|
||||
assert result["success"] is True
|
||||
assert "Žánr/Komedie" in result["tags_added"]
|
||||
assert "Žánr/Drama" in result["tags_added"]
|
||||
assert "Rok/2020" in result["tags_added"]
|
||||
assert "Země/Česko" in result["tags_added"]
|
||||
|
||||
# Kontrola že tagy jsou opravdu přidány
|
||||
tag_paths = {tag.full_path for tag in file_obj.tags}
|
||||
assert "Žánr/Komedie" in tag_paths
|
||||
assert "Žánr/Drama" in tag_paths
|
||||
assert "Rok/2020" in tag_paths
|
||||
assert "Země/Česko" in tag_paths
|
||||
|
||||
def test_apply_csfd_tags_genres_only(self, test_file, tag_manager, mock_csfd_movie):
|
||||
"""Test načtení pouze žánrů"""
|
||||
from unittest.mock import patch
|
||||
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
with patch("src.core.csfd.fetch_movie", return_value=mock_csfd_movie):
|
||||
result = file_obj.apply_csfd_tags(add_genres=True, add_year=False, add_country=False)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "Žánr/Komedie" in result["tags_added"]
|
||||
assert "Rok/2020" not in result["tags_added"]
|
||||
assert "Země/Česko" not in result["tags_added"]
|
||||
|
||||
def test_apply_csfd_tags_no_duplicate(self, test_file, tag_manager, mock_csfd_movie):
|
||||
"""Test že duplicitní tagy nejsou přidány"""
|
||||
from unittest.mock import patch
|
||||
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
# Přidáme tag ručně
|
||||
file_obj.add_tag("Žánr/Komedie")
|
||||
|
||||
with patch("src.core.csfd.fetch_movie", return_value=mock_csfd_movie):
|
||||
result = file_obj.apply_csfd_tags()
|
||||
|
||||
# Komedie by neměla být v tags_added, protože už existuje
|
||||
assert "Žánr/Komedie" not in result["tags_added"]
|
||||
assert "Žánr/Drama" in result["tags_added"]
|
||||
|
||||
def test_apply_csfd_tags_network_error(self, test_file, tag_manager):
|
||||
"""Test chyby při načítání z CSFD"""
|
||||
from unittest.mock import patch
|
||||
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
with patch("src.core.csfd.fetch_movie", side_effect=Exception("Network error")):
|
||||
result = file_obj.apply_csfd_tags()
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
assert result["tags_added"] == []
|
||||
988
tests/test_file_manager.py
Normal file
988
tests/test_file_manager.py
Normal file
@@ -0,0 +1,988 @@
|
||||
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
|
||||
|
||||
|
||||
class TestFileManagerCloseFolder:
|
||||
"""Testy pro close_folder metodu"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_path = config_dir / "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):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "file1.txt").write_text("content1")
|
||||
(data_dir / "file2.txt").write_text("content2")
|
||||
return data_dir
|
||||
|
||||
def test_close_folder_clears_state(self, file_manager, temp_dir):
|
||||
"""Test že close_folder vymaže stav"""
|
||||
file_manager.append(temp_dir)
|
||||
assert len(file_manager.filelist) == 2
|
||||
assert file_manager.current_folder == temp_dir
|
||||
|
||||
file_manager.close_folder()
|
||||
|
||||
assert len(file_manager.filelist) == 0
|
||||
assert len(file_manager.folders) == 0
|
||||
assert file_manager.current_folder is None
|
||||
assert len(file_manager.folder_configs) == 0
|
||||
|
||||
def test_close_folder_saves_metadata(self, file_manager, temp_dir):
|
||||
"""Test že close_folder uloží metadata"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Find file1.txt specifically
|
||||
file = next(f for f in file_manager.filelist if f.filename == "file1.txt")
|
||||
file.add_tag("Test/CloseTag")
|
||||
|
||||
file_manager.close_folder()
|
||||
|
||||
# Reload file and check tag persists
|
||||
from src.core.file import File
|
||||
reloaded = File(temp_dir / "file1.txt", file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Test/CloseTag" in tag_paths
|
||||
|
||||
def test_close_folder_callback(self, file_manager, temp_dir):
|
||||
"""Test že close_folder volá callback"""
|
||||
file_manager.append(temp_dir)
|
||||
callback_calls = []
|
||||
|
||||
def callback(filelist):
|
||||
callback_calls.append(len(filelist))
|
||||
|
||||
file_manager.on_files_changed = callback
|
||||
file_manager.close_folder()
|
||||
|
||||
assert len(callback_calls) == 1
|
||||
assert callback_calls[0] == 0 # Empty list after close
|
||||
|
||||
def test_close_folder_no_folder_open(self, file_manager):
|
||||
"""Test close_folder bez otevřené složky"""
|
||||
# Should not raise
|
||||
file_manager.close_folder()
|
||||
assert file_manager.current_folder is None
|
||||
|
||||
def test_close_folder_preserves_global_config(self, file_manager, temp_dir):
|
||||
"""Test že close_folder zachová global config"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.global_config["test_key"] = "test_value"
|
||||
|
||||
file_manager.close_folder()
|
||||
|
||||
assert file_manager.global_config.get("test_key") == "test_value"
|
||||
|
||||
|
||||
class TestFileManagerRenameTag:
|
||||
"""Testy pro přejmenování tagů v souborech"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_path = config_dir / "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):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "file1.txt").write_text("content1")
|
||||
(data_dir / "file2.txt").write_text("content2")
|
||||
(data_dir / "file3.txt").write_text("content3")
|
||||
return data_dir
|
||||
|
||||
def test_rename_tag_in_files_success(self, file_manager, temp_dir):
|
||||
"""Test úspěšného přejmenování tagu v souborech"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přidat tag dvěma souborům
|
||||
files_to_tag = file_manager.filelist[:2]
|
||||
file_manager.assign_tag_to_file_objects(files_to_tag, "Video/HD")
|
||||
|
||||
# Přejmenovat tag
|
||||
updated_count = file_manager.rename_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert updated_count == 2
|
||||
|
||||
# Zkontrolovat že tagy jsou přejmenovány
|
||||
for f in files_to_tag:
|
||||
tag_paths = {t.full_path for t in f.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_rename_tag_in_files_persistence(self, file_manager, temp_dir):
|
||||
"""Test že přejmenovaný tag přežije reload"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
|
||||
file_manager.rename_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
# Reload soubor
|
||||
from src.core.file import File
|
||||
reloaded = File(file.file_path, file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_rename_tag_in_files_no_match(self, file_manager, temp_dir):
|
||||
"""Test přejmenování tagu který žádný soubor nemá"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects(file_manager.filelist[:1], "Video/HD")
|
||||
|
||||
updated_count = file_manager.rename_tag_in_files("Video", "4K", "UHD")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_rename_tag_in_files_nonexistent_category(self, file_manager, temp_dir):
|
||||
"""Test přejmenování tagu v neexistující kategorii"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
updated_count = file_manager.rename_tag_in_files("NonExistent", "Tag", "NewTag")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_rename_tag_in_files_callback(self, file_manager, temp_dir):
|
||||
"""Test že přejmenování tagu volá callback"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects(file_manager.filelist[:1], "Video/HD")
|
||||
|
||||
callback_calls = []
|
||||
def callback(filelist):
|
||||
callback_calls.append(len(filelist))
|
||||
|
||||
file_manager.on_files_changed = callback
|
||||
file_manager.rename_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert len(callback_calls) == 1
|
||||
|
||||
def test_rename_tag_preserves_other_tags(self, file_manager, temp_dir):
|
||||
"""Test že přejmenování jednoho tagu neovlivní ostatní tagy souboru"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
|
||||
file_manager.assign_tag_to_file_objects([file], "Quality/High")
|
||||
|
||||
file_manager.rename_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
assert "Quality/High" in tag_paths
|
||||
|
||||
def test_rename_category_in_files_success(self, file_manager, temp_dir):
|
||||
"""Test úspěšného přejmenování kategorie v souborech"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přidat tagy ze stejné kategorie
|
||||
file_manager.assign_tag_to_file_objects(file_manager.filelist[:2], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/4K")
|
||||
|
||||
# Přejmenovat kategorii
|
||||
updated_count = file_manager.rename_category_in_files("Video", "Rozlišení")
|
||||
|
||||
assert updated_count == 2
|
||||
|
||||
# Zkontrolovat že tagy mají novou kategorii
|
||||
file1 = file_manager.filelist[0]
|
||||
tag_paths = {t.full_path for t in file1.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Rozlišení/4K" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
assert "Video/4K" not in tag_paths
|
||||
|
||||
def test_rename_category_in_files_persistence(self, file_manager, temp_dir):
|
||||
"""Test že přejmenovaná kategorie přežije reload"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
|
||||
file_manager.rename_category_in_files("Video", "Rozlišení")
|
||||
|
||||
# Reload soubor
|
||||
from src.core.file import File
|
||||
reloaded = File(file.file_path, file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_rename_category_in_files_no_match(self, file_manager, temp_dir):
|
||||
"""Test přejmenování kategorie kterou žádný soubor nemá"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects(file_manager.filelist[:1], "Video/HD")
|
||||
|
||||
updated_count = file_manager.rename_category_in_files("Audio", "Sound")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_rename_category_in_files_nonexistent(self, file_manager, temp_dir):
|
||||
"""Test přejmenování neexistující kategorie"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
updated_count = file_manager.rename_category_in_files("NonExistent", "NewName")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_rename_category_preserves_other_categories(self, file_manager, temp_dir):
|
||||
"""Test že přejmenování kategorie neovlivní jiné kategorie"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
|
||||
|
||||
file_manager.rename_category_in_files("Video", "Rozlišení")
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
|
||||
|
||||
class TestFileManagerMergeTag:
|
||||
"""Testy pro slučování tagů v souborech"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_path = config_dir / "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):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "file1.txt").write_text("content1")
|
||||
(data_dir / "file2.txt").write_text("content2")
|
||||
(data_dir / "file3.txt").write_text("content3")
|
||||
return data_dir
|
||||
|
||||
def test_merge_tag_in_files_success(self, file_manager, temp_dir):
|
||||
"""Test úspěšného sloučení tagů v souborech"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přidat oba tagy - jeden soubor má HD, druhý má FullHD
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Video/FullHD")
|
||||
|
||||
# Sloučit HD do FullHD
|
||||
updated_count = file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert updated_count == 1
|
||||
|
||||
# Soubor 0 by měl mít FullHD místo HD
|
||||
tag_paths = {t.full_path for t in file_manager.filelist[0].tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_merge_tag_in_files_file_has_both(self, file_manager, temp_dir):
|
||||
"""Test sloučení když soubor má oba tagy"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Soubor má oba tagy
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/FullHD")
|
||||
|
||||
# Sloučit HD do FullHD - HD by měl být odstraněn
|
||||
updated_count = file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert updated_count == 1
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
# FullHD by měl být jen jednou
|
||||
fullhd_count = sum(1 for t in file.tags if t.full_path == "Video/FullHD")
|
||||
assert fullhd_count == 1
|
||||
|
||||
def test_merge_tag_in_files_persistence(self, file_manager, temp_dir):
|
||||
"""Test že sloučený tag přežije reload"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Video/FullHD")
|
||||
|
||||
file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
# Reload soubor
|
||||
from src.core.file import File
|
||||
reloaded = File(file.file_path, file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_merge_tag_in_files_no_source(self, file_manager, temp_dir):
|
||||
"""Test sloučení když žádný soubor nemá zdrojový tag"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/FullHD")
|
||||
|
||||
updated_count = file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_merge_tag_preserves_other_tags(self, file_manager, temp_dir):
|
||||
"""Test že sloučení neovlivní ostatní tagy"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/FullHD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
|
||||
|
||||
file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
|
||||
def test_merge_category_in_files_success(self, file_manager, temp_dir):
|
||||
"""Test úspěšného sloučení kategorií v souborech"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přidat tagy z různých kategorií
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Rozlišení/4K")
|
||||
|
||||
# Sloučit Video do Rozlišení
|
||||
updated_count = file_manager.merge_category_in_files("Video", "Rozlišení")
|
||||
|
||||
assert updated_count == 1
|
||||
|
||||
# Soubor 0 by měl mít Rozlišení/HD místo Video/HD
|
||||
tag_paths = {t.full_path for t in file_manager.filelist[0].tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_merge_category_in_files_persistence(self, file_manager, temp_dir):
|
||||
"""Test že sloučená kategorie přežije reload"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Rozlišení/4K")
|
||||
|
||||
file_manager.merge_category_in_files("Video", "Rozlišení")
|
||||
|
||||
# Reload soubor
|
||||
from src.core.file import File
|
||||
reloaded = File(file.file_path, file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_merge_category_no_source_files(self, file_manager, temp_dir):
|
||||
"""Test sloučení když žádný soubor nemá zdrojovou kategorii"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Rozlišení/4K")
|
||||
|
||||
updated_count = file_manager.merge_category_in_files("Video", "Rozlišení")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_merge_category_preserves_other_categories(self, file_manager, temp_dir):
|
||||
"""Test že sloučení kategorie neovlivní jiné kategorie"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Rozlišení/4K")
|
||||
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
|
||||
|
||||
file_manager.merge_category_in_files("Video", "Rozlišení")
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Rozlišení/4K" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
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)
|
||||
90
tests/test_media_utils.py
Normal file
90
tests/test_media_utils.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import tempfile
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
import os
|
||||
|
||||
# Skip all tests if no display is available (CI environment)
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("DISPLAY") is None and os.environ.get("WAYLAND_DISPLAY") is None,
|
||||
reason="No display available for GUI tests"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
"""Fixture to initialize QApplication for Qt tests."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
app = QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
def _make_png(path: Path, width: int = 32, height: int = 32) -> None:
|
||||
"""Write a minimal valid PNG file without Pillow."""
|
||||
def chunk(name: bytes, data: bytes) -> bytes:
|
||||
c = struct.pack(">I", len(data)) + name + data
|
||||
return c + struct.pack(">I", zlib.crc32(name + data) & 0xFFFFFFFF)
|
||||
|
||||
raw_rows = b"".join(b"\x00" + bytes([255, 0, 0] * width) for _ in range(height))
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
idat = zlib.compress(raw_rows)
|
||||
|
||||
png = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", ihdr)
|
||||
+ chunk(b"IDAT", idat)
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
path.write_bytes(png)
|
||||
|
||||
|
||||
def test_load_icon_returns_qicon(qapp):
|
||||
"""Test that load_icon returns QIcon"""
|
||||
from src.ui.utils import load_icon
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
_make_png(tmp_path, 100, 100)
|
||||
icon = load_icon(tmp_path)
|
||||
assert isinstance(icon, QIcon)
|
||||
assert not icon.isNull()
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_load_icon_custom_size(qapp):
|
||||
"""Test that load_icon respects custom size parameter"""
|
||||
from src.ui.utils import load_icon
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
_make_png(tmp_path, 500, 500)
|
||||
icon = load_icon(tmp_path, size=32)
|
||||
assert not icon.isNull()
|
||||
sizes = icon.availableSizes()
|
||||
assert len(sizes) > 0
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_load_icon_different_formats(qapp):
|
||||
"""Test loading different image formats"""
|
||||
from src.ui.utils import load_icon
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
# Only PNG is reliably producible without Pillow; BMP can be crafted too
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
_make_png(tmp_path, 32, 32)
|
||||
icon = load_icon(tmp_path)
|
||||
assert isinstance(icon, QIcon)
|
||||
assert not icon.isNull()
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
147
tests/test_tag.py
Normal file
147
tests/test_tag.py
Normal file
@@ -0,0 +1,147 @@
|
||||
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"
|
||||
|
||||
|
||||
class TestTagFromString:
|
||||
"""Testy pro Tag.from_string() class method"""
|
||||
|
||||
def test_from_string_with_category(self):
|
||||
"""Test parsování stringu s kategorií"""
|
||||
tag = Tag.from_string("Stav/Nové")
|
||||
assert tag.category == "Stav"
|
||||
assert tag.name == "Nové"
|
||||
|
||||
def test_from_string_without_category(self):
|
||||
"""Test parsování stringu bez kategorie - použije default"""
|
||||
tag = Tag.from_string("simple")
|
||||
assert tag.category == "default"
|
||||
assert tag.name == "simple"
|
||||
|
||||
def test_from_string_custom_default_category(self):
|
||||
"""Test parsování s vlastní default kategorií"""
|
||||
tag = Tag.from_string("simple", default_category="Custom")
|
||||
assert tag.category == "Custom"
|
||||
assert tag.name == "simple"
|
||||
|
||||
def test_from_string_multiple_slashes(self):
|
||||
"""Test parsování stringu s více lomítky"""
|
||||
tag = Tag.from_string("Kategorie/Název/s/lomítky")
|
||||
assert tag.category == "Kategorie"
|
||||
assert tag.name == "Název/s/lomítky"
|
||||
|
||||
def test_from_string_unicode(self):
|
||||
"""Test parsování unicode stringu"""
|
||||
tag = Tag.from_string("Žánr/Komedie")
|
||||
assert tag.category == "Žánr"
|
||||
assert tag.name == "Komedie"
|
||||
|
||||
def test_from_string_equality(self):
|
||||
"""Test že from_string vytváří ekvivalentní tag"""
|
||||
tag1 = Tag("Stav", "Nové")
|
||||
tag2 = Tag.from_string("Stav/Nové")
|
||||
assert tag1 == tag2
|
||||
assert hash(tag1) == hash(tag2)
|
||||
611
tests/test_tag_manager.py
Normal file
611
tests/test_tag_manager.py
Normal file
@@ -0,0 +1,611 @@
|
||||
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
|
||||
|
||||
|
||||
class TestRenameTag:
|
||||
"""Testy pro přejmenování tagů a kategorií"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def empty_tag_manager(self):
|
||||
tm = TagManager()
|
||||
for category in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(category)
|
||||
return tm
|
||||
|
||||
def test_rename_tag_success(self, empty_tag_manager):
|
||||
"""Test úspěšného přejmenování tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
new_tag = tm.rename_tag("Video", "HD", "FullHD")
|
||||
|
||||
assert new_tag is not None
|
||||
assert new_tag.name == "FullHD"
|
||||
assert new_tag.category == "Video"
|
||||
# Old tag should not exist
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" not in tag_names
|
||||
assert "FullHD" in tag_names
|
||||
|
||||
def test_rename_tag_nonexistent_category(self, empty_tag_manager):
|
||||
"""Test přejmenování tagu v neexistující kategorii"""
|
||||
result = empty_tag_manager.rename_tag("Nonexistent", "Tag", "NewTag")
|
||||
assert result is None
|
||||
|
||||
def test_rename_tag_nonexistent_tag(self, empty_tag_manager):
|
||||
"""Test přejmenování neexistujícího tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.rename_tag("Video", "Nonexistent", "NewTag")
|
||||
assert result is None
|
||||
|
||||
def test_rename_tag_to_existing_name(self, empty_tag_manager):
|
||||
"""Test přejmenování tagu na existující název"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
|
||||
result = tm.rename_tag("Video", "HD", "4K")
|
||||
assert result is None
|
||||
# Original tags should still exist
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
|
||||
def test_rename_tag_same_name(self, empty_tag_manager):
|
||||
"""Test přejmenování tagu na stejný název (no-op)"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
new_tag = tm.rename_tag("Video", "HD", "HD")
|
||||
# Should succeed but effectively be a no-op
|
||||
assert new_tag is not None
|
||||
assert new_tag.name == "HD"
|
||||
|
||||
def test_rename_category_success(self, empty_tag_manager):
|
||||
"""Test úspěšného přejmenování kategorie"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
|
||||
result = tm.rename_category("Video", "Rozlišení")
|
||||
|
||||
assert result is True
|
||||
assert "Video" not in tm.get_categories()
|
||||
assert "Rozlišení" in tm.get_categories()
|
||||
# Tags should be moved to new category
|
||||
tags = tm.get_tags_in_category("Rozlišení")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
|
||||
def test_rename_category_nonexistent(self, empty_tag_manager):
|
||||
"""Test přejmenování neexistující kategorie"""
|
||||
result = empty_tag_manager.rename_category("Nonexistent", "NewName")
|
||||
assert result is False
|
||||
|
||||
def test_rename_category_to_existing_name(self, empty_tag_manager):
|
||||
"""Test přejmenování kategorie na existující název"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Audio", "MP3")
|
||||
|
||||
result = tm.rename_category("Video", "Audio")
|
||||
assert result is False
|
||||
# Original categories should still exist
|
||||
assert "Video" in tm.get_categories()
|
||||
assert "Audio" in tm.get_categories()
|
||||
|
||||
def test_rename_category_same_name(self, empty_tag_manager):
|
||||
"""Test přejmenování kategorie na stejný název"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.rename_category("Video", "Video")
|
||||
# Should succeed but effectively be a no-op
|
||||
assert result is True
|
||||
assert "Video" in tm.get_categories()
|
||||
|
||||
def test_rename_tag_preserves_other_tags(self, empty_tag_manager):
|
||||
"""Test že přejmenování jednoho tagu neovlivní ostatní"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Video", "SD")
|
||||
|
||||
tm.rename_tag("Video", "HD", "FullHD")
|
||||
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert len(tag_names) == 3
|
||||
assert "FullHD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
assert "SD" in tag_names
|
||||
assert "HD" not in tag_names
|
||||
|
||||
def test_rename_category_preserves_tags(self, empty_tag_manager):
|
||||
"""Test že přejmenování kategorie zachová všechny tagy"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Video", "SD")
|
||||
|
||||
tm.rename_category("Video", "Rozlišení")
|
||||
|
||||
tags = tm.get_tags_in_category("Rozlišení")
|
||||
assert len(tags) == 3
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
assert "SD" in tag_names
|
||||
|
||||
|
||||
class TestMergeTag:
|
||||
"""Testy pro slučování tagů a kategorií"""
|
||||
|
||||
@pytest.fixture
|
||||
def empty_tag_manager(self):
|
||||
tm = TagManager()
|
||||
for category in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(category)
|
||||
return tm
|
||||
|
||||
def test_merge_tag_success(self, empty_tag_manager):
|
||||
"""Test úspěšného sloučení tagů"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "FullHD")
|
||||
|
||||
result = tm.merge_tag("Video", "HD", "FullHD")
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "FullHD"
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "FullHD" in tag_names
|
||||
assert "HD" not in tag_names
|
||||
assert len(tag_names) == 1
|
||||
|
||||
def test_merge_tag_nonexistent_source(self, empty_tag_manager):
|
||||
"""Test sloučení neexistujícího zdrojového tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "FullHD")
|
||||
|
||||
result = tm.merge_tag("Video", "HD", "FullHD")
|
||||
assert result is None
|
||||
|
||||
def test_merge_tag_nonexistent_target(self, empty_tag_manager):
|
||||
"""Test sloučení do neexistujícího cílového tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.merge_tag("Video", "HD", "FullHD")
|
||||
assert result is None
|
||||
|
||||
def test_merge_tag_nonexistent_category(self, empty_tag_manager):
|
||||
"""Test sloučení v neexistující kategorii"""
|
||||
result = empty_tag_manager.merge_tag("Nonexistent", "HD", "FullHD")
|
||||
assert result is None
|
||||
|
||||
def test_merge_tag_preserves_other_tags(self, empty_tag_manager):
|
||||
"""Test že sloučení jednoho tagu neovlivní ostatní"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Video", "SD")
|
||||
|
||||
tm.merge_tag("Video", "HD", "4K")
|
||||
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert len(tag_names) == 2
|
||||
assert "4K" in tag_names
|
||||
assert "SD" in tag_names
|
||||
assert "HD" not in tag_names
|
||||
|
||||
def test_merge_category_success(self, empty_tag_manager):
|
||||
"""Test úspěšného sloučení kategorií"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Rozlišení", "SD")
|
||||
|
||||
result = tm.merge_category("Video", "Rozlišení")
|
||||
|
||||
assert result is True
|
||||
assert "Video" not in tm.get_categories()
|
||||
assert "Rozlišení" in tm.get_categories()
|
||||
tags = tm.get_tags_in_category("Rozlišení")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
assert "SD" in tag_names
|
||||
|
||||
def test_merge_category_nonexistent_source(self, empty_tag_manager):
|
||||
"""Test sloučení neexistující zdrojové kategorie"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Rozlišení", "HD")
|
||||
|
||||
result = tm.merge_category("Video", "Rozlišení")
|
||||
assert result is False
|
||||
|
||||
def test_merge_category_nonexistent_target(self, empty_tag_manager):
|
||||
"""Test sloučení do neexistující cílové kategorie"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.merge_category("Video", "Rozlišení")
|
||||
assert result is False
|
||||
|
||||
def test_merge_category_same_category(self, empty_tag_manager):
|
||||
"""Test sloučení kategorie se sebou samou"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.merge_category("Video", "Video")
|
||||
assert result is True # No-op, should succeed
|
||||
|
||||
def test_merge_category_duplicate_tags(self, empty_tag_manager):
|
||||
"""Test sloučení kategorií s duplicitními tagy"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Rozlišení", "HD") # Same tag name in target
|
||||
|
||||
result = tm.merge_category("Video", "Rozlišení")
|
||||
|
||||
assert result is True
|
||||
tags = tm.get_tags_in_category("Rozlišení")
|
||||
tag_names = [t.name for t in tags]
|
||||
# HD should appear only once (set deduplication)
|
||||
assert tag_names.count("HD") == 1
|
||||
assert "4K" in tag_names
|
||||
|
||||
def test_tag_exists(self, empty_tag_manager):
|
||||
"""Test kontroly existence tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
assert tm.tag_exists("Video", "HD") is True
|
||||
assert tm.tag_exists("Video", "4K") is False
|
||||
assert tm.tag_exists("Nonexistent", "HD") is False
|
||||
|
||||
def test_category_exists(self, empty_tag_manager):
|
||||
"""Test kontroly existence kategorie"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
assert tm.category_exists("Video") is True
|
||||
assert tm.category_exists("Nonexistent") is False
|
||||
194
tests/test_undo_redo.py
Normal file
194
tests/test_undo_redo.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Tests for FileManager undo/redo stack.
|
||||
"""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_dir(tmp_path, monkeypatch):
|
||||
cfg = tmp_path / "cfg"
|
||||
cfg.mkdir()
|
||||
monkeypatch.setattr("src.core.config.GLOBAL_CONFIG_FILE", cfg / ".Tagger.!gtag")
|
||||
monkeypatch.setattr("src.core.config._XDG_CONFIG_DIR", cfg)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fm(tmp_path, config_dir):
|
||||
tm = TagManager()
|
||||
manager = FileManager(tm)
|
||||
# Two in-memory File objects (no real disk files needed for tag ops)
|
||||
f1 = File.__new__(File)
|
||||
f1.file_path = tmp_path / "a.txt"
|
||||
f1.tags = []
|
||||
f1.tagmanager = tm
|
||||
f1.csfd_url = None
|
||||
f1.date = None
|
||||
f1.csfd_cache = None
|
||||
|
||||
f2 = File.__new__(File)
|
||||
f2.file_path = tmp_path / "b.txt"
|
||||
f2.tags = []
|
||||
f2.tagmanager = tm
|
||||
f2.csfd_url = None
|
||||
f2.date = None
|
||||
f2.csfd_cache = None
|
||||
|
||||
# Patch save_metadata to be a no-op
|
||||
f1.save_metadata = lambda: None
|
||||
f2.save_metadata = lambda: None
|
||||
|
||||
manager.filelist = [f1, f2]
|
||||
manager._f1, manager._f2 = f1, f2
|
||||
return manager
|
||||
|
||||
|
||||
class TestUndoRedoAssign:
|
||||
def test_assign_undo_redo(self, fm):
|
||||
tag = fm.tagmanager.add_tag("Žánr", "Drama")
|
||||
fm.assign_tag_to_files([fm._f1], tag)
|
||||
|
||||
assert tag in fm._f1.tags
|
||||
assert fm.can_undo()
|
||||
assert not fm.can_redo()
|
||||
|
||||
fm.undo()
|
||||
assert tag not in fm._f1.tags
|
||||
assert not fm.can_undo()
|
||||
assert fm.can_redo()
|
||||
|
||||
fm.redo()
|
||||
assert tag in fm._f1.tags
|
||||
|
||||
def test_remove_undo_redo(self, fm):
|
||||
tag = fm.tagmanager.add_tag("Žánr", "Komedie")
|
||||
fm._f1.tags = [tag]
|
||||
|
||||
fm.remove_tag_from_files([fm._f1], tag)
|
||||
assert tag not in fm._f1.tags
|
||||
|
||||
fm.undo()
|
||||
assert tag in fm._f1.tags
|
||||
|
||||
fm.redo()
|
||||
assert tag not in fm._f1.tags
|
||||
|
||||
def test_assign_noop_not_pushed(self, fm):
|
||||
"""Assign to file that already has tag should not push undo entry."""
|
||||
tag = fm.tagmanager.add_tag("Žánr", "Drama")
|
||||
fm._f1.tags = [tag]
|
||||
fm.assign_tag_to_files([fm._f1], tag)
|
||||
assert not fm.can_undo()
|
||||
|
||||
def test_redo_cleared_on_new_op(self, fm):
|
||||
tag = fm.tagmanager.add_tag("Žánr", "Drama")
|
||||
fm.assign_tag_to_files([fm._f1], tag)
|
||||
fm.undo()
|
||||
assert fm.can_redo()
|
||||
|
||||
tag2 = fm.tagmanager.add_tag("Žánr", "Thriller")
|
||||
fm.assign_tag_to_files([fm._f1], tag2)
|
||||
assert not fm.can_redo()
|
||||
|
||||
def test_undo_empty_returns_none(self, fm):
|
||||
assert fm.undo() is None
|
||||
|
||||
def test_redo_empty_returns_none(self, fm):
|
||||
assert fm.redo() is None
|
||||
|
||||
|
||||
class TestUndoRedoRename:
|
||||
def test_rename_tag_undo_redo(self, fm):
|
||||
fm.tagmanager.add_tag("Žánr", "Drama")
|
||||
tag_old = Tag("Žánr", "Drama")
|
||||
fm._f1.tags = [tag_old]
|
||||
fm._f2.tags = [tag_old]
|
||||
|
||||
count = fm.rename_tag_in_files("Žánr", "Drama", "Thriller")
|
||||
assert count == 2
|
||||
assert Tag("Žánr", "Thriller") in fm._f1.tags
|
||||
assert fm.tagmanager.tag_exists("Žánr", "Thriller")
|
||||
assert not fm.tagmanager.tag_exists("Žánr", "Drama")
|
||||
|
||||
fm.undo()
|
||||
assert Tag("Žánr", "Drama") in fm._f1.tags
|
||||
assert fm.tagmanager.tag_exists("Žánr", "Drama")
|
||||
assert not fm.tagmanager.tag_exists("Žánr", "Thriller")
|
||||
|
||||
fm.redo()
|
||||
assert Tag("Žánr", "Thriller") in fm._f1.tags
|
||||
assert fm.tagmanager.tag_exists("Žánr", "Thriller")
|
||||
|
||||
def test_rename_category_undo_redo(self, fm):
|
||||
fm.tagmanager.add_tag("StaráKat", "X")
|
||||
tag = Tag("StaráKat", "X")
|
||||
fm._f1.tags = [tag]
|
||||
|
||||
fm.rename_category_in_files("StaráKat", "NováKat")
|
||||
assert Tag("NováKat", "X") in fm._f1.tags
|
||||
assert fm.tagmanager.category_exists("NováKat")
|
||||
assert not fm.tagmanager.category_exists("StaráKat")
|
||||
|
||||
fm.undo()
|
||||
assert Tag("StaráKat", "X") in fm._f1.tags
|
||||
assert fm.tagmanager.category_exists("StaráKat")
|
||||
assert not fm.tagmanager.category_exists("NováKat")
|
||||
|
||||
fm.redo()
|
||||
assert Tag("NováKat", "X") in fm._f1.tags
|
||||
|
||||
|
||||
class TestUndoRedoMerge:
|
||||
def test_merge_tag_undo_redo(self, fm):
|
||||
fm.tagmanager.add_tag("Žánr", "Drama")
|
||||
fm.tagmanager.add_tag("Žánr", "Thriller")
|
||||
fm._f1.tags = [Tag("Žánr", "Drama")]
|
||||
fm._f2.tags = [Tag("Žánr", "Drama"), Tag("Žánr", "Thriller")]
|
||||
|
||||
fm.merge_tag_in_files("Žánr", "Drama", "Thriller")
|
||||
assert Tag("Žánr", "Thriller") in fm._f1.tags
|
||||
assert Tag("Žánr", "Drama") not in fm._f1.tags
|
||||
assert not fm.tagmanager.tag_exists("Žánr", "Drama")
|
||||
|
||||
fm.undo()
|
||||
assert Tag("Žánr", "Drama") in fm._f1.tags
|
||||
assert Tag("Žánr", "Drama") not in fm._f2.tags or Tag("Žánr", "Thriller") in fm._f2.tags
|
||||
assert fm.tagmanager.tag_exists("Žánr", "Drama")
|
||||
|
||||
fm.redo()
|
||||
assert Tag("Žánr", "Thriller") in fm._f1.tags
|
||||
assert not fm.tagmanager.tag_exists("Žánr", "Drama")
|
||||
|
||||
def test_merge_category_undo_redo(self, fm):
|
||||
fm.tagmanager.add_tag("SrcKat", "A")
|
||||
fm.tagmanager.add_tag("TgtKat", "B")
|
||||
fm._f1.tags = [Tag("SrcKat", "A")]
|
||||
fm._f2.tags = [Tag("TgtKat", "B")]
|
||||
|
||||
fm.merge_category_in_files("SrcKat", "TgtKat")
|
||||
assert Tag("TgtKat", "A") in fm._f1.tags
|
||||
assert not fm.tagmanager.category_exists("SrcKat")
|
||||
|
||||
fm.undo()
|
||||
assert Tag("SrcKat", "A") in fm._f1.tags
|
||||
assert fm.tagmanager.category_exists("SrcKat")
|
||||
assert not fm.tagmanager.tag_exists("TgtKat", "A")
|
||||
|
||||
fm.redo()
|
||||
assert Tag("TgtKat", "A") in fm._f1.tags
|
||||
assert not fm.tagmanager.category_exists("SrcKat")
|
||||
|
||||
|
||||
class TestUndoLimit:
|
||||
def test_max_undo_entries(self, fm):
|
||||
from src.core.file_manager import _MAX_UNDO
|
||||
tag = fm.tagmanager.add_tag("T", "x")
|
||||
for _ in range(_MAX_UNDO + 5):
|
||||
fm._f1.tags = []
|
||||
fm.assign_tag_to_files([fm._f1], tag)
|
||||
assert len(fm._undo_stack) == _MAX_UNDO
|
||||
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