pax dfe8fd3815 settings: cap thumbnail size at 200px
behavior change: max thumbnail size reduced from 400px to 200px.
2026-04-09 21:33:00 -05:00

801 lines
31 KiB
Python

"""Settings dialog."""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QTabWidget,
QWidget,
QLabel,
QPushButton,
QSpinBox,
QComboBox,
QCheckBox,
QLineEdit,
QListWidget,
QMessageBox,
QGroupBox,
QProgressBar,
)
from ..core.db import Database
from ..core.cache import cache_size_bytes, cache_file_count, clear_cache, evict_oldest
from ..core.config import (
data_dir, cache_dir, thumbnails_dir, db_path, IS_WINDOWS,
)
class SettingsDialog(QDialog):
"""Full settings panel with tabs."""
settings_changed = Signal()
bookmarks_imported = Signal()
def __init__(self, db: Database, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._db = db
self.setWindowTitle("Settings")
# Set only a minimum WIDTH explicitly. Leaving the minimum height
# auto means Qt derives it from the layout's minimumSizeHint, which
# respects the cache spinboxes' setMinimumHeight floor. A hardcoded
# `setMinimumSize(550, 450)` was a hard floor that overrode the
# layout's needs and let the user drag the dialog below the height
# the cache tab actually requires, clipping the spinboxes.
self.setMinimumWidth(550)
layout = QVBoxLayout(self)
self._tabs = QTabWidget()
layout.addWidget(self._tabs)
self._tabs.addTab(self._build_general_tab(), "General")
self._tabs.addTab(self._build_cache_tab(), "Cache")
self._tabs.addTab(self._build_blacklist_tab(), "Blacklist")
self._tabs.addTab(self._build_paths_tab(), "Paths")
self._tabs.addTab(self._build_theme_tab(), "Theme")
self._tabs.addTab(self._build_network_tab(), "Network")
# Bottom buttons
btns = QHBoxLayout()
btns.addStretch()
save_btn = QPushButton("Save")
save_btn.clicked.connect(self._save_and_close)
btns.addWidget(save_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
btns.addWidget(cancel_btn)
layout.addLayout(btns)
@staticmethod
def _spinbox_row(spinbox: QSpinBox) -> QWidget:
"""Wrap a QSpinBox in a horizontal layout with side-by-side
[-] [spinbox] [+] buttons. Mirrors the search-bar score field
pattern in app.py — QSpinBox's native vertical arrow buttons
cramp the value text and read poorly in dense form layouts;
explicit +/- buttons are clearer and respect the spinbox's
configured singleStep.
"""
spinbox.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons)
# Hard minimum height. QSS `min-height` is a hint the QFormLayout
# can override under pressure (when the dialog is resized to its
# absolute minimum bounds), which causes the spinbox value text to
# vertically clip. setMinimumHeight is a Python-side floor that
# propagates up the layout chain — the dialog's own min size grows
# to accommodate it instead of squeezing the contents. 24px gives
# a couple of extra pixels of headroom over the 22px native button
# height for the 13px font, comfortable on every tested DPI/scale.
spinbox.setMinimumHeight(24)
container = QWidget()
h = QHBoxLayout(container)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(2)
h.addWidget(spinbox, 1)
# Inline padding override matches the rest of the app's narrow
# toolbar buttons. The new bundled themes use `padding: 2px 8px`
# globally, but `2px 6px` here gives the +/- glyph a touch more
# room to breathe in a 25px-wide button.
_btn_style = "padding: 2px 6px;"
minus = QPushButton("-")
minus.setFixedWidth(25)
minus.setStyleSheet(_btn_style)
minus.clicked.connect(
lambda: spinbox.setValue(spinbox.value() - spinbox.singleStep())
)
plus = QPushButton("+")
plus.setFixedWidth(25)
plus.setStyleSheet(_btn_style)
plus.clicked.connect(
lambda: spinbox.setValue(spinbox.value() + spinbox.singleStep())
)
h.addWidget(minus)
h.addWidget(plus)
return container
# -- General tab --
def _build_general_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
form = QFormLayout()
# Page size
self._page_size = QSpinBox()
self._page_size.setRange(10, 100)
self._page_size.setValue(self._db.get_setting_int("page_size"))
form.addRow("Results per page:", self._spinbox_row(self._page_size))
# Thumbnail size
self._thumb_size = QSpinBox()
self._thumb_size.setRange(100, 200)
self._thumb_size.setSingleStep(20)
self._thumb_size.setValue(self._db.get_setting_int("thumbnail_size"))
form.addRow("Thumbnail size (px):", self._spinbox_row(self._thumb_size))
# Default rating
self._default_rating = QComboBox()
self._default_rating.addItems(["all", "general", "sensitive", "questionable", "explicit"])
current_rating = self._db.get_setting("default_rating")
idx = self._default_rating.findText(current_rating)
if idx >= 0:
self._default_rating.setCurrentIndex(idx)
form.addRow("Default rating filter:", self._default_rating)
# Default site
self._default_site = QComboBox()
self._default_site.addItem("(none)", 0)
for site in self._db.get_sites():
self._default_site.addItem(site.name, site.id)
default_site_id = self._db.get_setting_int("default_site_id")
if default_site_id:
idx = self._default_site.findData(default_site_id)
if idx >= 0:
self._default_site.setCurrentIndex(idx)
form.addRow("Default site:", self._default_site)
# Default min score
self._default_score = QSpinBox()
self._default_score.setRange(0, 99999)
self._default_score.setValue(self._db.get_setting_int("default_score"))
form.addRow("Default minimum score:", self._spinbox_row(self._default_score))
# Preload thumbnails
self._preload = QCheckBox("Load thumbnails automatically")
self._preload.setChecked(self._db.get_setting_bool("preload_thumbnails"))
form.addRow("", self._preload)
# Prefetch adjacent posts
self._prefetch_combo = QComboBox()
self._prefetch_combo.addItems(["Off", "Nearby", "Aggressive"])
prefetch_mode = self._db.get_setting("prefetch_mode") or "Off"
idx = self._prefetch_combo.findText(prefetch_mode)
if idx >= 0:
self._prefetch_combo.setCurrentIndex(idx)
form.addRow("Prefetch:", self._prefetch_combo)
# Infinite scroll
self._infinite_scroll = QCheckBox("Infinite scroll (replaces page buttons)")
self._infinite_scroll.setChecked(self._db.get_setting_bool("infinite_scroll"))
form.addRow("", self._infinite_scroll)
# Slideshow monitor
from PySide6.QtWidgets import QApplication
self._monitor_combo = QComboBox()
self._monitor_combo.addItem("Same as app")
for i, screen in enumerate(QApplication.screens()):
self._monitor_combo.addItem(f"{screen.name()} ({screen.size().width()}x{screen.size().height()})")
current_monitor = self._db.get_setting("slideshow_monitor")
if current_monitor:
idx = self._monitor_combo.findText(current_monitor)
if idx >= 0:
self._monitor_combo.setCurrentIndex(idx)
form.addRow("Popout monitor:", self._monitor_combo)
# File dialog platform (Linux only)
self._file_dialog_combo = None
if not IS_WINDOWS:
self._file_dialog_combo = QComboBox()
self._file_dialog_combo.addItems(["qt", "gtk"])
current = self._db.get_setting("file_dialog_platform")
idx = self._file_dialog_combo.findText(current)
if idx >= 0:
self._file_dialog_combo.setCurrentIndex(idx)
form.addRow("File dialog (restart required):", self._file_dialog_combo)
layout.addLayout(form)
layout.addStretch()
return w
# -- Cache tab --
def _build_cache_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
# Cache stats
stats_group = QGroupBox("Cache Statistics")
stats_layout = QFormLayout(stats_group)
images, thumbs = cache_file_count()
total_bytes = cache_size_bytes()
total_mb = total_bytes / (1024 * 1024)
self._cache_images_label = QLabel(f"{images}")
stats_layout.addRow("Cached images:", self._cache_images_label)
self._cache_thumbs_label = QLabel(f"{thumbs}")
stats_layout.addRow("Cached thumbnails:", self._cache_thumbs_label)
self._cache_size_label = QLabel(f"{total_mb:.1f} MB")
stats_layout.addRow("Total size:", self._cache_size_label)
self._fav_count_label = QLabel(f"{self._db.bookmark_count()}")
stats_layout.addRow("Bookmarks:", self._fav_count_label)
layout.addWidget(stats_group)
# Cache limits
limits_group = QGroupBox("Cache Limits")
limits_layout = QFormLayout(limits_group)
self._max_cache = QSpinBox()
self._max_cache.setRange(100, 50000)
self._max_cache.setSingleStep(100)
self._max_cache.setSuffix(" MB")
self._max_cache.setValue(self._db.get_setting_int("max_cache_mb"))
limits_layout.addRow("Max cache size:", self._spinbox_row(self._max_cache))
self._max_thumb_cache = QSpinBox()
self._max_thumb_cache.setRange(50, 10000)
self._max_thumb_cache.setSingleStep(50)
self._max_thumb_cache.setSuffix(" MB")
self._max_thumb_cache.setValue(self._db.get_setting_int("max_thumb_cache_mb") or 500)
limits_layout.addRow("Max thumbnail cache:", self._spinbox_row(self._max_thumb_cache))
self._auto_evict = QCheckBox("Auto-evict oldest when limit reached")
self._auto_evict.setChecked(self._db.get_setting_bool("auto_evict"))
limits_layout.addRow("", self._auto_evict)
self._clear_on_exit = QCheckBox("Clear cache on exit (session-only cache)")
self._clear_on_exit.setChecked(self._db.get_setting_bool("clear_cache_on_exit"))
limits_layout.addRow("", self._clear_on_exit)
layout.addWidget(limits_group)
# Cache actions
actions_group = QGroupBox("Actions")
actions_layout = QVBoxLayout(actions_group)
btn_row1 = QHBoxLayout()
clear_thumbs_btn = QPushButton("Clear Thumbnails")
clear_thumbs_btn.clicked.connect(self._clear_thumbnails)
btn_row1.addWidget(clear_thumbs_btn)
clear_cache_btn = QPushButton("Clear Image Cache")
clear_cache_btn.clicked.connect(self._clear_image_cache)
btn_row1.addWidget(clear_cache_btn)
actions_layout.addLayout(btn_row1)
btn_row2 = QHBoxLayout()
clear_all_btn = QPushButton("Clear Everything")
clear_all_btn.setStyleSheet(f"QPushButton {{ color: #ff4444; }}")
clear_all_btn.clicked.connect(self._clear_all)
btn_row2.addWidget(clear_all_btn)
evict_btn = QPushButton("Evict to Limit Now")
evict_btn.clicked.connect(self._evict_now)
btn_row2.addWidget(evict_btn)
actions_layout.addLayout(btn_row2)
layout.addWidget(actions_group)
layout.addStretch()
return w
# -- Blacklist tab --
def _build_blacklist_tab(self) -> QWidget:
from PySide6.QtWidgets import QTextEdit
w = QWidget()
layout = QVBoxLayout(w)
self._bl_enabled = QCheckBox("Enable blacklist")
self._bl_enabled.setChecked(self._db.get_setting_bool("blacklist_enabled"))
layout.addWidget(self._bl_enabled)
layout.addWidget(QLabel(
"Posts containing these tags will be hidden from results.\n"
"Paste tags separated by spaces or newlines:"
))
self._bl_text = QTextEdit()
self._bl_text.setPlaceholderText("tag1 tag2 tag3 ...")
# Load existing tags into the text box
tags = self._db.get_blacklisted_tags()
self._bl_text.setPlainText(" ".join(tags))
layout.addWidget(self._bl_text)
io_row = QHBoxLayout()
export_bl_btn = QPushButton("Export")
export_bl_btn.clicked.connect(self._bl_export)
io_row.addWidget(export_bl_btn)
import_bl_btn = QPushButton("Import")
import_bl_btn.clicked.connect(self._bl_import)
io_row.addWidget(import_bl_btn)
layout.addLayout(io_row)
# Blacklisted posts
layout.addWidget(QLabel("Blacklisted posts (by URL):"))
self._bl_post_list = QListWidget()
for url in sorted(self._db.get_blacklisted_posts()):
self._bl_post_list.addItem(url)
layout.addWidget(self._bl_post_list)
bl_post_row = QHBoxLayout()
add_post_btn = QPushButton("Add...")
add_post_btn.clicked.connect(self._bl_add_post)
bl_post_row.addWidget(add_post_btn)
remove_post_btn = QPushButton("Remove Selected")
remove_post_btn.clicked.connect(self._bl_remove_post)
bl_post_row.addWidget(remove_post_btn)
clear_posts_btn = QPushButton("Clear All")
clear_posts_btn.clicked.connect(self._bl_clear_posts)
bl_post_row.addWidget(clear_posts_btn)
layout.addLayout(bl_post_row)
return w
def _bl_add_post(self) -> None:
from PySide6.QtWidgets import QInputDialog
text, ok = QInputDialog.getMultiLineText(
self, "Add Blacklisted Posts",
"Paste URLs (one per line or space-separated):",
)
if ok and text.strip():
urls = text.replace("\n", " ").split()
for url in urls:
url = url.strip()
if url:
self._db.add_blacklisted_post(url)
self._bl_post_list.addItem(url)
def _bl_remove_post(self) -> None:
item = self._bl_post_list.currentItem()
if item:
self._db.remove_blacklisted_post(item.text())
self._bl_post_list.takeItem(self._bl_post_list.row(item))
def _bl_clear_posts(self) -> None:
reply = QMessageBox.question(
self, "Confirm", "Remove all blacklisted posts?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
for url in self._db.get_blacklisted_posts():
self._db.remove_blacklisted_post(url)
self._bl_post_list.clear()
# -- Paths tab --
def _build_paths_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
form = QFormLayout()
data = QLineEdit(str(data_dir()))
data.setReadOnly(True)
form.addRow("Data directory:", data)
cache = QLineEdit(str(cache_dir()))
cache.setReadOnly(True)
form.addRow("Image cache:", cache)
thumbs = QLineEdit(str(thumbnails_dir()))
thumbs.setReadOnly(True)
form.addRow("Thumbnails:", thumbs)
db = QLineEdit(str(db_path()))
db.setReadOnly(True)
form.addRow("Database:", db)
layout.addLayout(form)
# Library directory (editable)
lib_row = QHBoxLayout()
from ..core.config import saved_dir
current_lib = self._db.get_setting("library_dir") or str(saved_dir())
self._library_dir = QLineEdit(current_lib)
lib_row.addWidget(self._library_dir, stretch=1)
browse_lib_btn = QPushButton("Browse...")
browse_lib_btn.clicked.connect(self._browse_library_dir)
lib_row.addWidget(browse_lib_btn)
layout.addWidget(QLabel("Library directory:"))
layout.addLayout(lib_row)
# Library filename template (editable). Applies to every save action
# — Save to Library, Save As, batch downloads, multi-select bulk
# operations, and bookmark→library copies. Empty = post id.
layout.addWidget(QLabel("Library filename template:"))
self._library_filename_template = QLineEdit(
self._db.get_setting("library_filename_template") or ""
)
self._library_filename_template.setPlaceholderText("e.g. %artist%_%id% (leave blank for post id)")
layout.addWidget(self._library_filename_template)
tmpl_help = QLabel(
"Tokens: %id% %md5% %ext% %rating% %score% "
"%artist% %character% %copyright% %general% %meta% %species%\n"
"Applies to every save action: Save to Library, Save As, Batch Download, "
"multi-select bulk operations, and bookmark→library copies.\n"
"Note: Gelbooru and Moebooru only support %id% / %md5% / %score% / %rating% / %ext%."
)
tmpl_help.setWordWrap(True)
tmpl_help.setStyleSheet("color: palette(mid); font-size: 10pt;")
layout.addWidget(tmpl_help)
open_btn = QPushButton("Open Data Folder")
open_btn.clicked.connect(self._open_data_folder)
layout.addWidget(open_btn)
layout.addStretch()
# Export / Import
exp_group = QGroupBox("Backup")
exp_layout = QHBoxLayout(exp_group)
export_btn = QPushButton("Export Bookmarks")
export_btn.clicked.connect(self._export_bookmarks)
exp_layout.addWidget(export_btn)
import_btn = QPushButton("Import Bookmarks")
import_btn.clicked.connect(self._import_bookmarks)
exp_layout.addWidget(import_btn)
layout.addWidget(exp_group)
return w
# -- Theme tab --
def _build_theme_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(QLabel(
"Customize the app's appearance with a Qt stylesheet (QSS).\n"
"Place a custom.qss file in your data directory.\n"
"Restart the app after editing."
))
css_path = data_dir() / "custom.qss"
path_label = QLineEdit(str(css_path))
path_label.setReadOnly(True)
layout.addWidget(path_label)
btn_row = QHBoxLayout()
edit_btn = QPushButton("Edit custom.qss")
edit_btn.clicked.connect(self._edit_custom_css)
btn_row.addWidget(edit_btn)
create_btn = QPushButton("Create from Template")
create_btn.clicked.connect(self._create_css_template)
btn_row.addWidget(create_btn)
guide_btn = QPushButton("View Guide")
guide_btn.clicked.connect(self._view_css_guide)
btn_row.addWidget(guide_btn)
layout.addLayout(btn_row)
delete_btn = QPushButton("Delete custom.qss (Reset to Default)")
delete_btn.clicked.connect(self._delete_custom_css)
layout.addWidget(delete_btn)
layout.addStretch()
return w
# -- Network tab --
def _build_network_tab(self) -> QWidget:
from ..core.cache import get_connection_log
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(QLabel(
"All hosts contacted this session. booru-viewer only connects\n"
"to the booru sites you configure — no telemetry or analytics."
))
self._net_list = QListWidget()
self._net_list.setAlternatingRowColors(True)
layout.addWidget(self._net_list)
refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(self._refresh_network)
layout.addWidget(refresh_btn)
self._refresh_network()
return w
def _refresh_network(self) -> None:
from ..core.cache import get_connection_log
self._net_list.clear()
log = get_connection_log()
if not log:
self._net_list.addItem("No connections made yet")
return
for host, times in log.items():
self._net_list.addItem(f"{host} ({len(times)} requests, last: {times[-1]})")
def _edit_custom_css(self) -> None:
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
css_path = data_dir() / "custom.qss"
if not css_path.exists():
css_path.write_text("/* booru-viewer custom stylesheet */\n\n")
QDesktopServices.openUrl(QUrl.fromLocalFile(str(css_path)))
def _create_css_template(self) -> None:
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
# Open themes reference online and create a blank custom.qss for editing
QDesktopServices.openUrl(QUrl("https://git.pax.moe/pax/booru-viewer/src/branch/main/themes"))
css_path = data_dir() / "custom.qss"
if not css_path.exists():
css_path.write_text("/* booru-viewer custom stylesheet */\n/* See themes reference for examples */\n\n")
QDesktopServices.openUrl(QUrl.fromLocalFile(str(css_path)))
def _view_css_guide(self) -> None:
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
dest = data_dir() / "custom_css_guide.txt"
# Copy guide to appdata if not already there
if not dest.exists():
import sys
# Try source tree, then PyInstaller bundle
for candidate in [
Path(__file__).parent / "custom_css_guide.txt",
Path(getattr(sys, '_MEIPASS', __file__)) / "booru_viewer" / "gui" / "custom_css_guide.txt",
]:
if candidate.is_file():
dest.write_text(candidate.read_text())
break
if dest.exists():
QDesktopServices.openUrl(QUrl.fromLocalFile(str(dest)))
else:
# Fallback: show in dialog
from PySide6.QtWidgets import QTextEdit, QDialog, QVBoxLayout
dlg = QDialog(self)
dlg.setWindowTitle("Custom CSS Guide")
dlg.resize(600, 500)
layout = QVBoxLayout(dlg)
text = QTextEdit()
text.setReadOnly(True)
text.setPlainText(
"booru-viewer Custom Stylesheet Guide\n"
"=====================================\n\n"
"Place a file named 'custom.qss' in your data directory.\n"
f"Path: {data_dir() / 'custom.qss'}\n\n"
"WIDGET REFERENCE\n"
"----------------\n"
"QMainWindow, QPushButton, QLineEdit, QComboBox, QScrollBar,\n"
"QLabel, QStatusBar, QTabWidget, QTabBar, QListWidget,\n"
"QMenu, QMenuBar, QToolTip, QDialog, QSplitter, QProgressBar,\n"
"QSpinBox, QCheckBox, QSlider\n\n"
"STATES: :hover, :pressed, :focus, :selected, :disabled\n\n"
"PROPERTIES: color, background-color, border, border-radius,\n"
"padding, margin, font-family, font-size\n\n"
"EXAMPLE\n"
"-------\n"
"QPushButton {\n"
" background: #333; color: white;\n"
" border: 1px solid #555; border-radius: 4px;\n"
" padding: 6px 16px;\n"
"}\n"
"QPushButton:hover { background: #555; }\n\n"
"Restart the app after editing custom.qss."
)
layout.addWidget(text)
dlg.exec()
def _delete_custom_css(self) -> None:
css_path = data_dir() / "custom.qss"
if css_path.exists():
css_path.unlink()
QMessageBox.information(self, "Done", "Deleted. Restart to use default theme.")
else:
QMessageBox.information(self, "Info", "No custom.qss found.")
# -- Actions --
def _refresh_stats(self) -> None:
images, thumbs = cache_file_count()
total_bytes = cache_size_bytes()
total_mb = total_bytes / (1024 * 1024)
self._cache_images_label.setText(f"{images}")
self._cache_thumbs_label.setText(f"{thumbs}")
self._cache_size_label.setText(f"{total_mb:.1f} MB")
def _clear_thumbnails(self) -> None:
count = clear_cache(clear_images=False, clear_thumbnails=True)
QMessageBox.information(self, "Done", f"Deleted {count} thumbnails.")
self._refresh_stats()
def _clear_image_cache(self) -> None:
reply = QMessageBox.question(
self, "Confirm",
"Delete all cached images? (Bookmarks stay in the database but cached files are removed.)",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
count = clear_cache(clear_images=True, clear_thumbnails=False)
QMessageBox.information(self, "Done", f"Deleted {count} cached images.")
self._refresh_stats()
def _clear_all(self) -> None:
reply = QMessageBox.question(
self, "Confirm",
"Delete ALL cached images and thumbnails?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
count = clear_cache(clear_images=True, clear_thumbnails=True)
QMessageBox.information(self, "Done", f"Deleted {count} files.")
self._refresh_stats()
def _evict_now(self) -> None:
max_bytes = self._max_cache.value() * 1024 * 1024
# Protect bookmarked file paths
protected = set()
for fav in self._db.get_bookmarks(limit=999999):
if fav.cached_path:
protected.add(fav.cached_path)
count = evict_oldest(max_bytes, protected)
QMessageBox.information(self, "Done", f"Evicted {count} files.")
self._refresh_stats()
def _bl_export(self) -> None:
from .dialogs import save_file
path = save_file(self, "Export Blacklist", "blacklist.txt", "Text (*.txt)")
if not path:
return
tags = self._bl_text.toPlainText().split()
with open(path, "w") as f:
f.write("\n".join(tags))
QMessageBox.information(self, "Done", f"Exported {len(tags)} tags.")
def _bl_import(self) -> None:
from .dialogs import open_file
path = open_file(self, "Import Blacklist", "Text (*.txt)")
if not path:
return
try:
with open(path) as f:
tags = [line.strip() for line in f if line.strip()]
existing = self._bl_text.toPlainText().split()
merged = list(dict.fromkeys(existing + tags))
self._bl_text.setPlainText(" ".join(merged))
QMessageBox.information(self, "Done", f"Imported {len(tags)} tags.")
except Exception as e:
QMessageBox.warning(self, "Error", str(e))
def _browse_library_dir(self) -> None:
from PySide6.QtWidgets import QFileDialog
path = QFileDialog.getExistingDirectory(self, "Select Library Directory", self._library_dir.text())
if path:
self._library_dir.setText(path)
def _open_data_folder(self) -> None:
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
QDesktopServices.openUrl(QUrl.fromLocalFile(str(data_dir())))
def _export_bookmarks(self) -> None:
from .dialogs import save_file
import json
path = save_file(self, "Export Bookmarks", "bookmarks.json", "JSON (*.json)")
if not path:
return
favs = self._db.get_bookmarks(limit=999999)
data = [
{
"post_id": f.post_id,
"site_id": f.site_id,
"file_url": f.file_url,
"preview_url": f.preview_url,
"tags": f.tags,
"rating": f.rating,
"score": f.score,
"source": f.source,
"folder": f.folder,
"bookmarked_at": f.bookmarked_at,
}
for f in favs
]
with open(path, "w") as fp:
json.dump(data, fp, indent=2)
QMessageBox.information(self, "Done", f"Exported {len(data)} bookmarks.")
def _import_bookmarks(self) -> None:
from .dialogs import open_file
import json
path = open_file(self, "Import Bookmarks", "JSON (*.json)")
if not path:
return
try:
with open(path) as fp:
data = json.load(fp)
count = 0
for item in data:
try:
folder = item.get("folder")
self._db.add_bookmark(
site_id=item["site_id"],
post_id=item["post_id"],
file_url=item["file_url"],
preview_url=item.get("preview_url"),
tags=item.get("tags", ""),
rating=item.get("rating"),
score=item.get("score"),
source=item.get("source"),
folder=folder,
)
if folder:
self._db.add_folder(folder)
count += 1
except Exception:
pass
QMessageBox.information(self, "Done", f"Imported {count} bookmarks.")
self.bookmarks_imported.emit()
except Exception as e:
QMessageBox.warning(self, "Error", str(e))
# -- Save --
def _save_and_close(self) -> None:
self._db.set_setting("page_size", str(self._page_size.value()))
self._db.set_setting("thumbnail_size", str(self._thumb_size.value()))
self._db.set_setting("default_rating", self._default_rating.currentText())
self._db.set_setting("default_site_id", str(self._default_site.currentData() or 0))
self._db.set_setting("default_score", str(self._default_score.value()))
self._db.set_setting("preload_thumbnails", "1" if self._preload.isChecked() else "0")
self._db.set_setting("prefetch_mode", self._prefetch_combo.currentText())
self._db.set_setting("infinite_scroll", "1" if self._infinite_scroll.isChecked() else "0")
self._db.set_setting("slideshow_monitor", self._monitor_combo.currentText())
self._db.set_setting("library_dir", self._library_dir.text().strip())
self._db.set_setting("library_filename_template", self._library_filename_template.text().strip())
self._db.set_setting("max_cache_mb", str(self._max_cache.value()))
self._db.set_setting("max_thumb_cache_mb", str(self._max_thumb_cache.value()))
self._db.set_setting("auto_evict", "1" if self._auto_evict.isChecked() else "0")
self._db.set_setting("clear_cache_on_exit", "1" if self._clear_on_exit.isChecked() else "0")
self._db.set_setting("blacklist_enabled", "1" if self._bl_enabled.isChecked() else "0")
# Sync blacklist from text box
new_tags = set(self._bl_text.toPlainText().split())
old_tags = set(self._db.get_blacklisted_tags())
for tag in old_tags - new_tags:
self._db.remove_blacklisted_tag(tag)
for tag in new_tags - old_tags:
self._db.add_blacklisted_tag(tag)
if self._file_dialog_combo is not None:
self._db.set_setting("file_dialog_platform", self._file_dialog_combo.currentText())
self.settings_changed.emit()
self.accept()