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