Files
kapitanbooru-uploader/kapitanbooru_uploader/ImageBrowser.py
Kapitan 88288c7e22
All checks were successful
Gitea/kapitanbooru-uploader/pipeline/head This commit looks good
Bump version to 0.9.3; update localization files and add tag states in ImageBrowser
2025-06-26 17:51:25 +02:00

1196 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.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.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("<Button-1>", 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(
"<Button-1>", 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("<<ListboxSelect>>", 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("<Configure>", 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("<Left>", self.on_arrow_key)
self.bind("<Right>", 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.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.current_index to determine selection state
- Checks self.uploaded against self.image_files for post existence
"""
has_current = self.current_index is not None
post_id = (
self.core.uploaded.get(self.core.image_files[self.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.current_index is None:
return
post_id = self.core.uploaded.get(self.core.image_files[self.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.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.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.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.current_index is None:
return
new_index = self.current_index - 1
if new_index < 0:
new_index = len(self.core.image_files) - 1
self.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.current_index is None:
return
new_index = self.current_index + 1
if new_index >= len(self.core.image_files):
new_index = 0
self.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, "<Button-1>", 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, "<Button-1>", 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, "<Button-1>", 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.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.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
),
),
)