From 22a14b1e4126d2a58a081e5f3d5ad290bd68efef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Fri, 12 Jun 2026 16:01:54 +0200 Subject: [PATCH] =?UTF-8?q?Rework=20Tagger=20fork=20into=20Curator=20movie?= =?UTF-8?q?-library=20manager=20(PySide6=20GUI,=20pool=20index,=20=C4=8CSF?= =?UTF-8?q?D=20import)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 37 + CHANGELOG.md | 68 + Curator.py | 34 + Curator.spec | 45 + PROJECT.md | 117 ++ README.md | 60 + poetry.lock | 861 ++++++++++++ prebuild.py | 66 + pyproject.toml | 35 + src/__init__.py | 0 src/_version.py | 2 + src/constants.py | 66 + src/core/__init__.py | 0 src/core/config.py | 115 ++ src/core/constants.py | 4 + src/core/csfd.py | 429 ++++++ src/core/file.py | 216 ++++ src/core/file_manager.py | 272 ++++ src/core/hardlink_manager.py | 403 ++++++ src/core/list_manager.py | 20 + src/core/media_utils.py | 43 + src/core/pool_index.py | 64 + src/core/tag.py | 22 + src/core/tag_manager.py | 67 + src/core/utils.py | 7 + src/resources/images/32/32_calendar.png | Bin 0 -> 596 bytes src/resources/images/32/32_checked.png | Bin 0 -> 892 bytes src/resources/images/32/32_computer.png | Bin 0 -> 618 bytes src/resources/images/32/32_crossed.png | Bin 0 -> 961 bytes src/resources/images/32/32_tag.png | Bin 0 -> 710 bytes src/resources/images/32/32_unchecked.png | Bin 0 -> 716 bytes src/resources/images/orig/orig_calendar.png | Bin 0 -> 3002 bytes src/resources/images/orig/orig_checked.png | Bin 0 -> 15671 bytes src/resources/images/orig/orig_computer.png | Bin 0 -> 11354 bytes src/resources/images/orig/orig_crossed.png | Bin 0 -> 16363 bytes src/resources/images/orig/orig_tag.png | Bin 0 -> 13261 bytes src/ui/__init__.py | 0 src/ui/gui.py | 1296 +++++++++++++++++++ src/ui/qt_app.py | 590 +++++++++ tests/__init__.py | 1 + tests/conftest.py | 28 + tests/test_config.py | 413 ++++++ tests/test_constants.py | 140 ++ tests/test_csfd.py | 286 ++++ tests/test_file.py | 265 ++++ tests/test_file_manager.py | 612 +++++++++ tests/test_hardlink_manager.py | 628 +++++++++ tests/test_media_utils.py | 75 ++ tests/test_pool_index.py | 97 ++ tests/test_tag.py | 106 ++ tests/test_tag_manager.py | 327 +++++ tests/test_utils.py | 178 +++ 52 files changed, 8095 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Curator.py create mode 100644 Curator.spec create mode 100644 PROJECT.md create mode 100644 poetry.lock create mode 100644 prebuild.py create mode 100644 pyproject.toml create mode 100644 src/__init__.py create mode 100644 src/_version.py create mode 100644 src/constants.py create mode 100644 src/core/__init__.py create mode 100644 src/core/config.py create mode 100644 src/core/constants.py create mode 100644 src/core/csfd.py create mode 100644 src/core/file.py create mode 100644 src/core/file_manager.py create mode 100644 src/core/hardlink_manager.py create mode 100644 src/core/list_manager.py create mode 100644 src/core/media_utils.py create mode 100644 src/core/pool_index.py create mode 100644 src/core/tag.py create mode 100644 src/core/tag_manager.py create mode 100644 src/core/utils.py create mode 100644 src/resources/images/32/32_calendar.png create mode 100644 src/resources/images/32/32_checked.png create mode 100644 src/resources/images/32/32_computer.png create mode 100644 src/resources/images/32/32_crossed.png create mode 100644 src/resources/images/32/32_tag.png create mode 100644 src/resources/images/32/32_unchecked.png create mode 100644 src/resources/images/orig/orig_calendar.png create mode 100644 src/resources/images/orig/orig_checked.png create mode 100644 src/resources/images/orig/orig_computer.png create mode 100644 src/resources/images/orig/orig_crossed.png create mode 100644 src/resources/images/orig/orig_tag.png create mode 100644 src/ui/__init__.py create mode 100644 src/ui/gui.py create mode 100644 src/ui/qt_app.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_constants.py create mode 100644 tests/test_csfd.py create mode 100644 tests/test_file.py create mode 100644 tests/test_file_manager.py create mode 100644 tests/test_hardlink_manager.py create mode 100644 tests/test_media_utils.py create mode 100644 tests/test_pool_index.py create mode 100644 tests/test_tag.py create mode 100644 tests/test_tag_manager.py create mode 100644 tests/test_utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fdacba --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.pyc +*.pyo + +# Virtual environment +.venv/ + +# Tooling caches +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ + +# Environment and secrets +.env + +# Logs (loguru file sink) +logs/ + +# PyInstaller build artifacts (dist/ is committed — see DESIGN_DOCUMENT §13) +build/ + +# Curator runtime metadata (user/machine-specific, regenerated) +.Curator.!gtag +*.!gtag +*.!ftag +*.!tag +*.!index + +# IDE +.vscode/ +.idea/ + +AGENTS.md +CLAUDE.md +DESIGN_DOCUMENT.md +.claude/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3479bca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +Document all notable changes to the project, grouped by version and release date. + +## Format + +Each version entry uses these sections (include only those that apply): + +- **Added** — new features +- **Changed** — changes to existing functionality +- **Fixed** — bug fixes +- **Removed** — removed features +- **Dependencies** — added, updated, or removed dependencies + +## Rules + +- Follow semantic versioning: `MAJOR.MINOR.PATCH` +- Newest version goes at the top +- Always update this file before bumping the version in `pyproject.toml` +- Document changes as they are made, not all at once at release time + +## Unreleased + +### Added +- Fork of the former **Tagger** project as **Curator**, a movie-library manager. +- **Pool** concept (single source of truth) with `Filmy` / `Seriály` folders and + a configurable **Filmotéka** output folder, stored in the global config. +- "Import movie" flow: pick a video, enter Title + ČSFD link, the file is copied + into `pool/Filmy` as `Title.ext` (non-destructive) and indexed. +- `File` now stores `title` and `csfd_link`. +- New **PySide6** GUI reframed around the Filmotéka workflow (pool setup, import, + tag filter sidebar, movie table, one-click Filmotéka generation), replacing the + tkinter GUI as the entry point. +- Seriály **copy-as-is mirror**: `pool/Seriály` is cloned 1:1 into the Filmotéka + output as hardlinks (`HardlinkManager.mirror_as_is`), generated alongside the + movie tree. +- **Unified pool metadata index** (`pool_index.py`): the whole pool's metadata + lives in a single `/.Curator.!index` JSON keyed by pool-relative path. + `File` reads/writes the index when one is injected and otherwise keeps using + per-file `.!tag` sidecars; `FileManager` uses the index for the pool. +- **Configurable copy-as-is folders**: `copyasis_folders` in the global config + (editable from the GUI) lists pool subfolders mirrored 1:1 during generation; + `Seriály` is the default. Generalizes the previously hardcoded Seriály mirror. +- Project `README.md` (overview, concepts, workflow, run/build instructions). +- **ČSFD scraping** (`csfd.py`, ported from the Tagger devel branch): fetches + movie data from a ČSFD link (JSON-LD + HTML parsing). `File.apply_csfd_tags` + assigns Žánr / Rok / Země tags and caches the fetched data in the metadata. + The GUI auto-fetches on import when a link is given and offers "Načíst tagy + z ČSFD" for selected movies. +- App startup injects `truststore` so HTTPS uses the OS certificate store — + ČSFD fetching works behind corporate SSL inspection (where certifi's bundle + lacks the proxy root CA). + +### Fixed +- `media_utils.add_video_resolution_tag` referenced `subprocess` without importing it. +- ČSFD parsing updated for the current site HTML: year is read from JSON-LD + `dateCreated`, and the origin line (now bullet-separated, no commas) is + tokenized so country / year / duration are extracted correctly. + +### Changed +- All references to "Tagger" renamed to "Curator" (code, spec, config filenames + `.Curator.!gtag` / `.Curator.!ftag`, tests). +- `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility). + +### Dependencies +- Added `pyside6` (GUI), `requests` + `beautifulsoup4` (ČSFD scraping), + `truststore` (OS cert store for HTTPS). Declared `python-dotenv`, `pillow`, + `loguru` (already imported by the inherited code). diff --git a/Curator.py b/Curator.py new file mode 100644 index 0000000..c09c57b --- /dev/null +++ b/Curator.py @@ -0,0 +1,34 @@ +# Imports +from src.ui.qt_app import run +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager + + +def _use_system_certificates() -> None: + """Make HTTPS (ČSFD fetching) use the OS certificate store. + + Required behind corporate SSL inspection, where the proxy's root CA is in the + Windows trust store but not in certifi's bundle. Best-effort; ignored if the + optional `truststore` package is unavailable. + """ + try: + import truststore + truststore.inject_into_ssl() + except Exception: + pass + + +class State: + def __init__(self) -> None: + self.tagmanager = TagManager() + self.filehandler = FileManager(self.tagmanager) + + +def main() -> None: + _use_system_certificates() + state = State() + run(state.filehandler, state.tagmanager) + + +if __name__ == "__main__": + main() diff --git a/Curator.spec b/Curator.spec new file mode 100644 index 0000000..9a162de --- /dev/null +++ b/Curator.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['Curator.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='Curator', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + onefile=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='Curator', +) diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..e276266 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,117 @@ +# PROJECT.md + +This file is project-specific. Only include information directly related to the concrete project — goals, current state, architecture decisions, known issues, and tasks. + +## Origin + +Curator is a fork of the former **Tagger** project. The tagging, filtering and +hardlink-tree parts are inherited and keep working as before. On top of that, +Curator becomes a full **movie library manager (Filmotéka)**. + +## Core idea + +Curator manages a personal movie library based on two folders: + +- **Pool** — the managed repository of video files. This is the **single source + of truth**. Curator manages the pool itself (insert/remove file), so files are + never moved by hand. The pool has exactly two top-level folders: **Filmy** + (movies — tag-based tree) and **Seriály** (series — a "copy-as-is" folder + mirrored 1:1 into the output; see Design decisions). Every file lives here + exactly once. +- **Filmotéka (output)** — a generated, browsable directory tree made only of + **hardlinks** into the pool (the same mechanism as today's hardlink manager). + It is fully disposable: deleting the Filmotéka folder loses nothing, because + it can always be regenerated from the pool. + +### Workflow + +1. The user configures two folders: the **pool** and the **Filmotéka output**. +2. The user picks a video file via "Open file". +3. Curator opens a dialog to fill in basic info — at minimum the **title/name** + and a **ČSFD link**. +4. Curator **renames** the file and **moves** it into the managed pool, and + writes a **metadata file** describing it. +5. From the pool, Curator **generates the Filmotéka** — a complex tree of + hardlinks built from each file's tags/metadata (like the current hardlink + manager, but driven by the pool). +6. Deleting the Filmotéka has no effect on the pool; the tree is regenerated on + demand. + +## Current state + +- Inherited from Tagger: `Tag`, `TagManager`, `File` (sidecar metadata), + `FileManager` (folder scan, filtering, ignore patterns), 3-level config, + `HardlinkManager` (create/sync/cleanup), pytest suite. +- Rename Tagger → Curator done across code, spec, config filenames + (`.Curator.!gtag` / `.Curator.!ftag`) and tests. +- **PySide6 GUI** (`src/ui/qt_app.py`) reframed around the Filmotéka workflow is + the entry point; the old tkinter `src/ui/gui.py` is retained for reference. +- **Pool + Filmotéka wired up:** global config holds `pool_dir` / `filmoteka_dir`; + `FileManager` creates `Filmy`/`Seriály`, imports movies (copy → `Title.ext`), + loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`. +- `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index** + (`/.Curator.!index`, see `pool_index.py`); `File` writes there when an + index is injected, and still falls back to per-file `.!tag` sidecars for + arbitrary (non-pool) folders. + +### GUI decision + +The GUI was **reframed around the Filmotéka** (not kept as a generic tagger) and +**rewritten in PySide6**: Pool/Filmotéka setup, Import movie, tag-filter sidebar, +movie table, and one-click Filmotéka generation. + +## Design decisions + +- **Metadata storage:** one **unified metadata file** for the whole pool (a + central index), not per-file sidecars. Justified because Curator owns the pool + and files are never moved manually, so it is not exposed to path drift. +- **Import dialog:** collects only **Title** + **ČSFD link**. The file is renamed + to `Title.ext`. When a ČSFD link is given, Curator fetches the movie and assigns + Žánr / Rok / Země tags automatically; further tags can be added via the UI. +- **Genres:** a movie can have **multiple genres**, so it appears under each of + its genre branches in the Filmotéka (multiple hardlinks). +- **Pool layout:** two top-level folders — **Filmy** and **Seriály**. Movies are + the first target; the Seriály branch follows the "copy-as-is" rule below. +- **Copy-as-is folders (Seriály):** a subfolder inside the pool can be marked as + **copy / as-is**. For such a folder Curator does **not** build a tag-based tree; + instead it **mirrors the exact directory hierarchy** from the pool into the + Filmotéka output, with the files materialized as **hardlinks** into the pool. + So `pool/Seriály/...` is cloned 1:1 into `output/Seriály/...` (same structure, + hardlinked files). This is how Seriály work. +- **File naming:** imported movies are renamed to **`Title.ext`** (no year in the + filename; year lives in metadata/tags). +- **Import is non-destructive:** the original file is **copied** into the pool, + the source is left in place. +- **Filmotéka tree:** **one level per category** — `output/Category/Tag/film` + (hardlink), same shape as the current hardlink manager. For now the tree is + built from these categories: **Rok**, **Žánr**, **Hodnocení**. + +## Tasks + +# (no open tasks — see Done) + +## Done + +- Pool-root and Filmotéka-output folder settings in the global config +- Filmy / Seriály top-level folder handling in the pool +- "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext +- Remove-from-pool (delete file + its metadata) +- Generate the Filmotéka hardlink tree from the pool (Rok / Žánr / Hodnocení) +- Filmotéka fully regenerable from the pool alone (delete output = no loss) +- GUI reframed around the Filmotéka and rewritten in PySide6 +- Seriály "copy-as-is" mirror: pool/Seriály cloned 1:1 into the output as + hardlinks (`HardlinkManager.mirror_as_is`), wired into Filmotéka generation +- Fixed `media_utils` missing `subprocess` import +- Unified pool metadata index (`pool_index.py`): one `.Curator.!index` per pool; + `File` reads/writes it when injected, `FileManager` uses it for the pool +- Configurable copy-as-is folders (`copyasis_folders` in global config, editable + from the GUI); each is mirrored 1:1 during Filmotéka generation (Seriály default) +- README.md written (overview, concepts, workflow, run/build instructions) +- ČSFD scraping (`csfd.py`, ported from Tagger devel): `File.apply_csfd_tags` + fetches a movie and assigns Žánr / Rok / Země tags (cached in metadata); wired + into the GUI (auto-fetch on import with a ČSFD link, plus "Načíst tagy z ČSFD"). + Parsing updated for current ČSFD HTML and verified live against Matrix + (film/9499); HTTPS uses the OS cert store via `truststore` (corporate SSL) +- Fixed template cruft: `src/constants.py` made consistent (Curator values, + `get_version`/`get_debug_mode` API) and `test_constants.py` aligned; removed + the imported `tagger/` devel dump diff --git a/README.md b/README.md index e69de29..80d663a 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,60 @@ +# Curator + +Curator is a desktop **movie-library manager (Filmotéka)**. You import video +files into a managed **pool**, tag them, and Curator generates a browsable +**Filmotéka** — a directory tree of hardlinks into the pool — without ever +duplicating the actual video data on disk. + +Curator is a fork of the former *Tagger* project; the tagging, filtering and +hardlink-tree machinery is inherited and extended into a full library workflow. + +## Concepts + +- **Pool** — the managed repository of files and the **single source of truth**. + Curator owns it: it imports and removes files itself, so nothing moves behind + its back. The pool has two top-level folders: + - `Filmy` — movies, organized by tags into the generated tree. + - `Seriály` — series (and any other **copy-as-is** folder), mirrored verbatim. +- **Filmotéka (output)** — a generated tree of **hardlinks** into the pool. It is + fully disposable: delete it and nothing is lost, because it is regenerated from + the pool on demand. +- **Pool index** — all pool metadata lives in a single `.Curator.!index` JSON + file at the pool root (title, ČSFD link, tags, date per file). + +## Workflow + +1. Configure the **pool** and the **Filmotéka output** folder. +2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The + file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and + recorded in the index. If a ČSFD link is given, Curator fetches the movie from + [ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země** tags + automatically (use "Načíst tagy z ČSFD" to (re)fetch later). +3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI. +4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree + (from the `Rok` / `Žánr` / `Hodnocení` categories); copy-as-is folders such as + `Seriály` are mirrored 1:1 as hardlinks. + +## Running + +```bash +poetry install +poetry run python Curator.py +``` + +The GUI is built with **PySide6**. + +## Development + +```bash +poetry run pytest # tests +poetry run ruff check # lint / format +poetry run mypy # type checking +``` + +## Building a standalone executable + +```bash +poetry run pyinstaller Curator.spec +``` + +The resulting `dist/Curator` is the distribution artifact. diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..bcf5db9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,861 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "ast-serialize" +version = "0.5.0" +description = "Python bindings for mypy AST serialization" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb"}, + {file = "ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101"}, + {file = "ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d"}, + {file = "ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a"}, + {file = "ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590"}, + {file = "ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642"}, + {file = "ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6"}, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.7.0" +groups = ["main"] +files = [ + {file = "beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9"}, + {file = "beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7"}, +] + +[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.5.20" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, + {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, +] + +[[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.18" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, +] + +[package.extras] +all = ["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 = "librt" +version = "0.11.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"}, + {file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"}, + {file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"}, + {file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"}, + {file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"}, + {file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"}, + {file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"}, + {file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"}, + {file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"}, + {file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"}, + {file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"}, + {file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"}, + {file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"}, + {file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"}, + {file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"}, + {file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"}, + {file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"}, + {file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"}, + {file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"}, + {file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"}, + {file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"}, + {file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"}, + {file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"}, + {file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"}, + {file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"}, + {file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"}, + {file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"}, + {file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"}, + {file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"}, + {file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"}, + {file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"}, + {file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"}, + {file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"}, + {file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"}, + {file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"}, + {file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"}, + {file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"}, + {file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"}, + {file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"}, + {file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"}, +] + +[[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 = "mypy" +version = "2.1.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc"}, + {file = "mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849"}, + {file = "mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd"}, + {file = "mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166"}, + {file = "mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8"}, + {file = "mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8"}, + {file = "mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e"}, + {file = "mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41"}, + {file = "mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca"}, + {file = "mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538"}, + {file = "mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398"}, + {file = "mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563"}, + {file = "mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389"}, + {file = "mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666"}, + {file = "mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af"}, + {file = "mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6"}, + {file = "mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211"}, + {file = "mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b"}, + {file = "mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22"}, + {file = "mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b"}, + {file = "mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8"}, + {file = "mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5"}, + {file = "mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e"}, + {file = "mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e"}, + {file = "mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285"}, + {file = "mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5"}, + {file = "mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65"}, + {file = "mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d"}, + {file = "mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2"}, + {file = "mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f"}, + {file = "mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4"}, + {file = "mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef"}, + {file = "mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135"}, + {file = "mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21"}, + {file = "mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57"}, + {file = "mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e"}, + {file = "mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780"}, + {file = "mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd"}, + {file = "mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08"}, + {file = "mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081"}, + {file = "mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7"}, + {file = "mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6"}, + {file = "mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289"}, + {file = "mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633"}, +] + +[package.dependencies] +ast-serialize = ">=0.3.0,<1.0.0" +librt = {version = ">=0.11.0", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=1.0.0" +typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""} + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "26.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + +[[package]] +name = "pillow" +version = "12.2.0" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, + {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, + {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, + {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, + {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, + {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, + {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, + {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, + {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, + {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, + {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, + {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, + {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, + {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, + {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, + {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, + {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, + {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, + {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, + {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, + {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, + {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, + {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +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 = "pyside6" +version = "6.11.1" +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.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6"}, + {file = "pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2"}, + {file = "pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84"}, + {file = "pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6"}, + {file = "pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805"}, +] + +[package.dependencies] +PySide6_Addons = "6.11.1" +PySide6_Essentials = "6.11.1" +shiboken6 = "6.11.1" + +[[package]] +name = "pyside6-addons" +version = "6.11.1" +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.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9"}, + {file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f"}, + {file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993"}, + {file = "pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923"}, + {file = "pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37"}, +] + +[package.dependencies] +PySide6_Essentials = "6.11.1" +shiboken6 = "6.11.1" + +[[package]] +name = "pyside6-essentials" +version = "6.11.1" +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.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957"}, + {file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60"}, + {file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d"}, + {file = "pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40"}, + {file = "pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f"}, +] + +[package.dependencies] +shiboken6 = "6.11.1" + +[[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 = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[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 = "ruff" +version = "0.15.17" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f"}, + {file = "ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7"}, + {file = "ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4"}, + {file = "ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7"}, + {file = "ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f"}, + {file = "ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f"}, + {file = "ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8"}, + {file = "ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392"}, + {file = "ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084"}, + {file = "ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456"}, + {file = "ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219"}, +] + +[[package]] +name = "shiboken6" +version = "6.11.1" +description = "Python/C++ bindings helper module" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02"}, + {file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe"}, + {file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f"}, + {file = "shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7"}, + {file = "shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d"}, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65"}, + {file = "soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e"}, +] + +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + +[[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", "dev"] +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.7.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, +] + +[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.14,<3.15" +content-hash = "5906affa631fe91b3b93e881e52420a2e591110023722a7a2d5264c96706dcf2" diff --git a/prebuild.py b/prebuild.py new file mode 100644 index 0000000..bc5707e --- /dev/null +++ b/prebuild.py @@ -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") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ca16aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "curator" +version = "0.1.0" +description = "" +authors = [ + {name = "jan.doubravsky@gmail.com"} +] +readme = "README.md" +requires-python = ">=3.14,<3.15" +dependencies = [ + "pyside6 (>=6.11.1,<7.0.0)", + "requests (>=2.34.2,<3.0.0)", + "beautifulsoup4 (>=4.15.0,<5.0.0)", + "python-dotenv (>=1.2.2,<2.0.0)", + "pillow (>=12.2.0,<13.0.0)", + "loguru (>=0.7.3,<0.8.0)", + "truststore (>=0.10.4,<0.11.0)" +] + +[tool.poetry] +package-mode = false + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[dependency-groups] +dev = [ + "ruff (>=0.15.17,<0.16.0)", + "mypy (>=2.1.0,<3.0.0)", + "pytest (>=9.0.3,<10.0.0)" +] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/_version.py b/src/_version.py new file mode 100644 index 0000000..941897d --- /dev/null +++ b/src/_version.py @@ -0,0 +1,2 @@ +"""Auto-generated — do not edit manually.""" +__version__ = "0.1.0" diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..49c324b --- /dev/null +++ b/src/constants.py @@ -0,0 +1,66 @@ +""" +Application-level constants for Curator (build/runtime metadata). + +This module derives the app version and debug flag. The version is loaded from +`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for +frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`). + +Note: per-feature constants (window size, tag colors, …) live in +`src/core/constants.py`; this module is only the version/debug surface used by +the build tooling and frozen builds. +""" + +import os +import tomllib +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +_PYPROJECT_PATH = Path(__file__).parent.parent / "pyproject.toml" +_VERSION_FILE = Path(__file__).parent / "_version.py" + +APP_NAME: str = "Curator" + + +def get_version() -> str: + """Return the project version (pyproject → _version.py → unknown).""" + # 1. pyproject.toml (preferred) + try: + with open(_PYPROJECT_PATH, "rb") as f: + version = tomllib.load(f)["project"]["version"] + # Cache a fallback for frozen/PyInstaller builds (best-effort). + try: + _VERSION_FILE.write_text( + f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n', + encoding="utf-8", + ) + except OSError: + pass + return version + except (FileNotFoundError, KeyError): + pass + + # 2. generated _version.py + try: + from src._version import __version__ # type: ignore[import] + return __version__ + except ImportError: + pass + + # 3. last resort + return "0.0.0-unknown" + + +def get_debug_mode() -> bool: + """Return True when ENV_DEBUG is set to a truthy value in the environment.""" + return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes") + + +APP_VERSION: str = get_version() +ENV_DEBUG: bool = get_debug_mode() +APP_TITLE: str = f"{APP_NAME} v{APP_VERSION}" + ("-DEV" if ENV_DEBUG else "") + +# Backwards-compatible alias used by prebuild.py +VERSION: str = APP_VERSION diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..540a939 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,115 @@ +""" +Configuration management for Curator + +Three levels of configuration: +1. Global config (.Curator.!gtag next to Curator.py) - app-wide settings +2. Folder config (.Curator.!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 + +# Global config file (next to the main script) +GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag" + +# Folder config filename +FOLDER_CONFIG_NAME = ".Curator.!ftag" + + +# ============================================================================= +# GLOBAL CONFIG - Application settings +# ============================================================================= + +DEFAULT_GLOBAL_CONFIG = { + "window_geometry": "1200x800", + "window_maximized": False, + "last_folder": None, + "sidebar_width": 250, + "recent_folders": [], + "pool_dir": None, # managed pool root (single source of truth) + "filmoteka_dir": None, # generated Filmotéka output (hardlink tree) + "copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is) +} + + +def load_global_config() -> dict: + """Load global application config""" + if GLOBAL_CONFIG_FILE.exists(): + try: + with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f: + config = json.load(f) + # Merge with defaults for any missing keys + for key, value in DEFAULT_GLOBAL_CONFIG.items(): + if key not in config: + config[key] = value + return config + except Exception: + return DEFAULT_GLOBAL_CONFIG.copy() + return DEFAULT_GLOBAL_CONFIG.copy() + + +def save_global_config(cfg: dict): + """Save global application config""" + with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + +# ============================================================================= +# FOLDER CONFIG - Per-folder settings +# ============================================================================= + +DEFAULT_FOLDER_CONFIG = { + "ignore_patterns": [], + "custom_tags": {}, # Additional tags specific to this folder + "recursive": True, # Whether to scan subfolders + "hardlink_output_dir": None, # Output directory for hardlink structure + "hardlink_categories": None, # Categories to include in hardlink (None = all) +} + + +def get_folder_config_path(folder: Path) -> Path: + """Get path to folder config file""" + return folder / FOLDER_CONFIG_NAME + + +def load_folder_config(folder: Path) -> dict: + """Load folder-specific config""" + config_path = get_folder_config_path(folder) + if config_path.exists(): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + # Merge with defaults for any missing keys + for key, value in DEFAULT_FOLDER_CONFIG.items(): + if key not in config: + config[key] = value + return config + except Exception: + return DEFAULT_FOLDER_CONFIG.copy() + return DEFAULT_FOLDER_CONFIG.copy() + + +def save_folder_config(folder: Path, cfg: dict): + """Save folder-specific config""" + config_path = get_folder_config_path(folder) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + +def folder_has_config(folder: Path) -> bool: + """Check if folder has a tagger config""" + return get_folder_config_path(folder).exists() + + +# ============================================================================= +# BACKWARDS COMPATIBILITY +# ============================================================================= + +def load_config(): + """Legacy function - returns global config""" + return load_global_config() + + +def save_config(cfg: dict): + """Legacy function - saves global config""" + save_global_config(cfg) diff --git a/src/core/constants.py b/src/core/constants.py new file mode 100644 index 0000000..4256897 --- /dev/null +++ b/src/core/constants.py @@ -0,0 +1,4 @@ +# src/core/constants.py +VERSION = "v1.0.3" +APP_NAME = "Curator" +APP_VIEWPORT = "1000x700" \ No newline at end of file diff --git a/src/core/csfd.py b/src/core/csfd.py new file mode 100644 index 0000000..5c57f13 --- /dev/null +++ b/src/core/csfd.py @@ -0,0 +1,429 @@ +""" +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) + + # Year (CSFD exposes it via dateCreated / datePublished) + for date_key in ("dateCreated", "datePublished"): + date_val = json_data.get(date_key) + if date_val: + year_match = re.search(r"(19\d{2}|20\d{2})", str(date_val)) + if year_match: + data["year"] = int(year_match.group(1)) + break + + # 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 the origin info line. + + CSFD separates the values with inline bullet ```` elements (no commas), + so ``get_text(strip=True)`` would glue them together (e.g. "USA1999136 min"). + We tokenize on those inline boundaries (and on commas, for the older format) + before extracting each field. + """ + info: dict = {} + + origin_elem = soup.select_one(".origin") + if not origin_elem: + return info + + # Split on inline element boundaries, then also on commas (older format). + raw = origin_elem.get_text(separator="|", strip=True) + tokens = [t.strip() for part in raw.split("|") for t in part.split(",")] + tokens = [t for t in tokens if t] + + for token in tokens: + if "year" not in info and re.fullmatch(r"(19\d{2}|20\d{2})", token): + info["year"] = int(token) + continue + if "duration" not in info: + duration_match = re.search(r"(\d+)\s*min", token) + if duration_match: + info["duration"] = int(duration_match.group(1)) + continue + # Country: first alphabetic token that doesn't start with a digit. + if "country" not in info and not token[0].isdigit() and re.search(r"[^\W\d_]", token): + info["country"] = token + + 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) diff --git a/src/core/file.py b/src/core/file.py new file mode 100644 index 0000000..52babc5 --- /dev/null +++ b/src/core/file.py @@ -0,0 +1,216 @@ +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, index=None) -> None: + self.file_path = file_path + self.filename = file_path.name + self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag" + # Optional unified pool index; when set, metadata lives there instead of + # in the sidecar file (see PoolIndex). + self.index = index + self.new = True + self.ignored = False + self.tags: list[Tag] = [] + self.tagmanager = tagmanager + # new: optional date string "YYYY-MM-DD" (assigned manually) + self.date: str | None = None + # movie-library fields + self.title: str | None = None + self.csfd_link: 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: + if self.index is not None: + record = self.index.get(self.file_path) + if record is None: + self._init_new_metadata() + self.save_metadata() + else: + self._apply_record(record) + return + + if not self.metadata_filename.exists(): + self._init_new_metadata() + self.save_metadata() + else: + self.load_metadata() + + def _init_new_metadata(self) -> None: + self.new = True + self.ignored = False + self.tags = [] + self.date = None + self.title = None + self.csfd_link = None + self.csfd_cache = None + if self.tagmanager: + tag = self.tagmanager.add_tag("Stav", "Nové") + self.tags.append(tag) + + def _build_record(self) -> dict: + 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, + "title": self.title, + "csfd_link": self.csfd_link, + } + if self.csfd_cache is not None: + data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache} + return data + + def _apply_record(self, data: dict) -> None: + self.new = data.get("new", True) + self.ignored = data.get("ignored", False) + self.tags = [] + self.date = data.get("date", None) + self.title = data.get("title", None) + self.csfd_link = data.get("csfd_link", 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 + + for tag_str in data.get("tags", []): + if "/" in tag_str: + category, name = tag_str.split("/", 1) + tag = self.tagmanager.add_tag(category, name) + self.tags.append(tag) + + def save_metadata(self): + data = self._build_record() + if self.index is not None: + self.index.set(self.file_path, data) + return + with open(self.metadata_filename, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + def load_metadata(self) -> None: + with open(self.metadata_filename, "r", encoding="utf-8") as f: + data = json.load(f) + self._apply_record(data) + + def delete_metadata(self) -> None: + """Remove this file's metadata (from the index, or its sidecar file).""" + if self.index is not None: + self.index.delete(self.file_path) + elif self.metadata_filename.exists(): + self.metadata_filename.unlink() + + def set_date(self, date_str: str | None): + """Nastaví datum (např. '2025-09-25') nebo None pro smazání.""" + if date_str is None or date_str == "": + self.date = None + else: + # neprvádíme složitou validaci zde; očekáváme 'YYYY-MM-DD' + self.date = date_str + self.save_metadata() + + def set_csfd_link(self, url: str | None) -> None: + """Nastaví CSFD odkaz nebo None. Invaliduje cache při změně odkazu.""" + new_url = url if url else None + if new_url != self.csfd_link: + self.csfd_cache = None # odkaz se změnil — stará cache je neplatná + self.csfd_link = new_url + self.save_metadata() + + def get_cached_movie(self): + """Vrátí CSFDMovie z cache, nebo None. Nevyžaduje síť.""" + 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ě); cachuje data. + + Returns: + dict s klíči 'success', 'movie'/'error', 'tags_added' + """ + if not self.csfd_link: + return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []} + + try: + from .csfd import fetch_movie + movie = fetch_movie(self.csfd_link) + 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: list[str] = [] + + def _add(category: str, name: str) -> None: + tag_obj = self.tagmanager.add_tag(category, name) if self.tagmanager else Tag(category, name) + if tag_obj not in self.tags: + self.tags.append(tag_obj) + tags_added.append(f"{category}/{name}") + + if add_genres and movie.genres: + for genre in movie.genres: + _add("Žánr", genre) + if add_year and movie.year: + _add("Rok", str(movie.year)) + if add_country and movie.country: + _add("Země", movie.country) + + # Use the CSFD title if we don't have one yet + if movie.title and not self.title: + self.title = movie.title + + 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 + if isinstance(tag, str): + if "/" in tag and self.tagmanager: + category, name = tag.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + else: + tag_obj = TagClass("default", tag) + elif isinstance(tag, TagClass): + tag_obj = tag + else: + return + if tag_obj not in self.tags: + self.tags.append(tag_obj) + self.save_metadata() + + def remove_tag(self, tag): + # tag může být Tag nebo string (full_path) + if isinstance(tag, str): + if "/" in tag: + category, name = tag.split("/", 1) + tag_obj = Tag(category, name) + else: + tag_obj = Tag("default", tag) + elif isinstance(tag, Tag): + tag_obj = tag + else: + return + if tag_obj in self.tags: + self.tags.remove(tag_obj) + self.save_metadata() \ No newline at end of file diff --git a/src/core/file_manager.py b/src/core/file_manager.py new file mode 100644 index 0000000..151c7c7 --- /dev/null +++ b/src/core/file_manager.py @@ -0,0 +1,272 @@ +from pathlib import Path +import shutil +from .file import File +from .tag_manager import TagManager +from .pool_index import PoolIndex +from .utils import list_files +from typing import Iterable +import fnmatch +from src.core.config import ( + load_global_config, save_global_config, + load_folder_config, save_folder_config +) + +# Top-level folders inside the managed pool +POOL_MOVIES = "Filmy" +POOL_SERIES = "Seriály" + +# Curator metadata files that must never be treated as content +METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index") + + +class FileManager: + def __init__(self, tagmanager: TagManager): + self.filelist: list[File] = [] + self.folders: list[Path] = [] + self.tagmanager = tagmanager + self.on_files_changed = None # callback do GUI + self.global_config = load_global_config() + self.folder_configs: dict[Path, dict] = {} # folder -> config + self.current_folder: Path | None = None + self.index: PoolIndex | None = None # unified pool metadata index + + # ------------------------------------------------------------------ + # Pool (single source of truth) and Filmotéka (generated output) + # ------------------------------------------------------------------ + + @property + def pool_dir(self) -> Path | None: + value = self.global_config.get("pool_dir") + return Path(value) if value else None + + @property + def movies_dir(self) -> Path | None: + pool = self.pool_dir + return pool / POOL_MOVIES if pool else None + + @property + def series_dir(self) -> Path | None: + pool = self.pool_dir + return pool / POOL_SERIES if pool else None + + @property + def copyasis_folders(self) -> list[str]: + """Names of pool subfolders mirrored 1:1 (copy-as-is) into the output.""" + return self.global_config.get("copyasis_folders", [POOL_SERIES]) + + def set_copyasis_folders(self, names: list[str]) -> None: + """Set the copy-as-is subfolder list and persist it.""" + cleaned = [n.strip() for n in names if n.strip()] + self.global_config["copyasis_folders"] = cleaned + save_global_config(self.global_config) + + @property + def filmoteka_dir(self) -> Path | None: + value = self.global_config.get("filmoteka_dir") + return Path(value) if value else None + + def set_pool_dir(self, pool_dir: Path) -> None: + """Set the managed pool root and create its top-level folders.""" + pool_dir = Path(pool_dir) + (pool_dir / POOL_MOVIES).mkdir(parents=True, exist_ok=True) + (pool_dir / POOL_SERIES).mkdir(parents=True, exist_ok=True) + self.global_config["pool_dir"] = str(pool_dir) + save_global_config(self.global_config) + + def set_filmoteka_dir(self, filmoteka_dir: Path) -> None: + """Set the Filmotéka output folder (generated hardlink tree).""" + self.global_config["filmoteka_dir"] = str(Path(filmoteka_dir)) + save_global_config(self.global_config) + + def load_pool_movies(self) -> None: + """Reload the movie list from pool/Filmy using the unified pool index.""" + self.filelist = [] + movies = self.movies_dir + pool = self.pool_dir + if not (movies and movies.is_dir() and pool): + return + + self.index = PoolIndex(pool) + for each in list_files(movies): + if each.name.endswith(METADATA_SUFFIXES): + continue + file_obj = File(each, self.tagmanager, index=self.index) + self.filelist.append(file_obj) + + def import_movie(self, source: Path, title: str, csfd_link: str | None = None) -> File: + """Copy a video file into pool/Filmy as 'Title.ext', index its metadata. + + The original file is left in place (non-destructive copy). + """ + movies = self.movies_dir + pool = self.pool_dir + if movies is None or pool is None: + raise RuntimeError("Pool není nastaven.") + movies.mkdir(parents=True, exist_ok=True) + + if self.index is None: + self.index = PoolIndex(pool) + + source = Path(source) + safe_title = title.strip() or source.stem + target = movies / f"{safe_title}{source.suffix}" + + # Avoid clobbering an existing movie of the same name + counter = 1 + while target.exists(): + target = movies / f"{safe_title}_{counter}{source.suffix}" + counter += 1 + + shutil.copy2(source, target) + + file_obj = File(target, self.tagmanager, index=self.index) + file_obj.title = safe_title + file_obj.csfd_link = csfd_link or None + file_obj.save_metadata() + + self.filelist.append(file_obj) + if self.on_files_changed: + self.on_files_changed(self.filelist) + return file_obj + + def append(self, folder: Path) -> None: + """Add a folder to scan for files""" + self.folders.append(folder) + 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", []) + + for each in list_files(folder): + # Skip all Curator metadata files (.!tag / .!ftag / .!gtag / .!index) + if each.name.endswith(METADATA_SUFFIXES): + continue + + full_path = each.as_posix() + + # Check against ignore patterns + if any( + fnmatch.fnmatch(each.name, pat) or fnmatch.fnmatch(full_path, pat) + for pat in ignore_patterns + ): + continue + + file_obj = File(each, self.tagmanager) + self.filelist.append(file_obj) + + def get_folder_config(self, folder: Path = None) -> dict: + """Get config for a folder (or current folder if not specified)""" + if folder is None: + folder = self.current_folder + if folder is None: + return {} + if folder not in self.folder_configs: + self.folder_configs[folder] = load_folder_config(folder) + return self.folder_configs[folder] + + def save_folder_config(self, folder: Path = None, config: dict = None): + """Save config for a folder""" + if folder is None: + folder = self.current_folder + if folder is None: + return + if config is None: + config = self.folder_configs.get(folder, {}) + self.folder_configs[folder] = config + save_folder_config(folder, config) + + def set_ignore_patterns(self, patterns: list[str], folder: Path = None): + """Set ignore patterns for a folder""" + config = self.get_folder_config(folder) + config["ignore_patterns"] = patterns + self.save_folder_config(folder, config) + + def get_ignore_patterns(self, folder: Path = None) -> list[str]: + """Get ignore patterns for a folder""" + config = self.get_folder_config(folder) + return config.get("ignore_patterns", []) + + def assign_tag_to_file_objects(self, files_objs: list[File], tag): + """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" + for f in files_objs: + if isinstance(tag, str): + if "/" in tag: + category, name = tag.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + else: + 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) + + 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) + 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): + """ + Vrátí jen soubory, které obsahují všechny zadané tagy. + 'tags' může být iterace Tag objektů nebo stringů 'category/name'. + """ + tags_list = list(tags) if tags is not None else [] + if not tags_list: + return self.filelist + + 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: + continue + + 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 + + # Legacy property for backwards compatibility + @property + def config(self): + """Legacy: returns global config""" + return self.global_config diff --git a/src/core/hardlink_manager.py b/src/core/hardlink_manager.py new file mode 100644 index 0000000..cc75445 --- /dev/null +++ b/src/core/hardlink_manager.py @@ -0,0 +1,403 @@ +""" +Hardlink Manager for Curator + +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 mirror_as_is( + self, + source_dir: Path, + subfolder: str | None = None, + dry_run: bool = False + ) -> Tuple[int, int]: + """Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone. + + Recreates the exact directory hierarchy of ``source_dir`` under + ``output_dir/subfolder`` (or directly under ``output_dir`` when + ``subfolder`` is None) and hardlinks every file. Curator metadata files + (``.!tag`` / ``.!ftag`` / ``.!gtag`` / ``.!index``) are skipped. + + Used for Seriály: the pool structure is the source of truth and is cloned + verbatim instead of being rebuilt from tags. + + Returns: + Tuple of (successful_links, failed_links) + """ + source_dir = Path(source_dir) + if not source_dir.is_dir(): + return 0, 0 + + base = self.output_dir / subfolder if subfolder else self.output_dir + success_count = 0 + fail_count = 0 + + for src_file in source_dir.rglob("*"): + if not src_file.is_file(): + continue + if src_file.name.endswith((".!tag", ".!ftag", ".!gtag", ".!index")): + continue + + target_file = base / src_file.relative_to(source_dir) + try: + if not dry_run: + target_file.parent.mkdir(parents=True, exist_ok=True) + if target_file.exists(): + if self._is_same_file(src_file, target_file): + success_count += 1 + continue + target_file.unlink() + os.link(src_file, target_file) + self.created_links.append(target_file) + success_count += 1 + except OSError as e: + self.errors.append((src_file, 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 diff --git a/src/core/list_manager.py b/src/core/list_manager.py new file mode 100644 index 0000000..341de49 --- /dev/null +++ b/src/core/list_manager.py @@ -0,0 +1,20 @@ +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) \ No newline at end of file diff --git a/src/core/media_utils.py b/src/core/media_utils.py new file mode 100644 index 0000000..0a933a2 --- /dev/null +++ b/src/core/media_utils.py @@ -0,0 +1,43 @@ +# Module header +import sys +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 +import subprocess +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): + """ + Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p. + Vyžaduje 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], + capture_output=True, + text=True, + check=True + ) + res = result.stdout.strip() # např. "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}") + except Exception as e: + print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}") \ No newline at end of file diff --git a/src/core/pool_index.py b/src/core/pool_index.py new file mode 100644 index 0000000..7c0ad2f --- /dev/null +++ b/src/core/pool_index.py @@ -0,0 +1,64 @@ +""" +Unified metadata index for the Curator pool. + +Instead of one sidecar file per movie, the whole pool keeps a single JSON index +at ``/.Curator.!index``. Curator owns the pool (it inserts/removes files +itself), so files never move behind its back and a central index is safe. + +Records are keyed by the file's path relative to the pool root (POSIX form), +e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines. +""" +import json +from pathlib import Path + +INDEX_FILENAME = ".Curator.!index" + + +class PoolIndex: + """Single-file JSON metadata store for all files in a pool.""" + + def __init__(self, pool_dir: Path) -> None: + self.pool_dir = Path(pool_dir) + self.index_path = self.pool_dir / INDEX_FILENAME + self.records: dict[str, dict] = {} + self.load() + + def _key(self, file_path: Path) -> str: + """Pool-relative POSIX key for a file path.""" + path = Path(file_path) + try: + return path.relative_to(self.pool_dir).as_posix() + except ValueError: + return path.as_posix() + + def load(self) -> None: + """Load the index from disk (missing/corrupt index = empty).""" + if not self.index_path.exists(): + self.records = {} + return + try: + with open(self.index_path, "r", encoding="utf-8") as f: + data = json.load(f) + self.records = data.get("movies", {}) + except (json.JSONDecodeError, OSError): + self.records = {} + + def save(self) -> None: + """Persist the index to disk.""" + self.pool_dir.mkdir(parents=True, exist_ok=True) + with open(self.index_path, "w", encoding="utf-8") as f: + json.dump({"movies": self.records}, f, indent=2, ensure_ascii=False) + + def get(self, file_path: Path) -> dict | None: + """Return the record for a file, or None if it is not indexed.""" + return self.records.get(self._key(file_path)) + + def set(self, file_path: Path, data: dict) -> None: + """Upsert a record and persist the index.""" + self.records[self._key(file_path)] = data + self.save() + + def delete(self, file_path: Path) -> None: + """Remove a record (if present) and persist the index.""" + if self.records.pop(self._key(file_path), None) is not None: + self.save() diff --git a/src/core/tag.py b/src/core/tag.py new file mode 100644 index 0000000..3858c22 --- /dev/null +++ b/src/core/tag.py @@ -0,0 +1,22 @@ +class Tag: + def __init__(self, category: str, name: str): + self.category = category + self.name = name + + @property + def full_path(self): + return f"{self.category}/{self.name}" + + def __str__(self): + return self.full_path + + def __repr__(self): + return f"Tag({self.full_path})" + + def __eq__(self, other): + if isinstance(other, Tag): + return self.category == other.category and self.name == other.name + return False + + def __hash__(self): + return hash((self.category, self.name)) \ No newline at end of file diff --git a/src/core/tag_manager.py b/src/core/tag_manager.py new file mode 100644 index 0000000..25a81ed --- /dev/null +++ b/src/core/tag_manager.py @@ -0,0 +1,67 @@ +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: + self.tags_by_category[category] = set() + + def remove_category(self, category: str): + if category in self.tags_by_category: + del self.tags_by_category[category] + + def add_tag(self, category: str, name: str) -> Tag: + self.add_category(category) + tag = Tag(category, name) + self.tags_by_category[category].add(tag) + return tag + + def remove_tag(self, category: str, name: str): + if category in self.tags_by_category: + tag = Tag(category, name) + self.tags_by_category[category].discard(tag) + if not self.tags_by_category[category]: + self.remove_category(category) + + def get_all_tags(self): + """Vrací list všech tagů full_path""" + return [tag.full_path for tags in self.tags_by_category.values() for tag in tags] + + def get_categories(self): + return list(self.tags_by_category.keys()) + + 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 \ No newline at end of file diff --git a/src/core/utils.py b/src/core/utils.py new file mode 100644 index 0000000..98edf75 --- /dev/null +++ b/src/core/utils.py @@ -0,0 +1,7 @@ +from pathlib import Path + +def list_files(folder_path: str | Path) -> list[Path]: + folder = Path(folder_path) + if not folder.is_dir(): + raise NotADirectoryError(f"{folder} není platná složka.") + return [file_path for file_path in folder.rglob("*") if file_path.is_file()] \ No newline at end of file diff --git a/src/resources/images/32/32_calendar.png b/src/resources/images/32/32_calendar.png new file mode 100644 index 0000000000000000000000000000000000000000..71da1336054a4e9623e47c0a620e41d283e47ccc GIT binary patch literal 596 zcmV-a0;~OrP)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007 zbV*G`2k8k22QVT!Qp@H500EduL_t(o!|j(bNL67R#eeVRRuDu|Q#m9kPFmOyG=?RI zaC367w@HH_2I3|TNkc&h?~Ou3^T8zt!9glT1P4JdX-W#B#X*GkT)qc?KDuN1qWfRY z|DJo`ethSg;X*m(T<}!lgQPQ{`lml?`%-)F`pto=fAa>a7i~d#fII`Q@?idVHEaZ1 zj%+VVdI(GeS>5)QqyoB{fl$L*kp+`LyQ80hMW7FO7Bp+X z7Vs3fm$*Y<4!D_VcYt+Z1b7tqTG@h*4dLCuo!H)&)D7GYvM;uOO6mtLC+^hto}?=& zGwu0m`$r1r)eyq%q7Z({LUX iw!-^n`43P|N6tC%>vmV3#Y*7-0000z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007 zbV*G`2k8k22Q4eS;vZfB00O{CL_t(o!|j(%NL67Fh9AAAEs7{e3WA705y_ocA*F$m z%3O+|MQOB&qG+uxS{MZ7DqIyRC2A3o5pNJk3H>ZGgup~8OU;tR7D0;$q)wX=E|>dr zu2Mprxs99%%Y z-*2O&7pszl9`<2iy@$J2(%h<4K{Oi834&nA@AC^#zEa=`lQ0N^6Am5#$CnCjW@~|K zz;LRv%uOI`c79vZ^Cf~801trEK!=%~59NTF?Ey;svn>noe&Aq?xZTVu!bG59vCbup zECD`9KPg5}*^fE~zI*z6Q7pSe(f;P$lVGmn*#9pF%$$&7=~gsp-t&gYZpcg5)B0ylk5Gyq2ufbW;|X(_WHNFwj>(=Yaq z2+*BC@3WtLI$hqxU%^-Bg^83UbpxkTIEXi|DoIme5;xv~kIqYy8A$5#`c6c%K((aF zu$2?8@yD9B(`GSSD_MazmL^Hh!U9L5(MS*k zEkLU`tTCWc()VnZ(mr2GM^e~CfWyE=pv=Xu0h2VJ@w*_&T0ZJ)`B=z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007 zbV*G`2k8k22QN15(h&s!00FK^L_t(o!|m3)YZOrw2JqiXf)5s-h>98vDn249L~Md$ z5fsF`rC2F~A_gq9OKT@~+DV!P|AS*d>|$@H7K()gA4HH~Wf2{l8(4$8%*3&BJ{XvD z?%aF6`Oag81`Qhg?`RELOetMN7sIPO|0k|TM7J`5PN&lA?7~zf1yV|Fyv5F%QoV@%5%IOG0@F1Hht0TKMS+`GUsLdeGx&k=hhVOWnePgBO045{i1P)(V^db(BMxFQBDVBP z!4y8{4ybx<#rFIOKAL^Ia~jrSG%Iwg`3q{$pg}Kw0hzyZ^D!>pP5=M^07*qoM6N<$ Ef?z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007 zbV*G`2k8k22QDj@&rF>F00RX{L_t(o!|j({h)q!x$A5ROFbq;A21Op6BAysi=E3*Z zwPr$IG!suglE{OR8Xpsd2ML8&#EY2bPmJ*qDMKhR%{UJp7$YTx42{h3vTvPE=g!=F zXUq%hWwqDZXaE0a@3q!m8~$}k^IxGGH<=7WfUk1zrP9lFs%k0W(Vg8-N1j2;H_n1x5g`fF+VX3`iDKR8%B_AZT{y*$$NcEpUK>s38b~>QKN{ z#et_%sfLR_-_3^SVD-r*7&+|&$5t*cWA>R$p^Tq%(fYp+2MRstc=XsYs z&ztP~{==TKAYFpjnc2LUG~C?P)m0?vZj5hRGMOAM>1NEjhh3%J(PJ7W5{ZjIvm2f_ zvy$kX#>U2sXAZT3}0vNM-^J4GjTM4SWSA0!?N%KHvM^%$5V^-0*G?1jn;& z$ribwOt)m!(d)Sv;H2xK>s+k00_BoE_oXSl=$g_Oy#TLr7gsuyo&qZ+ea!h?kfAMa z0dBj$`W|Y_XS*6w94hH2ff`9a`}#0h5A4qJVG;lhK%J!L{rxnX3#z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007 zbV*G`2k8k22P+s=XxYvH00Ih0L_t(o!|j(lXcSQthQB-tSwXP#0kKdrh=oPK#z$lQ zgP6iXBxoU67*P~8M6}Q-2x_Hl`TCr$o>G(U59)S z_y}BVfgE6S%-$BW-&WlLNn3!Mz^l8aQM7QMJyVcq0tM`5*|&z}_5) z=YaFT0ozllUPO{giM*W_4}lZ5%Z&!ss)obBn+(Jy;F#@F%a2k?w}FFAu2Tk1+8$4< zxLzh40cIP_{~i^vjakV53Bp7`&IUoy1?;Xdn+a07*qoM6N<$g8b(pnE(I) literal 0 HcmV?d00001 diff --git a/src/resources/images/32/32_unchecked.png b/src/resources/images/32/32_unchecked.png new file mode 100644 index 0000000000000000000000000000000000000000..c503b47e071f075cedbd2668877c75353f13716a GIT binary patch literal 716 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 ahPXG)0sG=VX@7|fa?;EapMT8l#{UCpLoUex literal 0 HcmV?d00001 diff --git a/src/resources/images/orig/orig_crossed.png b/src/resources/images/orig/orig_crossed.png new file mode 100644 index 0000000000000000000000000000000000000000..8cbd67bbb93af58ce0009225a9a4f5a98d77c5fc GIT binary patch literal 16363 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 list of checkbuttons + + self.geometry("500x600") + self.minsize(400, 400) + self.configure(bg=COLORS["bg"]) + + tk.Label(self, text=f"Vybráno souborů: {len(files)}", + bg=COLORS["bg"], font=("Arial", 11, "bold")).pack(pady=10) + + # Scrollable frame + canvas = tk.Canvas(self, bg=COLORS["bg"]) + scrollbar = ttk.Scrollbar(self, orient="vertical", command=canvas.yview) + frame = tk.Frame(canvas, bg=COLORS["bg"]) + + frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True, padx=10) + scrollbar.pack(side="right", fill="y") + + # Enable mousewheel scrolling (only when dialog is active) + def on_mousewheel(event): + if canvas.winfo_exists(): + canvas.yview_scroll(int(-1*(event.delta/120)), "units") + + def on_scroll_up(event): + if canvas.winfo_exists(): + canvas.yview_scroll(-1, "units") + + def on_scroll_down(event): + if canvas.winfo_exists(): + canvas.yview_scroll(1, "units") + + canvas.bind("", on_mousewheel) + canvas.bind("", on_scroll_up) + canvas.bind("", on_scroll_down) + frame.bind("", on_mousewheel) + frame.bind("", on_scroll_up) + frame.bind("", on_scroll_down) + + file_tag_sets = [{t.full_path for t in f.tags} for f in files] + + # Group by category + tags_by_category = {} + for full_path, tag in self.tags_by_full.items(): + if tag.category not in tags_by_category: + tags_by_category[tag.category] = [] + tags_by_category[tag.category].append((full_path, tag)) + + # Sort tags within each category + 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 = tk.Label(frame, text=f"▸ {category}{exclusive_note}", bg=COLORS["bg"], + fg=color, font=("Arial", 10, "bold")) + cat_label.pack(fill="x", anchor="w", pady=(12, 4)) + + self.category_checkbuttons[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 = 0 + elif have_count == len(files): + init = 1 + else: + init = 2 # mixed + + cb = tk.Checkbutton(frame, text=f" {tag.name}", anchor="w", bg=COLORS["bg"], + font=("Arial", 10)) + cb.state_value = init + cb.full_path = full_path + cb.tag_color = color + cb.category = category + cb.pack(fill="x", anchor="w", padx=20) + cb.bind("", self._on_toggle) + + self._update_checkbox_look(cb) + self.checkbuttons[full_path] = cb + self.vars[full_path] = init + self.category_checkbuttons[category].append(cb) + + btn_frame = tk.Frame(self, bg=COLORS["bg"]) + btn_frame.pack(pady=15) + tk.Button(btn_frame, text="OK", command=self.on_ok, width=12, + font=("Arial", 10)).pack(side="left", padx=5) + tk.Button(btn_frame, text="Zrušit", command=self.destroy, width=12, + font=("Arial", 10)).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 + category = cb.category + cur = cb.state_value + + # For exclusive categories, uncheck others first + if category in EXCLUSIVE_CATEGORIES: + if cur == 0 or cur == 2: # turning on + # Uncheck all others in this category + for other_cb in self.category_checkbuttons.get(category, []): + if other_cb != cb and other_cb.state_value != 0: + other_cb.state_value = 0 + self._update_checkbox_look(other_cb) + cb.state_value = 1 + else: # turning off + cb.state_value = 0 + else: + # Normal toggle behavior + if cur == 0: + cb.state_value = 1 + elif cur == 1: + cb.state_value = 0 + elif cur == 2: + cb.state_value = 1 + + self._update_checkbox_look(cb) + return "break" + + def _update_checkbox_look(self, cb: tk.Checkbutton): + v = cb.state_value + color = getattr(cb, 'tag_color', '#333333') + if v == 0: + cb.deselect() + cb.config(fg="#666666") + elif v == 1: + cb.select() + cb.config(fg=color) + elif v == 2: + cb.deselect() + cb.config(fg="#cc6600") # orange for mixed + + 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.filehandler = filehandler + self.tagmanager = tagmanager + self.list_manager = ListManager() + + # State + self.states = {} + self.file_items = {} # Treeview item_id -> File object mapping + self.selected_tree_item_for_context = None + self.hide_ignored_var = None + self.filter_text = "" + self.show_full_path = False + self.sort_mode = "name" + self.sort_order = "asc" + self.category_colors = {} # category -> color mapping + + self.filehandler.on_files_changed = self.update_files_from_manager + + def _on_close(self): + """Save window geometry and close""" + # Check if maximized + is_maximized = self.root.state() == 'zoomed' + self.filehandler.global_config["window_maximized"] = is_maximized + + # Save geometry only when not maximized + if not is_maximized: + self.filehandler.global_config["window_geometry"] = self.root.geometry() + + save_global_config(self.filehandler.global_config) + self.root.destroy() + + def main(self): + root = tk.Tk() + root.title(f"{APP_NAME} {VERSION}") + + # Load window geometry from global config + geometry = self.filehandler.global_config.get("window_geometry", APP_VIEWPORT) + root.geometry(geometry) + if self.filehandler.global_config.get("window_maximized", False): + root.state('zoomed') + + root.configure(bg=COLORS["bg"]) + self.root = root + + # Bind window close to save geometry + root.protocol("WM_DELETE_WINDOW", self._on_close) + + self.hide_ignored_var = tk.BooleanVar(value=False, master=root) + + # Load last folder + last = self.filehandler.global_config.get("last_folder") + if last: + try: + self.filehandler.append(Path(last)) + except Exception: + pass + + # Load icons + self._load_icons() + + # Build UI + self._create_menu() + self._create_toolbar() + self._create_main_layout() + self._create_status_bar() + self._create_context_menus() + self._bind_shortcuts() + + # Initial refresh + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + + root.mainloop() + + def _load_icons(self): + """Load application icons""" + try: + unchecked = load_icon("src/resources/images/32/32_unchecked.png") + checked = load_icon("src/resources/images/32/32_checked.png") + tag_icon = load_icon("src/resources/images/32/32_tag.png") + self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon} + self.root.unchecked_img = unchecked + self.root.checked_img = checked + self.root.tag_img = tag_icon + except Exception as e: + print(f"Warning: Could not load icons: {e}") + self.icons = {"unchecked": None, "checked": None, "tag": None} + + def _create_menu(self): + """Create menu bar""" + menu_bar = tk.Menu(self.root) + self.root.config(menu=menu_bar) + + # File menu + file_menu = tk.Menu(menu_bar, tearoff=0) + file_menu.add_command(label="Open Folder... (Ctrl+O)", command=self.open_folder_dialog) + file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns) + file_menu.add_separator() + file_menu.add_command(label="Exit (Ctrl+Q)", command=self.root.quit) + + # View menu + view_menu = tk.Menu(menu_bar, tearoff=0) + view_menu.add_checkbutton( + label="Skrýt ignorované", + variable=self.hide_ignored_var, + command=self.toggle_hide_ignored + ) + view_menu.add_command(label="Refresh (F5)", command=self.refresh_all) + + # Tools menu + tools_menu = tk.Menu(menu_bar, tearoff=0) + tools_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) + tools_menu.add_command(label="Detekovat rozlišení videí", command=self.detect_video_resolution) + tools_menu.add_command(label="Přiřadit tagy (Ctrl+T)", command=self.assign_tag_to_selected_bulk) + tools_menu.add_separator() + tools_menu.add_command(label="Nastavit hardlink složku...", command=self.configure_hardlink_folder) + tools_menu.add_command(label="Aktualizovat hardlink strukturu", command=self.update_hardlink_structure) + tools_menu.add_command(label="Vytvořit hardlink strukturu...", command=self.create_hardlink_structure) + + menu_bar.add_cascade(label="Soubor", menu=file_menu) + menu_bar.add_cascade(label="Pohled", menu=view_menu) + menu_bar.add_cascade(label="Nástroje", menu=tools_menu) + + def _create_toolbar(self): + """Create toolbar with buttons""" + toolbar = tk.Frame(self.root, bg=COLORS["toolbar_bg"], height=40, relief=tk.RAISED, bd=1) + toolbar.pack(side=tk.TOP, fill=tk.X) + + # Buttons + tk.Button(toolbar, text="📁 Otevřít složku", command=self.open_folder_dialog, + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=5, pady=5) + + tk.Button(toolbar, text="🔄 Obnovit", command=self.refresh_all, + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) + + tk.Button(toolbar, text="🏷️ Nový tag", command=lambda: self.tree_add_tag(background=True), + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + + tk.Button(toolbar, text="📅 Nastavit datum", command=self.set_date_for_selected, + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) + + # Search box + search_frame = tk.Frame(toolbar, bg=COLORS["toolbar_bg"]) + search_frame.pack(side=tk.RIGHT, padx=10, pady=5) + + tk.Label(search_frame, text="🔍", bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT) + self.search_var = tk.StringVar() + self.search_var.trace('w', lambda *args: self.on_filter_changed()) + search_entry = tk.Entry(search_frame, textvariable=self.search_var, width=25) + search_entry.pack(side=tk.LEFT, padx=5) + + def _create_main_layout(self): + """Create main split layout""" + # Main container + main_container = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, sashwidth=5, bg=COLORS["border"]) + main_container.pack(fill=tk.BOTH, expand=True) + + # Left sidebar (tags) + self._create_sidebar(main_container) + + # Right panel (files table) + self._create_file_panel(main_container) + + def _create_sidebar(self, parent): + """Create left sidebar with tag tree""" + sidebar_frame = tk.Frame(parent, bg=COLORS["sidebar_bg"], width=250) + + # Sidebar header + header = tk.Frame(sidebar_frame, bg=COLORS["sidebar_bg"]) + header.pack(fill=tk.X, padx=5, pady=5) + + tk.Label(header, text="📂 Štítky", font=("Arial", 10, "bold"), + bg=COLORS["sidebar_bg"]).pack(side=tk.LEFT) + + # Tag tree + tree_frame = tk.Frame(sidebar_frame) + tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + self.tag_tree = ttk.Treeview(tree_frame, selectmode="browse", show="tree") + self.tag_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + tree_scroll = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tag_tree.yview) + tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) + self.tag_tree.config(yscrollcommand=tree_scroll.set) + + # Bind events + self.tag_tree.bind("", self.on_tree_left_click) + self.tag_tree.bind("", self.on_tree_right_click) + + parent.add(sidebar_frame) + + def _create_file_panel(self, parent): + """Create right panel with file table""" + file_frame = tk.Frame(parent, bg=COLORS["bg"]) + + # Control panel + control_frame = tk.Frame(file_frame, bg=COLORS["bg"]) + control_frame.pack(fill=tk.X, padx=5, pady=5) + + # View options + tk.Checkbutton(control_frame, text="Plná cesta", variable=tk.BooleanVar(), + command=self.toggle_show_path, bg=COLORS["bg"]).pack(side=tk.LEFT, padx=5) + + + # File table + table_frame = tk.Frame(file_frame) + table_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Define columns + columns = ("name", "date", "tags", "size") + self.file_table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended") + + # Column headers with sort commands + self.file_table.heading("name", text="📄 Název ▲", command=lambda: self.sort_by_column("name")) + self.file_table.heading("date", text="📅 Datum", command=lambda: self.sort_by_column("date")) + self.file_table.heading("tags", text="🏷️ Štítky") + self.file_table.heading("size", text="💾 Velikost", command=lambda: self.sort_by_column("size")) + + # Column widths + self.file_table.column("name", width=300) + self.file_table.column("date", width=100) + self.file_table.column("tags", width=200) + self.file_table.column("size", width=80) + + # Scrollbars + vsb = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.file_table.yview) + hsb = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.file_table.xview) + self.file_table.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + self.file_table.grid(row=0, column=0, sticky="nsew") + vsb.grid(row=0, column=1, sticky="ns") + hsb.grid(row=1, column=0, sticky="ew") + + table_frame.grid_rowconfigure(0, weight=1) + table_frame.grid_columnconfigure(0, weight=1) + + # Bind events + self.file_table.bind("", self.on_file_double_click) + self.file_table.bind("", self.on_file_right_click) + self.file_table.bind("<>", self.on_selection_changed) + + parent.add(file_frame) + + def _create_status_bar(self): + """Create status bar at bottom""" + status_frame = tk.Frame(self.root, bg=COLORS["status_bg"], relief=tk.SUNKEN, bd=1) + status_frame.pack(side=tk.BOTTOM, fill=tk.X) + + # Left side - status message + self.status_label = tk.Label(status_frame, text="Připraven", anchor=tk.W, + bg=COLORS["status_bg"], padx=10) + self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Right side - file count + self.file_count_label = tk.Label(status_frame, text="0 souborů", anchor=tk.E, + bg=COLORS["status_bg"], padx=10) + self.file_count_label.pack(side=tk.RIGHT) + + # Selected size + self.selected_size_label = tk.Label(status_frame, text="", anchor=tk.E, + bg=COLORS["status_bg"], padx=10) + self.selected_size_label.pack(side=tk.RIGHT) + + # Selected count + self.selected_count_label = tk.Label(status_frame, text="", anchor=tk.E, + bg=COLORS["status_bg"], padx=10) + self.selected_count_label.pack(side=tk.RIGHT) + + def _create_context_menus(self): + """Create context menus""" + # Tag context menu + self.tag_menu = tk.Menu(self.root, tearoff=0) + self.tag_menu.add_command(label="Nový štítek", command=self.tree_add_tag) + self.tag_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag) + + # File context menu + self.file_menu = tk.Menu(self.root, tearoff=0) + self.file_menu.add_command(label="Otevřít soubor", command=self.open_selected_files) + self.file_menu.add_command(label="Přiřadit štítky (Ctrl+T)", command=self.assign_tag_to_selected_bulk) + self.file_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) + self.file_menu.add_separator() + self.file_menu.add_command(label="Smazat z indexu (Del)", command=self.remove_selected_files) + + def _bind_shortcuts(self): + """Bind keyboard shortcuts""" + self.root.bind("", lambda e: self.open_folder_dialog()) + self.root.bind("", lambda e: self.root.quit()) + self.root.bind("", lambda e: self.assign_tag_to_selected_bulk()) + self.root.bind("", lambda e: self.set_date_for_selected()) + self.root.bind("", lambda e: self.search_var.get()) # Focus search + self.root.bind("", lambda e: self.refresh_all()) + self.root.bind("", lambda e: self.remove_selected_files()) + + # ================================================== + # SIDEBAR / TAG TREE METHODS + # ================================================== + + def refresh_sidebar(self): + """Refresh tag tree in sidebar""" + # Clear tree + for item in self.tag_tree.get_children(): + self.tag_tree.delete(item) + + # Reset tag item mapping + self.tag_tree_items = {} # full_path -> tree item_id + + # Count files per tag (from all files) + tag_counts = {} + for f in self.filehandler.filelist: + for t in f.tags: + tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 + + # Add root + total_files = len(self.filehandler.filelist) + root_id = self.tag_tree.insert("", "end", text=f"📂 Všechny soubory ({total_files})", image=self.icons.get("tag")) + self.tag_tree.item(root_id, open=True) + self.root_tag_id = root_id + + # Assign colors to categories + categories = self.tagmanager.get_categories() + color_index = 0 + for category in categories: + if category not in self.category_colors: + # Use predefined color for default categories, otherwise cycle through TAG_COLORS + if category in DEFAULT_CATEGORY_COLORS: + self.category_colors[category] = DEFAULT_CATEGORY_COLORS[category] + else: + self.category_colors[category] = TAG_COLORS[color_index % len(TAG_COLORS)] + color_index += 1 + + # Add categories and tags + for category in categories: + color = self.category_colors.get(category, "#333333") + cat_id = self.tag_tree.insert(root_id, "end", text=f"📁 {category}", image=self.icons.get("tag"), + tags=(f"cat_{category}",)) + self.states[cat_id] = False + + for tag in self.tagmanager.get_tags_in_category(category): + count = tag_counts.get(tag.full_path, 0) + count_str = f" ({count})" if count > 0 else "" + tag_id = self.tag_tree.insert(cat_id, "end", text=f" {tag.name}{count_str}", + image=self.icons.get("unchecked"), + tags=(f"tag_{category}",)) + self.states[tag_id] = False + self.tag_tree_items[tag.full_path] = (tag_id, tag.name) + + # Apply color to category tags + self.tag_tree.tag_configure(f"cat_{category}", foreground=color) + self.tag_tree.tag_configure(f"tag_{category}", foreground=color) + + def update_tag_counts(self, filtered_files): + """Update tag counts in sidebar based on filtered files""" + if not hasattr(self, 'tag_tree_items'): + return + + # Count files per tag from filtered files + tag_counts = {} + for f in filtered_files: + for t in f.tags: + tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 + + # Update each tag item text + for full_path, (item_id, tag_name) in self.tag_tree_items.items(): + count = tag_counts.get(full_path, 0) + count_str = f" ({count})" if count > 0 else "" + # Preserve the checkbox state + current_text = f" {tag_name}{count_str}" + self.tag_tree.item(item_id, text=current_text) + + # Update root count + total = len(filtered_files) + self.tag_tree.item(self.root_tag_id, text=f"📂 Všechny soubory ({total})") + + def on_tree_left_click(self, event): + """Handle left click on tag tree""" + region = self.tag_tree.identify("region", event.x, event.y) + if region not in ("tree", "icon"): + return + + item_id = self.tag_tree.identify_row(event.y) + if not item_id: + return + + parent_id = self.tag_tree.parent(item_id) + + # Toggle folder open/close + if parent_id == "" or parent_id == self.root_tag_id: + is_open = self.tag_tree.item(item_id, "open") + self.tag_tree.item(item_id, open=not is_open) + return + + # Toggle tag checkbox + self.states[item_id] = not self.states.get(item_id, False) + self.tag_tree.item(item_id, image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]) + + # Update file list + self.update_files_from_manager(self.filehandler.filelist) + + def on_tree_right_click(self, event): + """Handle right click on tag tree""" + item_id = self.tag_tree.identify_row(event.y) + if item_id: + self.selected_tree_item_for_context = item_id + self.tag_tree.selection_set(item_id) + self.tag_menu.tk_popup(event.x_root, event.y_root) + + def tree_add_tag(self, background=False): + """Add new tag""" + name = simpledialog.askstring("Nový tag", "Název tagu:") + if not name: + return + + parent = self.selected_tree_item_for_context if not background else self.root_tag_id + new_id = self.tag_tree.insert(parent, "end", text=f" {name}", image=self.icons["unchecked"]) + self.states[new_id] = False + + if parent == self.root_tag_id: + self.tagmanager.add_category(name) + self.tag_tree.item(new_id, image=self.icons["tag"]) + else: + category = self.tag_tree.item(parent, "text").replace("📁 ", "") + self.tagmanager.add_tag(category, name) + + self.status_label.config(text=f"Vytvořen tag: {name}") + + def tree_delete_tag(self): + """Delete selected tag""" + item = self.selected_tree_item_for_context + if not item: + return + + name = self.tag_tree.item(item, "text").strip() + ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{name}'?") + if not ans: + return + + parent_id = self.tag_tree.parent(item) + self.tag_tree.delete(item) + self.states.pop(item, None) + + if parent_id == self.root_tag_id: + self.tagmanager.remove_category(name.replace("📁 ", "")) + else: + category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") + self.tagmanager.remove_tag(category, name) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Smazán tag: {name}") + + def get_checked_tags(self) -> List[Tag]: + """Get list of checked tags""" + tags = [] + for item_id, checked in self.states.items(): + if not checked: + continue + parent_id = self.tag_tree.parent(item_id) + if parent_id == "" or parent_id == self.root_tag_id: + continue + category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") + # Get tag name from stored mapping (not from text which includes count) + tag_name = None + for full_path, (stored_id, stored_name) in self.tag_tree_items.items(): + if stored_id == item_id: + tag_name = stored_name + break + if tag_name: + tags.append(Tag(category, tag_name)) + return tags + + # ================================================== + # FILE TABLE METHODS + # ================================================== + + def update_files_from_manager(self, filelist=None): + """Update file table""" + if filelist is None: + filelist = self.filehandler.filelist + + # Filter by checked tags + checked_tags = self.get_checked_tags() + filtered_files = self.filehandler.filter_files_by_tags(checked_tags) + + # Filter by search text + search_text = self.search_var.get().lower() if hasattr(self, 'search_var') else "" + if search_text: + filtered_files = [ + f for f in filtered_files + if search_text in f.filename.lower() or + (self.show_full_path and search_text in str(f.file_path).lower()) + ] + + # Filter ignored + if self.hide_ignored_var and self.hide_ignored_var.get(): + filtered_files = [ + f for f in filtered_files + if "Stav/Ignorované" not in {t.full_path for t in f.tags} + ] + + # Sort + reverse = (self.sort_order == "desc") + if self.sort_mode == "name": + filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse) + elif self.sort_mode == "date": + filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse) + elif self.sort_mode == "size": + filtered_files.sort(key=lambda f: f.file_path.stat().st_size if f.file_path.exists() else 0, reverse=reverse) + + # Clear table + for item in self.file_table.get_children(): + self.file_table.delete(item) + self.file_items.clear() + + # Populate table + for f in filtered_files: + name = str(f.file_path) if self.show_full_path else f.filename + date = f.date or "" + tags = ", ".join([t.name for t in f.tags[:3]]) # Show first 3 tags + if len(f.tags) > 3: + tags += f" +{len(f.tags) - 3}" + + try: + size = f.file_path.stat().st_size + size_str = self._format_size(size) + except: + size_str = "?" + + item_id = self.file_table.insert("", "end", values=(name, date, tags, size_str)) + self.file_items[item_id] = f + + # Update status + self.file_count_label.config(text=f"{len(filtered_files)} souborů") + self.status_label.config(text=f"Zobrazeno {len(filtered_files)} souborů") + + # Update tag counts in sidebar + self.update_tag_counts(filtered_files) + + def _format_size(self, size_bytes): + """Format file size""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + def get_selected_files(self) -> List[File]: + """Get selected files from table""" + selected_items = self.file_table.selection() + return [self.file_items[item] for item in selected_items if item in self.file_items] + + def on_selection_changed(self, event=None): + """Update status bar when selection changes""" + files = self.get_selected_files() + count = len(files) + + if count == 0: + self.selected_count_label.config(text="") + self.selected_size_label.config(text="") + else: + self.selected_count_label.config(text=f"{count} vybráno") + total_size = 0 + for f in files: + try: + total_size += f.file_path.stat().st_size + except: + pass + self.selected_size_label.config(text=f"[{self._format_size(total_size)}]") + + def on_file_double_click(self, event): + """Handle double click on file""" + files = self.get_selected_files() + for f in files: + self.open_file(f.file_path) + + def on_file_right_click(self, event): + """Handle right click on file""" + # Select item under cursor if not selected + item = self.file_table.identify_row(event.y) + if item and item not in self.file_table.selection(): + self.file_table.selection_set(item) + + # Update selected count + count = len(self.file_table.selection()) + self.selected_count_label.config(text=f"{count} vybráno" if count > 0 else "") + + self.file_menu.tk_popup(event.x_root, event.y_root) + + def open_file(self, path): + """Open file with default application""" + try: + if sys.platform.startswith("win"): + os.startfile(path) + elif sys.platform.startswith("darwin"): + subprocess.call(["open", path]) + else: + subprocess.call(["xdg-open", path]) + self.status_label.config(text=f"Otevírám: {path.name}") + except Exception as e: + messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}") + + # ================================================== + # ACTIONS + # ================================================== + + def open_folder_dialog(self): + """Open folder selection dialog""" + folder = filedialog.askdirectory(title="Vyber složku pro sledování") + if not folder: + return + + folder_path = Path(folder) + try: + self.filehandler.append(folder_path) + for f in self.filehandler.filelist: + if f.tags and f.tagmanager: + for t in f.tags: + f.tagmanager.add_tag(t.category, t.name) + + self.status_label.config(text=f"Přidána složka: {folder_path}") + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + except Exception as e: + messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}") + + def open_selected_files(self): + """Open selected files""" + files = self.get_selected_files() + for f in files: + self.open_file(f.file_path) + + def remove_selected_files(self): + """Remove selected files from index""" + files = self.get_selected_files() + if not files: + return + + ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?") + if ans: + for f in files: + if f in self.filehandler.filelist: + self.filehandler.filelist.remove(f) + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Odstraněno {len(files)} souborů z indexu") + + def assign_tag_to_selected_bulk(self): + """Assign tags to selected files (bulk mode)""" + files = self.get_selected_files() + if not files: + self.status_label.config(text="Nebyly vybrány žádné soubory") + return + + all_tags = [] + for category in self.tagmanager.get_categories(): + for tag in self.tagmanager.get_tags_in_category(category): + all_tags.append(tag) + + if not all_tags: + messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") + return + + dialog = MultiFileTagAssignDialog(self.root, all_tags, files, self.category_colors) + result = dialog.result + + if result is None: + self.status_label.config(text="Přiřazení zrušeno") + return + + for full_path, state in result.items(): + if state == 1: + if "/" in full_path: + category, name = full_path.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + self.filehandler.assign_tag_to_file_objects(files, tag_obj) + elif state == 0: + if "/" in full_path: + category, name = full_path.split("/", 1) + tag_obj = Tag(category, name) + self.filehandler.remove_tag_from_file_objects(files, tag_obj) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text="Hromadné přiřazení tagů dokončeno") + + def set_date_for_selected(self): + """Set date for selected files""" + files = self.get_selected_files() + if not files: + self.status_label.config(text="Nebyly vybrány žádné soubory") + return + + prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):" + date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root) + if date_str is None: + return + + for f in files: + f.set_date(date_str if date_str != "" else None) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)") + + def detect_video_resolution(self): + """Detect video resolution using ffprobe""" + files = self.get_selected_files() + if not files: + self.status_label.config(text="Nebyly vybrány žádné soubory") + return + + count = 0 + for f in files: + try: + path = str(f.file_path) + result = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=height", "-of", "csv=p=0", path], + capture_output=True, + text=True, + check=True + ) + height_str = result.stdout.strip() + if not height_str.isdigit(): + continue + height = int(height_str) + tag_name = f"{height}p" + tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name) + f.add_tag(tag_obj) + count += 1 + except Exception as e: + print(f"Chyba u {f.filename}: {e}") + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Přiřazeno rozlišení tagů k {count} souborům") + + def set_ignore_patterns(self): + """Set ignore patterns for current folder""" + current = ", ".join(self.filehandler.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.set_ignore_patterns(patterns) + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text="Ignore patterns aktualizovány") + + def toggle_hide_ignored(self): + """Toggle hiding ignored files""" + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_show_path(self): + """Toggle showing full path""" + self.show_full_path = not self.show_full_path + self.update_files_from_manager(self.filehandler.filelist) + + def sort_by_column(self, column: str): + """Sort by column header click""" + if self.sort_mode == column: + self.sort_order = "desc" if self.sort_order == "asc" else "asc" + else: + self.sort_mode = column + self.sort_order = "asc" + + self._update_sort_indicators() + self.update_files_from_manager(self.filehandler.filelist) + + def _update_sort_indicators(self): + """Update column header sort indicators""" + arrow = "▲" if self.sort_order == "asc" else "▼" + + headers = { + "name": "📄 Název", + "date": "📅 Datum", + "size": "💾 Velikost" + } + + for col, base_text in headers.items(): + if col == self.sort_mode: + self.file_table.heading(col, text=f"{base_text} {arrow}") + else: + self.file_table.heading(col, text=base_text) + + def on_filter_changed(self): + """Handle search/filter change""" + self.update_files_from_manager(self.filehandler.filelist) + + def refresh_all(self): + """Refresh everything""" + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text="Obnoveno") + + def configure_hardlink_folder(self): + """Configure hardlink output folder for current project""" + if not self.filehandler.current_folder: + messagebox.showwarning("Upozornění", "Nejprve otevřete složku") + return + + # Get current settings + folder_config = self.filehandler.get_folder_config() + current_dir = folder_config.get("hardlink_output_dir") + current_categories = folder_config.get("hardlink_categories") + + # Ask for output directory + initial_dir = current_dir if current_dir else str(self.filehandler.current_folder) + output_dir = filedialog.askdirectory( + title="Vyber cílovou složku pro hardlink strukturu", + initialdir=initial_dir, + mustexist=False + ) + if not output_dir: + return + + # Get available categories + categories = self.tagmanager.get_categories() + if not categories: + messagebox.showwarning("Upozornění", "Žádné kategorie tagů") + return + + # Show category selection dialog + selected_categories = self._show_category_selection_dialog( + categories, + preselected=current_categories + ) + if selected_categories is None: + return # Cancelled + + # Save to folder config + folder_config["hardlink_output_dir"] = output_dir + folder_config["hardlink_categories"] = selected_categories if selected_categories else None + self.filehandler.save_folder_config(config=folder_config) + + messagebox.showinfo("Hotovo", f"Hardlink složka nastavena:\n{output_dir}") + self.status_label.config(text=f"Hardlink složka nastavena: {output_dir}") + + def update_hardlink_structure(self): + """Quick update hardlink structure using saved settings""" + if not self.filehandler.current_folder: + messagebox.showwarning("Upozornění", "Nejprve otevřete složku") + return + + # Get saved settings + folder_config = self.filehandler.get_folder_config() + output_dir = folder_config.get("hardlink_output_dir") + saved_categories = folder_config.get("hardlink_categories") + + if not output_dir: + messagebox.showinfo("Info", "Hardlink složka není nastavena.\nPoužijte 'Nastavit hardlink složku...' pro konfiguraci.") + return + + output_path = Path(output_dir) + files = self.filehandler.filelist + + if not files: + messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") + return + + # Create manager and analyze + manager = HardlinkManager(output_path) + + # Find what needs to be created and removed + preview_create = manager.get_preview(files, saved_categories) + obsolete = manager.find_obsolete_links(files, saved_categories) + + # Filter out already existing links from preview + to_create = [] + for source, target in preview_create: + if not target.exists(): + to_create.append((source, target)) + elif not manager._is_same_file(source, target): + to_create.append((source, target)) + + if not to_create and not obsolete: + messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") + return + + # Build confirmation message + confirm_lines = [] + if to_create: + confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") + if obsolete: + confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") + confirm_lines.append(f"\nCílová složka: {output_path}") + confirm_lines.append("\nPokračovat?") + + if not messagebox.askyesno("Potvrdit aktualizaci", "\n".join(confirm_lines)): + return + + # Perform sync + self.status_label.config(text="Aktualizuji hardlink strukturu...") + self.root.update() + + created, create_fail, removed, remove_fail = manager.sync_structure(files, saved_categories) + + # Show result + result_lines = [] + if created > 0: + result_lines.append(f"Vytvořeno: {created} hardlinků") + if removed > 0: + result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") + + if create_fail > 0 or remove_fail > 0: + if create_fail > 0: + result_lines.append(f"Selhalo vytvoření: {create_fail}") + if remove_fail > 0: + result_lines.append(f"Selhalo odebrání: {remove_fail}") + messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) + else: + messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") + + self.status_label.config(text=f"Hardlink struktura aktualizována (vytvořeno: {created}, odebráno: {removed})") + + def create_hardlink_structure(self): + """Create hardlink directory structure based on file tags (manual selection)""" + files = self.filehandler.filelist + if not files: + messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") + return + + # Ask for output directory + output_dir = filedialog.askdirectory( + title="Vyber cílovou složku pro hardlink strukturu", + mustexist=False + ) + if not output_dir: + return + + output_path = Path(output_dir) + + # Get available categories + categories = self.tagmanager.get_categories() + if not categories: + messagebox.showwarning("Upozornění", "Žádné kategorie tagů") + return + + # Show category selection dialog + selected_categories = self._show_category_selection_dialog(categories) + if selected_categories is None: + return # Cancelled + + cat_filter = selected_categories if selected_categories else None + + # Create manager and analyze + manager = HardlinkManager(output_path) + + # Find what needs to be created and removed + preview_create = manager.get_preview(files, cat_filter) + obsolete = manager.find_obsolete_links(files, cat_filter) + + # Filter out already existing links from preview + to_create = [] + for source, target in preview_create: + if not target.exists(): + to_create.append((source, target)) + elif not manager._is_same_file(source, target): + to_create.append((source, target)) + + if not to_create and not obsolete: + messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") + return + + # Build confirmation message + confirm_lines = [] + if to_create: + confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") + if obsolete: + confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") + confirm_lines.append(f"\nCílová složka: {output_path}") + confirm_lines.append("\nPokračovat?") + + if not messagebox.askyesno("Potvrdit synchronizaci", "\n".join(confirm_lines)): + return + + # Perform sync + self.status_label.config(text="Synchronizuji hardlink strukturu...") + self.root.update() + + created, create_fail, removed, remove_fail = manager.sync_structure(files, cat_filter) + + # Show result + result_lines = [] + if created > 0 or create_fail > 0: + result_lines.append(f"Vytvořeno: {created} hardlinků") + if create_fail > 0: + result_lines.append(f"Selhalo vytvoření: {create_fail}") + if removed > 0 or remove_fail > 0: + result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") + if remove_fail > 0: + result_lines.append(f"Selhalo odebrání: {remove_fail}") + + if create_fail > 0 or remove_fail > 0: + if manager.errors: + result_lines.append("\nChyby:") + for path, err in manager.errors[:5]: + result_lines.append(f"- {path.name}: {err}") + if len(manager.errors) > 5: + result_lines.append(f"... a dalších {len(manager.errors) - 5} chyb") + messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) + else: + messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") + + self.status_label.config(text=f"Hardlink struktura synchronizována (vytvořeno: {created}, odebráno: {removed})") + + def _show_category_selection_dialog(self, categories: List[str], preselected: List[str] | None = None) -> List[str] | None: + """Show dialog to select which categories to include in hardlink structure + + Args: + categories: List of available category names + preselected: Optional list of categories to pre-check (None = all checked) + """ + dialog = tk.Toplevel(self.root) + dialog.title("Vybrat kategorie") + dialog.geometry("350x400") + dialog.transient(self.root) + dialog.grab_set() + + result = {"categories": None} + + tk.Label(dialog, text="Vyberte kategorie pro vytvoření struktury:", + font=("Arial", 10, "bold")).pack(pady=10) + + # Scrollable frame for checkboxes + frame = tk.Frame(dialog) + frame.pack(fill=tk.BOTH, expand=True, padx=10) + + canvas = tk.Canvas(frame) + scrollbar = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) + scrollable_frame = tk.Frame(canvas) + + scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # Category checkboxes + category_vars = {} + for category in sorted(categories): + # If preselected is None, check all; otherwise check only those in preselected + initial_value = preselected is None or category in preselected + var = tk.BooleanVar(value=initial_value) + category_vars[category] = var + color = self.category_colors.get(category, "#333333") + cb = tk.Checkbutton(scrollable_frame, text=category, variable=var, + fg=color, font=("Arial", 10), anchor="w") + cb.pack(fill="x", pady=2) + + # Buttons + btn_frame = tk.Frame(dialog) + btn_frame.pack(pady=10) + + def on_ok(): + result["categories"] = [cat for cat, var in category_vars.items() if var.get()] + dialog.destroy() + + def on_cancel(): + result["categories"] = None + dialog.destroy() + + def select_all(): + for var in category_vars.values(): + var.set(True) + + def select_none(): + for var in category_vars.values(): + var.set(False) + + tk.Button(btn_frame, text="Všechny", command=select_all, width=8).pack(side=tk.LEFT, padx=2) + tk.Button(btn_frame, text="Žádné", command=select_none, width=8).pack(side=tk.LEFT, padx=2) + tk.Button(btn_frame, text="OK", command=on_ok, width=10).pack(side=tk.LEFT, padx=10) + tk.Button(btn_frame, text="Zrušit", command=on_cancel, width=10).pack(side=tk.LEFT, padx=2) + + self.root.wait_window(dialog) + return result["categories"] diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py new file mode 100644 index 0000000..8266bf7 --- /dev/null +++ b/src/ui/qt_app.py @@ -0,0 +1,590 @@ +""" +PySide6 GUI for Curator — reframed around the Filmotéka (movie-library) workflow. + +Layout: +- Toolbar / menu: configure the Pool and the Filmotéka output, import a movie, + generate the Filmotéka hardlink tree. +- Left sidebar: tag tree used as an AND-filter over the movie list. +- Center: the movie table (the contents of pool/Filmy). +- Status bar: counts and the current pool/output paths. +""" +import os +import sys +import subprocess +from pathlib import Path +from typing import List + +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, + QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, + QFormLayout, QHeaderView, QMenu, QAbstractItemView, +) + +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.constants import APP_NAME, VERSION +from src.core.hardlink_manager import HardlinkManager + +# Categories that drive the generated Filmotéka tree (see PROJECT.md) +FILMOTEKA_CATEGORIES = ["Rok", "Žánr", "Hodnocení"] + + +class ImportMovieDialog(QDialog): + """Collect the Title and ČSFD link for a movie being imported into the pool.""" + + def __init__(self, parent: QWidget, default_title: str) -> None: + super().__init__(parent) + self.setWindowTitle("Importovat film do poolu") + self.setMinimumWidth(420) + + layout = QVBoxLayout(self) + form = QFormLayout() + self.title_edit = QLineEdit(default_title) + self.csfd_edit = QLineEdit() + self.csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...") + form.addRow("Název:", self.title_edit) + form.addRow("ČSFD odkaz:", self.csfd_edit) + layout.addLayout(form) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @property + def title(self) -> str: + return self.title_edit.text().strip() + + @property + def csfd_link(self) -> str: + return self.csfd_edit.text().strip() + + +class AssignTagsDialog(QDialog): + """Tri-state bulk tag assignment over the selected movies. + + Result maps full_path -> 1 (assign), 0 (remove), 2 (leave mixed/unchanged). + """ + + def __init__(self, parent: QWidget, tagmanager: TagManager, files: List[File]) -> None: + super().__init__(parent) + self.setWindowTitle("Přiřadit štítky") + self.setMinimumSize(380, 480) + self.result_map: dict[str, int] = {} + + file_tag_sets = [{t.full_path for t in f.tags} for f in files] + + layout = QVBoxLayout(self) + layout.addWidget(QLabel(f"Vybráno filmů: {len(files)}")) + + self.tree = QTreeWidget() + self.tree.setHeaderHidden(True) + layout.addWidget(self.tree) + + self._items: list[tuple[str, QTreeWidgetItem]] = [] + for category in self.tagmanager_categories(tagmanager): + cat_item = QTreeWidgetItem([category]) + cat_item.setFlags(Qt.ItemIsEnabled) + self.tree.addTopLevelItem(cat_item) + cat_item.setExpanded(True) + for tag in tagmanager.get_tags_in_category(category): + have = sum(1 for s in file_tag_sets if tag.full_path in s) + if have == 0: + state = Qt.Unchecked + elif have == len(files): + state = Qt.Checked + else: + state = Qt.PartiallyChecked + item = QTreeWidgetItem([tag.name]) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsTristate) + item.setCheckState(0, state) + cat_item.addChild(item) + self._items.append((tag.full_path, item)) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @staticmethod + def tagmanager_categories(tagmanager: TagManager) -> List[str]: + return sorted(tagmanager.get_categories()) + + def accept(self) -> None: + mapping = {Qt.Checked: 1, Qt.Unchecked: 0, Qt.PartiallyChecked: 2} + self.result_map = {fp: mapping[item.checkState(0)] for fp, item in self._items} + super().accept() + + +class QtApp(QMainWindow): + def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None: + super().__init__() + self.filehandler = filehandler + self.tagmanager = tagmanager + self.file_rows: dict[int, File] = {} # table row -> File + self.filehandler.on_files_changed = lambda _=None: self.refresh_table() + + self.setWindowTitle(f"{APP_NAME} {VERSION} — Filmotéka") + geometry = self.filehandler.global_config.get("window_geometry", "1200x800") + try: + w, h = (int(x) for x in geometry.lower().split("x")) + self.resize(w, h) + except Exception: + self.resize(1200, 800) + + self._build_menu() + self._build_central() + self._build_statusbar() + + # Load the pool if one is configured + if self.filehandler.movies_dir: + self.filehandler.load_pool_movies() + self.refresh_sidebar() + self.refresh_table() + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _build_menu(self) -> None: + bar = self.menuBar() + + pool_menu = bar.addMenu("&Pool") + self._add_action(pool_menu, "Nastavit pool…", self.set_pool, "Ctrl+P") + self._add_action(pool_menu, "Nastavit Filmotéku (výstup)…", self.set_filmoteka) + pool_menu.addSeparator() + self._add_action(pool_menu, "Znovu načíst pool", self.reload_pool, "F5") + pool_menu.addSeparator() + self._add_action(pool_menu, "Konec", self.close, "Ctrl+Q") + + movie_menu = bar.addMenu("&Filmy") + self._add_action(movie_menu, "Importovat film…", self.import_movie, "Ctrl+I") + self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T") + self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D") + self._add_action(movie_menu, "Upravit ČSFD odkaz…", self.edit_csfd) + self._add_action(movie_menu, "Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) + movie_menu.addSeparator() + self._add_action(movie_menu, "Odebrat z poolu…", self.remove_movies, "Del") + + film_menu = bar.addMenu("Filmo&téka") + self._add_action(film_menu, "Vygenerovat Filmotéku", self.generate_filmoteka, "Ctrl+G") + self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders) + + def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction: + action = QAction(text, self) + if shortcut: + action.setShortcut(QKeySequence(shortcut)) + action.triggered.connect(slot) + menu.addAction(action) + return action + + def _build_central(self) -> None: + splitter = QSplitter(Qt.Horizontal) + + # Sidebar — tag filter + sidebar = QWidget() + side_layout = QVBoxLayout(sidebar) + side_layout.setContentsMargins(4, 4, 4, 4) + side_layout.addWidget(QLabel("📂 Štítky (filtr)")) + self.tag_tree = QTreeWidget() + self.tag_tree.setHeaderHidden(True) + self.tag_tree.itemChanged.connect(self._on_tag_filter_changed) + side_layout.addWidget(self.tag_tree) + splitter.addWidget(sidebar) + + # Main — search + movie table + main = QWidget() + main_layout = QVBoxLayout(main) + main_layout.setContentsMargins(4, 4, 4, 4) + + search_row = QHBoxLayout() + search_row.addWidget(QLabel("🔍")) + self.search_edit = QLineEdit() + self.search_edit.setPlaceholderText("Hledat film…") + self.search_edit.textChanged.connect(self.refresh_table) + search_row.addWidget(self.search_edit) + import_btn = QPushButton("➕ Importovat film") + import_btn.clicked.connect(self.import_movie) + search_row.addWidget(import_btn) + main_layout.addLayout(search_row) + + self.table = QTableWidget(0, 5) + self.table.setHorizontalHeaderLabels(["Název", "Datum", "Štítky", "Velikost", "ČSFD"]) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setContextMenuPolicy(Qt.CustomContextMenu) + self.table.customContextMenuRequested.connect(self._show_table_menu) + self.table.doubleClicked.connect(lambda _: self.open_movies()) + self.table.itemSelectionChanged.connect(self._update_selection_status) + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.Stretch) + main_layout.addWidget(self.table) + + splitter.addWidget(main) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + splitter.setSizes([260, 940]) + self.setCentralWidget(splitter) + + def _build_statusbar(self) -> None: + self.status = self.statusBar() + self._update_path_status() + + # ------------------------------------------------------------------ + # Sidebar (tag filter) + # ------------------------------------------------------------------ + + def refresh_sidebar(self) -> None: + self.tag_tree.blockSignals(True) + self.tag_tree.clear() + counts: dict[str, int] = {} + for f in self.filehandler.filelist: + for t in f.tags: + counts[t.full_path] = counts.get(t.full_path, 0) + 1 + + for category in self.tagmanager.get_categories(): + cat_item = QTreeWidgetItem([category]) + cat_item.setFlags(Qt.ItemIsEnabled) + self.tag_tree.addTopLevelItem(cat_item) + cat_item.setExpanded(True) + for tag in self.tagmanager.get_tags_in_category(category): + count = counts.get(tag.full_path, 0) + label = f"{tag.name} ({count})" if count else tag.name + item = QTreeWidgetItem([label]) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + item.setCheckState(0, Qt.Unchecked) + item.setData(0, Qt.UserRole, tag.full_path) + cat_item.addChild(item) + self.tag_tree.blockSignals(False) + + def _on_tag_filter_changed(self, _item, _col) -> None: + self.refresh_table() + + def _checked_filter_tags(self) -> List[Tag]: + tags: List[Tag] = [] + for i in range(self.tag_tree.topLevelItemCount()): + cat = self.tag_tree.topLevelItem(i) + for j in range(cat.childCount()): + child = cat.child(j) + if child.checkState(0) == Qt.Checked: + full_path = child.data(0, Qt.UserRole) + category, name = full_path.split("/", 1) + tags.append(Tag(category, name)) + return tags + + # ------------------------------------------------------------------ + # Movie table + # ------------------------------------------------------------------ + + def refresh_table(self, *_args) -> None: + filtered = self.filehandler.filter_files_by_tags(self._checked_filter_tags()) + search = self.search_edit.text().lower() if hasattr(self, "search_edit") else "" + if search: + filtered = [f for f in filtered if search in (f.title or f.filename).lower()] + filtered.sort(key=lambda f: (f.title or f.filename).lower()) + + self.table.setRowCount(len(filtered)) + self.file_rows.clear() + for row, f in enumerate(filtered): + self.file_rows[row] = f + name = f.title or f.filename + tags = ", ".join(t.name for t in f.tags) + try: + size = self._format_size(f.file_path.stat().st_size) + except OSError: + size = "?" + csfd = "🔗" if f.csfd_link else "" + for col, value in enumerate([name, f.date or "", tags, size, csfd]): + self.table.setItem(row, col, QTableWidgetItem(value)) + + self.refresh_sidebar() + self._update_selection_status() + self.status.showMessage(f"Zobrazeno {len(filtered)} filmů", 4000) + + @staticmethod + def _format_size(size_bytes: float) -> str: + for unit in ["B", "KB", "MB", "GB"]: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + def _selected_movies(self) -> List[File]: + rows = {idx.row() for idx in self.table.selectionModel().selectedRows()} + return [self.file_rows[r] for r in sorted(rows) if r in self.file_rows] + + def _show_table_menu(self, pos) -> None: + menu = QMenu(self) + menu.addAction("Otevřít", self.open_movies) + menu.addAction("Přiřadit štítky…", self.assign_tags) + menu.addAction("Nastavit datum…", self.set_date) + menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd) + menu.addAction("Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) + menu.addSeparator() + menu.addAction("Odebrat z poolu…", self.remove_movies) + menu.exec(self.table.viewport().mapToGlobal(pos)) + + def _update_selection_status(self) -> None: + files = self._selected_movies() + if not files: + self._update_path_status() + return + total = 0 + for f in files: + try: + total += f.file_path.stat().st_size + except OSError: + pass + self.status.showMessage(f"{len(files)} vybráno — {self._format_size(total)}") + + def _update_path_status(self) -> None: + pool = self.filehandler.pool_dir + out = self.filehandler.filmoteka_dir + self.status.showMessage( + f"Pool: {pool or '—'} | Filmotéka: {out or '—'} | " + f"{len(self.filehandler.filelist)} filmů" + ) + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def set_pool(self) -> None: + folder = QFileDialog.getExistingDirectory(self, "Vyber složku poolu") + if not folder: + return + self.filehandler.set_pool_dir(Path(folder)) + self.filehandler.load_pool_movies() + self.refresh_table() + self._update_path_status() + + def set_filmoteka(self) -> None: + folder = QFileDialog.getExistingDirectory(self, "Vyber výstupní složku Filmotéky") + if not folder: + return + self.filehandler.set_filmoteka_dir(Path(folder)) + self._update_path_status() + + def reload_pool(self) -> None: + if not self.filehandler.movies_dir: + QMessageBox.information(self, "Pool", "Nejprve nastavte pool.") + return + self.filehandler.load_pool_movies() + self.refresh_table() + + def import_movie(self) -> None: + if not self.filehandler.movies_dir: + QMessageBox.warning(self, "Pool", "Nejprve nastavte pool (menu Pool → Nastavit pool).") + return + path, _ = QFileDialog.getOpenFileName(self, "Vyber video soubor") + if not path: + return + source = Path(path) + dialog = ImportMovieDialog(self, default_title=source.stem) + if dialog.exec() != QDialog.Accepted: + return + try: + movie = self.filehandler.import_movie(source, dialog.title, dialog.csfd_link) + except Exception as exc: # noqa: BLE001 — surface any import failure to the user + QMessageBox.critical(self, "Chyba importu", str(exc)) + return + + # If a ČSFD link was given, enrich the movie with tags right away + if movie.csfd_link: + self.status.showMessage("Načítám z ČSFD…") + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + _, tags_total, errors = self._fetch_csfd_for([movie]) + finally: + QApplication.restoreOverrideCursor() + if errors: + QMessageBox.warning(self, "ČSFD", "Tagy se nepodařilo načíst:\n" + errors[0]) + else: + self.status.showMessage( + f"Importováno: {movie.title} (+{tags_total} tagů z ČSFD)", 5000 + ) + + self.refresh_table() + self.status.showMessage(f"Importováno: {dialog.title}", 5000) + + def open_movies(self) -> None: + for f in self._selected_movies(): + self._open_path(f.file_path) + + def _open_path(self, path: Path) -> None: + try: + if sys.platform.startswith("win"): + os.startfile(path) # type: ignore[attr-defined] + elif sys.platform.startswith("darwin"): + subprocess.call(["open", str(path)]) + else: + subprocess.call(["xdg-open", str(path)]) + except Exception as exc: # noqa: BLE001 + QMessageBox.critical(self, "Chyba", f"Nelze otevřít {path}: {exc}") + + def assign_tags(self) -> None: + files = self._selected_movies() + if not files: + QMessageBox.information(self, "Štítky", "Nejprve vyberte filmy.") + return + dialog = AssignTagsDialog(self, self.tagmanager, files) + if dialog.exec() != QDialog.Accepted: + return + for full_path, state in dialog.result_map.items(): + category, name = full_path.split("/", 1) + if state == 1: + self.filehandler.assign_tag_to_file_objects(files, self.tagmanager.add_tag(category, name)) + elif state == 0: + self.filehandler.remove_tag_from_file_objects(files, Tag(category, name)) + self.refresh_table() + + def set_date(self) -> None: + files = self._selected_movies() + if not files: + QMessageBox.information(self, "Datum", "Nejprve vyberte filmy.") + return + text, ok = QInputDialog.getText(self, "Nastavit datum", "Datum (YYYY-MM-DD, prázdné = smazat):") + if not ok: + return + for f in files: + f.set_date(text.strip() or None) + self.refresh_table() + + def edit_csfd(self) -> None: + files = self._selected_movies() + if len(files) != 1: + QMessageBox.information(self, "ČSFD", "Vyberte právě jeden film.") + return + f = files[0] + text, ok = QInputDialog.getText(self, "ČSFD odkaz", "URL:", text=f.csfd_link or "") + if not ok: + return + f.set_csfd_link(text.strip() or None) + self.refresh_table() + + def apply_csfd_tags_for_selected(self) -> None: + files = [f for f in self._selected_movies() if f.csfd_link] + if not files: + QMessageBox.information( + self, "ČSFD", "Vyberte filmy, které mají nastavený ČSFD odkaz." + ) + return + + self.status.showMessage(f"Načítám z ČSFD ({len(files)})…") + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + ok_count, tags_total, errors = self._fetch_csfd_for(files) + finally: + QApplication.restoreOverrideCursor() + + self.refresh_table() + msg = f"Načteno: {ok_count}/{len(files)} filmů, přidáno {tags_total} tagů." + if errors: + msg += "\n\nChyby:\n" + "\n".join(errors[:5]) + QMessageBox.warning(self, "ČSFD dokončeno s chybami", msg) + else: + QMessageBox.information(self, "ČSFD", msg) + + def _fetch_csfd_for(self, files) -> tuple[int, int, list[str]]: + """Apply CSFD tags to each file; return (ok_count, tags_added, errors).""" + ok_count = 0 + tags_total = 0 + errors: list[str] = [] + for f in files: + result = f.apply_csfd_tags() + if result["success"]: + ok_count += 1 + tags_total += len(result["tags_added"]) + else: + errors.append(f"{f.title or f.filename}: {result['error']}") + return ok_count, tags_total, errors + + def remove_movies(self) -> None: + files = self._selected_movies() + if not files: + return + answer = QMessageBox.question( + self, "Odebrat z poolu", + f"Smazat {len(files)} film(ů) z poolu včetně souboru a metadat?", + ) + if answer != QMessageBox.Yes: + return + for f in files: + try: + f.delete_metadata() + if f.file_path.exists(): + f.file_path.unlink() + except OSError as exc: + QMessageBox.warning(self, "Chyba", f"Nelze smazat {f.filename}: {exc}") + if f in self.filehandler.filelist: + self.filehandler.filelist.remove(f) + self.refresh_table() + + def generate_filmoteka(self) -> None: + out = self.filehandler.filmoteka_dir + if not out: + QMessageBox.warning(self, "Filmotéka", "Nejprve nastavte výstupní složku Filmotéky.") + return + files = self.filehandler.filelist + if not files: + QMessageBox.information(self, "Filmotéka", "Pool je prázdný.") + return + manager = HardlinkManager(out) + created, create_fail, removed, remove_fail = manager.sync_structure(files, FILMOTEKA_CATEGORIES) + + # Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked) + pool = self.filehandler.pool_dir + mirrored = 0 + mirror_fail = 0 + for name in self.filehandler.copyasis_folders: + src = pool / name if pool else None + if src and src.is_dir() and any(src.iterdir()): + m_created, m_failed = manager.mirror_as_is(src, name) + mirrored += m_created + mirror_fail += m_failed + + msg = ( + f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n" + f"Copy-as-is — zrcadleno: {mirrored}" + ) + if create_fail or remove_fail or mirror_fail: + msg += f"\nSelhalo: {create_fail + remove_fail + mirror_fail}" + QMessageBox.warning(self, "Filmotéka dokončena s chybami", msg) + else: + QMessageBox.information(self, "Filmotéka vygenerována", msg) + self.status.showMessage( + f"Filmotéka: filmy +{created}/-{removed}, copy-as-is +{mirrored}", 5000 + ) + + def edit_copyasis_folders(self) -> None: + current = ", ".join(self.filehandler.copyasis_folders) + text, ok = QInputDialog.getText( + self, "Copy-as-is složky", + "Názvy pool podsložek zrcadlených 1:1 (oddělené čárkou):", text=current + ) + if not ok: + return + self.filehandler.set_copyasis_folders(text.split(",")) + self.status.showMessage( + "Copy-as-is složky: " + ", ".join(self.filehandler.copyasis_folders), 5000 + ) + + def closeEvent(self, event) -> None: # noqa: N802 — Qt override + self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}" + from src.core.config import save_global_config + save_global_config(self.filehandler.global_config) + super().closeEvent(event) + + +def run(filehandler: FileManager, tagmanager: TagManager) -> None: + app = QApplication.instance() or QApplication(sys.argv) + window = QtApp(filehandler, tagmanager) + window.show() + app.exec() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9d10015 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..58d7999 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,413 @@ +import pytest +import json +from src.core.config import ( + load_global_config, save_global_config, DEFAULT_GLOBAL_CONFIG, + load_folder_config, save_folder_config, DEFAULT_FOLDER_CONFIG, + get_folder_config_path, folder_has_config, FOLDER_CONFIG_NAME, + load_config, save_config # Legacy functions +) + + +class TestGlobalConfig: + """Testy pro globální config""" + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + """Fixture pro dočasný globální config soubor""" + config_path = tmp_path / "config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + def test_default_global_config_structure(self): + """Test struktury defaultní globální konfigurace""" + assert "window_geometry" in DEFAULT_GLOBAL_CONFIG + assert "window_maximized" in DEFAULT_GLOBAL_CONFIG + assert "last_folder" in DEFAULT_GLOBAL_CONFIG + assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG + assert "recent_folders" in DEFAULT_GLOBAL_CONFIG + assert DEFAULT_GLOBAL_CONFIG["window_geometry"] == "1200x800" + assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False + assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None + + def test_load_global_config_nonexistent_file(self, temp_global_config): + """Test načtení globální konfigurace když soubor neexistuje""" + config = load_global_config() + assert config == DEFAULT_GLOBAL_CONFIG + + def test_save_global_config(self, temp_global_config): + """Test uložení globální konfigurace""" + test_config = { + "window_geometry": "800x600", + "window_maximized": True, + "last_folder": "/home/user/documents", + "sidebar_width": 300, + "recent_folders": ["/path1", "/path2"], + } + + save_global_config(test_config) + + assert temp_global_config.exists() + with open(temp_global_config, "r", encoding="utf-8") as f: + saved_data = json.load(f) + assert saved_data == test_config + + def test_load_global_config_existing_file(self, temp_global_config): + """Test načtení existující globální konfigurace""" + test_config = { + "window_geometry": "1920x1080", + "window_maximized": False, + "last_folder": "/test/path", + "sidebar_width": 250, + "recent_folders": [], + "pool_dir": None, + "filmoteka_dir": None, + "copyasis_folders": ["Seriály"], + } + + 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 == ".Curator.!ftag" + + def test_load_folder_config_nonexistent(self, tmp_path): + """Test načtení neexistujícího složkového configu""" + config = load_folder_config(tmp_path) + assert config == DEFAULT_FOLDER_CONFIG + + def test_save_folder_config(self, tmp_path): + """Test uložení složkového configu""" + test_config = { + "ignore_patterns": ["*.tmp", "*.log"], + "custom_tags": {"Projekt": ["Web", "API"]}, + "recursive": False, + } + + save_folder_config(tmp_path, test_config) + + config_path = get_folder_config_path(tmp_path) + assert config_path.exists() + + with open(config_path, "r", encoding="utf-8") as f: + saved_data = json.load(f) + assert saved_data == test_config + + def test_load_folder_config_existing(self, tmp_path): + """Test načtení existujícího složkového configu""" + test_config = { + "ignore_patterns": ["*.pyc"], + "custom_tags": {}, + "recursive": True, + "hardlink_output_dir": None, + "hardlink_categories": None, + } + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded == test_config + + def test_load_folder_config_merges_defaults(self, tmp_path): + """Test že chybějící klíče jsou doplněny z defaultů""" + partial_config = {"ignore_patterns": ["*.tmp"]} + + config_path = get_folder_config_path(tmp_path) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(partial_config, f) + + loaded = load_folder_config(tmp_path) + assert loaded["ignore_patterns"] == ["*.tmp"] + assert loaded["custom_tags"] == DEFAULT_FOLDER_CONFIG["custom_tags"] + assert loaded["recursive"] == DEFAULT_FOLDER_CONFIG["recursive"] + + def test_folder_has_config_true(self, tmp_path): + """Test folder_has_config když config existuje""" + save_folder_config(tmp_path, DEFAULT_FOLDER_CONFIG) + assert folder_has_config(tmp_path) is True + + def test_folder_has_config_false(self, tmp_path): + """Test folder_has_config když config neexistuje""" + assert folder_has_config(tmp_path) is False + + def test_folder_config_ignore_patterns(self, tmp_path): + """Test ukládání ignore patterns""" + patterns = ["*.tmp", "*.log", "*.cache", "*/node_modules/*", "*.pyc"] + test_config = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": patterns} + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["ignore_patterns"] == patterns + assert len(loaded["ignore_patterns"]) == 5 + + def test_folder_config_custom_tags(self, tmp_path): + """Test ukládání custom tagů""" + custom_tags = { + "Projekt": ["Frontend", "Backend", "API"], + "Stav": ["Hotovo", "Rozpracováno"], + } + test_config = {**DEFAULT_FOLDER_CONFIG, "custom_tags": custom_tags} + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["custom_tags"] == custom_tags + + def test_folder_config_corrupted_file(self, tmp_path): + """Test načtení poškozeného folder config souboru""" + config_path = get_folder_config_path(tmp_path) + with open(config_path, "w") as f: + f.write("{ invalid json }") + + config = load_folder_config(tmp_path) + assert config == DEFAULT_FOLDER_CONFIG + + def test_folder_config_utf8_encoding(self, tmp_path): + """Test UTF-8 v folder configu""" + test_config = { + "ignore_patterns": ["*.čeština"], + "custom_tags": {"Štítky": ["Červená", "Žlutá"]}, + "recursive": True, + } + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["ignore_patterns"] == ["*.čeština"] + assert loaded["custom_tags"]["Štítky"] == ["Červená", "Žlutá"] + + def test_multiple_folders_independent_configs(self, tmp_path): + """Test že různé složky mají nezávislé configy""" + folder1 = tmp_path / "folder1" + folder2 = tmp_path / "folder2" + folder1.mkdir() + folder2.mkdir() + + config1 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.txt"]} + config2 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.jpg"]} + + save_folder_config(folder1, config1) + save_folder_config(folder2, config2) + + loaded1 = load_folder_config(folder1) + loaded2 = load_folder_config(folder2) + + assert loaded1["ignore_patterns"] == ["*.txt"] + assert loaded2["ignore_patterns"] == ["*.jpg"] + + +class TestLegacyFunctions: + """Testy pro zpětnou kompatibilitu""" + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + """Fixture pro dočasný globální config soubor""" + config_path = tmp_path / "config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + def test_load_config_legacy(self, temp_global_config): + """Test že load_config funguje jako alias pro load_global_config""" + test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/test"} + + save_global_config(test_config) + loaded = load_config() + + assert loaded["last_folder"] == "/test" + + def test_save_config_legacy(self, temp_global_config): + """Test že save_config funguje jako alias pro save_global_config""" + test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/legacy"} + + save_config(test_config) + loaded = load_global_config() + + assert loaded["last_folder"] == "/legacy" + + +class TestConfigEdgeCases: + """Testy pro edge cases""" + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + """Fixture pro dočasný globální config soubor""" + config_path = tmp_path / "config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + def test_config_path_with_spaces(self, temp_global_config): + """Test s cestou obsahující mezery""" + test_config = { + **DEFAULT_GLOBAL_CONFIG, + "last_folder": "/path/with spaces/in name" + } + + save_global_config(test_config) + loaded = load_global_config() + + assert loaded["last_folder"] == "/path/with spaces/in name" + + def test_config_long_path(self, temp_global_config): + """Test s dlouhou cestou""" + long_path = "/very/long/path/" + "subdir/" * 50 + "final" + test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": long_path} + + save_global_config(test_config) + loaded = load_global_config() + + assert loaded["last_folder"] == long_path + + def test_config_many_recent_folders(self, temp_global_config): + """Test s velkým počtem recent folders""" + folders = [f"/path/folder{i}" for i in range(100)] + test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders} + + save_global_config(test_config) + loaded = load_global_config() + + assert len(loaded["recent_folders"]) == 100 + + def test_folder_config_special_characters_in_patterns(self, tmp_path): + """Test se speciálními znaky v patterns""" + test_config = { + **DEFAULT_FOLDER_CONFIG, + "ignore_patterns": ["*.tmp", "file[0-9].txt", "test?.log"] + } + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["ignore_patterns"] == test_config["ignore_patterns"] + + def test_config_json_formatting(self, temp_global_config): + """Test že config je uložen ve správném JSON formátu s indentací""" + test_config = {**DEFAULT_GLOBAL_CONFIG} + + save_global_config(test_config) + + with open(temp_global_config, "r", encoding="utf-8") as f: + content = f.read() + + # Mělo by být naformátováno s indentací + assert " " in content + + def test_config_ensure_ascii_false(self, temp_global_config): + """Test že ensure_ascii=False funguje správně""" + test_config = { + **DEFAULT_GLOBAL_CONFIG, + "last_folder": "/cesta/čeština" + } + + save_global_config(test_config) + + with open(temp_global_config, "r", encoding="utf-8") as f: + content = f.read() + + assert "čeština" in content + assert "\\u" not in content # Nemělo by být escapováno + + def test_config_overwrite(self, temp_global_config): + """Test přepsání existující konfigurace""" + config1 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path1"} + config2 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path2"} + + save_global_config(config1) + save_global_config(config2) + + loaded = load_global_config() + assert loaded["last_folder"] == "/path2" + + def test_folder_config_recursive_false(self, tmp_path): + """Test nastavení recursive na False""" + test_config = {**DEFAULT_FOLDER_CONFIG, "recursive": False} + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["recursive"] is False + + def test_empty_folder_config(self, tmp_path): + """Test prázdného folder configu""" + config_path = get_folder_config_path(tmp_path) + with open(config_path, "w", encoding="utf-8") as f: + json.dump({}, f) + + loaded = load_folder_config(tmp_path) + # Mělo by doplnit všechny defaulty + assert loaded["ignore_patterns"] == [] + assert loaded["custom_tags"] == {} + assert loaded["recursive"] is True diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..718ed3d --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,140 @@ +"""Tests for constants module.""" + +import re +from pathlib import Path +from unittest.mock import patch + +import pytest + +from src.constants import ( + APP_NAME, + APP_TITLE, + APP_VERSION, + ENV_DEBUG, + get_debug_mode, + get_version, +) + + +# --------------------------------------------------------------------------- +# get_version() +# --------------------------------------------------------------------------- + + +def test_get_version_returns_string() -> None: + """get_version() should return a string.""" + assert isinstance(get_version(), str) + + +def test_get_version_semver_format() -> None: + """get_version() should return a semver-like string X.Y.Z.""" + version = get_version() + assert re.match(r"^\d+\.\d+\.\d+", version), f"Not semver: {version!r}" + + +def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None: + """get_version() returns '0.0.0-unknown' when pyproject.toml and _version.py are both missing.""" + missing = tmp_path / "nonexistent.toml" + with patch("src.constants._PYPROJECT_PATH", missing): + result = get_version() + # Either fallback _version.py exists (from previous run) or returns unknown + assert isinstance(result, str) + assert len(result) > 0 + + +def test_get_version_unknown_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """get_version() returns '0.0.0-unknown' when all sources are unavailable.""" + missing = tmp_path / "nonexistent.toml" + monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing) + + # Patch _version import to also fail + with patch("src.constants.Path.write_text", side_effect=OSError): + with patch.dict("sys.modules", {"src._version": None}): + result = get_version() + + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# get_debug_mode() +# --------------------------------------------------------------------------- + + +def test_get_debug_mode_returns_bool() -> None: + """get_debug_mode() should always return a bool.""" + assert isinstance(get_debug_mode(), bool) + + +def test_get_debug_mode_true(monkeypatch: pytest.MonkeyPatch) -> None: + """get_debug_mode() returns True when ENV_DEBUG=true.""" + monkeypatch.setenv("ENV_DEBUG", "true") + assert get_debug_mode() is True + + +def test_get_debug_mode_true_variants(monkeypatch: pytest.MonkeyPatch) -> None: + """get_debug_mode() accepts '1' and 'yes' as truthy values.""" + for value in ("1", "yes", "YES", "True", "TRUE"): + monkeypatch.setenv("ENV_DEBUG", value) + assert get_debug_mode() is True, f"Expected True for ENV_DEBUG={value!r}" + + +def test_get_debug_mode_false(monkeypatch: pytest.MonkeyPatch) -> None: + """get_debug_mode() returns False when ENV_DEBUG=false.""" + monkeypatch.setenv("ENV_DEBUG", "false") + assert get_debug_mode() is False + + +def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: + """get_debug_mode() returns False when ENV_DEBUG is not set.""" + monkeypatch.delenv("ENV_DEBUG", raising=False) + assert get_debug_mode() is False + + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + + +def test_env_debug_is_bool() -> None: + """ENV_DEBUG should be a bool.""" + assert isinstance(ENV_DEBUG, bool) + + +def test_app_version_is_string() -> None: + """APP_VERSION should be a string.""" + assert isinstance(APP_VERSION, str) + + +def test_app_version_semver_format() -> None: + """APP_VERSION should follow semver format X.Y.Z.""" + assert re.match(r"^\d+\.\d+\.\d+", APP_VERSION), f"Not semver: {APP_VERSION!r}" + + +def test_app_name_value() -> None: + """APP_NAME should be 'Curator'.""" + assert APP_NAME == "Curator" + + +def test_app_title_contains_name_and_version() -> None: + """APP_TITLE should contain APP_NAME and APP_VERSION.""" + assert APP_NAME in APP_TITLE + assert APP_VERSION in APP_TITLE + + +def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> None: + """APP_TITLE ends with '-DEV' when ENV_DEBUG is True.""" + import src.constants as consts + + monkeypatch.setenv("ENV_DEBUG", "true") + monkeypatch.setattr(consts, "ENV_DEBUG", True) + title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if True else "") + assert title.endswith("-DEV") + + +def test_app_title_no_dev_suffix_when_not_debug(monkeypatch: pytest.MonkeyPatch) -> None: + """APP_TITLE does not end with '-DEV' when ENV_DEBUG is False.""" + import src.constants as consts + + monkeypatch.setattr(consts, "ENV_DEBUG", False) + title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if False else "") + assert not title.endswith("-DEV") diff --git a/tests/test_csfd.py b/tests/test_csfd.py new file mode 100644 index 0000000..024ae31 --- /dev/null +++ b/tests/test_csfd.py @@ -0,0 +1,286 @@ +"""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 = """ + + + + + +
85%%
+
+ Drama / + Thriller +
+
Česko, 2020, 120 min
+
+ +
+

