Browse multi-select: split library + bookmark actions, conditional visibility

The browse grid's multi-select right-click menu collapsed library and
bookmark actions into a single "Remove All Bookmarks" entry that did
*both* — it called delete_from_library and remove_bookmark per post,
and was unconditionally visible regardless of selection state. Two
problems:

1. There was no way to bulk-unsave files from the library without
   also stripping the bookmarks. Saved-but-not-bookmarked posts had
   no bulk-unsave path at all.
2. The single misleadingly-named action didn't match the single-post
   right-click menu's clean separation of "Save to Library / Unsave
   from Library" vs. "Bookmark as / Remove Bookmark".

Reshape: split into four distinct actions, each with symmetric
conditional visibility:

  - Save All to Library     → shown only if any post is unsaved
  - Unsave All from Library → shown only if any post is saved (NEW)
  - Bookmark All            → shown only if any post is unbookmarked
  - Remove All Bookmarks    → shown only if any post is bookmarked

Mixed selections show whichever subset of the four is relevant. The
new Unsave All from Library calls a new _bulk_unsave method that
mirrors the _bulk_save shape but synchronously (delete_from_library
is a filesystem op, no httpx round-trip). Remove All Bookmarks now
*only* removes bookmarks — it no longer touches the library, matching
the single-post Remove Bookmark action's scope.

Always-shown actions (Download All, Copy All URLs) stay below a
separator at the bottom.

Verified:
  - Multi-select unbookmarked+unsaved posts → only Save All / Bookmark All
  - Multi-select saved-not-bookmarked → only Unsave All / Bookmark All
  - Multi-select bookmarked+saved → only Unsave All / Remove All Bookmarks
  - Mixed selection → all four appear
  - Unsave All from Library removes files, leaves bookmarks
  - Remove All Bookmarks removes bookmarks, leaves files
This commit is contained in:
pax 2026-04-08 15:59:46 -05:00
parent c4efdb76f8
commit db774fc33e

View File

