CSFD integration
This commit is contained in:
@@ -96,17 +96,3 @@ def save_folder_config(folder: Path, cfg: dict):
|
||||
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):
|
||||
"""Legacy function - saves global config"""
|
||||
save_global_config(cfg)
|
||||
|
||||
375
src/core/csfd.py
Normal file
375
src/core/csfd.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
CSFD.cz scraper module for fetching movie information.
|
||||
|
||||
This module provides functionality to fetch movie data from CSFD.cz (Czech-Slovak Film Database).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from urllib.parse import urljoin
|
||||
|
||||
try:
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
HAS_DEPENDENCIES = True
|
||||
except ImportError:
|
||||
HAS_DEPENDENCIES = False
|
||||
requests = None # type: ignore
|
||||
BeautifulSoup = None # type: ignore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
CSFD_BASE_URL = "https://www.csfd.cz"
|
||||
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
|
||||
|
||||
# User agent to avoid being blocked
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept-Language": "cs,en;q=0.9",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSFDMovie:
|
||||
"""Represents movie data from CSFD.cz"""
|
||||
title: str
|
||||
url: str
|
||||
year: Optional[int] = None
|
||||
genres: list[str] = field(default_factory=list)
|
||||
directors: list[str] = field(default_factory=list)
|
||||
actors: list[str] = field(default_factory=list)
|
||||
rating: Optional[int] = None # Percentage 0-100
|
||||
rating_count: Optional[int] = None
|
||||
duration: Optional[int] = None # Minutes
|
||||
country: Optional[str] = None
|
||||
poster_url: Optional[str] = None
|
||||
plot: Optional[str] = None
|
||||
csfd_id: Optional[int] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
parts = [self.title]
|
||||
if self.year:
|
||||
parts[0] += f" ({self.year})"
|
||||
if self.rating is not None:
|
||||
parts.append(f"Hodnocení: {self.rating}%")
|
||||
if self.genres:
|
||||
parts.append(f"Žánr: {', '.join(self.genres)}")
|
||||
if self.directors:
|
||||
parts.append(f"Režie: {', '.join(self.directors)}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _check_dependencies():
|
||||
"""Check if required dependencies are installed."""
|
||||
if not HAS_DEPENDENCIES:
|
||||
raise ImportError(
|
||||
"CSFD module requires 'requests' and 'beautifulsoup4' packages. "
|
||||
"Install them with: pip install requests beautifulsoup4"
|
||||
)
|
||||
|
||||
|
||||
def _extract_csfd_id(url: str) -> Optional[int]:
|
||||
"""Extract CSFD movie ID from URL."""
|
||||
match = re.search(r"/film/(\d+)", url)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _parse_duration(duration_str: str) -> Optional[int]:
|
||||
"""Parse ISO 8601 duration (PT97M) to minutes."""
|
||||
match = re.search(r"PT(\d+)M", duration_str)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def fetch_movie(url: str) -> CSFDMovie:
|
||||
"""
|
||||
Fetch movie information from CSFD.cz URL.
|
||||
|
||||
Args:
|
||||
url: Full CSFD.cz movie URL (e.g., https://www.csfd.cz/film/9423-pane-vy-jste-vdova/)
|
||||
|
||||
Returns:
|
||||
CSFDMovie object with extracted data
|
||||
|
||||
Raises:
|
||||
ImportError: If required dependencies are not installed
|
||||
requests.RequestException: If network request fails
|
||||
ValueError: If URL is invalid or page cannot be parsed
|
||||
"""
|
||||
_check_dependencies()
|
||||
|
||||
response = requests.get(url, headers=HEADERS, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# Try to extract JSON-LD structured data first (most reliable)
|
||||
movie_data = _extract_json_ld(soup)
|
||||
|
||||
# Extract additional data from HTML
|
||||
movie_data["url"] = url
|
||||
movie_data["csfd_id"] = _extract_csfd_id(url)
|
||||
|
||||
# Get rating from HTML if not in JSON-LD
|
||||
if movie_data.get("rating") is None:
|
||||
movie_data["rating"] = _extract_rating(soup)
|
||||
|
||||
# Get poster URL
|
||||
if movie_data.get("poster_url") is None:
|
||||
movie_data["poster_url"] = _extract_poster(soup)
|
||||
|
||||
# Get plot summary
|
||||
if movie_data.get("plot") is None:
|
||||
movie_data["plot"] = _extract_plot(soup)
|
||||
|
||||
# Get country and year from origin info
|
||||
origin_info = _extract_origin_info(soup)
|
||||
if origin_info:
|
||||
if movie_data.get("country") is None:
|
||||
movie_data["country"] = origin_info.get("country")
|
||||
if movie_data.get("year") is None:
|
||||
movie_data["year"] = origin_info.get("year")
|
||||
if movie_data.get("duration") is None:
|
||||
movie_data["duration"] = origin_info.get("duration")
|
||||
|
||||
# Get genres from HTML if not in JSON-LD
|
||||
if not movie_data.get("genres"):
|
||||
movie_data["genres"] = _extract_genres(soup)
|
||||
|
||||
return CSFDMovie(**movie_data)
|
||||
|
||||
|
||||
def _extract_json_ld(soup: BeautifulSoup) -> dict:
|
||||
"""Extract movie data from JSON-LD structured data."""
|
||||
data = {
|
||||
"title": "",
|
||||
"year": None,
|
||||
"genres": [],
|
||||
"directors": [],
|
||||
"actors": [],
|
||||
"rating": None,
|
||||
"rating_count": None,
|
||||
"duration": None,
|
||||
"country": None,
|
||||
"poster_url": None,
|
||||
"plot": None,
|
||||
}
|
||||
|
||||
# Find JSON-LD script
|
||||
script_tags = soup.find_all("script", type="application/ld+json")
|
||||
for script in script_tags:
|
||||
try:
|
||||
json_data = json.loads(script.string)
|
||||
|
||||
# Handle both single object and array
|
||||
if isinstance(json_data, list):
|
||||
for item in json_data:
|
||||
if item.get("@type") == "Movie":
|
||||
json_data = item
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
if json_data.get("@type") != "Movie":
|
||||
continue
|
||||
|
||||
# Title
|
||||
data["title"] = json_data.get("name", "")
|
||||
|
||||
# Genres
|
||||
genre = json_data.get("genre", [])
|
||||
if isinstance(genre, str):
|
||||
data["genres"] = [genre]
|
||||
else:
|
||||
data["genres"] = list(genre)
|
||||
|
||||
# Directors
|
||||
directors = json_data.get("director", [])
|
||||
if isinstance(directors, dict):
|
||||
directors = [directors]
|
||||
data["directors"] = [d.get("name", "") for d in directors if d.get("name")]
|
||||
|
||||
# Actors
|
||||
actors = json_data.get("actor", [])
|
||||
if isinstance(actors, dict):
|
||||
actors = [actors]
|
||||
data["actors"] = [a.get("name", "") for a in actors if a.get("name")]
|
||||
|
||||
# Rating
|
||||
agg_rating = json_data.get("aggregateRating", {})
|
||||
if agg_rating:
|
||||
rating_value = agg_rating.get("ratingValue")
|
||||
if rating_value is not None:
|
||||
data["rating"] = round(float(rating_value))
|
||||
data["rating_count"] = agg_rating.get("ratingCount")
|
||||
|
||||
# Duration
|
||||
duration_str = json_data.get("duration", "")
|
||||
if duration_str:
|
||||
data["duration"] = _parse_duration(duration_str)
|
||||
|
||||
# Poster
|
||||
image = json_data.get("image")
|
||||
if image:
|
||||
if isinstance(image, str):
|
||||
data["poster_url"] = image
|
||||
elif isinstance(image, dict):
|
||||
data["poster_url"] = image.get("url")
|
||||
|
||||
# Description
|
||||
data["plot"] = json_data.get("description")
|
||||
|
||||
break # Found movie data
|
||||
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
continue
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _extract_rating(soup: BeautifulSoup) -> Optional[int]:
|
||||
"""Extract rating percentage from HTML."""
|
||||
# Look for rating box
|
||||
rating_elem = soup.select_one(".film-rating-average")
|
||||
if rating_elem:
|
||||
text = rating_elem.get_text(strip=True)
|
||||
match = re.search(r"(\d+)%", text)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_poster(soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extract poster image URL from HTML."""
|
||||
# Look for poster image
|
||||
poster = soup.select_one(".film-poster img")
|
||||
if poster:
|
||||
src = poster.get("src") or poster.get("data-src")
|
||||
if src:
|
||||
if src.startswith("//"):
|
||||
return "https:" + src
|
||||
return src
|
||||
return None
|
||||
|
||||
|
||||
def _extract_plot(soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extract plot summary from HTML."""
|
||||
# Look for plot/description section
|
||||
plot_elem = soup.select_one(".plot-full p")
|
||||
if plot_elem:
|
||||
return plot_elem.get_text(strip=True)
|
||||
|
||||
# Alternative: shorter plot
|
||||
plot_elem = soup.select_one(".plot-preview p")
|
||||
if plot_elem:
|
||||
return plot_elem.get_text(strip=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_genres(soup: BeautifulSoup) -> list[str]:
|
||||
"""Extract genres from HTML."""
|
||||
genres = []
|
||||
genre_links = soup.select(".genres a")
|
||||
for link in genre_links:
|
||||
genre = link.get_text(strip=True)
|
||||
if genre:
|
||||
genres.append(genre)
|
||||
return genres
|
||||
|
||||
|
||||
def _extract_origin_info(soup: BeautifulSoup) -> dict:
|
||||
"""Extract country, year, duration from origin info line."""
|
||||
info = {}
|
||||
|
||||
# Look for origin line like "Československo, 1970, 97 min"
|
||||
origin_elem = soup.select_one(".origin")
|
||||
if origin_elem:
|
||||
text = origin_elem.get_text(strip=True)
|
||||
|
||||
# Extract year
|
||||
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", text)
|
||||
if year_match:
|
||||
info["year"] = int(year_match.group(1))
|
||||
|
||||
# Extract duration
|
||||
duration_match = re.search(r"(\d+)\s*min", text)
|
||||
if duration_match:
|
||||
info["duration"] = int(duration_match.group(1))
|
||||
|
||||
# Extract country (first part before comma)
|
||||
parts = text.split(",")
|
||||
if parts:
|
||||
info["country"] = parts[0].strip()
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def search_movies(query: str, limit: int = 10) -> list[CSFDMovie]:
|
||||
"""
|
||||
Search for movies on CSFD.cz.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of CSFDMovie objects with basic info (title, url, year)
|
||||
"""
|
||||
_check_dependencies()
|
||||
|
||||
search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}"
|
||||
response = requests.get(search_url, headers=HEADERS, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
results = []
|
||||
|
||||
# Find movie results
|
||||
movie_items = soup.select(".film-title-name, .search-result-item a[href*='/film/']")
|
||||
|
||||
for item in movie_items[:limit]:
|
||||
href = item.get("href", "")
|
||||
if "/film/" not in href:
|
||||
continue
|
||||
|
||||
title = item.get_text(strip=True)
|
||||
url = urljoin(CSFD_BASE_URL, href)
|
||||
|
||||
# Try to get year from sibling/parent
|
||||
year = None
|
||||
parent = item.find_parent(class_="article-content")
|
||||
if parent:
|
||||
year_elem = parent.select_one(".info")
|
||||
if year_elem:
|
||||
year_match = re.search(r"\((\d{4})\)", year_elem.get_text())
|
||||
if year_match:
|
||||
year = int(year_match.group(1))
|
||||
|
||||
results.append(CSFDMovie(
|
||||
title=title,
|
||||
url=url,
|
||||
year=year,
|
||||
csfd_id=_extract_csfd_id(url)
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def fetch_movie_by_id(csfd_id: int) -> CSFDMovie:
|
||||
"""
|
||||
Fetch movie by CSFD ID.
|
||||
|
||||
Args:
|
||||
csfd_id: CSFD movie ID number
|
||||
|
||||
Returns:
|
||||
CSFDMovie object with full data
|
||||
"""
|
||||
url = f"{CSFD_BASE_URL}/film/{csfd_id}/"
|
||||
return fetch_movie(url)
|
||||
@@ -13,6 +13,8 @@ class File:
|
||||
self.tagmanager = tagmanager
|
||||
# new: optional date string "YYYY-MM-DD" (assigned manually)
|
||||
self.date: str | None = None
|
||||
# CSFD.cz URL for movie info
|
||||
self.csfd_url: str | None = None
|
||||
self.get_metadata()
|
||||
|
||||
def get_metadata(self) -> None:
|
||||
@@ -21,6 +23,7 @@ class File:
|
||||
self.ignored = False
|
||||
self.tags = []
|
||||
self.date = None
|
||||
self.csfd_url = None
|
||||
if self.tagmanager:
|
||||
tag = self.tagmanager.add_tag("Stav", "Nové")
|
||||
self.tags.append(tag)
|
||||
@@ -36,6 +39,8 @@ class File:
|
||||
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
|
||||
# date může být None
|
||||
"date": self.date,
|
||||
# CSFD URL
|
||||
"csfd_url": self.csfd_url,
|
||||
}
|
||||
with open(self.metadata_filename, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
@@ -47,6 +52,7 @@ class File:
|
||||
self.ignored = data.get("ignored", False)
|
||||
self.tags = []
|
||||
self.date = data.get("date", None)
|
||||
self.csfd_url = data.get("csfd_url", None)
|
||||
|
||||
if not self.tagmanager:
|
||||
return
|
||||
@@ -66,6 +72,59 @@ class File:
|
||||
self.date = date_str
|
||||
self.save_metadata()
|
||||
|
||||
def set_csfd_url(self, url: str | None):
|
||||
"""Nastaví CSFD URL nebo None pro smazání."""
|
||||
if url is None or url == "":
|
||||
self.csfd_url = None
|
||||
else:
|
||||
self.csfd_url = url
|
||||
self.save_metadata()
|
||||
|
||||
def apply_csfd_tags(self, add_genres: bool = True, add_year: bool = True, add_country: bool = True) -> dict:
|
||||
"""
|
||||
Načte informace z CSFD a přiřadí tagy (žánr, rok, země).
|
||||
|
||||
Returns:
|
||||
dict s klíči 'success', 'movie', 'error', 'tags_added'
|
||||
"""
|
||||
if not self.csfd_url:
|
||||
return {"success": False, "error": "CSFD URL není nastavena", "tags_added": []}
|
||||
|
||||
try:
|
||||
from .csfd import fetch_movie
|
||||
movie = fetch_movie(self.csfd_url)
|
||||
except ImportError as e:
|
||||
return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
|
||||
|
||||
tags_added = []
|
||||
|
||||
if add_genres and movie.genres:
|
||||
for genre in movie.genres:
|
||||
tag_obj = self.tagmanager.add_tag("Žánr", genre) if self.tagmanager else Tag("Žánr", genre)
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(f"Žánr/{genre}")
|
||||
|
||||
if add_year and movie.year:
|
||||
year_str = str(movie.year)
|
||||
tag_obj = self.tagmanager.add_tag("Rok", year_str) if self.tagmanager else Tag("Rok", year_str)
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(f"Rok/{year_str}")
|
||||
|
||||
if add_country and movie.country:
|
||||
tag_obj = self.tagmanager.add_tag("Země", movie.country) if self.tagmanager else Tag("Země", movie.country)
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(f"Země/{movie.country}")
|
||||
|
||||
if tags_added:
|
||||
self.save_metadata()
|
||||
|
||||
return {"success": True, "movie": movie, "tags_added": tags_added}
|
||||
|
||||
def add_tag(self, tag):
|
||||
# tag může být Tag nebo string
|
||||
from .tag import Tag as TagClass
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from .file import File
|
||||
from .tag import Tag
|
||||
from .tag_manager import TagManager
|
||||
from .utils import list_files
|
||||
from typing import Iterable
|
||||
@@ -98,39 +99,34 @@ class FileManager:
|
||||
config = self.get_folder_config(folder)
|
||||
return config.get("ignore_patterns", [])
|
||||
|
||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||
def assign_tag_to_files(self, files: list[File], tag):
|
||||
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||
for f in files_objs:
|
||||
if isinstance(tag, str):
|
||||
if "/" in tag:
|
||||
category, name = tag.split("/", 1)
|
||||
tag_obj = self.tagmanager.add_tag(category, name)
|
||||
else:
|
||||
tag_obj = self.tagmanager.add_tag("default", tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
if isinstance(tag, str):
|
||||
parsed = Tag.from_string(tag)
|
||||
tag_obj = self.tagmanager.add_tag(parsed.category, parsed.name)
|
||||
else:
|
||||
tag_obj = tag
|
||||
|
||||
for f in files:
|
||||
if tag_obj not in f.tags:
|
||||
f.tags.append(tag_obj)
|
||||
f.save_metadata()
|
||||
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
|
||||
def remove_tag_from_files(self, files: list[File], tag):
|
||||
"""Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů."""
|
||||
for f in files_objs:
|
||||
if isinstance(tag, str):
|
||||
if "/" in tag:
|
||||
category, name = tag.split("/", 1)
|
||||
from .tag import Tag as TagClass
|
||||
tag_obj = TagClass(category, name)
|
||||
else:
|
||||
from .tag import Tag as TagClass
|
||||
tag_obj = TagClass("default", tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
if isinstance(tag, str):
|
||||
tag_obj = Tag.from_string(tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
|
||||
for f in files:
|
||||
if tag_obj in f.tags:
|
||||
f.tags.remove(tag_obj)
|
||||
f.save_metadata()
|
||||
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
@@ -144,14 +140,11 @@ class FileManager:
|
||||
return self.filelist
|
||||
|
||||
target_full_paths = set()
|
||||
from .tag import Tag as TagClass
|
||||
for t in tags_list:
|
||||
if isinstance(t, TagClass):
|
||||
if isinstance(t, Tag):
|
||||
target_full_paths.add(t.full_path)
|
||||
elif isinstance(t, str):
|
||||
target_full_paths.add(t)
|
||||
else:
|
||||
continue
|
||||
|
||||
filtered = []
|
||||
for f in self.filelist:
|
||||
@@ -160,6 +153,210 @@ class FileManager:
|
||||
filtered.append(f)
|
||||
return filtered
|
||||
|
||||
# Backwards compatibility aliases
|
||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||
"""Deprecated: Use assign_tag_to_files instead."""
|
||||
return self.assign_tag_to_files(files_objs, tag)
|
||||
|
||||
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
|
||||
"""Deprecated: Use remove_tag_from_files instead."""
|
||||
return self.remove_tag_from_files(files_objs, tag)
|
||||
|
||||
def close_folder(self):
|
||||
"""
|
||||
Safely close current folder - save all metadata and clear state.
|
||||
|
||||
This method:
|
||||
1. Saves metadata for all files
|
||||
2. Saves folder config
|
||||
3. Clears file list, folders, and configs
|
||||
4. Notifies GUI via callback
|
||||
"""
|
||||
if not self.current_folder:
|
||||
return
|
||||
|
||||
# Save all file metadata
|
||||
for f in self.filelist:
|
||||
try:
|
||||
f.save_metadata()
|
||||
except Exception:
|
||||
pass # Ignore errors during save
|
||||
|
||||
# Save folder config
|
||||
if self.current_folder in self.folder_configs:
|
||||
self.save_folder_config(self.current_folder)
|
||||
|
||||
# Clear state
|
||||
self.filelist.clear()
|
||||
self.folders.clear()
|
||||
self.folder_configs.clear()
|
||||
self.current_folder = None
|
||||
|
||||
# Notify GUI
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed([])
|
||||
|
||||
def rename_tag_in_files(self, category: str, old_name: str, new_name: str) -> int:
|
||||
"""
|
||||
Rename a tag in all files that have it.
|
||||
|
||||
Args:
|
||||
category: The category containing the tag
|
||||
old_name: Current name of the tag
|
||||
new_name: New name for the tag
|
||||
|
||||
Returns:
|
||||
Number of files updated
|
||||
"""
|
||||
old_tag = Tag(category, old_name)
|
||||
new_tag = self.tagmanager.rename_tag(category, old_name, new_name)
|
||||
|
||||
if new_tag is None:
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
for f in self.filelist:
|
||||
if old_tag in f.tags:
|
||||
# Remove old tag and add new one
|
||||
f.tags.remove(old_tag)
|
||||
f.tags.append(new_tag)
|
||||
f.save_metadata()
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0 and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
return updated_count
|
||||
|
||||
def rename_category_in_files(self, old_category: str, new_category: str) -> int:
|
||||
"""
|
||||
Rename a category in all files that have tags from it.
|
||||
|
||||
Args:
|
||||
old_category: Current name of the category
|
||||
new_category: New name for the category
|
||||
|
||||
Returns:
|
||||
Number of files updated
|
||||
"""
|
||||
# Get all tags in old category before renaming
|
||||
old_tags = self.tagmanager.get_tags_in_category(old_category)
|
||||
if not old_tags:
|
||||
return 0
|
||||
|
||||
# Rename the category in TagManager
|
||||
if not self.tagmanager.rename_category(old_category, new_category):
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
for f in self.filelist:
|
||||
file_updated = False
|
||||
new_tags = []
|
||||
for tag in f.tags:
|
||||
if tag.category == old_category:
|
||||
# Replace with new category tag
|
||||
new_tags.append(Tag(new_category, tag.name))
|
||||
file_updated = True
|
||||
else:
|
||||
new_tags.append(tag)
|
||||
|
||||
if file_updated:
|
||||
f.tags = new_tags
|
||||
f.save_metadata()
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0 and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
return updated_count
|
||||
|
||||
def merge_tag_in_files(self, category: str, source_name: str, target_name: str) -> int:
|
||||
"""
|
||||
Merge source tag into target tag in all files.
|
||||
Files with source tag will have it replaced by target tag.
|
||||
Files that already have target tag will just have source tag removed.
|
||||
|
||||
Args:
|
||||
category: The category containing both tags
|
||||
source_name: Name of the tag to merge (will be removed)
|
||||
target_name: Name of the tag to merge into (will be kept)
|
||||
|
||||
Returns:
|
||||
Number of files updated
|
||||
"""
|
||||
source_tag = Tag(category, source_name)
|
||||
target_tag = Tag(category, target_name)
|
||||
|
||||
# Merge in TagManager first
|
||||
result_tag = self.tagmanager.merge_tag(category, source_name, target_name)
|
||||
if result_tag is None:
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
for f in self.filelist:
|
||||
if source_tag in f.tags:
|
||||
# Remove source tag
|
||||
f.tags.remove(source_tag)
|
||||
|
||||
# Add target tag if not already present
|
||||
if target_tag not in f.tags:
|
||||
f.tags.append(target_tag)
|
||||
|
||||
f.save_metadata()
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0 and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
return updated_count
|
||||
|
||||
def merge_category_in_files(self, source_category: str, target_category: str) -> int:
|
||||
"""
|
||||
Merge source category into target category in all files.
|
||||
All tags from source category will be moved to target category.
|
||||
|
||||
Args:
|
||||
source_category: Category to merge (will be removed)
|
||||
target_category: Category to merge into (will receive all tags)
|
||||
|
||||
Returns:
|
||||
Number of files updated
|
||||
"""
|
||||
# Get all tags in source category before merging
|
||||
source_tags = self.tagmanager.get_tags_in_category(source_category)
|
||||
if not source_tags:
|
||||
return 0
|
||||
|
||||
# Merge in TagManager first
|
||||
if not self.tagmanager.merge_category(source_category, target_category):
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
for f in self.filelist:
|
||||
file_updated = False
|
||||
new_tags = []
|
||||
for tag in f.tags:
|
||||
if tag.category == source_category:
|
||||
# Replace with target category tag
|
||||
new_tag = Tag(target_category, tag.name)
|
||||
# Only add if not already present
|
||||
if new_tag not in new_tags:
|
||||
new_tags.append(new_tag)
|
||||
file_updated = True
|
||||
else:
|
||||
if tag not in new_tags:
|
||||
new_tags.append(tag)
|
||||
|
||||
if file_updated:
|
||||
f.tags = new_tags
|
||||
f.save_metadata()
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0 and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
return updated_count
|
||||
|
||||
# Legacy property for backwards compatibility
|
||||
@property
|
||||
def config(self):
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
from typing import List
|
||||
from .file import File
|
||||
|
||||
class ListManager:
|
||||
def __init__(self):
|
||||
# 'name' nebo 'date'
|
||||
self.sort_mode = "name"
|
||||
|
||||
def set_sort(self, mode: str):
|
||||
if mode in ("name", "date"):
|
||||
self.sort_mode = mode
|
||||
|
||||
def sort_files(self, files: List[File]) -> List[File]:
|
||||
if self.sort_mode == "name":
|
||||
return sorted(files, key=lambda f: f.filename.lower())
|
||||
else:
|
||||
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
|
||||
def date_key(f):
|
||||
return (f.date is None, f.date or "")
|
||||
return sorted(files, key=date_key)
|
||||
@@ -1,19 +1,19 @@
|
||||
# Module header
|
||||
import sys
|
||||
import subprocess
|
||||
from .file import File
|
||||
from .tag_manager import TagManager
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit("This module is not intended to be executed as the main program.")
|
||||
|
||||
# Imports
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
# Functions
|
||||
def load_icon(path) -> ImageTk.PhotoImage:
|
||||
img = Image.open(path)
|
||||
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||
return ImageTk.PhotoImage(img)
|
||||
# Backwards compatibility: load_icon moved to src/ui/utils.py
|
||||
def load_icon(path):
|
||||
"""Deprecated: Use src.ui.utils.load_icon instead."""
|
||||
from src.ui.utils import load_icon as _load_icon
|
||||
return _load_icon(path)
|
||||
|
||||
|
||||
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
|
||||
"""
|
||||
|
||||
@@ -3,6 +3,27 @@ class Tag:
|
||||
self.category = category
|
||||
self.name = name
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, tag_str: str, default_category: str = "default") -> "Tag":
|
||||
"""
|
||||
Parse a tag from 'category/name' string format.
|
||||
|
||||
Args:
|
||||
tag_str: Tag string in 'category/name' format
|
||||
default_category: Category to use if no '/' in string
|
||||
|
||||
Returns:
|
||||
Tag object
|
||||
|
||||
Examples:
|
||||
Tag.from_string("Stav/Nové") -> Tag("Stav", "Nové")
|
||||
Tag.from_string("simple") -> Tag("default", "simple")
|
||||
"""
|
||||
if "/" in tag_str:
|
||||
category, name = tag_str.split("/", 1)
|
||||
return cls(category, name)
|
||||
return cls(default_category, tag_str)
|
||||
|
||||
@property
|
||||
def full_path(self):
|
||||
return f"{self.category}/{self.name}"
|
||||
|
||||
@@ -64,4 +64,144 @@ class TagManager:
|
||||
# Sort alphabetically for custom categories
|
||||
tags.sort(key=lambda t: t.name)
|
||||
|
||||
return tags
|
||||
return tags
|
||||
|
||||
def rename_tag(self, category: str, old_name: str, new_name: str) -> Tag | None:
|
||||
"""
|
||||
Rename a tag within a category.
|
||||
|
||||
Args:
|
||||
category: The category containing the tag
|
||||
old_name: Current name of the tag
|
||||
new_name: New name for the tag
|
||||
|
||||
Returns:
|
||||
The new Tag object if successful, None if tag not found or new name already exists
|
||||
"""
|
||||
if category not in self.tags_by_category:
|
||||
return None
|
||||
|
||||
old_tag = Tag(category, old_name)
|
||||
new_tag = Tag(category, new_name)
|
||||
|
||||
# Check if old tag exists
|
||||
if old_tag not in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Check if new name already exists (and is different)
|
||||
if old_name != new_name and new_tag in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Remove old tag and add new one
|
||||
self.tags_by_category[category].discard(old_tag)
|
||||
self.tags_by_category[category].add(new_tag)
|
||||
|
||||
return new_tag
|
||||
|
||||
def rename_category(self, old_category: str, new_category: str) -> bool:
|
||||
"""
|
||||
Rename a category.
|
||||
|
||||
Args:
|
||||
old_category: Current name of the category
|
||||
new_category: New name for the category
|
||||
|
||||
Returns:
|
||||
True if successful, False if category not found or new name already exists
|
||||
"""
|
||||
if old_category not in self.tags_by_category:
|
||||
return False
|
||||
|
||||
# Check if new category already exists (and is different)
|
||||
if old_category != new_category and new_category in self.tags_by_category:
|
||||
return False
|
||||
|
||||
# Get all tags from old category
|
||||
old_tags = self.tags_by_category[old_category]
|
||||
|
||||
# Create new tags with new category
|
||||
new_tags = {Tag(new_category, tag.name) for tag in old_tags}
|
||||
|
||||
# Remove old category and add new one
|
||||
del self.tags_by_category[old_category]
|
||||
self.tags_by_category[new_category] = new_tags
|
||||
|
||||
return True
|
||||
|
||||
def merge_tag(self, category: str, source_name: str, target_name: str) -> Tag | None:
|
||||
"""
|
||||
Merge source tag into target tag (removes source, keeps target).
|
||||
|
||||
Args:
|
||||
category: The category containing both tags
|
||||
source_name: Name of the tag to merge (will be removed)
|
||||
target_name: Name of the tag to merge into (will be kept)
|
||||
|
||||
Returns:
|
||||
The target Tag object if successful, None if either tag not found
|
||||
"""
|
||||
if category not in self.tags_by_category:
|
||||
return None
|
||||
|
||||
source_tag = Tag(category, source_name)
|
||||
target_tag = Tag(category, target_name)
|
||||
|
||||
# Check if source tag exists
|
||||
if source_tag not in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Check if target tag exists
|
||||
if target_tag not in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Remove source tag (target already exists)
|
||||
self.tags_by_category[category].discard(source_tag)
|
||||
|
||||
# Clean up empty category
|
||||
if not self.tags_by_category[category]:
|
||||
self.remove_category(category)
|
||||
|
||||
return target_tag
|
||||
|
||||
def merge_category(self, source_category: str, target_category: str) -> bool:
|
||||
"""
|
||||
Merge source category into target category (moves all tags, removes source).
|
||||
|
||||
Args:
|
||||
source_category: Category to merge (will be removed)
|
||||
target_category: Category to merge into (will receive all tags)
|
||||
|
||||
Returns:
|
||||
True if successful, False if either category not found
|
||||
"""
|
||||
if source_category not in self.tags_by_category:
|
||||
return False
|
||||
|
||||
if target_category not in self.tags_by_category:
|
||||
return False
|
||||
|
||||
if source_category == target_category:
|
||||
return True # No-op
|
||||
|
||||
# Get all tags from source category
|
||||
source_tags = self.tags_by_category[source_category]
|
||||
|
||||
# Create new tags with target category and add to target
|
||||
for tag in source_tags:
|
||||
new_tag = Tag(target_category, tag.name)
|
||||
self.tags_by_category[target_category].add(new_tag)
|
||||
|
||||
# Remove source category
|
||||
del self.tags_by_category[source_category]
|
||||
|
||||
return True
|
||||
|
||||
def tag_exists(self, category: str, name: str) -> bool:
|
||||
"""Check if a tag exists in a category."""
|
||||
if category not in self.tags_by_category:
|
||||
return False
|
||||
return Tag(category, name) in self.tags_by_category[category]
|
||||
|
||||
def category_exists(self, category: str) -> bool:
|
||||
"""Check if a category exists."""
|
||||
return category in self.tags_by_category
|
||||
273
src/ui/gui.py
273
src/ui/gui.py
@@ -9,12 +9,12 @@ 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.ui.utils import load_icon
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager, DEFAULT_TAG_ORDER
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
from src.core.list_manager import ListManager
|
||||
# ListManager removed - sorting implemented directly in GUI
|
||||
from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT
|
||||
from src.core.config import save_global_config
|
||||
from src.core.hardlink_manager import HardlinkManager
|
||||
@@ -219,8 +219,6 @@ class App:
|
||||
def __init__(self, filehandler: FileManager, tagmanager: TagManager):
|
||||
self.filehandler = filehandler
|
||||
self.tagmanager = tagmanager
|
||||
self.list_manager = ListManager()
|
||||
|
||||
# State
|
||||
self.states = {}
|
||||
self.file_items = {} # Treeview item_id -> File object mapping
|
||||
@@ -231,6 +229,7 @@ class App:
|
||||
self.sort_mode = "name"
|
||||
self.sort_order = "asc"
|
||||
self.category_colors = {} # category -> color mapping
|
||||
self.show_csfd_column = True # CSFD column visibility
|
||||
|
||||
self.filehandler.on_files_changed = self.update_files_from_manager
|
||||
|
||||
@@ -312,6 +311,7 @@ class App:
|
||||
# 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="Zavřít složku (Ctrl+W)", command=self.close_folder)
|
||||
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)
|
||||
@@ -323,6 +323,12 @@ class App:
|
||||
variable=self.hide_ignored_var,
|
||||
command=self.toggle_hide_ignored
|
||||
)
|
||||
self.show_csfd_var = tk.BooleanVar(value=True, master=self.root)
|
||||
view_menu.add_checkbutton(
|
||||
label="Zobrazit CSFD sloupec",
|
||||
variable=self.show_csfd_var,
|
||||
command=self.toggle_csfd_column
|
||||
)
|
||||
view_menu.add_command(label="Refresh (F5)", command=self.refresh_all)
|
||||
|
||||
# Tools menu
|
||||
@@ -331,6 +337,9 @@ class App:
|
||||
tools_menu.add_command(label="Detekovat rozlišení videí", command=self.detect_video_resolution)
|
||||
tools_menu.add_command(label="Přiřadit tagy (Ctrl+T)", command=self.assign_tag_to_selected_bulk)
|
||||
tools_menu.add_separator()
|
||||
tools_menu.add_command(label="Nastavit CSFD URL...", command=self.set_csfd_url_for_selected)
|
||||
tools_menu.add_command(label="Načíst tagy z CSFD", command=self.apply_csfd_tags_for_selected)
|
||||
tools_menu.add_separator()
|
||||
tools_menu.add_command(label="Nastavit hardlink složku...", command=self.configure_hardlink_folder)
|
||||
tools_menu.add_command(label="Aktualizovat hardlink strukturu", command=self.update_hardlink_structure)
|
||||
tools_menu.add_command(label="Vytvořit hardlink strukturu...", command=self.create_hardlink_structure)
|
||||
@@ -428,22 +437,27 @@ class App:
|
||||
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")
|
||||
# Define columns (including CSFD)
|
||||
columns = ("name", "date", "tags", "csfd", "size")
|
||||
self.file_table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended")
|
||||
|
||||
# Column headers with sort commands
|
||||
self.file_table.heading("name", text="📄 Název ▲", command=lambda: self.sort_by_column("name"))
|
||||
self.file_table.heading("date", text="📅 Datum", command=lambda: self.sort_by_column("date"))
|
||||
self.file_table.heading("tags", text="🏷️ Štítky")
|
||||
self.file_table.heading("csfd", text="🎬 CSFD")
|
||||
self.file_table.heading("size", text="💾 Velikost", command=lambda: self.sort_by_column("size"))
|
||||
|
||||
# Column widths
|
||||
self.file_table.column("name", width=300)
|
||||
self.file_table.column("date", width=100)
|
||||
self.file_table.column("tags", width=200)
|
||||
self.file_table.column("csfd", width=50)
|
||||
self.file_table.column("size", width=80)
|
||||
|
||||
# Load CSFD column visibility from folder config
|
||||
self._update_csfd_column_visibility()
|
||||
|
||||
# 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)
|
||||
@@ -493,6 +507,7 @@ class App:
|
||||
# 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="Přejmenovat štítek", command=self.tree_rename_tag)
|
||||
self.tag_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag)
|
||||
|
||||
# File context menu
|
||||
@@ -501,11 +516,15 @@ class App:
|
||||
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="Nastavit CSFD URL...", command=self.set_csfd_url_for_selected)
|
||||
self.file_menu.add_command(label="Načíst tagy z CSFD", command=self.apply_csfd_tags_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-w>", lambda e: self.close_folder())
|
||||
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())
|
||||
@@ -570,6 +589,9 @@ class App:
|
||||
self.tag_tree.tag_configure(f"cat_{category}", foreground=color)
|
||||
self.tag_tree.tag_configure(f"tag_{category}", foreground=color)
|
||||
|
||||
# Force tree update
|
||||
self.tag_tree.update_idletasks()
|
||||
|
||||
def update_tag_counts(self, filtered_files):
|
||||
"""Update tag counts in sidebar based on filtered files"""
|
||||
if not hasattr(self, 'tag_tree_items'):
|
||||
@@ -669,6 +691,134 @@ class App:
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_label.config(text=f"Smazán tag: {name}")
|
||||
|
||||
def tree_rename_tag(self):
|
||||
"""Rename selected tag or category"""
|
||||
item = self.selected_tree_item_for_context
|
||||
if not item:
|
||||
return
|
||||
|
||||
# Don't allow renaming root
|
||||
if item == self.root_tag_id:
|
||||
return
|
||||
|
||||
parent_id = self.tag_tree.parent(item)
|
||||
current_text = self.tag_tree.item(item, "text").strip()
|
||||
|
||||
# Check if this is a category (parent is root) or a tag
|
||||
is_category = (parent_id == self.root_tag_id)
|
||||
|
||||
if is_category:
|
||||
# Renaming a category
|
||||
current_name = current_text.replace("📁 ", "")
|
||||
new_name = simpledialog.askstring(
|
||||
"Přejmenovat kategorii",
|
||||
f"Nový název kategorie '{current_name}':",
|
||||
initialvalue=current_name
|
||||
)
|
||||
if not new_name or new_name == current_name:
|
||||
return
|
||||
|
||||
# Check if new name already exists - offer merge
|
||||
if new_name in self.tagmanager.get_categories():
|
||||
merge = messagebox.askyesno(
|
||||
"Kategorie existuje",
|
||||
f"Kategorie '{new_name}' již existuje.\n\n"
|
||||
f"Chcete sloučit kategorii '{current_name}' do '{new_name}'?\n\n"
|
||||
f"Všechny štítky z '{current_name}' budou přesunuty do '{new_name}'.",
|
||||
icon="question"
|
||||
)
|
||||
if not merge:
|
||||
return
|
||||
|
||||
# Merge category in all files
|
||||
updated_count = self.filehandler.merge_category_in_files(current_name, new_name)
|
||||
|
||||
# Refresh sidebar
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
self.status_label.config(
|
||||
text=f"Kategorie sloučena: {current_name} → {new_name} ({updated_count} souborů)"
|
||||
)
|
||||
return
|
||||
|
||||
# Rename category in all files
|
||||
updated_count = self.filehandler.rename_category_in_files(current_name, new_name)
|
||||
|
||||
# Refresh sidebar
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
self.status_label.config(
|
||||
text=f"Kategorie přejmenována: {current_name} → {new_name} ({updated_count} souborů)"
|
||||
)
|
||||
else:
|
||||
# Renaming a tag
|
||||
# Get tag name (without count suffix)
|
||||
# Find the tag name from the mapping
|
||||
tag_name = None
|
||||
for full_path, (item_id, name) in self.tag_tree_items.items():
|
||||
if item_id == item:
|
||||
tag_name = name
|
||||
break
|
||||
|
||||
if tag_name is None:
|
||||
# Fallback: parse from text (remove leading spaces and count)
|
||||
tag_name = current_text.lstrip()
|
||||
# Remove count suffix like " (5)"
|
||||
import re
|
||||
tag_name = re.sub(r'\s*\(\d+\)\s*$', '', tag_name)
|
||||
|
||||
category = self.tag_tree.item(parent_id, "text").replace("📁 ", "")
|
||||
|
||||
new_name = simpledialog.askstring(
|
||||
"Přejmenovat štítek",
|
||||
f"Nový název štítku '{tag_name}':",
|
||||
initialvalue=tag_name
|
||||
)
|
||||
if not new_name or new_name == tag_name:
|
||||
return
|
||||
|
||||
# Check if new name already exists in this category - offer merge
|
||||
existing_tags = [t.name for t in self.tagmanager.get_tags_in_category(category)]
|
||||
if new_name in existing_tags:
|
||||
merge = messagebox.askyesno(
|
||||
"Štítek existuje",
|
||||
f"Štítek '{new_name}' v kategorii '{category}' již existuje.\n\n"
|
||||
f"Chcete sloučit '{tag_name}' do '{new_name}'?\n\n"
|
||||
f"Všechny soubory s '{tag_name}' budou mít tento štítek nahrazen za '{new_name}'.",
|
||||
icon="question"
|
||||
)
|
||||
if not merge:
|
||||
return
|
||||
|
||||
# Merge tag in all files
|
||||
updated_count = self.filehandler.merge_tag_in_files(category, tag_name, new_name)
|
||||
|
||||
# Refresh sidebar
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
self.status_label.config(
|
||||
text=f"Štítek sloučen: {category}/{tag_name} → {category}/{new_name} ({updated_count} souborů)"
|
||||
)
|
||||
return
|
||||
|
||||
# Rename tag in all files
|
||||
updated_count = self.filehandler.rename_tag_in_files(category, tag_name, new_name)
|
||||
|
||||
# Refresh sidebar
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
self.status_label.config(
|
||||
text=f"Štítek přejmenován: {category}/{tag_name} → {category}/{new_name} ({updated_count} souborů)"
|
||||
)
|
||||
|
||||
def get_checked_tags(self) -> List[Tag]:
|
||||
"""Get list of checked tags"""
|
||||
tags = []
|
||||
@@ -740,13 +890,16 @@ class App:
|
||||
if len(f.tags) > 3:
|
||||
tags += f" +{len(f.tags) - 3}"
|
||||
|
||||
# CSFD indicator
|
||||
csfd = "✓" if f.csfd_url else ""
|
||||
|
||||
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))
|
||||
item_id = self.file_table.insert("", "end", values=(name, date, tags, csfd, size_str))
|
||||
self.file_items[item_id] = f
|
||||
|
||||
# Update status
|
||||
@@ -838,11 +991,27 @@ class App:
|
||||
f.tagmanager.add_tag(t.category, t.name)
|
||||
|
||||
self.status_label.config(text=f"Přidána složka: {folder_path}")
|
||||
self._update_csfd_column_visibility() # Load CSFD column setting for new folder
|
||||
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 close_folder(self):
|
||||
"""Close current folder safely"""
|
||||
if not self.filehandler.current_folder:
|
||||
self.status_label.config(text="Žádná složka není otevřena")
|
||||
return
|
||||
|
||||
folder_name = self.filehandler.current_folder.name
|
||||
|
||||
# Close folder (saves metadata and clears state)
|
||||
self.filehandler.close_folder()
|
||||
|
||||
# Refresh UI
|
||||
self.refresh_sidebar()
|
||||
self.status_label.config(text=f"Složka zavřena: {folder_name}")
|
||||
|
||||
def open_selected_files(self):
|
||||
"""Open selected files"""
|
||||
files = self.get_selected_files()
|
||||
@@ -974,6 +1143,96 @@ class App:
|
||||
self.show_full_path = not self.show_full_path
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_csfd_column(self):
|
||||
"""Toggle CSFD column visibility"""
|
||||
self.show_csfd_column = self.show_csfd_var.get()
|
||||
self._update_csfd_column_visibility()
|
||||
|
||||
# Save to folder config
|
||||
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 width based on visibility setting"""
|
||||
# Load from folder config if available
|
||||
if self.filehandler.current_folder:
|
||||
folder_config = self.filehandler.get_folder_config()
|
||||
self.show_csfd_column = folder_config.get("show_csfd_column", True)
|
||||
if hasattr(self, 'show_csfd_var'):
|
||||
self.show_csfd_var.set(self.show_csfd_column)
|
||||
|
||||
# Update column width
|
||||
if hasattr(self, 'file_table'):
|
||||
if self.show_csfd_column:
|
||||
self.file_table.column("csfd", width=50)
|
||||
else:
|
||||
self.file_table.column("csfd", width=0)
|
||||
|
||||
def set_csfd_url_for_selected(self):
|
||||
"""Set CSFD URL for selected files"""
|
||||
files = self.get_selected_files()
|
||||
if not files:
|
||||
self.status_label.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
|
||||
# Get current URL from first file
|
||||
current_url = files[0].csfd_url or ""
|
||||
|
||||
prompt = "Zadej CSFD URL (např. https://www.csfd.cz/film/9423-pane-vy-jste-vdova/):"
|
||||
url = simpledialog.askstring("Nastavit CSFD URL", prompt, initialvalue=current_url, parent=self.root)
|
||||
if url is None:
|
||||
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.config(text=f"CSFD URL nastaveno pro {len(files)} soubor(ů)")
|
||||
|
||||
def apply_csfd_tags_for_selected(self):
|
||||
"""Load tags from CSFD for selected files"""
|
||||
files = self.get_selected_files()
|
||||
if not files:
|
||||
self.status_label.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
|
||||
# Filter files with CSFD URL
|
||||
files_with_url = [f for f in files if f.csfd_url]
|
||||
if not files_with_url:
|
||||
messagebox.showwarning("Upozornění", "Žádný z vybraných souborů nemá nastavenou CSFD URL")
|
||||
return
|
||||
|
||||
self.status_label.config(text=f"Načítám tagy z CSFD pro {len(files_with_url)} souborů...")
|
||||
self.root.update()
|
||||
|
||||
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
|
||||
|
||||
# Refresh sidebar to show new categories
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks() # Force UI refresh
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
# Show result
|
||||
if error_count > 0:
|
||||
messagebox.showwarning("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.config(
|
||||
text=f"Načteno z CSFD: {success_count} souborů, přidáno {len(all_tags_added)} tagů")
|
||||
|
||||
def sort_by_column(self, column: str):
|
||||
"""Sort by column header click"""
|
||||
if self.sort_mode == column:
|
||||
|
||||
19
src/ui/utils.py
Normal file
19
src/ui/utils.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
UI utility functions for Tagger GUI.
|
||||
"""
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
|
||||
def load_icon(path) -> ImageTk.PhotoImage:
|
||||
"""
|
||||
Load an icon from file and resize to 16x16.
|
||||
|
||||
Args:
|
||||
path: Path to the image file
|
||||
|
||||
Returns:
|
||||
ImageTk.PhotoImage resized to 16x16 pixels
|
||||
"""
|
||||
img = Image.open(path)
|
||||
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||
return ImageTk.PhotoImage(img)
|
||||
Reference in New Issue
Block a user