Full plot description.

+ + +""" % 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 (comma-separated legacy format).""" + info = _extract_origin_info(soup) + assert info["country"] == "Česko" + assert info["year"] == 2020 + assert info["duration"] == 120 + + def test_extract_origin_info_bullet_format(self): + """Test current CSFD format with inline bullet spans (no commas).""" + from bs4 import BeautifulSoup + html = ( + '
USA ' + '1999 ' + '136 min (Alternativní 131 min)
' + ) + info = _extract_origin_info(BeautifulSoup(html, "html.parser")) + assert info["country"] == "USA" + assert info["year"] == 1999 + assert info["duration"] == 136 + + def test_extract_json_ld_year_from_date_created(self): + """Year is taken from JSON-LD dateCreated when present.""" + from bs4 import BeautifulSoup + html = ( + '' + ) + data = _extract_json_ld(BeautifulSoup(html, "html.parser")) + assert data["year"] == 1999 + + +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 = """ + + Test Movie + Another Movie + + """ + 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() diff --git a/tests/test_file.py b/tests/test_file.py new file mode 100644 index 0000000..aa9149d --- /dev/null +++ b/tests/test_file.py @@ -0,0 +1,265 @@ +import pytest +import json +from pathlib import Path +from src.core.file import File +from src.core.tag import Tag +from src.core.tag_manager import TagManager + + +class TestFile: + """Testy pro třídu File""" + + @pytest.fixture + def temp_dir(self, tmp_path): + """Fixture pro dočasný adresář""" + return tmp_path + + @pytest.fixture + def tag_manager(self): + """Fixture pro TagManager""" + return TagManager() + + @pytest.fixture + def test_file(self, temp_dir): + """Fixture pro testovací soubor""" + test_file = temp_dir / "test.txt" + test_file.write_text("test content") + return test_file + + def test_file_creation(self, test_file, tag_manager): + """Test vytvoření File objektu""" + file_obj = File(test_file, tag_manager) + assert file_obj.file_path == test_file + assert file_obj.filename == "test.txt" + assert file_obj.new == True + + def test_file_metadata_filename(self, test_file, tag_manager): + """Test názvu metadata souboru""" + file_obj = File(test_file, tag_manager) + expected = test_file.parent / ".test.txt.!tag" + assert file_obj.metadata_filename == expected + + def test_file_initial_tags(self, test_file, tag_manager): + """Test že nový soubor má tag Stav/Nové""" + file_obj = File(test_file, tag_manager) + assert len(file_obj.tags) == 1 + assert file_obj.tags[0].full_path == "Stav/Nové" + + def test_file_metadata_saved(self, test_file, tag_manager): + """Test že metadata jsou uložena při vytvoření""" + file_obj = File(test_file, tag_manager) + assert file_obj.metadata_filename.exists() + + def test_file_save_metadata(self, test_file, tag_manager): + """Test uložení metadat""" + file_obj = File(test_file, tag_manager) + file_obj.new = False + file_obj.ignored = True + file_obj.save_metadata() + + # Načtení a kontrola + with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["new"] == False + assert data["ignored"] == True + + def test_file_load_metadata(self, test_file, tag_manager): + """Test načtení metadat""" + # Vytvoření a uložení metadat + file_obj = File(test_file, tag_manager) + tag = tag_manager.add_tag("Video", "HD") + file_obj.tags.append(tag) + file_obj.date = "2025-01-15" + file_obj.save_metadata() + + # Vytvoření nového objektu - měl by načíst metadata + file_obj2 = File(test_file, tag_manager) + assert len(file_obj2.tags) == 2 # Stav/Nové + Video/HD + assert file_obj2.date == "2025-01-15" + + # Kontrola že tagy obsahují správné hodnoty + tag_paths = {tag.full_path for tag in file_obj2.tags} + assert "Video/HD" in tag_paths + assert "Stav/Nové" in tag_paths + + def test_file_set_date(self, test_file, tag_manager): + """Test nastavení data""" + file_obj = File(test_file, tag_manager) + file_obj.set_date("2025-12-25") + assert file_obj.date == "2025-12-25" + + # Kontrola že bylo uloženo + with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: + data = json.load(f) + assert data["date"] == "2025-12-25" + + def test_file_set_date_to_none(self, test_file, tag_manager): + """Test smazání data""" + file_obj = File(test_file, tag_manager) + file_obj.set_date("2025-12-25") + file_obj.set_date(None) + assert file_obj.date is None + + def test_file_set_date_empty_string(self, test_file, tag_manager): + """Test nastavení prázdného řetězce jako datum""" + file_obj = File(test_file, tag_manager) + file_obj.set_date("2025-12-25") + file_obj.set_date("") + assert file_obj.date is None + + def test_file_add_tag_object(self, test_file, tag_manager): + """Test přidání Tag objektu""" + file_obj = File(test_file, tag_manager) + tag = Tag("Video", "4K") + file_obj.add_tag(tag) + + assert tag in file_obj.tags + assert len(file_obj.tags) == 2 # Stav/Nové + Video/4K + + def test_file_add_tag_string(self, test_file, tag_manager): + """Test přidání tagu jako string""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("Audio/MP3") + + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "Audio/MP3" in tag_paths + + def test_file_add_tag_string_without_category(self, test_file, tag_manager): + """Test přidání tagu bez kategorie (použije 'default')""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("SimpleTag") + + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "default/SimpleTag" in tag_paths + + def test_file_add_duplicate_tag(self, test_file, tag_manager): + """Test že duplicitní tag není přidán""" + file_obj = File(test_file, tag_manager) + tag = Tag("Video", "HD") + file_obj.add_tag(tag) + file_obj.add_tag(tag) + + # Spočítáme kolikrát se tag vyskytuje + count = sum(1 for t in file_obj.tags if t == tag) + assert count == 1 + + def test_file_remove_tag_object(self, test_file, tag_manager): + """Test odstranění Tag objektu""" + file_obj = File(test_file, tag_manager) + tag = Tag("Video", "HD") + file_obj.add_tag(tag) + file_obj.remove_tag(tag) + + assert tag not in file_obj.tags + + def test_file_remove_tag_string(self, test_file, tag_manager): + """Test odstranění tagu jako string""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("Video/HD") + file_obj.remove_tag("Video/HD") + + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "Video/HD" not in tag_paths + + def test_file_remove_tag_string_without_category(self, test_file, tag_manager): + """Test odstranění tagu bez kategorie""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("SimpleTag") + file_obj.remove_tag("SimpleTag") + + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "default/SimpleTag" not in tag_paths + + def test_file_remove_nonexistent_tag(self, test_file, tag_manager): + """Test odstranění neexistujícího tagu (nemělo by vyhodit výjimku)""" + file_obj = File(test_file, tag_manager) + initial_count = len(file_obj.tags) + file_obj.remove_tag("Nonexistent/Tag") + assert len(file_obj.tags) == initial_count + + def test_file_without_tagmanager(self, test_file): + """Test File bez TagManager""" + file_obj = File(test_file, tagmanager=None) + assert file_obj.tagmanager is None + assert len(file_obj.tags) == 0 # Bez TagManager se nepřidá Stav/Nové + + def test_file_metadata_persistence(self, test_file, tag_manager): + """Test že metadata přežijí reload""" + # Vytvoření a úprava souboru + file_obj1 = File(test_file, tag_manager) + file_obj1.add_tag("Video/HD") + file_obj1.add_tag("Audio/Stereo") + file_obj1.set_date("2025-01-01") + file_obj1.new = False + file_obj1.ignored = True + file_obj1.save_metadata() + + # Načtení nového objektu + file_obj2 = File(test_file, tag_manager) + + # Kontrola + assert file_obj2.new == False + assert file_obj2.ignored == True + assert file_obj2.date == "2025-01-01" + + tag_paths = {tag.full_path for tag in file_obj2.tags} + assert "Video/HD" in tag_paths + assert "Audio/Stereo" in tag_paths + + def test_file_metadata_json_format(self, test_file, tag_manager): + """Test formátu JSON metadat""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("Test/Tag") + file_obj.set_date("2025-06-15") + + # Kontrola obsahu JSON + with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: + data = json.load(f) + + assert "new" in data + assert "ignored" in data + assert "tags" in data + assert "date" in data + assert isinstance(data["tags"], list) + + def test_file_unicode_handling(self, temp_dir, tag_manager): + """Test správného zacházení s unicode znaky""" + test_file = temp_dir / "český_soubor.txt" + test_file.write_text("obsah") + + file_obj = File(test_file, tag_manager) + file_obj.add_tag("Kategorie/Český tag") + file_obj.save_metadata() + + # Reload a kontrola + file_obj2 = File(test_file, tag_manager) + tag_paths = {tag.full_path for tag in file_obj2.tags} + assert "Kategorie/Český tag" in tag_paths + + def test_file_complex_scenario(self, test_file, tag_manager): + """Test komplexního scénáře použití""" + file_obj = File(test_file, tag_manager) + + # Přidání více tagů + file_obj.add_tag("Video/HD") + file_obj.add_tag("Video/Stereo") + file_obj.add_tag("Stav/Zkontrolováno") + file_obj.set_date("2025-01-01") + + # Odstranění tagu + file_obj.remove_tag("Stav/Nové") + + # Kontrola stavu + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "Video/HD" in tag_paths + assert "Video/Stereo" in tag_paths + assert "Stav/Zkontrolováno" in tag_paths + assert "Stav/Nové" not in tag_paths + assert file_obj.date == "2025-01-01" + + # Reload a kontrola persistence + file_obj2 = File(test_file, tag_manager) + tag_paths2 = {tag.full_path for tag in file_obj2.tags} + assert tag_paths == tag_paths2 + assert file_obj2.date == "2025-01-01" diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py new file mode 100644 index 0000000..febf74b --- /dev/null +++ b/tests/test_file_manager.py @@ -0,0 +1,612 @@ +import pytest +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager +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_curator_config_files(self, file_manager, temp_dir): + """Test že Curator config soubory jsou ignorovány""" + (temp_dir / ".Curator.!ftag").write_text('{}') # Folder config + (temp_dir / ".Curator.!gtag").write_text('{}') # Global config + + file_manager.append(temp_dir) + + filenames = {f.filename for f in file_manager.filelist} + assert ".Curator.!ftag" not in filenames + assert ".Curator.!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 TestPoolManagement: + """Testy pro pool a copy-as-is složky""" + + @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, temp_global_config): + return FileManager(TagManager()) + + def test_set_pool_creates_top_level_folders(self, file_manager, tmp_path): + pool = tmp_path / "pool" + file_manager.set_pool_dir(pool) + + assert (pool / "Filmy").is_dir() + assert (pool / "Seriály").is_dir() + assert file_manager.pool_dir == pool + + def test_import_movie_copies_and_indexes(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + source = tmp_path / "raw.mkv" + source.write_bytes(b"x" * 10) + + movie = file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1") + + assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv" + assert source.exists() # non-destructive copy + assert movie.title == "Matrix" + assert movie.csfd_link == "https://csfd.cz/film/1" + assert file_manager.index.get(movie.file_path) is not None + + def test_load_pool_movies_reads_from_index(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + source = tmp_path / "raw.mkv" + source.write_bytes(b"x" * 10) + file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1") + + reloaded = FileManager(TagManager()) + reloaded.set_pool_dir(tmp_path / "pool") + reloaded.load_pool_movies() + + assert len(reloaded.filelist) == 1 + assert reloaded.filelist[0].title == "Matrix" + + def test_copyasis_folders_default_and_set(self, file_manager): + assert file_manager.copyasis_folders == ["Seriály"] + + file_manager.set_copyasis_folders(["Seriály", " Dokumenty ", ""]) + assert file_manager.copyasis_folders == ["Seriály", "Dokumenty"] diff --git a/tests/test_hardlink_manager.py b/tests/test_hardlink_manager.py new file mode 100644 index 0000000..8ac8883 --- /dev/null +++ b/tests/test_hardlink_manager.py @@ -0,0 +1,628 @@ +import pytest +import os +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" + + +class TestMirrorAsIs: + """Testy pro copy-as-is zrcadlení (Seriály)""" + + def test_mirror_clones_hierarchy_with_hardlinks(self, tmp_path): + """Adresářová struktura se zrcadlí 1:1 a soubory jsou hardlinky""" + source = tmp_path / "Seriály" + (source / "Show" / "S01").mkdir(parents=True) + ep1 = source / "Show" / "S01" / "ep1.mkv" + ep2 = source / "Show" / "S01" / "ep2.mkv" + ep1.write_text("a") + ep2.write_text("b") + + output = tmp_path / "out" + manager = HardlinkManager(output) + created, failed = manager.mirror_as_is(source, "Seriály") + + assert failed == 0 + assert created == 2 + linked = output / "Seriály" / "Show" / "S01" / "ep1.mkv" + assert linked.exists() + assert linked.stat().st_ino == ep1.stat().st_ino + + def test_mirror_skips_curator_metadata(self, tmp_path): + """Metadata soubory (.!tag, .!index) se nezrcadlí""" + source = tmp_path / "Seriály" + source.mkdir() + (source / "ep1.mkv").write_text("a") + (source / ".ep1.mkv.!tag").write_text("{}") + (source / ".Curator.!index").write_text("{}") + + output = tmp_path / "out" + manager = HardlinkManager(output) + created, failed = manager.mirror_as_is(source, "Seriály") + + assert created == 1 + assert failed == 0 + assert not (output / "Seriály" / ".ep1.mkv.!tag").exists() + + def test_mirror_nonexistent_source_is_noop(self, tmp_path): + """Neexistující zdroj nic neudělá""" + manager = HardlinkManager(tmp_path / "out") + assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0) diff --git a/tests/test_media_utils.py b/tests/test_media_utils.py new file mode 100644 index 0000000..e626d7e --- /dev/null +++ b/tests/test_media_utils.py @@ -0,0 +1,75 @@ +import tempfile +from pathlib import Path +import pytest + +from src.core.media_utils import load_icon +from PIL import Image, ImageTk +import tkinter as tk + + +@pytest.fixture(scope="module") +def tk_root(): + """Fixture pro inicializaci Tkinteru (nutné pro ImageTk).""" + root = tk.Tk() + yield root + root.destroy() + + +def test_load_icon_returns_photoimage(tk_root): + """Test že load_icon vrací PhotoImage""" + # vytvoříme dočasný obrázek + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + # vytvoříme 100x100 červený obrázek + img = Image.new("RGB", (100, 100), color="red") + img.save(tmp_path) + + icon = load_icon(tmp_path) + + # musí být PhotoImage + assert isinstance(icon, ImageTk.PhotoImage) + + # ověříme velikost 16x16 + assert icon.width() == 16 + assert icon.height() == 16 + finally: + tmp_path.unlink(missing_ok=True) + + +def test_load_icon_resizes_image(tk_root): + """Test že load_icon správně změní velikost obrázku""" + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + # vytvoříme velký obrázek 500x500 + img = Image.new("RGB", (500, 500), color="blue") + img.save(tmp_path) + + icon = load_icon(tmp_path) + + # i velký obrázek by měl být zmenšen na 16x16 + assert icon.width() == 16 + assert icon.height() == 16 + finally: + tmp_path.unlink(missing_ok=True) + + +def test_load_icon_different_formats(tk_root): + """Test načítání různých formátů obrázků""" + formats = [".png", ".jpg", ".bmp"] + + for fmt in formats: + with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + img = Image.new("RGB", (32, 32), color="green") + img.save(tmp_path) + + icon = load_icon(tmp_path) + + assert isinstance(icon, ImageTk.PhotoImage) + assert icon.width() == 16 + assert icon.height() == 16 + finally: + tmp_path.unlink(missing_ok=True) diff --git a/tests/test_pool_index.py b/tests/test_pool_index.py new file mode 100644 index 0000000..1316b6b --- /dev/null +++ b/tests/test_pool_index.py @@ -0,0 +1,97 @@ +import json + +from src.core.pool_index import PoolIndex, INDEX_FILENAME +from src.core.file import File +from src.core.tag_manager import TagManager + + +class TestPoolIndex: + """Testy pro unifikovaný metadata index poolu""" + + def test_empty_index_when_no_file(self, tmp_path): + index = PoolIndex(tmp_path) + assert index.records == {} + assert index.get(tmp_path / "Filmy" / "x.mkv") is None + + def test_set_and_get_by_relative_key(self, tmp_path): + index = PoolIndex(tmp_path) + path = tmp_path / "Filmy" / "Matrix.mkv" + index.set(path, {"title": "Matrix"}) + + assert index.get(path) == {"title": "Matrix"} + # key is the pool-relative POSIX path + assert "Filmy/Matrix.mkv" in index.records + + def test_set_persists_to_disk(self, tmp_path): + index = PoolIndex(tmp_path) + index.set(tmp_path / "Filmy" / "A.mkv", {"title": "A"}) + + on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8")) + assert on_disk["movies"]["Filmy/A.mkv"]["title"] == "A" + + def test_reload_reads_existing_index(self, tmp_path): + PoolIndex(tmp_path).set(tmp_path / "Filmy" / "A.mkv", {"title": "A"}) + + reloaded = PoolIndex(tmp_path) + assert reloaded.get(tmp_path / "Filmy" / "A.mkv") == {"title": "A"} + + def test_delete_removes_record(self, tmp_path): + index = PoolIndex(tmp_path) + path = tmp_path / "Filmy" / "A.mkv" + index.set(path, {"title": "A"}) + index.delete(path) + + assert index.get(path) is None + assert PoolIndex(tmp_path).get(path) is None + + def test_corrupt_index_loads_empty(self, tmp_path): + (tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8") + index = PoolIndex(tmp_path) + assert index.records == {} + + +class TestFileWithIndex: + """File používá index místo sidecar souboru, je-li injektován""" + + def test_index_backed_file_writes_no_sidecar(self, tmp_path): + index = PoolIndex(tmp_path) + movie = tmp_path / "Filmy" / "Matrix.mkv" + movie.parent.mkdir(parents=True) + movie.write_bytes(b"x") + + f = File(movie, TagManager(), index=index) + + assert not f.metadata_filename.exists() # no sidecar + assert index.get(movie) is not None # record created in index + assert f.tags[0].full_path == "Stav/Nové" + + def test_index_backed_metadata_persists_across_reload(self, tmp_path): + index = PoolIndex(tmp_path) + movie = tmp_path / "Filmy" / "Matrix.mkv" + movie.parent.mkdir(parents=True) + movie.write_bytes(b"x") + + tm = TagManager() + f = File(movie, tm, index=index) + f.title = "Matrix" + f.csfd_link = "https://csfd.cz/film/1" + f.add_tag("Žánr/Akční") + f.set_date("1999-03-31") + + # Fresh index + File read from disk + index2 = PoolIndex(tmp_path) + f2 = File(movie, TagManager(), index=index2) + assert f2.title == "Matrix" + assert f2.csfd_link == "https://csfd.cz/film/1" + assert f2.date == "1999-03-31" + assert "Žánr/Akční" in {t.full_path for t in f2.tags} + + def test_delete_metadata_removes_index_record(self, tmp_path): + index = PoolIndex(tmp_path) + movie = tmp_path / "Filmy" / "Matrix.mkv" + movie.parent.mkdir(parents=True) + movie.write_bytes(b"x") + + f = File(movie, TagManager(), index=index) + f.delete_metadata() + assert index.get(movie) is None diff --git a/tests/test_tag.py b/tests/test_tag.py new file mode 100644 index 0000000..37ccd7b --- /dev/null +++ b/tests/test_tag.py @@ -0,0 +1,106 @@ +import pytest +from src.core.tag import Tag + + +class TestTag: + """Testy pro třídu Tag""" + + def test_tag_creation(self): + """Test vytvoření tagu""" + tag = Tag("Kategorie", "Název") + assert tag.category == "Kategorie" + assert tag.name == "Název" + + def test_tag_full_path(self): + """Test full_path property""" + tag = Tag("Video", "HD") + assert tag.full_path == "Video/HD" + + def test_tag_str_representation(self): + """Test string reprezentace""" + tag = Tag("Foto", "Dovolená") + assert str(tag) == "Foto/Dovolená" + + def test_tag_repr(self): + """Test repr reprezentace""" + tag = Tag("Audio", "Hudba") + assert repr(tag) == "Tag(Audio/Hudba)" + + def test_tag_equality_same_tags(self): + """Test rovnosti stejných tagů""" + tag1 = Tag("Kategorie", "Název") + tag2 = Tag("Kategorie", "Název") + assert tag1 == tag2 + + def test_tag_equality_different_tags(self): + """Test nerovnosti různých tagů""" + tag1 = Tag("Kategorie1", "Název") + tag2 = Tag("Kategorie2", "Název") + assert tag1 != tag2 + + tag3 = Tag("Kategorie", "Název1") + tag4 = Tag("Kategorie", "Název2") + assert tag3 != tag4 + + def test_tag_equality_with_non_tag(self): + """Test porovnání s ne-Tag objektem""" + tag = Tag("Kategorie", "Název") + assert tag != "Kategorie/Název" + assert tag != 123 + assert tag != None + + def test_tag_hash(self): + """Test hashování - důležité pro použití v set/dict""" + tag1 = Tag("Kategorie", "Název") + tag2 = Tag("Kategorie", "Název") + tag3 = Tag("Jiná", "Název") + + # Stejné tagy mají stejný hash + assert hash(tag1) == hash(tag2) + # Různé tagy mají různý hash (většinou) + assert hash(tag1) != hash(tag3) + + def test_tag_in_set(self): + """Test použití tagů v set""" + tag1 = Tag("Kategorie", "Název") + tag2 = Tag("Kategorie", "Název") + tag3 = Tag("Jiná", "Název") + + tag_set = {tag1, tag2, tag3} + # tag1 a tag2 jsou stejné, takže set obsahuje pouze 2 prvky + assert len(tag_set) == 2 + assert tag1 in tag_set + assert tag3 in tag_set + + def test_tag_in_dict(self): + """Test použití tagů jako klíčů v dict""" + tag1 = Tag("Kategorie", "Název") + tag2 = Tag("Kategorie", "Název") + + tag_dict = {tag1: "hodnota1"} + tag_dict[tag2] = "hodnota2" + + # tag1 a tag2 jsou stejné, takže dict má 1 klíč + assert len(tag_dict) == 1 + assert tag_dict[tag1] == "hodnota2" + + def test_tag_with_special_characters(self): + """Test tagů se speciálními znaky""" + tag = Tag("Kategorie/Složitá", "Název s mezerami") + assert tag.category == "Kategorie/Složitá" + assert tag.name == "Název s mezerami" + assert tag.full_path == "Kategorie/Složitá/Název s mezerami" + + def test_tag_with_empty_strings(self): + """Test tagů s prázdnými řetězci""" + tag = Tag("", "") + assert tag.category == "" + assert tag.name == "" + assert tag.full_path == "/" + + def test_tag_unicode(self): + """Test tagů s unicode znaky""" + tag = Tag("Kategorie", "Čeština") + assert tag.category == "Kategorie" + assert tag.name == "Čeština" + assert tag.full_path == "Kategorie/Čeština" diff --git a/tests/test_tag_manager.py b/tests/test_tag_manager.py new file mode 100644 index 0000000..5fdcb82 --- /dev/null +++ b/tests/test_tag_manager.py @@ -0,0 +1,327 @@ +import pytest +from src.core.tag_manager import TagManager, DEFAULT_TAGS +from src.core.tag import Tag + + +class TestTagManager: + """Testy pro třídu TagManager""" + + @pytest.fixture + def tag_manager(self): + """Fixture pro vytvoření TagManager instance""" + return TagManager() + + @pytest.fixture + def empty_tag_manager(self): + """Fixture pro prázdný TagManager (bez default tagů)""" + tm = TagManager() + # Odstranit default tagy pro testy které potřebují prázdný manager + for category in list(tm.tags_by_category.keys()): + tm.remove_category(category) + return tm + + def test_tag_manager_creation_has_defaults(self, tag_manager): + """Test vytvoření TagManager obsahuje default tagy""" + assert "Hodnocení" in tag_manager.tags_by_category + assert "Barva" in tag_manager.tags_by_category + + def test_tag_manager_default_tags_count(self, tag_manager): + """Test počtu default tagů""" + # Hodnocení má 5 hvězdiček + assert len(tag_manager.tags_by_category["Hodnocení"]) == 5 + # Barva má 6 barev + assert len(tag_manager.tags_by_category["Barva"]) == 6 + + def test_add_category(self, tag_manager): + """Test přidání kategorie""" + tag_manager.add_category("Video") + assert "Video" in tag_manager.tags_by_category + assert tag_manager.tags_by_category["Video"] == set() + + def test_add_category_duplicate(self, empty_tag_manager): + """Test přidání duplicitní kategorie""" + empty_tag_manager.add_category("Video") + empty_tag_manager.add_category("Video") + assert len(empty_tag_manager.tags_by_category) == 1 + + def test_remove_category(self, tag_manager): + """Test odstranění kategorie""" + tag_manager.add_category("Video") + tag_manager.remove_category("Video") + assert "Video" not in tag_manager.tags_by_category + + def test_remove_nonexistent_category(self, tag_manager): + """Test odstranění neexistující kategorie""" + # Nemělo by vyhodit výjimku + tag_manager.remove_category("Neexistující") + assert "Neexistující" not in tag_manager.tags_by_category + + def test_add_tag(self, tag_manager): + """Test přidání tagu""" + tag = tag_manager.add_tag("Video", "HD") + assert isinstance(tag, Tag) + assert tag.category == "Video" + assert tag.name == "HD" + assert "Video" in tag_manager.tags_by_category + assert tag in tag_manager.tags_by_category["Video"] + + def test_add_tag_creates_category(self, tag_manager): + """Test že add_tag vytvoří kategorii pokud neexistuje""" + tag = tag_manager.add_tag("NovaKategorie", "Tag") + assert "NovaKategorie" in tag_manager.tags_by_category + + def test_add_multiple_tags_same_category(self, tag_manager): + """Test přidání více tagů do stejné kategorie""" + tag1 = tag_manager.add_tag("Video", "HD") + tag2 = tag_manager.add_tag("Video", "4K") + tag3 = tag_manager.add_tag("Video", "SD") + + assert len(tag_manager.tags_by_category["Video"]) == 3 + assert tag1 in tag_manager.tags_by_category["Video"] + assert tag2 in tag_manager.tags_by_category["Video"] + assert tag3 in tag_manager.tags_by_category["Video"] + + def test_add_duplicate_tag(self, tag_manager): + """Test přidání duplicitního tagu (set zabrání duplicitám)""" + tag1 = tag_manager.add_tag("Video", "HD") + tag2 = tag_manager.add_tag("Video", "HD") + + assert len(tag_manager.tags_by_category["Video"]) == 1 + assert tag1 == tag2 + + def test_remove_tag(self, tag_manager): + """Test odstranění tagu - když je poslední, kategorie se smaže""" + tag_manager.add_tag("Video", "HD") + tag_manager.remove_tag("Video", "HD") + + # Kategorie by měla být smazána (podle implementace v tag_manager.py) + assert "Video" not in tag_manager.tags_by_category + + def test_remove_tag_removes_empty_category(self, tag_manager): + """Test že odstranění posledního tagu odstraní i kategorii""" + tag_manager.add_tag("Video", "HD") + tag_manager.remove_tag("Video", "HD") + + assert "Video" not in tag_manager.tags_by_category + + def test_remove_tag_keeps_category_with_other_tags(self, tag_manager): + """Test že odstranění tagu neodstraní kategorii s dalšími tagy""" + tag_manager.add_tag("Video", "HD") + tag_manager.add_tag("Video", "4K") + tag_manager.remove_tag("Video", "HD") + + assert "Video" in tag_manager.tags_by_category + assert len(tag_manager.tags_by_category["Video"]) == 1 + + def test_remove_nonexistent_tag(self, tag_manager): + """Test odstranění neexistujícího tagu""" + tag_manager.add_category("Video") + # Nemělo by vyhodit výjimku + tag_manager.remove_tag("Video", "Neexistující") + + def test_remove_tag_from_nonexistent_category(self, tag_manager): + """Test odstranění tagu z neexistující kategorie""" + # Nemělo by vyhodit výjimku + tag_manager.remove_tag("Neexistující", "Tag") + + def test_get_all_tags_empty(self, empty_tag_manager): + """Test získání všech tagů (prázdný manager)""" + tags = empty_tag_manager.get_all_tags() + assert tags == [] + + def test_get_all_tags(self, empty_tag_manager): + """Test získání všech tagů""" + empty_tag_manager.add_tag("Video", "HD") + empty_tag_manager.add_tag("Video", "4K") + empty_tag_manager.add_tag("Audio", "MP3") + + tags = empty_tag_manager.get_all_tags() + assert len(tags) == 3 + assert "Video/HD" in tags + assert "Video/4K" in tags + assert "Audio/MP3" in tags + + def test_get_all_tags_includes_defaults(self, tag_manager): + """Test že get_all_tags obsahuje default tagy""" + tags = tag_manager.get_all_tags() + # Minimálně 11 default tagů (5 hodnocení + 6 barev) + assert len(tags) >= 11 + + def test_get_categories_empty(self, empty_tag_manager): + """Test získání kategorií (prázdný manager)""" + categories = empty_tag_manager.get_categories() + assert categories == [] + + def test_get_categories(self, empty_tag_manager): + """Test získání kategorií""" + empty_tag_manager.add_tag("Video", "HD") + empty_tag_manager.add_tag("Audio", "MP3") + empty_tag_manager.add_tag("Foto", "RAW") + + categories = empty_tag_manager.get_categories() + assert len(categories) == 3 + assert "Video" in categories + assert "Audio" in categories + assert "Foto" in categories + + def test_get_categories_includes_defaults(self, tag_manager): + """Test že get_categories obsahuje default kategorie""" + categories = tag_manager.get_categories() + assert "Hodnocení" in categories + assert "Barva" in categories + + def test_get_tags_in_category_empty(self, tag_manager): + """Test získání tagů z prázdné kategorie""" + tag_manager.add_category("Video") + tags = tag_manager.get_tags_in_category("Video") + assert tags == [] + + def test_get_tags_in_category(self, tag_manager): + """Test získání tagů z kategorie""" + tag_manager.add_tag("Video", "HD") + tag_manager.add_tag("Video", "4K") + tag_manager.add_tag("Audio", "MP3") + + video_tags = tag_manager.get_tags_in_category("Video") + assert len(video_tags) == 2 + + # Kontrola že obsahují správné tagy (pořadí není garantováno) + tag_names = {tag.name for tag in video_tags} + assert "HD" in tag_names + assert "4K" in tag_names + + def test_get_tags_in_nonexistent_category(self, tag_manager): + """Test získání tagů z neexistující kategorie""" + tags = tag_manager.get_tags_in_category("Neexistující") + assert tags == [] + + def test_complex_scenario(self, empty_tag_manager): + """Test komplexního scénáře použití""" + tm = empty_tag_manager + + # Přidání několika kategorií a tagů + tm.add_tag("Video", "HD") + tm.add_tag("Video", "4K") + tm.add_tag("Audio", "MP3") + tm.add_tag("Audio", "FLAC") + tm.add_tag("Foto", "RAW") + + # Kontrola stavu + assert len(tm.get_categories()) == 3 + assert len(tm.get_all_tags()) == 5 + + # Odstranění některých tagů + tm.remove_tag("Video", "HD") + assert len(tm.get_tags_in_category("Video")) == 1 + + # Odstranění celé kategorie + tm.remove_category("Foto") + assert "Foto" not in tm.get_categories() + assert len(tm.get_all_tags()) == 3 + + def test_tag_uniqueness_in_set(self, tag_manager): + """Test že tagy jsou správně ukládány jako set (bez duplicit)""" + tag_manager.add_tag("Video", "HD") + tag_manager.add_tag("Video", "HD") + tag_manager.add_tag("Video", "HD") + + # I když přidáme 3x, v setu je jen 1 + assert len(tag_manager.tags_by_category["Video"]) == 1 + + +class TestDefaultTags: + """Testy pro defaultní tagy""" + + def test_default_tags_constant_exists(self): + """Test že DEFAULT_TAGS konstanta existuje""" + assert DEFAULT_TAGS is not None + assert isinstance(DEFAULT_TAGS, dict) + + def test_default_tags_has_hodnoceni(self): + """Test že DEFAULT_TAGS obsahuje Hodnocení""" + assert "Hodnocení" in DEFAULT_TAGS + assert len(DEFAULT_TAGS["Hodnocení"]) == 5 + + def test_default_tags_has_barva(self): + """Test že DEFAULT_TAGS obsahuje Barva""" + assert "Barva" in DEFAULT_TAGS + assert len(DEFAULT_TAGS["Barva"]) == 6 + + def test_hodnoceni_stars_content(self): + """Test obsahu hvězdiček v Hodnocení""" + stars = DEFAULT_TAGS["Hodnocení"] + assert "⭐" in stars + assert "⭐⭐⭐⭐⭐" in stars + + def test_barva_colors_content(self): + """Test obsahu barev v Barva""" + colors = DEFAULT_TAGS["Barva"] + # Kontrolujeme že obsahuje některé barvy + color_names = " ".join(colors) + assert "Červená" in color_names + assert "Zelená" in color_names + assert "Modrá" in color_names + + def test_tag_manager_loads_all_default_tags(self): + """Test že TagManager načte všechny default tagy""" + tm = TagManager() + + for category, tag_names in DEFAULT_TAGS.items(): + assert category in tm.tags_by_category + tags_in_category = tm.get_tags_in_category(category) + assert len(tags_in_category) == len(tag_names) + + def test_can_add_custom_tags_alongside_defaults(self): + """Test že lze přidat vlastní tagy vedle defaultních""" + tm = TagManager() + initial_count = len(tm.get_all_tags()) + + tm.add_tag("Custom", "MyTag") + + assert len(tm.get_all_tags()) == initial_count + 1 + assert "Custom" in tm.get_categories() + + def test_can_remove_default_category(self): + """Test že lze odstranit default kategorii""" + tm = TagManager() + tm.remove_category("Hodnocení") + + assert "Hodnocení" not in tm.tags_by_category + + def test_hodnoceni_tags_are_sorted_by_stars(self): + """Test že tagy v Hodnocení jsou seřazeny od 1 do 5 hvězd""" + tm = TagManager() + tags = tm.get_tags_in_category("Hodnocení") + + tag_names = [t.name for t in tags] + assert tag_names == ["⭐", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"] + + def test_barva_tags_are_sorted_in_predefined_order(self): + """Test že tagy v Barva jsou seřazeny v předdefinovaném pořadí""" + tm = TagManager() + tags = tm.get_tags_in_category("Barva") + + tag_names = [t.name for t in tags] + expected = ["🔴 Červená", "🟠 Oranžová", "🟡 Žlutá", "🟢 Zelená", "🔵 Modrá", "🟣 Fialová"] + assert tag_names == expected + + def test_custom_category_tags_sorted_alphabetically(self): + """Test že tagy v custom kategorii jsou seřazeny abecedně""" + tm = TagManager() + tm.add_tag("Video", "HD") + tm.add_tag("Video", "4K") + tm.add_tag("Video", "SD") + + tags = tm.get_tags_in_category("Video") + tag_names = [t.name for t in tags] + + assert tag_names == ["4K", "HD", "SD"] + + def test_can_add_tag_to_default_category(self): + """Test že lze přidat tag do default kategorie""" + tm = TagManager() + initial_count = len(tm.get_tags_in_category("Hodnocení")) + + tm.add_tag("Hodnocení", "Custom Rating") + + assert len(tm.get_tags_in_category("Hodnocení")) == initial_count + 1 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..55d42d2 --- /dev/null +++ b/tests/test_utils.py @@ -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)