import os import threading import tkinter as tk from tkinter import filedialog, messagebox, ttk from typing import Dict, Tuple from packaging.version import parse as parse_version import itertools import networkx as nx import requests from PIL import Image, ImageTk import wdtagger as wdt import tomli from .ProcessingDialog import ProcessingDialog from .I18N import _ from .Core import Core from .autocomplete import TagManager from .common import open_tag_wiki_url, open_webbrowser from .settings import Settings from .tag_processing import ( TAG_FIXES, extract_parameters, parse_parameters, process_tag, extract_artist_from_filename, ) class ImageBrowser(tk.Tk): def __init__(self): super().__init__() self.title("Kapitanbooru Uploader") self.geometry("900x600") self.core = Core(Settings()) self.core.check_uploaded_files_callback = self.check_uploaded_files_callback self.core.update_status_bar_callback = lambda: self.after( 0, self.update_status_bar ) self.core.process_tagger_for_image_callback = ( self.update_listbox_item_color_by_rating ) self.core.upload_file_success_callback = lambda file_path: self.after( 0, lambda idx=self.core.image_files.index(file_path): self.listbox.itemconfig( idx, {"bg": "lightgray"} ), ) self.core.upload_file_completed_callback = lambda: self.upload_button.after( 0, self.update_button_states ) self.run_tagger_threads: Dict[str, threading.Thread] = {} # Oryginalny obraz (do skalowania) self.current_image_original = None self.current_parameters = "" # Słowniki przechowujące stany tagów (dla PNG i Taggera) self.png_tags_states = {} self.tagger_tags_states = {} self.tagger_thread_idx = 0 self.create_menu() self.create_widgets() self.bind_events() # Schedule first update check self.after(1000, self._schedule_update_check) self.after(100, self.process_core_tasks) def process_core_tasks(self): """Process Core's main thread tasks""" self.core.process_main_thread_queue() self.after(100, self.process_core_tasks) def check_uploaded_files_callback(self, idx): """Callback for when a file's upload status is checked""" self.after( 0, lambda idx=idx: self.listbox.itemconfig(idx, {"bg": "lightgray"}), ) # Jeśli aktualnie wybrany plik, zmień przycisk if self.core.current_index == idx: self.after(0, self.update_button_states) def _schedule_update_check(self): """Schedule periodic update checks""" self._check_for_update() # Check every 5 minutes (300,000 ms) self.after(300000, self._schedule_update_check) def is_installed_via_pipx(self): """Check if the application is installed via pipx""" return os.environ.get("__PIPX_ORIGIN") == "pipx" def _check_for_update(self): """Check for updates in a background thread""" def check_thread(): try: # Fetch pyproject.toml using requests response = requests.get( "https://git.mlesniak.pl/kapitan/kapitanbooru-uploader/raw/branch/main/pyproject.toml", timeout=10, # Add reasonable timeout ) response.raise_for_status() # Raise exception for HTTP errors remote_toml = tomli.loads(response.text) remote_version_str = remote_toml["project"]["version"] remote_version = parse_version(remote_version_str) current_version = parse_version(self.core.version) if ( remote_version > current_version and remote_version > self.core.acknowledged_version ): self.after(0, lambda: self._notify_user(remote_version_str)) self.core.acknowledged_version = remote_version except requests.exceptions.RequestException as e: print(_("Update check failed: {error}").format(error=e)) except KeyError as e: print(_("Malformed pyproject.toml: {error}").format(error=e)) except Exception as e: print( _("Unexpected error during update check: {error}").format(error=e) ) threading.Thread(target=check_thread, daemon=True).start() def _notify_user(self, new_version): """Show update notification with translated messages""" title = _("Update Available") message_template = _( "A new version {new_version} is available!\n" "You have version {current_version}.\n\n" "Update using: {update_command}" ) formatted_message = message_template.format( new_version=new_version, current_version=self.core.version, update_command=( "pipx upgrade kapitanbooru-uploader" if self.is_installed_via_pipx() else "pip install --upgrade kapitanbooru-uploader" ), ) messagebox.showinfo(title, formatted_message) def reload_ui(self): """Reload UI components with new language""" # Destroy current widgets for widget in self.winfo_children(): widget.destroy() # Rebuild UI self.create_menu() self.create_widgets() def adjust_text_widget_height(self, widget): """ Ustawia wysokość widgetu Text na liczbę linii w jego treści, ale nie więcej niż max_lines. """ content = widget.get("1.0", "end-1c") num_lines = 0 cur_line_len = 0 text = content.split() for word in text: if cur_line_len + len(word) > 34: num_lines += 1 cur_line_len = 0 cur_line_len += len(word) + 1 max_lines = ( self.png_tags_text.winfo_height() + self.tagger_tags_text.winfo_height() + self.final_tags_text.winfo_height() - widget.winfo_height() - 8 ) widget.config(height=min(num_lines, max_lines) if num_lines > 4 else 4) def create_menu(self): menubar = tk.Menu(self) self.config(menu=menubar) # Create file menu and store it as instance variable self.file_menu = tk.Menu(menubar, tearoff=0) # File menu items - create references for items we need to control self.file_menu.add_command(label=_("Otwórz folder"), command=self.select_folder) self.file_menu.add_separator() self.file_menu.add_command( label=_("Wyślij"), command=self.upload_current_image, state=tk.DISABLED ) self.file_menu.add_command( label=_("Wyślij wszystko"), command=self.upload_all_files, state=tk.DISABLED ) self.file_menu.add_separator() self.file_menu.add_command( label=_("Podmień tagi"), command=self.edit_current_image, state=tk.DISABLED ) self.file_menu.add_command( label=_("Otwórz post"), command=self.view_current_post, state=tk.DISABLED ) self.file_menu.add_separator() self.file_menu.add_command(label=_("Zakończ"), command=self.quit) menubar.add_cascade(label=_("Plik"), menu=self.file_menu) # Options menu options_menu = tk.Menu(menubar, tearoff=0) options_menu.add_command(label=_("Ustawienia"), command=self.open_settings) options_menu.add_separator() options_menu.add_command( label=_("Wyczyść cache Taggera"), command=self.clear_cache ) options_menu.add_command( label=_("Zregeneruj bazę tagów"), command=self.regenerate_tags_db ) menubar.add_cascade(label=_("Opcje"), menu=options_menu) help_menu = tk.Menu(menubar, tearoff=0) help_menu.add_command(label=_("About"), command=self.show_about) menubar.add_cascade(label=_("Help"), menu=help_menu) def show_about(self): """Multilingual About window (updated version)""" about = tk.Toplevel(self) about.title(_("About Kapitanbooru Uploader")) about.resizable(False, False) # Window content frame = ttk.Frame(about, padding=10) frame.pack(fill="both", expand=True) row_counter = itertools.count(0) ttk.Label( frame, text="Kapitanbooru Uploader", font=("TkDefaultFont", 16, "bold") ).grid(row=next(row_counter), column=0, sticky=tk.W) current_version = parse_version(self.core.version) if current_version < self.core.acknowledged_version: ttk.Label( frame, text=_("A new version {new_version} is available!").format( new_version=self.core.acknowledged_version ), foreground="red", ).grid(row=next(row_counter), column=0, sticky=tk.W) content = [ _("Current version: {version}").format(version=self.core.version), "", _("A GUI application for uploading images to KapitanBooru."), _("Features include image upload, tag management, automatic"), _("tagging with wdtagger, and cache management."), "", _("Authors:"), "Michał Leśniak", "", _("License: MIT License"), f"Copyright © 2025 Michał Leśniak", "", ] for text in content: ttk.Label(frame, text=text).grid( row=next(row_counter), column=0, sticky=tk.W ) # Repository link repo_frame = ttk.Frame(frame) repo_frame.grid(row=next(row_counter), sticky=tk.W) ttk.Label(repo_frame, text=_("Repository:")).pack(side=tk.LEFT) repo_url = "https://git.mlesniak.pl/kapitan/kapitanbooru-uploader" repo = ttk.Label(repo_frame, text=repo_url, cursor="hand2", foreground="blue") repo.pack(side=tk.LEFT, padx=(5, 0)) repo.bind("", lambda e: open_webbrowser(repo_url, self.core.settings)) # Website link website_frame = ttk.Frame(frame) website_frame.grid(row=next(row_counter), sticky=tk.W) ttk.Label(website_frame, text=_("Website:")).pack(side=tk.LEFT) website_url = "https://booru.mlesniak.pl" website = ttk.Label( website_frame, text=website_url, cursor="hand2", foreground="blue" ) website.pack(side=tk.LEFT, padx=(5, 0)) website.bind( "", lambda e: open_webbrowser(website_url, self.core.settings) ) # Close button ttk.Button(about, text=_("Close"), command=about.destroy).pack(pady=10) def regenerate_tags_db(self): self.processing_dialog = ProcessingDialog( self, self.core.tags_repo.regenerate_db ) def clear_cache(self): res, err = self.core.tagger_cache.clear_cache() if res: messagebox.showinfo(_("Cache"), _("Cache Taggera zostało wyczyszczone.")) else: messagebox.showerror( _("Cache"), f"{_('Błąd przy czyszczeniu cache:')} {err}" ) def open_settings(self): settings_window = tk.Toplevel(self) settings_window.title(_("Ustawienia")) settings_window.geometry("300x430") # Enlarged vertically settings_window.resizable(False, False) # Disable resizing settings_window.grab_set() lbl_login = tk.Label(settings_window, text=_("Login:")) lbl_login.pack(pady=(10, 0)) entry_login = tk.Entry(settings_window) entry_login.pack(pady=(0, 10), padx=10, fill="x") entry_login.insert(0, self.core.settings.username) lbl_password = tk.Label(settings_window, text=_("Hasło:")) lbl_password.pack(pady=(10, 0)) entry_password = tk.Entry(settings_window, show="*") entry_password.pack(pady=(0, 10), padx=10, fill="x") entry_password.insert(0, self.core.settings.password) lbl_base_url = tk.Label(settings_window, text=_("Base URL:")) lbl_base_url.pack(pady=(10, 0)) entry_base_url = tk.Entry(settings_window) entry_base_url.pack(pady=(0, 10), padx=10, fill="x") entry_base_url.insert(0, self.core.settings.base_url) lbl_default_tags = tk.Label(settings_window, text=_("Default Tags:")) lbl_default_tags.pack(pady=(10, 0)) entry_default_tags = tk.Entry(settings_window) entry_default_tags.pack(pady=(0, 10), padx=10, fill="x") entry_default_tags.insert(0, self.core.settings.default_tags) lbl_browser = tk.Label(settings_window, text=_("Browser:")) lbl_browser.pack(pady=(10, 0)) cb_browser = ttk.Combobox( settings_window, values=list(self.core.settings.installed_browsers.keys()), state="readonly", ) cb_browser.pack(pady=(0, 10), padx=10, fill="x") cb_browser.set( self.core.settings.installed_browsers_reverse.get( self.core.settings.browser, "Default" ) ) lbl_lang = tk.Label(settings_window, text=_("Language:")) lbl_lang.pack(pady=(10, 0)) cb_lang = ttk.Combobox( settings_window, values=list(self.core.settings.i18n.languages.values()), state="readonly", ) cb_lang.pack(pady=(0, 10), padx=10, fill="x") cb_lang.set( self.core.settings.i18n.languages.get( self.core.settings.i18n.current_lang, "en" ) ) def save_and_close(): self.core.settings.username = entry_login.get() self.core.settings.password = entry_password.get() self.core.settings.base_url = entry_base_url.get() self.core.settings.default_tags = entry_default_tags.get() self.core.settings.browser = self.core.settings.installed_browsers[ cb_browser.get() ] self.core.settings.i18n.set_language( next( ( lang for lang, name in self.core.settings.i18n.languages.items() if name == cb_lang.get() ), "en", ) ) self.core.settings.save_settings() settings_window.destroy() self.reload_ui() btn_save = tk.Button(settings_window, text=_("Zapisz"), command=save_and_close) btn_save.pack(pady=10) def create_widgets(self): # Główna ramka – trzy kolumny main_frame = tk.Frame(self) main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # Lewa kolumna – lista plików left_frame = tk.Frame(main_frame) left_frame.grid(row=0, column=0, sticky=tk.NS) self.listbox = tk.Listbox(left_frame, width=30) self.listbox.pack(side=tk.LEFT, fill=tk.Y) self.listbox.bind("<>", self.on_listbox_select) scrollbar = tk.Scrollbar( left_frame, orient="vertical", command=self.listbox.yview ) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.listbox.config(yscrollcommand=scrollbar.set) # Środkowa kolumna – podgląd obrazu center_frame = tk.Frame(main_frame) center_frame.grid(row=0, column=1, sticky=tk.NSEW, padx=10) main_frame.grid_columnconfigure(1, weight=1) main_frame.grid_rowconfigure(0, weight=1) main_frame.grid_rowconfigure(1, weight=0) self.image_label = tk.Label(center_frame) self.image_label.pack(fill=tk.BOTH, expand=True) self.image_label.bind("", self.on_image_label_resize) # Prawa kolumna – panel tagów i uploadu (ograniczona szerokość) right_frame = tk.Frame(main_frame, width=300) right_frame.grid(row=0, column=2, sticky=tk.NSEW, padx=5) right_frame.grid_propagate(False) right_frame.grid_columnconfigure(0, weight=1) # Ustal wiersze: right_frame.grid_rowconfigure(0, weight=0) # PNG Tags – naturalny rozmiar right_frame.grid_rowconfigure(1, weight=0) # Tagger Tags – naturalny rozmiar right_frame.grid_rowconfigure(2, weight=0) # Manual Tags (jednolinijkowe) right_frame.grid_rowconfigure(3, weight=1) # Final Tags – rozszerza się right_frame.grid_rowconfigure(4, weight=0) # Upload Panel # PNG Tags – widget Text z scrollbar png_frame = tk.LabelFrame(right_frame, text=_("PNG Tags")) png_frame.grid(row=0, column=0, sticky=tk.EW, padx=5, pady=5) png_frame.grid_columnconfigure(0, weight=1) self.png_tags_text = tk.Text(png_frame, wrap=tk.WORD) self.png_tags_text.grid(row=0, column=0, sticky=tk.EW) scrollbar_png = tk.Scrollbar(png_frame, command=self.png_tags_text.yview) scrollbar_png.grid(row=0, column=1, sticky=tk.NS) self.png_tags_text.config( yscrollcommand=scrollbar_png.set, state=tk.DISABLED, height=4 ) # Tagger Tags – widget Text z scrollbar tagger_frame = tk.LabelFrame(right_frame, text=_("Tagger Tags")) tagger_frame.grid(row=1, column=0, sticky=tk.EW, padx=5, pady=5) tagger_frame.grid_columnconfigure(0, weight=1) self.tagger_tags_text = tk.Text(tagger_frame, wrap=tk.WORD) self.tagger_tags_text.grid(row=0, column=0, sticky=tk.EW) scrollbar_tagger = tk.Scrollbar( tagger_frame, command=self.tagger_tags_text.yview ) scrollbar_tagger.grid(row=0, column=1, sticky=tk.NS) self.tagger_tags_text.config( yscrollcommand=scrollbar_tagger.set, state=tk.DISABLED, height=4 ) # Manual Tags – Entry (stała wysokość) manual_frame = tk.LabelFrame(right_frame, text=_("Manual Tags")) manual_frame.grid(row=2, column=0, sticky=tk.NSEW, padx=5, pady=5) self.manual_tags_manager = TagManager( manual_frame, self.core.settings, self.core.tags_repo, self.update_final_tags, ) self.manual_tags_manager.pack(fill=tk.BOTH, expand=True) # Final Tags – widget Text z scrollbar, który rozszerza się final_frame = tk.LabelFrame(right_frame, text=_("Final Tags")) final_frame.grid(row=3, column=0, sticky=tk.NSEW, padx=5, pady=5) final_frame.grid_rowconfigure(0, weight=1) final_frame.grid_columnconfigure(0, weight=1) self.final_tags_text = tk.Text(final_frame, state=tk.DISABLED, wrap=tk.WORD) self.final_tags_text.grid(row=0, column=0, sticky=tk.NSEW) scrollbar_final = tk.Scrollbar(final_frame, command=self.final_tags_text.yview) scrollbar_final.grid(row=0, column=1, sticky=tk.NS) self.final_tags_text.config(yscrollcommand=scrollbar_final.set) # Panel uploadu i rating – nie zmienia rozmiaru pionowo upload_frame = tk.Frame(right_frame) upload_frame.grid(row=4, column=0, sticky=tk.EW, padx=5, pady=5) self.rating_var = tk.StringVar(value="Unrated") rating_options = ["General", "Sensitive", "Questionable", "Explicit", "Unrated"] self.rating_dropdown = tk.OptionMenu( upload_frame, self.rating_var, *rating_options ) self.rating_dropdown.pack(side=tk.LEFT, padx=5) self.upload_button = tk.Button( upload_frame, text=_("Wyślij"), command=self.upload_current_image ) self.upload_button.pack(side=tk.LEFT, padx=5) self.upload_button.config(state=tk.DISABLED) self.view_post_button = tk.Button( upload_frame, text=_("Wyświetl"), command=self.view_current_post ) self.view_post_button.pack(side=tk.LEFT, padx=5) self.view_post_button.config(state=tk.DISABLED) btn_prev = tk.Button(upload_frame, text="<<", command=self.show_prev) btn_prev.pack(side=tk.LEFT, padx=5) btn_next = tk.Button(upload_frame, text=">>", command=self.show_next) btn_next.pack(side=tk.LEFT, padx=5) # Na końcu okna głównego dodaj status bar: self.status_label = tk.Label( main_frame, text="", bd=1, relief=tk.SUNKEN, anchor=tk.W ) self.status_label.grid(row=1, column=0, columnspan=3, sticky=tk.EW) def update_status_bar(self): status_text = ( f"{_('Przetworzono tagi:')} {len(self.core.tagger_processed)}/{self.core.total_files} {_('plików')} | " f"{_('Zweryfikowano status uploadu:')} {self.core.upload_verified}/{self.core.total_files} {_('plików')} | " f"{_('Zuploadowano:')} {self.core.uploaded_count}/{self.core.upload_verified} {_('plików')}" ) self.status_label.config(text=status_text) def on_arrow_key(self, event): """ Obsługuje klawisze strzałek w lewo i w prawo. """ # Jeśli fokus jest na Manual Tags, nie przełączamy plików. if self.focus_get() == self.manual_tags_manager: return if event.keysym == "Left": self.show_prev() elif event.keysym == "Right": self.show_next() def bind_events(self): """ Przypisuje zdarzenia do klawiszy strzałek w lewo i w prawo. """ self.bind("", self.on_arrow_key) self.bind("", self.on_arrow_key) def select_folder(self): """ Otwiera okno dialogowe wyboru folderu z obrazkami i wczytuje pliki PNG, JPEG, WebP, AVIF i GIF z wybranego folderu. """ folder = filedialog.askdirectory( title=_("Wybierz folder z obrazkami PNG, JPEG, WebP, AVIF i GIF") ) if folder: self.core.folder_path = folder self.core.load_images() # Clear the entire listbox to prepare for reloading new items self.listbox.delete(0, tk.END) for file in self.core.image_files: self.listbox.insert(tk.END, os.path.basename(file)) if self.core.image_files: self.core.current_index = 0 self.listbox.select_set(0) self.show_image(0) self.update_button_states() else: messagebox.showinfo( _("Informacja"), _("Brak plików PNG, JPEG, WebP, AVIF lub GIF w wybranym folderze."), ) def update_button_states(self): """ Update the state of UI elements based on current application state. Synchronizes the enabled/disabled states of menu items and buttons with: - Whether an image is currently selected (current_index exists) - Whether the selected image has an existing post (post_id exists in uploaded) State transitions: - When no image is selected (current_index is None): * Disables all upload-related buttons and menu items - When an image is selected: * Always enables 'Wyślij wszystko' (Send All) * Enables 'Wyślij' (Send) if no post exists for the image * Enables 'Podmień tagi' (Replace Tags) and 'Otwórz post' (Open Post) if a post exists for the image * Updates the upload button's text and command based on post existence Modifies: - File menu items: 'Wyślij', 'Wyślij wszystko', 'Podmień tagi', 'Otwórz post' - Buttons: upload_button, view_post_button Dependencies: - Relies on self.core.current_index to determine selection state - Checks self.uploaded against self.image_files for post existence """ has_current = self.core.current_index is not None post_id = ( self.core.uploaded.get(self.core.image_files[self.core.current_index]) if has_current else None ) # Common state for all elements send_all_state = tk.NORMAL if has_current else tk.DISABLED post_ops_state = tk.NORMAL if post_id else tk.DISABLED # Update menu items self.file_menu.entryconfig(_("Wyślij wszystko"), state=send_all_state) self.file_menu.entryconfig(_("Podmień tagi"), state=post_ops_state) self.file_menu.entryconfig(_("Otwórz post"), state=post_ops_state) # Update buttons self.upload_button.config( state=tk.NORMAL if has_current else tk.DISABLED, text=_("Podmień tagi") if post_id else _("Wyślij"), command=self.edit_current_image if post_id else self.upload_current_image, ) self.view_post_button.config(state=post_ops_state) # Special case for "Wyślij" menu item wyślij_state = tk.DISABLED if post_id else tk.NORMAL self.file_menu.entryconfig( _("Wyślij"), state=wyślij_state if has_current else tk.DISABLED ) def view_current_post(self): """Otwiera w przeglądarce URL posta.""" if self.core.current_index is None: return post_id = self.core.uploaded.get(self.core.image_files[self.core.current_index]) if post_id: url = self.core.settings.base_url.rstrip("/") + "/post/view/" + str(post_id) open_webbrowser(url, self.core.settings) def on_listbox_select(self, event): """ Wywoływane po wybraniu pliku z listy. Wyświetla obrazek, aktualizuje tagi PNG, uruchamia Taggera, ustawia przycisk uploadu. """ if not self.listbox.curselection(): return index = self.listbox.curselection()[0] self.core.current_index = index self.show_image(index) self.update_button_states() def upload_current_image(self): """ Uploaduje obrazek do serwera. Wysyła POST z obrazkiem, tagami i ratingiem. Po zakończeniu uploadu, ustawia przycisk na "Wyświetl". """ file_path = self.core.image_files[self.core.current_index] if self.core.uploaded.get(file_path, False): # Jeśli plik już uploadowany, ustaw przycisk na "Wyświetl" self.update_button_states() return self.upload_button.config(state=tk.DISABLED) self.processing_dialog = ProcessingDialog( self, lambda file_path, process_queue=None, cancel_event=None: self.core.upload_file( file_path=file_path, final_tags=self.final_tags_text.get("1.0", tk.END).strip(), final_rating=self.rating_var.get(), progress_queue=process_queue, cancel_event=cancel_event, warning_callback=lambda message: messagebox.showwarning( _("Wysyłanie"), message ), info_callback=lambda message: messagebox.showinfo( _("Wysyłanie"), message ), error_callback=lambda message: messagebox.showerror( _("Błąd wysyłania"), message ), ), file_path, ) def show_image(self, index): """ Wyświetla obrazek o podanym indeksie z listy. Odczytuje tagi z pliku PNG, uruchamia Taggera, aktualizuje widgety z tagami. """ if index < 0 or index >= len(self.core.image_files): return file_path = self.core.image_files[index] try: img = Image.open(file_path) parameters = extract_parameters(img, file_path) artist_tag = extract_artist_from_filename(file_path) self.current_image_original = img.copy() self.current_parameters = parameters self.update_display_image() parsed_parameters = parse_parameters(parameters, self.core.tags_repo) if artist_tag: parsed_parameters += ( f" artist:{artist_tag.replace(' ', '_').replace('\\', '')}" ) # Uaktualnij widget PNG Tags self.update_png_tags_widget(parsed_parameters.split()) # Uruchom Taggera w osobnym wątku thread_name = f"tagger{self.tagger_thread_idx}" thread = threading.Thread(target=self.run_tagger, args=(thread_name,)) self.run_tagger_threads[thread_name] = thread self.tagger_thread_idx += 1 thread.start() except Exception as e: messagebox.showerror(_("Błąd"), f"{_('Nie można załadować obrazka:')}\n{e}") def edit_current_image(self): """ Modyfikuje obrazek na serwerze. Wysyła POST z md5 obrazka, tagami i ratingiem. """ file_path = self.core.image_files[self.core.current_index] if not self.core.uploaded.get(file_path, False): self.update_button_states() return self.upload_button.config(state=tk.DISABLED) self.processing_dialog = ProcessingDialog( self, lambda file_path, progress_queue=None, cancel_event=None: self.core.edit_file( file_path=file_path, final_tags=self.final_tags_text.get("1.0", tk.END).strip(), final_rating=self.rating_var.get(), progress_queue=progress_queue, cancel_event=cancel_event, error_callback=lambda message: messagebox.showerror( _("Błąd edycji"), message ), info_callback=lambda message: messagebox.showinfo( _("Sukces edycji"), message ), ), file_path, ) def update_display_image(self): """ Aktualizuje obrazek na podstawie aktualnego rozmiaru okna. Skaluje obrazek, jeśli jego rozmiar jest większy niż dostępna przestrzeń. """ if self.current_image_original is None: return self.image_label.update_idletasks() avail_width = self.image_label.winfo_width() avail_height = self.image_label.winfo_height() if avail_width <= 1 or avail_height <= 1: avail_width, avail_height = 400, 400 orig_width, orig_height = self.current_image_original.size scale = min(avail_width / orig_width, avail_height / orig_height, 1) new_width = int(orig_width * scale) new_height = int(orig_height * scale) try: resample_filter = Image.Resampling.LANCZOS except AttributeError: resample_filter = Image.LANCZOS if scale < 1: img = self.current_image_original.resize( (new_width, new_height), resample=resample_filter ) else: img = self.current_image_original self.image_cache = ImageTk.PhotoImage(img) self.image_label.config(image=self.image_cache) def update_listbox_item_color_by_rating(self, file_path, rating): """ Ustawia kolor tła dla pozycji w liście na podstawie ratingu, o ile plik nie został uploadowany. Kolor: lightgreen dla General, yellow dla Sensitive, darkorange dla Questionable, red dla Explicit. Jeśli plik uploadowany, nie zmieniamy (pozostaje lightgray). """ # Jeśli plik jest oznaczony jako uploadowany, nic nie robimy if self.core.uploaded.get(file_path, False): return try: index = self.core.image_files.index(file_path) except ValueError: return # Mapowanie ratingu na kolor if rating == "general": color = "lightgreen" elif rating == "sensitive": color = "yellow" elif rating == "questionable": color = "darkorange" elif rating == "explicit": color = "red" else: color = "white" self.after( 0, lambda idx=index, col=color: self.listbox.itemconfig(idx, {"bg": col}) ) def on_image_label_resize(self, event): """ Wywoływane przy zmianie rozmiaru okna. Aktualizuje obrazek w zależności od nowego rozmiaru okna. """ self.update_display_image() def show_prev(self): """ Przełącza na poprzedni obraz """ if self.core.current_index is None: return new_index = self.core.current_index - 1 if new_index < 0: new_index = len(self.core.image_files) - 1 self.core.current_index = new_index self.listbox.select_clear(0, tk.END) self.listbox.select_set(new_index) self.listbox.activate(new_index) self.show_image(new_index) self.update_button_states() def show_next(self): """ Przełącza na następny obraz """ if self.core.current_index is None: return new_index = self.core.current_index + 1 if new_index >= len(self.core.image_files): new_index = 0 self.core.current_index = new_index self.listbox.select_clear(0, tk.END) self.listbox.select_set(new_index) self.listbox.activate(new_index) self.show_image(new_index) self.update_button_states() # --- Metody obsługujące widgety z tagami --- def update_png_tags_widget(self, tags_list): """ Aktualizuje widget z tagami z PNG. Tworzy tagi jako klikalne, zaznaczone lub niezaznaczone. """ self.png_tags_text.config(state=tk.NORMAL) self.png_tags_text.delete("1.0", tk.END) self.png_tags_states = {} for tag in tags_list: self.png_tags_states[tag] = True start_index = self.png_tags_text.index(tk.INSERT) self.png_tags_text.insert(tk.INSERT, tag) end_index = self.png_tags_text.index(tk.INSERT) tag_name = "png_" + tag self.png_tags_text.tag_add(tag_name, start_index, end_index) self.png_tags_text.tag_configure(tag_name, foreground="blue") self.png_tags_text.tag_bind(tag_name, "", self.toggle_png_tag) self.png_tags_text.insert(tk.INSERT, " ") self.png_tags_text.config(state=tk.DISABLED) self.adjust_text_widget_height(self.png_tags_text) self.update_final_tags() def toggle_png_tag(self, event): """ Obsługuje kliknięcie na tag w PNG Tags. Zmienia stan tagu (zaznaczony/niezaznaczony) i aktualizuje listę finalnych tagów. """ index = self.png_tags_text.index("@%d,%d" % (event.x, event.y)) for t in self.png_tags_text.tag_names(index): if t.startswith("png_"): actual_tag = t[len("png_") :] self.png_tags_states[actual_tag] = not self.png_tags_states.get( actual_tag, True ) color = "blue" if self.png_tags_states[actual_tag] else "black" self.png_tags_text.tag_configure(t, foreground=color) break self.update_visible_tagger_tags() def get_visible_tags(self) -> list[tuple[str, bool, float]]: """ Zwraca listę tagów, które mają być widoczne w Tagger Tags. Tagi zaznaczone są zawsze widoczne. Tagi niezaznaczone są widoczne, jeśli nie są implikowane przez zaznaczone tagi. """ visible_tags = [] # character:amber_redmond cheat / WORKAROUND if "character:amber_redmond" in self.manual_tags_manager.manual_tags: for tag in [ "1girl", "tan", "black_hair", "very_short_hair", "short_hair", "black_choker", "heart_choker", "red_hair", "cutoffs", "blue_shorts", "short_shorts", "short_sleeves", "off_shoulder", "black_footwear", "feet", "toenail_polish", "toenails", "fingernails", "nail_polish", ]: if tag in self.tagger_tags_states: self.tagger_tags_states[tag] = ( True, self.tagger_tags_states[tag][1], ) for tag in [ "virtual_youtuber", "dark_skin", "dark-skinned_female", "black_collar", "collar", "yellow_eyes", ]: if tag in self.tagger_tags_states: self.tagger_tags_states.pop(tag) selected_tags = [ tag for tag, (selected, _) in self.tagger_tags_states.items() if selected ] selected_png_tags = [ tag for tag, selected in self.png_tags_states.items() if selected ] implied_by_selected = set() # Safely collect implications from selected tags for tag in selected_tags: if tag in self.core.implication_graph: implied_by_selected.update( nx.descendants(self.core.implication_graph, tag) ) else: print( _("Warning: Tag '{tag}' not found in implication graph").format( tag=tag ) ) self.core.missing_tags.add(tag) # Log missing tags for tag in selected_png_tags: if tag in self.core.implication_graph: implied_by_selected.update( nx.descendants(self.core.implication_graph, tag) ) else: print( _("Warning: Tag '{tag}' not found in implication graph").format( tag=tag ) ) self.core.missing_tags.add(tag) # Log missing tags # Build visible list for tag, (selected, confidence) in self.tagger_tags_states.items(): if selected: visible_tags.append((tag, True, confidence)) else: if tag not in implied_by_selected: visible_tags.append((tag, False, confidence)) return visible_tags def update_tagger_tags_widget(self, result: wdt.Result): """ Aktualizuje widget z tagami z Taggera. Zaznacza tagi, które są wspólne z tagami z PNG. Ukrywa tagi, które są implikowane przez zaznaczone tagi. """ # Opróżniamy widget i słownik stanów self.tagger_tags_states = {} tags_dict = {} # Przetwarzamy tagi postaci – dodajemy prefix "character:" i zaznaczamy je for tag, prob in result.character_tag_data.items(): full_tag = "character:" + tag # Zamień nieprawidłowe tagi na poprawne if full_tag in TAG_FIXES: full_tag = TAG_FIXES[full_tag] tags_dict[full_tag] = prob # Przetwarzamy tagi general – wszystkie ustawiamy jako zaznaczone for tag, prob in result.general_tag_data.items(): # Zamień nieprawidłowe tagi na poprawne if tag in TAG_FIXES: tag = TAG_FIXES[tag] tags_dict[tag] = prob for tag, prob in tags_dict.items(): self.tagger_tags_states[tag] = (True, prob) # Obliczamy przecięcie tagów z PNG i Taggera common = set(self.png_tags_states.keys()).intersection( set(self.tagger_tags_states.keys()) ) # Aktualizujemy stany w obu grupach – tylko wspólne tagi pozostają zaznaczone (True) for tag in self.png_tags_states: if tag.startswith("character:") or tag.startswith("copyright:"): continue # Pomijamy tagi postaci i praw autorskich, bo mamy do nich pewność self.png_tags_states[tag] = tag in common tag_name = "png_" + tag color = "blue" if self.png_tags_states[tag] else "black" self.png_tags_text.config(state=tk.NORMAL) self.png_tags_text.tag_configure(tag_name, foreground=color) self.png_tags_text.config(state=tk.DISABLED) for tag in self.tagger_tags_states: if tag.startswith("character:"): continue # Pomijamy tagi postaci, bo mamy do nich pewność self.tagger_tags_states[tag] = ( tag in common, self.tagger_tags_states[tag][1], ) self.update_visible_tagger_tags() def update_visible_tagger_tags(self): """ Aktualizuje widget z tagami z Taggera. Wyświetla tagi z Taggera w formacie: "tag (confidence %)". Koloruje tagi na podstawie stanu (zaznaczony/niezaznaczony) i prawdopodobieństwa. Podczas kliknięcia na tag, zmienia stan (zaznaczony/niezaznaczony). Poprawia wysokość widgetu. Aktualizuje listę finalnych tagów. """ self.tagger_tags_text.config(state=tk.NORMAL) self.tagger_tags_text.delete("1.0", tk.END) visible_tags = self.get_visible_tags() for tag, selected, prob in visible_tags: display_text = f"{tag} ({float(prob)*100:.1f}%)" # np. "Rem (99.9%)" start_index = self.tagger_tags_text.index(tk.INSERT) self.tagger_tags_text.insert(tk.INSERT, display_text) end_index = self.tagger_tags_text.index(tk.INSERT) tag_name = "tagger_" + tag color = self.get_tagger_tag_color((selected, prob)) self.tagger_tags_text.tag_add(tag_name, start_index, end_index) self.tagger_tags_text.tag_configure(tag_name, foreground=color) self.tagger_tags_text.tag_bind( tag_name, "", self.toggle_tagger_tag ) self.tagger_tags_text.insert(tk.INSERT, " ") self.tagger_tags_text.config(state=tk.DISABLED) self.adjust_text_widget_height(self.tagger_tags_text) self.update_final_tags() def get_tagger_tag_color(self, tagger_tag_state: Tuple[bool, float]) -> str: """ Zwraca kolor dla tagu z Taggera na podstawie stanu (selected) i prawdopodobieństwa. Kolor: - niebieski, jeśli tag jest zaznaczony, - czerwony, jeśli tag nie jest zaznaczony i prawdopodobieństwo < 0.3. - żółty, jeśli tag nie jest zaznaczony i prawdopodobieństwo >= 0.3 i < 0.6. - zielony, jeśli tag nie jest zaznaczony i prawdopodobieństwo >= 0.6. """ selected, prob = tagger_tag_state if selected: return "blue" if prob < 0.3: return "red" if prob < 0.6: return "darkorange" return "green" def toggle_tagger_tag(self, event): """ Obsługuje kliknięcie na tag z Taggera. Zmienia stan tagu (zaznaczony/niezaznaczony) i aktualizuje kolor. """ index = self.tagger_tags_text.index("@%d,%d" % (event.x, event.y)) for t in self.tagger_tags_text.tag_names(index): if t.startswith("tagger_"): actual_tag = t[len("tagger_") :] tag_state, tag_prob = self.tagger_tags_states.get(actual_tag, (True, 0)) self.tagger_tags_states[actual_tag] = (not tag_state, tag_prob) color = self.get_tagger_tag_color(self.tagger_tags_states[actual_tag]) self.tagger_tags_text.tag_configure(t, foreground=color) break self.update_visible_tagger_tags() def update_final_tags(self): """Buduje finalną listę tagów, uwzględniając default tags, PNG, Tagger i manualne. Dla każdego tagu pobiera informacje z bazy i ustawia styl (kolor i podkreślenie): - Deprecated: czerwony, podkreślony, - Tag nie istnieje: żółty, podkreślony, - Normalny: np. niebieski, bez podkreślenia. """ final_set = set() if self.core.settings.default_tags: final_set.update(self.core.settings.default_tags.split()) for tag, selected in self.png_tags_states.items(): if selected: final_set.add(tag) for tag, selected in self.tagger_tags_states.items(): if selected[0]: final_set.add(tag) manual = self.manual_tags_manager.manual_tags if manual: final_set.update(manual) final_list = sorted(final_set) self.final_tags_text.config(state=tk.NORMAL) self.final_tags_text.delete("1.0", tk.END) for tag in final_list: tag, deprecated = process_tag(tag, self.core.tags_repo) # Ustal kolor i podkreślenie na podstawie wyniku if deprecated is True: color = "red" underline = 1 elif deprecated is None: color = "darkorange" underline = 1 else: color = "blue" underline = 0 start_index = self.final_tags_text.index(tk.INSERT) self.final_tags_text.insert(tk.INSERT, tag) end_index = self.final_tags_text.index(tk.INSERT) tag_name = "final_" + tag self.final_tags_text.tag_add(tag_name, start_index, end_index) self.final_tags_text.tag_configure( tag_name, foreground=color, underline=underline ) self.final_tags_text.tag_bind( tag_name, "", self.open_final_tag_wiki_url ) self.final_tags_text.insert(tk.INSERT, " ") self.final_tags_text.config(state=tk.DISABLED) def open_final_tag_wiki_url(self, event): """Otwiera w przeglądarce URL strony wiki dla klikniętego tagu. Usuwa znane prefiksy, aby utworzyć poprawny URL. """ index = self.final_tags_text.index("@%d,%d" % (event.x, event.y)) for t in self.final_tags_text.tag_names(index): if t.startswith("final_"): actual_tag = t[len("final_") :] open_tag_wiki_url(actual_tag, self.core.settings) break def run_tagger(self, thread_name): """ Przetwarza aktualnie wybrany obrazek. Jeśli wynik jest już w cache, wykorzystuje go; w przeciwnym razie uruchamia Taggera i zapisuje wynik do cache. """ if self.core.current_index is None: return # Ustaw komunikat, że Tagger pracuje self.tagger_tags_text.config(state=tk.NORMAL) self.tagger_tags_text.delete("1.0", tk.END) self.tagger_tags_text.insert("1.0", _("Tagger przetwarza...")) self.tagger_tags_text.config(state=tk.DISABLED) file_path = self.core.image_files[self.core.current_index] result = self.core.get_tagger_results(file_path, lambda: self.after(0, self.update_status_bar)) new_rating = self.core.map_tagger_rating(result) self.rating_var.set(new_rating) self.after(0, lambda: self.update_tagger_tags_widget(result)) self.update_listbox_item_color_by_rating(file_path, result.rating) self.after(100, lambda: self.run_tagger_threads[thread_name].join()) def upload_all_files(self): """ Metoda, która po potwierdzeniu przez użytkownika uploaduje wszystkie niewrzucone pliki. Dla każdego pliku oblicza finalne tagi (przy użyciu compute_final_tags_for_file) i wywołuje upload_file. """ if not messagebox.askyesno( _("Potwierdzenie"), _( "Czy na pewno chcesz wrzucić wszystkie niewrzucone pliki?\nKażdy z nich zostanie oznaczony tagiem 'meta:auto_upload'.\nUpewnij się, że tagi są poprawne!" ), ): return self.processing_dialog = ProcessingDialog( self, lambda progress_queue=None, cancel_event=None, secondary_progress_queue=None: self.core.upload_all_files( progress_queue=progress_queue, cancel_event=cancel_event, secondary_progress_queue=secondary_progress_queue, update_status_callback=lambda: self.after(0, self.update_status_bar), manual_tags=set(self.manual_tags_manager.manual_tags), warning_callback=lambda message: messagebox.showwarning( _("Wysyłanie"), message ), info_callback=lambda message: messagebox.showinfo( _("Wysyłanie"), message ), error_callback=lambda message: messagebox.showerror( _("Błąd wysyłania"), message ), ), )