Hardlink generation added

This commit is contained in:
2025-12-28 16:05:34 +01:00
parent 5cdf98bdfe
commit 7e02e57397
21 changed files with 3392 additions and 2343 deletions

View File

@@ -1,22 +1,112 @@
"""
Configuration management for Tagger
Three levels of configuration:
1. Global config (config.json next to Tagger.py) - app-wide settings
2. Folder config (.tagger.json in project root) - folder-specific settings
3. File tags (.filename.!tag) - per-file metadata (handled in file.py)
"""
import json
from pathlib import Path
CONFIG_FILE = Path("config.json")
# Global config file (next to the main script)
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / "config.json"
default_config = {
"ignore_patterns": [],
"last_folder": None
# Folder config filename
FOLDER_CONFIG_NAME = ".tagger.json"
# =============================================================================
# GLOBAL CONFIG - Application settings
# =============================================================================
DEFAULT_GLOBAL_CONFIG = {
"window_geometry": "1200x800",
"window_maximized": False,
"last_folder": None,
"sidebar_width": 250,
"recent_folders": [],
}
def load_config():
if CONFIG_FILE.exists():
def load_global_config() -> dict:
"""Load global application config"""
if GLOBAL_CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
return json.load(f)
with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Merge with defaults for any missing keys
for key, value in DEFAULT_GLOBAL_CONFIG.items():
if key not in config:
config[key] = value
return config
except Exception:
return default_config.copy()
return default_config.copy()
return DEFAULT_GLOBAL_CONFIG.copy()
return DEFAULT_GLOBAL_CONFIG.copy()
def save_global_config(cfg: dict):
"""Save global application config"""
with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
# =============================================================================
# FOLDER CONFIG - Per-folder settings
# =============================================================================
DEFAULT_FOLDER_CONFIG = {
"ignore_patterns": [],
"custom_tags": {}, # Additional tags specific to this folder
"recursive": True, # Whether to scan subfolders
"hardlink_output_dir": None, # Output directory for hardlink structure
"hardlink_categories": None, # Categories to include in hardlink (None = all)
}
def get_folder_config_path(folder: Path) -> Path:
"""Get path to folder config file"""
return folder / FOLDER_CONFIG_NAME
def load_folder_config(folder: Path) -> dict:
"""Load folder-specific config"""
config_path = get_folder_config_path(folder)
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# Merge with defaults for any missing keys
for key, value in DEFAULT_FOLDER_CONFIG.items():
if key not in config:
config[key] = value
return config
except Exception:
return DEFAULT_FOLDER_CONFIG.copy()
return DEFAULT_FOLDER_CONFIG.copy()
def save_folder_config(folder: Path, cfg: dict):
"""Save folder-specific config"""
config_path = get_folder_config_path(folder)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def folder_has_config(folder: Path) -> bool:
"""Check if folder has a tagger config"""
return get_folder_config_path(folder).exists()
# =============================================================================
# BACKWARDS COMPATIBILITY
# =============================================================================
def load_config():
"""Legacy function - returns global config"""
return load_global_config()
def save_config(cfg: dict):
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
"""Legacy function - saves global config"""
save_global_config(cfg)

View File

@@ -1,4 +1,4 @@
# src/core/constants.py
VERSION = "v1.0.2"
VERSION = "v1.0.3"
APP_NAME = "Tagger"
APP_VIEWPORT = "1000x700"

View File

@@ -4,7 +4,11 @@ from .tag_manager import TagManager
from .utils import list_files
from typing import Iterable
import fnmatch
from src.core.config import load_config, save_config
from src.core.config import (
load_global_config, save_global_config,
load_folder_config, save_folder_config
)
class FileManager:
def __init__(self, tagmanager: TagManager):
@@ -12,21 +16,44 @@ class FileManager:
self.folders: list[Path] = []
self.tagmanager = tagmanager
self.on_files_changed = None # callback do GUI
self.config = load_config()
self.global_config = load_global_config()
self.folder_configs: dict[Path, dict] = {} # folder -> config
self.current_folder: Path | None = None
def append(self, folder: Path) -> None:
"""Add a folder to scan for files"""
self.folders.append(folder)
self.config["last_folder"] = str(folder)
save_config(self.config)
self.current_folder = folder
# Update global config with last folder
self.global_config["last_folder"] = str(folder)
# Update recent folders list
recent = self.global_config.get("recent_folders", [])
folder_str = str(folder)
if folder_str in recent:
recent.remove(folder_str)
recent.insert(0, folder_str)
self.global_config["recent_folders"] = recent[:10] # Keep max 10
save_global_config(self.global_config)
# Load folder-specific config
folder_config = load_folder_config(folder)
self.folder_configs[folder] = folder_config
# Get ignore patterns from folder config
ignore_patterns = folder_config.get("ignore_patterns", [])
ignore_patterns = self.config.get("ignore_patterns", [])
for each in list_files(folder):
if each.name.endswith(".!tag"):
continue
if each.name == ".tagger.json":
continue
full_path = each.as_posix() # celá cesta jako string
full_path = each.as_posix()
# kontrolujeme jméno i celou cestu
# Check against ignore patterns
if any(
fnmatch.fnmatch(each.name, pat) or fnmatch.fnmatch(full_path, pat)
for pat in ignore_patterns
@@ -36,6 +63,38 @@ class FileManager:
file_obj = File(each, self.tagmanager)
self.filelist.append(file_obj)
def get_folder_config(self, folder: Path = None) -> dict:
"""Get config for a folder (or current folder if not specified)"""
if folder is None:
folder = self.current_folder
if folder is None:
return {}
if folder not in self.folder_configs:
self.folder_configs[folder] = load_folder_config(folder)
return self.folder_configs[folder]
def save_folder_config(self, folder: Path = None, config: dict = None):
"""Save config for a folder"""
if folder is None:
folder = self.current_folder
if folder is None:
return
if config is None:
config = self.folder_configs.get(folder, {})
self.folder_configs[folder] = config
save_folder_config(folder, config)
def set_ignore_patterns(self, patterns: list[str], folder: Path = None):
"""Set ignore patterns for a folder"""
config = self.get_folder_config(folder)
config["ignore_patterns"] = patterns
self.save_folder_config(folder, config)
def get_ignore_patterns(self, folder: Path = None) -> list[str]:
"""Get ignore patterns for a folder"""
config = self.get_folder_config(folder)
return config.get("ignore_patterns", [])
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
for f in files_objs:
@@ -44,7 +103,6 @@ class FileManager:
category, name = tag.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
else:
# pokud není uvedena kategorie, zařadíme pod "default"
tag_obj = self.tagmanager.add_tag("default", tag)
else:
tag_obj = tag
@@ -60,8 +118,6 @@ class FileManager:
if isinstance(tag, str):
if "/" in tag:
category, name = tag.split("/", 1)
tag_obj = File.__module__ # dummy to satisfy typing (we create Tag below)
# use Tag class directly
from .tag import Tag as TagClass
tag_obj = TagClass(category, name)
else:
@@ -84,7 +140,6 @@ class FileManager:
if not tags_list:
return self.filelist
# normalizuj cílové tagy na full_path stringy
target_full_paths = set()
from .tag import Tag as TagClass
for t in tags_list:
@@ -93,7 +148,6 @@ class FileManager:
elif isinstance(t, str):
target_full_paths.add(t)
else:
# neznámý typ: ignorovat
continue
filtered = []
@@ -101,4 +155,10 @@ class FileManager:
file_tags = {t.full_path for t in f.tags}
if all(tag in file_tags for tag in target_full_paths):
filtered.append(f)
return filtered
return filtered
# Legacy property for backwards compatibility
@property
def config(self):
"""Legacy: returns global config"""
return self.global_config

View File

@@ -0,0 +1,352 @@
"""
Hardlink Manager for Tagger
Creates directory structure based on file tags and creates hardlinks
to organize files without duplicating them on disk.
Example:
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
output/
├── žánr/
│ ├── Komedie/
│ │ └── film.mkv (hardlink)
│ └── Akční/
│ └── film.mkv (hardlink)
└── rok/
└── 1988/
└── film.mkv (hardlink)
"""
import os
from pathlib import Path
from typing import List, Tuple, Optional
from .file import File
class HardlinkManager:
"""Manager for creating hardlink-based directory structures from tagged files."""
def __init__(self, output_dir: Path):
"""
Initialize HardlinkManager.
Args:
output_dir: Base directory where the tag-based structure will be created
"""
self.output_dir = Path(output_dir)
self.created_links: List[Path] = []
self.errors: List[Tuple[Path, str]] = []
def create_structure_for_files(
self,
files: List[File],
categories: Optional[List[str]] = None,
dry_run: bool = False
) -> Tuple[int, int]:
"""
Create hardlink structure for given files based on their tags.
Args:
files: List of File objects to process
categories: Optional list of categories to include (None = all)
dry_run: If True, only simulate without creating actual links
Returns:
Tuple of (successful_links, failed_links)
"""
self.created_links = []
self.errors = []
success_count = 0
fail_count = 0
for file_obj in files:
if not file_obj.tags:
continue
for tag in file_obj.tags:
# Skip if category filter is set and this category is not included
if categories is not None and tag.category not in categories:
continue
# Create target directory path: output/category/tag_name/
target_dir = self.output_dir / tag.category / tag.name
target_file = target_dir / file_obj.filename
try:
if not dry_run:
# Create directory structure
target_dir.mkdir(parents=True, exist_ok=True)
# Skip if link already exists
if target_file.exists():
# Check if it's already a hardlink to the same file
if self._is_same_file(file_obj.file_path, target_file):
continue
else:
# Different file exists, add suffix
target_file = self._get_unique_name(target_file)
# Create hardlink
os.link(file_obj.file_path, target_file)
self.created_links.append(target_file)
success_count += 1
except OSError as e:
self.errors.append((file_obj.file_path, str(e)))
fail_count += 1
return success_count, fail_count
def _is_same_file(self, path1: Path, path2: Path) -> bool:
"""Check if two paths point to the same file (same inode)."""
try:
return path1.stat().st_ino == path2.stat().st_ino
except OSError:
return False
def _get_unique_name(self, path: Path) -> Path:
"""Get a unique filename by adding a numeric suffix."""
stem = path.stem
suffix = path.suffix
parent = path.parent
counter = 1
while True:
new_name = f"{stem}_{counter}{suffix}"
new_path = parent / new_name
if not new_path.exists():
return new_path
counter += 1
def remove_created_links(self) -> int:
"""
Remove all hardlinks created by the last operation.
Returns:
Number of links removed
"""
removed = 0
for link_path in self.created_links:
try:
if link_path.exists() and link_path.is_file():
link_path.unlink()
removed += 1
# Try to remove empty parent directories
self._remove_empty_parents(link_path.parent)
except OSError:
pass
self.created_links = []
return removed
def _remove_empty_parents(self, path: Path) -> None:
"""Remove empty parent directories up to output_dir."""
try:
while path != self.output_dir and path.is_dir():
if any(path.iterdir()):
break # Directory not empty
path.rmdir()
path = path.parent
except OSError:
pass
def get_preview(self, files: List[File], categories: Optional[List[str]] = None) -> List[Tuple[Path, Path]]:
"""
Get a preview of what links would be created.
Args:
files: List of File objects
categories: Optional list of categories to include
Returns:
List of tuples (source_path, target_path)
"""
preview = []
for file_obj in files:
if not file_obj.tags:
continue
for tag in file_obj.tags:
if categories is not None and tag.category not in categories:
continue
target_dir = self.output_dir / tag.category / tag.name
target_file = target_dir / file_obj.filename
preview.append((file_obj.file_path, target_file))
return preview
def find_obsolete_links(
self,
files: List[File],
categories: Optional[List[str]] = None
) -> List[Tuple[Path, Path]]:
"""
Find hardlinks in the output directory that no longer match file tags.
Scans the output directory for hardlinks that point to source files,
but whose category/tag path no longer matches the file's current tags.
Args:
files: List of File objects (source files)
categories: Optional list of categories to check (None = all)
Returns:
List of tuples (link_path, source_path) for obsolete links
"""
obsolete = []
if not self.output_dir.exists():
return obsolete
# Build a map of source file inodes to File objects
inode_to_file: dict[int, File] = {}
for file_obj in files:
try:
inode = file_obj.file_path.stat().st_ino
inode_to_file[inode] = file_obj
except OSError:
continue
# Build expected paths for each file based on current tags
expected_paths: dict[int, set[Path]] = {}
for file_obj in files:
try:
inode = file_obj.file_path.stat().st_ino
expected_paths[inode] = set()
for tag in file_obj.tags:
if categories is not None and tag.category not in categories:
continue
target = self.output_dir / tag.category / tag.name / file_obj.filename
expected_paths[inode].add(target)
except OSError:
continue
# Scan output directory for existing hardlinks
for category_dir in self.output_dir.iterdir():
if not category_dir.is_dir():
continue
# Filter by categories if specified
if categories is not None and category_dir.name not in categories:
continue
for tag_dir in category_dir.iterdir():
if not tag_dir.is_dir():
continue
for link_file in tag_dir.iterdir():
if not link_file.is_file():
continue
try:
link_inode = link_file.stat().st_ino
# Check if this inode belongs to one of our source files
if link_inode in inode_to_file:
source_file = inode_to_file[link_inode]
# Check if this link path is expected
if link_inode in expected_paths:
if link_file not in expected_paths[link_inode]:
# This link exists but tag was removed
obsolete.append((link_file, source_file.file_path))
except OSError:
continue
return obsolete
def remove_obsolete_links(
self,
files: List[File],
categories: Optional[List[str]] = None,
dry_run: bool = False
) -> Tuple[int, List[Path]]:
"""
Remove hardlinks that no longer match file tags.
Args:
files: List of File objects
categories: Optional list of categories to check
dry_run: If True, only return what would be removed
Returns:
Tuple of (removed_count, list_of_removed_paths)
"""
obsolete = self.find_obsolete_links(files, categories)
removed_paths = []
if dry_run:
return len(obsolete), [link for link, _ in obsolete]
for link_path, _ in obsolete:
try:
link_path.unlink()
removed_paths.append(link_path)
# Try to remove empty parent directories
self._remove_empty_parents(link_path.parent)
except OSError:
pass
return len(removed_paths), removed_paths
def sync_structure(
self,
files: List[File],
categories: Optional[List[str]] = None,
dry_run: bool = False
) -> Tuple[int, int, int, int]:
"""
Synchronize hardlink structure with current file tags.
This will:
1. Remove hardlinks for removed tags
2. Create new hardlinks for new tags
Args:
files: List of File objects
categories: Optional list of categories to sync
dry_run: If True, only simulate
Returns:
Tuple of (created, create_failed, removed, remove_failed)
"""
# First find how many obsolete links there are
obsolete_count = len(self.find_obsolete_links(files, categories))
# Remove obsolete links
removed, removed_paths = self.remove_obsolete_links(files, categories, dry_run)
remove_failed = obsolete_count - removed if not dry_run else 0
# Then create new links
created, create_failed = self.create_structure_for_files(files, categories, dry_run)
return created, create_failed, removed, remove_failed
def create_hardlink_structure(
files: List[File],
output_dir: Path,
categories: Optional[List[str]] = None
) -> Tuple[int, int, List[Tuple[Path, str]]]:
"""
Convenience function to create hardlink structure.
Args:
files: List of File objects to process
output_dir: Base directory for output
categories: Optional list of categories to include
Returns:
Tuple of (successful_count, failed_count, errors_list)
"""
manager = HardlinkManager(output_dir)
success, fail = manager.create_structure_for_files(files, categories)
return success, fail, manager.errors

View File

@@ -1,8 +1,22 @@
from .tag import Tag
# Default tags that are always available
DEFAULT_TAGS = {
"Hodnocení": ["", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"],
"Barva": ["🔴 Červená", "🟠 Oranžová", "🟡 Žlutá", "🟢 Zelená", "🔵 Modrá", "🟣 Fialová"],
}
class TagManager:
def __init__(self):
self.tags_by_category = {} # {category: set(Tag)}
self._init_default_tags()
def _init_default_tags(self):
"""Initialize default tags (ratings and colors)"""
for category, tags in DEFAULT_TAGS.items():
for tag_name in tags:
self.add_tag(category, tag_name)
def add_category(self, category: str):
if category not in self.tags_by_category:

File diff suppressed because it is too large Load Diff

View File

@@ -1,712 +0,0 @@
"""
Modern qBittorrent-style GUI for Tagger
"""
import os
import sys
import subprocess
import tkinter as tk
from tkinter import ttk, simpledialog, messagebox, filedialog
from pathlib import Path
from typing import List
from src.core.media_utils import load_icon
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.core.file import File
from src.core.tag import Tag
from src.core.list_manager import ListManager
from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT
from src.core.config import save_config
# 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",
}
class ModernApp:
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.filehandler.on_files_changed = self.update_files_from_manager
def main(self):
root = tk.Tk()
root.title(f"{APP_NAME} {VERSION}")
root.geometry(APP_VIEWPORT)
root.configure(bg=COLORS["bg"])
self.root = root
self.hide_ignored_var = tk.BooleanVar(value=False, master=root)
# Load last folder
last = self.filehandler.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)
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)
# Sort options
tk.Label(control_frame, text="Třídění:", bg=COLORS["bg"]).pack(side=tk.LEFT, padx=(15, 5))
self.sort_combo = ttk.Combobox(control_frame, values=["Název", "Datum"], width=10, state="readonly")
self.sort_combo.current(0)
self.sort_combo.bind("<<ComboboxSelected>>", lambda e: self.toggle_sort_mode())
self.sort_combo.pack(side=tk.LEFT)
self.order_var = tk.StringVar(value="▲ Vzestupně")
order_btn = tk.Button(control_frame, textvariable=self.order_var, command=self.toggle_sort_order,
relief=tk.FLAT, bg=COLORS["bg"])
order_btn.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
self.file_table.heading("name", text="📄 Název souboru")
self.file_table.heading("date", text="📅 Datum")
self.file_table.heading("tags", text="🏷️ Štítky")
self.file_table.heading("size", text="💾 Velikost")
# 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)
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)
# Middle - 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)
# Add root
root_id = self.tag_tree.insert("", "end", text="📂 Všechny tagy", image=self.icons.get("tag"))
self.tag_tree.item(root_id, open=True)
self.root_tag_id = root_id
# Add categories and tags
for category in self.tagmanager.get_categories():
cat_id = self.tag_tree.insert(root_id, "end", text=f"📁 {category}", image=self.icons.get("tag"))
self.states[cat_id] = False
for tag in self.tagmanager.get_tags_in_category(category):
tag_id = self.tag_tree.insert(cat_id, "end", text=f" {tag.name}",
image=self.icons.get("unchecked"))
self.states[tag_id] = False
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("📁 ", "")
name = self.tag_tree.item(item_id, "text").strip()
tags.append(Tag(category, 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)
# 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ů")
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_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
# Import the dialog from old GUI
from src.ui.gui_old import MultiFileTagAssignDialog
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)
result = getattr(dialog, "result", None)
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"""
current = ", ".join(self.filehandler.config.get("ignore_patterns", []))
s = simpledialog.askstring("Ignore patterns",
"Zadej patterny oddělené čárkou (např. *.png, *.tmp):",
initialvalue=current)
if s is None:
return
patterns = [p.strip() for p in s.split(",") if p.strip()]
self.filehandler.config["ignore_patterns"] = patterns
save_config(self.filehandler.config)
self.update_files_from_manager(self.filehandler.filelist)
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 toggle_sort_mode(self):
"""Toggle sort mode"""
selected = self.sort_combo.get()
self.sort_mode = "date" if selected == "Datum" else "name"
self.update_files_from_manager(self.filehandler.filelist)
def toggle_sort_order(self):
"""Toggle sort order"""
self.sort_order = "desc" if self.sort_order == "asc" else "asc"
self.order_var.set("▼ Sestupně" if self.sort_order == "desc" else "▲ Vzestupně")
self.update_files_from_manager(self.filehandler.filelist)
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")

View File

@@ -1,711 +0,0 @@
import os
import sys
import subprocess
import tkinter as tk
from tkinter import ttk, simpledialog, messagebox, filedialog
from pathlib import Path
from typing import List
from src.core.media_utils import load_icon
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.core.file import File
from src.core.tag import Tag
from src.core.list_manager import ListManager
from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT
from src.core.config import save_config # <-- doplněno
class TagSelectionDialog(tk.Toplevel):
"""
Jednoduchý dialog pro výběr tagů (původní, používán jinde).
(tento třída zůstává pro jednobodové použití)
"""
def __init__(self, parent, tags: list[str]):
super().__init__(parent)
self.title("Vyber tagy")
self.selected_tags = []
self.vars = {}
tk.Label(self, text="Vyber tagy k přiřazení:").pack(pady=5)
frame = tk.Frame(self)
frame.pack(padx=10, pady=5)
for tag in tags:
var = tk.BooleanVar(value=False)
chk = tk.Checkbutton(frame, text=tag, variable=var)
chk.pack(anchor="w")
self.vars[tag] = var
btn_frame = tk.Frame(self)
btn_frame.pack(pady=5)
tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5)
tk.Button(btn_frame, text="Zrušit", command=self.destroy).pack(side="left", padx=5)
self.transient(parent)
self.grab_set()
parent.wait_window(self)
def on_ok(self):
self.selected_tags = [tag for tag, var in self.vars.items() if var.get()]
self.destroy()
class MultiFileTagAssignDialog(tk.Toplevel):
def __init__(self, parent, all_tags: List[Tag], files: List[File]):
super().__init__(parent)
self.title("Přiřadit tagy k vybraným souborům")
self.vars: dict[str, int] = {}
self.checkbuttons: dict[str, tk.Checkbutton] = {}
self.tags_by_full = {t.full_path: t for t in all_tags}
self.files = files
tk.Label(self, text=f"Vybráno souborů: {len(files)}").pack(pady=5)
frame = tk.Frame(self)
frame.pack(padx=10, pady=5, fill="both", expand=True)
file_tag_sets = [{t.full_path for t in f.tags} for f in files]
for full_path, tag in sorted(self.tags_by_full.items()):
have_count = sum(1 for s in file_tag_sets if full_path in s)
if have_count == 0:
init = 0
elif have_count == len(files):
init = 1
else:
init = 2 # mixed
cb = tk.Checkbutton(frame, text=full_path, anchor="w")
cb.state_value = init
cb.full_path = full_path
cb.pack(fill="x", anchor="w")
cb.bind("<Button-1>", self._on_toggle)
self._update_checkbox_look(cb)
self.checkbuttons[full_path] = cb
self.vars[full_path] = init
btn_frame = tk.Frame(self)
btn_frame.pack(pady=5)
tk.Button(btn_frame, text="OK", command=self.on_ok).pack(side="left", padx=5)
tk.Button(btn_frame, text="Cancel", command=self.destroy).pack(side="left", padx=5)
self.transient(parent)
self.grab_set()
parent.wait_window(self)
def _on_toggle(self, event):
cb: tk.Checkbutton = event.widget
cur = cb.state_value
if cur == 0: # OFF → ON
cb.state_value = 1
elif cur == 1: # ON → OFF
cb.state_value = 0
elif cur == 2: # MIXED → ON
cb.state_value = 1
self._update_checkbox_look(cb)
return "break"
def _update_checkbox_look(self, cb: tk.Checkbutton):
"""Aktualizuje vizuál podle stavu."""
v = cb.state_value
if v == 0:
cb.deselect()
cb.config(fg="black")
elif v == 1:
cb.select()
cb.config(fg="blue")
elif v == 2:
cb.deselect() # mixed = nezaškrtnuté, ale červený text
cb.config(fg="red")
def on_ok(self):
self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()}
self.destroy()
class App:
def __init__(self, filehandler: FileManager, tagmanager: TagManager):
self.states = {}
self.listbox_map: dict[int, list[File]] = {}
self.selected_tree_item_for_context = None
self.selected_list_index_for_context = None
self.filehandler = filehandler
self.tagmanager = tagmanager
self.list_manager = ListManager()
# tady jen připravíme proměnnou, ale nevytváříme BooleanVar!
self.hide_ignored_var = None
self.filter_text = ""
self.show_full_path = False
self.sort_mode = "name"
self.sort_order = "asc"
self.filehandler.on_files_changed = self.update_files_from_manager
def detect_video_resolution(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
count = 0
for f in files:
try:
path = str(f.file_path)
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=height", "-of", "csv=p=0", path],
capture_output=True,
text=True,
check=True
)
height_str = result.stdout.strip()
if not height_str.isdigit():
continue
height = int(height_str)
tag_name = f"{height}p"
tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name)
f.add_tag(tag_obj)
count += 1
except Exception as e:
print(f"Chyba u {f.filename}: {e}")
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Přiřazeno rozlišení tagů k {count} souborům")
# ==================================================
# MAIN GUI
# ==================================================
def main(self):
root = tk.Tk()
root.title(APP_NAME + " " + VERSION)
root.geometry(APP_VIEWPORT)
self.root = root
# teď už máme root, takže můžeme vytvořit BooleanVar
self.hide_ignored_var = tk.BooleanVar(value=False, master=root)
last = self.filehandler.config.get("last_folder")
if last:
try:
self.filehandler.append(Path(last))
except Exception:
pass
# ---- Ikony
unchecked = load_icon("src/resources/images/32/32_unchecked.png")
checked = load_icon("src/resources/images/32/32_checked.png")
tag_icon = load_icon("src/resources/images/32/32_tag.png")
self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon}
root.unchecked_img = unchecked
root.checked_img = checked
root.tag_img = tag_icon
# ---- Layout
menu_bar = tk.Menu(root)
root.config(menu=menu_bar)
file_menu = tk.Menu(menu_bar, tearoff=0)
file_menu.add_command(label="Open Folder...", command=self.open_folder_dialog)
file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=root.quit)
view_menu = tk.Menu(menu_bar, tearoff=0)
view_menu.add_checkbutton(
label="Skrýt ignorované",
variable=self.hide_ignored_var,
command=self.toggle_hide_ignored
)
function_menu = tk.Menu(menu_bar, tearoff=0)
function_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
function_menu.add_command(label="Detekovat rozlišení u videí", command=self.detect_video_resolution)
menu_bar.add_cascade(label="Soubor", menu=file_menu)
menu_bar.add_cascade(label="Pohled", menu=view_menu)
menu_bar.add_cascade(label="Funkce", menu=function_menu)
main_frame = tk.Frame(root)
main_frame.pack(fill="both", expand=True)
main_frame.columnconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=2)
main_frame.rowconfigure(0, weight=1)
# ---- Tree (left)
self.tree = ttk.Treeview(main_frame)
self.tree.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
self.tree.bind("<Button-1>", self.on_tree_left_click)
self.tree.bind("<Button-3>", self.on_tree_right_click)
# ---- Right side (filter + listbox)
right_frame = tk.Frame(main_frame)
right_frame.grid(row=0, column=1, sticky="nsew", padx=4, pady=4)
right_frame.rowconfigure(1, weight=1)
right_frame.columnconfigure(0, weight=1)
# Filter + buttons row
filter_frame = tk.Frame(right_frame)
filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 4))
filter_frame.columnconfigure(0, weight=1)
self.filter_entry = tk.Entry(filter_frame)
self.filter_entry.grid(row=0, column=0, sticky="ew")
self.filter_entry.bind("<KeyRelease>", lambda e: self.on_filter_changed())
self.btn_toggle_path = tk.Button(filter_frame, text="Name", width=6, command=self.toggle_show_path)
self.btn_toggle_path.grid(row=0, column=1, padx=2)
self.btn_toggle_sortmode = tk.Button(filter_frame, text="Sort:Name", width=8, command=self.toggle_sort_mode)
self.btn_toggle_sortmode.grid(row=0, column=2, padx=2)
self.btn_toggle_order = tk.Button(filter_frame, text="ASC", width=5, command=self.toggle_sort_order)
self.btn_toggle_order.grid(row=0, column=3, padx=2)
# Listbox + scrollbar
self.listbox = tk.Listbox(right_frame, selectmode="extended")
self.listbox.grid(row=1, column=0, sticky="nsew")
self.listbox.bind("<Double-1>", self.on_list_double)
self.listbox.bind("<Button-3>", self.on_list_right_click)
lb_scroll = tk.Scrollbar(right_frame, orient="vertical", command=self.listbox.yview)
lb_scroll.grid(row=1, column=1, sticky="ns")
self.listbox.config(yscrollcommand=lb_scroll.set)
# ---- Status bar
self.status_bar = tk.Label(root, text="Připraven", anchor="w", relief="sunken")
self.status_bar.pack(side="bottom", fill="x")
# ---- Context menus
self.tree_menu = tk.Menu(root, tearoff=0)
self.tree_menu.add_command(label="Nový štítek", command=self.tree_add_tag)
self.tree_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag)
self.list_menu = tk.Menu(root, tearoff=0)
self.list_menu.add_command(label="Otevřít soubor", command=self.list_open_file)
self.list_menu.add_command(label="Smazat z indexu", command=self.list_remove_file)
self.list_menu.add_command(label="Nastavit datum", command=self.set_date_for_selected)
self.list_menu.add_command(label="Přiřadit štítek", command=self.assign_tag_to_selected_bulk)
# ---- Root node
root_id = self.tree.insert("", "end", text="Štítky", image=self.icons["tag"])
self.tree.item(root_id, open=True)
self.root_id = root_id
# ⚡ refresh při startu
self.refresh_tree_tags()
self.update_files_from_manager(self.filehandler.filelist)
root.mainloop()
# ==================================================
# FILTER + SORT TOGGLES
# ==================================================
def set_ignore_patterns(self):
current = ", ".join(self.filehandler.config.get("ignore_patterns", []))
s = simpledialog.askstring("Ignore patterns", "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", initialvalue=current)
if s is None:
return
patterns = [p.strip() for p in s.split(",") if p.strip()]
self.filehandler.config["ignore_patterns"] = patterns
save_config(self.filehandler.config)
self.update_files_from_manager(self.filehandler.filelist)
def toggle_hide_ignored(self):
self.update_files_from_manager(self.filehandler.filelist)
def on_filter_changed(self):
self.filter_text = self.filter_entry.get().strip().lower()
self.update_files_from_manager(self.filehandler.filelist)
def toggle_show_path(self):
self.show_full_path = not self.show_full_path
self.btn_toggle_path.config(text="Path" if self.show_full_path else "Name")
self.update_files_from_manager(self.filehandler.filelist)
def toggle_sort_mode(self):
self.sort_mode = "date" if self.sort_mode == "name" else "name"
self.btn_toggle_sortmode.config(text=f"Sort:{self.sort_mode.capitalize()}")
self.update_files_from_manager(self.filehandler.filelist)
def toggle_sort_order(self):
self.sort_order = "desc" if self.sort_order == "asc" else "asc"
self.btn_toggle_order.config(text=self.sort_order.upper())
self.update_files_from_manager(self.filehandler.filelist)
# ==================================================
# FILE REFRESH + MAP
# ==================================================
def update_files_from_manager(self, filelist=None):
if filelist is None:
filelist = self.filehandler.filelist
# filtr tagy
checked_tags = self.get_checked_tags()
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
# filtr text
if self.filter_text:
filtered_files = [
f for f in filtered_files
if self.filter_text in f.filename.lower() or
(self.show_full_path and self.filter_text in str(f.file_path).lower())
]
if self.hide_ignored_var and self.hide_ignored_var.get():
filtered_files = [
f for f in filtered_files
if "Stav/Ignorované" not in {t.full_path for t in f.tags}
]
# řazení
reverse = (self.sort_order == "desc")
if self.sort_mode == "name":
filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse)
elif self.sort_mode == "date":
filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse)
# naplníme listbox
self.listbox.delete(0, "end")
self.listbox_map = {}
for i, f in enumerate(filtered_files):
if self.show_full_path:
display = str(f.file_path)
else:
display = f.filename
if f.date:
display = f"{display}{f.date}"
self.listbox.insert("end", display)
self.listbox_map[i] = [f]
self.status_bar.config(text=f"Zobrazeno {self.listbox.size()} položek")
# ==================================================
# GET SELECTED FILES
# ==================================================
def get_selected_files_objects(self):
indices = self.listbox.curselection()
files = []
for idx in indices:
files.extend(self.listbox_map.get(idx, []))
return files
# ==================================================
# ASSIGN TAG (jednoduchý)
# ==================================================
def assign_tag_to_selected(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
all_tags: List[Tag] = []
for category in self.tagmanager.get_categories():
for tag in self.tagmanager.get_tags_in_category(category):
all_tags.append(tag)
if not all_tags:
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
return
tag_strings = [tag.full_path for tag in all_tags]
dialog = TagSelectionDialog(self.root, tag_strings)
selected_tag_strings = dialog.selected_tags
if not selected_tag_strings:
self.status_bar.config(text="Nebyl vybrán žádný tag")
return
selected_tags: list[Tag] = []
for full_tag in selected_tag_strings:
if "/" in full_tag:
category, name = full_tag.split("/", 1)
selected_tags.append(self.tagmanager.add_tag(category, name))
for tag in selected_tags:
self.filehandler.assign_tag_to_file_objects(files, tag)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Přiřazeny tagy: {', '.join(selected_tag_strings)}")
# ==================================================
# ASSIGN TAG (pokročilé pro více souborů - tri-state)
# ==================================================
def assign_tag_to_selected_bulk(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
all_tags: List[Tag] = []
for category in self.tagmanager.get_categories():
for tag in self.tagmanager.get_tags_in_category(category):
all_tags.append(tag)
if not all_tags:
messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány")
return
dialog = MultiFileTagAssignDialog(self.root, all_tags, files)
result = getattr(dialog, "result", None)
if result is None:
self.status_bar.config(text="Přiřazení zrušeno")
return
for full_path, state in result.items():
if state == 1:
if "/" in full_path:
category, name = full_path.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
self.filehandler.assign_tag_to_file_objects(files, tag_obj)
elif state == 0:
if "/" in full_path:
category, name = full_path.split("/", 1)
from src.core.tag import Tag as TagClass
tag_obj = TagClass(category, name)
self.filehandler.remove_tag_from_file_objects(files, tag_obj)
else:
continue
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text="Hromadné přiřazení tagů dokončeno")
# ==================================================
# SET DATE FOR SELECTED FILES
# ==================================================
def set_date_for_selected(self):
files = self.get_selected_files_objects()
if not files:
self.status_bar.config(text="Nebyly vybrány žádné soubory")
return
prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):"
date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root)
if date_str is None:
return
for f in files:
f.set_date(date_str if date_str != "" else None)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)")
# ==================================================
# DOUBLE CLICK OPEN
# ==================================================
def on_list_double(self, event):
for f in self.get_selected_files_objects():
self.open_file(f.file_path)
# ==================================================
# OPEN FILE
# ==================================================
def open_file(self, path):
try:
if sys.platform.startswith("win"):
os.startfile(path)
elif sys.platform.startswith("darwin"):
subprocess.call(["open", path])
else:
subprocess.call(["xdg-open", path])
self.status_bar.config(text=f"Otevírám: {path}")
except Exception as e:
messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}")
# ==================================================
# LIST CONTEXT MENU
# ==================================================
def on_list_right_click(self, event):
idx = self.listbox.nearest(event.y)
if idx is None:
return
# pokud položka není součástí aktuálního výběru, přidáme ji
if idx not in self.listbox.curselection():
self.listbox.selection_set(idx)
self.selected_list_index_for_context = idx
self.list_menu.tk_popup(event.x_root, event.y_root)
def list_open_file(self):
for f in self.get_selected_files_objects():
self.open_file(f.file_path)
def list_remove_file(self):
files = self.get_selected_files_objects()
if not files:
return
ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?")
if ans:
for f in files:
if f in self.filehandler.filelist:
self.filehandler.filelist.remove(f)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Odstraněno {len(files)} souborů z indexu")
# ==================================================
# OPEN FOLDER
# ==================================================
def open_folder_dialog(self):
folder = filedialog.askdirectory(title="Vyber složku pro sledování")
if not folder:
return
folder_path = Path(folder)
try:
self.filehandler.append(folder_path)
for f in self.filehandler.filelist:
if f.tags and f.tagmanager:
for t in f.tags:
f.tagmanager.add_tag(t.category, t.name)
self.status_bar.config(text=f"Přidána složka: {folder_path}")
self.refresh_tree_tags()
self.update_files_from_manager(self.filehandler.filelist)
except Exception as e:
messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}")
# ==================================================
# TREE EVENTS
# ==================================================
def on_tree_left_click(self, event):
region = self.tree.identify("region", event.x, event.y)
if region not in ("tree", "icon"):
return
item_id = self.tree.identify_row(event.y)
if not item_id:
return
parent_id = self.tree.parent(item_id)
if parent_id == "" or parent_id == self.root_id:
is_open = self.tree.item(item_id, "open")
self.tree.item(item_id, open=not is_open)
return
self.states[item_id] = not self.states.get(item_id, False)
self.tree.item(
item_id,
image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]
)
self.status_bar.config(
text=f"Tag {'checked' if self.states[item_id] else 'unchecked'}: {self.build_full_tag(item_id)}"
)
checked_tags = self.get_checked_tags()
filtered_files = self.filehandler.filter_files_by_tags(checked_tags)
self.update_files_from_manager(filtered_files)
def on_tree_right_click(self, event):
item_id = self.tree.identify_row(event.y)
if item_id:
self.selected_tree_item_for_context = item_id
self.tree.selection_set(item_id)
self.tree_menu.tk_popup(event.x_root, event.y_root)
else:
menu = tk.Menu(self.root, tearoff=0)
menu.add_command(label="Nový top-level tag", command=lambda: self.tree_add_tag(background=True))
menu.tk_popup(event.x_root, event.y_root)
# ==================================================
# TREE TAG CRUD
# ==================================================
def tree_add_tag(self, background=False):
name = simpledialog.askstring("Nový tag", "Název tagu:")
if not name:
return
parent = self.selected_tree_item_for_context if not background else self.root_id
new_id = self.tree.insert(parent, "end", text=name, image=self.icons["unchecked"])
self.states[new_id] = False
if parent == self.root_id:
category = name
self.tagmanager.add_category(category)
self.tree.item(new_id, image=self.icons["tag"])
else:
category = self.tree.item(parent, "text")
self.tagmanager.add_tag(category, name)
self.status_bar.config(text=f"Vytvořen tag: {self.build_full_tag(new_id)}")
def tree_delete_tag(self):
item = self.selected_tree_item_for_context
if not item:
return
full = self.build_full_tag(item)
ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{full}'?")
if not ans:
return
tag_name = self.tree.item(item, "text")
parent_id = self.tree.parent(item)
self.tree.delete(item)
self.states.pop(item, None)
if parent_id == self.root_id:
self.tagmanager.remove_category(tag_name)
else:
category = self.tree.item(parent_id, "text")
self.tagmanager.remove_tag(category, tag_name)
self.update_files_from_manager(self.filehandler.filelist)
self.status_bar.config(text=f"Smazán tag: {full}")
# ==================================================
# TREE HELPERS
# ==================================================
def build_full_tag(self, item_id):
parts = []
cur = item_id
while cur and cur != self.root_id:
parts.append(self.tree.item(cur, "text"))
cur = self.tree.parent(cur)
parts.reverse()
return "/".join(parts) if parts else ""
def get_checked_full_tags(self):
return {self.build_full_tag(i) for i, v in self.states.items() if v}
def refresh_tree_tags(self):
for child in self.tree.get_children(self.root_id):
self.tree.delete(child)
for category in self.tagmanager.get_categories():
cat_id = self.tree.insert(self.root_id, "end", text=category, image=self.icons["tag"])
self.states[cat_id] = False
for tag in self.tagmanager.get_tags_in_category(category):
tag_id = self.tree.insert(cat_id, "end", text=tag.name, image=self.icons["unchecked"])
self.states[tag_id] = False
self.tree.item(self.root_id, open=True)
def get_checked_tags(self) -> List[Tag]:
tags: List[Tag] = []
for item_id, checked in self.states.items():
if not checked:
continue
parent_id = self.tree.parent(item_id)
if parent_id == self.root_id:
continue
category = self.tree.item(parent_id, "text")
name = self.tree.item(item_id, "text")
tags.append(Tag(category, name))
return tags
def _get_checked_recursive(self, item):
tags = []
if self.states.get(item, False):
parent = self.tree.parent(item)
if parent and parent != self.root_id:
parent_text = self.tree.item(parent, "text")
text = self.tree.item(item, "text")
tags.append(f"{parent_text}/{text}")
for child in self.tree.get_children(item):
tags.extend(self._get_checked_recursive(child))
return tags