Better UI, edit tags
All checks were successful
Gitea/kapitanbooru-uploader/pipeline/head This commit looks good
All checks were successful
Gitea/kapitanbooru-uploader/pipeline/head This commit looks good
This commit is contained in:
parent
dbea14888e
commit
5571e18102
@ -1,64 +1,96 @@
|
|||||||
from .common import open_tag_wiki_url, open_webbrowser
|
|
||||||
from .ProgressFile import ProgressFile
|
|
||||||
from .autocomplete import TagManager
|
|
||||||
from .settings import Settings
|
|
||||||
from .tag_processing import TAG_FIXES, parse_parameters, process_tag
|
|
||||||
from .tagger_cache import TaggerCache
|
|
||||||
from .TagsRepo import TagsRepo
|
|
||||||
|
|
||||||
|
|
||||||
import networkx as nx
|
|
||||||
import requests
|
|
||||||
import wdtagger as wdt
|
|
||||||
from PIL import Image, ImageTk, PngImagePlugin
|
|
||||||
|
|
||||||
|
|
||||||
import glob
|
import glob
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import filedialog, messagebox, ttk
|
from tkinter import filedialog, messagebox, ttk
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
|
import networkx as nx
|
||||||
|
import requests
|
||||||
|
from PIL import Image, ImageTk, PngImagePlugin
|
||||||
|
|
||||||
|
import wdtagger as wdt
|
||||||
|
from .ProgressFile import ProgressFile
|
||||||
|
from .TagsRepo import TagsRepo
|
||||||
|
from .autocomplete import TagManager
|
||||||
|
from .common import get_auth_token, login, open_tag_wiki_url, open_webbrowser
|
||||||
|
from .settings import Settings
|
||||||
|
from .tag_processing import TAG_FIXES, parse_parameters, process_tag
|
||||||
|
from .tagger_cache import TaggerCache
|
||||||
|
|
||||||
|
|
||||||
class ProcessingDialog:
|
class ProcessingDialog:
|
||||||
def __init__(self, root, target_function, *args):
|
def __init__(self, root, target_function, *args):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.top = tk.Toplevel(root) # Create a top-level window
|
self.top = tk.Toplevel(root)
|
||||||
self.top.title("Processing...")
|
self.top.title("Processing...")
|
||||||
self.top.geometry("300x150")
|
self.top.geometry("300x150")
|
||||||
self.top.protocol("WM_DELETE_WINDOW", self.on_close) # Handle close event
|
self.top.protocol("WM_DELETE_WINDOW", self.on_close)
|
||||||
|
|
||||||
# Create Label and Progress Bar (or rotating animation)
|
|
||||||
self.label = tk.Label(self.top, text="Processing, please wait...")
|
self.label = tk.Label(self.top, text="Processing, please wait...")
|
||||||
self.label.pack(pady=10)
|
self.label.pack(pady=10)
|
||||||
|
|
||||||
|
# Start with indeterminate progress bar
|
||||||
self.progress = ttk.Progressbar(self.top, mode="indeterminate")
|
self.progress = ttk.Progressbar(self.top, mode="indeterminate")
|
||||||
self.progress.pack(pady=10, fill="x")
|
self.progress.pack(pady=10, fill="x")
|
||||||
self.progress.start(10) # Start animation
|
self.progress.start(10)
|
||||||
|
|
||||||
# Create a thread for the target function
|
# Setup communication queue and periodic checker
|
||||||
|
self.queue = queue.Queue()
|
||||||
self.running = True
|
self.running = True
|
||||||
self.thread = threading.Thread(
|
self.thread = threading.Thread(
|
||||||
target=self.run_task, args=(target_function, *args)
|
target=self.run_task, args=(target_function, *args)
|
||||||
)
|
)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
|
self.top.after(100, self.process_queue)
|
||||||
|
|
||||||
|
def process_queue(self):
|
||||||
|
"""Process messages from the background thread"""
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
msg = self.queue.get_nowait()
|
||||||
|
|
||||||
|
if msg[0] == "mode":
|
||||||
|
self.progress.config(mode=msg[1])
|
||||||
|
if msg[1] == "determinate":
|
||||||
|
self.progress["value"] = 0
|
||||||
|
elif msg[0] == "max":
|
||||||
|
self.progress["maximum"] = msg[1]
|
||||||
|
elif msg[0] == "progress":
|
||||||
|
self.progress["value"] = msg[1]
|
||||||
|
elif msg[0] == "label":
|
||||||
|
self.label.config(text=msg[1])
|
||||||
|
|
||||||
|
self.top.update_idletasks()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
if self.running:
|
||||||
|
self.top.after(100, self.process_queue)
|
||||||
|
|
||||||
def run_task(self, target_function, *args):
|
def run_task(self, target_function, *args):
|
||||||
|
"""Execute target function with progress queue if supported"""
|
||||||
try:
|
try:
|
||||||
target_function(*args) # Run the function
|
sig = inspect.signature(target_function)
|
||||||
|
if "progress_queue" in sig.parameters:
|
||||||
|
target_function(*args, progress_queue=self.queue)
|
||||||
|
else:
|
||||||
|
target_function(*args)
|
||||||
finally:
|
finally:
|
||||||
self.close_dialog() # Close when done
|
self.close_dialog()
|
||||||
|
|
||||||
def close_dialog(self):
|
def close_dialog(self):
|
||||||
"""Safely close the dialog."""
|
"""Safely close the dialog"""
|
||||||
if self.running:
|
if self.running:
|
||||||
self.running = False
|
self.running = False
|
||||||
self.top.after(0, self.top.destroy) # Ensure UI updates on the main thread
|
self.progress.stop()
|
||||||
|
self.top.after(0, self.top.destroy)
|
||||||
|
|
||||||
def on_close(self):
|
def on_close(self):
|
||||||
"""User manually closed the dialog, terminate thread."""
|
"""Handle manual window closure"""
|
||||||
self.running = False
|
self.running = False
|
||||||
self.top.destroy()
|
self.top.destroy()
|
||||||
|
|
||||||
@ -271,12 +303,42 @@ class ImageBrowser(tk.Tk):
|
|||||||
def create_menu(self):
|
def create_menu(self):
|
||||||
menubar = tk.Menu(self)
|
menubar = tk.Menu(self)
|
||||||
self.config(menu=menubar)
|
self.config(menu=menubar)
|
||||||
menubar.add_command(label="Ustawienia", command=self.open_settings)
|
|
||||||
menubar.add_command(label="Wyczyść cache Taggera", command=self.clear_cache)
|
# Create file menu and store it as instance variable
|
||||||
menubar.add_command(label="Wrzuć wszystko", command=self.upload_all_files)
|
self.file_menu = tk.Menu(menubar, tearoff=0)
|
||||||
menubar.add_command(
|
|
||||||
|
# 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
|
label="Zregeneruj bazę tagów", command=self.regenerate_tags_db
|
||||||
)
|
)
|
||||||
|
menubar.add_cascade(label="Opcje", menu=options_menu)
|
||||||
|
|
||||||
def regenerate_tags_db(self):
|
def regenerate_tags_db(self):
|
||||||
self.processing_dialog = ProcessingDialog(self, self.tags_repo.regenerate_db)
|
self.processing_dialog = ProcessingDialog(self, self.tags_repo.regenerate_db)
|
||||||
@ -362,7 +424,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||||
# Lewa kolumna – lista plików
|
# Lewa kolumna – lista plików
|
||||||
left_frame = tk.Frame(main_frame)
|
left_frame = tk.Frame(main_frame)
|
||||||
left_frame.grid(row=0, column=0, sticky="ns")
|
left_frame.grid(row=0, column=0, sticky=tk.NS)
|
||||||
self.listbox = tk.Listbox(left_frame, width=30)
|
self.listbox = tk.Listbox(left_frame, width=30)
|
||||||
self.listbox.pack(side=tk.LEFT, fill=tk.Y)
|
self.listbox.pack(side=tk.LEFT, fill=tk.Y)
|
||||||
self.listbox.bind("<<ListboxSelect>>", self.on_listbox_select)
|
self.listbox.bind("<<ListboxSelect>>", self.on_listbox_select)
|
||||||
@ -374,7 +436,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
|
|
||||||
# Środkowa kolumna – podgląd obrazu
|
# Środkowa kolumna – podgląd obrazu
|
||||||
center_frame = tk.Frame(main_frame)
|
center_frame = tk.Frame(main_frame)
|
||||||
center_frame.grid(row=0, column=1, sticky="nsew", padx=10)
|
center_frame.grid(row=0, column=1, sticky=tk.NSEW, padx=10)
|
||||||
main_frame.grid_columnconfigure(1, weight=1)
|
main_frame.grid_columnconfigure(1, weight=1)
|
||||||
main_frame.grid_rowconfigure(0, weight=1)
|
main_frame.grid_rowconfigure(0, weight=1)
|
||||||
main_frame.grid_rowconfigure(1, weight=0)
|
main_frame.grid_rowconfigure(1, weight=0)
|
||||||
@ -384,7 +446,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
|
|
||||||
# Prawa kolumna – panel tagów i uploadu (ograniczona szerokość)
|
# Prawa kolumna – panel tagów i uploadu (ograniczona szerokość)
|
||||||
right_frame = tk.Frame(main_frame, width=300)
|
right_frame = tk.Frame(main_frame, width=300)
|
||||||
right_frame.grid(row=0, column=2, sticky="nsew", padx=5)
|
right_frame.grid(row=0, column=2, sticky=tk.NSEW, padx=5)
|
||||||
right_frame.grid_propagate(False)
|
right_frame.grid_propagate(False)
|
||||||
right_frame.grid_columnconfigure(0, weight=1)
|
right_frame.grid_columnconfigure(0, weight=1)
|
||||||
# Ustal wiersze:
|
# Ustal wiersze:
|
||||||
@ -396,33 +458,33 @@ class ImageBrowser(tk.Tk):
|
|||||||
|
|
||||||
# PNG Tags – widget Text z scrollbar
|
# PNG Tags – widget Text z scrollbar
|
||||||
png_frame = tk.LabelFrame(right_frame, text="PNG Tags")
|
png_frame = tk.LabelFrame(right_frame, text="PNG Tags")
|
||||||
png_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
|
png_frame.grid(row=0, column=0, sticky=tk.EW, padx=5, pady=5)
|
||||||
png_frame.grid_columnconfigure(0, weight=1)
|
png_frame.grid_columnconfigure(0, weight=1)
|
||||||
self.png_tags_text = tk.Text(png_frame, wrap="word")
|
self.png_tags_text = tk.Text(png_frame, wrap=tk.WORD)
|
||||||
self.png_tags_text.grid(row=0, column=0, sticky="ew")
|
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 = tk.Scrollbar(png_frame, command=self.png_tags_text.yview)
|
||||||
scrollbar_png.grid(row=0, column=1, sticky="ns")
|
scrollbar_png.grid(row=0, column=1, sticky=tk.NS)
|
||||||
self.png_tags_text.config(
|
self.png_tags_text.config(
|
||||||
yscrollcommand=scrollbar_png.set, state="disabled", height=4
|
yscrollcommand=scrollbar_png.set, state=tk.DISABLED, height=4
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tagger Tags – widget Text z scrollbar
|
# Tagger Tags – widget Text z scrollbar
|
||||||
tagger_frame = tk.LabelFrame(right_frame, text="Tagger Tags")
|
tagger_frame = tk.LabelFrame(right_frame, text="Tagger Tags")
|
||||||
tagger_frame.grid(row=1, column=0, sticky="ew", padx=5, pady=5)
|
tagger_frame.grid(row=1, column=0, sticky=tk.EW, padx=5, pady=5)
|
||||||
tagger_frame.grid_columnconfigure(0, weight=1)
|
tagger_frame.grid_columnconfigure(0, weight=1)
|
||||||
self.tagger_tags_text = tk.Text(tagger_frame, wrap="word")
|
self.tagger_tags_text = tk.Text(tagger_frame, wrap=tk.WORD)
|
||||||
self.tagger_tags_text.grid(row=0, column=0, sticky="ew")
|
self.tagger_tags_text.grid(row=0, column=0, sticky=tk.EW)
|
||||||
scrollbar_tagger = tk.Scrollbar(
|
scrollbar_tagger = tk.Scrollbar(
|
||||||
tagger_frame, command=self.tagger_tags_text.yview
|
tagger_frame, command=self.tagger_tags_text.yview
|
||||||
)
|
)
|
||||||
scrollbar_tagger.grid(row=0, column=1, sticky="ns")
|
scrollbar_tagger.grid(row=0, column=1, sticky=tk.NS)
|
||||||
self.tagger_tags_text.config(
|
self.tagger_tags_text.config(
|
||||||
yscrollcommand=scrollbar_tagger.set, state="disabled", height=4
|
yscrollcommand=scrollbar_tagger.set, state=tk.DISABLED, height=4
|
||||||
)
|
)
|
||||||
|
|
||||||
# Manual Tags – Entry (stała wysokość)
|
# Manual Tags – Entry (stała wysokość)
|
||||||
manual_frame = tk.LabelFrame(right_frame, text="Manual Tags")
|
manual_frame = tk.LabelFrame(right_frame, text="Manual Tags")
|
||||||
manual_frame.grid(row=2, column=0, sticky="nsew", padx=5, pady=5)
|
manual_frame.grid(row=2, column=0, sticky=tk.NSEW, padx=5, pady=5)
|
||||||
self.manual_tags_manager = TagManager(
|
self.manual_tags_manager = TagManager(
|
||||||
manual_frame, self.settings, self.tags_repo
|
manual_frame, self.settings, self.tags_repo
|
||||||
)
|
)
|
||||||
@ -436,18 +498,18 @@ class ImageBrowser(tk.Tk):
|
|||||||
|
|
||||||
# Final Tags – widget Text z scrollbar, który rozszerza się
|
# Final Tags – widget Text z scrollbar, który rozszerza się
|
||||||
final_frame = tk.LabelFrame(right_frame, text="Final Tags")
|
final_frame = tk.LabelFrame(right_frame, text="Final Tags")
|
||||||
final_frame.grid(row=3, column=0, sticky="nsew", padx=5, pady=5)
|
final_frame.grid(row=3, column=0, sticky=tk.NSEW, padx=5, pady=5)
|
||||||
final_frame.grid_rowconfigure(0, weight=1)
|
final_frame.grid_rowconfigure(0, weight=1)
|
||||||
final_frame.grid_columnconfigure(0, weight=1)
|
final_frame.grid_columnconfigure(0, weight=1)
|
||||||
self.final_tags_text = tk.Text(final_frame, state="disabled", wrap="word")
|
self.final_tags_text = tk.Text(final_frame, state=tk.DISABLED, wrap=tk.WORD)
|
||||||
self.final_tags_text.grid(row=0, column=0, sticky="nsew")
|
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 = tk.Scrollbar(final_frame, command=self.final_tags_text.yview)
|
||||||
scrollbar_final.grid(row=0, column=1, sticky="ns")
|
scrollbar_final.grid(row=0, column=1, sticky=tk.NS)
|
||||||
self.final_tags_text.config(yscrollcommand=scrollbar_final.set)
|
self.final_tags_text.config(yscrollcommand=scrollbar_final.set)
|
||||||
|
|
||||||
# Panel uploadu i rating – nie zmienia rozmiaru pionowo
|
# Panel uploadu i rating – nie zmienia rozmiaru pionowo
|
||||||
upload_frame = tk.Frame(right_frame)
|
upload_frame = tk.Frame(right_frame)
|
||||||
upload_frame.grid(row=4, column=0, sticky="ew", padx=5, pady=5)
|
upload_frame.grid(row=4, column=0, sticky=tk.EW, padx=5, pady=5)
|
||||||
self.rating_var = tk.StringVar(value="Unrated")
|
self.rating_var = tk.StringVar(value="Unrated")
|
||||||
rating_options = ["General", "Sensitive", "Questionable", "Explicit", "Unrated"]
|
rating_options = ["General", "Sensitive", "Questionable", "Explicit", "Unrated"]
|
||||||
self.rating_dropdown = tk.OptionMenu(
|
self.rating_dropdown = tk.OptionMenu(
|
||||||
@ -458,21 +520,18 @@ class ImageBrowser(tk.Tk):
|
|||||||
upload_frame, text="Upload", command=self.upload_current_image
|
upload_frame, text="Upload", command=self.upload_current_image
|
||||||
)
|
)
|
||||||
self.upload_button.pack(side=tk.LEFT, padx=5)
|
self.upload_button.pack(side=tk.LEFT, padx=5)
|
||||||
self.progress_var = tk.IntVar(value=0)
|
self.upload_button.config(state=tk.DISABLED)
|
||||||
self.progress_bar = ttk.Progressbar(
|
self.view_post_button = tk.Button(
|
||||||
upload_frame,
|
upload_frame, text="Wyświetl", command=self.view_current_post
|
||||||
orient="horizontal",
|
|
||||||
mode="determinate",
|
|
||||||
variable=self.progress_var,
|
|
||||||
maximum=100,
|
|
||||||
)
|
)
|
||||||
self.progress_bar.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
|
self.view_post_button.pack(side=tk.LEFT, padx=5)
|
||||||
|
self.view_post_button.config(state=tk.DISABLED)
|
||||||
|
|
||||||
# Na końcu okna głównego dodaj status bar:
|
# Na końcu okna głównego dodaj status bar:
|
||||||
self.status_label = tk.Label(
|
self.status_label = tk.Label(
|
||||||
main_frame, text="", bd=1, relief=tk.SUNKEN, anchor=tk.W
|
main_frame, text="", bd=1, relief=tk.SUNKEN, anchor=tk.W
|
||||||
)
|
)
|
||||||
self.status_label.grid(row=1, column=0, columnspan=3, sticky="ew")
|
self.status_label.grid(row=1, column=0, columnspan=3, sticky=tk.EW)
|
||||||
|
|
||||||
def update_status_bar(self):
|
def update_status_bar(self):
|
||||||
status_text = (
|
status_text = (
|
||||||
@ -585,31 +644,75 @@ class ImageBrowser(tk.Tk):
|
|||||||
)
|
)
|
||||||
# Jeśli aktualnie wybrany plik, zmień przycisk
|
# Jeśli aktualnie wybrany plik, zmień przycisk
|
||||||
if self.current_index == idx:
|
if self.current_index == idx:
|
||||||
self.after(0, self.set_upload_button_to_view_or_upload)
|
self.after(0, self.update_button_states)
|
||||||
else:
|
else:
|
||||||
self.uploaded[file_path] = False
|
self.uploaded[file_path] = False
|
||||||
self.after(0, self.update_status_bar)
|
self.after(0, self.update_status_bar)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Błąd podczas sprawdzania paczki uploadu:", e)
|
print("Błąd podczas sprawdzania paczki uploadu:", e)
|
||||||
|
|
||||||
def set_upload_button_to_view_or_upload(self):
|
def update_button_states(self):
|
||||||
"""
|
"""
|
||||||
Ustawia przycisk uploadu na "Wyświetl" lub "Upload" w zależności od stan
|
Update the state of UI elements based on current application state.
|
||||||
uploadu dla aktualnie wybranego pliku.
|
|
||||||
|
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.uploaded.get(self.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:
|
if self.current_index is None:
|
||||||
return
|
return
|
||||||
post_id = self.uploaded.get(self.image_files[self.current_index])
|
post_id = self.uploaded.get(self.image_files[self.current_index])
|
||||||
if post_id:
|
if post_id:
|
||||||
self.upload_button.config(
|
|
||||||
text="Wyświetl", command=lambda: self.view_post(post_id)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.upload_button.config(text="Upload", command=self.upload_current_image)
|
|
||||||
|
|
||||||
def view_post(self, post_id):
|
|
||||||
"""Otwiera w przeglądarce URL posta."""
|
|
||||||
|
|
||||||
url = self.settings.base_url.rstrip("/") + "/post/view/" + str(post_id)
|
url = self.settings.base_url.rstrip("/") + "/post/view/" + str(post_id)
|
||||||
open_webbrowser(url, self.settings)
|
open_webbrowser(url, self.settings)
|
||||||
|
|
||||||
@ -635,7 +738,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
index = self.listbox.curselection()[0]
|
index = self.listbox.curselection()[0]
|
||||||
self.current_index = index
|
self.current_index = index
|
||||||
self.show_image(index)
|
self.show_image(index)
|
||||||
self.set_upload_button_to_view_or_upload()
|
self.update_button_states()
|
||||||
|
|
||||||
def upload_current_image(self):
|
def upload_current_image(self):
|
||||||
"""
|
"""
|
||||||
@ -646,13 +749,10 @@ class ImageBrowser(tk.Tk):
|
|||||||
file_path = self.image_files[self.current_index]
|
file_path = self.image_files[self.current_index]
|
||||||
if self.uploaded.get(file_path, False):
|
if self.uploaded.get(file_path, False):
|
||||||
# Jeśli plik już uploadowany, ustaw przycisk na "Wyświetl"
|
# Jeśli plik już uploadowany, ustaw przycisk na "Wyświetl"
|
||||||
self.set_upload_button_to_view_or_upload()
|
self.update_button_states()
|
||||||
return
|
return
|
||||||
self.upload_button.config(state="disabled")
|
self.upload_button.config(state=tk.DISABLED)
|
||||||
self.progress_var.set(0)
|
self.processing_dialog = ProcessingDialog(self, self.upload_file, file_path)
|
||||||
threading.Thread(
|
|
||||||
target=self.upload_file, args=(file_path,), daemon=True
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def show_image(self, index):
|
def show_image(self, index):
|
||||||
"""
|
"""
|
||||||
@ -678,6 +778,18 @@ class ImageBrowser(tk.Tk):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
messagebox.showerror("Błąd", f"Nie można załadować obrazka:\n{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.image_files[self.current_index]
|
||||||
|
if not self.uploaded.get(file_path, False):
|
||||||
|
self.update_button_states()
|
||||||
|
return
|
||||||
|
self.upload_button.config(state=tk.DISABLED)
|
||||||
|
self.processing_dialog = ProcessingDialog(self, self.edit_file, file_path)
|
||||||
|
|
||||||
def update_display_image(self):
|
def update_display_image(self):
|
||||||
"""
|
"""
|
||||||
Aktualizuje obrazek na podstawie aktualnego rozmiaru okna.
|
Aktualizuje obrazek na podstawie aktualnego rozmiaru okna.
|
||||||
@ -756,7 +868,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
self.listbox.select_set(new_index)
|
self.listbox.select_set(new_index)
|
||||||
self.listbox.activate(new_index)
|
self.listbox.activate(new_index)
|
||||||
self.show_image(new_index)
|
self.show_image(new_index)
|
||||||
self.set_upload_button_to_view_or_upload()
|
self.update_button_states()
|
||||||
|
|
||||||
def show_next(self):
|
def show_next(self):
|
||||||
"""
|
"""
|
||||||
@ -772,7 +884,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
self.listbox.select_set(new_index)
|
self.listbox.select_set(new_index)
|
||||||
self.listbox.activate(new_index)
|
self.listbox.activate(new_index)
|
||||||
self.show_image(new_index)
|
self.show_image(new_index)
|
||||||
self.set_upload_button_to_view_or_upload()
|
self.update_button_states()
|
||||||
|
|
||||||
# --- Metody obsługujące widgety z tagami ---
|
# --- Metody obsługujące widgety z tagami ---
|
||||||
|
|
||||||
@ -781,7 +893,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
Aktualizuje widget z tagami z PNG.
|
Aktualizuje widget z tagami z PNG.
|
||||||
Tworzy tagi jako klikalne, zaznaczone lub niezaznaczone.
|
Tworzy tagi jako klikalne, zaznaczone lub niezaznaczone.
|
||||||
"""
|
"""
|
||||||
self.png_tags_text.config(state="normal")
|
self.png_tags_text.config(state=tk.NORMAL)
|
||||||
self.png_tags_text.delete("1.0", tk.END)
|
self.png_tags_text.delete("1.0", tk.END)
|
||||||
self.png_tags_states = {}
|
self.png_tags_states = {}
|
||||||
for tag in tags_list:
|
for tag in tags_list:
|
||||||
@ -794,7 +906,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
self.png_tags_text.tag_configure(tag_name, foreground="blue")
|
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.tag_bind(tag_name, "<Button-1>", self.toggle_png_tag)
|
||||||
self.png_tags_text.insert(tk.INSERT, " ")
|
self.png_tags_text.insert(tk.INSERT, " ")
|
||||||
self.png_tags_text.config(state="disabled")
|
self.png_tags_text.config(state=tk.DISABLED)
|
||||||
self.adjust_text_widget_height(self.png_tags_text)
|
self.adjust_text_widget_height(self.png_tags_text)
|
||||||
self.update_final_tags()
|
self.update_final_tags()
|
||||||
|
|
||||||
@ -933,9 +1045,9 @@ class ImageBrowser(tk.Tk):
|
|||||||
self.png_tags_states[tag] = tag in common
|
self.png_tags_states[tag] = tag in common
|
||||||
tag_name = "png_" + tag
|
tag_name = "png_" + tag
|
||||||
color = "blue" if self.png_tags_states[tag] else "black"
|
color = "blue" if self.png_tags_states[tag] else "black"
|
||||||
self.png_tags_text.config(state="normal")
|
self.png_tags_text.config(state=tk.NORMAL)
|
||||||
self.png_tags_text.tag_configure(tag_name, foreground=color)
|
self.png_tags_text.tag_configure(tag_name, foreground=color)
|
||||||
self.png_tags_text.config(state="disabled")
|
self.png_tags_text.config(state=tk.DISABLED)
|
||||||
|
|
||||||
for tag in self.tagger_tags_states:
|
for tag in self.tagger_tags_states:
|
||||||
if tag.startswith("character:"):
|
if tag.startswith("character:"):
|
||||||
@ -956,7 +1068,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
Poprawia wysokość widgetu.
|
Poprawia wysokość widgetu.
|
||||||
Aktualizuje listę finalnych tagów.
|
Aktualizuje listę finalnych tagów.
|
||||||
"""
|
"""
|
||||||
self.tagger_tags_text.config(state="normal")
|
self.tagger_tags_text.config(state=tk.NORMAL)
|
||||||
self.tagger_tags_text.delete("1.0", tk.END)
|
self.tagger_tags_text.delete("1.0", tk.END)
|
||||||
visible_tags = self.get_visible_tags()
|
visible_tags = self.get_visible_tags()
|
||||||
|
|
||||||
@ -974,7 +1086,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
)
|
)
|
||||||
self.tagger_tags_text.insert(tk.INSERT, " ")
|
self.tagger_tags_text.insert(tk.INSERT, " ")
|
||||||
|
|
||||||
self.tagger_tags_text.config(state="disabled")
|
self.tagger_tags_text.config(state=tk.DISABLED)
|
||||||
|
|
||||||
self.adjust_text_widget_height(self.tagger_tags_text)
|
self.adjust_text_widget_height(self.tagger_tags_text)
|
||||||
self.update_final_tags()
|
self.update_final_tags()
|
||||||
@ -1035,7 +1147,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
final_set.update(manual)
|
final_set.update(manual)
|
||||||
final_list = sorted(final_set)
|
final_list = sorted(final_set)
|
||||||
|
|
||||||
self.final_tags_text.config(state="normal")
|
self.final_tags_text.config(state=tk.NORMAL)
|
||||||
self.final_tags_text.delete("1.0", tk.END)
|
self.final_tags_text.delete("1.0", tk.END)
|
||||||
for tag in final_list:
|
for tag in final_list:
|
||||||
_, deprecated = process_tag(tag, self.tags_repo)
|
_, deprecated = process_tag(tag, self.tags_repo)
|
||||||
@ -1061,7 +1173,7 @@ class ImageBrowser(tk.Tk):
|
|||||||
tag_name, "<Button-1>", self.open_final_tag_wiki_url
|
tag_name, "<Button-1>", self.open_final_tag_wiki_url
|
||||||
)
|
)
|
||||||
self.final_tags_text.insert(tk.INSERT, " ")
|
self.final_tags_text.insert(tk.INSERT, " ")
|
||||||
self.final_tags_text.config(state="disabled")
|
self.final_tags_text.config(state=tk.DISABLED)
|
||||||
|
|
||||||
def open_final_tag_wiki_url(self, event):
|
def open_final_tag_wiki_url(self, event):
|
||||||
"""Otwiera w przeglądarce URL strony wiki dla klikniętego tagu.
|
"""Otwiera w przeglądarce URL strony wiki dla klikniętego tagu.
|
||||||
@ -1101,10 +1213,10 @@ class ImageBrowser(tk.Tk):
|
|||||||
if self.current_index is None:
|
if self.current_index is None:
|
||||||
return
|
return
|
||||||
# Ustaw komunikat, że Tagger pracuje
|
# Ustaw komunikat, że Tagger pracuje
|
||||||
self.tagger_tags_text.config(state="normal")
|
self.tagger_tags_text.config(state=tk.NORMAL)
|
||||||
self.tagger_tags_text.delete("1.0", tk.END)
|
self.tagger_tags_text.delete("1.0", tk.END)
|
||||||
self.tagger_tags_text.insert("1.0", "Tagger przetwarza...")
|
self.tagger_tags_text.insert("1.0", "Tagger przetwarza...")
|
||||||
self.tagger_tags_text.config(state="disabled")
|
self.tagger_tags_text.config(state=tk.DISABLED)
|
||||||
file_path = self.image_files[self.current_index]
|
file_path = self.image_files[self.current_index]
|
||||||
result = self.get_tagger_results(file_path)
|
result = self.get_tagger_results(file_path)
|
||||||
new_rating = self.map_tagger_rating(result)
|
new_rating = self.map_tagger_rating(result)
|
||||||
@ -1112,7 +1224,14 @@ class ImageBrowser(tk.Tk):
|
|||||||
self.after(0, lambda: self.update_tagger_tags_widget(result))
|
self.after(0, lambda: self.update_tagger_tags_widget(result))
|
||||||
self.update_listbox_item_color_by_rating(file_path, result.rating)
|
self.update_listbox_item_color_by_rating(file_path, result.rating)
|
||||||
|
|
||||||
def upload_file(self, file_path, final_tags=None, final_rating=None):
|
def upload_file(
|
||||||
|
self, file_path, final_tags=None, final_rating=None, progress_queue=None
|
||||||
|
):
|
||||||
|
base_file_name = os.path.basename(file_path)
|
||||||
|
if progress_queue:
|
||||||
|
progress_queue.put(("mode", "determinate"))
|
||||||
|
progress_queue.put(("max", 100))
|
||||||
|
progress_queue.put(("label", f"Wysyłam plik {base_file_name}..."))
|
||||||
url = self.settings.base_url.rstrip("/") + "/api/danbooru/add_post"
|
url = self.settings.base_url.rstrip("/") + "/api/danbooru/add_post"
|
||||||
tags = (
|
tags = (
|
||||||
self.final_tags_text.get("1.0", tk.END).strip()
|
self.final_tags_text.get("1.0", tk.END).strip()
|
||||||
@ -1134,16 +1253,16 @@ class ImageBrowser(tk.Tk):
|
|||||||
total_size = os.path.getsize(file_path)
|
total_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
def progress_callback(bytes_read, total_size):
|
def progress_callback(bytes_read, total_size):
|
||||||
|
if progress_queue:
|
||||||
percentage = int(bytes_read / total_size * 100)
|
percentage = int(bytes_read / total_size * 100)
|
||||||
self.progress_bar.after(0, lambda: self.progress_var.set(percentage))
|
progress_queue.put(("progress", percentage))
|
||||||
|
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
wrapped_file = ProgressFile(f, progress_callback, total_size)
|
wrapped_file = ProgressFile(f, progress_callback, total_size)
|
||||||
files = {
|
files = {"file": (base_file_name, wrapped_file, "image/png")}
|
||||||
"file": (os.path.basename(file_path), wrapped_file, "image/png")
|
|
||||||
}
|
|
||||||
response = requests.post(url, data=fields, files=files)
|
response = requests.post(url, data=fields, files=files)
|
||||||
self.progress_bar.after(0, lambda: self.progress_var.set(100))
|
if progress_queue:
|
||||||
|
progress_queue.put(("progress", 100))
|
||||||
show_warn = False
|
show_warn = False
|
||||||
post_url = None
|
post_url = None
|
||||||
if response.status_code in (200, 201):
|
if response.status_code in (200, 201):
|
||||||
@ -1156,9 +1275,6 @@ class ImageBrowser(tk.Tk):
|
|||||||
else:
|
else:
|
||||||
message = f"Upload zakończony błędem.\nStatus: {response.status_code}\nTreść: {response.text}"
|
message = f"Upload zakończony błędem.\nStatus: {response.status_code}\nTreść: {response.text}"
|
||||||
show_warn = True
|
show_warn = True
|
||||||
self.upload_button.after(
|
|
||||||
0, lambda: self.upload_button.config(state="normal")
|
|
||||||
)
|
|
||||||
# Aktualizacja wyglądu listy – musimy użyć domyślnych argumentów w lambdzie, aby zachować bieżący indeks
|
# Aktualizacja wyglądu listy – musimy użyć domyślnych argumentów w lambdzie, aby zachować bieżący indeks
|
||||||
if show_warn:
|
if show_warn:
|
||||||
if not final_tags:
|
if not final_tags:
|
||||||
@ -1177,13 +1293,101 @@ class ImageBrowser(tk.Tk):
|
|||||||
self.uploaded[file_path] = post_id
|
self.uploaded[file_path] = post_id
|
||||||
self.uploaded_count += 1
|
self.uploaded_count += 1
|
||||||
self.after(0, self.update_status_bar)
|
self.after(0, self.update_status_bar)
|
||||||
self.set_upload_button_to_view_or_upload()
|
self.after(0, self.update_button_states)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.upload_button.after(
|
self.upload_button.after(0, self.update_button_states)
|
||||||
0, lambda: self.upload_button.config(state="normal")
|
|
||||||
)
|
|
||||||
messagebox.showerror("Błąd uploadu", str(e))
|
messagebox.showerror("Błąd uploadu", str(e))
|
||||||
|
|
||||||
|
def edit_file(
|
||||||
|
self, file_path, final_tags=None, final_rating=None, progress_queue=None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update tags and rating for an existing post without uploading the file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the image file associated with the post
|
||||||
|
final_tags: Optional override tags (space-separated string)
|
||||||
|
final_rating: Optional override rating
|
||||||
|
progress_queue: Progress communication queue
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- The file must have an existing post ID in self.uploaded
|
||||||
|
"""
|
||||||
|
base_file_name = os.path.basename(file_path)
|
||||||
|
post_id = self.uploaded.get(file_path)
|
||||||
|
|
||||||
|
if not post_id:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Błąd edycji", "Post nie został znaleziony dla tego pliku"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if progress_queue:
|
||||||
|
progress_queue.put(("mode", "determinate"))
|
||||||
|
progress_queue.put(("max", 100))
|
||||||
|
progress_queue.put(("label", f"Aktualizuję tagi dla {base_file_name}..."))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get authentication session and token
|
||||||
|
session = login(self.settings)
|
||||||
|
auth_token = get_auth_token(session, self.settings)
|
||||||
|
|
||||||
|
# Prepare tags and rating
|
||||||
|
tags = (
|
||||||
|
self.final_tags_text.get("1.0", tk.END).strip()
|
||||||
|
if final_tags is None
|
||||||
|
else final_tags
|
||||||
|
)
|
||||||
|
|
||||||
|
rating_value = self.rating_map.get(
|
||||||
|
self.rating_var.get() if final_rating is None else final_rating, "?"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare API request
|
||||||
|
url = self.settings.base_url.rstrip("/") + "/post/set"
|
||||||
|
payload = {
|
||||||
|
"auth_token": auth_token,
|
||||||
|
"image_id": post_id,
|
||||||
|
"title": base_file_name,
|
||||||
|
"owner": self.settings.username,
|
||||||
|
"tags": tags,
|
||||||
|
"source": "",
|
||||||
|
"rating": rating_value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if progress_queue:
|
||||||
|
progress_queue.put(("progress", 50))
|
||||||
|
|
||||||
|
# Send update request
|
||||||
|
response = session.post(url, data=payload, allow_redirects=False)
|
||||||
|
|
||||||
|
# Handle 302 redirect as success case
|
||||||
|
if response.status_code == 302:
|
||||||
|
if progress_queue:
|
||||||
|
progress_queue.put(("progress", 100))
|
||||||
|
|
||||||
|
message = "Tagi zostały zaktualizowane!"
|
||||||
|
if not final_tags: # Only show success if not bulk operation
|
||||||
|
messagebox.showinfo("Sukces edycji", message)
|
||||||
|
|
||||||
|
# Update UI state
|
||||||
|
self.after(0, self.update_button_states)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Handle other status codes
|
||||||
|
error_msg = (
|
||||||
|
f"Błąd podczas aktualizacji tagów\nStatus: {response.status_code}"
|
||||||
|
)
|
||||||
|
if response.text:
|
||||||
|
error_msg += f"\nTreść: {response.text}"
|
||||||
|
messagebox.showerror("Błąd edycji", error_msg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Krytyczny błąd edycji", str(e))
|
||||||
|
finally:
|
||||||
|
if progress_queue:
|
||||||
|
progress_queue.put(("progress", 100))
|
||||||
|
|
||||||
def upload_all_files(self):
|
def upload_all_files(self):
|
||||||
"""
|
"""
|
||||||
Metoda, która po potwierdzeniu przez użytkownika uploaduje wszystkie niewrzucone pliki.
|
Metoda, która po potwierdzeniu przez użytkownika uploaduje wszystkie niewrzucone pliki.
|
||||||
|
@ -179,20 +179,24 @@ class TagManager(tk.Frame):
|
|||||||
based on custom logic.
|
based on custom logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, master, settings: Settings, tags_repo: TagsRepo, *args, **kwargs):
|
def __init__(
|
||||||
|
self, master, settings: Settings, tags_repo: TagsRepo, *args, **kwargs
|
||||||
|
):
|
||||||
super().__init__(master, *args, **kwargs)
|
super().__init__(master, *args, **kwargs)
|
||||||
self.tags_repo = tags_repo
|
self.tags_repo = tags_repo
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.manual_tags = [] # List to hold manually entered tags
|
self.manual_tags = [] # List to hold manually entered tags
|
||||||
|
|
||||||
# Entry for new tags (with autocompletion)
|
# Entry for new tags (with autocompletion)
|
||||||
self.entry = AutocompleteEntry(self, callback=self.add_tag, tags_repo=self.tags_repo)
|
self.entry = AutocompleteEntry(
|
||||||
|
self, callback=self.add_tag, tags_repo=self.tags_repo
|
||||||
|
)
|
||||||
self.entry.pack(fill=tk.X, padx=5, pady=5)
|
self.entry.pack(fill=tk.X, padx=5, pady=5)
|
||||||
|
|
||||||
# Text widget for displaying already entered tags
|
# Text widget for displaying already entered tags
|
||||||
self.tags_display = tk.Text(self, wrap="word", height=4)
|
self.tags_display = tk.Text(self, wrap=tk.WORD, height=4)
|
||||||
self.tags_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
self.tags_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||||
self.tags_display.config(state="disabled")
|
self.tags_display.config(state=tk.DISABLED)
|
||||||
# (Optional: add a scrollbar if needed)
|
# (Optional: add a scrollbar if needed)
|
||||||
|
|
||||||
def add_tag(self, tag):
|
def add_tag(self, tag):
|
||||||
@ -229,7 +233,7 @@ class TagManager(tk.Frame):
|
|||||||
self.tags_display.tag_bind(tag_name, "<Button-1>", self.remove_tag)
|
self.tags_display.tag_bind(tag_name, "<Button-1>", self.remove_tag)
|
||||||
self.tags_display.tag_bind(tag_name, "<Button-3>", self.open_tag_wiki_url)
|
self.tags_display.tag_bind(tag_name, "<Button-3>", self.open_tag_wiki_url)
|
||||||
self.tags_display.insert(tk.INSERT, " ")
|
self.tags_display.insert(tk.INSERT, " ")
|
||||||
self.tags_display.config(state="disabled")
|
self.tags_display.config(state=tk.DISABLED)
|
||||||
|
|
||||||
def remove_tag(self, event):
|
def remove_tag(self, event):
|
||||||
"""Remove the clicked tag from the list and update the display."""
|
"""Remove the clicked tag from the list and update the display."""
|
||||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kapitanbooru-uploader"
|
name = "kapitanbooru-uploader"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "A GUI application for uploading images to KapitanBooru"
|
description = "A GUI application for uploading images to KapitanBooru"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Michał Leśniak", email = "kapitan@mlesniak.pl"}
|
{name = "Michał Leśniak", email = "kapitan@mlesniak.pl"}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user