"""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)) )