diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..652d35d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv +__pycache__ +.pytest_cache \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6d3c95f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "editor.rulers": [ + 80 + ], + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf6aff1 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +install required modules to enviroment: + pip install -r requirements.txt \ No newline at end of file diff --git a/Tagger.py b/Tagger.py index 4c4e7f7..1b63fcd 100644 --- a/Tagger.py +++ b/Tagger.py @@ -1 +1,19 @@ -print("Test") \ No newline at end of file +# Imports +import tkinter as tk +from tkinter import ttk + +from src.ui.gui import App +from src.core.file_manager import list_files, FileManager +from src.core.tag_manager import TagManager +from pathlib import Path + +class State(): + def __init__(self) -> None: + self.tagmanager = TagManager() + self.filehandler = FileManager(self.tagmanager) + self.app = App(self.filehandler, self.tagmanager) + + + +STATE = State() +STATE.app.main() \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..6cd5b96 --- /dev/null +++ b/config.json @@ -0,0 +1,12 @@ +{ + "ignore_patterns": [ + "*.png", + "*.jpg", + "*.mp3", + "*/M/*", + "*/L/*", + "*/Ostatní/*", + "*.hidden*" + ], + "last_folder": "/media/veracrypt3" +} \ No newline at end of file diff --git a/data/samples/.50.png.!tag b/data/samples/.50.png.!tag new file mode 100644 index 0000000..bc1eb9a --- /dev/null +++ b/data/samples/.50.png.!tag @@ -0,0 +1,9 @@ +{ + "new": true, + "ignored": false, + "tags": [ + "Rozlišení/4K", + "Rozlišení/FullHD" + ], + "date": null +} \ No newline at end of file diff --git a/data/samples/.DORMER_PRAMET.PDF.!tag b/data/samples/.DORMER_PRAMET.PDF.!tag new file mode 100644 index 0000000..f0e3d74 --- /dev/null +++ b/data/samples/.DORMER_PRAMET.PDF.!tag @@ -0,0 +1,8 @@ +{ + "new": true, + "ignored": false, + "tags": [ + "Rozlišení/4K" + ], + "date": "2025-09-15" +} \ No newline at end of file diff --git a/data/samples/50.png b/data/samples/50.png new file mode 100644 index 0000000..802fe33 Binary files /dev/null and b/data/samples/50.png differ diff --git a/data/samples/DORMER_PRAMET.PDF b/data/samples/DORMER_PRAMET.PDF new file mode 100644 index 0000000..df832a2 --- /dev/null +++ b/data/samples/DORMER_PRAMET.PDF @@ -0,0 +1,210 @@ +%PDF-1.6 % +1 0 obj <>/OCGs[5 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream + + + + + application/pdf + + + DORMER PRAMET TM logo black + + + + + Pavel Remeš + Pavel Remes + + + + + Marketing Dpt. 787 53 Sumperk, Czech Republic + + + Adobe Illustrator 27.6 (Windows) + 2024-11-19T12:48:57+02:00 + 2024-11-19T12:48:57+01:00 + 2024-11-19T12:48:57+01:00 + + + + 256 + 72 + JPEG + /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgASAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A8z5JodirsVdirsVdirsV dir6V/5wq/47Hmn/AJh7T/icmAs4Pbvz7/8AJPeaf+YP/mYmAM5cn5+ZJpZB5U8/ecfKd2l15f1a 4sWRgzQo5ML0NaSQtWNx/rLikF9wfkt+bNl+Y/lc33prbaxYssOrWaklVkYVWSOu/pyUPGu4oRvS pi2g28Z/5yO/5yB1mHWbryZ5Su3sorImLV9TgYrM8w+1BE43RY+jsNy23QbkMZSfNL3E7zGd5Hac nkZWYl+XjyO9cLW98/5x5/P7XtM8wWflXzPfSX+h6hItvaXVy5eW0mc8Y/3jmpiY0Uqx+HqKCoIZ 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 6UuDxcFTT0oexwFnHk9swMnYq7FX/9k= + + + + xmp.did:d8460b81-f0b4-b54f-90cb-f1169ae4002d + uuid:04d3f7ee-b4f3-4080-b4f4-fc39258cc8b8 + proof:pdf + uuid:6CC3FC99C2BFDE11AFC3CA46B930BAE5 + + uuid:ae1ad9e8-25ce-45bc-851b-942f5049d62f + xmp.did:351d9a2d-71f1-3d42-ad11-b0eef367c74f + uuid:6CC3FC99C2BFDE11AFC3CA46B930BAE5 + default + + + + + saved + xmp.iid:5872C63803E8E411A4A09BB2B09E70BA + 2015-04-21T11:05:19+02:00 + Adobe Illustrator CS6 (Windows) + / + + + saved + xmp.iid:5FBFBBE305E8E41193D7858916604D92 + 2015-04-21T11:07:52+02:00 + Adobe Bridge CS6 (Windows) + /metadata + + + saved + xmp.iid:D0FCA8F16226E711A2EEF3A1EB1A0515 + 2017-04-21T09:20:06+02:00 + Adobe Illustrator CS6 (Windows) + / + + + saved + xmp.iid:eafbe254-adf0-4c49-ad3a-1734bef02370 + 2019-01-02T14:50:24+01:00 + Adobe Illustrator CC 23.0 (Windows) + / + + + saved + xmp.iid:d4d72b2d-9304-034c-9ced-4fba2904b342 + 2019-01-02T15:06:17+01:00 + Adobe Bridge CC 2017 (Windows) + /metadata + + + saved + xmp.iid:4ccde57e-9c46-2043-a80e-a8cb4f87ab08 + 2019-04-09T13:49:07+02:00 + Adobe Illustrator CC 23.0 (Windows) + / + + + saved + xmp.iid:d8460b81-f0b4-b54f-90cb-f1169ae4002d + 2024-11-19T12:48:09+01:00 + Adobe Illustrator 27.6 (Windows) + / + + + + 1 + False + True + + 106.814775 + 46.000000 + Millimeters + + + + Cyan + Magenta + Yellow + Black + + + + + + Výchozí skupina vzorků + 0 + + + + Adobe PDF library 17.00 + Chief Graphic Designer + Adobe Illustrator + + Unicovska 7 + Šumperk + Czech Republic + 787 53 + +420 583 381 553 + pavel.remes@dormerpramet.com + Czech Republic + + + + + + + + + + + + + + + + + + + + + + + + + + +endstream endobj 3 0 obj <> endobj 7 0 obj <>/Properties<>>>/TrimBox[0.0 0.0 302.782 130.394]/Type/Page>> endobj 8 0 obj <>stream +HlWK$ )֏0f1x݀_ 0GR^ѯ,)з?=yy3ʨg9tc9#vP:s|o?aEKgjFv~<<,G'+?R38%IJiP o+,٭bsR豟G>4[-ā$BU*IτQˆ(BL!fABld4e!4`/?qP +l 56/J[ (| xaIaJ[k篢ON(bG +-:l异Q* GVPGyZӽxcP+wѾ@{U׽v>{?cW<tEoH#sg9{Kj,~peQ/|SVa/J,!$_ eYCAjz,7IxS Efm?nj؈Šj2+??s%N#/NEZf9 +x ZЂPb/ 6M%DJ<uA(:Jea; +2ﵮ$Ke!Q;P*oȅ]d#/jm#H?giD!WLd +:"H-0XWVq9lXёh ,8PS"V$- ylՈVҞ19ڃa +jN8T56Qq03g`0+X`W@%;o.lLG! ?v>cOuXS1xYJ<;[[7<1xW,9b1 +am?lOPz$v0m).QHOjܴ܎&le֏GTu}qŎ}+#vձnzVY-g8%tj9rat"vq^ʀU5.'^UM>*3.:N'{7 +Jѱl@5-sv;:hVxDvڊH/eW?¼P5-/l o#h dZ1g@_[\Mhpy6zg^PU5g,kG #Dzl"yWm뮗+FU[yB[&"q3ԂC$[܊{~ls#/VZ}g͕ꣶlNU$>l궠:|4ZxAZy7B5}!M 'jWec``1`Ir)Tnc:+#ɘ>QbƛTI#%2QBkݝ>@uL“[ɕkl;[ynh +܀bޏFhǴIvZˡS`bѤh!1r2ûQ5oNVo ^wgq9mkN]9HoO&aTdOl6% A'2inr(W5]i3v TYҪA.g89W{vc7:G +bۤztx&A. tJ-aod078)IaU;Ku$i =1_#l:> endobj 10 0 obj [/View/Design] endobj 11 0 obj <>>> endobj 9 0 obj <> endobj 6 0 obj [5 0 R] endobj 12 0 obj <> endobj xref +0 13 +0000000000 65535 f +0000000016 00000 n +0000000144 00000 n +0000021179 00000 n +0000000000 00000 f +0000024127 00000 n +0000024423 00000 n +0000021230 00000 n +0000021563 00000 n +0000024313 00000 n +0000024197 00000 n +0000024228 00000 n +0000024446 00000 n +trailer +<<8886194BA5A7D34E82FD2511260ACF59>]>> +startxref +24726 +%%EOF diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..037103e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pillow \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 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..734155f --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,22 @@ +import json +from pathlib import Path + +CONFIG_FILE = Path("config.json") + +default_config = { + "ignore_patterns": [], + "last_folder": None +} + +def load_config(): + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return default_config.copy() + return default_config.copy() + +def save_config(cfg: dict): + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) diff --git a/src/core/constants.py b/src/core/constants.py new file mode 100644 index 0000000..abbc08d --- /dev/null +++ b/src/core/constants.py @@ -0,0 +1,4 @@ +# src/core/constants.py +VERSION = "v1.0.2" +APP_NAME = "Tagger" +APP_VIEWPORT = "1000x700" \ No newline at end of file diff --git a/src/core/file.py b/src/core/file.py new file mode 100644 index 0000000..802daa2 --- /dev/null +++ b/src/core/file.py @@ -0,0 +1,100 @@ +from pathlib import Path +import json +from .tag import Tag + +class File: + def __init__(self, file_path: Path, tagmanager=None) -> None: + self.file_path = file_path + self.filename = file_path.name + self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag" + 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 + self.get_metadata() + + def get_metadata(self) -> None: + if not self.metadata_filename.exists(): + self.new = True + self.ignored = False + self.tags = [] + self.date = None + if self.tagmanager: + tag = self.tagmanager.add_tag("Stav", "Nové") + self.tags.append(tag) + self.save_metadata() + else: + self.load_metadata() + + def save_metadata(self): + 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, + } + 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.new = data.get("new", True) + self.ignored = data.get("ignored", False) + self.tags = [] + self.date = data.get("date", 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 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 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..5a5be7c --- /dev/null +++ b/src/core/file_manager.py @@ -0,0 +1,104 @@ +from pathlib import Path +from .file import File +from .tag_manager import TagManager +from .utils import list_files +from typing import Iterable +import fnmatch +from src.core.config import load_config, save_config + +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.config = load_config() + + def append(self, folder: Path) -> None: + self.folders.append(folder) + self.config["last_folder"] = str(folder) + save_config(self.config) + + ignore_patterns = self.config.get("ignore_patterns", []) + for each in list_files(folder): + if each.name.endswith(".!tag"): + continue + + full_path = each.as_posix() # celá cesta jako string + + # kontrolujeme jméno i celou cestu + 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 assign_tag_to_file_objects(self, files_objs: list[File], tag): + """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" + for f in files_objs: + if isinstance(tag, str): + if "/" in tag: + category, name = tag.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + else: + # pokud není uvedena kategorie, zařadíme pod "default" + tag_obj = self.tagmanager.add_tag("default", tag) + else: + tag_obj = tag + if tag_obj not in f.tags: + f.tags.append(tag_obj) + f.save_metadata() + if self.on_files_changed: + self.on_files_changed(self.filelist) + + def remove_tag_from_file_objects(self, files_objs: list[File], tag): + """Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů.""" + for f in files_objs: + if isinstance(tag, str): + if "/" in tag: + category, name = tag.split("/", 1) + tag_obj = File.__module__ # dummy to satisfy typing (we create Tag below) + # use Tag class directly + from .tag import Tag as TagClass + tag_obj = TagClass(category, name) + else: + from .tag import Tag as TagClass + tag_obj = TagClass("default", tag) + else: + tag_obj = tag + if tag_obj in f.tags: + f.tags.remove(tag_obj) + f.save_metadata() + if self.on_files_changed: + self.on_files_changed(self.filelist) + + def filter_files_by_tags(self, tags: Iterable): + """ + 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 + + # normalizuj cílové tagy na full_path stringy + target_full_paths = set() + from .tag import Tag as TagClass + for t in tags_list: + if isinstance(t, TagClass): + target_full_paths.add(t.full_path) + elif isinstance(t, str): + target_full_paths.add(t) + else: + # neznámý typ: ignorovat + 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 \ No newline at end of file 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..fde1a47 --- /dev/null +++ b/src/core/media_utils.py @@ -0,0 +1,42 @@ +# 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 +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/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..4bd3ef2 --- /dev/null +++ b/src/core/tag_manager.py @@ -0,0 +1,36 @@ +from .tag import Tag + +class TagManager: + def __init__(self): + self.tags_by_category = {} # {category: set(Tag)} + + 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): + return list(self.tags_by_category.get(category, [])) \ 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 0000000..71da133 Binary files /dev/null and b/src/resources/images/32/32_calendar.png differ diff --git a/src/resources/images/32/32_checked.png b/src/resources/images/32/32_checked.png new file mode 100644 index 0000000..0b91010 Binary files /dev/null and b/src/resources/images/32/32_checked.png differ diff --git a/src/resources/images/32/32_computer.png b/src/resources/images/32/32_computer.png new file mode 100644 index 0000000..58f77fe Binary files /dev/null and b/src/resources/images/32/32_computer.png differ diff --git a/src/resources/images/32/32_crossed.png b/src/resources/images/32/32_crossed.png new file mode 100644 index 0000000..e34c323 Binary files /dev/null and b/src/resources/images/32/32_crossed.png differ diff --git a/src/resources/images/32/32_tag.png b/src/resources/images/32/32_tag.png new file mode 100644 index 0000000..0d350f3 Binary files /dev/null and b/src/resources/images/32/32_tag.png differ diff --git a/src/resources/images/32/32_unchecked.png b/src/resources/images/32/32_unchecked.png new file mode 100644 index 0000000..c503b47 Binary files /dev/null and b/src/resources/images/32/32_unchecked.png differ diff --git a/src/resources/images/orig/orig_calendar.png b/src/resources/images/orig/orig_calendar.png new file mode 100644 index 0000000..eb3b953 Binary files /dev/null and b/src/resources/images/orig/orig_calendar.png differ diff --git a/src/resources/images/orig/orig_checked.png b/src/resources/images/orig/orig_checked.png new file mode 100644 index 0000000..54a504d Binary files /dev/null and b/src/resources/images/orig/orig_checked.png differ diff --git a/src/resources/images/orig/orig_computer.png b/src/resources/images/orig/orig_computer.png new file mode 100644 index 0000000..ac006a9 Binary files /dev/null and b/src/resources/images/orig/orig_computer.png differ diff --git a/src/resources/images/orig/orig_crossed.png b/src/resources/images/orig/orig_crossed.png new file mode 100644 index 0000000..8cbd67b Binary files /dev/null and b/src/resources/images/orig/orig_crossed.png differ diff --git a/src/resources/images/orig/orig_tag.png b/src/resources/images/orig/orig_tag.png new file mode 100644 index 0000000..f8de5fe Binary files /dev/null and b/src/resources/images/orig/orig_tag.png differ diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/gui.py b/src/ui/gui.py new file mode 100644 index 0000000..7a529e0 --- /dev/null +++ b/src/ui/gui.py @@ -0,0 +1,711 @@ +import os +import sys +import subprocess +import tkinter as tk +from tkinter import ttk, simpledialog, messagebox, filedialog +from pathlib import Path +from typing import List + +from src.core.media_utils import load_icon +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager +from src.core.file import File +from src.core.tag import Tag +from src.core.list_manager import ListManager +from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT +from src.core.config import save_config # <-- doplněno + + + + +class TagSelectionDialog(tk.Toplevel): + """ + Jednoduchý dialog pro výběr tagů (původní, používán jinde). + (tento třída zůstává pro jednobodové použití) + """ + def __init__(self, parent, tags: list[str]): + super().__init__(parent) + self.title("Vyber tagy") + self.selected_tags = [] + self.vars = {} + + tk.Label(self, text="Vyber tagy k přiřazení:").pack(pady=5) + + frame = tk.Frame(self) + frame.pack(padx=10, pady=5) + + for tag in tags: + var = tk.BooleanVar(value=False) + chk = tk.Checkbutton(frame, text=tag, variable=var) + chk.pack(anchor="w") + self.vars[tag] = var + + btn_frame = tk.Frame(self) + btn_frame.pack(pady=5) + tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5) + tk.Button(btn_frame, text="Zrušit", command=self.destroy).pack(side="left", padx=5) + + self.transient(parent) + self.grab_set() + parent.wait_window(self) + + def on_ok(self): + self.selected_tags = [tag for tag, var in self.vars.items() if var.get()] + self.destroy() + + +class MultiFileTagAssignDialog(tk.Toplevel): + def __init__(self, parent, all_tags: List[Tag], files: List[File]): + super().__init__(parent) + self.title("Přiřadit tagy k vybraným souborům") + self.vars: dict[str, int] = {} + self.checkbuttons: dict[str, tk.Checkbutton] = {} + self.tags_by_full = {t.full_path: t for t in all_tags} + self.files = files + + tk.Label(self, text=f"Vybráno souborů: {len(files)}").pack(pady=5) + + frame = tk.Frame(self) + frame.pack(padx=10, pady=5, fill="both", expand=True) + + file_tag_sets = [{t.full_path for t in f.tags} for f in files] + + for full_path, tag in sorted(self.tags_by_full.items()): + have_count = sum(1 for s in file_tag_sets if full_path in s) + if have_count == 0: + init = 0 + elif have_count == len(files): + init = 1 + else: + init = 2 # mixed + + cb = tk.Checkbutton(frame, text=full_path, anchor="w") + cb.state_value = init + cb.full_path = full_path + cb.pack(fill="x", anchor="w") + cb.bind("", self._on_toggle) + + self._update_checkbox_look(cb) + self.checkbuttons[full_path] = cb + self.vars[full_path] = init + + btn_frame = tk.Frame(self) + btn_frame.pack(pady=5) + tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5) + tk.Button(btn_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5) + + self.transient(parent) + self.grab_set() + parent.wait_window(self) + + def _on_toggle(self, event): + cb: tk.Checkbutton = event.widget + cur = cb.state_value + if cur == 0: # OFF → ON + cb.state_value = 1 + elif cur == 1: # ON → OFF + cb.state_value = 0 + elif cur == 2: # MIXED → ON + cb.state_value = 1 + self._update_checkbox_look(cb) + return "break" + + def _update_checkbox_look(self, cb: tk.Checkbutton): + """Aktualizuje vizuál podle stavu.""" + v = cb.state_value + if v == 0: + cb.deselect() + cb.config(fg="black") + elif v == 1: + cb.select() + cb.config(fg="blue") + elif v == 2: + cb.deselect() # mixed = nezaškrtnuté, ale červený text + cb.config(fg="red") + + def on_ok(self): + self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()} + self.destroy() + + +class App: + def __init__(self, filehandler: FileManager, tagmanager: TagManager): + self.states = {} + self.listbox_map: dict[int, list[File]] = {} + self.selected_tree_item_for_context = None + self.selected_list_index_for_context = None + self.filehandler = filehandler + self.tagmanager = tagmanager + self.list_manager = ListManager() + + # tady jen připravíme proměnnou, ale nevytváříme BooleanVar! + self.hide_ignored_var = None + + self.filter_text = "" + self.show_full_path = False + self.sort_mode = "name" + self.sort_order = "asc" + + self.filehandler.on_files_changed = self.update_files_from_manager + + def detect_video_resolution(self): + files = self.get_selected_files_objects() + if not files: + self.status_bar.config(text="Nebyly vybrány žádné soubory") + return + + count = 0 + for f in files: + try: + path = str(f.file_path) + result = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=height", "-of", "csv=p=0", path], + capture_output=True, + text=True, + check=True + ) + height_str = result.stdout.strip() + if not height_str.isdigit(): + continue + height = int(height_str) + tag_name = f"{height}p" + tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name) + f.add_tag(tag_obj) + count += 1 + except Exception as e: + print(f"Chyba u {f.filename}: {e}") + + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Přiřazeno rozlišení tagů k {count} souborům") + + + # ================================================== + # MAIN GUI + # ================================================== + def main(self): + root = tk.Tk() + root.title(APP_NAME + " " + VERSION) + root.geometry(APP_VIEWPORT) + self.root = root + + # teď už máme root, takže můžeme vytvořit BooleanVar + self.hide_ignored_var = tk.BooleanVar(value=False, master=root) + + last = self.filehandler.config.get("last_folder") + if last: + try: + self.filehandler.append(Path(last)) + except Exception: + pass + + # ---- Ikony + unchecked = load_icon("src/resources/images/32/32_unchecked.png") + checked = load_icon("src/resources/images/32/32_checked.png") + tag_icon = load_icon("src/resources/images/32/32_tag.png") + self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon} + root.unchecked_img = unchecked + root.checked_img = checked + root.tag_img = tag_icon + + # ---- Layout + menu_bar = tk.Menu(root) + root.config(menu=menu_bar) + + file_menu = tk.Menu(menu_bar, tearoff=0) + file_menu.add_command(label="Open Folder...", command=self.open_folder_dialog) + file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns) + file_menu.add_separator() + file_menu.add_command(label="Exit", command=root.quit) + + view_menu = tk.Menu(menu_bar, tearoff=0) + view_menu.add_checkbutton( + label="Skrýt ignorované", + variable=self.hide_ignored_var, + command=self.toggle_hide_ignored + ) + function_menu = tk.Menu(menu_bar, tearoff=0) + function_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected) + function_menu.add_command(label="Detekovat rozlišení u videí", command=self.detect_video_resolution) + + + menu_bar.add_cascade(label="Soubor", menu=file_menu) + menu_bar.add_cascade(label="Pohled", menu=view_menu) + menu_bar.add_cascade(label="Funkce", menu=function_menu) + + main_frame = tk.Frame(root) + main_frame.pack(fill="both", expand=True) + main_frame.columnconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=2) + main_frame.rowconfigure(0, weight=1) + + # ---- Tree (left) + self.tree = ttk.Treeview(main_frame) + self.tree.grid(row=0, column=0, sticky="nsew", padx=4, pady=4) + self.tree.bind("", self.on_tree_left_click) + self.tree.bind("", self.on_tree_right_click) + + # ---- Right side (filter + listbox) + right_frame = tk.Frame(main_frame) + right_frame.grid(row=0, column=1, sticky="nsew", padx=4, pady=4) + right_frame.rowconfigure(1, weight=1) + right_frame.columnconfigure(0, weight=1) + + # Filter + buttons row + filter_frame = tk.Frame(right_frame) + filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 4)) + filter_frame.columnconfigure(0, weight=1) + + self.filter_entry = tk.Entry(filter_frame) + self.filter_entry.grid(row=0, column=0, sticky="ew") + self.filter_entry.bind("", lambda e: self.on_filter_changed()) + + self.btn_toggle_path = tk.Button(filter_frame, text="Name", width=6, command=self.toggle_show_path) + self.btn_toggle_path.grid(row=0, column=1, padx=2) + + self.btn_toggle_sortmode = tk.Button(filter_frame, text="Sort:Name", width=8, command=self.toggle_sort_mode) + self.btn_toggle_sortmode.grid(row=0, column=2, padx=2) + + self.btn_toggle_order = tk.Button(filter_frame, text="ASC", width=5, command=self.toggle_sort_order) + self.btn_toggle_order.grid(row=0, column=3, padx=2) + + # Listbox + scrollbar + self.listbox = tk.Listbox(right_frame, selectmode="extended") + self.listbox.grid(row=1, column=0, sticky="nsew") + self.listbox.bind("", self.on_list_double) + self.listbox.bind("", self.on_list_right_click) + + lb_scroll = tk.Scrollbar(right_frame, orient="vertical", command=self.listbox.yview) + lb_scroll.grid(row=1, column=1, sticky="ns") + self.listbox.config(yscrollcommand=lb_scroll.set) + + # ---- Status bar + self.status_bar = tk.Label(root, text="Připraven", anchor="w", relief="sunken") + self.status_bar.pack(side="bottom", fill="x") + + # ---- Context menus + self.tree_menu = tk.Menu(root, tearoff=0) + self.tree_menu.add_command(label="Nový štítek", command=self.tree_add_tag) + self.tree_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag) + + self.list_menu = tk.Menu(root, tearoff=0) + self.list_menu.add_command(label="Otevřít soubor", command=self.list_open_file) + self.list_menu.add_command(label="Smazat z indexu", command=self.list_remove_file) + self.list_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected) + self.list_menu.add_command(label="Přiřadit štítek", command=self.assign_tag_to_selected_bulk) + + # ---- Root node + root_id = self.tree.insert("", "end", text="Štítky", image=self.icons["tag"]) + self.tree.item(root_id, open=True) + self.root_id = root_id + + # ⚡ refresh při startu + self.refresh_tree_tags() + self.update_files_from_manager(self.filehandler.filelist) + + root.mainloop() + + + # ================================================== + # FILTER + SORT TOGGLES + # ================================================== + def set_ignore_patterns(self): + current = ", ".join(self.filehandler.config.get("ignore_patterns", [])) + s = simpledialog.askstring("Ignore patterns", "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", initialvalue=current) + if s is None: + return + patterns = [p.strip() for p in s.split(",") if p.strip()] + self.filehandler.config["ignore_patterns"] = patterns + save_config(self.filehandler.config) + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_hide_ignored(self): + self.update_files_from_manager(self.filehandler.filelist) + + def on_filter_changed(self): + self.filter_text = self.filter_entry.get().strip().lower() + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_show_path(self): + self.show_full_path = not self.show_full_path + self.btn_toggle_path.config(text="Path" if self.show_full_path else "Name") + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_sort_mode(self): + self.sort_mode = "date" if self.sort_mode == "name" else "name" + self.btn_toggle_sortmode.config(text=f"Sort:{self.sort_mode.capitalize()}") + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_sort_order(self): + self.sort_order = "desc" if self.sort_order == "asc" else "asc" + self.btn_toggle_order.config(text=self.sort_order.upper()) + self.update_files_from_manager(self.filehandler.filelist) + + # ================================================== + # FILE REFRESH + MAP + # ================================================== + def update_files_from_manager(self, filelist=None): + if filelist is None: + filelist = self.filehandler.filelist + + # filtr tagy + checked_tags = self.get_checked_tags() + filtered_files = self.filehandler.filter_files_by_tags(checked_tags) + + # filtr text + if self.filter_text: + filtered_files = [ + f for f in filtered_files + if self.filter_text in f.filename.lower() or + (self.show_full_path and self.filter_text in str(f.file_path).lower()) + ] + + if self.hide_ignored_var and self.hide_ignored_var.get(): + filtered_files = [ + f for f in filtered_files + if "Stav/Ignorované" not in {t.full_path for t in f.tags} + ] + + + + # řazení + reverse = (self.sort_order == "desc") + if self.sort_mode == "name": + filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse) + elif self.sort_mode == "date": + filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse) + + # naplníme listbox + self.listbox.delete(0, "end") + self.listbox_map = {} + + for i, f in enumerate(filtered_files): + if self.show_full_path: + display = str(f.file_path) + else: + display = f.filename + if f.date: + display = f"{display} — {f.date}" + self.listbox.insert("end", display) + self.listbox_map[i] = [f] + + self.status_bar.config(text=f"Zobrazeno {self.listbox.size()} položek") + + # ================================================== + # GET SELECTED FILES + # ================================================== + def get_selected_files_objects(self): + indices = self.listbox.curselection() + files = [] + for idx in indices: + files.extend(self.listbox_map.get(idx, [])) + return files + + # ================================================== + # ASSIGN TAG (jednoduchý) + # ================================================== + def assign_tag_to_selected(self): + files = self.get_selected_files_objects() + if not files: + self.status_bar.config(text="Nebyly vybrány žádné soubory") + return + + all_tags: List[Tag] = [] + for category in self.tagmanager.get_categories(): + for tag in self.tagmanager.get_tags_in_category(category): + all_tags.append(tag) + + if not all_tags: + messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") + return + + tag_strings = [tag.full_path for tag in all_tags] + dialog = TagSelectionDialog(self.root, tag_strings) + selected_tag_strings = dialog.selected_tags + + if not selected_tag_strings: + self.status_bar.config(text="Nebyl vybrán žádný tag") + return + + selected_tags: list[Tag] = [] + for full_tag in selected_tag_strings: + if "/" in full_tag: + category, name = full_tag.split("/", 1) + selected_tags.append(self.tagmanager.add_tag(category, name)) + + for tag in selected_tags: + self.filehandler.assign_tag_to_file_objects(files, tag) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Přiřazeny tagy: {', '.join(selected_tag_strings)}") + + # ================================================== + # ASSIGN TAG (pokročilé pro více souborů - tri-state) + # ================================================== + def assign_tag_to_selected_bulk(self): + files = self.get_selected_files_objects() + if not files: + self.status_bar.config(text="Nebyly vybrány žádné soubory") + return + + all_tags: List[Tag] = [] + for category in self.tagmanager.get_categories(): + for tag in self.tagmanager.get_tags_in_category(category): + all_tags.append(tag) + + if not all_tags: + messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") + return + + dialog = MultiFileTagAssignDialog(self.root, all_tags, files) + result = getattr(dialog, "result", None) + if result is None: + self.status_bar.config(text="Přiřazení zrušeno") + return + + for full_path, state in result.items(): + if state == 1: + if "/" in full_path: + category, name = full_path.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + self.filehandler.assign_tag_to_file_objects(files, tag_obj) + elif state == 0: + if "/" in full_path: + category, name = full_path.split("/", 1) + from src.core.tag import Tag as TagClass + tag_obj = TagClass(category, name) + self.filehandler.remove_tag_from_file_objects(files, tag_obj) + else: + continue + + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text="Hromadné přiřazení tagů dokončeno") + + # ================================================== + # SET DATE FOR SELECTED FILES + # ================================================== + def set_date_for_selected(self): + files = self.get_selected_files_objects() + if not files: + self.status_bar.config(text="Nebyly vybrány žádné soubory") + return + prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):" + date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root) + if date_str is None: + return + for f in files: + f.set_date(date_str if date_str != "" else None) + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)") + + # ================================================== + # DOUBLE CLICK OPEN + # ================================================== + def on_list_double(self, event): + for f in self.get_selected_files_objects(): + self.open_file(f.file_path) + + # ================================================== + # OPEN FILE + # ================================================== + def open_file(self, path): + try: + if sys.platform.startswith("win"): + os.startfile(path) + elif sys.platform.startswith("darwin"): + subprocess.call(["open", path]) + else: + subprocess.call(["xdg-open", path]) + self.status_bar.config(text=f"Otevírám: {path}") + except Exception as e: + messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}") + + # ================================================== + # LIST CONTEXT MENU + # ================================================== + def on_list_right_click(self, event): + idx = self.listbox.nearest(event.y) + if idx is None: + return + + # pokud položka není součástí aktuálního výběru, přidáme ji + if idx not in self.listbox.curselection(): + self.listbox.selection_set(idx) + + self.selected_list_index_for_context = idx + self.list_menu.tk_popup(event.x_root, event.y_root) + + + def list_open_file(self): + for f in self.get_selected_files_objects(): + self.open_file(f.file_path) + + def list_remove_file(self): + files = self.get_selected_files_objects() + if not files: + return + ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?") + if ans: + for f in files: + if f in self.filehandler.filelist: + self.filehandler.filelist.remove(f) + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Odstraněno {len(files)} souborů z indexu") + + # ================================================== + # OPEN FOLDER + # ================================================== + def open_folder_dialog(self): + folder = filedialog.askdirectory(title="Vyber složku pro sledování") + if not folder: + return + folder_path = Path(folder) + try: + self.filehandler.append(folder_path) + for f in self.filehandler.filelist: + if f.tags and f.tagmanager: + for t in f.tags: + f.tagmanager.add_tag(t.category, t.name) + self.status_bar.config(text=f"Přidána složka: {folder_path}") + self.refresh_tree_tags() + self.update_files_from_manager(self.filehandler.filelist) + except Exception as e: + messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}") + + # ================================================== + # TREE EVENTS + # ================================================== + def on_tree_left_click(self, event): + region = self.tree.identify("region", event.x, event.y) + if region not in ("tree", "icon"): + return + + item_id = self.tree.identify_row(event.y) + if not item_id: + return + + parent_id = self.tree.parent(item_id) + if parent_id == "" or parent_id == self.root_id: + is_open = self.tree.item(item_id, "open") + self.tree.item(item_id, open=not is_open) + return + + self.states[item_id] = not self.states.get(item_id, False) + self.tree.item( + item_id, + image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"] + ) + self.status_bar.config( + text=f"Tag {'checked' if self.states[item_id] else 'unchecked'}: {self.build_full_tag(item_id)}" + ) + + checked_tags = self.get_checked_tags() + filtered_files = self.filehandler.filter_files_by_tags(checked_tags) + self.update_files_from_manager(filtered_files) + + def on_tree_right_click(self, event): + item_id = self.tree.identify_row(event.y) + if item_id: + self.selected_tree_item_for_context = item_id + self.tree.selection_set(item_id) + self.tree_menu.tk_popup(event.x_root, event.y_root) + else: + menu = tk.Menu(self.root, tearoff=0) + menu.add_command(label="Nový top-level tag", command=lambda: self.tree_add_tag(background=True)) + menu.tk_popup(event.x_root, event.y_root) + + # ================================================== + # TREE TAG CRUD + # ================================================== + def tree_add_tag(self, background=False): + name = simpledialog.askstring("Nový tag", "Název tagu:") + if not name: + return + parent = self.selected_tree_item_for_context if not background else self.root_id + new_id = self.tree.insert(parent, "end", text=name, image=self.icons["unchecked"]) + self.states[new_id] = False + + if parent == self.root_id: + category = name + self.tagmanager.add_category(category) + self.tree.item(new_id, image=self.icons["tag"]) + else: + category = self.tree.item(parent, "text") + self.tagmanager.add_tag(category, name) + + self.status_bar.config(text=f"Vytvořen tag: {self.build_full_tag(new_id)}") + + def tree_delete_tag(self): + item = self.selected_tree_item_for_context + if not item: + return + full = self.build_full_tag(item) + ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{full}'?") + if not ans: + return + tag_name = self.tree.item(item, "text") + parent_id = self.tree.parent(item) + self.tree.delete(item) + self.states.pop(item, None) + + if parent_id == self.root_id: + self.tagmanager.remove_category(tag_name) + else: + category = self.tree.item(parent_id, "text") + self.tagmanager.remove_tag(category, tag_name) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_bar.config(text=f"Smazán tag: {full}") + + # ================================================== + # TREE HELPERS + # ================================================== + def build_full_tag(self, item_id): + parts = [] + cur = item_id + while cur and cur != self.root_id: + parts.append(self.tree.item(cur, "text")) + cur = self.tree.parent(cur) + parts.reverse() + return "/".join(parts) if parts else "" + + def get_checked_full_tags(self): + return {self.build_full_tag(i) for i, v in self.states.items() if v} + + def refresh_tree_tags(self): + for child in self.tree.get_children(self.root_id): + self.tree.delete(child) + + for category in self.tagmanager.get_categories(): + cat_id = self.tree.insert(self.root_id, "end", text=category, image=self.icons["tag"]) + self.states[cat_id] = False + for tag in self.tagmanager.get_tags_in_category(category): + tag_id = self.tree.insert(cat_id, "end", text=tag.name, image=self.icons["unchecked"]) + self.states[tag_id] = False + + self.tree.item(self.root_id, open=True) + + def get_checked_tags(self) -> List[Tag]: + tags: List[Tag] = [] + for item_id, checked in self.states.items(): + if not checked: + continue + parent_id = self.tree.parent(item_id) + if parent_id == self.root_id: + continue + category = self.tree.item(parent_id, "text") + name = self.tree.item(item_id, "text") + tags.append(Tag(category, name)) + return tags + + def _get_checked_recursive(self, item): + tags = [] + if self.states.get(item, False): + parent = self.tree.parent(item) + if parent and parent != self.root_id: + parent_text = self.tree.item(parent, "text") + text = self.tree.item(item, "text") + tags.append(f"{parent_text}/{text}") + for child in self.tree.get_children(item): + tags.extend(self._get_checked_recursive(child)) + return tags diff --git a/tests/test_image.py b/tests/test_image.py new file mode 100644 index 0000000..3ce500e --- /dev/null +++ b/tests/test_image.py @@ -0,0 +1,40 @@ +import sys, os +import tempfile +from pathlib import Path +import pytest + +# přidáme src do sys.path (pokud nespouštíš pytest s -m nebo PYTHONPATH=src) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) + +from core.image import load_icon +from PIL import Image, ImageTk +import tkinter as tk + + +@pytest.fixture(scope="module") +def tk_root(): + """Fixture pro inicializaci Tkinteru (nutné pro ImageTk).""" + root = tk.Tk() + yield root + root.destroy() + + +def test_load_icon_returns_photoimage(tk_root): + # vytvoříme dočasný obrázek + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + # vytvoříme 100x100 červený obrázek + img = Image.new("RGB", (100, 100), color="red") + img.save(tmp_path) + + icon = load_icon(tmp_path) + + # musí být PhotoImage + assert isinstance(icon, ImageTk.PhotoImage) + + # ověříme velikost 16x16 + assert icon.width() == 16 + assert icon.height() == 16 + finally: + tmp_path.unlink(missing_ok=True) \ No newline at end of file