diff --git a/booru_viewer/gui/main_window.py b/booru_viewer/gui/main_window.py index a6eb441..9895831 100644 --- a/booru_viewer/gui/main_window.py +++ b/booru_viewer/gui/main_window.py @@ -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 diff --git a/booru_viewer/gui/media_controller.py b/booru_viewer/gui/media_controller.py index 17b170f..cb0ecdd 100644 --- a/booru_viewer/gui/media_controller.py +++ b/booru_viewer/gui/media_controller.py @@ -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 diff --git a/booru_viewer/gui/popout_controller.py b/booru_viewer/gui/popout_controller.py index 0672443..9ac38b2 100644 --- a/booru_viewer/gui/popout_controller.py +++ b/booru_viewer/gui/popout_controller.py @@ -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) diff --git a/booru_viewer/gui/post_actions.py b/booru_viewer/gui/post_actions.py new file mode 100644 index 0000000..31ccc0a --- /dev/null +++ b/booru_viewer/gui/post_actions.py @@ -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)) + )