Add undo/redo stack for tag operations (assign, remove, rename, merge) with Ctrl+Z/Ctrl+Y

This commit is contained in:
2026-04-09 18:04:37 +02:00
parent 2bcd5b1f4b
commit db280fb5c2
24 changed files with 2705 additions and 2316 deletions

View File

@@ -1,6 +1,6 @@
# Tagger - Project Documentation
**Version:** 1.1.0 | **Status:** Stable | **GUI:** PySide6/Qt6
**Version:** 1.2.0 | **Status:** Stable | **GUI:** PySide6/Qt6
---
@@ -8,7 +8,8 @@
Desktop app for organizing files using hierarchical tags (category/name).
**Features:** Folder scanning, tag filtering, rename/merge tags, CSFD.cz integration, hardlink structure, 3-level config (global/folder/file).
**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.
---
@@ -20,11 +21,12 @@ Tagger/
├── src/core/ # Business logic (NO UI imports!)
│ ├── tag.py # Tag value object (immutable)
│ ├── tag_manager.py # Tag/category management
│ ├── file.py # File with metadata
│ ├── file_manager.py # File management, filtering
│ ├── 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
│ ├── csfd.py # CSFD scraper + CSFDMovie serialization
│ ├── media_utils.py # ffprobe integration
│ ├── constants.py # APP_NAME, VERSION
│ └── _version.py # Version fallback for PyInstaller
├── src/ui/
@@ -37,34 +39,63 @@ Tagger/
## Architecture Rules
1. **UI must not contain business logic** - call FileManager/TagManager
2. **Core must not import UI** - no PySide6/tkinter in src/core/
3. **Dependency injection** - pass via constructor
4. **UTF-8 everywhere** - `encoding='utf-8'`, `ensure_ascii=False`
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 | Contents |
|-------|------|----------|
| Global | `.Tagger.!gtag` | window geometry, last folder |
| Folder | `.Tagger.!ftag` | ignore patterns, hardlink settings |
| File | `.filename.!tag` | tags, date, state |
| 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")`
**Tag** immutable, `Tag(category, name)`, `Tag.from_string("cat/name")`
**File** - `file_path`, `tags[]`, `date`, `csfd_url`, metadata in `.filename.!tag`
**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()`
**TagManager** `add_tag()`, `get_categories()`, `rename_tag()`, `merge_tag()`
**FileManager** - `append(folder)`, `filter_files_by_tags()`, `close_folder()`
**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()`
**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é"],
)
```
---
@@ -80,7 +111,7 @@ poetry run pyinstaller --onefile Tagger.py # Build
## Shortcuts
`Ctrl+O` Open | `Ctrl+T` Tags | `Ctrl+D` Date | `F5` Refresh | `Del` Remove
`Ctrl+O` Open | `Ctrl+T` Tags | `Ctrl+D` Date | `Ctrl+W` Close | `F5` Refresh | `Del` Remove
---
@@ -92,10 +123,14 @@ poetry run pyinstaller --onefile Tagger.py # Build
**Metadata corrupted:** Auto-recovers with defaults.
**Config not saved:** Check `~/.config/Tagger/` exists and is writable.
---
---
## Metrics
- **Tests:** 274 ✅
- **Python:** 3.13+
- **Dependencies:** PySide6, Pillow, requests, beautifulsoup4
- **Python:** 3.14+
- **Dependencies:** PySide6, requests, beautifulsoup4, loguru, python-dotenv