""" Modern qBittorrent-style GUI for Tagger """ 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_global_config from src.core.hardlink_manager import HardlinkManager # qBittorrent-inspired color scheme COLORS = { "bg": "#ffffff", "sidebar_bg": "#f5f5f5", "toolbar_bg": "#f0f0f0", "selected": "#0078d7", "selected_text": "#ffffff", "border": "#d0d0d0", "status_bg": "#f8f8f8", "text": "#000000", } # Tag category colors TAG_COLORS = [ "#e74c3c", # red "#3498db", # blue "#2ecc71", # green "#f39c12", # orange "#9b59b6", # purple "#1abc9c", # teal "#e91e63", # pink "#00bcd4", # cyan ] # Fixed colors for default categories DEFAULT_CATEGORY_COLORS = { "Hodnocení": "#f1c40f", # gold/yellow for stars "Barva": "#95a5a6", # gray for color category } # Categories where only one tag can be selected (exclusive/radio behavior) EXCLUSIVE_CATEGORIES = {"Hodnocení"} class MultiFileTagAssignDialog(tk.Toplevel): """Dialog for bulk tag assignment to multiple files""" def __init__(self, parent, all_tags: List[Tag], files: List[File], category_colors: dict = None): super().__init__(parent) self.title("Přiřadit tagy k vybraným souborům") self.result = None 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 self.category_colors = category_colors or {} self.category_checkbuttons: dict[str, list] = {} # category -> list of checkbuttons self.geometry("500x600") self.minsize(400, 400) self.configure(bg=COLORS["bg"]) tk.Label(self, text=f"Vybráno souborů: {len(files)}", bg=COLORS["bg"], font=("Arial", 11, "bold")).pack(pady=10) # Scrollable frame canvas = tk.Canvas(self, bg=COLORS["bg"]) scrollbar = ttk.Scrollbar(self, orient="vertical", command=canvas.yview) frame = tk.Frame(canvas, bg=COLORS["bg"]) frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side="left", fill="both", expand=True, padx=10) scrollbar.pack(side="right", fill="y") # Enable mousewheel scrolling (only when dialog is active) def on_mousewheel(event): if canvas.winfo_exists(): canvas.yview_scroll(int(-1*(event.delta/120)), "units") def on_scroll_up(event): if canvas.winfo_exists(): canvas.yview_scroll(-1, "units") def on_scroll_down(event): if canvas.winfo_exists(): canvas.yview_scroll(1, "units") canvas.bind("", on_mousewheel) canvas.bind("", on_scroll_up) canvas.bind("", on_scroll_down) frame.bind("", on_mousewheel) frame.bind("", on_scroll_up) frame.bind("", on_scroll_down) file_tag_sets = [{t.full_path for t in f.tags} for f in files] # Group by category tags_by_category = {} for full_path, tag in self.tags_by_full.items(): if tag.category not in tags_by_category: tags_by_category[tag.category] = [] tags_by_category[tag.category].append((full_path, tag)) for category in sorted(tags_by_category.keys()): color = self.category_colors.get(category, "#333333") is_exclusive = category in EXCLUSIVE_CATEGORIES exclusive_note = " (pouze jedno)" if is_exclusive else "" cat_label = tk.Label(frame, text=f"▸ {category}{exclusive_note}", bg=COLORS["bg"], fg=color, font=("Arial", 10, "bold")) cat_label.pack(fill="x", anchor="w", pady=(12, 4)) self.category_checkbuttons[category] = [] for full_path, tag in sorted(tags_by_category[category], key=lambda x: x[1].name): have_count = sum(1 for s in file_tag_sets if full_path in s) if have_count == 0: init = 0 elif have_count == len(files): init = 1 else: init = 2 # mixed cb = tk.Checkbutton(frame, text=f" {tag.name}", anchor="w", bg=COLORS["bg"], font=("Arial", 10)) cb.state_value = init cb.full_path = full_path cb.tag_color = color cb.category = category cb.pack(fill="x", anchor="w", padx=20) cb.bind("", self._on_toggle) self._update_checkbox_look(cb) self.checkbuttons[full_path] = cb self.vars[full_path] = init self.category_checkbuttons[category].append(cb) btn_frame = tk.Frame(self, bg=COLORS["bg"]) btn_frame.pack(pady=15) tk.Button(btn_frame, text="OK", command=self.on_ok, width=12, font=("Arial", 10)).pack(side="left", padx=5) tk.Button(btn_frame, text="Zrušit", command=self.destroy, width=12, font=("Arial", 10)).pack(side="left", padx=5) self.transient(parent) self.grab_set() parent.wait_window(self) def _on_toggle(self, event): cb: tk.Checkbutton = event.widget category = cb.category cur = cb.state_value # For exclusive categories, uncheck others first if category in EXCLUSIVE_CATEGORIES: if cur == 0 or cur == 2: # turning on # Uncheck all others in this category for other_cb in self.category_checkbuttons.get(category, []): if other_cb != cb and other_cb.state_value != 0: other_cb.state_value = 0 self._update_checkbox_look(other_cb) cb.state_value = 1 else: # turning off cb.state_value = 0 else: # Normal toggle behavior if cur == 0: cb.state_value = 1 elif cur == 1: cb.state_value = 0 elif cur == 2: cb.state_value = 1 self._update_checkbox_look(cb) return "break" def _update_checkbox_look(self, cb: tk.Checkbutton): v = cb.state_value color = getattr(cb, 'tag_color', '#333333') if v == 0: cb.deselect() cb.config(fg="#666666") elif v == 1: cb.select() cb.config(fg=color) elif v == 2: cb.deselect() cb.config(fg="#cc6600") # orange for mixed def on_ok(self): self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()} self.destroy() class App: def __init__(self, filehandler: FileManager, tagmanager: TagManager): self.filehandler = filehandler self.tagmanager = tagmanager self.list_manager = ListManager() # State self.states = {} self.file_items = {} # Treeview item_id -> File object mapping self.selected_tree_item_for_context = None self.hide_ignored_var = None self.filter_text = "" self.show_full_path = False self.sort_mode = "name" self.sort_order = "asc" self.category_colors = {} # category -> color mapping self.filehandler.on_files_changed = self.update_files_from_manager def _on_close(self): """Save window geometry and close""" # Check if maximized is_maximized = self.root.state() == 'zoomed' self.filehandler.global_config["window_maximized"] = is_maximized # Save geometry only when not maximized if not is_maximized: self.filehandler.global_config["window_geometry"] = self.root.geometry() save_global_config(self.filehandler.global_config) self.root.destroy() def main(self): root = tk.Tk() root.title(f"{APP_NAME} {VERSION}") # Load window geometry from global config geometry = self.filehandler.global_config.get("window_geometry", APP_VIEWPORT) root.geometry(geometry) if self.filehandler.global_config.get("window_maximized", False): root.state('zoomed') root.configure(bg=COLORS["bg"]) self.root = root # Bind window close to save geometry root.protocol("WM_DELETE_WINDOW", self._on_close) self.hide_ignored_var = tk.BooleanVar(value=False, master=root) # Load last folder last = self.filehandler.global_config.get("last_folder") if last: try: self.filehandler.append(Path(last)) except Exception: pass # Load icons self._load_icons() # Build UI self._create_menu() self._create_toolbar() self._create_main_layout() self._create_status_bar() self._create_context_menus() self._bind_shortcuts() # Initial refresh self.refresh_sidebar() self.update_files_from_manager(self.filehandler.filelist) root.mainloop() def _load_icons(self): """Load application icons""" try: unchecked = load_icon("src/resources/images/32/32_unchecked.png") checked = load_icon("src/resources/images/32/32_checked.png") tag_icon = load_icon("src/resources/images/32/32_tag.png") self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon} self.root.unchecked_img = unchecked self.root.checked_img = checked self.root.tag_img = tag_icon except Exception as e: print(f"Warning: Could not load icons: {e}") self.icons = {"unchecked": None, "checked": None, "tag": None} def _create_menu(self): """Create menu bar""" menu_bar = tk.Menu(self.root) self.root.config(menu=menu_bar) # File menu file_menu = tk.Menu(menu_bar, tearoff=0) file_menu.add_command(label="Open Folder... (Ctrl+O)", command=self.open_folder_dialog) file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns) file_menu.add_separator() file_menu.add_command(label="Exit (Ctrl+Q)", command=self.root.quit) # View menu view_menu = tk.Menu(menu_bar, tearoff=0) view_menu.add_checkbutton( label="Skrýt ignorované", variable=self.hide_ignored_var, command=self.toggle_hide_ignored ) view_menu.add_command(label="Refresh (F5)", command=self.refresh_all) # Tools menu tools_menu = tk.Menu(menu_bar, tearoff=0) tools_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) tools_menu.add_command(label="Detekovat rozlišení videí", command=self.detect_video_resolution) tools_menu.add_command(label="Přiřadit tagy (Ctrl+T)", command=self.assign_tag_to_selected_bulk) tools_menu.add_separator() tools_menu.add_command(label="Nastavit hardlink složku...", command=self.configure_hardlink_folder) tools_menu.add_command(label="Aktualizovat hardlink strukturu", command=self.update_hardlink_structure) tools_menu.add_command(label="Vytvořit hardlink strukturu...", command=self.create_hardlink_structure) menu_bar.add_cascade(label="Soubor", menu=file_menu) menu_bar.add_cascade(label="Pohled", menu=view_menu) menu_bar.add_cascade(label="Nástroje", menu=tools_menu) def _create_toolbar(self): """Create toolbar with buttons""" toolbar = tk.Frame(self.root, bg=COLORS["toolbar_bg"], height=40, relief=tk.RAISED, bd=1) toolbar.pack(side=tk.TOP, fill=tk.X) # Buttons tk.Button(toolbar, text="📁 Otevřít složku", command=self.open_folder_dialog, relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=5, pady=5) tk.Button(toolbar, text="🔄 Obnovit", command=self.refresh_all, relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) tk.Button(toolbar, text="🏷️ Nový tag", command=lambda: self.tree_add_tag(background=True), relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) tk.Button(toolbar, text="📅 Nastavit datum", command=self.set_date_for_selected, relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) # Search box search_frame = tk.Frame(toolbar, bg=COLORS["toolbar_bg"]) search_frame.pack(side=tk.RIGHT, padx=10, pady=5) tk.Label(search_frame, text="🔍", bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT) self.search_var = tk.StringVar() self.search_var.trace('w', lambda *args: self.on_filter_changed()) search_entry = tk.Entry(search_frame, textvariable=self.search_var, width=25) search_entry.pack(side=tk.LEFT, padx=5) def _create_main_layout(self): """Create main split layout""" # Main container main_container = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, sashwidth=5, bg=COLORS["border"]) main_container.pack(fill=tk.BOTH, expand=True) # Left sidebar (tags) self._create_sidebar(main_container) # Right panel (files table) self._create_file_panel(main_container) def _create_sidebar(self, parent): """Create left sidebar with tag tree""" sidebar_frame = tk.Frame(parent, bg=COLORS["sidebar_bg"], width=250) # Sidebar header header = tk.Frame(sidebar_frame, bg=COLORS["sidebar_bg"]) header.pack(fill=tk.X, padx=5, pady=5) tk.Label(header, text="📂 Štítky", font=("Arial", 10, "bold"), bg=COLORS["sidebar_bg"]).pack(side=tk.LEFT) # Tag tree tree_frame = tk.Frame(sidebar_frame) tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.tag_tree = ttk.Treeview(tree_frame, selectmode="browse", show="tree") self.tag_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) tree_scroll = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tag_tree.yview) tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) self.tag_tree.config(yscrollcommand=tree_scroll.set) # Bind events self.tag_tree.bind("", self.on_tree_left_click) self.tag_tree.bind("", self.on_tree_right_click) parent.add(sidebar_frame) def _create_file_panel(self, parent): """Create right panel with file table""" file_frame = tk.Frame(parent, bg=COLORS["bg"]) # Control panel control_frame = tk.Frame(file_frame, bg=COLORS["bg"]) control_frame.pack(fill=tk.X, padx=5, pady=5) # View options tk.Checkbutton(control_frame, text="Plná cesta", variable=tk.BooleanVar(), command=self.toggle_show_path, bg=COLORS["bg"]).pack(side=tk.LEFT, padx=5) # File table table_frame = tk.Frame(file_frame) table_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # Define columns columns = ("name", "date", "tags", "size") self.file_table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended") # Column headers with sort commands self.file_table.heading("name", text="📄 Název ▲", command=lambda: self.sort_by_column("name")) self.file_table.heading("date", text="📅 Datum", command=lambda: self.sort_by_column("date")) self.file_table.heading("tags", text="🏷️ Štítky") self.file_table.heading("size", text="💾 Velikost", command=lambda: self.sort_by_column("size")) # Column widths self.file_table.column("name", width=300) self.file_table.column("date", width=100) self.file_table.column("tags", width=200) self.file_table.column("size", width=80) # Scrollbars vsb = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.file_table.yview) hsb = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.file_table.xview) self.file_table.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) self.file_table.grid(row=0, column=0, sticky="nsew") vsb.grid(row=0, column=1, sticky="ns") hsb.grid(row=1, column=0, sticky="ew") table_frame.grid_rowconfigure(0, weight=1) table_frame.grid_columnconfigure(0, weight=1) # Bind events self.file_table.bind("", self.on_file_double_click) self.file_table.bind("", self.on_file_right_click) self.file_table.bind("<>", self.on_selection_changed) parent.add(file_frame) def _create_status_bar(self): """Create status bar at bottom""" status_frame = tk.Frame(self.root, bg=COLORS["status_bg"], relief=tk.SUNKEN, bd=1) status_frame.pack(side=tk.BOTTOM, fill=tk.X) # Left side - status message self.status_label = tk.Label(status_frame, text="Připraven", anchor=tk.W, bg=COLORS["status_bg"], padx=10) self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) # Right side - file count self.file_count_label = tk.Label(status_frame, text="0 souborů", anchor=tk.E, bg=COLORS["status_bg"], padx=10) self.file_count_label.pack(side=tk.RIGHT) # Selected size self.selected_size_label = tk.Label(status_frame, text="", anchor=tk.E, bg=COLORS["status_bg"], padx=10) self.selected_size_label.pack(side=tk.RIGHT) # Selected count self.selected_count_label = tk.Label(status_frame, text="", anchor=tk.E, bg=COLORS["status_bg"], padx=10) self.selected_count_label.pack(side=tk.RIGHT) def _create_context_menus(self): """Create context menus""" # Tag context menu self.tag_menu = tk.Menu(self.root, tearoff=0) self.tag_menu.add_command(label="Nový štítek", command=self.tree_add_tag) self.tag_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag) # File context menu self.file_menu = tk.Menu(self.root, tearoff=0) self.file_menu.add_command(label="Otevřít soubor", command=self.open_selected_files) self.file_menu.add_command(label="Přiřadit štítky (Ctrl+T)", command=self.assign_tag_to_selected_bulk) self.file_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) self.file_menu.add_separator() self.file_menu.add_command(label="Smazat z indexu (Del)", command=self.remove_selected_files) def _bind_shortcuts(self): """Bind keyboard shortcuts""" self.root.bind("", lambda e: self.open_folder_dialog()) self.root.bind("", lambda e: self.root.quit()) self.root.bind("", lambda e: self.assign_tag_to_selected_bulk()) self.root.bind("", lambda e: self.set_date_for_selected()) self.root.bind("", lambda e: self.search_var.get()) # Focus search self.root.bind("", lambda e: self.refresh_all()) self.root.bind("", lambda e: self.remove_selected_files()) # ================================================== # SIDEBAR / TAG TREE METHODS # ================================================== def refresh_sidebar(self): """Refresh tag tree in sidebar""" # Clear tree for item in self.tag_tree.get_children(): self.tag_tree.delete(item) # Reset tag item mapping self.tag_tree_items = {} # full_path -> tree item_id # Count files per tag (from all files) tag_counts = {} for f in self.filehandler.filelist: for t in f.tags: tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 # Add root total_files = len(self.filehandler.filelist) root_id = self.tag_tree.insert("", "end", text=f"📂 Všechny soubory ({total_files})", image=self.icons.get("tag")) self.tag_tree.item(root_id, open=True) self.root_tag_id = root_id # Assign colors to categories categories = self.tagmanager.get_categories() color_index = 0 for category in categories: if category not in self.category_colors: # Use predefined color for default categories, otherwise cycle through TAG_COLORS if category in DEFAULT_CATEGORY_COLORS: self.category_colors[category] = DEFAULT_CATEGORY_COLORS[category] else: self.category_colors[category] = TAG_COLORS[color_index % len(TAG_COLORS)] color_index += 1 # Add categories and tags for category in categories: color = self.category_colors.get(category, "#333333") cat_id = self.tag_tree.insert(root_id, "end", text=f"📁 {category}", image=self.icons.get("tag"), tags=(f"cat_{category}",)) self.states[cat_id] = False for tag in self.tagmanager.get_tags_in_category(category): count = tag_counts.get(tag.full_path, 0) count_str = f" ({count})" if count > 0 else "" tag_id = self.tag_tree.insert(cat_id, "end", text=f" {tag.name}{count_str}", image=self.icons.get("unchecked"), tags=(f"tag_{category}",)) self.states[tag_id] = False self.tag_tree_items[tag.full_path] = (tag_id, tag.name) # Apply color to category tags self.tag_tree.tag_configure(f"cat_{category}", foreground=color) self.tag_tree.tag_configure(f"tag_{category}", foreground=color) def update_tag_counts(self, filtered_files): """Update tag counts in sidebar based on filtered files""" if not hasattr(self, 'tag_tree_items'): return # Count files per tag from filtered files tag_counts = {} for f in filtered_files: for t in f.tags: tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 # Update each tag item text for full_path, (item_id, tag_name) in self.tag_tree_items.items(): count = tag_counts.get(full_path, 0) count_str = f" ({count})" if count > 0 else "" # Preserve the checkbox state current_text = f" {tag_name}{count_str}" self.tag_tree.item(item_id, text=current_text) # Update root count total = len(filtered_files) self.tag_tree.item(self.root_tag_id, text=f"📂 Všechny soubory ({total})") def on_tree_left_click(self, event): """Handle left click on tag tree""" region = self.tag_tree.identify("region", event.x, event.y) if region not in ("tree", "icon"): return item_id = self.tag_tree.identify_row(event.y) if not item_id: return parent_id = self.tag_tree.parent(item_id) # Toggle folder open/close if parent_id == "" or parent_id == self.root_tag_id: is_open = self.tag_tree.item(item_id, "open") self.tag_tree.item(item_id, open=not is_open) return # Toggle tag checkbox self.states[item_id] = not self.states.get(item_id, False) self.tag_tree.item(item_id, image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]) # Update file list self.update_files_from_manager(self.filehandler.filelist) def on_tree_right_click(self, event): """Handle right click on tag tree""" item_id = self.tag_tree.identify_row(event.y) if item_id: self.selected_tree_item_for_context = item_id self.tag_tree.selection_set(item_id) self.tag_menu.tk_popup(event.x_root, event.y_root) def tree_add_tag(self, background=False): """Add new tag""" name = simpledialog.askstring("Nový tag", "Název tagu:") if not name: return parent = self.selected_tree_item_for_context if not background else self.root_tag_id new_id = self.tag_tree.insert(parent, "end", text=f" {name}", image=self.icons["unchecked"]) self.states[new_id] = False if parent == self.root_tag_id: self.tagmanager.add_category(name) self.tag_tree.item(new_id, image=self.icons["tag"]) else: category = self.tag_tree.item(parent, "text").replace("📁 ", "") self.tagmanager.add_tag(category, name) self.status_label.config(text=f"Vytvořen tag: {name}") def tree_delete_tag(self): """Delete selected tag""" item = self.selected_tree_item_for_context if not item: return name = self.tag_tree.item(item, "text").strip() ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{name}'?") if not ans: return parent_id = self.tag_tree.parent(item) self.tag_tree.delete(item) self.states.pop(item, None) if parent_id == self.root_tag_id: self.tagmanager.remove_category(name.replace("📁 ", "")) else: category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") self.tagmanager.remove_tag(category, name) self.update_files_from_manager(self.filehandler.filelist) self.status_label.config(text=f"Smazán tag: {name}") def get_checked_tags(self) -> List[Tag]: """Get list of checked tags""" tags = [] for item_id, checked in self.states.items(): if not checked: continue parent_id = self.tag_tree.parent(item_id) if parent_id == "" or parent_id == self.root_tag_id: continue category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") # Get tag name from stored mapping (not from text which includes count) tag_name = None for full_path, (stored_id, stored_name) in self.tag_tree_items.items(): if stored_id == item_id: tag_name = stored_name break if tag_name: tags.append(Tag(category, tag_name)) return tags # ================================================== # FILE TABLE METHODS # ================================================== def update_files_from_manager(self, filelist=None): """Update file table""" if filelist is None: filelist = self.filehandler.filelist # Filter by checked tags checked_tags = self.get_checked_tags() filtered_files = self.filehandler.filter_files_by_tags(checked_tags) # Filter by search text search_text = self.search_var.get().lower() if hasattr(self, 'search_var') else "" if search_text: filtered_files = [ f for f in filtered_files if search_text in f.filename.lower() or (self.show_full_path and search_text in str(f.file_path).lower()) ] # Filter ignored if self.hide_ignored_var and self.hide_ignored_var.get(): filtered_files = [ f for f in filtered_files if "Stav/Ignorované" not in {t.full_path for t in f.tags} ] # Sort reverse = (self.sort_order == "desc") if self.sort_mode == "name": filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse) elif self.sort_mode == "date": filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse) elif self.sort_mode == "size": filtered_files.sort(key=lambda f: f.file_path.stat().st_size if f.file_path.exists() else 0, reverse=reverse) # Clear table for item in self.file_table.get_children(): self.file_table.delete(item) self.file_items.clear() # Populate table for f in filtered_files: name = str(f.file_path) if self.show_full_path else f.filename date = f.date or "" tags = ", ".join([t.name for t in f.tags[:3]]) # Show first 3 tags if len(f.tags) > 3: tags += f" +{len(f.tags) - 3}" try: size = f.file_path.stat().st_size size_str = self._format_size(size) except: size_str = "?" item_id = self.file_table.insert("", "end", values=(name, date, tags, size_str)) self.file_items[item_id] = f # Update status self.file_count_label.config(text=f"{len(filtered_files)} souborů") self.status_label.config(text=f"Zobrazeno {len(filtered_files)} souborů") # Update tag counts in sidebar self.update_tag_counts(filtered_files) def _format_size(self, size_bytes): """Format file size""" for unit in ['B', 'KB', 'MB', 'GB']: if size_bytes < 1024.0: return f"{size_bytes:.1f} {unit}" size_bytes /= 1024.0 return f"{size_bytes:.1f} TB" def get_selected_files(self) -> List[File]: """Get selected files from table""" selected_items = self.file_table.selection() return [self.file_items[item] for item in selected_items if item in self.file_items] def on_selection_changed(self, event=None): """Update status bar when selection changes""" files = self.get_selected_files() count = len(files) if count == 0: self.selected_count_label.config(text="") self.selected_size_label.config(text="") else: self.selected_count_label.config(text=f"{count} vybráno") total_size = 0 for f in files: try: total_size += f.file_path.stat().st_size except: pass self.selected_size_label.config(text=f"[{self._format_size(total_size)}]") def on_file_double_click(self, event): """Handle double click on file""" files = self.get_selected_files() for f in files: self.open_file(f.file_path) def on_file_right_click(self, event): """Handle right click on file""" # Select item under cursor if not selected item = self.file_table.identify_row(event.y) if item and item not in self.file_table.selection(): self.file_table.selection_set(item) # Update selected count count = len(self.file_table.selection()) self.selected_count_label.config(text=f"{count} vybráno" if count > 0 else "") self.file_menu.tk_popup(event.x_root, event.y_root) def open_file(self, path): """Open file with default application""" try: if sys.platform.startswith("win"): os.startfile(path) elif sys.platform.startswith("darwin"): subprocess.call(["open", path]) else: subprocess.call(["xdg-open", path]) self.status_label.config(text=f"Otevírám: {path.name}") except Exception as e: messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}") # ================================================== # ACTIONS # ================================================== def open_folder_dialog(self): """Open folder selection dialog""" folder = filedialog.askdirectory(title="Vyber složku pro sledování") if not folder: return folder_path = Path(folder) try: self.filehandler.append(folder_path) for f in self.filehandler.filelist: if f.tags and f.tagmanager: for t in f.tags: f.tagmanager.add_tag(t.category, t.name) self.status_label.config(text=f"Přidána složka: {folder_path}") self.refresh_sidebar() self.update_files_from_manager(self.filehandler.filelist) except Exception as e: messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}") def open_selected_files(self): """Open selected files""" files = self.get_selected_files() for f in files: self.open_file(f.file_path) def remove_selected_files(self): """Remove selected files from index""" files = self.get_selected_files() if not files: return ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?") if ans: for f in files: if f in self.filehandler.filelist: self.filehandler.filelist.remove(f) self.update_files_from_manager(self.filehandler.filelist) self.status_label.config(text=f"Odstraněno {len(files)} souborů z indexu") def assign_tag_to_selected_bulk(self): """Assign tags to selected files (bulk mode)""" files = self.get_selected_files() if not files: self.status_label.config(text="Nebyly vybrány žádné soubory") return all_tags = [] for category in self.tagmanager.get_categories(): for tag in self.tagmanager.get_tags_in_category(category): all_tags.append(tag) if not all_tags: messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") return dialog = MultiFileTagAssignDialog(self.root, all_tags, files, self.category_colors) result = dialog.result if result is None: self.status_label.config(text="Přiřazení zrušeno") return for full_path, state in result.items(): if state == 1: if "/" in full_path: category, name = full_path.split("/", 1) tag_obj = self.tagmanager.add_tag(category, name) self.filehandler.assign_tag_to_file_objects(files, tag_obj) elif state == 0: if "/" in full_path: category, name = full_path.split("/", 1) tag_obj = Tag(category, name) self.filehandler.remove_tag_from_file_objects(files, tag_obj) self.update_files_from_manager(self.filehandler.filelist) self.status_label.config(text="Hromadné přiřazení tagů dokončeno") def set_date_for_selected(self): """Set date for selected files""" files = self.get_selected_files() if not files: self.status_label.config(text="Nebyly vybrány žádné soubory") return prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):" date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root) if date_str is None: return for f in files: f.set_date(date_str if date_str != "" else None) self.update_files_from_manager(self.filehandler.filelist) self.status_label.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)") def detect_video_resolution(self): """Detect video resolution using ffprobe""" files = self.get_selected_files() if not files: self.status_label.config(text="Nebyly vybrány žádné soubory") return count = 0 for f in files: try: path = str(f.file_path) result = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=height", "-of", "csv=p=0", path], capture_output=True, text=True, check=True ) height_str = result.stdout.strip() if not height_str.isdigit(): continue height = int(height_str) tag_name = f"{height}p" tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name) f.add_tag(tag_obj) count += 1 except Exception as e: print(f"Chyba u {f.filename}: {e}") self.update_files_from_manager(self.filehandler.filelist) self.status_label.config(text=f"Přiřazeno rozlišení tagů k {count} souborům") def set_ignore_patterns(self): """Set ignore patterns for current folder""" current = ", ".join(self.filehandler.get_ignore_patterns()) s = simpledialog.askstring("Ignore patterns", "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", initialvalue=current) if s is None: return patterns = [p.strip() for p in s.split(",") if p.strip()] self.filehandler.set_ignore_patterns(patterns) self.update_files_from_manager(self.filehandler.filelist) self.status_label.config(text="Ignore patterns aktualizovány") def toggle_hide_ignored(self): """Toggle hiding ignored files""" self.update_files_from_manager(self.filehandler.filelist) def toggle_show_path(self): """Toggle showing full path""" self.show_full_path = not self.show_full_path self.update_files_from_manager(self.filehandler.filelist) def sort_by_column(self, column: str): """Sort by column header click""" if self.sort_mode == column: self.sort_order = "desc" if self.sort_order == "asc" else "asc" else: self.sort_mode = column self.sort_order = "asc" self._update_sort_indicators() self.update_files_from_manager(self.filehandler.filelist) def _update_sort_indicators(self): """Update column header sort indicators""" arrow = "▲" if self.sort_order == "asc" else "▼" headers = { "name": "📄 Název", "date": "📅 Datum", "size": "💾 Velikost" } for col, base_text in headers.items(): if col == self.sort_mode: self.file_table.heading(col, text=f"{base_text} {arrow}") else: self.file_table.heading(col, text=base_text) def on_filter_changed(self): """Handle search/filter change""" self.update_files_from_manager(self.filehandler.filelist) def refresh_all(self): """Refresh everything""" self.refresh_sidebar() self.update_files_from_manager(self.filehandler.filelist) self.status_label.config(text="Obnoveno") def configure_hardlink_folder(self): """Configure hardlink output folder for current project""" if not self.filehandler.current_folder: messagebox.showwarning("Upozornění", "Nejprve otevřete složku") return # Get current settings folder_config = self.filehandler.get_folder_config() current_dir = folder_config.get("hardlink_output_dir") current_categories = folder_config.get("hardlink_categories") # Ask for output directory initial_dir = current_dir if current_dir else str(self.filehandler.current_folder) output_dir = filedialog.askdirectory( title="Vyber cílovou složku pro hardlink strukturu", initialdir=initial_dir, mustexist=False ) if not output_dir: return # Get available categories categories = self.tagmanager.get_categories() if not categories: messagebox.showwarning("Upozornění", "Žádné kategorie tagů") return # Show category selection dialog selected_categories = self._show_category_selection_dialog( categories, preselected=current_categories ) if selected_categories is None: return # Cancelled # Save to folder config folder_config["hardlink_output_dir"] = output_dir folder_config["hardlink_categories"] = selected_categories if selected_categories else None self.filehandler.save_folder_config(config=folder_config) messagebox.showinfo("Hotovo", f"Hardlink složka nastavena:\n{output_dir}") self.status_label.config(text=f"Hardlink složka nastavena: {output_dir}") def update_hardlink_structure(self): """Quick update hardlink structure using saved settings""" if not self.filehandler.current_folder: messagebox.showwarning("Upozornění", "Nejprve otevřete složku") return # Get saved settings folder_config = self.filehandler.get_folder_config() output_dir = folder_config.get("hardlink_output_dir") saved_categories = folder_config.get("hardlink_categories") if not output_dir: messagebox.showinfo("Info", "Hardlink složka není nastavena.\nPoužijte 'Nastavit hardlink složku...' pro konfiguraci.") return output_path = Path(output_dir) files = self.filehandler.filelist if not files: messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") return # Create manager and analyze manager = HardlinkManager(output_path) # Find what needs to be created and removed preview_create = manager.get_preview(files, saved_categories) obsolete = manager.find_obsolete_links(files, saved_categories) # Filter out already existing links from preview to_create = [] for source, target in preview_create: if not target.exists(): to_create.append((source, target)) elif not manager._is_same_file(source, target): to_create.append((source, target)) if not to_create and not obsolete: messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") return # Build confirmation message confirm_lines = [] if to_create: confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") if obsolete: confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") confirm_lines.append(f"\nCílová složka: {output_path}") confirm_lines.append("\nPokračovat?") if not messagebox.askyesno("Potvrdit aktualizaci", "\n".join(confirm_lines)): return # Perform sync self.status_label.config(text="Aktualizuji hardlink strukturu...") self.root.update() created, create_fail, removed, remove_fail = manager.sync_structure(files, saved_categories) # Show result result_lines = [] if created > 0: result_lines.append(f"Vytvořeno: {created} hardlinků") if removed > 0: result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") if create_fail > 0 or remove_fail > 0: if create_fail > 0: result_lines.append(f"Selhalo vytvoření: {create_fail}") if remove_fail > 0: result_lines.append(f"Selhalo odebrání: {remove_fail}") messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) else: messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") self.status_label.config(text=f"Hardlink struktura aktualizována (vytvořeno: {created}, odebráno: {removed})") def create_hardlink_structure(self): """Create hardlink directory structure based on file tags (manual selection)""" files = self.filehandler.filelist if not files: messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") return # Ask for output directory output_dir = filedialog.askdirectory( title="Vyber cílovou složku pro hardlink strukturu", mustexist=False ) if not output_dir: return output_path = Path(output_dir) # Get available categories categories = self.tagmanager.get_categories() if not categories: messagebox.showwarning("Upozornění", "Žádné kategorie tagů") return # Show category selection dialog selected_categories = self._show_category_selection_dialog(categories) if selected_categories is None: return # Cancelled cat_filter = selected_categories if selected_categories else None # Create manager and analyze manager = HardlinkManager(output_path) # Find what needs to be created and removed preview_create = manager.get_preview(files, cat_filter) obsolete = manager.find_obsolete_links(files, cat_filter) # Filter out already existing links from preview to_create = [] for source, target in preview_create: if not target.exists(): to_create.append((source, target)) elif not manager._is_same_file(source, target): to_create.append((source, target)) if not to_create and not obsolete: messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") return # Build confirmation message confirm_lines = [] if to_create: confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") if obsolete: confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") confirm_lines.append(f"\nCílová složka: {output_path}") confirm_lines.append("\nPokračovat?") if not messagebox.askyesno("Potvrdit synchronizaci", "\n".join(confirm_lines)): return # Perform sync self.status_label.config(text="Synchronizuji hardlink strukturu...") self.root.update() created, create_fail, removed, remove_fail = manager.sync_structure(files, cat_filter) # Show result result_lines = [] if created > 0 or create_fail > 0: result_lines.append(f"Vytvořeno: {created} hardlinků") if create_fail > 0: result_lines.append(f"Selhalo vytvoření: {create_fail}") if removed > 0 or remove_fail > 0: result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") if remove_fail > 0: result_lines.append(f"Selhalo odebrání: {remove_fail}") if create_fail > 0 or remove_fail > 0: if manager.errors: result_lines.append("\nChyby:") for path, err in manager.errors[:5]: result_lines.append(f"- {path.name}: {err}") if len(manager.errors) > 5: result_lines.append(f"... a dalších {len(manager.errors) - 5} chyb") messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) else: messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") self.status_label.config(text=f"Hardlink struktura synchronizována (vytvořeno: {created}, odebráno: {removed})") def _show_category_selection_dialog(self, categories: List[str], preselected: List[str] | None = None) -> List[str] | None: """Show dialog to select which categories to include in hardlink structure Args: categories: List of available category names preselected: Optional list of categories to pre-check (None = all checked) """ dialog = tk.Toplevel(self.root) dialog.title("Vybrat kategorie") dialog.geometry("350x400") dialog.transient(self.root) dialog.grab_set() result = {"categories": None} tk.Label(dialog, text="Vyberte kategorie pro vytvoření struktury:", font=("Arial", 10, "bold")).pack(pady=10) # Scrollable frame for checkboxes frame = tk.Frame(dialog) frame.pack(fill=tk.BOTH, expand=True, padx=10) canvas = tk.Canvas(frame) scrollbar = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) scrollable_frame = tk.Frame(canvas) scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") canvas.configure(yscrollcommand=scrollbar.set) canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") # Category checkboxes category_vars = {} for category in sorted(categories): # If preselected is None, check all; otherwise check only those in preselected initial_value = preselected is None or category in preselected var = tk.BooleanVar(value=initial_value) category_vars[category] = var color = self.category_colors.get(category, "#333333") cb = tk.Checkbutton(scrollable_frame, text=category, variable=var, fg=color, font=("Arial", 10), anchor="w") cb.pack(fill="x", pady=2) # Buttons btn_frame = tk.Frame(dialog) btn_frame.pack(pady=10) def on_ok(): result["categories"] = [cat for cat, var in category_vars.items() if var.get()] dialog.destroy() def on_cancel(): result["categories"] = None dialog.destroy() def select_all(): for var in category_vars.values(): var.set(True) def select_none(): for var in category_vars.values(): var.set(False) tk.Button(btn_frame, text="Všechny", command=select_all, width=8).pack(side=tk.LEFT, padx=2) tk.Button(btn_frame, text="Žádné", command=select_none, width=8).pack(side=tk.LEFT, padx=2) tk.Button(btn_frame, text="OK", command=on_ok, width=10).pack(side=tk.LEFT, padx=10) tk.Button(btn_frame, text="Zrušit", command=on_cancel, width=10).pack(side=tk.LEFT, padx=2) self.root.wait_window(dialog) return result["categories"]