Merge branch 'devel' into release

This commit is contained in:
2025-10-03 17:31:36 +02:00
35 changed files with 1382 additions and 1 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.venv
__pycache__
.pytest_cache

10
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"editor.rulers": [
80
],
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
install required modules to enviroment:
pip install -r requirements.txt

View File

@@ -1 +1,19 @@
print("Test")
# 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()

12
config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"ignore_patterns": [
"*.png",
"*.jpg",
"*.mp3",
"*/M/*",
"*/L/*",
"*/Ostatní/*",
"*.hidden*"
],
"last_folder": "/media/veracrypt3"
}

View File

@@ -0,0 +1,9 @@
{
"new": true,
"ignored": false,
"tags": [
"Rozlišení/4K",
"Rozlišení/FullHD"
],
"date": null
}

View File

@@ -0,0 +1,8 @@
{
"new": true,
"ignored": false,
"tags": [
"Rozlišení/4K"
],
"date": "2025-09-15"
}

BIN
data/samples/50.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

File diff suppressed because one or more lines are too long

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
pillow

0
src/__init__.py Normal file
View File

0
src/core/__init__.py Normal file
View File

22
src/core/config.py Normal file
View File

@@ -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)

4
src/core/constants.py Normal file
View File

@@ -0,0 +1,4 @@
# src/core/constants.py
VERSION = "v1.0.2"
APP_NAME = "Tagger"
APP_VIEWPORT = "1000x700"

100
src/core/file.py Normal file
View File

@@ -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()

104
src/core/file_manager.py Normal file
View File

@@ -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

20
src/core/list_manager.py Normal file
View File

@@ -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)

42
src/core/media_utils.py Normal file
View File

@@ -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}")

22
src/core/tag.py Normal file
View File

@@ -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))

36
src/core/tag_manager.py Normal file
View File

@@ -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, []))

7
src/core/utils.py Normal file
View File

@@ -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()]

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

0
src/ui/__init__.py Normal file
View File

711
src/ui/gui.py Normal file
View File

