Files
Tagger/src/ui/gui.py
2025-12-28 17:51:46 +01:00

1297 lines
51 KiB
Python

"""
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, DEFAULT_TAG_ORDER
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("<Configure>", 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("<MouseWheel>", on_mousewheel)
canvas.bind("<Button-4>", on_scroll_up)
canvas.bind("<Button-5>", on_scroll_down)
frame.bind("<MouseWheel>", on_mousewheel)
frame.bind("<Button-4>", on_scroll_up)
frame.bind("<Button-5>", on_scroll_down)
file_tag_sets = [{t.full_path for t in f.tags} for f in files]
# Group by category
tags_by_category = {}
for full_path, tag in self.tags_by_full.items():
if tag.category not in tags_by_category:
tags_by_category[tag.category] = []
tags_by_category[tag.category].append((full_path, tag))
# Sort tags within each category
for category in tags_by_category:
if category in DEFAULT_TAG_ORDER:
order = DEFAULT_TAG_ORDER[category]
tags_by_category[category].sort(key=lambda x: order.get(x[1].name, 999))
else:
tags_by_category[category].sort(key=lambda x: x[1].name)
for category in sorted(tags_by_category.keys()):
color = self.category_colors.get(category, "#333333")
is_exclusive = category in EXCLUSIVE_CATEGORIES
exclusive_note = " (pouze jedno)" if is_exclusive else ""
cat_label = tk.Label(frame, text=f"{category}{exclusive_note}", bg=COLORS["bg"],
fg=color, font=("Arial", 10, "bold"))
cat_label.pack(fill="x", anchor="w", pady=(12, 4))
self.category_checkbuttons[category] = []
for full_path, tag in tags_by_category[category]:
have_count = sum(1 for s in file_tag_sets if full_path in s)
if have_count == 0:
init = 0
elif have_count == len(files):
init = 1
else:
init = 2 # mixed
cb = tk.Checkbutton(frame, text=f" {tag.name}", anchor="w", bg=COLORS["bg"],
font=("Arial", 10))
cb.state_value = init
cb.full_path = full_path
cb.tag_color = color
cb.category = category
cb.pack(fill="x", anchor="w", padx=20)
cb.bind("<Button-1>", 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("<Button-1>", self.on_tree_left_click)
self.tag_tree.bind("<Button-3>", 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("<Double-1>", self.on_file_double_click)
self.file_table.bind("<Button-3>", self.on_file_right_click)
self.file_table.bind("<<TreeviewSelect>>", 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("<Control-o>", lambda e: self.open_folder_dialog())
self.root.bind("<Control-q>", lambda e: self.root.quit())
self.root.bind("<Control-t>", lambda e: self.assign_tag_to_selected_bulk())
self.root.bind("<Control-d>", lambda e: self.set_date_for_selected())
self.root.bind("<Control-f>", lambda e: self.search_var.get()) # Focus search
self.root.bind("<F5>", lambda e: self.refresh_all())
self.root.bind("<Delete>", 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("<Configure>", 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"]