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