@@ -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("<Button-1>", self._on_toggle)
self._update_checkbox_look(cb)
self.checkbuttons[full_path] = cb
self.vars[full_path] = init
btn_frame = tk.Frame(self)
btn_frame.pack(pady=5)
tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5)
tk.Button(btn_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5)
self.transient(parent)
self.grab_set()
parent.wait_window(self)
def _on_toggle(self, event):
cb: tk.Checkbutton = event.widget
cur = cb.state_value
if cur == 0: # OFF → ON
cb.state_value = 1
elif cur == 1: # ON → OFF
cb.state_value = 0
elif cur == 2: # MIXED → ON
cb.state_value = 1
self._update_checkbox_look(cb)
return "break"
def _update_checkbox_look(self, cb: tk.Checkbutton):
"""Aktualizuje vizuál podle stavu."""
v = cb.state_value
if v == 0:
cb.deselect()
cb.config(fg="black")
elif v == 1:
cb.select()
cb.config(fg="blue")
elif v == 2:
cb.deselect() # mixed = nezaškrtnuté, ale červený text
cb.config(fg="red")
def on_ok(self):
self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()}
self.destroy()
class App:
def __init__(self, filehandler: FileManager, tagmanager: TagManager):
self.states = {}
self.listbox_map: dict[int, list[File]] = {}
self.selected_tree_item_for_context = None
self.selected_list_index_for_context = None
self.filehandler = filehandler
self.tagmanager = tagmanager
self.list_manager = ListManager()
# tady jen připravíme proměnnou, ale nevytváříme BooleanVar!
self.hide_ignored_var = None
self.filter_text = ""
self.show_full_path = False
self.sort_mode = "name"
self.sort_order = "asc"
self.filehandler.on_files_changed = self.update_files_from_manager
def detect_video_resolution(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
count = 0
for f in files:
try:
path = str(f.file_path)
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=height", "-of", "csv=p=0", path],
capture_output=True,
text=True,
check=True
)
height_str = result.stdout.strip()
if not height_str.isdigit():
continue
height = int(height_str)
tag_name = f"{height}p"
tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name)
f.add_tag(tag_obj)
count += 1
except Exception as e:
print(f"Chyba u {f.filename}: {e}")
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Přiřazeno rozlišení tagů k {count} souborům")
# ==================================================
# MAIN GUI
# ==================================================
def main(self):
root = tk.Tk()
root.title(APP_NAME + " " + VERSION)
root.geometry(APP_VIEWPORT)
self.root = root
# teď už máme root, takže můžeme vytvořit BooleanVar
self.hide_ignored_var = tk.BooleanVar(value=False, master=root)
last = self.filehandler.config.get("last_folder")
if last:
try:
self.filehandler.append(Path(last))
except Exception:
pass
# ---- Ikony
unchecked = load_icon("src/resources/images/32/32_unchecked.png")
checked = load_icon("src/resources/images/32/32_checked.png")
tag_icon = load_icon("src/resources/images/32/32_tag.png")
self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon}
root.unchecked_img = unchecked
root.checked_img = checked
root.tag_img = tag_icon
# ---- Layout
menu_bar = tk.Menu(root)
root.config(menu=menu_bar)
file_menu = tk.Menu(menu_bar, tearoff=0)
file_menu.add_command(label="Open Folder...", command=self.open_folder_dialog)
file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=root.quit)
view_menu = tk.Menu(menu_bar, tearoff=0)
view_menu.add_checkbutton(
label="Skrýt ignorované",
variable=self.hide_ignored_var,
command=self.toggle_hide_ignored
)
function_menu = tk.Menu(menu_bar, tearoff=0)
function_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
function_menu.add_command(label="Detekovat rozlišení u videí", command=self.detect_video_resolution)
menu_bar.add_cascade(label="Soubor", menu=file_menu)
menu_bar.add_cascade(label="Pohled", menu=view_menu)
menu_bar.add_cascade(label="Funkce", menu=function_menu)
main_frame = tk.Frame(root)
main_frame.pack(fill="both", expand=True)
main_frame.columnconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=2)
main_frame.rowconfigure(0, weight=1)
# ---- Tree (left)
self.tree = ttk.Treeview(main_frame)
self.tree.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
self.tree.bind("<Button-1>", self.on_tree_left_click)
self.tree.bind("<Button-3>", self.on_tree_right_click)
# ---- Right side (filter + listbox)
right_frame = tk.Frame(main_frame)
right_frame.grid(row=0, column=1, sticky="nsew", padx=4, pady=4)
right_frame.rowconfigure(1, weight=1)
right_frame.columnconfigure(0, weight=1)
# Filter + buttons row
filter_frame = tk.Frame(right_frame)
filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 4))
filter_frame.columnconfigure(0, weight=1)
self.filter_entry = tk.Entry(filter_frame)
self.filter_entry.grid(row=0, column=0, sticky="ew")
self.filter_entry.bind("<KeyRelease>", lambda e: self.on_filter_changed())
self.btn_toggle_path = tk.Button(filter_frame, text="Name", width=6, command=self.toggle_show_path)
self.btn_toggle_path.grid(row=0, column=1, padx=2)
self.btn_toggle_sortmode = tk.Button(filter_frame, text="Sort:Name", width=8, command=self.toggle_sort_mode)
self.btn_toggle_sortmode.grid(row=0, column=2, padx=2)
self.btn_toggle_order = tk.Button(filter_frame, text="ASC", width=5, command=self.toggle_sort_order)
self.btn_toggle_order.grid(row=0, column=3, padx=2)
# Listbox + scrollbar
self.listbox = tk.Listbox(right_frame, selectmode="extended")
self.listbox.grid(row=1, column=0, sticky="nsew")
self.listbox.bind("<Double-1>", self.on_list_double)
self.listbox.bind("<Button-3>", self.on_list_right_click)
lb_scroll = tk.Scrollbar(right_frame, orient="vertical", command=self.listbox.yview)
lb_scroll.grid(row=1, column=1, sticky="ns")
self.listbox.config(yscrollcommand=lb_scroll.set)
# ---- Status bar
self.status_bar = tk.Label(root, text="Připraven", anchor="w", relief="sunken")
self.status_bar.pack(side="bottom", fill="x")
# ---- Context menus
self.tree_menu = tk.Menu(root, tearoff=0)
self.tree_menu.add_command(label="Nový štítek", command=self.tree_add_tag)
self.tree_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag)
self.list_menu = tk.Menu(root, tearoff=0)
self.list_menu.add_command(label="Otevřít soubor", command=self.list_open_file)
self.list_menu.add_command(label="Smazat z indexu", command=self.list_remove_file)
self.list_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
self.list_menu.add_command(label="Přiřadit štítek", command=self.assign_tag_to_selected_bulk)
# ---- Root node
root_id = self.tree.insert("", "end", text="Štítky", image=self.icons["tag"])
self.tree.item(root_id, open=True)
self.root_id = root_id
# ⚡ refresh při startu
self.refresh_tree_tags()
self.update_files_from_manager(self.filehandler.filelist)
root.mainloop()
# ==================================================
# FILTER + SORT TOGGLES
# ==================================================
def set_ignore_patterns(self):
current = ", ".join(self.filehandler.config.get("ignore_patterns", []))
s = simpledialog.askstring("Ignore patterns", "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", initialvalue=current)
if s is None:
return
patterns = [p.strip() for p in s.split(",") if p.strip()]
self.filehandler.config["ignore_patterns"] = patterns
save_config(self.filehandler.config)
self.update_files_from_manager(self.filehandler.filelist)
def toggle_hide_ignored(self):
self.update_files_from_manager(self.filehandler.filelist)
def on_filter_changed(self):
self.filter_text = self.filter_entry.get().strip().lower()
self.update_files_from_manager(self.filehandler.filelist)
def toggle_show_path(self):
self.show_full_path = not self.show_full_path
self.btn_toggle_path.config(text="Path" if self.show_full_path else "Name")
self.update_files_from_manager(self.filehandler.filelist)
def toggle_sort_mode(self):
self.sort_mode = "date" if self.sort_mode == "name" else "name"
self.btn_toggle_sortmode.config(text=f"Sort:{self.sort_mode.capitalize()}")
self.update_files_from_manager(self.filehandler.filelist)
def toggle_sort_order(self):
self.sort_order = "desc" if self.sort_order == "asc" else "asc"
self.btn_toggle_order.config(text=self.sort_order.upper())
self.update_files_from_manager(self.filehandler.filelist)
# ==================================================
# FILE REFRESH + MAP
# ==================================================
def update_files_from_manager(self, filelist=None):
if filelist is None:
filelist = self.filehandler.filelist
# filtr tagy
checked_tags = self.get_checked_tags()
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
# filtr text
if self.filter_text:
filtered_files = [
f for f in filtered_files
if self.filter_text in f.filename.lower() or
(self.show_full_path and self.filter_text in str(f.file_path).lower())
]
if self.hide_ignored_var and self.hide_ignored_var.get():
filtered_files = [
f for f in filtered_files
if "Stav/Ignorované" not in {t.full_path for t in f.tags}
]
# řazení
reverse = (self.sort_order == "desc")
if self.sort_mode == "name":
filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse)
elif self.sort_mode == "date":
filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse)
# naplníme listbox
self.listbox.delete(0, "end")
self.listbox_map = {}
for i, f in enumerate(filtered_files):
if self.show_full_path:
display = str(f.file_path)
else:
display = f.filename
if f.date:
display = f"{display}{f.date}"
self.listbox.insert("end", display)
self.listbox_map[i] = [f]
self.status_bar.config(text=f"Zobrazeno {self.listbox.size()} položek")
# ==================================================
# GET SELECTED FILES
# ==================================================
def get_selected_files_objects(self):
indices = self.listbox.curselection()
files = []
for idx in indices:
files.extend(self.listbox_map.get(idx, []))
return files
# ==================================================
# ASSIGN TAG (jednoduchý)
# ==================================================
def assign_tag_to_selected(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
all_tags: List[Tag] = []
for category in self.tagmanager.get_categories():
for tag in self.tagmanager.get_tags_in_category(category):
all_tags.append(tag)
if not all_tags:
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
return
tag_strings = [tag.full_path for tag in all_tags]
dialog = TagSelectionDialog(self.root, tag_strings)
selected_tag_strings = dialog.selected_tags
if not selected_tag_strings:
self.status_bar.config(text="Nebyl vybrán žádný tag")
return
selected_tags: list[Tag] = []
for full_tag in selected_tag_strings:
if "/" in full_tag:
category, name = full_tag.split("/", 1)
selected_tags.append(self.tagmanager.add_tag(category, name))
for tag in selected_tags:
self.filehandler.assign_tag_to_file_objects(files, tag)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Přiřazeny tagy: {', '.join(selected_tag_strings)}")
# ==================================================
# ASSIGN TAG (pokročilé pro více souborů - tri-state)
# ==================================================
def assign_tag_to_selected_bulk(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
all_tags: List[Tag] = []
for category in self.tagmanager.get_categories():
for tag in self.tagmanager.get_tags_in_category(category):
all_tags.append(tag)
if not all_tags:
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
return
dialog = MultiFileTagAssignDialog(self.root, all_tags, files)
result = getattr(dialog, "result", None)
if result is None:
self.status_bar.config(text="Přiřazení zrušeno")
return
for full_path, state in result.items():
if state == 1:
if "/" in full_path:
category, name = full_path.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
self.filehandler.assign_tag_to_file_objects(files, tag_obj)
elif state == 0:
if "/" in full_path:
category, name = full_path.split("/", 1)
from src.core.tag import Tag as TagClass
tag_obj = TagClass(category, name)
self.filehandler.remove_tag_from_file_objects(files, tag_obj)
else:
continue
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text="Hromadné přiřazení tagů dokončeno")
# ==================================================
# SET DATE FOR SELECTED FILES
# ==================================================
def set_date_for_selected(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):"
date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root)
if date_str is None:
return
for f in files:
f.set_date(date_str if date_str != "" else None)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)")
# ==================================================
# DOUBLE CLICK OPEN
# ==================================================
def on_list_double(self, event):
for f in self.get_selected_files_objects():
self.open_file(f.file_path)
# ==================================================
# OPEN FILE
# ==================================================
def open_file(self, path):
try:
if sys.platform.startswith("win"):
os.startfile(path)
elif sys.platform.startswith("darwin"):
subprocess.call(["open", path])
else:
subprocess.call(["xdg-open", path])
self.status_bar.config(text=f"Otevírám: {path}")
except Exception as e:
messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}")
# ==================================================
# LIST CONTEXT MENU
# ==================================================
def on_list_right_click(self, event):
idx = self.listbox.nearest(event.y)
if idx is None:
return
# pokud položka není součástí aktuálního výběru, přidáme ji
if idx not in self.listbox.curselection():
self.listbox.selection_set(idx)
self.selected_list_index_for_context = idx
self.list_menu.tk_popup(event.x_root, event.y_root)
def list_open_file(self):
for f in self.get_selected_files_objects():
self.open_file(f.file_path)
def list_remove_file(self):
files = self.get_selected_files_objects()
if not files:
return
ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?")
if ans:
for f in files:
if f in self.filehandler.filelist:
self.filehandler.filelist.remove(f)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Odstraněno {len(files)} souborů z indexu")
# ==================================================
# OPEN FOLDER
# ==================================================
def open_folder_dialog(self):
folder = filedialog.askdirectory(title="Vyber složku pro sledování")
if not folder:
return
folder_path = Path(folder)
try:
self.filehandler.append(folder_path)
for f in self.filehandler.filelist:
if f.tags and f.tagmanager:
for t in f.tags:
f.tagmanager.add_tag(t.category, t.name)
self.status_bar.config(text=f"Přidána složka: {folder_path}")
self.refresh_tree_tags()
self.update_files_from_manager(self.filehandler.filelist)
except Exception as e:
messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}")
# ==================================================
# TREE EVENTS
# ==================================================
def on_tree_left_click(self, event):
region = self.tree.identify("region", event.x, event.y)
if region not in ("tree", "icon"):
return
item_id = self.tree.identify_row(event.y)
if not item_id:
return
parent_id = self.tree.parent(item_id)
if parent_id == "" or parent_id == self.root_id:
is_open = self.tree.item(item_id, "open")
self.tree.item(item_id, open=not is_open)
return
self.states[item_id] = not self.states.get(item_id, False)
self.tree.item(
item_id,
image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]
)
self.status_bar.config(
text=f"Tag {'checked' if self.states[item_id] else 'unchecked'}: {self.build_full_tag(item_id)}"
)
checked_tags = self.get_checked_tags()
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
self.update_files_from_manager(filtered_files)
def on_tree_right_click(self, event):
item_id = self.tree.identify_row(event.y)
if item_id:
self.selected_tree_item_for_context = item_id
self.tree.selection_set(item_id)
self.tree_menu.tk_popup(event.x_root, event.y_root)
else:
menu = tk.Menu(self.root, tearoff=0)
menu.add_command(label="Nový top-level tag", command=lambda: self.tree_add_tag(background=True))
menu.tk_popup(event.x_root, event.y_root)
# ==================================================
# TREE TAG CRUD
# ==================================================
def tree_add_tag(self, background=False):
name = simpledialog.askstring("Nový tag", "Název tagu:")
if not name:
return
parent = self.selected_tree_item_for_context if not background else self.root_id
new_id = self.tree.insert(parent, "end", text=name, image=self.icons["unchecked"])
self.states[new_id] = False
if parent == self.root_id:
category = name
self.tagmanager.add_category(category)
self.tree.item(new_id, image=self.icons["tag"])
else:
category = self.tree.item(parent, "text")
self.tagmanager.add_tag(category, name)
self.status_bar.config(text=f"Vytvořen tag: {self.build_full_tag(new_id)}")
def tree_delete_tag(self):
item = self.selected_tree_item_for_context
if not item:
return
full = self.build_full_tag(item)
ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{full}'?")
if not ans:
return
tag_name = self.tree.item(item, "text")
parent_id = self.tree.parent(item)
self.tree.delete(item)
self.states.pop(item, None)
if parent_id == self.root_id:
self.tagmanager.remove_category(tag_name)
else:
category = self.tree.item(parent_id, "text")
self.tagmanager.remove_tag(category, tag_name)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Smazán tag: {full}")
# ==================================================
# TREE HELPERS
# ==================================================
def build_full_tag(self, item_id):
parts = []
cur = item_id
while cur and cur != self.root_id:
parts.append(self.tree.item(cur, "text"))
cur = self.tree.parent(cur)
parts.reverse()
return "/".join(parts) if parts else ""
def get_checked_full_tags(self):
return {self.build_full_tag(i) for i, v in self.states.items() if v}
def refresh_tree_tags(self):
for child in self.tree.get_children(self.root_id):
self.tree.delete(child)
for category in self.tagmanager.get_categories():
cat_id = self.tree.insert(self.root_id, "end", text=category, image=self.icons["tag"])
self.states[cat_id] = False
for tag in self.tagmanager.get_tags_in_category(category):
tag_id = self.tree.insert(cat_id, "end", text=tag.name, image=self.icons["unchecked"])
self.states[tag_id] = False
self.tree.item(self.root_id, open=True)
def get_checked_tags(self) -> List[Tag]:
tags: List[Tag] = []
for item_id, checked in self.states.items():
if not checked:
continue
parent_id = self.tree.parent(item_id)
if parent_id == self.root_id:
continue
category = self.tree.item(parent_id, "text")
name = self.tree.item(item_id, "text")
tags.append(Tag(category, name))
return tags
def _get_checked_recursive(self, item):
tags = []
if self.states.get(item, False):
parent = self.tree.parent(item)
if parent and parent != self.root_id:
parent_text = self.tree.item(parent, "text")
text = self.tree.item(item, "text")
tags.append(f"{parent_text}/{text}")
for child in self.tree.get_children(item):
tags.extend(self._get_checked_recursive(child))
return tags

40
tests/test_image.py Normal file
View File

@@ -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)