@ -2411,27 +2411,64 @@ class BooruApp(QMainWindow):
return False return False
def _on_multi_context_menu(self, indices: list, pos) -> None: def _on_multi_context_menu(self, indices: list, pos) -> None:
"""Context menu for multi-selected posts.""" """Context menu for multi-selected posts.
Library and bookmark actions are split into independent
save/unsave and bookmark/remove-bookmark pairs (mirroring the
single-post menu's separation), with symmetric conditional
visibility: each action only appears when the selection actually
contains posts the action would affect. Save All to Library
appears only when at least one post is unsaved; Unsave All from
Library only when at least one is saved; Bookmark All only when
at least one is unbookmarked; Remove All Bookmarks only when at
least one is bookmarked.
"""
posts = [self._posts[i] for i in indices if 0 <= i < len(self._posts)] posts = [self._posts[i] for i in indices if 0 <= i < len(self._posts)]
if not posts: if not posts:
return return
count = len(posts) count = len(posts)
menu = QMenu(self) site_id = self._site_combo.currentData()
fav_all = menu.addAction(f"Bookmark All ({count})") 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)
menu = QMenu(self)
# Library section
save_menu = None
save_unsorted = None
save_new = None
save_folder_actions: dict[int, str] = {}
if any_unsaved:
from ..core.config import library_folders
save_menu = menu.addMenu(f"Save All to Library ({count})") save_menu = menu.addMenu(f"Save All to Library ({count})")
save_unsorted = save_menu.addAction("Unfiled") save_unsorted = save_menu.addAction("Unfiled")
save_folder_actions = {}
from ..core.config import library_folders
for folder in library_folders(): for folder in library_folders():
a = save_menu.addAction(folder) a = save_menu.addAction(folder)
save_folder_actions[id(a)] = folder save_folder_actions[id(a)] = folder
save_menu.addSeparator() save_menu.addSeparator()
save_new = save_menu.addAction("+ New Folder...") save_new = save_menu.addAction("+ New Folder...")
unsave_lib_all = None
if any_saved:
unsave_lib_all = menu.addAction(f"Unsave All from Library ({count})")
# Bookmark section
if (any_unsaved or any_saved) and (any_unbookmarked or any_bookmarked):
menu.addSeparator() menu.addSeparator()
fav_all = None
if any_unbookmarked:
fav_all = menu.addAction(f"Bookmark All ({count})")
unfav_all = None
if any_bookmarked:
unfav_all = menu.addAction(f"Remove All Bookmarks ({count})") unfav_all = menu.addAction(f"Remove All Bookmarks ({count})")
# Always-shown actions
if any_unsaved or any_saved or any_unbookmarked or any_bookmarked:
menu.addSeparator() menu.addSeparator()
batch_dl = menu.addAction(f"Download All ({count})...") batch_dl = menu.addAction(f"Download All ({count})...")
copy_urls = menu.addAction("Copy All URLs") copy_urls = menu.addAction("Copy All URLs")
@ -2440,11 +2477,11 @@ class BooruApp(QMainWindow):
if not action: if not action:
return return
if action == fav_all: if fav_all is not None and action == fav_all:
self._bulk_bookmark(indices, posts) self._bulk_bookmark(indices, posts)
elif action == save_unsorted: elif save_unsorted is not None and action == save_unsorted:
self._bulk_save(indices, posts, None) self._bulk_save(indices, posts, None)
elif action == save_new: elif save_new is not None and action == save_new:
from PySide6.QtWidgets import QInputDialog, QMessageBox from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip(): if ok and name.strip():
@ -2457,25 +2494,24 @@ class BooruApp(QMainWindow):
self._bulk_save(indices, posts, name.strip()) self._bulk_save(indices, posts, name.strip())
elif id(action) in save_folder_actions: elif id(action) in save_folder_actions:
self._bulk_save(indices, posts, save_folder_actions[id(action)]) self._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)
elif action == batch_dl: elif action == batch_dl:
from .dialogs import select_directory from .dialogs import select_directory
dest = select_directory(self, "Download to folder") dest = select_directory(self, "Download to folder")
if dest: if dest:
self._batch_download_posts(posts, dest) self._batch_download_posts(posts, dest)
elif action == unfav_all: elif unfav_all is not None and action == unfav_all:
site_id = self._site_combo.currentData()
if site_id: if site_id:
from ..core.cache import delete_from_library
for post in posts: for post in posts:
# Single call now walks every library folder by post id.
delete_from_library(post.id)
self._db.remove_bookmark(site_id, post.id) self._db.remove_bookmark(site_id, post.id)
for idx in indices: for idx in indices:
if 0 <= idx < len(self._grid._thumbs): if 0 <= idx < len(self._grid._thumbs):
self._grid._thumbs[idx].set_bookmarked(False) self._grid._thumbs[idx].set_bookmarked(False)
self._grid._thumbs[idx].set_saved_locally(False)
self._grid._clear_multi() self._grid._clear_multi()
self._status.showMessage(f"Removed {count} bookmarks") self._status.showMessage(f"Removed {count} bookmarks")
if self._stack.currentIndex() == 1:
self._bookmarks_view.refresh()
elif action == copy_urls: elif action == copy_urls:
urls = "\n".join(p.file_url for p in posts) urls = "\n".join(p.file_url for p in posts)
QApplication.clipboard().setText(urls) QApplication.clipboard().setText(urls)
@ -2537,6 +2573,29 @@ class BooruApp(QMainWindow):
self._run_async(_do) 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)
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._update_fullscreen_state()
def _ensure_bookmarked(self, post: Post) -> None: def _ensure_bookmarked(self, post: Post) -> None:
"""Bookmark a post if not already bookmarked.""" """Bookmark a post if not already bookmarked."""
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()