commit 5a97d610a7efe4ab92c9996dd2c42f0856214bcb Author: Kapitan Date: Thu Feb 13 22:11:35 2025 +0100 actually works Now with tagger Miejsce na zdjęcie Linki do wiki Zapis ustawień Tagger działa w tle Kolorujemy pliki po ratingu Tagger cache Tagi w bazie Pobranie implikacji tagów Autocomplete Podział na pliki i skrypty + nowe API Structure for package Version 0.1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1800114 --- /dev/null +++ b/.gitignore @@ -0,0 +1,174 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7961e36 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,54 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Module", + "type": "debugpy", + "request": "launch", + "module": "kapitanbooru_uploader" + }, + { + "name": "Python: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + }, + { + "name": "Python: Attach using Process ID", + "type": "debugpy", + "request": "attach", + "processId": "${command:pickProcess}" + }, + { + "name": "Python: Remote Attach", + "type": "debugpy", + "request": "attach" + }, + { + "name": "Python: Terminal (external)", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "externalTerminal" + }, + { + "name": "Python: Terminal (integrated)", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + }, + { + "name": "Python main", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/main.py", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..76103f7 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,38 @@ +pipeline { + agent { label 'Pi4' } // Use Raspberry Pi 4 agent + environment { + PIP_EXTRA_INDEX_URL = 'http://localhost:8090/simple/' // Local PyPI repo + PACKAGE_NAME = 'kapitanbooru_uploader' // Your package name + } + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Setup Python') { + steps { + sh 'python3.13 -m venv venv' // Create a virtual environment + sh '. venv/bin/activate && pip install --upgrade pip build twine' + } + } + + stage('Build Package') { + steps { + sh '. venv/bin/activate && python -m build' // Builds the package + } + } + + stage('Publish to Local PyPI') { + steps { + sh '. venv/bin/activate && twine upload --repository-url http://localhost:8090/ dist/*' + } + } + } + post { + cleanup { + sh 'rm -rf venv dist build *.egg-info' // Clean up build artifacts + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf00800 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 Michał Leśniak + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0588b8 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Kapitanbooru Uploader + +Kapitanbooru Uploader is a GUI application for uploading images to KapitanBooru. It provides features such as tag management, automatic tagging using wdtagger, and more. + +## Features + +- **Image Upload**: Easily upload images to KapitanBooru. +- **Tag Management**: Manage tags for images, including automatic tagging using wdtagger. +- **Cache Management**: Cache results from wdtagger to speed up processing. +- **Settings**: Configure application settings such as default tags, cache expiry, and more. + +## Installation + +1. Clone the repository: + ```sh + git clone https://git.mlesniak.pl/kapitan/kapitanbooru-uploader.git + cd kapitanbooru-uploader + ``` + +2. Install the required dependencies: + ```sh + pip install -r requirements.txt + ``` + +3. Ensure you have the required Python version: + ```sh + python --version + # Should be >= 3.13 + ``` + +## Usage + +1. Run the application: + ```sh + python -m kapitanbooru_uploader.main + ``` + +2. Select the folder containing the images you want to upload. + +3. Manage tags and upload images using the GUI. + +## Configuration + +Configuration settings can be found in the `settings.json` file located in the application data directory. You can modify settings such as username, password, base URL, default tags, and more. + +## Development + +### Running Tests + +To run tests, use the following command: +```sh +pytest +``` + +### Debugging + +You can use the provided VSCode launch configurations to debug the application. Open the `.vscode/launch.json` file and select the appropriate configuration. + +## Contributing + +Contributions are welcome! Please fork the repository and submit a pull request. + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. + +## Contact + +For any questions or issues, please contact Michał Leśniak at [kapitan@mlesniak.pl](mailto:kapitan@mlesniak.pl). diff --git a/kapitanbooru_uploader/ImageBrowser.py b/kapitanbooru_uploader/ImageBrowser.py new file mode 100644 index 0000000..eb4277d --- /dev/null +++ b/kapitanbooru_uploader/ImageBrowser.py @@ -0,0 +1,1212 @@ +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 hashlib +import os +import threading +import tkinter as tk +from tkinter import filedialog, messagebox, ttk +from typing import Tuple + + +class ProcessingDialog: + def __init__(self, root, target_function, *args): + self.root = root + self.top = tk.Toplevel(root) # Create a top-level window + self.top.title("Processing...") + self.top.geometry("300x150") + self.top.protocol("WM_DELETE_WINDOW", self.on_close) # Handle close event + + # Create Label and Progress Bar (or rotating animation) + self.label = tk.Label(self.top, text="Processing, please wait...") + self.label.pack(pady=10) + + self.progress = ttk.Progressbar(self.top, mode="indeterminate") + self.progress.pack(pady=10, fill="x") + self.progress.start(10) # Start animation + + # Create a thread for the target function + self.running = True + self.thread = threading.Thread( + target=self.run_task, args=(target_function, *args) + ) + self.thread.start() + + def run_task(self, target_function, *args): + try: + target_function(*args) # Run the function + finally: + self.close_dialog() # Close when done + + def close_dialog(self): + """Safely close the dialog.""" + if self.running: + self.running = False + self.top.after(0, self.top.destroy) # Ensure UI updates on the main thread + + def on_close(self): + """User manually closed the dialog, terminate thread.""" + self.running = False + self.top.destroy() + + +class ImageBrowser(tk.Tk): + def __init__(self): + super().__init__() + self.title("Kapitanbooru Uploader") + self.geometry("900x600") + + self.settings = Settings() + self.tags_repo = TagsRepo(self.settings) + + self.implication_graph = self.load_implication_graph() + self.missing_tags = set() # Track tags not in the graph + + # Dodatkowe ustawienia dla Taggera + self.tagger_name = "wdtagger" + self.tagger_version = ( + "1.0" # możesz ustawić wersję dynamicznie, jeśli to możliwe + ) + self.tagger_cache = TaggerCache( + self.settings, self.tagger_name, self.tagger_version + ) + + self.folder_path = "" + self.image_files = [] + self.image_files_md5 = [] + self.current_index = None + self.image_cache = None + + # Liczniki statusu + self.total_files = 0 + self.tagger_processed = set() + self.upload_verified = 0 + self.uploaded_count = 0 + + # Oryginalny obraz (do skalowania) + self.current_image_original = None + self.current_parameters = "" + + # Mapa ratingów: wyświetlana nazwa -> wartość wysyłana + self.rating_map = { + "General": "g", + "Sensitive": "s", + "Questionable": "q", + "Explicit": "e", + "Unrated": "", + } + + # Słowniki przechowujące stany tagów (dla PNG i Taggera) + self.png_tags_states = {} + self.tagger_tags_states = {} + + # Ścieżki do ustawień i cache + + # Ładujemy ustawienia + + # Nowy słownik przechowujący informację, czy dany plik (ścieżka) został już uploadowany + self.uploaded = {} # key: file path, value: True/False + + self.create_menu() + self.create_widgets() + self.bind_events() + + def load_implication_graph(self) -> nx.DiGraph: + G = nx.DiGraph() + conn = self.tags_repo.get_conn() + cursor = conn.cursor() + + # Step 1: Add all tags from the 'tags' table + cursor.execute( + """ + SELECT + CASE category + WHEN 1 THEN 'artist:' || name + WHEN 3 THEN 'copyright:' || name + WHEN 4 THEN 'character:' || name + WHEN 5 THEN 'meta:' || name + ELSE name + END AS prefixed_name + FROM tags + """ + ) + db_tags = {row[0] for row in cursor.fetchall()} + G.add_nodes_from(db_tags) + + # Step 2: Add nodes from implications (antecedents/consequents not in 'tags' table) + cursor.execute("SELECT antecedent, consequent FROM tag_closure") + edge_tags = set() + for ant, cons in cursor.fetchall(): + edge_tags.add(ant) + edge_tags.add(cons) + G.add_nodes_from(edge_tags - db_tags) # Add tags only in implications + + # Step 3: Add edges + cursor.execute("SELECT antecedent, consequent FROM tag_closure") + G.add_edges_from(cursor.fetchall()) + + conn.close() + return G + + 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 compute_final_tags_and_rating_for_file(self, file_path): + """ + Oblicza finalną listę tagów dla danego pliku oraz rating. + Łączy tagi z: + - pliku (PNG): parsowane przez parse_parameters, + - Taggera (wynik z cache lub wyliczony na bieżąco), + - ustawień (default tags), + - manualnych tagów (z pola manual_tags_entry), + oraz dodaje tag "meta:auto_upload". + Zwraca finalny ciąg tagów oraz rating. + """ + # Pobierz tagi z pliku + try: + img = Image.open(file_path) + parameters = "" + if isinstance(img, PngImagePlugin.PngImageFile): + parameters = img.info.get("parameters", "") + png_tags = set(parse_parameters(parameters, self.tags_repo).split()) + img.close() + except Exception as e: + print("Błąd przy otwieraniu pliku", file_path, ":", e) + png_tags = set() + + # Pobierz tagi z Taggera – sprawdzając cache + result = self.get_tagger_results(file_path) + tagger_tags = set() + rating = "Unrated" + tagger_tags.update( + ( + TAG_FIXES[tag] if tag in TAG_FIXES else tag + for tag in result.general_tag_data.keys() + ) + ) # Zamień nieprawidłowe tagi na poprawne + for t in result.character_tags: + full_tag = "character:" + t.replace(" ", "_").replace("\\", "") + # Zamień nieprawidłowe tagi na poprawne + if full_tag in TAG_FIXES: + full_tag = TAG_FIXES[full_tag] + tagger_tags.add(full_tag) + rating = self.map_tagger_rating(result) + + # Pobierz tagi z ustawień i manualne + default_tags = set(self.settings.default_tags.split()) + manual_tags = set(self.manual_tags_manager.manual_tags) + + # Finalna lista: suma wszystkich tagów + final_tags = default_tags.union(png_tags).union(tagger_tags).union(manual_tags) + final_tags.add("meta:auto_upload") + return " ".join(sorted(final_tags)), rating + + def get_tagger_results(self, file_path) -> wdt.Result: + md5 = self.image_files_md5[file_path] + cached = self.tagger_cache[md5] + if cached: + self.tagger_processed.add(md5) + return cached["result"] + try: + tagger = wdt.Tagger() + with Image.open(file_path) as img: + result = tagger.tag(img) + self.tagger_cache[md5] = result + self.tagger_processed.add(md5) + self.after(0, self.update_status_bar) + print(f"Tagger przetworzył: {file_path}") + return result + except Exception as e: + print("Błąd Taggera dla", file_path, ":", e) + + def map_tagger_rating(self, result: wdt.Result) -> str: + """ + Mapuje rating z Taggera na wartość używaną w Kapitanbooru. + """ + if result.rating == "general": + new_rating = "General" + elif result.rating == "sensitive": + new_rating = "Sensitive" + elif result.rating == "questionable": + new_rating = "Questionable" + elif result.rating == "explicit": + new_rating = "Explicit" + else: + new_rating = "Unrated" + return new_rating + + def create_menu(self): + menubar = tk.Menu(self) + self.config(menu=menubar) + menubar.add_command(label="Ustawienia", command=self.open_settings) + menubar.add_command(label="Wyczyść cache Taggera", command=self.clear_cache) + menubar.add_command(label="Wrzuć wszystko", command=self.upload_all_files) + menubar.add_command( + label="Zregeneruj bazę tagów", command=self.regenerate_tags_db + ) + + def regenerate_tags_db(self): + self.processing_dialog = ProcessingDialog(self, self.tags_repo.regenerate_db) + + def clear_cache(self): + res, err = self.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("300x350") + 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.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.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.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.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.settings.installed_browsers.keys()), + state="readonly", + ) + cb_browser.pack(pady=(0, 10), padx=10, fill="x") + cb_browser.set( + self.settings.installed_browsers_reverse.get( + self.settings.browser, "Default" + ) + ) + + def save_and_close(): + self.settings.username = entry_login.get() + self.settings.password = entry_password.get() + self.settings.base_url = entry_base_url.get() + self.settings.default_tags = entry_default_tags.get() + self.settings.browser = self.settings.installed_browsers[cb_browser.get()] + self.settings.save_settings() + settings_window.destroy() + + btn_save = tk.Button(settings_window, text="Zapisz", command=save_and_close) + btn_save.pack(pady=10) + + def create_widgets(self): + # Górna ramka + top_frame = tk.Frame(self) + top_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) + btn_select_folder = tk.Button( + top_frame, text="Wybierz folder", command=self.select_folder + ) + btn_select_folder.pack(side=tk.LEFT, padx=5) + btn_prev = tk.Button(top_frame, text="<<", command=self.show_prev) + btn_prev.pack(side=tk.LEFT, padx=5) + btn_next = tk.Button(top_frame, text=">>", command=self.show_next) + btn_next.pack(side=tk.LEFT, padx=5) + + # 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="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="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="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="ew", padx=5, pady=5) + png_frame.grid_columnconfigure(0, weight=1) + self.png_tags_text = tk.Text(png_frame, wrap="word") + self.png_tags_text.grid(row=0, column=0, sticky="ew") + scrollbar_png = tk.Scrollbar(png_frame, command=self.png_tags_text.yview) + scrollbar_png.grid(row=0, column=1, sticky="ns") + self.png_tags_text.config( + yscrollcommand=scrollbar_png.set, state="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="ew", padx=5, pady=5) + tagger_frame.grid_columnconfigure(0, weight=1) + self.tagger_tags_text = tk.Text(tagger_frame, wrap="word") + self.tagger_tags_text.grid(row=0, column=0, sticky="ew") + scrollbar_tagger = tk.Scrollbar( + tagger_frame, command=self.tagger_tags_text.yview + ) + scrollbar_tagger.grid(row=0, column=1, sticky="ns") + self.tagger_tags_text.config( + yscrollcommand=scrollbar_tagger.set, state="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="nsew", padx=5, pady=5) + self.manual_tags_manager = TagManager( + manual_frame, self.settings, self.tags_repo + ) + self.manual_tags_manager.pack(fill=tk.BOTH, expand=True) + self.manual_tags_manager.bind( + "", lambda e: self.update_final_tags(), add="+" + ) + self.manual_tags_manager.bind( + "", lambda e: self.update_final_tags(), add="+" + ) + + # 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="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="disabled", wrap="word") + self.final_tags_text.grid(row=0, column=0, sticky="nsew") + scrollbar_final = tk.Scrollbar(final_frame, command=self.final_tags_text.yview) + scrollbar_final.grid(row=0, column=1, sticky="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="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="Upload", command=self.upload_current_image + ) + self.upload_button.pack(side=tk.LEFT, padx=5) + self.progress_var = tk.IntVar(value=0) + self.progress_bar = ttk.Progressbar( + upload_frame, + orient="horizontal", + mode="determinate", + variable=self.progress_var, + maximum=100, + ) + self.progress_bar.pack(side=tk.LEFT, fill=tk.X, expand=True, 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="ew") + + def update_status_bar(self): + status_text = ( + f"Przetworzono tagi: {len(self.tagger_processed)}/{self.total_files} plików | " + f"Zweryfikowano status uploadu: {self.upload_verified}/{self.total_files} plików | " + f"Zuploadowano: {self.uploaded_count}/{self.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 z wybranego folderu. + """ + folder = filedialog.askdirectory(title="Wybierz folder z obrazkami PNG") + if folder: + self.folder_path = folder + self.load_images() + + def load_images(self): + """ + Ładuje pliki PNG z wybranego folderu. + """ + pattern = os.path.join(self.folder_path, "*.png") + self.image_files = sorted(glob.glob(pattern)) + self.total_files = len(self.image_files) + self.image_files_md5 = { + file: self.compute_md5(file) for file in self.image_files + } + self.tagger_processed.clear() + for md5 in self.image_files_md5.values(): + if self.tagger_cache[md5]: + self.tagger_processed.add(md5) + self.listbox.delete(0, tk.END) + self.uploaded.clear() + for file in self.image_files: + self.listbox.insert(tk.END, os.path.basename(file)) + self.uploaded[file] = False + if self.image_files: + self.current_index = 0 + self.listbox.select_set(0) + self.show_image(0) + self.post_load_processing() + else: + messagebox.showinfo("Informacja", "Brak plików PNG w wybranym folderze.") + + def post_load_processing(self): + """ + Po załadowaniu plików, sprawdza czy są jakieś pliki do uploadu oraz przetwarza Taggerem pliki. + """ + threading.Thread(target=self.check_uploaded_files, daemon=True).start() + threading.Thread(target=self.process_tagger_queue, daemon=True).start() + + def check_uploaded_files(self): + """ + Dla każdego obrazu oblicza MD5, grupuje je w paczki (do 100 skrótów), + wysyła zapytanie do endpointa 'posts.json' dla każdej paczki, + a następnie na podstawie odpowiedzi ustawia w self.uploaded post id dla uploadowanych plików. + """ + file_md5_list = [ + (idx, file, self.image_files_md5[file]) + for idx, file in enumerate(self.image_files) + ] + + batch_size = 100 + for i in range(0, len(file_md5_list), batch_size): + batch = file_md5_list[i : i + batch_size] + batch_md5 = [item[2] for item in batch] + md5_param = ",".join(batch_md5) + url = self.settings.base_url.rstrip("/") + "/posts.json" + try: + response = requests.get(url, params={"md5": md5_param}) + + root = response.json() + found = {} + for elem in root: + post_md5 = elem.get("md5", "").lower() + post_id = elem.get("id") + if post_md5 and post_id: + found[post_md5] = post_id + for idx, file_path, md5 in batch: + self.upload_verified += 1 # Każdy plik w batchu jest zweryfikowany + if md5.lower() in found: + self.uploaded[file_path] = found[md5.lower()] + self.uploaded_count += 1 + 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.set_upload_button_to_view_or_upload) + else: + self.uploaded[file_path] = False + self.after(0, self.update_status_bar) + except Exception as e: + print("Błąd podczas sprawdzania paczki uploadu:", e) + + def set_upload_button_to_view_or_upload(self): + """ + Ustawia przycisk uploadu na "Wyświetl" lub "Upload" w zależności od stan + uploadu dla aktualnie wybranego pliku. + """ + if self.current_index is None: + return + post_id = self.uploaded.get(self.image_files[self.current_index]) + 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) + open_webbrowser(url, self.settings) + + def compute_md5(self, file_path, chunk_size=8192): + """Oblicza MD5 dla danego pliku.""" + hash_md5 = hashlib.md5() + try: + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(chunk_size), b""): + hash_md5.update(chunk) + except Exception as e: + print("Błąd przy obliczaniu MD5:", e) + return "" + return hash_md5.hexdigest() + + 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.set_upload_button_to_view_or_upload() + + 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.image_files[self.current_index] + if self.uploaded.get(file_path, False): + # Jeśli plik już uploadowany, ustaw przycisk na "Wyświetl" + self.set_upload_button_to_view_or_upload() + return + self.upload_button.config(state="disabled") + self.progress_var.set(0) + threading.Thread( + target=self.upload_file, args=(file_path,), daemon=True + ).start() + + 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.image_files): + return + file_path = self.image_files[index] + try: + img = Image.open(file_path) + parameters = "" + if isinstance(img, PngImagePlugin.PngImageFile): + parameters = img.info.get("parameters", "") + self.current_image_original = img.copy() + self.current_parameters = parameters + self.update_display_image() + parsed_parameters = parse_parameters(parameters, self.tags_repo) + # Uaktualnij widget PNG Tags + self.update_png_tags_widget(parsed_parameters.split()) + # Uruchom Taggera w osobnym wątku + threading.Thread(target=self.run_tagger, daemon=True).start() + except Exception as e: + messagebox.showerror("Błąd", f"Nie można załadować obrazka:\n{e}") + + 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.uploaded.get(file_path, False): + return + try: + index = self.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.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.set_upload_button_to_view_or_upload() + + 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.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.set_upload_button_to_view_or_upload() + + # --- 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="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="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.implication_graph: + implied_by_selected.update(nx.descendants(self.implication_graph, tag)) + else: + print(f"Warning: Tag '{tag}' not found in implication graph") + self.missing_tags.add(tag) # Log missing tags + for tag in selected_png_tags: + if tag in self.implication_graph: + implied_by_selected.update(nx.descendants(self.implication_graph, tag)) + else: + print(f"Warning: Tag '{tag}' not found in implication graph") + self.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="normal") + self.png_tags_text.tag_configure(tag_name, foreground=color) + self.png_tags_text.config(state="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="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="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.settings.default_tags: + final_set.update(self.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="normal") + self.final_tags_text.delete("1.0", tk.END) + for tag in final_list: + _, deprecated = process_tag(tag, self.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="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.settings) + break + + # --- Metody do cache'owania wyników Taggera --- + + def process_tagger_for_image(self, file_path): + """Przetwarza obrazek przy użyciu Taggera i zapisuje wynik do cache.""" + result = self.get_tagger_results(file_path) + self.update_listbox_item_color_by_rating(file_path, result.rating) + + def process_tagger_queue(self): + """Przetwarza wszystkie obrazki w tle (pomijając aktualnie wybrany).""" + for file_path in self.image_files: + # Jeśli obrazek jest aktualnie wybrany, pomijamy – on będzie przetwarzany w foreground + if ( + self.current_index is not None + and file_path == self.image_files[self.current_index] + ): + continue + self.process_tagger_for_image(file_path) + + def run_tagger(self): + """ + 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="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="disabled") + file_path = self.image_files[self.current_index] + result = self.get_tagger_results(file_path) + new_rating = self.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) + + def upload_file(self, file_path, final_tags=None, final_rating=None): + url = self.settings.base_url.rstrip("/") + "/api/danbooru/add_post" + tags = ( + self.final_tags_text.get("1.0", tk.END).strip() + if final_tags is None + else final_tags + ) + fields = { + "login": self.settings.username, + "password": self.settings.password, + "tags": tags, + "source": "", + } + rating_value = self.rating_map.get( + self.rating_var.get() if final_rating is None else final_rating, "" + ) + if rating_value: + fields["rating"] = rating_value + try: + total_size = os.path.getsize(file_path) + + def progress_callback(bytes_read, total_size): + percentage = int(bytes_read / total_size * 100) + self.progress_bar.after(0, lambda: self.progress_var.set(percentage)) + + with open(file_path, "rb") as f: + wrapped_file = ProgressFile(f, progress_callback, total_size) + files = { + "file": (os.path.basename(file_path), wrapped_file, "image/png") + } + response = requests.post(url, data=fields, files=files) + self.progress_bar.after(0, lambda: self.progress_var.set(100)) + show_warn = False + post_url = None + if response.status_code in (200, 201): + message = "Upload zakończony powodzeniem!" + post_url = response.headers.get("X-Danbooru-Location", None) + elif response.status_code == 409: + message = f"Upload zakończony błędem.\nStatus: 409\nTreść: {response.headers.get('X-Danbooru-Errors', '')}" + post_url = response.headers.get("X-Danbooru-Location", None) + show_warn = True + else: + message = f"Upload zakończony błędem.\nStatus: {response.status_code}\nTreść: {response.text}" + 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 + if show_warn: + if not final_tags: + messagebox.showwarning("Upload", message) + else: + if not final_tags: + messagebox.showinfo("Upload", message) + self.after( + 0, + lambda idx=self.image_files.index( + file_path + ): self.listbox.itemconfig(idx, {"bg": "lightgray"}), + ) + if post_url: + post_id = post_url.split("/")[-1] + self.uploaded[file_path] = post_id + self.uploaded_count += 1 + self.after(0, self.update_status_bar) + self.set_upload_button_to_view_or_upload() + except Exception as e: + self.upload_button.after( + 0, lambda: self.upload_button.config(state="normal") + ) + messagebox.showerror("Błąd uploadu", str(e)) + + 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 + + def worker(): + for file_path in self.image_files: + if not self.uploaded.get(file_path, False): + final_tags, final_rating = ( + self.compute_final_tags_and_rating_for_file(file_path) + ) + print( + f"Uploading {file_path} z tagami: {final_tags} i ratingiem: {final_rating}" + ) + self.upload_file( + file_path, final_tags=final_tags, final_rating=final_rating + ) + + threading.Thread(target=worker, daemon=True).start() diff --git a/kapitanbooru_uploader/ProgressFile.py b/kapitanbooru_uploader/ProgressFile.py new file mode 100644 index 0000000..8ad483d --- /dev/null +++ b/kapitanbooru_uploader/ProgressFile.py @@ -0,0 +1,16 @@ +# Klasa pomocnicza do monitorowania postępu uploadu +class ProgressFile: + def __init__(self, f, callback, total_size): + self.f = f + self.callback = callback + self.total_size = total_size + self.read_bytes = 0 + + def read(self, size=-1): + data = self.f.read(size) + self.read_bytes += len(data) + self.callback(self.read_bytes, self.total_size) + return data + + def __getattr__(self, attr): + return getattr(self.f, attr) \ No newline at end of file diff --git a/kapitanbooru_uploader/TagsRepo.py b/kapitanbooru_uploader/TagsRepo.py new file mode 100644 index 0000000..4b3353c --- /dev/null +++ b/kapitanbooru_uploader/TagsRepo.py @@ -0,0 +1,427 @@ +from collections import deque +import json +import os +import sqlite3 +import time +import requests +from pathlib import Path + +from .settings import Settings + +# Stałe – auth_token (CSRF token) oraz ciasteczka +AUTH_TOKEN = "" # ustaw właściwą wartość +SHM_SESSION = "" # ustaw właściwą wartość +SHM_USER = "" # ustaw właściwą wartość + +POST_URL = "http://192.168.1.11:8001/auto_tag/import" +BATCH_SIZE = 1000 # maksymalna liczba wierszy w jednej partii + + +def flatten_graph(graph): + """ + Dla każdego tagu (klucza) oblicza domknięcie przechodnie – + czyli zbiór wszystkich tagów osiągalnych w grafie. + Używamy cache, aby uniknąć wielokrotnych obliczeń. + """ + cache = {} + + def dfs(tag): + if tag in cache: + return cache[tag] + result = set() + # Przechodzimy po bezpośrednich konsekwencjach + for nxt in graph.get(tag, set()): + result.add(nxt) + result |= dfs(nxt) + cache[tag] = result + return result + + flattened = {} + for tag in graph.keys(): + flattened[tag] = dfs(tag) + return flattened + + +class TagsRepo: + def __init__(self, settings: Settings): + self.settings = settings + self.db_path = os.path.join( + os.path.dirname(settings.get_settings_path()), "tags.db" + ) + regenerate = False + if not Path(self.db_path).is_file(): + regenerate = True + print(f"Database file not found: {self.db_path}, will regenerate DB") + self.init_tags_db() + if regenerate: + self.regenerate_db() + + def get_conn(self): + return sqlite3.connect(self.db_path) + + # --- Inicjalizacja bazy tagów --- + def init_tags_db(self): + try: + conn = self.get_conn() + cursor = conn.cursor() + tables = { + "tags": """ + CREATE TABLE IF NOT EXISTS "tags" ( + "index" INTEGER, + "id" INTEGER, + "name" TEXT, + "post_count" INTEGER, + "category" INTEGER, + "created_at" TIMESTAMP, + "updated_at" TIMESTAMP, + "is_deprecated" INTEGER, + "words" TEXT + ) + """, + "tag_aliases": """ + CREATE TABLE IF NOT EXISTS "tag_aliases" ( + "index" INTEGER, + "alias" TEXT, + "tag" TEXT + ) + """, + "tag_closure": """ + CREATE TABLE IF NOT EXISTS "tag_closure" ( + antecedent TEXT NOT NULL, + consequent TEXT NOT NULL, + depth INTEGER NOT NULL, + PRIMARY KEY (antecedent, consequent) + ) + """, + "tag_implications": """ + CREATE TABLE IF NOT EXISTS "tag_implications" ( + antecedent TEXT NOT NULL, + consequent TEXT NOT NULL, + PRIMARY KEY (antecedent, consequent) + ) + """, + } + indexes = { + "tags": [ + """CREATE INDEX IF NOT EXISTS ix_tags_index ON tags ("index")""", + "CREATE INDEX IF NOT EXISTS tags_index_category ON tags (category)", + "CREATE INDEX IF NOT EXISTS tags_index_created_at ON tags (created_at)", + "CREATE INDEX IF NOT EXISTS tags_index_id ON tags (id)", + "CREATE INDEX IF NOT EXISTS tags_index_is_deprecated ON tags (is_deprecated)", + "CREATE INDEX IF NOT EXISTS tags_index_name ON tags (name)", + "CREATE INDEX IF NOT EXISTS tags_index_post_count ON tags (post_count)", + "CREATE INDEX IF NOT EXISTS tags_index_updated_at ON tags (updated_at)", + ], + "tag_aliases": [ + """CREATE INDEX IF NOT EXISTS ix_tag_aliases_index ON tag_aliases ("index")""", + "CREATE INDEX IF NOT EXISTS tag_aliases_index_alias ON tag_aliases (alias)", + "CREATE INDEX IF NOT EXISTS tag_aliases_index_tag ON tag_aliases (tag)", + ], + "tag_closure": [ + "CREATE INDEX IF NOT EXISTS idx_closure_antecedent ON tag_closure (antecedent)" + ], + "tag_implications": [ + "CREATE INDEX IF NOT EXISTS idx_implications_antecedent ON tag_implications (antecedent)" + ], + } + for table, create_stmt in tables.items(): + cursor.execute(create_stmt) + for table, index_list in indexes.items(): + for index_stmt in index_list: + cursor.execute(index_stmt) + conn.commit() + conn.close() + except Exception as e: + print("Błąd przy inicjalizacji bazy tagów:", e) + + def regenerate_db(self): + # Połączenie z bazą SQLite i pobranie tagów + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute("DELETE FROM tags") + cursor.execute("DELETE FROM tag_aliases") + conn.commit() + + rate_limit = 10 # requests per second + min_interval = 1.0 / rate_limit # minimum seconds between requests (0.1 sec) + + data_list = [] + + page = 0 + while True: + print(f"Tagi - Pobieranie od id {page}...") + start_time = time.monotonic() + url = f"https://danbooru.donmai.us/tags.json?limit=1000&page=a{page}" + response = requests.get(url) + if response.status_code != 200: + print( + f"Błąd przy pobieraniu strony {page}: HTTP {response.status_code}" + ) + break + + data = response.json() + if not data: + break + page = None + for item in data: + id = item.get("id") + if not page: + page = id + name = item.get("name") + post_count = item.get("post_count") + category = item.get("category") + created_at = item.get("created_at") + updated_at = item.get("updated_at") + is_deprecated = item.get("is_deprecated") + words = json.dumps(item.get("words")) + data_list.append( + ( + id, + name, + post_count, + category, + created_at, + updated_at, + is_deprecated, + words, + ) + ) + + if len(data) < 1000: + break + + # Calculate elapsed time and sleep if necessary to enforce the rate limit + elapsed = time.monotonic() - start_time + if elapsed < min_interval: + time.sleep(min_interval - elapsed) + + print(f"Tagi - Pobrano {len(data_list)} tagów...") + data_list = sorted(data_list, key=lambda x: x[0]) + data_list = [(idx,) + row for idx, row in enumerate(data_list)] + + cursor.executemany( + """ + INSERT INTO tags ("index", id, name, post_count, category, created_at, updated_at, is_deprecated, words) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + data_list, + ) + conn.commit() + data_list = [] + + page = 0 + while True: + print(f"Aliasy tagów - Pobieranie od id {page}...") + start_time = time.monotonic() + url = f"https://danbooru.donmai.us/tag_aliases.json?limit=1000&only=id,antecedent_name,consequent_name&search[status]=active&page=a{page}" + response = requests.get(url) + if response.status_code != 200: + print( + f"Błąd przy pobieraniu strony {page}: HTTP {response.status_code}" + ) + break + + data = response.json() + if not data: + break + page = None + for item in data: + id = item.get("id") + if not page: + page = id + antecedent = item.get("antecedent_name") + consequent = item.get("consequent_name") + data_list.append((antecedent, consequent)) + + if len(data) < 1000: + break + + # Calculate elapsed time and sleep if necessary to enforce the rate limit + elapsed = time.monotonic() - start_time + if elapsed < min_interval: + time.sleep(min_interval - elapsed) + + print(f"Aliasy tagów - Pobrano {len(data_list)} aliasów tagów...") + data_list = sorted(data_list, key=lambda x: x[0]) + data_list = [(idx,) + row for idx, row in enumerate(data_list)] + + cursor.executemany( + """ + INSERT INTO tag_aliases ("index", alias, tag) + VALUES (?, ?, ?) + """, + data_list, + ) + conn.commit() + data_list = [] + + # Pobranie tagów kategorii "character" (category = 4) + cursor.execute("SELECT name FROM tags WHERE category = 4") + character_tags = {row[0] for row in cursor.fetchall()} + + # Pobranie tagów kategorii "copyright" (category = 3) + cursor.execute("SELECT name FROM tags WHERE category = 3") + copyright_tags = {row[0] for row in cursor.fetchall()} + + # Pobranie tagów kategorii "meta" (category = 5) + cursor.execute("SELECT name FROM tags WHERE category = 5") + meta_tags = {row[0] for row in cursor.fetchall()} + + # Pobranie tagów kategorii "artist" (category = 1) + cursor.execute("SELECT name FROM tags WHERE category = 1") + artist_tags = {row[0] for row in cursor.fetchall()} + + cursor.execute("DELETE FROM tag_implications") # Optional: reset table + cursor.execute("DELETE FROM tag_closure") # Optional: reset table + conn.commit() + + # Budujemy strukturę implikacji: słownik, gdzie + # kluczem jest antecedent_name, a wartością zbiór consequent_name. + tag_dict = {} + + page = 0 + while True: + print(f"Implikacje tagów - Pobieranie od id {page}...") + url = f"https://danbooru.donmai.us/tag_implications.json?limit=1000&page=a{page}" + response = requests.get(url) + if response.status_code != 200: + print( + f"Błąd przy pobieraniu strony {page}: HTTP {response.status_code}" + ) + break + + data = response.json() + if not data: + break + page = None + for item in data: + id = item.get("id") + if not page: + page = id + if item.get("status") != "active": + continue + + antecedent = item.get("antecedent_name") + consequent = item.get("consequent_name") + + # Dodanie prefiksu, jeżeli tag należy do jednej z kategorii + if antecedent in character_tags: + antecedent = f"character:{antecedent}" + elif antecedent in copyright_tags: + antecedent = f"copyright:{antecedent}" + elif antecedent in meta_tags: + antecedent = f"meta:{antecedent}" + elif antecedent in artist_tags: + antecedent = f"artist:{antecedent}" + + if consequent in character_tags: + consequent = f"character:{consequent}" + elif consequent in copyright_tags: + consequent = f"copyright:{consequent}" + elif consequent in meta_tags: + consequent = f"meta:{consequent}" + elif consequent in artist_tags: + consequent = f"artist:{consequent}" + + if antecedent not in tag_dict: + tag_dict[antecedent] = set() + tag_dict[antecedent].add(consequent) + + if len(data) < 1000: + break + # Calculate elapsed time and sleep if necessary to enforce the rate limit + elapsed = time.monotonic() - start_time + if elapsed < min_interval: + time.sleep(min_interval - elapsed) + + print(f"Implikacje tagów - Pobrano {len(tag_dict)} implikacji tagów...") + # Batch insert all unique pairs + for antecedent, consequents in tag_dict.items(): + for consequent in consequents: + if antecedent != consequent: + cursor.execute( + "INSERT OR IGNORE INTO tag_implications VALUES (?, ?)", + (antecedent, consequent), + ) + conn.commit() + cursor.executemany( + "INSERT INTO tag_closure VALUES (?, ?, ?)", + self.build_transitive_closure(tag_dict), + ) + conn.commit() + conn.close() + + def build_transitive_closure(self, tag_dict): + closure = set() + for antecedent in tag_dict: + visited = set() + queue = deque([(antecedent, 0)]) + + while queue: + current_tag, depth = queue.popleft() + if current_tag in visited: + continue + visited.add(current_tag) + + # Add to closure if not self-reference + if current_tag != antecedent: + closure.add((antecedent, current_tag, depth)) + + # Traverse next level + for next_tag in tag_dict.get(current_tag, []): + queue.append((next_tag, depth + 1)) + + return closure + + +# def garbage(): +# +# # Spłaszczenie struktury – obliczenie domknięcia przechodniego +# flattened_tag_dict = flatten_graph(tag_dict) +# print("Spłaszczono strukturę implikacji.") +# +# # Przygotowanie listy wierszy do CSV +# # Każdy wiersz: (antecedent, consequents jako space-separated string) +# csv_rows = [] +# for antecedent, consequents in flattened_tag_dict.items(): +# # Sortujemy, żeby wynik był deterministyczny +# consequents_str = " ".join(sorted(consequents)) +# csv_rows.append((antecedent, consequents_str)) +# +# print(f"Łącznie wierszy do wysłania: {len(csv_rows)}") +# +# # Konfiguracja ciasteczek do żądania POST +# cookies = { +# 'shm_session': SHM_SESSION, +# 'shm_user': SHM_USER +# } +# +# # Wysyłanie danych w partiach po BATCH_SIZE wierszy +# for i in range(0, len(csv_rows), BATCH_SIZE): +# batch = csv_rows[i:i+BATCH_SIZE] +# # Utworzenie pliku CSV w pamięci +# output = io.StringIO() +# writer = csv.writer(output, quoting=csv.QUOTE_ALL) +# for row in batch: +# writer.writerow(row) +# csv_content = output.getvalue() +# output.close() +# +# # Przygotowanie danych formularza (z auth_token) oraz pliku CSV +# data = { +# 'auth_token': AUTH_TOKEN +# } +# files = { +# 'auto_tag_file': ('batch.csv', csv_content, 'text/csv') +# } +# +# print(f"Wysyłanie batcha wierszy {i+1} - {i+len(batch)}...") +# post_response = requests.post(POST_URL, data=data, files=files, cookies=cookies, allow_redirects=False) +# if post_response.status_code in (200, 302): +# print(f"Batch {i+1}-{i+len(batch)} wysłany pomyślnie.") +# else: +# print(f"Błąd przy wysyłaniu batcha {i+1}-{i+len(batch)}: HTTP {post_response.status_code}") +# +# +# print("Wszystkie dane zostały wysłane.") diff --git a/kapitanbooru_uploader/__init__.py b/kapitanbooru_uploader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapitanbooru_uploader/__main__.py b/kapitanbooru_uploader/__main__.py new file mode 100644 index 0000000..0252ad3 --- /dev/null +++ b/kapitanbooru_uploader/__main__.py @@ -0,0 +1,12 @@ +"""kapitanbooru_uploader.__main__: executed +when kapitanbooru_uploader directory is called as script.""" +from .ImageBrowser import ImageBrowser + + +def main(): + app = ImageBrowser() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/kapitanbooru_uploader/autocomplete.py b/kapitanbooru_uploader/autocomplete.py new file mode 100644 index 0000000..27d1281 --- /dev/null +++ b/kapitanbooru_uploader/autocomplete.py @@ -0,0 +1,252 @@ +import tkinter as tk +from tkinter import font + +from .TagsRepo import TagsRepo +from .common import open_tag_wiki_url +from .tag_processing import process_tag +from .settings import Settings + + +class AutocompleteEntry(tk.Entry): + def __init__(self, master, tags_repo: TagsRepo, callback=None, *args, **kwargs): + super().__init__(master, *args, **kwargs) + self.tags_repo = tags_repo + self.callback = callback + self.listbox = None + self.listbox_window = None + self.suggestions = [] + self.suggestion_map = {} + self.search_after_id = None # Przechowuje ID opóźnionego wyszukiwania + self.selection_index = -1 + + self.bind("", self.on_keyrelease) + self.bind("", self.on_down) + self.bind("", self.on_up) + self.bind("", self.on_return) + self.bind("", self.on_return) + self.bind("", lambda e: self.hide_listbox()) + + def on_keyrelease(self, event): + if event.keysym in ("Down", "Up", "Return", "Tab"): + return + if event.keysym == "Escape": + self.hide_listbox() + return + if self.search_after_id: + self.after_cancel(self.search_after_id) + self.search_after_id = self.after(200, self.update_suggestions) + + def update_suggestions(self): + self.search_after_id = None + # Pobieramy cały tekst oraz indeks kursora + full_text = self.get() + # self.index(tk.INSERT) zwraca indeks w formacie "linia.kolumna", możemy go wykorzystać jako indeks znakowy + text_before_cursor = full_text[: self.index(tk.INSERT)] + # Jeżeli ostatni znak to spacja, to znaczy, że użytkownik zakończył ostatni tag – nie sugerujemy + if text_before_cursor and text_before_cursor[-1].isspace(): + self.hide_listbox() + return + # Podziel tekst przed kursorem na tokeny (oddzielone spacjami) + tokens = text_before_cursor.split() + prefix = tokens[-1] if tokens else "" + if not prefix: + self.hide_listbox() + return + # Pobieramy sugestie na podstawie prefixu + self.suggestions = self.get_suggestions(prefix) + if self.suggestions: + self.show_listbox() + else: + self.hide_listbox() + + def on_return(self, event): + if self.listbox and self.selection_index >= 0: + selected_display = self.listbox.get(self.selection_index) + # Pobieramy wartość do wstawienia z mapy (czyli bez liczby postów) + suggestion = self.suggestion_map.get(selected_display, selected_display) + tag = suggestion + else: + tag = self.get().strip() + if tag and self.callback: + self.callback(tag) + self.delete(0, tk.END) + self.hide_listbox() + return "break" + + def get_suggestions(self, prefix): + try: + conn = self.tags_repo.get_conn() + cursor = conn.cursor() + query = """ + SELECT name, category, post_count FROM tags + WHERE name LIKE ? AND post_count >= 1 + ORDER BY post_count DESC + LIMIT 10 + """ + cursor.execute(query, (prefix + "%",)) + results = cursor.fetchall() + conn.close() + # Mapowanie kategorii na prefiksy + prefix_map = {1: "artist:", 3: "copyright:", 4: "character:", 5: "meta:"} + suggestions = [] + # Utwórz słownik mapujący tekst wyświetlany (z liczbą) na tekst do wstawienia (bez liczby) + self.suggestion_map = {} + for row in results: + name, category, post_count = row + tag_insert = prefix_map.get(category, "") + name + display_text = f"{tag_insert} ({post_count})" + suggestions.append(display_text) + self.suggestion_map[display_text] = tag_insert + return suggestions + except Exception as e: + print("Błąd przy pobieraniu sugestii:", e) + return [] + + def show_listbox(self): + if self.listbox_window: + self.listbox_window.destroy() + self.listbox_window = tk.Toplevel(self) + self.listbox_window.wm_overrideredirect(True) + self.listbox = tk.Listbox(self.listbox_window, height=6) + self.listbox.bind("", self.on_listbox_click) + self.listbox.bind("", self.on_listbox_motion) + for suggestion in self.suggestions: + self.listbox.insert(tk.END, suggestion) + self.listbox.pack(fill=tk.BOTH, expand=True) + # Pobierz czcionkę używaną w listboxie + list_font = font.Font(font=self.listbox.cget("font")) + # Oblicz maksymalną szerokość na podstawie najdłuższego elementu + max_width = ( + max(list_font.measure(item) for item in self.suggestions) + 20 + ) # +20 dla marginesu + + # Ustaw szerokość okna na podstawie najszerszego elementu + self.listbox_window.geometry(f"{max_width}x200") # 200 - wysokość okna + + # Umieszczamy okno poniżej pola autouzupełniania + x = self.winfo_rootx() + y = self.winfo_rooty() + self.winfo_height() + self.listbox_window.geometry("+%d+%d" % (x, y)) + self.listbox_window.deiconify() + self.selection_index = -1 + + def hide_listbox(self): + if self.listbox_window: + self.listbox_window.destroy() + self.listbox_window = None + self.listbox = None + self.selection_index = -1 + + def on_listbox_click(self, event): + if self.listbox: + index = self.listbox.curselection() + if index: + value = self.listbox.get(index) + self.delete(0, tk.END) + self.insert(tk.END, value) + self.hide_listbox() + return "break" + + def on_listbox_motion(self, event): + if self.listbox: + self.listbox.selection_clear(0, tk.END) + index = self.listbox.nearest(event.y) + self.listbox.selection_set(first=index) + self.selection_index = index + + def on_down(self, event): + if self.listbox: + self.selection_index = (self.selection_index + 1) % self.listbox.size() + self.listbox.selection_clear(0, tk.END) + self.listbox.selection_set(self.selection_index) + self.listbox.activate(self.selection_index) + return "break" + + def on_up(self, event): + if self.listbox: + self.selection_index = (self.selection_index - 1) % self.listbox.size() + self.listbox.selection_clear(0, tk.END) + self.listbox.selection_set(self.selection_index) + self.listbox.activate(self.selection_index) + return "break" + + +class TagManager(tk.Frame): + """ + This widget holds a tag input entry (with autocompletion) and a display area + that shows the entered tags. In the display area, left-clicking on a tag removes it, + and right-clicking opens its wiki URL. Tag appearance is adjusted (color/underline) + based on custom logic. + """ + + def __init__(self, master, settings: Settings, tags_repo: TagsRepo, *args, **kwargs): + super().__init__(master, *args, **kwargs) + self.tags_repo = tags_repo + self.settings = settings + self.manual_tags = [] # List to hold manually entered tags + + # Entry for new tags (with autocompletion) + self.entry = AutocompleteEntry(self, callback=self.add_tag, tags_repo=self.tags_repo) + self.entry.pack(fill=tk.X, padx=5, pady=5) + + # Text widget for displaying already entered tags + self.tags_display = tk.Text(self, wrap="word", height=4) + self.tags_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + self.tags_display.config(state="disabled") + # (Optional: add a scrollbar if needed) + + def add_tag(self, tag): + """Add a new tag if it is not already present.""" + if tag and tag not in self.manual_tags: + self.manual_tags.append(tag) + self.update_tags_display() + + def update_tags_display(self): + """Refresh the text widget to display all manual tags with styling and event bindings.""" + self.tags_display.config(state="normal") + self.tags_display.delete("1.0", tk.END) + for tag in self.manual_tags: + # Process tag to decide its style + _, deprecated = process_tag(tag, self.tags_repo) + if deprecated is True: + color = "red" + underline = 1 + elif deprecated is None: + color = "darkorange" + underline = 1 + else: + color = "blue" + underline = 0 + start_index = self.tags_display.index(tk.INSERT) + self.tags_display.insert(tk.INSERT, tag) + end_index = self.tags_display.index(tk.INSERT) + tag_name = "manual_" + tag + self.tags_display.tag_add(tag_name, start_index, end_index) + self.tags_display.tag_configure( + tag_name, foreground=color, underline=underline + ) + # Left-click: remove tag; Right-click: open wiki URL + self.tags_display.tag_bind(tag_name, "", self.remove_tag) + self.tags_display.tag_bind(tag_name, "", self.open_tag_wiki_url) + self.tags_display.insert(tk.INSERT, " ") + self.tags_display.config(state="disabled") + + def remove_tag(self, event): + """Remove the clicked tag from the list and update the display.""" + index = self.tags_display.index("@%d,%d" % (event.x, event.y)) + for t in self.tags_display.tag_names(index): + if t.startswith("manual_"): + tag = t[len("manual_") :] + if tag in self.manual_tags: + self.manual_tags.remove(tag) + self.update_tags_display() + break + + def open_tag_wiki_url(self, event): + """Open a wiki URL for the clicked tag.""" + index = self.tags_display.index("@%d,%d" % (event.x, event.y)) + for t in self.tags_display.tag_names(index): + if t.startswith("manual_"): + tag = t[len("manual_") :] + open_tag_wiki_url(tag, self.settings) + break diff --git a/kapitanbooru_uploader/common.py b/kapitanbooru_uploader/common.py new file mode 100644 index 0000000..c37aace --- /dev/null +++ b/kapitanbooru_uploader/common.py @@ -0,0 +1,124 @@ +import subprocess + +from bs4 import BeautifulSoup +import requests + +from .settings import Settings + + +def open_tag_wiki_url(tag, settings: Settings): + """Otwiera w przeglądarce URL strony wiki dla podanego tagu.""" + # Usuń prefiksy + for prefix in [ + "character:", + "artist:", + "meta:", + "copyright:", + "general:", + ]: + if tag.startswith(prefix): + tag = tag[len(prefix) :] + break + + url = "https://danbooru.donmai.us/wiki_pages/" + tag + open_webbrowser(url, settings) + + +def open_webbrowser(url, settings: Settings): + """Otwiera URL w wybranej przeglądarce (lub domyślnej).""" + if settings.browser: + try: + subprocess.run([settings.browser, url], check=True) + return + except Exception as e: + print("Błąd przy otwieraniu przeglądarki:", e) + import webbrowser + + webbrowser.open(url) + + +def login(settings: Settings): + """ + Log in to the server using settings and return a session with cookies. + + settings should have: + - base_url (e.g., "https://example.com") + - username + - password + """ + # Construct the full URL for login + url = settings.base_url.rstrip("/") + "/user_admin/login" + + # Prepare the payload for URL-encoded form data + payload = {"user": settings.username, "pass": settings.password} + + # Set the proper header + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + # Use a session so that cookies are automatically stored for future requests. + session = requests.Session() + + # Send the POST request and prevent automatic redirects, + # so we can capture the 302 response with Set-Cookie headers. + response = session.post(url, data=payload, headers=headers, allow_redirects=False) + + if response.status_code == 302: + # The session's cookie jar should now contain the cookies set by the server. + shm_user = session.cookies.get("shm_user") + shm_session = session.cookies.get("shm_session") + + if not (shm_user and shm_session): + raise Exception("Login succeeded, but expected cookies were not set.") + + print("Login successful. Cookies stored in session:") + print(f"shm_user: {shm_user}") + print(f"shm_session: {shm_session}") + + return session + else: + raise Exception(f"Login failed: {response.status_code} - {response.text}") + + +def get_auth_token(session, settings): + """ + Given a logged-in session and settings, fetch the user page + and extract the auth_token from the hidden input field. + + settings should have: + - base_url (e.g., "https://example.com") + The session should contain the 'shm_user' cookie. + """ + # Retrieve the user identifier from cookies + shm_user = session.cookies.get("shm_user") + if not shm_user: + raise Exception("shm_user cookie not found; login might have failed.") + + # Build the URL to fetch, e.g., /user/ + user_url = f"{settings.base_url.rstrip('/')}/user/{shm_user}" + + # Option 1: Simply allow redirects (if your server sends 302 and eventually a 200) + # response = session.get(user_url) # redirects allowed by default + + # Option 2: If you want to control redirection manually, disable them: + # response = session.get(user_url, allow_redirects=False) + # Then you might have to follow the redirects manually. + + # For simplicity, we'll allow redirects: + response = session.get(user_url) + + if response.status_code != 200: + raise Exception( + f"Failed to load {user_url}, status code: {response.status_code}" + ) + + # Parse the returned HTML with BeautifulSoup + soup = BeautifulSoup(response.text, "html.parser") + + # Look for the hidden input with name "auth_token" + auth_input = soup.find("input", {"name": "auth_token"}) + if auth_input and auth_input.has_attr("value"): + auth_token = auth_input["value"] + print(f"Found auth_token: {auth_token}") + return auth_token + else: + raise Exception("auth_token not found in the HTML page.") diff --git a/kapitanbooru_uploader/fix_tags.py b/kapitanbooru_uploader/fix_tags.py new file mode 100644 index 0000000..21c50b3 --- /dev/null +++ b/kapitanbooru_uploader/fix_tags.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +import os +import time +import requests +import json +import tempfile +from PIL import Image +from wdtagger import Tagger + +# Stałe (ustaw odpowiednie wartości) +LOGIN = "" +API_KEY = "" +AUTH_TOKEN = "" +SHM_SESSION = "" +SHM_USER = "" + +TAGGER = Tagger() + +# Bazowy URL Kapitanbooru +BASE_URL = "http://192.168.1.11:8001" + + +# Funkcja wyciągająca tytuł – czyli nazwę pliku bez rozszerzenia +def extract_title(file_name): + return os.path.splitext(file_name)[0] + + +# Funkcja aktualizująca rating i tagi dla danego obrazu na serwerze +def update_post(post, new_rating): + """ + Aktualizuje post na serwerze, ustawiając rating. + new_rating – rating ustalony przez Taggera (bez mapowania general i sensitive). + Rating wysyłamy jako pierwsza litera (mała), a kod 302 traktujemy jako sukces. + """ + post_id = post.get("id") + title = extract_title(post.get("file_name", "")) + owner = LOGIN # Stałe LOGIN + source = post.get("source", "") + rating_param = new_rating[0] if new_rating else "?" + if rating_param == post.get("rating"): + print(f"Post {post_id} już ma rating {new_rating}, pomijam.") + return False + tags = post.get("tag_string") + + url = BASE_URL.rstrip("/") + "/post/set" + cookies = {"shm_session": SHM_SESSION, "shm_user": SHM_USER} + data = { + "auth_token": AUTH_TOKEN, + "image_id": post_id, + "title": title, + "owner": owner, + "tags": tags, + "source": source, + "rating": rating_param, + } + try: + # Ustawiamy allow_redirects=False, aby 302 nie było traktowane jako błąd + r = requests.post(url, data=data, cookies=cookies, allow_redirects=False) + if r.status_code in (200, 201, 302): + print(f"Post {post_id} zaktualizowany, rating: {new_rating}") + else: + print(f"Błąd aktualizacji postu {post_id}: {r.status_code} {r.text}") + except Exception as e: + print(f"Błąd przy aktualizacji postu {post_id}: {e}") + return True + + +def main(): + page = 1 + posts = json.loads("[]") + total_processed = 0 + while True: + # Tworzymy URL do pobierania postów + posts_url = ( + f"{BASE_URL}/posts.json?&tags=rating:s&limit=100&page={page}" + f"&login={LOGIN}&api_key={API_KEY}" + ) + try: + response = requests.get(posts_url) + if response.status_code != 200: + print( + f"Błąd pobierania posts.json: {response.status_code} {response.text}" + ) + break + response_posts = response.json() + except Exception as e: + print("Błąd przy pobieraniu JSON:", e) + break + + if not response_posts: + print("Brak więcej postów.") + break + + posts.extend(response_posts) + print( + f"Pobrano stronę {page} z {len(response_posts)} postami. Zebrano łącznie {len(posts)} postów." + ) + + # Opcjonalnie: odczekaj chwilę między postami, aby nie przeciążyć serwera + time.sleep(0.5) + # Jeśli mniej niż 100 postów na stronie, kończymy + if len(response_posts) < 100: + break + page += 1 + + for post in posts: + total_processed += 1 + print(f"\nPrzetwarzam post {post.get('id')} ({total_processed})...") + file_url = post.get("file_url") + if not file_url: + print("Brak file_url, pomijam.") + continue + # Pobieramy obrazek do tymczasowego pliku + try: + r = requests.get(file_url, stream=True) + if r.status_code != 200: + print(f"Błąd pobierania obrazu: {r.status_code}") + continue + with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp_file: + for chunk in r.iter_content(chunk_size=8192): + tmp_file.write(chunk) + tmp_file_path = tmp_file.name + print(f"Obrazek zapisany tymczasowo: {tmp_file_path}") + except Exception as e: + print("Błąd przy pobieraniu obrazu:", e) + continue + + # Otwieramy obrazek i uruchamiamy Taggera + try: + img = Image.open(tmp_file_path) + except Exception as e: + print("Błąd przy otwieraniu obrazu:", e) + os.remove(tmp_file_path) + continue + + try: + result = TAGGER.tag(img) + new_rating = ( + result.rating + if result.rating in ["general", "sensitive", "questionable", "explicit"] + else "" + ) + print(f"Tagger: rating = {result.rating}") + except Exception as e: + print("Błąd Taggera:", e) + os.remove(tmp_file_path) + continue + finally: + img.close() + + # Aktualizujemy post na serwerze + updated = update_post(post, new_rating) + + # Usuwamy tymczasowy plik + try: + os.remove(tmp_file_path) + print(f"Tymczasowy plik {tmp_file_path} usunięty.") + except Exception as e: + print("Błąd przy usuwaniu tymczasowego pliku:", e) + + # Odczekaj chwilę między postami, aby nie przeciążyć serwera, jeśli aktualizowano + if updated: + time.sleep(0.5) + + print(f"\nZakończono przetwarzanie. Łącznie przetworzono {total_processed} postów.") + + +if __name__ == "__main__": + main() diff --git a/kapitanbooru_uploader/requirements.txt b/kapitanbooru_uploader/requirements.txt new file mode 100644 index 0000000..4058538 --- /dev/null +++ b/kapitanbooru_uploader/requirements.txt @@ -0,0 +1,6 @@ +networkx==3.4.2 +Pillow==11.1.0 +pywin32==308 +Requests==2.32.3 +wdtagger==0.13.2 +bs4==0.0.2 \ No newline at end of file diff --git a/kapitanbooru_uploader/settings.py b/kapitanbooru_uploader/settings.py new file mode 100644 index 0000000..551c1b1 --- /dev/null +++ b/kapitanbooru_uploader/settings.py @@ -0,0 +1,179 @@ +import base64 +import importlib +import json +import os +import sqlite3 +import subprocess +import sys + +# Na Windowsie używamy DPAPI +if sys.platform.startswith("win"): + try: + import win32crypt + import winreg + except ImportError: + win32crypt = None # Upewnij się, że masz zainstalowany pywin32 + winreg = None # Upewnij się, że masz zainstalowany pywin32 + + +def get_browser_paths_windows(): + """Returns a dictionary of browsers and their executable paths from Windows registry and Start Menu.""" + browsers = {"Default": None} # "Default" for default system browser + + # Check the registry for installed browsers + registry_paths = [ + r"SOFTWARE\Clients\StartMenuInternet", # 64-bit Windows + r"SOFTWARE\WOW6432Node\Clients\StartMenuInternet", # 32-bit applications + ] + + for reg_path in registry_paths: + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path) as key: + for i in range(winreg.QueryInfoKey(key)[0]): # Iterate over subkeys + browser_name = winreg.EnumKey(key, i) + try: + browser_key_path = os.path.join( + reg_path, browser_name, r"shell\open\command" + ) + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, browser_key_path + ) as subkey: + command, _ = winreg.QueryValueEx(subkey, None) + browsers[browser_name] = command.strip( + '"' + ) # Clean command string + except FileNotFoundError: + pass # Skip if no command found + except FileNotFoundError: + pass # Registry path not found, continue + + return browsers + +def get_browsers_linux(): + """Detects installed browsers on Linux by checking available executables.""" + browsers = {"Default": None} + browser_names = [ + "firefox", + "google-chrome", + "chromium", + "opera", + "brave", + "vivaldi", + ] + + for browser in browser_names: + if ( + subprocess.run( + ["which", browser], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ).returncode + == 0 + ): + browsers[browser] = browser + + return browsers + + +def detect_installed_browsers(): + """Detects available browsers depending on the OS.""" + if sys.platform == "win32" and winreg: # Windows and winreg is available + browsers = get_browser_paths_windows() + elif sys.platform.startswith("linux"): # Linux + browsers = get_browsers_linux() + + return browsers + + +# --- Funkcje pomocnicze do szyfrowania/odszyfrowania hasła na Windowsie --- + + +def encrypt_password(password): + """Szyfruje hasło przy użyciu DPAPI i zwraca zakodowaną base64 postać.""" + if win32crypt is None: + return password # jeśli brak win32crypt, zwróć hasło w postaci jawnej + blob = win32crypt.CryptProtectData( + password.encode("utf-8"), None, None, None, None, 0 + ) + return base64.b64encode(blob).decode("utf-8") + + +def decrypt_password(enc_password): + """Odszyfrowuje hasło zapisane w formacie base64 przy użyciu DPAPI.""" + if win32crypt is None: + return enc_password + enc_data = base64.b64decode(enc_password) + data = win32crypt.CryptUnprotectData(enc_data, None, None, None, 0) + return data[1].decode("utf-8") + + +class Settings: + def __init__(self): + # Ustawienia domyślne + self.username = "" + self.password = "" + self.base_url = "http://192.168.1.11:8001" + self.default_tags = "artist:kapitan meta:ai-generated" + self.cache_expiry = 604800 # 7 dni w sekundach + self.browser = "" + self.installed_browsers = detect_installed_browsers() + self.load_settings() + self.installed_browsers_reverse = { + v: k for k, v in self.installed_browsers.items() + } + + def get_settings_path(self): + """Ustala ścieżkę do pliku ustawień w zależności od systemu.""" + if sys.platform.startswith("win"): + base_dir = os.path.join(os.environ.get("APPDATA", ""), "Kapitanbooru") + else: + base_dir = os.path.expanduser("~/.kapitanbooru") + if not os.path.exists(base_dir): + os.makedirs(base_dir) + return os.path.join(base_dir, "settings.json") + + def load_settings(self): + """Ładuje ustawienia z pliku, jeżeli plik istnieje.""" + # Ustawienia domyślne + self.username = "" + self.password = "" + self.base_url = "http://192.168.1.11:8001" + self.default_tags = "artist:kapitan meta:ai-generated" + self.cache_expiry = 604800 # 7 dni w sekundach + self.browser = "" + try: + if os.path.exists(self.get_settings_path()): + with open(self.get_settings_path(), "r", encoding="utf-8") as f: + data = json.load(f) + self.username = data.get("username", self.username) + # Jeśli system Windows, odszyfruj hasło + if sys.platform.startswith("win") and "password" in data: + self.password = decrypt_password(data["password"]) + else: + self.password = data.get("password", self.password) + self.base_url = data.get("base_url", self.base_url) + self.default_tags = data.get("default_tags", self.default_tags) + self.cache_expiry = data.get("cache_expiry", self.cache_expiry) + self.browser = data.get("browser", self.browser) + if self.browser not in self.installed_browsers: + self.browser = "" + except Exception as e: + print("Błąd podczas ładowania ustawień:", e) + + def save_settings(self): + """Zapisuje ustawienia do pliku.""" + data = { + "username": self.username, + "base_url": self.base_url, + "default_tags": self.default_tags, + "cache_expiry": self.cache_expiry, + "browser": self.browser, + } + # Na Windowsie szyfrujemy hasło + if sys.platform.startswith("win"): + data["password"] = encrypt_password(self.password) + else: + data["password"] = self.password + try: + with open(self.get_settings_path(), "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + except Exception as e: + print("Błąd podczas zapisywania ustawień:", e) diff --git a/kapitanbooru_uploader/tag_processing.py b/kapitanbooru_uploader/tag_processing.py new file mode 100644 index 0000000..590c1e0 --- /dev/null +++ b/kapitanbooru_uploader/tag_processing.py @@ -0,0 +1,181 @@ +from functools import lru_cache +import re + +from .TagsRepo import TagsRepo + + +@lru_cache(maxsize=1) +def get_character_tags(tags_repo: TagsRepo): + """Zwraca zbiór nazw tagów z kategorii Character (kategoria = 4) z bazy tags.sqlite.""" + try: + conn = tags_repo.get_conn() + cursor = conn.cursor() + cursor.execute("SELECT name FROM tags WHERE category = 4") + rows = cursor.fetchall() + conn.close() + return {row[0] for row in rows} + except Exception as e: + print("Błąd przy pobieraniu tagów postaci:", e) + return set() + + +@lru_cache(maxsize=1) +def get_copyright_tags(tags_repo: TagsRepo): + """Zwraca zbiór nazw tagów z kategorii Copyright (kategoria = 3) z bazy tags.sqlite.""" + try: + conn = tags_repo.get_conn() + cursor = conn.cursor() + cursor.execute("SELECT name FROM tags WHERE category = 3") + rows = cursor.fetchall() + conn.close() + return {row[0] for row in rows} + except Exception as e: + print("Błąd przy pobieraniu tagów copyright:", e) + return set() + + +# Wzorce i ustawienia związane z tagami +COEFFICIENT_PATTERN = re.compile(r"^.*?(:\d+|\d+\.\d+)$") +UNESCAPED_PATTERN = re.compile(r"(?=3.13" +readme = "README.md" +license = {file = "LICENSE"} + +[project.scripts] +kapitanbooru-uploader = "kapitanbooru_uploader.__main__:main" + +[tool.setuptools] +packages = ["kapitanbooru_uploader"] \ No newline at end of file