refactor: extract PostActionsController from main_window.py
Move 26 bookmark/save/library/batch/blacklist methods and _batch_dest state into gui/post_actions.py. Rewire 8 signal connections and update popout_controller signal targets. Extract is_batch_message and is_in_library as pure functions for Phase 2 tests. main_window.py: 1935 -> 1400 lines. behavior change: none
This commit is contained in:
parent
0a8d392158
commit
8e9dda8671
@ -63,6 +63,7 @@ from .privacy import PrivacyController
|
||||
from .search_controller import SearchController
|
||||
from .media_controller import MediaController
|
||||
from .popout_controller import PopoutController
|
||||
from .post_actions import PostActionsController
|
||||
|
||||
log = logging.getLogger("booru")
|
||||
|
||||
@ -129,6 +130,7 @@ class BooruApp(QMainWindow):
|
||||
self._search_ctrl = SearchController(self)
|
||||
self._media_ctrl = MediaController(self)
|
||||
self._popout_ctrl = PopoutController(self)
|
||||
self._post_actions = PostActionsController(self)
|
||||
self._main_window_save_timer = QTimer(self)
|
||||
self._main_window_save_timer.setSingleShot(True)
|
||||
self._main_window_save_timer.setInterval(300)
|
||||
@ -148,11 +150,11 @@ class BooruApp(QMainWindow):
|
||||
s.image_done.connect(self._media_ctrl.on_image_done, Q)
|
||||
s.image_error.connect(self._on_image_error, Q)
|
||||
s.video_stream.connect(self._media_ctrl.on_video_stream, Q)
|
||||
s.bookmark_done.connect(self._on_bookmark_done, Q)
|
||||
s.bookmark_error.connect(self._on_bookmark_error, Q)
|
||||
s.bookmark_done.connect(self._post_actions.on_bookmark_done, Q)
|
||||
s.bookmark_error.connect(self._post_actions.on_bookmark_error, Q)
|
||||
s.autocomplete_done.connect(self._search_ctrl.on_autocomplete_done, Q)
|
||||
s.batch_progress.connect(self._on_batch_progress, Q)
|
||||
s.batch_done.connect(self._on_batch_done, Q)
|
||||
s.batch_progress.connect(self._post_actions.on_batch_progress, Q)
|
||||
s.batch_done.connect(self._post_actions.on_batch_done, Q)
|
||||
s.download_progress.connect(self._media_ctrl.on_download_progress, Q)
|
||||
s.prefetch_progress.connect(self._media_ctrl.on_prefetch_progress, Q)
|
||||
s.categories_updated.connect(self._on_categories_updated, Q)
|
||||
@ -210,9 +212,6 @@ class BooruApp(QMainWindow):
|
||||
self._dl_progress.hide()
|
||||
self._status.showMessage(f"Error: {e}")
|
||||
|
||||
def _on_bookmark_error(self, e: str) -> None:
|
||||
self._status.showMessage(f"Error: {e}")
|
||||
|
||||
def _run_async(self, coro_func, *args):
|
||||
future = asyncio.run_coroutine_threadsafe(coro_func(*args), self._async_loop)
|
||||
future.add_done_callback(self._on_async_done)
|
||||
@ -326,7 +325,7 @@ class BooruApp(QMainWindow):
|
||||
self._bookmarks_view = BookmarksView(self._db)
|
||||
self._bookmarks_view.bookmark_selected.connect(self._on_bookmark_selected)
|
||||
self._bookmarks_view.bookmark_activated.connect(self._on_bookmark_activated)
|
||||
self._bookmarks_view.bookmarks_changed.connect(self._refresh_browse_saved_dots)
|
||||
self._bookmarks_view.bookmarks_changed.connect(self._post_actions.refresh_browse_saved_dots)
|
||||
self._bookmarks_view.open_in_browser_requested.connect(
|
||||
lambda site_id, post_id: self._open_post_id_in_browser(post_id, site_id=site_id)
|
||||
)
|
||||
@ -335,7 +334,7 @@ class BooruApp(QMainWindow):
|
||||
self._library_view = LibraryView(db=self._db)
|
||||
self._library_view.file_selected.connect(self._on_library_selected)
|
||||
self._library_view.file_activated.connect(self._on_library_activated)
|
||||
self._library_view.files_deleted.connect(self._on_library_files_deleted)
|
||||
self._library_view.files_deleted.connect(self._post_actions.on_library_files_deleted)
|
||||
self._stack.addWidget(self._library_view)
|
||||
|
||||
self._splitter.addWidget(self._stack)
|
||||
@ -347,12 +346,12 @@ class BooruApp(QMainWindow):
|
||||
self._preview.close_requested.connect(self._close_preview)
|
||||
self._preview.open_in_default.connect(self._open_preview_in_default)
|
||||
self._preview.open_in_browser.connect(self._open_preview_in_browser)
|
||||
self._preview.bookmark_requested.connect(self._bookmark_from_preview)
|
||||
self._preview.bookmark_to_folder.connect(self._bookmark_to_folder_from_preview)
|
||||
self._preview.save_to_folder.connect(self._save_from_preview)
|
||||
self._preview.unsave_requested.connect(self._unsave_from_preview)
|
||||
self._preview.blacklist_tag_requested.connect(self._blacklist_tag_from_popout)
|
||||
self._preview.blacklist_post_requested.connect(self._blacklist_post_from_popout)
|
||||
self._preview.bookmark_requested.connect(self._post_actions.bookmark_from_preview)
|
||||
self._preview.bookmark_to_folder.connect(self._post_actions.bookmark_to_folder_from_preview)
|
||||
self._preview.save_to_folder.connect(self._post_actions.save_from_preview)
|
||||
self._preview.unsave_requested.connect(self._post_actions.unsave_from_preview)
|
||||
self._preview.blacklist_tag_requested.connect(self._post_actions.blacklist_tag_from_popout)
|
||||
self._preview.blacklist_post_requested.connect(self._post_actions.blacklist_post_from_popout)
|
||||
self._preview.navigate.connect(self._navigate_preview)
|
||||
self._preview.play_next_requested.connect(self._on_video_end_next)
|
||||
self._preview.fullscreen_requested.connect(self._popout_ctrl.open)
|
||||
@ -506,7 +505,7 @@ class BooruApp(QMainWindow):
|
||||
|
||||
self._batch_action = QAction("Batch &Download Page...", self)
|
||||
self._batch_action.setShortcut(QKeySequence("Ctrl+D"))
|
||||
self._batch_action.triggered.connect(self._batch_download)
|
||||
self._batch_action.triggered.connect(self._post_actions.batch_download)
|
||||
file_menu.addAction(self._batch_action)
|
||||
|
||||
file_menu.addSeparator()
|
||||
@ -730,7 +729,7 @@ class BooruApp(QMainWindow):
|
||||
self._preview.update_bookmark_state(
|
||||
bool(self._db.is_bookmarked(fav.site_id, post.id))
|
||||
)
|
||||
self._preview.update_save_state(self._is_post_saved(post.id))
|
||||
self._preview.update_save_state(self._post_actions.is_post_saved(post.id))
|
||||
info = f"Bookmark #{fav.post_id}"
|
||||
|
||||
# Try local cache first
|
||||
@ -911,169 +910,6 @@ class BooruApp(QMainWindow):
|
||||
"""
|
||||
self._navigate_preview(1, wrap=True)
|
||||
|
||||
def _is_post_saved(self, post_id: int) -> bool:
|
||||
"""Check if a post is saved in the library (any folder).
|
||||
|
||||
Goes through library_meta — format-agnostic, sees both
|
||||
digit-stem v0.2.3 files and templated post-refactor saves.
|
||||
Single indexed SELECT, no filesystem walk.
|
||||
"""
|
||||
return self._db.is_post_in_library(post_id)
|
||||
|
||||
def _get_preview_post(self):
|
||||
"""Get the post currently shown in the preview, from grid or stored ref."""
|
||||
idx = self._grid.selected_index
|
||||
if 0 <= idx < len(self._posts):
|
||||
return self._posts[idx], idx
|
||||
if self._preview._current_post:
|
||||
return self._preview._current_post, -1
|
||||
return None, -1
|
||||
|
||||
def _bookmark_from_preview(self) -> None:
|
||||
post, idx = self._get_preview_post()
|
||||
if not post:
|
||||
return
|
||||
site_id = self._preview._current_site_id or self._site_combo.currentData()
|
||||
if not site_id:
|
||||
return
|
||||
if idx >= 0:
|
||||
self._toggle_bookmark(idx)
|
||||
else:
|
||||
if self._db.is_bookmarked(site_id, post.id):
|
||||
self._db.remove_bookmark(site_id, post.id)
|
||||
else:
|
||||
from ..core.cache import cached_path_for
|
||||
cached = cached_path_for(post.file_url)
|
||||
self._db.add_bookmark(
|
||||
site_id=site_id, post_id=post.id,
|
||||
file_url=post.file_url, preview_url=post.preview_url or "",
|
||||
tags=post.tags, rating=post.rating, score=post.score,
|
||||
source=post.source, cached_path=str(cached) if cached.exists() else None,
|
||||
tag_categories=post.tag_categories,
|
||||
)
|
||||
bookmarked = bool(self._db.is_bookmarked(site_id, post.id))
|
||||
self._preview.update_bookmark_state(bookmarked)
|
||||
self._popout_ctrl.update_state()
|
||||
# Refresh bookmarks tab if visible
|
||||
if self._stack.currentIndex() == 1:
|
||||
self._bookmarks_view.refresh()
|
||||
|
||||
def _bookmark_to_folder_from_preview(self, folder: str) -> None:
|
||||
"""Bookmark the current preview post into a specific bookmark folder.
|
||||
|
||||
Triggered by the toolbar Bookmark-as submenu, which only shows
|
||||
when the post is not yet bookmarked — so this method only handles
|
||||
the create path, never the move/remove paths. Empty string means
|
||||
Unfiled. Brand-new folder names get added to the DB folder list
|
||||
first so the bookmarks tab combo immediately shows them.
|
||||
"""
|
||||
post, idx = self._get_preview_post()
|
||||
if not post:
|
||||
return
|
||||
site_id = self._preview._current_site_id or self._site_combo.currentData()
|
||||
if not site_id:
|
||||
return
|
||||
target = folder if folder else None
|
||||
if target and target not in self._db.get_folders():
|
||||
try:
|
||||
self._db.add_folder(target)
|
||||
except ValueError as e:
|
||||
self._status.showMessage(f"Invalid folder name: {e}")
|
||||
return
|
||||
if idx >= 0:
|
||||
# In the grid — go through _toggle_bookmark so the grid
|
||||
# thumbnail's bookmark badge updates via _on_bookmark_done.
|
||||
self._toggle_bookmark(idx, target)
|
||||
else:
|
||||
# Preview-only post (e.g. opened from the bookmarks tab while
|
||||
# browse is empty). Inline the add — no grid index to update.
|
||||
from ..core.cache import cached_path_for
|
||||
cached = cached_path_for(post.file_url)
|
||||
self._db.add_bookmark(
|
||||
site_id=site_id, post_id=post.id,
|
||||
file_url=post.file_url, preview_url=post.preview_url or "",
|
||||
tags=post.tags, rating=post.rating, score=post.score,
|
||||
source=post.source,
|
||||
cached_path=str(cached) if cached.exists() else None,
|
||||
folder=target,
|
||||
tag_categories=post.tag_categories,
|
||||
)
|
||||
where = target or "Unfiled"
|
||||
self._status.showMessage(f"Bookmarked #{post.id} to {where}")
|
||||
self._preview.update_bookmark_state(True)
|
||||
self._popout_ctrl.update_state()
|
||||
# Refresh bookmarks tab if visible so the new entry appears.
|
||||
if self._stack.currentIndex() == 1:
|
||||
self._bookmarks_view.refresh()
|
||||
|
||||
def _save_from_preview(self, folder: str) -> None:
|
||||
post, idx = self._get_preview_post()
|
||||
if post:
|
||||
target = folder if folder else None
|
||||
# _save_to_library calls saved_folder_dir() which mkdir's the
|
||||
# target directory itself — no need to register it in the
|
||||
# bookmark folders DB table (those are unrelated now).
|
||||
self._save_to_library(post, target)
|
||||
# State updates happen in _on_bookmark_done after async save completes
|
||||
|
||||
def _unsave_from_preview(self) -> None:
|
||||
post, idx = self._get_preview_post()
|
||||
if not post:
|
||||
return
|
||||
# delete_from_library walks every library folder by post id and
|
||||
# deletes every match in one call — no folder hint needed. Pass
|
||||
# db so templated filenames also get unlinked AND the meta row
|
||||
# gets cleaned up.
|
||||
from ..core.cache import delete_from_library
|
||||
deleted = delete_from_library(post.id, db=self._db)
|
||||
if deleted:
|
||||
self._status.showMessage(f"Removed #{post.id} from library")
|
||||
self._preview.update_save_state(False)
|
||||
# Update browse grid thumbnail saved dot
|
||||
for i, p in enumerate(self._posts):
|
||||
if p.id == post.id and i < len(self._grid._thumbs):
|
||||
self._grid._thumbs[i].set_saved_locally(False)
|
||||
break
|
||||
# Update bookmarks grid thumbnail
|
||||
bm_grid = self._bookmarks_view._grid
|
||||
for i, fav in enumerate(self._bookmarks_view._bookmarks):
|
||||
if fav.post_id == post.id and i < len(bm_grid._thumbs):
|
||||
bm_grid._thumbs[i].set_saved_locally(False)
|
||||
break
|
||||
# Refresh library tab if visible
|
||||
if self._stack.currentIndex() == 2:
|
||||
self._library_view.refresh()
|
||||
else:
|
||||
self._status.showMessage(f"#{post.id} not in library")
|
||||
self._popout_ctrl.update_state()
|
||||
|
||||
def _blacklist_tag_from_popout(self, tag: str) -> None:
|
||||
reply = QMessageBox.question(
|
||||
self, "Blacklist Tag",
|
||||
f"Blacklist tag \"{tag}\"?\nPosts with this tag will be hidden.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._db.add_blacklisted_tag(tag)
|
||||
self._db.set_setting("blacklist_enabled", "1")
|
||||
self._status.showMessage(f"Blacklisted: {tag}")
|
||||
self._search_ctrl.remove_blacklisted_from_grid(tag=tag)
|
||||
|
||||
def _blacklist_post_from_popout(self) -> None:
|
||||
post, idx = self._get_preview_post()
|
||||
if post:
|
||||
reply = QMessageBox.question(
|
||||
self, "Blacklist Post",
|
||||
f"Blacklist post #{post.id}?\nThis post will be hidden from results.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._db.add_blacklisted_post(post.file_url)
|
||||
self._status.showMessage(f"Post #{post.id} blacklisted")
|
||||
self._search_ctrl.remove_blacklisted_from_grid(post_url=post.file_url)
|
||||
|
||||
def _close_preview(self) -> None:
|
||||
self._preview.clear()
|
||||
|
||||
@ -1104,7 +940,7 @@ class BooruApp(QMainWindow):
|
||||
save_lib_new = save_lib_menu.addAction("+ New Folder...")
|
||||
|
||||
unsave_lib = None
|
||||
if self._is_post_saved(post.id):
|
||||
if self._post_actions.is_post_saved(post.id):
|
||||
unsave_lib = menu.addAction("Unsave from Library")
|
||||
copy_clipboard = menu.addAction("Copy File to Clipboard")
|
||||
copy_url = menu.addAction("Copy Image URL")
|
||||
@ -1122,7 +958,7 @@ class BooruApp(QMainWindow):
|
||||
bm_folder_actions: dict[int, str] = {}
|
||||
bm_unfiled = None
|
||||
bm_new = None
|
||||
if self._is_current_bookmarked(index):
|
||||
if self._post_actions.is_current_bookmarked(index):
|
||||
fav_action = menu.addAction("Remove Bookmark")
|
||||
else:
|
||||
fav_menu = menu.addMenu("Bookmark as")
|
||||
@ -1154,9 +990,9 @@ class BooruApp(QMainWindow):
|
||||
elif action == open_default:
|
||||
self._open_in_default(post)
|
||||
elif action == save_as:
|
||||
self._save_as(post)
|
||||
self._post_actions.save_as(post)
|
||||
elif action == save_lib_unsorted:
|
||||
self._save_to_library(post, None)
|
||||
self._post_actions.save_to_library(post, None)
|
||||
elif action == save_lib_new:
|
||||
from PySide6.QtWidgets import QInputDialog, QMessageBox
|
||||
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||||
@ -1170,12 +1006,12 @@ class BooruApp(QMainWindow):
|
||||
except ValueError as e:
|
||||
QMessageBox.warning(self, "Invalid Folder Name", str(e))
|
||||
return
|
||||
self._save_to_library(post, name.strip())
|
||||
self._post_actions.save_to_library(post, name.strip())
|
||||
elif id(action) in save_lib_folders:
|
||||
self._save_to_library(post, save_lib_folders[id(action)])
|
||||
self._post_actions.save_to_library(post, save_lib_folders[id(action)])
|
||||
elif action == unsave_lib:
|
||||
self._preview._current_post = post
|
||||
self._unsave_from_preview()
|
||||
self._post_actions.unsave_from_preview()
|
||||
elif action == copy_clipboard:
|
||||
self._copy_file_to_clipboard()
|
||||
elif action == copy_url:
|
||||
@ -1186,9 +1022,9 @@ class BooruApp(QMainWindow):
|
||||
self._status.showMessage("Tags copied")
|
||||
elif fav_action is not None and action == fav_action:
|
||||
# Currently bookmarked → flat "Remove Bookmark" path.
|
||||
self._toggle_bookmark(index)
|
||||
self._post_actions.toggle_bookmark(index)
|
||||
elif bm_unfiled is not None and action == bm_unfiled:
|
||||
self._toggle_bookmark(index, None)
|
||||
self._post_actions.toggle_bookmark(index, None)
|
||||
elif bm_new is not None and action == bm_new:
|
||||
from PySide6.QtWidgets import QInputDialog, QMessageBox
|
||||
name, ok = QInputDialog.getText(self, "New Bookmark Folder", "Folder name:")
|
||||
@ -1200,9 +1036,9 @@ class BooruApp(QMainWindow):
|
||||
except ValueError as e:
|
||||
QMessageBox.warning(self, "Invalid Folder Name", str(e))
|
||||
return
|
||||
self._toggle_bookmark(index, name.strip())
|
||||
self._post_actions.toggle_bookmark(index, name.strip())
|
||||
elif id(action) in bm_folder_actions:
|
||||
self._toggle_bookmark(index, bm_folder_actions[id(action)])
|
||||
self._post_actions.toggle_bookmark(index, bm_folder_actions[id(action)])
|
||||
elif self._is_child_of_menu(action, bl_menu):
|
||||
tag = action.text()
|
||||
self._db.add_blacklisted_tag(tag)
|
||||
@ -1253,8 +1089,8 @@ class BooruApp(QMainWindow):
|
||||
site_id = self._site_combo.currentData()
|
||||
any_bookmarked = bool(site_id) and any(self._db.is_bookmarked(site_id, p.id) for p in posts)
|
||||
any_unbookmarked = bool(site_id) and any(not self._db.is_bookmarked(site_id, p.id) for p in posts)
|
||||
any_saved = any(self._is_post_saved(p.id) for p in posts)
|
||||
any_unsaved = any(not self._is_post_saved(p.id) for p in posts)
|
||||
any_saved = any(self._post_actions.is_post_saved(p.id) for p in posts)
|
||||
any_unsaved = any(not self._post_actions.is_post_saved(p.id) for p in posts)
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
@ -1300,9 +1136,9 @@ class BooruApp(QMainWindow):
|
||||
return
|
||||
|
||||
if fav_all is not None and action == fav_all:
|
||||
self._bulk_bookmark(indices, posts)
|
||||
self._post_actions.bulk_bookmark(indices, posts)
|
||||
elif save_unsorted is not None and action == save_unsorted:
|
||||
self._bulk_save(indices, posts, None)
|
||||
self._post_actions.bulk_save(indices, posts, None)
|
||||
elif save_new is not None and action == save_new:
|
||||
from PySide6.QtWidgets import QInputDialog, QMessageBox
|
||||
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||||
@ -1313,16 +1149,16 @@ class BooruApp(QMainWindow):
|
||||
except ValueError as e:
|
||||
QMessageBox.warning(self, "Invalid Folder Name", str(e))
|
||||
return
|
||||
self._bulk_save(indices, posts, name.strip())
|
||||
self._post_actions.bulk_save(indices, posts, name.strip())
|
||||
elif id(action) in save_folder_actions:
|
||||
self._bulk_save(indices, posts, save_folder_actions[id(action)])
|
||||
self._post_actions.bulk_save(indices, posts, save_folder_actions[id(action)])
|
||||
elif unsave_lib_all is not None and action == unsave_lib_all:
|
||||
self._bulk_unsave(indices, posts)
|
||||
self._post_actions.bulk_unsave(indices, posts)
|
||||
elif action == batch_dl:
|
||||
from .dialogs import select_directory
|
||||
dest = select_directory(self, "Download to folder")
|
||||
if dest:
|
||||
self._batch_download_posts(posts, dest)
|
||||
self._post_actions.batch_download_posts(posts, dest)
|
||||
elif unfav_all is not None and action == unfav_all:
|
||||
if site_id:
|
||||
for post in posts:
|
||||
@ -1339,127 +1175,6 @@ class BooruApp(QMainWindow):
|
||||
QApplication.clipboard().setText(urls)
|
||||
self._status.showMessage(f"Copied {count} URLs")
|
||||
|
||||
def _bulk_bookmark(self, indices: list[int], posts: list[Post]) -> None:
|
||||
site_id = self._site_combo.currentData()
|
||||
if not site_id:
|
||||
return
|
||||
self._status.showMessage(f"Bookmarking {len(posts)}...")
|
||||
|
||||
async def _do():
|
||||
for i, (idx, post) in enumerate(zip(indices, posts)):
|
||||
if self._db.is_bookmarked(site_id, post.id):
|
||||
continue
|
||||
try:
|
||||
path = await download_image(post.file_url)
|
||||
self._db.add_bookmark(
|
||||
site_id=site_id, post_id=post.id,
|
||||
file_url=post.file_url, preview_url=post.preview_url,
|
||||
tags=post.tags, rating=post.rating, score=post.score,
|
||||
source=post.source, cached_path=str(path),
|
||||
tag_categories=post.tag_categories,
|
||||
)
|
||||
self._signals.bookmark_done.emit(idx, f"Bookmarked {i+1}/{len(posts)}")
|
||||
except Exception as e:
|
||||
log.warning(f"Operation failed: {e}")
|
||||
self._signals.batch_done.emit(f"Bookmarked {len(posts)} posts")
|
||||
|
||||
self._run_async(_do)
|
||||
|
||||
def _bulk_save(self, indices: list[int], posts: list[Post], folder: str | None) -> None:
|
||||
"""Bulk-save the selected posts into the library, optionally inside a subfolder.
|
||||
|
||||
Each iteration routes through save_post_file with a shared
|
||||
in_flight set so template-collision-prone batches (e.g.
|
||||
%artist% on a page that has many posts by the same artist) get
|
||||
sequential _1, _2, _3 suffixes instead of clobbering each other.
|
||||
"""
|
||||
from ..core.config import saved_dir, saved_folder_dir
|
||||
from ..core.library_save import save_post_file
|
||||
|
||||
where = folder or "Unfiled"
|
||||
self._status.showMessage(f"Saving {len(posts)} to {where}...")
|
||||
try:
|
||||
dest_dir = saved_folder_dir(folder) if folder else saved_dir()
|
||||
except ValueError as e:
|
||||
self._status.showMessage(f"Invalid folder name: {e}")
|
||||
return
|
||||
|
||||
in_flight: set[str] = set()
|
||||
|
||||
async def _do():
|
||||
fetcher = self._get_category_fetcher()
|
||||
for i, (idx, post) in enumerate(zip(indices, posts)):
|
||||
try:
|
||||
src = Path(await download_image(post.file_url))
|
||||
await save_post_file(src, post, dest_dir, self._db, in_flight, category_fetcher=fetcher)
|
||||
self._copy_library_thumb(post)
|
||||
self._signals.bookmark_done.emit(idx, f"Saved {i+1}/{len(posts)} to {where}")
|
||||
except Exception as e:
|
||||
log.warning(f"Bulk save #{post.id} failed: {e}")
|
||||
self._signals.batch_done.emit(f"Saved {len(posts)} to {where}")
|
||||
|
||||
self._run_async(_do)
|
||||
|
||||
def _bulk_unsave(self, indices: list[int], posts: list[Post]) -> None:
|
||||
"""Bulk-remove selected posts from the library.
|
||||
|
||||
Mirrors `_bulk_save` shape but synchronously — `delete_from_library`
|
||||
is a filesystem op, no httpx round-trip needed. Touches only the
|
||||
library (filesystem); bookmarks are a separate DB-backed concept
|
||||
and stay untouched. The grid's saved-locally dot clears for every
|
||||
selection slot regardless of whether the file was actually present
|
||||
— the user's intent is "make these not-saved", and a missing file
|
||||
is already not-saved.
|
||||
"""
|
||||
from ..core.cache import delete_from_library
|
||||
for post in posts:
|
||||
delete_from_library(post.id, db=self._db)
|
||||
for idx in indices:
|
||||
if 0 <= idx < len(self._grid._thumbs):
|
||||
self._grid._thumbs[idx].set_saved_locally(False)
|
||||
self._grid._clear_multi()
|
||||
self._status.showMessage(f"Removed {len(posts)} from library")
|
||||
if self._stack.currentIndex() == 2:
|
||||
self._library_view.refresh()
|
||||
self._popout_ctrl.update_state()
|
||||
|
||||
def _ensure_bookmarked(self, post: Post) -> None:
|
||||
"""Bookmark a post if not already bookmarked."""
|
||||
site_id = self._site_combo.currentData()
|
||||
if not site_id or self._db.is_bookmarked(site_id, post.id):
|
||||
return
|
||||
|
||||
async def _fav():
|
||||
try:
|
||||
path = await download_image(post.file_url)
|
||||
self._db.add_bookmark(
|
||||
site_id=site_id,
|
||||
post_id=post.id,
|
||||
file_url=post.file_url,
|
||||
preview_url=post.preview_url,
|
||||
tags=post.tags,
|
||||
rating=post.rating,
|
||||
score=post.score,
|
||||
source=post.source,
|
||||
cached_path=str(path),
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Operation failed: {e}")
|
||||
|
||||
self._run_async(_fav)
|
||||
|
||||
def _batch_download_posts(self, posts: list, dest: str) -> None:
|
||||
"""Multi-select Download All entry point. Delegates to
|
||||
_batch_download_to so the in_flight set, library_meta write,
|
||||
and saved-dots refresh share one implementation."""
|
||||
self._batch_download_to(posts, Path(dest))
|
||||
|
||||
def _is_current_bookmarked(self, index: int) -> bool:
|
||||
site_id = self._site_combo.currentData()
|
||||
if not site_id or index < 0 or index >= len(self._posts):
|
||||
return False
|
||||
return self._db.is_bookmarked(site_id, self._posts[index].id)
|
||||
|
||||
def _open_post_id_in_browser(self, post_id: int, site_id: int | None = None) -> None:
|
||||
"""Open the post page in the system browser. site_id selects which
|
||||
site's URL/api scheme to use; defaults to the currently selected
|
||||
@ -1500,162 +1215,8 @@ class BooruApp(QMainWindow):
|
||||
else:
|
||||
self._status.showMessage("Image not cached yet — double-click to download first")
|
||||
|
||||
def _copy_library_thumb(self, post: Post) -> None:
|
||||
"""Copy a post's browse thumbnail into the library thumbnail
|
||||
cache so the Library tab can paint it without re-downloading.
|
||||
No-op if there's no preview_url or the source thumb isn't cached."""
|
||||
if not post.preview_url:
|
||||
return
|
||||
from ..core.config import thumbnails_dir
|
||||
from ..core.cache import cached_path_for
|
||||
thumb_src = cached_path_for(post.preview_url, thumbnails_dir())
|
||||
if not thumb_src.exists():
|
||||
return
|
||||
lib_thumb_dir = thumbnails_dir() / "library"
|
||||
lib_thumb_dir.mkdir(parents=True, exist_ok=True)
|
||||
lib_thumb = lib_thumb_dir / f"{post.id}.jpg"
|
||||
if not lib_thumb.exists():
|
||||
import shutil
|
||||
shutil.copy2(thumb_src, lib_thumb)
|
||||
|
||||
def _save_to_library(self, post: Post, folder: str | None) -> None:
|
||||
"""Save a post into the library, optionally inside a subfolder.
|
||||
|
||||
Routes through the unified save_post_file flow so the filename
|
||||
template, sequential collision suffixes, same-post idempotency,
|
||||
and library_meta write are all handled in one place. Re-saving
|
||||
the same post into the same folder is a no-op (idempotent);
|
||||
saving into a different folder produces a second copy without
|
||||
touching the first.
|
||||
"""
|
||||
from ..core.config import saved_dir, saved_folder_dir
|
||||
from ..core.library_save import save_post_file
|
||||
|
||||
self._status.showMessage(f"Saving #{post.id} to library...")
|
||||
try:
|
||||
dest_dir = saved_folder_dir(folder) if folder else saved_dir()
|
||||
except ValueError as e:
|
||||
self._status.showMessage(f"Invalid folder name: {e}")
|
||||
return
|
||||
|
||||
async def _save():
|
||||
try:
|
||||
src = Path(await download_image(post.file_url))
|
||||
await save_post_file(src, post, dest_dir, self._db, category_fetcher=self._get_category_fetcher())
|
||||
self._copy_library_thumb(post)
|
||||
where = folder or "Unfiled"
|
||||
self._signals.bookmark_done.emit(
|
||||
self._grid.selected_index,
|
||||
f"Saved #{post.id} to {where}",
|
||||
)
|
||||
except Exception as e:
|
||||
self._signals.bookmark_error.emit(str(e))
|
||||
|
||||
self._run_async(_save)
|
||||
|
||||
def _save_as(self, post: Post) -> None:
|
||||
"""Open a Save As dialog for a single post and write the file
|
||||
through the unified save_post_file flow.
|
||||
|
||||
The default name in the dialog comes from rendering the user's
|
||||
library_filename_template against the post; the user can edit
|
||||
before confirming. If the chosen destination ends up inside
|
||||
saved_dir(), save_post_file registers a library_meta row —
|
||||
a behavior change from v0.2.3 (where Save As never wrote meta
|
||||
regardless of destination)."""
|
||||
from ..core.cache import cached_path_for
|
||||
from ..core.config import render_filename_template
|
||||
from ..core.library_save import save_post_file
|
||||
from .dialogs import save_file
|
||||
|
||||
src = cached_path_for(post.file_url)
|
||||
if not src.exists():
|
||||
self._status.showMessage("Image not cached — double-click to download first")
|
||||
return
|
||||
ext = src.suffix
|
||||
template = self._db.get_setting("library_filename_template")
|
||||
default_name = render_filename_template(template, post, ext)
|
||||
dest = save_file(self, "Save Image", default_name, f"Images (*{ext})")
|
||||
if not dest:
|
||||
return
|
||||
dest_path = Path(dest)
|
||||
|
||||
async def _do_save():
|
||||
try:
|
||||
actual = await save_post_file(
|
||||
src, post, dest_path.parent, self._db,
|
||||
explicit_name=dest_path.name,
|
||||
category_fetcher=self._get_category_fetcher(),
|
||||
)
|
||||
self._signals.bookmark_done.emit(
|
||||
self._grid.selected_index,
|
||||
f"Saved to {actual}",
|
||||
)
|
||||
except Exception as e:
|
||||
self._signals.bookmark_error.emit(f"Save failed: {e}")
|
||||
|
||||
self._run_async(_do_save)
|
||||
|
||||
# -- Batch download --
|
||||
|
||||
def _batch_download_to(self, posts: list[Post], dest_dir: Path) -> None:
|
||||
"""Download `posts` into `dest_dir`, routing each save through
|
||||
save_post_file with a shared in_flight set so collision-prone
|
||||
templates produce sequential _1, _2 suffixes within the batch.
|
||||
|
||||
Stashes `dest_dir` on `self._batch_dest` so _on_batch_progress
|
||||
and _on_batch_done can decide whether the destination is inside
|
||||
the library and the saved-dots need refreshing. The library_meta
|
||||
write happens automatically inside save_post_file when dest_dir
|
||||
is inside saved_dir() — fixes the v0.2.3 latent bug where batch
|
||||
downloads into a library folder left files unregistered.
|
||||
"""
|
||||
from ..core.library_save import save_post_file
|
||||
|
||||
self._batch_dest = dest_dir
|
||||
self._status.showMessage(f"Downloading {len(posts)} images...")
|
||||
in_flight: set[str] = set()
|
||||
|
||||
async def _batch():
|
||||
fetcher = self._get_category_fetcher()
|
||||
for i, post in enumerate(posts):
|
||||
try:
|
||||
src = Path(await download_image(post.file_url))
|
||||
await save_post_file(src, post, dest_dir, self._db, in_flight, category_fetcher=fetcher)
|
||||
self._signals.batch_progress.emit(i + 1, len(posts), post.id)
|
||||
except Exception as e:
|
||||
log.warning(f"Batch #{post.id} failed: {e}")
|
||||
self._signals.batch_done.emit(f"Downloaded {len(posts)} images to {dest_dir}")
|
||||
|
||||
self._run_async(_batch)
|
||||
|
||||
def _batch_download(self) -> None:
|
||||
if not self._posts:
|
||||
self._status.showMessage("No posts to download")
|
||||
return
|
||||
from .dialogs import select_directory
|
||||
dest = select_directory(self, "Download to folder")
|
||||
if not dest:
|
||||
return
|
||||
self._batch_download_to(list(self._posts), Path(dest))
|
||||
|
||||
def _on_batch_progress(self, current: int, total: int, post_id: int) -> None:
|
||||
self._status.showMessage(f"Downloading {current}/{total}...")
|
||||
# Light the browse saved-dot for the just-finished post if the
|
||||
# batch destination is inside the library. Runs per-post on the
|
||||
# main thread (this is a Qt slot), so the dot appears as the
|
||||
# files land instead of all at once when the batch completes.
|
||||
dest = getattr(self, "_batch_dest", None)
|
||||
if dest is None:
|
||||
return
|
||||
from ..core.config import saved_dir
|
||||
if not dest.is_relative_to(saved_dir()):
|
||||
return
|
||||
for i, p in enumerate(self._posts):
|
||||
if p.id == post_id and i < len(self._grid._thumbs):
|
||||
self._grid._thumbs[i].set_saved_locally(True)
|
||||
break
|
||||
|
||||
# -- Toggles --
|
||||
|
||||
def _toggle_log(self) -> None:
|
||||
@ -1747,7 +1308,7 @@ class BooruApp(QMainWindow):
|
||||
if key == Qt.Key.Key_F and self._posts:
|
||||
idx = self._grid.selected_index
|
||||
if 0 <= idx < len(self._posts):
|
||||
self._toggle_bookmark(idx)
|
||||
self._post_actions.toggle_bookmark(idx)
|
||||
return
|
||||
elif key == Qt.Key.Key_I:
|
||||
self._toggle_info()
|
||||
@ -1795,102 +1356,6 @@ class BooruApp(QMainWindow):
|
||||
|
||||
# -- Bookmarks --
|
||||
|
||||
def _toggle_bookmark(self, index: int, folder: str | None = None) -> None:
|
||||
"""Toggle the bookmark state of post at `index`.
|
||||
|
||||
When `folder` is given and the post is not yet bookmarked, the
|
||||
new bookmark is filed under that bookmark folder. The folder
|
||||
arg is ignored when removing — bookmark folder membership is
|
||||
moot if the bookmark itself is going away.
|
||||
"""
|
||||
post = self._posts[index]
|
||||
site_id = self._site_combo.currentData()
|
||||
if not site_id:
|
||||
return
|
||||
|
||||
if self._db.is_bookmarked(site_id, post.id):
|
||||
self._db.remove_bookmark(site_id, post.id)
|
||||
self._status.showMessage(f"Unbookmarked #{post.id}")
|
||||
thumbs = self._grid._thumbs
|
||||
if 0 <= index < len(thumbs):
|
||||
thumbs[index].set_bookmarked(False)
|
||||
else:
|
||||
self._status.showMessage(f"Bookmarking #{post.id}...")
|
||||
|
||||
async def _fav():
|
||||
try:
|
||||
path = await download_image(post.file_url)
|
||||
self._db.add_bookmark(
|
||||
site_id=site_id,
|
||||
post_id=post.id,
|
||||
file_url=post.file_url,
|
||||
preview_url=post.preview_url,
|
||||
tags=post.tags,
|
||||
rating=post.rating,
|
||||
score=post.score,
|
||||
source=post.source,
|
||||
cached_path=str(path),
|
||||
folder=folder,
|
||||
tag_categories=post.tag_categories,
|
||||
)
|
||||
where = folder or "Unfiled"
|
||||
self._signals.bookmark_done.emit(index, f"Bookmarked #{post.id} to {where}")
|
||||
except Exception as e:
|
||||
self._signals.bookmark_error.emit(str(e))
|
||||
|
||||
self._run_async(_fav)
|
||||
|
||||
def _on_bookmark_done(self, index: int, msg: str) -> None:
|
||||
self._status.showMessage(f"{len(self._posts)} results — {msg}")
|
||||
# Detect batch operations (e.g. "Saved 3/10 to Unfiled") — skip heavy updates
|
||||
is_batch = "/" in msg and any(c.isdigit() for c in msg.split("/")[0][-2:])
|
||||
thumbs = self._grid._thumbs
|
||||
if 0 <= index < len(thumbs):
|
||||
if "Saved" in msg:
|
||||
thumbs[index].set_saved_locally(True)
|
||||
if "Bookmarked" in msg:
|
||||
thumbs[index].set_bookmarked(True)
|
||||
if not is_batch:
|
||||
if "Bookmarked" in msg:
|
||||
self._preview.update_bookmark_state(True)
|
||||
if "Saved" in msg:
|
||||
self._preview.update_save_state(True)
|
||||
if self._stack.currentIndex() == 1:
|
||||
bm_grid = self._bookmarks_view._grid
|
||||
bm_idx = bm_grid.selected_index
|
||||
if 0 <= bm_idx < len(bm_grid._thumbs):
|
||||
bm_grid._thumbs[bm_idx].set_saved_locally(True)
|
||||
if self._stack.currentIndex() == 2:
|
||||
self._library_view.refresh()
|
||||
self._popout_ctrl.update_state()
|
||||
|
||||
def _on_library_files_deleted(self, post_ids: list) -> None:
|
||||
"""Library deleted files — clear saved dots on browse grid."""
|
||||
for i, p in enumerate(self._posts):
|
||||
if p.id in post_ids and i < len(self._grid._thumbs):
|
||||
self._grid._thumbs[i].set_saved_locally(False)
|
||||
|
||||
def _refresh_browse_saved_dots(self) -> None:
|
||||
"""Bookmarks changed — rescan saved state for all visible browse grid posts."""
|
||||
for i, p in enumerate(self._posts):
|
||||
if i < len(self._grid._thumbs):
|
||||
self._grid._thumbs[i].set_saved_locally(self._is_post_saved(p.id))
|
||||
site_id = self._site_combo.currentData()
|
||||
self._grid._thumbs[i].set_bookmarked(
|
||||
bool(site_id and self._db.is_bookmarked(site_id, p.id))
|
||||
)
|
||||
|
||||
def _on_batch_done(self, msg: str) -> None:
|
||||
self._status.showMessage(msg)
|
||||
self._popout_ctrl.update_state()
|
||||
if self._stack.currentIndex() == 1:
|
||||
self._bookmarks_view.refresh()
|
||||
if self._stack.currentIndex() == 2:
|
||||
self._library_view.refresh()
|
||||
# Saved-dot updates happen incrementally in _on_batch_progress as
|
||||
# each file lands; this slot just clears the destination stash.
|
||||
self._batch_dest = None
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
# Flush any pending splitter / window-state saves (debounce timers
|
||||
# may still be running if the user moved/resized within the last
|
||||
|
||||
@ -97,7 +97,7 @@ class MediaController:
|
||||
self._app._preview.update_bookmark_state(
|
||||
bool(site_id and self._app._db.is_bookmarked(site_id, post.id))
|
||||
)
|
||||
self._app._preview.update_save_state(self._app._is_post_saved(post.id))
|
||||
self._app._preview.update_save_state(self._app._post_actions.is_post_saved(post.id))
|
||||
self._app._status.showMessage(f"Loading #{post.id}...")
|
||||
preview_hidden = not (
|
||||
self._app._preview.isVisible() and self._app._preview.width() > 0
|
||||
|
||||
@ -95,14 +95,14 @@ class PopoutController:
|
||||
self._fullscreen_window.play_next_requested.connect(self._app._on_video_end_next)
|
||||
from ..core.config import library_folders
|
||||
self._fullscreen_window.set_folders_callback(library_folders)
|
||||
self._fullscreen_window.save_to_folder.connect(self._app._save_from_preview)
|
||||
self._fullscreen_window.unsave_requested.connect(self._app._unsave_from_preview)
|
||||
self._fullscreen_window.save_to_folder.connect(self._app._post_actions.save_from_preview)
|
||||
self._fullscreen_window.unsave_requested.connect(self._app._post_actions.unsave_from_preview)
|
||||
if show_actions:
|
||||
self._fullscreen_window.bookmark_requested.connect(self._app._bookmark_from_preview)
|
||||
self._fullscreen_window.bookmark_requested.connect(self._app._post_actions.bookmark_from_preview)
|
||||
self._fullscreen_window.set_bookmark_folders_callback(self._app._db.get_folders)
|
||||
self._fullscreen_window.bookmark_to_folder.connect(self._app._bookmark_to_folder_from_preview)
|
||||
self._fullscreen_window.blacklist_tag_requested.connect(self._app._blacklist_tag_from_popout)
|
||||
self._fullscreen_window.blacklist_post_requested.connect(self._app._blacklist_post_from_popout)
|
||||
self._fullscreen_window.bookmark_to_folder.connect(self._app._post_actions.bookmark_to_folder_from_preview)
|
||||
self._fullscreen_window.blacklist_tag_requested.connect(self._app._post_actions.blacklist_tag_from_popout)
|
||||
self._fullscreen_window.blacklist_post_requested.connect(self._app._post_actions.blacklist_post_from_popout)
|
||||
self._fullscreen_window.open_in_default.connect(self._app._open_preview_in_default)
|
||||
self._fullscreen_window.open_in_browser.connect(self._app._open_preview_in_browser)
|
||||
self._fullscreen_window.closed.connect(self.on_closed)
|
||||
|
||||
561
booru_viewer/gui/post_actions.py
Normal file
561
booru_viewer/gui/post_actions.py
Normal file
@ -0,0 +1,561 @@
|
||||
"""Bookmark, save/library, batch download, and blacklist operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
from ..core.cache import download_image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .main_window import BooruApp
|
||||
|
||||
log = logging.getLogger("booru")
|
||||
|
||||
|
||||
# Pure functions
|
||||
|
||||
def is_batch_message(msg: str) -> bool:
|
||||
"""Detect batch progress messages like 'Saved 3/10 to Unfiled'."""
|
||||
return "/" in msg and any(c.isdigit() for c in msg.split("/")[0][-2:])
|
||||
|
||||
def is_in_library(path: Path, saved_root: Path) -> bool:
|
||||
"""Check if path is inside the library root."""
|
||||
try:
|
||||
return path.is_relative_to(saved_root)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
class PostActionsController:
|
||||
def __init__(self, app: BooruApp) -> None:
|
||||
self._app = app
|
||||
self._batch_dest: Path | None = None
|
||||
|
||||
def on_bookmark_error(self, e: str) -> None:
|
||||
self._app._status.showMessage(f"Error: {e}")
|
||||
|
||||
def is_post_saved(self, post_id: int) -> bool:
|
||||
return self._app._db.is_post_in_library(post_id)
|
||||
|
||||
def get_preview_post(self):
|
||||
idx = self._app._grid.selected_index
|
||||
if 0 <= idx < len(self._app._posts):
|
||||
return self._app._posts[idx], idx
|
||||
if self._app._preview._current_post:
|
||||
return self._app._preview._current_post, -1
|
||||
return None, -1
|
||||
|
||||
def bookmark_from_preview(self) -> None:
|
||||
post, idx = self.get_preview_post()
|
||||
if not post:
|
||||
return
|
||||
site_id = self._app._preview._current_site_id or self._app._site_combo.currentData()
|
||||
if not site_id:
|
||||
return
|
||||
if idx >= 0:
|
||||
self.toggle_bookmark(idx)
|
||||
else:
|
||||
if self._app._db.is_bookmarked(site_id, post.id):
|
||||
self._app._db.remove_bookmark(site_id, post.id)
|
||||
else:
|
||||
from ..core.cache import cached_path_for
|
||||
cached = cached_path_for(post.file_url)
|
||||
self._app._db.add_bookmark(
|
||||
site_id=site_id, post_id=post.id,
|
||||
file_url=post.file_url, preview_url=post.preview_url or "",
|
||||
tags=post.tags, rating=post.rating, score=post.score,
|
||||
source=post.source, cached_path=str(cached) if cached.exists() else None,
|
||||
tag_categories=post.tag_categories,
|
||||
)
|
||||
bookmarked = bool(self._app._db.is_bookmarked(site_id, post.id))
|
||||
self._app._preview.update_bookmark_state(bookmarked)
|
||||
self._app._popout_ctrl.update_state()
|
||||
if self._app._stack.currentIndex() == 1:
|
||||
self._app._bookmarks_view.refresh()
|
||||
|
||||
def bookmark_to_folder_from_preview(self, folder: str) -> None:
|
||||
"""Bookmark the current preview post into a specific bookmark folder.
|
||||
|
||||
Triggered by the toolbar Bookmark-as submenu, which only shows
|
||||
when the post is not yet bookmarked -- so this method only handles
|
||||
the create path, never the move/remove paths. Empty string means
|
||||
Unfiled. Brand-new folder names get added to the DB folder list
|
||||
first so the bookmarks tab combo immediately shows them.
|
||||
"""
|
||||
post, idx = self.get_preview_post()
|
||||
if not post:
|
||||
return
|
||||
site_id = self._app._preview._current_site_id or self._app._site_combo.currentData()
|
||||
if not site_id:
|
||||
return
|
||||
target = folder if folder else None
|
||||
if target and target not in self._app._db.get_folders():
|
||||
try:
|
||||
self._app._db.add_folder(target)
|
||||
except ValueError as e:
|
||||
self._app._status.showMessage(f"Invalid folder name: {e}")
|
||||
return
|
||||
if idx >= 0:
|
||||
# In the grid -- go through toggle_bookmark so the grid
|
||||
# thumbnail's bookmark badge updates via on_bookmark_done.
|
||||
self.toggle_bookmark(idx, target)
|
||||
else:
|
||||
# Preview-only post (e.g. opened from the bookmarks tab while
|
||||
# browse is empty). Inline the add -- no grid index to update.
|
||||
from ..core.cache import cached_path_for
|
||||
cached = cached_path_for(post.file_url)
|
||||
self._app._db.add_bookmark(
|
||||
site_id=site_id, post_id=post.id,
|
||||
file_url=post.file_url, preview_url=post.preview_url or "",
|
||||
tags=post.tags, rating=post.rating, score=post.score,
|
||||
source=post.source,
|
||||
cached_path=str(cached) if cached.exists() else None,
|
||||
folder=target,
|
||||
tag_categories=post.tag_categories,
|
||||
)
|
||||
where = target or "Unfiled"
|
||||
self._app._status.showMessage(f"Bookmarked #{post.id} to {where}")
|
||||
self._app._preview.update_bookmark_state(True)
|
||||
self._app._popout_ctrl.update_state()
|
||||
# Refresh bookmarks tab if visible so the new entry appears.
|
||||
if self._app._stack.currentIndex() == 1:
|
||||
self._app._bookmarks_view.refresh()
|
||||
|
||||
def save_from_preview(self, folder: str) -> None:
|
||||
post, idx = self.get_preview_post()
|
||||
if post:
|
||||
target = folder if folder else None
|
||||
self.save_to_library(post, target)
|
||||
|
||||
def unsave_from_preview(self) -> None:
|
||||
post, idx = self.get_preview_post()
|
||||
if not post:
|
||||
return
|
||||
# delete_from_library walks every library folder by post id and
|
||||
# deletes every match in one call -- no folder hint needed. Pass
|
||||
# db so templated filenames also get unlinked AND the meta row
|
||||
# gets cleaned up.
|
||||
from ..core.cache import delete_from_library
|
||||
deleted = delete_from_library(post.id, db=self._app._db)
|
||||
if deleted:
|
||||
self._app._status.showMessage(f"Removed #{post.id} from library")
|
||||
self._app._preview.update_save_state(False)
|
||||
# Update browse grid thumbnail saved dot
|
||||
for i, p in enumerate(self._app._posts):
|
||||
if p.id == post.id and i < len(self._app._grid._thumbs):
|
||||
self._app._grid._thumbs[i].set_saved_locally(False)
|
||||
break
|
||||
# Update bookmarks grid thumbnail
|
||||
bm_grid = self._app._bookmarks_view._grid
|
||||
for i, fav in enumerate(self._app._bookmarks_view._bookmarks):
|
||||
if fav.post_id == post.id and i < len(bm_grid._thumbs):
|
||||
bm_grid._thumbs[i].set_saved_locally(False)
|
||||
break
|
||||
# Refresh library tab if visible
|
||||
if self._app._stack.currentIndex() == 2:
|
||||
self._app._library_view.refresh()
|
||||
else:
|
||||
self._app._status.showMessage(f"#{post.id} not in library")
|
||||
self._app._popout_ctrl.update_state()
|
||||
|
||||
def blacklist_tag_from_popout(self, tag: str) -> None:
|
||||
reply = QMessageBox.question(
|
||||
self._app, "Blacklist Tag",
|
||||
f"Blacklist tag \"{tag}\"?\nPosts with this tag will be hidden.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._app._db.add_blacklisted_tag(tag)
|
||||
self._app._db.set_setting("blacklist_enabled", "1")
|
||||
self._app._status.showMessage(f"Blacklisted: {tag}")
|
||||
self._app._search_ctrl.remove_blacklisted_from_grid(tag=tag)
|
||||
|
||||
def blacklist_post_from_popout(self) -> None:
|
||||
post, idx = self.get_preview_post()
|
||||
if post:
|
||||
reply = QMessageBox.question(
|
||||
self._app, "Blacklist Post",
|
||||
f"Blacklist post #{post.id}?\nThis post will be hidden from results.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._app._db.add_blacklisted_post(post.file_url)
|
||||
self._app._status.showMessage(f"Post #{post.id} blacklisted")
|
||||
self._app._search_ctrl.remove_blacklisted_from_grid(post_url=post.file_url)
|
||||
|
||||
def toggle_bookmark(self, index: int, folder: str | None = None) -> None:
|
||||
"""Toggle the bookmark state of post at `index`.
|
||||
|
||||
When `folder` is given and the post is not yet bookmarked, the
|
||||
new bookmark is filed under that bookmark folder. The folder
|
||||
arg is ignored when removing -- bookmark folder membership is
|
||||
moot if the bookmark itself is going away.
|
||||
"""
|
||||
post = self._app._posts[index]
|
||||
site_id = self._app._site_combo.currentData()
|
||||
if not site_id:
|
||||
return
|
||||
|
||||
if self._app._db.is_bookmarked(site_id, post.id):
|
||||
self._app._db.remove_bookmark(site_id, post.id)
|
||||
self._app._status.showMessage(f"Unbookmarked #{post.id}")
|
||||
thumbs = self._app._grid._thumbs
|
||||
if 0 <= index < len(thumbs):
|
||||
thumbs[index].set_bookmarked(False)
|
||||
else:
|
||||
self._app._status.showMessage(f"Bookmarking #{post.id}...")
|
||||
|
||||
async def _fav():
|
||||
try:
|
||||
path = await download_image(post.file_url)
|
||||
self._app._db.add_bookmark(
|
||||
site_id=site_id,
|
||||
post_id=post.id,
|
||||
file_url=post.file_url,
|
||||
preview_url=post.preview_url,
|
||||
tags=post.tags,
|
||||
rating=post.rating,
|
||||
score=post.score,
|
||||
source=post.source,
|
||||
cached_path=str(path),
|
||||
folder=folder,
|
||||
tag_categories=post.tag_categories,
|
||||
)
|
||||
where = folder or "Unfiled"
|
||||
self._app._signals.bookmark_done.emit(index, f"Bookmarked #{post.id} to {where}")
|
||||
except Exception as e:
|
||||
self._app._signals.bookmark_error.emit(str(e))
|
||||
|
||||
self._app._run_async(_fav)
|
||||
|
||||
def bulk_bookmark(self, indices: list[int], posts: list) -> None:
|
||||
site_id = self._app._site_combo.currentData()
|
||||
if not site_id:
|
||||
return
|
||||
self._app._status.showMessage(f"Bookmarking {len(posts)}...")
|
||||
|
||||
async def _do():
|
||||
for i, (idx, post) in enumerate(zip(indices, posts)):
|
||||
if self._app._db.is_bookmarked(site_id, post.id):
|
||||
continue
|
||||
try:
|
||||
path = await download_image(post.file_url)
|
||||
self._app._db.add_bookmark(
|
||||
site_id=site_id, post_id=post.id,
|
||||
file_url=post.file_url, preview_url=post.preview_url,
|
||||
tags=post.tags, rating=post.rating, score=post.score,
|
||||
source=post.source, cached_path=str(path),
|
||||
tag_categories=post.tag_categories,
|
||||
)
|
||||
self._app._signals.bookmark_done.emit(idx, f"Bookmarked {i+1}/{len(posts)}")
|
||||
except Exception as e:
|
||||
log.warning(f"Operation failed: {e}")
|
||||
self._app._signals.batch_done.emit(f"Bookmarked {len(posts)} posts")
|
||||
|
||||
self._app._run_async(_do)
|
||||
|
||||
def bulk_save(self, indices: list[int], posts: list, folder: str | None) -> None:
|
||||
"""Bulk-save the selected posts into the library, optionally inside a subfolder.
|
||||
|
||||
Each iteration routes through save_post_file with a shared
|
||||
in_flight set so template-collision-prone batches (e.g.
|
||||
%artist% on a page that has many posts by the same artist) get
|
||||
sequential _1, _2, _3 suffixes instead of clobbering each other.
|
||||
"""
|
||||
from ..core.config import saved_dir, saved_folder_dir
|
||||
from ..core.library_save import save_post_file
|
||||
|
||||
where = folder or "Unfiled"
|
||||
self._app._status.showMessage(f"Saving {len(posts)} to {where}...")
|
||||
try:
|
||||
dest_dir = saved_folder_dir(folder) if folder else saved_dir()
|
||||
except ValueError as e:
|
||||
self._app._status.showMessage(f"Invalid folder name: {e}")
|
||||
return
|
||||
|
||||
in_flight: set[str] = set()
|
||||
|
||||
async def _do():
|
||||
fetcher = self._app._get_category_fetcher()
|
||||
for i, (idx, post) in enumerate(zip(indices, posts)):
|
||||
try:
|
||||
src = Path(await download_image(post.file_url))
|
||||
await save_post_file(src, post, dest_dir, self._app._db, in_flight, category_fetcher=fetcher)
|
||||
self.copy_library_thumb(post)
|
||||
self._app._signals.bookmark_done.emit(idx, f"Saved {i+1}/{len(posts)} to {where}")
|
||||
except Exception as e:
|
||||
log.warning(f"Bulk save #{post.id} failed: {e}")
|
||||
self._app._signals.batch_done.emit(f"Saved {len(posts)} to {where}")
|
||||
|
||||
self._app._run_async(_do)
|
||||
|
||||
def bulk_unsave(self, indices: list[int], posts: list) -> None:
|
||||
"""Bulk-remove selected posts from the library.
|
||||
|
||||
Mirrors `bulk_save` shape but synchronously -- `delete_from_library`
|
||||
is a filesystem op, no httpx round-trip needed. Touches only the
|
||||
library (filesystem); bookmarks are a separate DB-backed concept
|
||||
and stay untouched. The grid's saved-locally dot clears for every
|
||||
selection slot regardless of whether the file was actually present
|
||||
-- the user's intent is "make these not-saved", and a missing file
|
||||
is already not-saved.
|
||||
"""
|
||||
from ..core.cache import delete_from_library
|
||||
for post in posts:
|
||||
delete_from_library(post.id, db=self._app._db)
|
||||
for idx in indices:
|
||||
if 0 <= idx < len(self._app._grid._thumbs):
|
||||
self._app._grid._thumbs[idx].set_saved_locally(False)
|
||||
self._app._grid._clear_multi()
|
||||
self._app._status.showMessage(f"Removed {len(posts)} from library")
|
||||
if self._app._stack.currentIndex() == 2:
|
||||
self._app._library_view.refresh()
|
||||
self._app._popout_ctrl.update_state()
|
||||
|
||||
def ensure_bookmarked(self, post) -> None:
|
||||
"""Bookmark a post if not already bookmarked."""
|
||||
site_id = self._app._site_combo.currentData()
|
||||
if not site_id or self._app._db.is_bookmarked(site_id, post.id):
|
||||
return
|
||||
|
||||
async def _fav():
|
||||
try:
|
||||
path = await download_image(post.file_url)
|
||||
self._app._db.add_bookmark(
|
||||
site_id=site_id,
|
||||
post_id=post.id,
|
||||
file_url=post.file_url,
|
||||
preview_url=post.preview_url,
|
||||
tags=post.tags,
|
||||
rating=post.rating,
|
||||
score=post.score,
|
||||
source=post.source,
|
||||
cached_path=str(path),
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Operation failed: {e}")
|
||||
|
||||
self._app._run_async(_fav)
|
||||
|
||||
def batch_download_posts(self, posts: list, dest: str) -> None:
|
||||
"""Multi-select Download All entry point. Delegates to
|
||||
batch_download_to so the in_flight set, library_meta write,
|
||||
and saved-dots refresh share one implementation."""
|
||||
self.batch_download_to(posts, Path(dest))
|
||||
|
||||
def batch_download_to(self, posts: list, dest_dir: Path) -> None:
|
||||
"""Download `posts` into `dest_dir`, routing each save through
|
||||
save_post_file with a shared in_flight set so collision-prone
|
||||
templates produce sequential _1, _2 suffixes within the batch.
|
||||
|
||||
Stashes `dest_dir` on `self._batch_dest` so on_batch_progress
|
||||
and on_batch_done can decide whether the destination is inside
|
||||
the library and the saved-dots need refreshing. The library_meta
|
||||
write happens automatically inside save_post_file when dest_dir
|
||||
is inside saved_dir() -- fixes the v0.2.3 latent bug where batch
|
||||
downloads into a library folder left files unregistered.
|
||||
"""
|
||||
from ..core.library_save import save_post_file
|
||||
|
||||
self._batch_dest = dest_dir
|
||||
self._app._status.showMessage(f"Downloading {len(posts)} images...")
|
||||
in_flight: set[str] = set()
|
||||
|
||||
async def _batch():
|
||||
fetcher = self._app._get_category_fetcher()
|
||||
for i, post in enumerate(posts):
|
||||
try:
|
||||
src = Path(await download_image(post.file_url))
|
||||
await save_post_file(src, post, dest_dir, self._app._db, in_flight, category_fetcher=fetcher)
|
||||
self._app._signals.batch_progress.emit(i + 1, len(posts), post.id)
|
||||
except Exception as e:
|
||||
log.warning(f"Batch #{post.id} failed: {e}")
|
||||
self._app._signals.batch_done.emit(f"Downloaded {len(posts)} images to {dest_dir}")
|
||||
|
||||
self._app._run_async(_batch)
|
||||
|
||||
def batch_download(self) -> None:
|
||||
if not self._app._posts:
|
||||
self._app._status.showMessage("No posts to download")
|
||||
return
|
||||
from .dialogs import select_directory
|
||||
dest = select_directory(self._app, "Download to folder")
|
||||
if not dest:
|
||||
return
|
||||
self.batch_download_to(list(self._app._posts), Path(dest))
|
||||
|
||||
def is_current_bookmarked(self, index: int) -> bool:
|
||||
site_id = self._app._site_combo.currentData()
|
||||
if not site_id or index < 0 or index >= len(self._app._posts):
|
||||
return False
|
||||
return self._app._db.is_bookmarked(site_id, self._app._posts[index].id)
|
||||
|
||||
def copy_library_thumb(self, post) -> None:
|
||||
"""Copy a post's browse thumbnail into the library thumbnail
|
||||
cache so the Library tab can paint it without re-downloading.
|
||||
No-op if there's no preview_url or the source thumb isn't cached."""
|
||||
if not post.preview_url:
|
||||
return
|
||||
from ..core.config import thumbnails_dir
|
||||
from ..core.cache import cached_path_for
|
||||
thumb_src = cached_path_for(post.preview_url, thumbnails_dir())
|
||||
if not thumb_src.exists():
|
||||
return
|
||||
lib_thumb_dir = thumbnails_dir() / "library"
|
||||
lib_thumb_dir.mkdir(parents=True, exist_ok=True)
|
||||
lib_thumb = lib_thumb_dir / f"{post.id}.jpg"
|
||||
if not lib_thumb.exists():
|
||||
import shutil
|
||||
shutil.copy2(thumb_src, lib_thumb)
|
||||
|
||||
def save_to_library(self, post, folder: str | None) -> None:
|
||||
"""Save a post into the library, optionally inside a subfolder.
|
||||
|
||||
Routes through the unified save_post_file flow so the filename
|
||||
template, sequential collision suffixes, same-post idempotency,
|
||||
and library_meta write are all handled in one place. Re-saving
|
||||
the same post into the same folder is a no-op (idempotent);
|
||||
saving into a different folder produces a second copy without
|
||||
touching the first.
|
||||
"""
|
||||
from ..core.config import saved_dir, saved_folder_dir
|
||||
from ..core.library_save import save_post_file
|
||||
|
||||
self._app._status.showMessage(f"Saving #{post.id} to library...")
|
||||
try:
|
||||
dest_dir = saved_folder_dir(folder) if folder else saved_dir()
|
||||
except ValueError as e:
|
||||
self._app._status.showMessage(f"Invalid folder name: {e}")
|
||||
return
|
||||
|
||||
async def _save():
|
||||
try:
|
||||
src = Path(await download_image(post.file_url))
|
||||
await save_post_file(src, post, dest_dir, self._app._db, category_fetcher=self._app._get_category_fetcher())
|
||||
self.copy_library_thumb(post)
|
||||
where = folder or "Unfiled"
|
||||
self._app._signals.bookmark_done.emit(
|
||||
self._app._grid.selected_index,
|
||||
f"Saved #{post.id} to {where}",
|
||||
)
|
||||
except Exception as e:
|
||||
self._app._signals.bookmark_error.emit(str(e))
|
||||
|
||||
self._app._run_async(_save)
|
||||
|
||||
def save_as(self, post) -> None:
|
||||
"""Open a Save As dialog for a single post and write the file
|
||||
through the unified save_post_file flow.
|
||||
|
||||
The default name in the dialog comes from rendering the user's
|
||||
library_filename_template against the post; the user can edit
|
||||
before confirming. If the chosen destination ends up inside
|
||||
saved_dir(), save_post_file registers a library_meta row --
|
||||
a behavior change from v0.2.3 (where Save As never wrote meta
|
||||
regardless of destination)."""
|
||||
from ..core.cache import cached_path_for
|
||||
from ..core.config import render_filename_template
|
||||
from ..core.library_save import save_post_file
|
||||
from .dialogs import save_file
|
||||
|
||||
src = cached_path_for(post.file_url)
|
||||
if not src.exists():
|
||||
self._app._status.showMessage("Image not cached — double-click to download first")
|
||||
return
|
||||
ext = src.suffix
|
||||
template = self._app._db.get_setting("library_filename_template")
|
||||
default_name = render_filename_template(template, post, ext)
|
||||
dest = save_file(self._app, "Save Image", default_name, f"Images (*{ext})")
|
||||
if not dest:
|
||||
return
|
||||
dest_path = Path(dest)
|
||||
|
||||
async def _do_save():
|
||||
try:
|
||||
actual = await save_post_file(
|
||||
src, post, dest_path.parent, self._app._db,
|
||||
explicit_name=dest_path.name,
|
||||
category_fetcher=self._app._get_category_fetcher(),
|
||||
)
|
||||
self._app._signals.bookmark_done.emit(
|
||||
self._app._grid.selected_index,
|
||||
f"Saved to {actual}",
|
||||
)
|
||||
except Exception as e:
|
||||
self._app._signals.bookmark_error.emit(f"Save failed: {e}")
|
||||
|
||||
self._app._run_async(_do_save)
|
||||
|
||||
def on_bookmark_done(self, index: int, msg: str) -> None:
|
||||
self._app._status.showMessage(f"{len(self._app._posts)} results — {msg}")
|
||||
# Detect batch operations (e.g. "Saved 3/10 to Unfiled") -- skip heavy updates
|
||||
is_batch = is_batch_message(msg)
|
||||
thumbs = self._app._grid._thumbs
|
||||
if 0 <= index < len(thumbs):
|
||||
if "Saved" in msg:
|
||||
thumbs[index].set_saved_locally(True)
|
||||
if "Bookmarked" in msg:
|
||||
thumbs[index].set_bookmarked(True)
|
||||
if not is_batch:
|
||||
if "Bookmarked" in msg:
|
||||
self._app._preview.update_bookmark_state(True)
|
||||
if "Saved" in msg:
|
||||
self._app._preview.update_save_state(True)
|
||||
if self._app._stack.currentIndex() == 1:
|
||||
bm_grid = self._app._bookmarks_view._grid
|
||||
bm_idx = bm_grid.selected_index
|
||||
if 0 <= bm_idx < len(bm_grid._thumbs):
|
||||
bm_grid._thumbs[bm_idx].set_saved_locally(True)
|
||||
if self._app._stack.currentIndex() == 2:
|
||||
self._app._library_view.refresh()
|
||||
self._app._popout_ctrl.update_state()
|
||||
|
||||
def on_batch_progress(self, current: int, total: int, post_id: int) -> None:
|
||||
self._app._status.showMessage(f"Downloading {current}/{total}...")
|
||||
# Light the browse saved-dot for the just-finished post if the
|
||||
# batch destination is inside the library. Runs per-post on the
|
||||
# main thread (this is a Qt slot), so the dot appears as the
|
||||
# files land instead of all at once when the batch completes.
|
||||
dest = self._batch_dest
|
||||
if dest is None:
|
||||
return
|
||||
from ..core.config import saved_dir
|
||||
if not is_in_library(dest, saved_dir()):
|
||||
return
|
||||
for i, p in enumerate(self._app._posts):
|
||||
if p.id == post_id and i < len(self._app._grid._thumbs):
|
||||
self._app._grid._thumbs[i].set_saved_locally(True)
|
||||
break
|
||||
|
||||
def on_batch_done(self, msg: str) -> None:
|
||||
self._app._status.showMessage(msg)
|
||||
self._app._popout_ctrl.update_state()
|
||||
if self._app._stack.currentIndex() == 1:
|
||||
self._app._bookmarks_view.refresh()
|
||||
if self._app._stack.currentIndex() == 2:
|
||||
self._app._library_view.refresh()
|
||||
# Saved-dot updates happen incrementally in on_batch_progress as
|
||||
# each file lands; this slot just clears the destination stash.
|
||||
self._batch_dest = None
|
||||
|
||||
def on_library_files_deleted(self, post_ids: list) -> None:
|
||||
"""Library deleted files -- clear saved dots on browse grid."""
|
||||
for i, p in enumerate(self._app._posts):
|
||||
if p.id in post_ids and i < len(self._app._grid._thumbs):
|
||||
self._app._grid._thumbs[i].set_saved_locally(False)
|
||||
|
||||
def refresh_browse_saved_dots(self) -> None:
|
||||
"""Bookmarks changed -- rescan saved state for all visible browse grid posts."""
|
||||
for i, p in enumerate(self._app._posts):
|
||||
if i < len(self._app._grid._thumbs):
|
||||
self._app._grid._thumbs[i].set_saved_locally(self.is_post_saved(p.id))
|
||||
site_id = self._app._site_combo.currentData()
|
||||
self._app._grid._thumbs[i].set_bookmarked(
|
||||
bool(site_id and self._app._db.is_bookmarked(site_id, p.id))
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user