Files
Tagger/src/ui/gui.py
2026-01-24 07:50:19 +01:00

1589 lines
57 KiB
Python

"""
Modern PySide6/Qt6 GUI for Tagger
"""
import os
import sys
import subprocess
import re
from pathlib import Path
from typing import List
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QSplitter, QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem,
QHeaderView, QMenu, QMenuBar, QToolBar, QStatusBar, QLabel,
QPushButton, QLineEdit, QCheckBox, QDialog, QDialogButtonBox,
QScrollArea, QFrame, QMessageBox, QInputDialog, QFileDialog,
QAbstractItemView, QSizePolicy
)
from PySide6.QtCore import Qt, QSize
from PySide6.QtGui import QAction, QIcon, QPixmap, QFont, QColor
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.constants import APP_NAME, APP_VIEWPORT
from src.core.config import save_global_config
from src.core.hardlink_manager import HardlinkManager
# 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(QDialog):
"""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.setWindowTitle("Přiřadit tagy k vybraným souborům")
self.setMinimumSize(500, 600)
self.result = None
self.tags_by_full = {t.full_path: t for t in all_tags}
self.files = files
self.category_colors = category_colors or {}
self.checkboxes: dict[str, QCheckBox] = {}
self.category_checkboxes: dict[str, list] = {}
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Header
header = QLabel(f"Vybráno souborů: {len(self.files)}")
header.setFont(QFont("Arial", 11, QFont.Bold))
header.setAlignment(Qt.AlignCenter)
layout.addWidget(header)
# Scrollable content
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
content = QWidget()
content_layout = QVBoxLayout(content)
content_layout.setSpacing(2)
# Calculate tag states
file_tag_sets = [{t.full_path for t in f.tags} for f in self.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)
# Create category sections
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 ""
# Category header
cat_label = QLabel(f"{category}{exclusive_note}")
cat_label.setFont(QFont("Arial", 10, QFont.Bold))
cat_label.setStyleSheet(f"color: {color}; margin-top: 12px;")
content_layout.addWidget(cat_label)
self.category_checkboxes[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_state = Qt.Unchecked
elif have_count == len(self.files):
init_state = Qt.Checked
else:
init_state = Qt.PartiallyChecked
cb = QCheckBox(f" {tag.name}")
cb.setTristate(True)
cb.setCheckState(init_state)
cb.setProperty("full_path", full_path)
cb.setProperty("category", category)
cb.setProperty("tag_color", color)
# Style based on state
self._update_checkbox_style(cb)
cb.stateChanged.connect(lambda state, c=cb: self._on_state_changed(c))
content_layout.addWidget(cb)
self.checkboxes[full_path] = cb
self.category_checkboxes[category].append(cb)
content_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
# Buttons
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
button_box.accepted.connect(self._on_ok)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
def _update_checkbox_style(self, cb: QCheckBox):
state = cb.checkState()
color = cb.property("tag_color") or "#333333"
if state == Qt.Unchecked:
cb.setStyleSheet("color: #666666;")
elif state == Qt.Checked:
cb.setStyleSheet(f"color: {color};")
else: # PartiallyChecked
cb.setStyleSheet("color: #cc6600;")
def _on_state_changed(self, cb: QCheckBox):
category = cb.property("category")
# Handle exclusive categories
if category in EXCLUSIVE_CATEGORIES:
if cb.checkState() == Qt.Checked:
# Uncheck all others in this category
for other_cb in self.category_checkboxes.get(category, []):
if other_cb != cb:
other_cb.blockSignals(True)
other_cb.setCheckState(Qt.Unchecked)
self._update_checkbox_style(other_cb)
other_cb.blockSignals(False)
self._update_checkbox_style(cb)
def _on_ok(self):
self.result = {}
for full_path, cb in self.checkboxes.items():
state = cb.checkState()
if state == Qt.Checked:
self.result[full_path] = 1
elif state == Qt.Unchecked:
self.result[full_path] = 0
else:
self.result[full_path] = 2 # mixed - don't change
self.accept()
class CategorySelectionDialog(QDialog):
"""Dialog for selecting categories for hardlink structure"""
def __init__(self, parent, categories: List[str], category_colors: dict,
preselected: List[str] | None = None):
super().__init__(parent)
self.setWindowTitle("Vybrat kategorie")
self.setMinimumSize(350, 400)
self.categories = categories
self.category_colors = category_colors
self.preselected = preselected
self.result = None
self.checkboxes: dict[str, QCheckBox] = {}
self._setup_ui()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Header
header = QLabel("Vyberte kategorie pro vytvoření struktury:")
header.setFont(QFont("Arial", 10, QFont.Bold))
layout.addWidget(header)
# Scrollable content
scroll = QScrollArea()
scroll.setWidgetResizable(True)
content = QWidget()
content_layout = QVBoxLayout(content)
for category in sorted(self.categories):
initial_value = (self.preselected is None or
category in self.preselected)
color = self.category_colors.get(category, "#333333")
cb = QCheckBox(category)
cb.setChecked(initial_value)
cb.setStyleSheet(f"color: {color};")
content_layout.addWidget(cb)
self.checkboxes[category] = cb
content_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
# Selection buttons
sel_layout = QHBoxLayout()
btn_all = QPushButton("Všechny")
btn_all.clicked.connect(self._select_all)
btn_none = QPushButton("Žádné")
btn_none.clicked.connect(self._select_none)
sel_layout.addWidget(btn_all)
sel_layout.addWidget(btn_none)
sel_layout.addStretch()
layout.addLayout(sel_layout)
# Dialog buttons
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
button_box.accepted.connect(self._on_ok)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
def _select_all(self):
for cb in self.checkboxes.values():
cb.setChecked(True)
def _select_none(self):
for cb in self.checkboxes.values():
cb.setChecked(False)
def _on_ok(self):
self.result = [cat for cat, cb in self.checkboxes.items() if cb.isChecked()]
self.accept()
class MainWindow(QMainWindow):
"""Main application window"""
def __init__(self, filehandler: FileManager, tagmanager: TagManager):
super().__init__()
self.filehandler = filehandler
self.tagmanager = tagmanager
# State
self.tag_states: dict[str, bool] = {} # tag full_path -> checked
self.file_items: dict[int, File] = {} # row -> File mapping
self.tag_tree_items: dict[str, tuple] = {} # full_path -> (item, name)
self.filter_text = ""
self.show_full_path = False
self.sort_column = 0
self.sort_order = Qt.AscendingOrder
self.category_colors: dict[str, str] = {}
self.show_csfd_column = True
self.hide_ignored = False
self.filehandler.on_files_changed = self.update_files_from_manager
self._setup_window()
self._create_menu()
self._create_toolbar()
self._create_main_layout()
self._create_status_bar()
self._setup_shortcuts()
# Load last folder
last = self.filehandler.global_config.get("last_folder")
if last:
try:
self.filehandler.append(Path(last))
except Exception:
pass
# Initial refresh
self.refresh_sidebar()
self.update_files_from_manager(self.filehandler.filelist)
def _setup_window(self):
self.setWindowTitle(APP_NAME)
# Parse viewport size
try:
w, h = APP_VIEWPORT.split("x")
self.resize(int(w), int(h))
except:
self.resize(1000, 700)
# Load saved geometry
geometry = self.filehandler.global_config.get("window_geometry")
if geometry:
try:
parts = geometry.split("x")
if len(parts) >= 2:
w = int(parts[0])
h_pos = parts[1].split("+")
h = int(h_pos[0])
self.resize(w, h)
if len(h_pos) >= 3:
self.move(int(h_pos[1]), int(h_pos[2]))
except:
pass
if self.filehandler.global_config.get("window_maximized", False):
self.showMaximized()
def _create_menu(self):
menubar = self.menuBar()
# File menu
file_menu = menubar.addMenu("Soubor")
open_action = QAction("Otevřít složku... (Ctrl+O)", self)
open_action.triggered.connect(self.open_folder_dialog)
file_menu.addAction(open_action)
close_action = QAction("Zavřít složku (Ctrl+W)", self)
close_action.triggered.connect(self.close_folder)
file_menu.addAction(close_action)
ignore_action = QAction("Nastavit ignorované vzory", self)
ignore_action.triggered.connect(self.set_ignore_patterns)
file_menu.addAction(ignore_action)
file_menu.addSeparator()
exit_action = QAction("Ukončit (Ctrl+Q)", self)
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
# View menu
view_menu = menubar.addMenu("Pohled")
self.hide_ignored_action = QAction("Skrýt ignorované", self)
self.hide_ignored_action.setCheckable(True)
self.hide_ignored_action.triggered.connect(self.toggle_hide_ignored)
view_menu.addAction(self.hide_ignored_action)
self.csfd_column_action = QAction("Zobrazit CSFD sloupec", self)
self.csfd_column_action.setCheckable(True)
self.csfd_column_action.setChecked(True)
self.csfd_column_action.triggered.connect(self.toggle_csfd_column)
view_menu.addAction(self.csfd_column_action)
refresh_action = QAction("Obnovit (F5)", self)
refresh_action.triggered.connect(self.refresh_all)
view_menu.addAction(refresh_action)
# Tools menu
tools_menu = menubar.addMenu("Nástroje")
date_action = QAction("Nastavit datum (Ctrl+D)", self)
date_action.triggered.connect(self.set_date_for_selected)
tools_menu.addAction(date_action)
resolution_action = QAction("Detekovat rozlišení videí", self)
resolution_action.triggered.connect(self.detect_video_resolution)
tools_menu.addAction(resolution_action)
tags_action = QAction("Přiřadit tagy (Ctrl+T)", self)
tags_action.triggered.connect(self.assign_tag_to_selected_bulk)
tools_menu.addAction(tags_action)
tools_menu.addSeparator()
csfd_url_action = QAction("Nastavit CSFD URL...", self)
csfd_url_action.triggered.connect(self.set_csfd_url_for_selected)
tools_menu.addAction(csfd_url_action)
csfd_tags_action = QAction("Načíst tagy z CSFD", self)
csfd_tags_action.triggered.connect(self.apply_csfd_tags_for_selected)
tools_menu.addAction(csfd_tags_action)
tools_menu.addSeparator()
hardlink_config_action = QAction("Nastavit hardlink složku...", self)
hardlink_config_action.triggered.connect(self.configure_hardlink_folder)
tools_menu.addAction(hardlink_config_action)
hardlink_update_action = QAction("Aktualizovat hardlink strukturu", self)
hardlink_update_action.triggered.connect(self.update_hardlink_structure)
tools_menu.addAction(hardlink_update_action)
hardlink_create_action = QAction("Vytvořit hardlink strukturu...", self)
hardlink_create_action.triggered.connect(self.create_hardlink_structure)
tools_menu.addAction(hardlink_create_action)
def _create_toolbar(self):
toolbar = QToolBar()
toolbar.setMovable(False)
self.addToolBar(toolbar)
# Open folder button
open_btn = QPushButton("📁 Otevřít složku")
open_btn.setFlat(True)
open_btn.clicked.connect(self.open_folder_dialog)
toolbar.addWidget(open_btn)
# Refresh button
refresh_btn = QPushButton("🔄 Obnovit")
refresh_btn.setFlat(True)
refresh_btn.clicked.connect(self.refresh_all)
toolbar.addWidget(refresh_btn)
toolbar.addSeparator()
# New tag button
tag_btn = QPushButton("🏷️ Nový tag")
tag_btn.setFlat(True)
tag_btn.clicked.connect(lambda: self.tree_add_tag(background=True))
toolbar.addWidget(tag_btn)
# Set date button
date_btn = QPushButton("📅 Nastavit datum")
date_btn.setFlat(True)
date_btn.clicked.connect(self.set_date_for_selected)
toolbar.addWidget(date_btn)
toolbar.addSeparator()
# Spacer
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
toolbar.addWidget(spacer)
# Search
search_label = QLabel("🔍 ")
toolbar.addWidget(search_label)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Hledat...")
self.search_input.setFixedWidth(200)
self.search_input.textChanged.connect(self.on_filter_changed)
toolbar.addWidget(self.search_input)
def _create_main_layout(self):
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
# Splitter for sidebar and main content
splitter = QSplitter(Qt.Horizontal)
# Left sidebar
sidebar = self._create_sidebar()
splitter.addWidget(sidebar)
# Right panel (file table)
file_panel = self._create_file_panel()
splitter.addWidget(file_panel)
# Set initial sizes
splitter.setSizes([250, 750])
layout.addWidget(splitter)
def _create_sidebar(self) -> QWidget:
sidebar = QWidget()
sidebar.setMinimumWidth(200)
sidebar.setMaximumWidth(400)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(5, 5, 5, 5)
# Header
header = QLabel("📂 Štítky")
header.setFont(QFont("Arial", 10, QFont.Bold))
layout.addWidget(header)
# Tag tree
self.tag_tree = QTreeWidget()
self.tag_tree.setHeaderHidden(True)
self.tag_tree.setSelectionMode(QAbstractItemView.SingleSelection)
self.tag_tree.itemClicked.connect(self.on_tree_item_clicked)
self.tag_tree.setContextMenuPolicy(Qt.CustomContextMenu)
self.tag_tree.customContextMenuRequested.connect(self.on_tree_context_menu)
layout.addWidget(self.tag_tree)
return sidebar
def _create_file_panel(self) -> QWidget:
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setContentsMargins(5, 5, 5, 5)
# Control panel
control_layout = QHBoxLayout()
self.full_path_cb = QCheckBox("Plná cesta")
self.full_path_cb.toggled.connect(self.toggle_show_path)
control_layout.addWidget(self.full_path_cb)
control_layout.addStretch()
layout.addLayout(control_layout)
# File table
self.file_table = QTableWidget()
self.file_table.setColumnCount(5)
self.file_table.setHorizontalHeaderLabels(
["📄 Název", "📅 Datum", "🏷️ Štítky", "🎬 CSFD", "💾 Velikost"]
)
self.file_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.file_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.file_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.file_table.setSortingEnabled(True)
self.file_table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.Stretch
)
self.file_table.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents
)
self.file_table.horizontalHeader().setSectionResizeMode(
2, QHeaderView.ResizeToContents
)
self.file_table.horizontalHeader().setSectionResizeMode(
3, QHeaderView.ResizeToContents
)
self.file_table.horizontalHeader().setSectionResizeMode(
4, QHeaderView.ResizeToContents
)
self.file_table.verticalHeader().setVisible(False)
self.file_table.doubleClicked.connect(self.on_file_double_click)
self.file_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.file_table.customContextMenuRequested.connect(self.on_file_context_menu)
self.file_table.selectionModel().selectionChanged.connect(
self.on_selection_changed
)
self.file_table.horizontalHeader().sortIndicatorChanged.connect(
self.on_sort_changed
)
layout.addWidget(self.file_table)
return panel
def _create_status_bar(self):
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_label = QLabel("Připraven")
self.status_bar.addWidget(self.status_label, 1)
self.selected_count_label = QLabel("")
self.status_bar.addPermanentWidget(self.selected_count_label)
self.selected_size_label = QLabel("")
self.status_bar.addPermanentWidget(self.selected_size_label)
self.file_count_label = QLabel("0 souborů")
self.status_bar.addPermanentWidget(self.file_count_label)
def _setup_shortcuts(self):
from PySide6.QtGui import QShortcut, QKeySequence
QShortcut(QKeySequence("Ctrl+O"), self, self.open_folder_dialog)
QShortcut(QKeySequence("Ctrl+W"), self, self.close_folder)
QShortcut(QKeySequence("Ctrl+Q"), self, self.close)
QShortcut(QKeySequence("Ctrl+T"), self, self.assign_tag_to_selected_bulk)
QShortcut(QKeySequence("Ctrl+D"), self, self.set_date_for_selected)
QShortcut(QKeySequence("F5"), self, self.refresh_all)
QShortcut(QKeySequence("Delete"), self, self.remove_selected_files)
# ==================================================
# SIDEBAR / TAG TREE METHODS
# ==================================================
def refresh_sidebar(self):
"""Refresh tag tree in sidebar"""
self.tag_tree.clear()
self.tag_tree_items.clear()
self.tag_states.clear()
# Count files per tag
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
# Root item
total_files = len(self.filehandler.filelist)
root = QTreeWidgetItem(self.tag_tree)
root.setText(0, f"📂 Všechny soubory ({total_files})")
root.setExpanded(True)
self.root_item = root
# Assign colors to categories
categories = self.tagmanager.get_categories()
color_index = 0
for category in categories:
if category not in self.category_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_item = QTreeWidgetItem(root)
cat_item.setText(0, f"📁 {category}")
cat_item.setForeground(0, QColor(color))
cat_item.setData(0, Qt.UserRole, {"type": "category", "name": category})
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_item = QTreeWidgetItem(cat_item)
tag_item.setText(0, f"{tag.name}{count_str}")
tag_item.setForeground(0, QColor(color))
tag_item.setData(0, Qt.UserRole, {
"type": "tag",
"full_path": tag.full_path,
"name": tag.name,
"category": category
})
self.tag_tree_items[tag.full_path] = (tag_item, tag.name)
self.tag_states[tag.full_path] = False
def update_tag_counts(self, filtered_files: List[File]):
"""Update tag counts in sidebar"""
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
for full_path, (item, tag_name) in self.tag_tree_items.items():
count = tag_counts.get(full_path, 0)
count_str = f" ({count})" if count > 0 else ""
checked = "" if self.tag_states.get(full_path, False) else ""
item.setText(0, f"{checked} {tag_name}{count_str}")
# Update root count
total = len(filtered_files)
self.root_item.setText(0, f"📂 Všechny soubory ({total})")
def on_tree_item_clicked(self, item: QTreeWidgetItem, column: int):
"""Handle click on tag tree item"""
data = item.data(0, Qt.UserRole)
if not data:
return
if data.get("type") == "tag":
full_path = data["full_path"]
self.tag_states[full_path] = not self.tag_states.get(full_path, False)
# Update checkbox visual
tag_name = data["name"]
count_match = re.search(r'\((\d+)\)$', item.text(0))
count_str = f" ({count_match.group(1)})" if count_match else ""
checked = "" if self.tag_states[full_path] else ""
item.setText(0, f"{checked} {tag_name}{count_str}")
self.update_files_from_manager(self.filehandler.filelist)
def on_tree_context_menu(self, pos):
"""Show context menu for tag tree"""
item = self.tag_tree.itemAt(pos)
if not item:
return
self.selected_tree_item = item
data = item.data(0, Qt.UserRole)
menu = QMenu(self)
add_action = QAction("Nový štítek", self)
add_action.triggered.connect(self.tree_add_tag)
menu.addAction(add_action)
if data and data.get("type") in ("tag", "category"):
rename_action = QAction("Přejmenovat", self)
rename_action.triggered.connect(self.tree_rename_tag)
menu.addAction(rename_action)
delete_action = QAction("Smazat", self)
delete_action.triggered.connect(self.tree_delete_tag)
menu.addAction(delete_action)
menu.exec(self.tag_tree.mapToGlobal(pos))
def tree_add_tag(self, background=False):
"""Add new tag"""
name, ok = QInputDialog.getText(self, "Nový tag", "Název tagu:")
if not ok or not name:
return
item = getattr(self, 'selected_tree_item', None) if not background else None
data = item.data(0, Qt.UserRole) if item else None
if data and data.get("type") == "category":
category = data["name"]
self.tagmanager.add_tag(category, name)
else:
self.tagmanager.add_category(name)
self.refresh_sidebar()
self.status_label.setText(f"Vytvořen tag: {name}")
def tree_delete_tag(self):
"""Delete selected tag"""
item = getattr(self, 'selected_tree_item', None)
if not item:
return
data = item.data(0, Qt.UserRole)
if not data:
return
if data.get("type") == "category":
name = data["name"]
reply = QMessageBox.question(
self, "Smazat kategorii",
f"Opravdu chcete smazat kategorii '{name}'?"
)
if reply == QMessageBox.Yes:
self.tagmanager.remove_category(name)
elif data.get("type") == "tag":
name = data["name"]
category = data["category"]
reply = QMessageBox.question(
self, "Smazat štítek",
f"Opravdu chcete smazat štítek '{name}'?"
)
if reply == QMessageBox.Yes:
self.tagmanager.remove_tag(category, name)
self.refresh_sidebar()
self.update_files_from_manager(self.filehandler.filelist)
def tree_rename_tag(self):
"""Rename selected tag or category"""
item = getattr(self, 'selected_tree_item', None)
if not item:
return
data = item.data(0, Qt.UserRole)
if not data:
return
if data.get("type") == "category":
current_name = data["name"]
new_name, ok = QInputDialog.getText(
self, "Přejmenovat kategorii",
f"Nový název kategorie '{current_name}':",
text=current_name
)
if not ok or not new_name or new_name == current_name:
return
# Check for existing category - offer merge
if new_name in self.tagmanager.get_categories():
reply = QMessageBox.question(
self, "Kategorie existuje",
f"Kategorie '{new_name}' již existuje.\n\n"
f"Chcete sloučit '{current_name}' do '{new_name}'?"
)
if reply != QMessageBox.Yes:
return
updated = self.filehandler.merge_category_in_files(
current_name, new_name
)
self.status_label.setText(
f"Kategorie sloučena: {current_name}{new_name} "
f"({updated} souborů)"
)
else:
updated = self.filehandler.rename_category_in_files(
current_name, new_name
)
self.status_label.setText(
f"Kategorie přejmenována: {current_name}{new_name} "
f"({updated} souborů)"
)
elif data.get("type") == "tag":
current_name = data["name"]
category = data["category"]
new_name, ok = QInputDialog.getText(
self, "Přejmenovat štítek",
f"Nový název štítku '{current_name}':",
text=current_name
)
if not ok or not new_name or new_name == current_name:
return
# Check for existing tag - offer merge
existing = [t.name for t in
self.tagmanager.get_tags_in_category(category)]
if new_name in existing:
reply = QMessageBox.question(
self, "Štítek existuje",
f"Štítek '{new_name}' v kategorii '{category}' již existuje.\n\n"
f"Chcete sloučit '{current_name}' do '{new_name}'?"
)
if reply != QMessageBox.Yes:
return
updated = self.filehandler.merge_tag_in_files(
category, current_name, new_name
)
self.status_label.setText(
f"Štítek sloučen: {category}/{current_name}"
f"{category}/{new_name} ({updated} souborů)"
)
else:
updated = self.filehandler.rename_tag_in_files(
category, current_name, new_name
)
self.status_label.setText(
f"Štítek přejmenován: {category}/{current_name}"
f"{category}/{new_name} ({updated} souborů)"
)
self.refresh_sidebar()
self.update_files_from_manager(self.filehandler.filelist)
def get_checked_tags(self) -> List[Tag]:
"""Get list of checked tags"""
tags = []
for full_path, checked in self.tag_states.items():
if checked and full_path in self.tag_tree_items:
item, name = self.tag_tree_items[full_path]
data = item.data(0, Qt.UserRole)
if data and data.get("type") == "tag":
tags.append(Tag(data["category"], data["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_input.text().lower() if hasattr(self, 'search_input') 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:
filtered_files = [
f for f in filtered_files
if "Stav/Ignorované" not in {t.full_path for t in f.tags}
]
# Clear table
self.file_table.setSortingEnabled(False)
self.file_table.setRowCount(0)
self.file_items.clear()
# Populate table
for row, f in enumerate(filtered_files):
self.file_table.insertRow(row)
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]])
if len(f.tags) > 3:
tags += f" +{len(f.tags) - 3}"
csfd = "" if f.csfd_url else ""
try:
size = f.file_path.stat().st_size
size_str = self._format_size(size)
except:
size_str = "?"
self.file_table.setItem(row, 0, QTableWidgetItem(name))
self.file_table.setItem(row, 1, QTableWidgetItem(date))
self.file_table.setItem(row, 2, QTableWidgetItem(tags))
self.file_table.setItem(row, 3, QTableWidgetItem(csfd))
self.file_table.setItem(row, 4, QTableWidgetItem(size_str))
self.file_items[row] = f
self.file_table.setSortingEnabled(True)
# Update CSFD column visibility
self.file_table.setColumnHidden(3, not self.show_csfd_column)
# Update status
self.file_count_label.setText(f"{len(filtered_files)} souborů")
self.status_label.setText(f"Zobrazeno {len(filtered_files)} souborů")
# Update tag counts
self.update_tag_counts(filtered_files)
def _format_size(self, size_bytes: int) -> str:
"""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"""
files = []
for index in self.file_table.selectionModel().selectedRows():
row = index.row()
# Find the file by matching the filename in the visible row
name_item = self.file_table.item(row, 0)
if name_item:
name = name_item.text()
for f in self.filehandler.filelist:
display_name = str(f.file_path) if self.show_full_path else f.filename
if display_name == name:
files.append(f)
break
return files
def on_selection_changed(self):
"""Update status bar when selection changes"""
files = self.get_selected_files()
count = len(files)
if count == 0:
self.selected_count_label.setText("")
self.selected_size_label.setText("")
else:
self.selected_count_label.setText(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.setText(f"[{self._format_size(total_size)}]")
def on_file_double_click(self, index):
"""Handle double click on file"""
files = self.get_selected_files()
for f in files:
self.open_file(f.file_path)
def on_file_context_menu(self, pos):
"""Show context menu for files"""
menu = QMenu(self)
open_action = QAction("Otevřít soubor", self)
open_action.triggered.connect(self.open_selected_files)
menu.addAction(open_action)
tags_action = QAction("Přiřadit štítky (Ctrl+T)", self)
tags_action.triggered.connect(self.assign_tag_to_selected_bulk)
menu.addAction(tags_action)
date_action = QAction("Nastavit datum (Ctrl+D)", self)
date_action.triggered.connect(self.set_date_for_selected)
menu.addAction(date_action)
menu.addSeparator()
csfd_url_action = QAction("Nastavit CSFD URL...", self)
csfd_url_action.triggered.connect(self.set_csfd_url_for_selected)
menu.addAction(csfd_url_action)
csfd_tags_action = QAction("Načíst tagy z CSFD", self)
csfd_tags_action.triggered.connect(self.apply_csfd_tags_for_selected)
menu.addAction(csfd_tags_action)
menu.addSeparator()
remove_action = QAction("Smazat z indexu (Del)", self)
remove_action.triggered.connect(self.remove_selected_files)
menu.addAction(remove_action)
menu.exec(self.file_table.mapToGlobal(pos))
def on_sort_changed(self, column: int, order: Qt.SortOrder):
"""Handle sort indicator change"""
self.sort_column = column
self.sort_order = order
def open_file(self, path: 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.setText(f"Otevírám: {path.name}")
except Exception as e:
QMessageBox.critical(self, "Chyba", f"Nelze otevřít {path}: {e}")
# ==================================================
# ACTIONS
# ==================================================
def open_folder_dialog(self):
"""Open folder selection dialog"""
folder = QFileDialog.getExistingDirectory(
self, "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.setText(f"Přidána složka: {folder_path}")
self._update_csfd_column_visibility()
self.refresh_sidebar()
self.update_files_from_manager(self.filehandler.filelist)
except Exception as e:
QMessageBox.critical(self, "Chyba", f"Nelze přidat složku {folder}: {e}")
def close_folder(self):
"""Close current folder safely"""
if not self.filehandler.current_folder:
self.status_label.setText("Žádná složka není otevřena")
return
folder_name = self.filehandler.current_folder.name
self.filehandler.close_folder()
self.refresh_sidebar()
self.status_label.setText(f"Složka zavřena: {folder_name}")
def open_selected_files(self):
"""Open selected files"""
for f in self.get_selected_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
reply = QMessageBox.question(
self, "Smazat z indexu",
f"Odstranit {len(files)} souborů z indexu?"
)
if reply == QMessageBox.Yes:
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.setText(f"Odstraněno {len(files)} souborů z indexu")
def assign_tag_to_selected_bulk(self):
"""Assign tags to selected files"""
files = self.get_selected_files()
if not files:
self.status_label.setText("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:
QMessageBox.warning(self, "Chyba", "Žádné tagy nejsou definovány")
return
dialog = MultiFileTagAssignDialog(
self, all_tags, files, self.category_colors
)
if dialog.exec() != QDialog.Accepted or dialog.result is None:
self.status_label.setText("Přiřazení zrušeno")
return
for full_path, state in dialog.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.setText("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.setText("Nebyly vybrány žádné soubory")
return
date_str, ok = QInputDialog.getText(
self, "Nastavit datum",
"Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):"
)
if not ok:
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.setText(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.setText("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:
pass
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.setText(f"Přiřazeno rozlišení tagů k {count} souborům")
def set_ignore_patterns(self):
"""Set ignore patterns"""
current = ", ".join(self.filehandler.get_ignore_patterns())
patterns, ok = QInputDialog.getText(
self, "Ignore patterns",
"Zadej patterny oddělené čárkou (např. *.png, *.tmp):",
text=current
)
if not ok:
return
pattern_list = [p.strip() for p in patterns.split(",") if p.strip()]
self.filehandler.set_ignore_patterns(pattern_list)
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.setText("Ignore patterns aktualizovány")
def toggle_hide_ignored(self):
"""Toggle hiding ignored files"""
self.hide_ignored = self.hide_ignored_action.isChecked()
self.update_files_from_manager(self.filehandler.filelist)
def toggle_show_path(self):
"""Toggle showing full path"""
self.show_full_path = self.full_path_cb.isChecked()
self.update_files_from_manager(self.filehandler.filelist)
def toggle_csfd_column(self):
"""Toggle CSFD column visibility"""
self.show_csfd_column = self.csfd_column_action.isChecked()
self.file_table.setColumnHidden(3, not self.show_csfd_column)
if self.filehandler.current_folder:
folder_config = self.filehandler.get_folder_config()
folder_config["show_csfd_column"] = self.show_csfd_column
self.filehandler.save_folder_config(config=folder_config)
def _update_csfd_column_visibility(self):
"""Update CSFD column from folder config"""
if self.filehandler.current_folder:
folder_config = self.filehandler.get_folder_config()
self.show_csfd_column = folder_config.get("show_csfd_column", True)
self.csfd_column_action.setChecked(self.show_csfd_column)
self.file_table.setColumnHidden(3, not self.show_csfd_column)
def set_csfd_url_for_selected(self):
"""Set CSFD URL for selected files"""
files = self.get_selected_files()
if not files:
self.status_label.setText("Nebyly vybrány žádné soubory")
return
current_url = files[0].csfd_url or ""
url, ok = QInputDialog.getText(
self, "Nastavit CSFD URL",
"Zadej CSFD URL:",
text=current_url
)
if not ok:
return
for f in files:
f.set_csfd_url(url if url != "" else None)
self.update_files_from_manager(self.filehandler.filelist)
self.status_label.setText(f"CSFD URL nastaveno pro {len(files)} soubor(ů)")
def apply_csfd_tags_for_selected(self):
"""Load tags from CSFD"""
files = self.get_selected_files()
if not files:
self.status_label.setText("Nebyly vybrány žádné soubory")
return
files_with_url = [f for f in files if f.csfd_url]
if not files_with_url:
QMessageBox.warning(
self, "Upozornění",
"Žádný z vybraných souborů nemá nastavenou CSFD URL"
)
return
self.status_label.setText(
f"Načítám tagy z CSFD pro {len(files_with_url)} souborů..."
)
QApplication.processEvents()
success_count = 0
error_count = 0
all_tags_added = []
for f in files_with_url:
result = f.apply_csfd_tags()
if result["success"]:
success_count += 1
all_tags_added.extend(result["tags_added"])
else:
error_count += 1
self.refresh_sidebar()
self.update_files_from_manager(self.filehandler.filelist)
if error_count > 0:
QMessageBox.warning(
self, "Dokončeno s chybami",
f"Úspěšně: {success_count}, Chyby: {error_count}\n"
f"Přidáno {len(all_tags_added)} tagů"
)
else:
self.status_label.setText(
f"Načteno z CSFD: {success_count} souborů, "
f"přidáno {len(all_tags_added)} tagů"
)
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.setText("Obnoveno")
def configure_hardlink_folder(self):
"""Configure hardlink output folder"""
if not self.filehandler.current_folder:
QMessageBox.warning(self, "Upozornění", "Nejprve otevřete složku")
return
folder_config = self.filehandler.get_folder_config()
current_dir = folder_config.get("hardlink_output_dir")
current_categories = folder_config.get("hardlink_categories")
initial_dir = current_dir if current_dir else str(self.filehandler.current_folder)
output_dir = QFileDialog.getExistingDirectory(
self, "Vyber cílovou složku pro hardlink strukturu",
initial_dir
)
if not output_dir:
return
categories = self.tagmanager.get_categories()
if not categories:
QMessageBox.warning(self, "Upozornění", "Žádné kategorie tagů")
return
dialog = CategorySelectionDialog(
self, categories, self.category_colors, current_categories
)
if dialog.exec() != QDialog.Accepted:
return
folder_config["hardlink_output_dir"] = output_dir
folder_config["hardlink_categories"] = dialog.result if dialog.result else None
self.filehandler.save_folder_config(config=folder_config)
QMessageBox.information(
self, "Hotovo", f"Hardlink složka nastavena:\n{output_dir}"
)
self.status_label.setText(f"Hardlink složka nastavena: {output_dir}")
def update_hardlink_structure(self):
"""Quick update hardlink structure"""
if not self.filehandler.current_folder:
QMessageBox.warning(self, "Upozornění", "Nejprve otevřete složku")
return
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:
QMessageBox.information(
self, "Info",
"Hardlink složka není nastavena.\n"
"Použijte 'Nastavit hardlink složku...' pro konfiguraci."
)
return
output_path = Path(output_dir)
files = self.filehandler.filelist
if not files:
QMessageBox.warning(self, "Upozornění", "Žádné soubory k zpracování")
return
manager = HardlinkManager(output_path)
preview_create = manager.get_preview(files, saved_categories)
obsolete = manager.find_obsolete_links(files, saved_categories)
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:
QMessageBox.information(
self, "Info",
"Struktura je již synchronizovaná, žádné změny nejsou potřeba"
)
return
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?")
reply = QMessageBox.question(
self, "Potvrdit aktualizaci", "\n".join(confirm_lines)
)
if reply != QMessageBox.Yes:
return
self.status_label.setText("Aktualizuji hardlink strukturu...")
QApplication.processEvents()
created, create_fail, removed, remove_fail = manager.sync_structure(
files, saved_categories
)
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}")
QMessageBox.warning(
self, "Dokončeno s chybami", "\n".join(result_lines)
)
else:
QMessageBox.information(
self, "Hotovo",
"\n".join(result_lines) if result_lines else "Žádné změny"
)
self.status_label.setText(
f"Hardlink struktura aktualizována "
f"(vytvořeno: {created}, odebráno: {removed})"
)
def create_hardlink_structure(self):
"""Create hardlink structure with manual selection"""
files = self.filehandler.filelist
if not files:
QMessageBox.warning(self, "Upozornění", "Žádné soubory k zpracování")
return
output_dir = QFileDialog.getExistingDirectory(
self, "Vyber cílovou složku pro hardlink strukturu"
)
if not output_dir:
return
output_path = Path(output_dir)
categories = self.tagmanager.get_categories()
if not categories:
QMessageBox.warning(self, "Upozornění", "Žádné kategorie tagů")
return
dialog = CategorySelectionDialog(
self, categories, self.category_colors
)
if dialog.exec() != QDialog.Accepted:
return
cat_filter = dialog.result if dialog.result else None
manager = HardlinkManager(output_path)
preview_create = manager.get_preview(files, cat_filter)
obsolete = manager.find_obsolete_links(files, cat_filter)
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:
QMessageBox.information(
self, "Info",
"Struktura je již synchronizovaná, žádné změny nejsou potřeba"
)
return
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?")
reply = QMessageBox.question(
self, "Potvrdit synchronizaci", "\n".join(confirm_lines)
)
if reply != QMessageBox.Yes:
return
self.status_label.setText("Synchronizuji hardlink strukturu...")
QApplication.processEvents()
created, create_fail, removed, remove_fail = manager.sync_structure(
files, cat_filter
)
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"
)
QMessageBox.warning(
self, "Dokončeno s chybami", "\n".join(result_lines)
)
else:
QMessageBox.information(
self, "Hotovo",
"\n".join(result_lines) if result_lines else "Žádné změny"
)
self.status_label.setText(
f"Hardlink struktura synchronizována "
f"(vytvořeno: {created}, odebráno: {removed})"
)
def closeEvent(self, event):
"""Save window state on close"""
is_maximized = self.isMaximized()
self.filehandler.global_config["window_maximized"] = is_maximized
if not is_maximized:
geo = self.geometry()
self.filehandler.global_config["window_geometry"] = (
f"{geo.width()}x{geo.height()}+{geo.x()}+{geo.y()}"
)
save_global_config(self.filehandler.global_config)
event.accept()
class App:
"""Application wrapper for compatibility with existing entry point"""
def __init__(self, filehandler: FileManager, tagmanager: TagManager):
self.filehandler = filehandler
self.tagmanager = tagmanager
def main(self):
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)
window = MainWindow(self.filehandler, self.tagmanager)
window.show()
app.exec()