From 250b1448066c8145cde9a307be05b097f29d13f4 Mon Sep 17 00:00:00 2001 From: pax Date: Tue, 7 Apr 2026 19:50:39 -0500 Subject: [PATCH] Decouple bookmark folders from library folders, add move-aware save + submenu pickers everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bookmark folders and library folders used to share identity through _db.get_folders() — the same string was both a row in favorite_folders and a directory under saved_dir. They look like one concept but they're two stores, and the cross-bleed produced a duplicate-on-move bug and made "Save to Library" silently re-file the bookmark too. Now they're independent name spaces: - library_folders() in core.config reads filesystem subdirs of saved_dir; the source of truth for every Save-to-Library menu - find_library_files(post_id) walks the library shallowly and is the new "is this saved?" / delete primitive - bookmark folders stay DB-backed and are only used for bookmark organization (filter combo, Move to Folder) - delete_from_library no longer takes a folder hint — walks every library folder by post id and deletes every match (also cleans up duplicates left by the old save-to-folder copy bug) - _save_to_library is move-aware: if the post is already in another library folder, atomic Path.rename() into the destination instead of re-copying from cache (the duplicate bug fix) - bookmark "Move to Folder" no longer also calls _copy_to_library; Save to Library no longer also calls move_bookmark_to_folder - settings export/import unchanged; favorite_folders table preserved so no migration UI additions: - Library tab right-click: Move to Folder submenu (single + multi), uses Path.rename for atomic moves - Bookmarks tab: − Folder button next to + Folder for deleting the selected bookmark folder (DB-only, library filesystem untouched) - Browse tab right-click: "Bookmark" replaced with "Bookmark as" submenu when not yet bookmarked (Unfiled / folders / + New); flat "Remove Bookmark" when already bookmarked - Embedded preview Bookmark button: same submenu shape via new bookmark_to_folder signal + set_bookmark_folders_callback - Popout Bookmark button: same shape — works in both browse and bookmarks tab modes - Popout Save button: Save-to-Library submenu via new save_to_folder + unsave_requested signals (drops save_toggle_requested + the _save_toggle_from_popout indirection) - Popout in library mode: Save button stays visible as Unsave; the rest of the toolbar (Bookmark / BL Tag / BL Post) is hidden State plumbing: - _update_fullscreen_state mirrors the embedded preview's _is_bookmarked / _is_saved instead of re-querying DB+filesystem, eliminating the popout state drift during async bookmark adds - Library tab Save button reads "Unsave" the entire time; Save button width bumped 60→75 so the label doesn't clip on tight themes - Embedded preview tracks _is_bookmarked alongside _is_saved so the new Bookmark-as submenu can flip to a flat unbookmark when active Naming: - "Unsorted" renamed to "Unfiled" everywhere user-facing — library Unfiled and bookmarks Unfiled now share one label. Internal comparison in library.py:_scan_files updated to match the combo. --- booru_viewer/core/cache.py | 29 ++- booru_viewer/core/config.py | 40 +++ booru_viewer/gui/app.py | 446 ++++++++++++++++++++++------------ booru_viewer/gui/bookmarks.py | 153 ++++++++---- booru_viewer/gui/library.py | 127 +++++++++- booru_viewer/gui/preview.py | 173 ++++++++++++- 6 files changed, 748 insertions(+), 220 deletions(-) diff --git a/booru_viewer/core/cache.py b/booru_viewer/core/cache.py index 696fa54..516828e 100644 --- a/booru_viewer/core/cache.py +++ b/booru_viewer/core/cache.py @@ -435,16 +435,27 @@ def is_cached(url: str, dest_dir: Path | None = None) -> bool: def delete_from_library(post_id: int, folder: str | None = None) -> bool: - """Delete a saved image from the library. Returns True if a file was deleted.""" - from .config import saved_dir, saved_folder_dir - search_dir = saved_folder_dir(folder) if folder else saved_dir() - from .config import MEDIA_EXTENSIONS - for ext in MEDIA_EXTENSIONS: - path = search_dir / f"{post_id}{ext}" - if path.exists(): + """Delete every saved copy of `post_id` from the library. + + Returns True if at least one file was deleted. + + The `folder` argument is kept for back-compat with existing call sites + but is now ignored — we walk every library folder by post id and delete + all matches. This is what makes the "bookmark folder ≠ library folder" + separation work: a bookmark no longer needs to know which folder its + library file lives in. It also cleans up duplicates left by the old + pre-fix "save to folder = copy" bug in a single Unsave action. + """ + from .config import find_library_files + matches = find_library_files(post_id) + deleted = False + for path in matches: + try: path.unlink() - return True - return False + deleted = True + except OSError: + pass + return deleted def cache_size_bytes(include_thumbnails: bool = True) -> int: diff --git a/booru_viewer/core/config.py b/booru_viewer/core/config.py index 15d90a5..84f99c6 100644 --- a/booru_viewer/core/config.py +++ b/booru_viewer/core/config.py @@ -109,6 +109,46 @@ def db_path() -> Path: return data_dir() / "booru.db" +def library_folders() -> list[str]: + """List library folder names — direct subdirectories of saved_dir(). + + The library is filesystem-truth: a folder exists iff there is a real + directory on disk. There is no separate DB list of folder names. This + is the source the "Save to Library → folder" menus everywhere should + read from. Bookmark folders (DB-backed) are a different concept. + """ + root = saved_dir() + if not root.is_dir(): + return [] + return sorted(d.name for d in root.iterdir() if d.is_dir()) + + +def find_library_files(post_id: int) -> list[Path]: + """Return all library files matching `post_id` across every folder. + + The library has a flat shape: root + one level of subdirectories. + We walk it shallowly (one iterdir of root + one iterdir per subdir) + looking for any media file whose stem equals str(post_id). Used by: + - "is this post saved?" badges (any match → yes) + - delete_from_library (delete every match — handles duplicates left + by the old save-to-folder copy bug in a single click) + - the move-aware _save_to_library / library "Move to Folder" actions + """ + matches: list[Path] = [] + root = saved_dir() + if not root.is_dir(): + return matches + stem = str(post_id) + for entry in root.iterdir(): + if entry.is_file() and entry.stem == stem and entry.suffix.lower() in MEDIA_EXTENSIONS: + matches.append(entry) + elif entry.is_dir(): + for sub in entry.iterdir(): + if sub.is_file() and sub.stem == stem and sub.suffix.lower() in MEDIA_EXTENSIONS: + matches.append(sub) + return matches + + # Defaults DEFAULT_THUMBNAIL_SIZE = (200, 200) DEFAULT_PAGE_SIZE = 40 diff --git a/booru_viewer/gui/app.py b/booru_viewer/gui/app.py index 14ddbca..684d59d 100644 --- a/booru_viewer/gui/app.py +++ b/booru_viewer/gui/app.py @@ -512,6 +512,7 @@ class BooruApp(QMainWindow): 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) @@ -519,7 +520,13 @@ class BooruApp(QMainWindow): self._preview.navigate.connect(self._navigate_preview) self._preview.play_next_requested.connect(self._on_video_end_next) self._preview.fullscreen_requested.connect(self._open_fullscreen_preview) - self._preview.set_folders_callback(self._db.get_folders) + # Library folders come from the filesystem (subdirs of saved_dir), + # not the bookmark folders DB table — those are separate concepts. + from ..core.config import library_folders + self._preview.set_folders_callback(library_folders) + # Bookmark folders feed the toolbar Bookmark-as submenu, sourced + # from the DB so it stays in sync with the bookmarks tab combo. + self._preview.set_bookmark_folders_callback(self._db.get_folders) self._fullscreen_window = None # Wide enough that the preview toolbar (Bookmark, Save, BL Tag, # BL Post, [stretch], Popout) has room to lay out all five buttons @@ -748,10 +755,18 @@ class BooruApp(QMainWindow): self._library_view._grid.clear_selection() self._preview._current_post = None self._preview._current_site_id = None - self._preview.update_bookmark_state(False) - self._preview.update_save_state(False) - # Show/hide preview toolbar buttons per tab is_library = index == 2 + self._preview.update_bookmark_state(False) + # On the library tab the Save button is the only toolbar action + # left visible (Bookmark / BL Tag / BL Post are hidden a few lines + # down). Library files are saved by definition, so the button + # should read "Unsave" the entire time the user is in that tab — + # forcing the state to True here makes that true even before the + # user clicks anything (the toolbar might already be showing old + # media from the previous tab; this is fine because the same media + # is also in the library if it was just saved). + self._preview.update_save_state(is_library) + # Show/hide preview toolbar buttons per tab self._preview._bookmark_btn.setVisible(not is_library) self._preview._bl_tag_btn.setVisible(not is_library) self._preview._bl_post_btn.setVisible(not is_library) @@ -1030,20 +1045,24 @@ class BooruApp(QMainWindow): # Clear loading after a brief delay so scroll signals don't re-trigger QTimer.singleShot(100, self._clear_loading) - from ..core.config import saved_dir, saved_folder_dir + from ..core.config import saved_dir from ..core.cache import cached_path_for, cache_dir site_id = self._site_combo.currentData() - # Pre-scan saved directories once instead of per-post exists() calls + # Pre-scan the library once into a flat post-id set so the per-post + # check below is O(1). Folders are filesystem-truth — walk every + # subdir of saved_dir() rather than consulting the bookmark folder + # list (which used to leak DB state into library detection). _sd = saved_dir() _saved_ids: set[int] = set() - if _sd.exists(): - _saved_ids = {int(f.stem) for f in _sd.iterdir() if f.is_file() and f.stem.isdigit()} - _folder_saved: dict[str, set[int]] = {} - for folder in self._db.get_folders(): - d = saved_folder_dir(folder) - if d.exists(): - _folder_saved[folder] = {int(f.stem) for f in d.iterdir() if f.is_file() and f.stem.isdigit()} + if _sd.is_dir(): + for entry in _sd.iterdir(): + if entry.is_file() and entry.stem.isdigit(): + _saved_ids.add(int(entry.stem)) + elif entry.is_dir(): + for sub in entry.iterdir(): + if sub.is_file() and sub.stem.isdigit(): + _saved_ids.add(int(sub.stem)) # Pre-fetch bookmarks for the site once and project to a post-id set # so the per-post check below is an O(1) membership test instead of @@ -1062,14 +1081,9 @@ class BooruApp(QMainWindow): # Bookmark status (DB) if post.id in _bookmarked_ids: thumb.set_bookmarked(True) - # Saved status (filesystem) — independent of bookmark - saved = post.id in _saved_ids - if not saved: - for folder_name, folder_ids in _folder_saved.items(): - if post.id in folder_ids: - saved = True - break - thumb.set_saved_locally(saved) + # Saved status (filesystem) — _saved_ids already covers both + # the unsorted root and every library subdirectory. + thumb.set_saved_locally(post.id in _saved_ids) # Set drag path from cache cached = cached_path_for(post.file_url) if cached.name in _cached_names: @@ -1366,61 +1380,39 @@ class BooruApp(QMainWindow): if self._fullscreen_window and self._fullscreen_window.isVisible(): self._preview._video_player.stop() self._fullscreen_window.set_media(path, info) - # Show/hide action buttons based on current tab - show = self._stack.currentIndex() != 2 - self._fullscreen_window._bookmark_btn.setVisible(show) - self._fullscreen_window._save_btn.setVisible(show) - self._fullscreen_window._bl_tag_btn.setVisible(show) - self._fullscreen_window._bl_post_btn.setVisible(show) - if show: - self._update_fullscreen_state() + # Bookmark / BL Tag / BL Post hidden on the library tab (no + # site/post id to act on for local-only files). Save stays + # visible — it acts as Unsave for the library file currently + # being viewed, matching the embedded preview's library mode. + show_full = self._stack.currentIndex() != 2 + self._fullscreen_window._bookmark_btn.setVisible(show_full) + self._fullscreen_window._save_btn.setVisible(True) + self._fullscreen_window._bl_tag_btn.setVisible(show_full) + self._fullscreen_window._bl_post_btn.setVisible(show_full) + self._update_fullscreen_state() def _update_fullscreen_state(self) -> None: - """Update popout button states for the current post.""" + """Update popout button states by mirroring the embedded preview. + + The embedded preview is the canonical owner of bookmark/save + state — every code path that bookmarks, unsaves, navigates, or + loads a post calls update_bookmark_state / update_save_state on + it. Re-querying the DB and filesystem here used to drift out of + sync with the embedded preview during async bookmark adds and + immediately after tab switches; mirroring eliminates the gap and + is one source of truth instead of two. + """ if not self._fullscreen_window: return - from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS - site_id = self._site_combo.currentData() - - if self._stack.currentIndex() == 1: - # Bookmarks view - grid = self._bookmarks_view._grid - favs = self._bookmarks_view._bookmarks - idx = grid.selected_index - if 0 <= idx < len(favs): - fav = favs[idx] - saved = False - if fav.folder: - saved = any( - (saved_folder_dir(fav.folder) / f"{fav.post_id}{ext}").exists() - for ext in MEDIA_EXTENSIONS - ) - else: - saved = any( - (saved_dir() / f"{fav.post_id}{ext}").exists() - for ext in MEDIA_EXTENSIONS - ) - self._fullscreen_window.update_state(True, saved) - self._fullscreen_window.set_post_tags( - fav.tag_categories or {}, (fav.tags or "").split() - ) - else: - self._fullscreen_window.update_state(False, False) - else: - post = None - idx = self._grid.selected_index - if 0 <= idx < len(self._posts): - post = self._posts[idx] - elif self._preview._current_post: - post = self._preview._current_post - if post: - s_id = self._preview._current_site_id or site_id - bookmarked = bool(s_id and self._db.is_bookmarked(s_id, post.id)) - saved = self._is_post_saved(post.id) - self._fullscreen_window.update_state(bookmarked, saved) - self._fullscreen_window.set_post_tags(post.tag_categories, post.tag_list) - else: - self._fullscreen_window.update_state(False, False) + self._fullscreen_window.update_state( + self._preview._is_bookmarked, + self._preview._is_saved, + ) + post = self._preview._current_post + if post is not None: + self._fullscreen_window.set_post_tags( + post.tag_categories or {}, post.tag_list + ) def _on_image_done(self, path: str, info: str) -> None: self._dl_progress.hide() @@ -1552,18 +1544,13 @@ class BooruApp(QMainWindow): self._update_fullscreen(fav.cached_path, info) return - # Try saved library - from ..core.config import saved_dir, saved_folder_dir - search_dirs = [saved_dir()] - if fav.folder: - search_dirs.insert(0, saved_folder_dir(fav.folder)) - for d in search_dirs: - for ext in MEDIA_EXTENSIONS: - path = d / f"{fav.post_id}{ext}" - if path.exists(): - self._set_preview_media(str(path), info) - self._update_fullscreen(str(path), info) - return + # Try saved library — walk by post id; the file may live in any + # library folder regardless of which bookmark folder fav is in. + from ..core.config import find_library_files + for path in find_library_files(fav.post_id): + self._set_preview_media(str(path), info) + self._update_fullscreen(str(path), info) + return # Download it self._status.showMessage(f"Downloading #{fav.post_id}...") @@ -1917,20 +1904,14 @@ class BooruApp(QMainWindow): ) def _is_post_saved(self, post_id: int) -> bool: - """Check if a post is saved in the library (any folder).""" - from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS - _sd = saved_dir() - if _sd.exists(): - for ext in MEDIA_EXTENSIONS: - if (_sd / f"{post_id}{ext}").exists(): - return True - for folder in self._db.get_folders(): - d = saved_folder_dir(folder) - if d.exists(): - for ext in MEDIA_EXTENSIONS: - if (d / f"{post_id}{ext}").exists(): - return True - return False + """Check if a post is saved in the library (any folder). + + Walks the library by post id rather than consulting the bookmark + folder list — library folders are filesystem-truth now, and a + post can be in any folder regardless of bookmark state. + """ + from ..core.config import find_library_files + return bool(find_library_files(post_id)) def _get_preview_post(self): """Get the post currently shown in the preview, from grid or stored ref.""" @@ -1970,12 +1951,61 @@ class BooruApp(QMainWindow): 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._update_fullscreen_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 - if folder and folder not in self._db.get_folders(): - self._db.add_folder(folder) + # _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 @@ -1983,25 +2013,10 @@ class BooruApp(QMainWindow): post, idx = self._get_preview_post() if not post: return + # delete_from_library now walks every library folder by post id + # and deletes every match in one call — no folder hint needed. from ..core.cache import delete_from_library - # Check all folders for saved files - from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS - deleted = False - # Try unsorted - _sd = saved_dir() - for ext in MEDIA_EXTENSIONS: - p = _sd / f"{post.id}{ext}" - if p.exists(): - p.unlink() - deleted = True - # Try all folders - for folder in self._db.get_folders(): - d = saved_folder_dir(folder) - for ext in MEDIA_EXTENSIONS: - p = d / f"{post.id}{ext}" - if p.exists(): - p.unlink() - deleted = True + deleted = delete_from_library(post.id) if deleted: self._status.showMessage(f"Removed #{post.id} from library") self._preview.update_save_state(False) @@ -2023,12 +2038,6 @@ class BooruApp(QMainWindow): self._status.showMessage(f"#{post.id} not in library") self._update_fullscreen_state() - def _save_toggle_from_popout(self) -> None: - if self._fullscreen_window and self._fullscreen_window._is_saved: - self._unsave_from_preview() - else: - self._save_from_preview("") - def _blacklist_tag_from_popout(self, tag: str) -> None: reply = QMessageBox.question( self, "Blacklist Tag", @@ -2103,9 +2112,21 @@ class BooruApp(QMainWindow): self._fullscreen_window = FullscreenPreview(grid_cols=cols, show_actions=show_actions, monitor=monitor, parent=self) self._fullscreen_window.navigate.connect(self._navigate_fullscreen) self._fullscreen_window.play_next_requested.connect(self._on_video_end_next) + # Save signals are always wired — even in library mode, the + # popout's Save button is the only toolbar action visible (acting + # as Unsave for the file being viewed), and it has its own + # Save-to-Library submenu shape that matches the embedded preview. + from ..core.config import library_folders + self._fullscreen_window.set_folders_callback(library_folders) + self._fullscreen_window.save_to_folder.connect(self._save_from_preview) + self._fullscreen_window.unsave_requested.connect(self._unsave_from_preview) if show_actions: self._fullscreen_window.bookmark_requested.connect(self._bookmark_from_preview) - self._fullscreen_window.save_toggle_requested.connect(self._save_toggle_from_popout) + # Same Bookmark-as flow as the embedded preview — popout reuses + # the existing handler since both signals carry just a folder + # name and read the post from self._preview._current_post. + self._fullscreen_window.set_bookmark_folders_callback(self._db.get_folders) + self._fullscreen_window.bookmark_to_folder.connect(self._bookmark_to_folder_from_preview) self._fullscreen_window.blacklist_tag_requested.connect(self._blacklist_tag_from_popout) self._fullscreen_window.blacklist_post_requested.connect(self._blacklist_post_from_popout) self._fullscreen_window.closed.connect(self._on_fullscreen_closed) @@ -2131,8 +2152,10 @@ class BooruApp(QMainWindow): pass sv.media_ready.connect(_seek_when_ready) self._fullscreen_window.set_media(path, info) - if show_actions: - self._update_fullscreen_state() + # Always sync state — the save button is visible in both modes + # (library mode = only Save shown, browse/bookmarks = full toolbar) + # so its Unsave label needs to land before the user sees it. + self._update_fullscreen_state() def _on_fullscreen_closed(self) -> None: # Persist popout window state to DB @@ -2205,12 +2228,14 @@ class BooruApp(QMainWindow): menu.addSeparator() save_as = menu.addAction("Save As...") - # Save to Library submenu + # Save to Library submenu — folders come from the library + # filesystem, not the bookmark folder DB. + from ..core.config import library_folders save_lib_menu = menu.addMenu("Save to Library") - save_lib_unsorted = save_lib_menu.addAction("Unsorted") + save_lib_unsorted = save_lib_menu.addAction("Unfiled") save_lib_menu.addSeparator() save_lib_folders = {} - for folder in self._db.get_folders(): + for folder in library_folders(): a = save_lib_menu.addAction(folder) save_lib_folders[id(a)] = folder save_lib_menu.addSeparator() @@ -2223,7 +2248,29 @@ class BooruApp(QMainWindow): copy_url = menu.addAction("Copy Image URL") copy_tags = menu.addAction("Copy Tags") menu.addSeparator() - fav_action = menu.addAction("Remove Bookmark" if self._is_current_bookmarked(index) else "Bookmark") + + # Bookmark action: when not yet bookmarked, offer "Bookmark as" + # with a submenu of bookmark folders so the user can file the + # new bookmark in one click. Bookmark folders come from the DB + # (separate name space from library folders). When already + # bookmarked, the action collapses to a flat "Remove Bookmark" + # — re-filing an existing bookmark belongs in the bookmarks tab + # right-click menu's "Move to Folder" submenu. + fav_action = None + bm_folder_actions: dict[int, str] = {} + bm_unfiled = None + bm_new = None + if self._is_current_bookmarked(index): + fav_action = menu.addAction("Remove Bookmark") + else: + fav_menu = menu.addMenu("Bookmark as") + bm_unfiled = fav_menu.addAction("Unfiled") + fav_menu.addSeparator() + for folder in self._db.get_folders(): + a = fav_menu.addAction(folder) + bm_folder_actions[id(a)] = folder + fav_menu.addSeparator() + bm_new = fav_menu.addAction("+ New Folder...") menu.addSeparator() bl_menu = menu.addMenu("Blacklist Tag") if post.tag_categories: @@ -2252,8 +2299,12 @@ class BooruApp(QMainWindow): from PySide6.QtWidgets import QInputDialog, QMessageBox name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") if ok and name.strip(): + # _save_to_library → saved_folder_dir() does the mkdir + # and the path-traversal check; we surface the same error + # message it would emit so a bad name is reported clearly. try: - self._db.add_folder(name.strip()) + from ..core.config import saved_folder_dir + saved_folder_dir(name.strip()) except ValueError as e: QMessageBox.warning(self, "Invalid Folder Name", str(e)) return @@ -2271,8 +2322,25 @@ class BooruApp(QMainWindow): elif action == copy_tags: QApplication.clipboard().setText(post.tags) self._status.showMessage("Tags copied") - elif action == fav_action: + elif fav_action is not None and action == fav_action: + # Currently bookmarked → flat "Remove Bookmark" path. self._toggle_bookmark(index) + elif bm_unfiled is not None and action == bm_unfiled: + self._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:") + if ok and name.strip(): + # Bookmark folders are DB-managed; add_folder validates + # the name and is the same call the bookmarks tab uses. + try: + self._db.add_folder(name.strip()) + except ValueError as e: + QMessageBox.warning(self, "Invalid Folder Name", str(e)) + return + self._toggle_bookmark(index, name.strip()) + elif id(action) in bm_folder_actions: + self._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) @@ -2361,9 +2429,10 @@ class BooruApp(QMainWindow): fav_all = menu.addAction(f"Bookmark All ({count})") save_menu = menu.addMenu(f"Save All to Library ({count})") - save_unsorted = save_menu.addAction("Unsorted") + save_unsorted = save_menu.addAction("Unfiled") save_folder_actions = {} - for folder in self._db.get_folders(): + from ..core.config import library_folders + for folder in library_folders(): a = save_menu.addAction(folder) save_folder_actions[id(a)] = folder save_menu.addSeparator() @@ -2388,7 +2457,8 @@ class BooruApp(QMainWindow): name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") if ok and name.strip(): try: - self._db.add_folder(name.strip()) + from ..core.config import saved_folder_dir + saved_folder_dir(name.strip()) except ValueError as e: QMessageBox.warning(self, "Invalid Folder Name", str(e)) return @@ -2404,13 +2474,9 @@ class BooruApp(QMainWindow): site_id = self._site_combo.currentData() if site_id: from ..core.cache import delete_from_library - from ..core.config import saved_dir, saved_folder_dir for post in posts: - # Delete from unsorted library - delete_from_library(post.id, None) - # Delete from all folders - for folder in self._db.get_folders(): - delete_from_library(post.id, folder) + # Single call now walks every library folder by post id. + delete_from_library(post.id) self._db.remove_bookmark(site_id, post.id) for idx in indices: if 0 <= idx < len(self._grid._thumbs): @@ -2451,7 +2517,7 @@ class BooruApp(QMainWindow): def _bulk_save(self, indices: list[int], posts: list[Post], folder: str | None) -> None: site_id = self._site_combo.currentData() - where = folder or "Unsorted" + where = folder or "Unfiled" self._status.showMessage(f"Saving {len(posts)} to {where}...") async def _do(): @@ -2568,23 +2634,78 @@ class BooruApp(QMainWindow): self._status.showMessage("Image not cached yet — double-click to download first") def _save_to_library(self, post: Post, folder: str | None) -> None: - """Download and save image to the library folder structure.""" - from ..core.config import saved_dir, saved_folder_dir + """Save (or relocate) an image in the library folder structure. + + If the post is already saved somewhere in the library, the existing + file is renamed into the target folder rather than re-downloading. + This is what makes "Save to Library → SomeFolder" act like a move + when the post is already in Unfiled (or another folder), instead + of producing a duplicate. rename() is atomic on the same filesystem + so a crash mid-move can never leave both copies behind. + """ + from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS self._status.showMessage(f"Saving #{post.id} to library...") + # Resolve destination synchronously — saved_folder_dir() does + # the path-traversal check and may raise ValueError. Surface + # that error here rather than from inside the async closure. + try: + if folder: + dest_dir = saved_folder_dir(folder) + else: + dest_dir = saved_dir() + except ValueError as e: + self._status.showMessage(f"Invalid folder name: {e}") + return + dest_resolved = dest_dir.resolve() + + # Look for an existing copy of this post anywhere in the library. + # The library is shallow (root + one level of subdirectories) so + # this is cheap — at most one iterdir per top-level entry. + existing: Path | None = None + root = saved_dir() + if root.is_dir(): + stem = str(post.id) + for entry in root.iterdir(): + if entry.is_file() and entry.stem == stem and entry.suffix.lower() in MEDIA_EXTENSIONS: + existing = entry + break + if entry.is_dir(): + for sub in entry.iterdir(): + if sub.is_file() and sub.stem == stem and sub.suffix.lower() in MEDIA_EXTENSIONS: + existing = sub + break + if existing is not None: + break + async def _save(): try: - path = await download_image(post.file_url) - ext = Path(path).suffix - if folder: - dest_dir = saved_folder_dir(folder) + if existing is not None: + # Already in the library — relocate instead of re-saving. + if existing.parent.resolve() != dest_resolved: + target = dest_dir / existing.name + if target.exists(): + # Destination already has a file with the same + # name (matched by post id, so it's the same + # post). Drop the source to collapse the + # duplicate rather than leaving both behind. + existing.unlink() + else: + try: + existing.rename(target) + except OSError: + # Cross-device rename — fall back to copy+unlink. + import shutil as _sh + _sh.move(str(existing), str(target)) else: - dest_dir = saved_dir() - dest = dest_dir / f"{post.id}{ext}" - if not dest.exists(): - import shutil - shutil.copy2(path, dest) + # Not in the library yet — pull from cache and copy in. + path = await download_image(post.file_url) + ext = Path(path).suffix + dest = dest_dir / f"{post.id}{ext}" + if not dest.exists(): + import shutil + shutil.copy2(path, dest) # Copy browse thumbnail to library thumbnail cache if post.preview_url: @@ -2607,7 +2728,7 @@ class BooruApp(QMainWindow): source=post.source, file_url=post.file_url, ) - where = folder or "Unsorted" + where = folder or "Unfiled" self._signals.bookmark_done.emit( self._grid.selected_index, f"Saved #{post.id} to {where}" @@ -2837,7 +2958,14 @@ class BooruApp(QMainWindow): # -- Bookmarks -- - def _toggle_bookmark(self, index: int) -> None: + 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: @@ -2865,9 +2993,11 @@ class BooruApp(QMainWindow): score=post.score, source=post.source, cached_path=str(path), + folder=folder, tag_categories=post.tag_categories, ) - self._signals.bookmark_done.emit(index, f"Bookmarked #{post.id}") + 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)) @@ -2875,7 +3005,7 @@ class BooruApp(QMainWindow): 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 Unsorted") — skip heavy updates + # 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): diff --git a/booru_viewer/gui/bookmarks.py b/booru_viewer/gui/bookmarks.py index 34a2d03..25fcc1a 100644 --- a/booru_viewer/gui/bookmarks.py +++ b/booru_viewer/gui/bookmarks.py @@ -71,12 +71,28 @@ class BookmarksView(QWidget): top.addWidget(self._folder_combo) manage_btn = QPushButton("+ Folder") - manage_btn.setToolTip("New folder") + manage_btn.setToolTip("New bookmark folder") manage_btn.setFixedWidth(75) manage_btn.setStyleSheet(_btn_style) manage_btn.clicked.connect(self._new_folder) top.addWidget(manage_btn) + # Delete the currently-selected bookmark folder. Disabled when + # the combo is on a virtual entry (All Bookmarks / Unfiled). + # This only removes the DB row — bookmarks in that folder become + # Unfiled (per remove_folder's UPDATE … SET folder = NULL). The + # library filesystem is untouched: bookmark folders and library + # folders are independent name spaces. + self._delete_folder_btn = QPushButton("− Folder") + self._delete_folder_btn.setToolTip("Delete the selected bookmark folder") + self._delete_folder_btn.setFixedWidth(75) + self._delete_folder_btn.setStyleSheet(_btn_style) + self._delete_folder_btn.clicked.connect(self._delete_folder) + top.addWidget(self._delete_folder_btn) + self._folder_combo.currentTextChanged.connect( + self._update_delete_folder_enabled + ) + self._search_input = QLineEdit() self._search_input.setPlaceholderText("Search bookmarks by tag") # Enter still triggers an immediate search. @@ -120,6 +136,39 @@ class BookmarksView(QWidget): if idx >= 0: self._folder_combo.setCurrentIndex(idx) self._folder_combo.blockSignals(False) + self._update_delete_folder_enabled() + + def _update_delete_folder_enabled(self, *_args) -> None: + """Enable the delete-folder button only on real folder rows.""" + text = self._folder_combo.currentText() + self._delete_folder_btn.setEnabled(text not in ("", "All Bookmarks", "Unfiled")) + + def _delete_folder(self) -> None: + """Delete the currently-selected bookmark folder. + + Bookmarks filed under it become Unfiled (remove_folder UPDATEs + favorites.folder = NULL before DELETE FROM favorite_folders). + Library files on disk are unaffected — bookmark folders and + library folders are separate concepts after the decoupling. + """ + name = self._folder_combo.currentText() + if name in ("", "All Bookmarks", "Unfiled"): + return + reply = QMessageBox.question( + self, + "Delete Bookmark Folder", + f"Delete bookmark folder '{name}'?\n\n" + f"Bookmarks in this folder will become Unfiled. " + f"Library files on disk are not affected.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self._db.remove_folder(name) + # Drop back to All Bookmarks so the now-orphan filter doesn't + # leave the combo on a missing row. + self._folder_combo.setCurrentText("All Bookmarks") + self.refresh() def refresh(self, search: str | None = None) -> None: self._refresh_folders() @@ -145,22 +194,12 @@ class BookmarksView(QWidget): self._count_label.setText(f"{len(self._bookmarks)} bookmarks") thumbs = self._grid.set_posts(len(self._bookmarks)) - from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS + from ..core.config import find_library_files for i, (fav, thumb) in enumerate(zip(self._bookmarks, thumbs)): thumb.set_bookmarked(True) - # Check if saved to library - saved = False - if fav.folder: - saved = any( - (saved_folder_dir(fav.folder) / f"{fav.post_id}{ext}").exists() - for ext in MEDIA_EXTENSIONS - ) - else: - saved = any( - (saved_dir() / f"{fav.post_id}{ext}").exists() - for ext in MEDIA_EXTENSIONS - ) - thumb.set_saved_locally(saved) + # Library state is filesystem-truth and folder-agnostic now — + # walk the library by post id, ignore the bookmark's folder. + thumb.set_saved_locally(bool(find_library_files(fav.post_id))) # Set cached path for drag-and-drop and copy if fav.cached_path and Path(fav.cached_path).exists(): thumb._cached_path = fav.cached_path @@ -250,31 +289,22 @@ class BookmarksView(QWidget): menu.addSeparator() save_as = menu.addAction("Save As...") - # Save to Library submenu + # Save to Library submenu — folders come from the library + # filesystem, not the bookmark folder DB. + from ..core.config import library_folders, find_library_files save_lib_menu = menu.addMenu("Save to Library") - save_lib_unsorted = save_lib_menu.addAction("Unsorted") + save_lib_unsorted = save_lib_menu.addAction("Unfiled") save_lib_menu.addSeparator() save_lib_folders = {} - for folder in self._db.get_folders(): + for folder in library_folders(): a = save_lib_menu.addAction(folder) save_lib_folders[id(a)] = folder save_lib_menu.addSeparator() save_lib_new = save_lib_menu.addAction("+ New Folder...") unsave_lib = None - # Only show unsave if the post is saved locally - from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS - _saved = False - _sd = saved_dir() - if _sd.exists(): - _saved = any((_sd / f"{fav.post_id}{ext}").exists() for ext in MEDIA_EXTENSIONS) - if not _saved: - for folder in self._db.get_folders(): - d = saved_folder_dir(folder) - if d.exists() and any((d / f"{fav.post_id}{ext}").exists() for ext in MEDIA_EXTENSIONS): - _saved = True - break - if _saved: + # Only show unsave if the post is actually on disk somewhere. + if find_library_files(fav.post_id): unsave_lib = menu.addAction("Unsave from Library") copy_file = menu.addAction("Copy File to Clipboard") copy_url = menu.addAction("Copy Image URL") @@ -305,13 +335,16 @@ class BookmarksView(QWidget): elif action == save_lib_new: name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") if ok and name.strip(): + # Validate the name via saved_folder_dir() which mkdir's + # the library subdir and runs the path-traversal check. + # No DB folder write — bookmark folders are independent. try: - self._db.add_folder(name.strip()) + from ..core.config import saved_folder_dir + saved_folder_dir(name.strip()) except ValueError as e: QMessageBox.warning(self, "Invalid Folder Name", str(e)) return self._copy_to_library(fav, name.strip()) - self._db.move_bookmark_to_folder(fav.id, name.strip()) self.refresh() elif id(action) in save_lib_folders: folder_name = save_lib_folders[id(action)] @@ -331,7 +364,10 @@ class BookmarksView(QWidget): shutil.copy2(src, dest) elif action == unsave_lib: from ..core.cache import delete_from_library - if delete_from_library(fav.post_id, fav.folder): + # delete_from_library walks every library folder by post id + # now — no folder hint needed (and fav.folder wouldn't be + # accurate anyway after the bookmark/library separation). + if delete_from_library(fav.post_id): self.refresh() self.bookmarks_changed.emit() elif action == copy_file: @@ -360,13 +396,14 @@ class BookmarksView(QWidget): except ValueError as e: QMessageBox.warning(self, "Invalid Folder Name", str(e)) return + # Pure bookmark organization: file the bookmark, don't + # touch the library filesystem. Save to Library is now a + # separate, explicit action. self._db.move_bookmark_to_folder(fav.id, name.strip()) - self._copy_to_library(fav, name.strip()) self.refresh() elif id(action) in folder_actions: folder_name = folder_actions[id(action)] self._db.move_bookmark_to_folder(fav.id, folder_name) - self._copy_to_library(fav, folder_name) self.refresh() elif action == remove_bookmark: self._db.remove_bookmark(fav.site_id, fav.post_id) @@ -378,11 +415,28 @@ class BookmarksView(QWidget): if not favs: return + from ..core.config import library_folders + menu = QMenu(self) - save_all = menu.addAction(f"Save All ({len(favs)}) to Library") + + # Save All to Library submenu — folders are filesystem-truth. + # Conversion from a flat action to a submenu so the user can + # pick a destination instead of having "save all" silently use + # each bookmark's fav.folder (which was the cross-bleed bug). + save_lib_menu = menu.addMenu(f"Save All ({len(favs)}) to Library") + save_lib_unsorted = save_lib_menu.addAction("Unfiled") + save_lib_menu.addSeparator() + save_lib_folder_actions: dict[int, str] = {} + for folder in library_folders(): + a = save_lib_menu.addAction(folder) + save_lib_folder_actions[id(a)] = folder + save_lib_menu.addSeparator() + save_lib_new = save_lib_menu.addAction("+ New Folder...") + unsave_all = menu.addAction(f"Unsave All ({len(favs)}) from Library") menu.addSeparator() + # Move to Folder is bookmark organization — reads from the DB. move_menu = menu.addMenu(f"Move All ({len(favs)}) to Folder") move_none = move_menu.addAction("Unfiled") move_menu.addSeparator() @@ -398,17 +452,32 @@ class BookmarksView(QWidget): if not action: return - if action == save_all: + def _save_all_into(folder_name: str | None) -> None: for fav in favs: - if fav.folder: - self._copy_to_library(fav, fav.folder) + if folder_name: + self._copy_to_library(fav, folder_name) else: self._copy_to_library_unsorted(fav) self.refresh() + + if action == save_lib_unsorted: + _save_all_into(None) + elif action == save_lib_new: + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if ok and name.strip(): + try: + from ..core.config import saved_folder_dir + saved_folder_dir(name.strip()) + except ValueError as e: + QMessageBox.warning(self, "Invalid Folder Name", str(e)) + return + _save_all_into(name.strip()) + elif id(action) in save_lib_folder_actions: + _save_all_into(save_lib_folder_actions[id(action)]) elif action == unsave_all: from ..core.cache import delete_from_library for fav in favs: - delete_from_library(fav.post_id, fav.folder) + delete_from_library(fav.post_id) self.refresh() self.bookmarks_changed.emit() elif action == move_none: @@ -417,9 +486,9 @@ class BookmarksView(QWidget): self.refresh() elif id(action) in folder_actions: folder_name = folder_actions[id(action)] + # Bookmark organization only — Save to Library is separate. for fav in favs: self._db.move_bookmark_to_folder(fav.id, folder_name) - self._copy_to_library(fav, folder_name) self.refresh() elif action == remove_all: for fav in favs: diff --git a/booru_viewer/gui/library.py b/booru_viewer/gui/library.py index b9e6c3c..fef3f0b 100644 --- a/booru_viewer/gui/library.py +++ b/booru_viewer/gui/library.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import ( QComboBox, QMenu, QMessageBox, + QInputDialog, QApplication, ) @@ -194,7 +195,7 @@ class LibraryView(QWidget): self._folder_combo.blockSignals(True) self._folder_combo.clear() self._folder_combo.addItem("All Files") - self._folder_combo.addItem("Unsorted") + self._folder_combo.addItem("Unfiled") root = saved_dir() if root.is_dir(): @@ -217,7 +218,7 @@ class LibraryView(QWidget): if folder_text == "All Files": return self._collect_recursive(root) - elif folder_text == "Unsorted": + elif folder_text == "Unfiled": return self._collect_top_level(root) else: sub = root / folder_text @@ -347,6 +348,76 @@ class LibraryView(QWidget): if 0 <= index < len(self._files): self.file_activated.emit(str(self._files[index])) + def _move_files_to_folder( + self, files: list[Path], target_folder: str | None + ) -> None: + """Move library files into target_folder (None = Unfiled root). + + Uses Path.rename for an atomic same-filesystem move. That matters + here because the bug we're fixing is "move produces a duplicate" — + a copy-then-delete sequence can leave both files behind if the + delete fails or the process is killed mid-step. rename() is one + syscall and either fully succeeds or doesn't happen at all. If + the rename crosses filesystems (rare — only if the user pointed + the library at a different mount than its parent), Python raises + OSError(EXDEV) and we fall back to shutil.move which copies-then- + unlinks; in that path the unlink failure is the only window for + a duplicate, and it's logged. + """ + import shutil + + try: + if target_folder: + dest_dir = saved_folder_dir(target_folder) + else: + dest_dir = saved_dir() + except ValueError as e: + QMessageBox.warning(self, "Invalid Folder Name", str(e)) + return + + dest_resolved = dest_dir.resolve() + moved = 0 + skipped_same = 0 + collisions: list[str] = [] + errors: list[str] = [] + + for src in files: + if not src.exists(): + continue + if src.parent.resolve() == dest_resolved: + skipped_same += 1 + continue + target = dest_dir / src.name + if target.exists(): + collisions.append(src.name) + continue + try: + src.rename(target) + moved += 1 + except OSError: + # Cross-device move — fall back to copy + delete. + try: + shutil.move(str(src), str(target)) + moved += 1 + except Exception as e: + log.warning("Failed to move %s → %s: %s", src, target, e) + errors.append(f"{src.name}: {e}") + + self.refresh() + + if collisions: + sample = "\n".join(collisions[:10]) + more = f"\n... and {len(collisions) - 10} more" if len(collisions) > 10 else "" + QMessageBox.warning( + self, + "Move Conflicts", + f"Skipped {len(collisions)} file(s) — destination already " + f"contains a file with the same name:\n\n{sample}{more}", + ) + if errors: + sample = "\n".join(errors[:10]) + QMessageBox.warning(self, "Move Errors", sample) + def _on_context_menu(self, index: int, pos) -> None: if index < 0 or index >= len(self._files): return @@ -361,6 +432,25 @@ class LibraryView(QWidget): menu.addSeparator() copy_file = menu.addAction("Copy File to Clipboard") copy_path = menu.addAction("Copy File Path") + menu.addSeparator() + + # Move to Folder submenu — atomic rename, no copy step, so a + # crash mid-move can never leave a duplicate behind. The current + # location is included in the list (no-op'd in the move helper) + # so the menu shape stays predictable for the user. + move_menu = menu.addMenu("Move to Folder") + move_unsorted = move_menu.addAction("Unfiled") + move_menu.addSeparator() + move_folder_actions: dict[int, str] = {} + root = saved_dir() + if root.is_dir(): + for entry in sorted(root.iterdir()): + if entry.is_dir(): + a = move_menu.addAction(entry.name) + move_folder_actions[id(a)] = entry.name + move_menu.addSeparator() + move_new = move_menu.addAction("+ New Folder...") + menu.addSeparator() delete_action = menu.addAction("Delete from Library") @@ -372,6 +462,14 @@ class LibraryView(QWidget): QDesktopServices.openUrl(QUrl.fromLocalFile(str(filepath))) elif action == open_folder: QDesktopServices.openUrl(QUrl.fromLocalFile(str(filepath.parent))) + elif action == move_unsorted: + self._move_files_to_folder([filepath], None) + elif action == move_new: + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if ok and name.strip(): + self._move_files_to_folder([filepath], name.strip()) + elif id(action) in move_folder_actions: + self._move_files_to_folder([filepath], move_folder_actions[id(action)]) elif action == copy_file: from PySide6.QtCore import QMimeData from PySide6.QtGui import QPixmap as _QP @@ -403,13 +501,36 @@ class LibraryView(QWidget): return menu = QMenu(self) + + move_menu = menu.addMenu(f"Move {len(files)} files to Folder") + move_unsorted = move_menu.addAction("Unfiled") + move_menu.addSeparator() + move_folder_actions: dict[int, str] = {} + root = saved_dir() + if root.is_dir(): + for entry in sorted(root.iterdir()): + if entry.is_dir(): + a = move_menu.addAction(entry.name) + move_folder_actions[id(a)] = entry.name + move_menu.addSeparator() + move_new = move_menu.addAction("+ New Folder...") + + menu.addSeparator() delete_all = menu.addAction(f"Delete {len(files)} files from Library") action = menu.exec(pos) if not action: return - if action == delete_all: + if action == move_unsorted: + self._move_files_to_folder(files, None) + elif action == move_new: + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if ok and name.strip(): + self._move_files_to_folder(files, name.strip()) + elif id(action) in move_folder_actions: + self._move_files_to_folder(files, move_folder_actions[id(action)]) + elif action == delete_all: reply = QMessageBox.question( self, "Confirm", f"Delete {len(files)} files from library?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, diff --git a/booru_viewer/gui/preview.py b/booru_viewer/gui/preview.py index 1ec5463..8911e64 100644 --- a/booru_viewer/gui/preview.py +++ b/booru_viewer/gui/preview.py @@ -38,7 +38,15 @@ class FullscreenPreview(QMainWindow): navigate = Signal(int) # direction: -1/+1 for left/right, -cols/+cols for up/down play_next_requested = Signal() # video ended in "Next" mode (wrap-aware) bookmark_requested = Signal() - save_toggle_requested = Signal() # save or unsave depending on state + # Bookmark-as: emitted when the popout's Bookmark button submenu picks + # a bookmark folder. Empty string = Unfiled. Mirrors ImagePreview's + # signal so app.py routes both through _bookmark_to_folder_from_preview. + bookmark_to_folder = Signal(str) + # Save-to-library: same signal pair as ImagePreview so app.py reuses + # _save_from_preview / _unsave_from_preview for both. Empty string = + # Unfiled (root of saved_dir). + save_to_folder = Signal(str) + unsave_requested = Signal() blacklist_tag_requested = Signal(str) # tag name blacklist_post_requested = Signal() privacy_requested = Signal() @@ -89,16 +97,26 @@ class FullscreenPreview(QMainWindow): # short labels in narrow fixed slots. _tb_btn_style = "padding: 2px 6px;" + # Bookmark folders for the popout's Bookmark-as submenu — wired + # by app.py via set_bookmark_folders_callback after construction. + self._bookmark_folders_callback = None + self._is_bookmarked = False + # Library folders for the popout's Save-to-Library submenu — + # wired by app.py via set_folders_callback. Same shape as the + # bookmark folders callback above; library and bookmark folders + # are independent name spaces and need separate callbacks. + self._folders_callback = None + self._bookmark_btn = QPushButton("Bookmark") self._bookmark_btn.setMaximumWidth(90) self._bookmark_btn.setStyleSheet(_tb_btn_style) - self._bookmark_btn.clicked.connect(self.bookmark_requested) + self._bookmark_btn.clicked.connect(self._on_bookmark_clicked) toolbar.addWidget(self._bookmark_btn) self._save_btn = QPushButton("Save") self._save_btn.setMaximumWidth(70) self._save_btn.setStyleSheet(_tb_btn_style) - self._save_btn.clicked.connect(self.save_toggle_requested) + self._save_btn.clicked.connect(self._on_save_clicked) toolbar.addWidget(self._save_btn) self._is_saved = False @@ -117,8 +135,11 @@ class FullscreenPreview(QMainWindow): toolbar.addWidget(self._bl_post_btn) if not show_actions: + # Library mode: only the Save button stays — it acts as + # Unsave for the file currently being viewed. Bookmark and + # blacklist actions are meaningless on already-saved local + # files (no site/post id to bookmark, no search to filter). self._bookmark_btn.hide() - self._save_btn.hide() self._bl_tag_btn.hide() self._bl_post_btn.hide() @@ -238,11 +259,91 @@ class FullscreenPreview(QMainWindow): self.blacklist_tag_requested.emit(action.text()) def update_state(self, bookmarked: bool, saved: bool) -> None: + self._is_bookmarked = bookmarked self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark") self._bookmark_btn.setMaximumWidth(90 if bookmarked else 80) self._is_saved = saved self._save_btn.setText("Unsave" if saved else "Save") + def set_bookmark_folders_callback(self, callback) -> None: + """Wire the bookmark folder list source. Called once from app.py + right after the popout is constructed; matches the embedded + ImagePreview's set_bookmark_folders_callback shape. + """ + self._bookmark_folders_callback = callback + + def set_folders_callback(self, callback) -> None: + """Wire the library folder list source. Called once from app.py + right after the popout is constructed; matches the embedded + ImagePreview's set_folders_callback shape. + """ + self._folders_callback = callback + + def _on_save_clicked(self) -> None: + """Popout Save button — same shape as the embedded preview's + version. When already saved, emit unsave_requested for the existing + unsave path. When not saved, pop a menu under the button with + Unfiled / library folders / + New Folder, then emit the chosen + name through save_to_folder. app.py reuses _save_from_preview / + _unsave_from_preview to handle both signals. + """ + if self._is_saved: + self.unsave_requested.emit() + return + menu = QMenu(self) + unfiled = menu.addAction("Unfiled") + menu.addSeparator() + folder_actions: dict[int, str] = {} + if self._folders_callback: + for folder in self._folders_callback(): + a = menu.addAction(folder) + folder_actions[id(a)] = folder + menu.addSeparator() + new_action = menu.addAction("+ New Folder...") + action = menu.exec(self._save_btn.mapToGlobal(self._save_btn.rect().bottomLeft())) + if not action: + return + if action == unfiled: + self.save_to_folder.emit("") + elif action == new_action: + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if ok and name.strip(): + self.save_to_folder.emit(name.strip()) + elif id(action) in folder_actions: + self.save_to_folder.emit(folder_actions[id(action)]) + + def _on_bookmark_clicked(self) -> None: + """Popout Bookmark button — same shape as the embedded preview's + version. When already bookmarked, emits bookmark_requested for the + existing toggle/remove path. When not bookmarked, pops a menu under + the button with Unfiled / bookmark folders / + New Folder, then + emits the chosen name through bookmark_to_folder. + """ + if self._is_bookmarked: + self.bookmark_requested.emit() + return + menu = QMenu(self) + unfiled = menu.addAction("Unfiled") + menu.addSeparator() + folder_actions: dict[int, str] = {} + if self._bookmark_folders_callback: + for folder in self._bookmark_folders_callback(): + a = menu.addAction(folder) + folder_actions[id(a)] = folder + menu.addSeparator() + new_action = menu.addAction("+ New Folder...") + action = menu.exec(self._bookmark_btn.mapToGlobal(self._bookmark_btn.rect().bottomLeft())) + if not action: + return + if action == unfiled: + self.bookmark_to_folder.emit("") + elif action == new_action: + name, ok = QInputDialog.getText(self, "New Bookmark Folder", "Folder name:") + if ok and name.strip(): + self.bookmark_to_folder.emit(name.strip()) + elif id(action) in folder_actions: + self.bookmark_to_folder.emit(folder_actions[id(action)]) + def set_media(self, path: str, info: str = "") -> None: self._info_label.setText(info) ext = Path(path).suffix.lower() @@ -1293,6 +1394,10 @@ class ImagePreview(QWidget): save_to_folder = Signal(str) unsave_requested = Signal() bookmark_requested = Signal() + # Bookmark-as: emitted when the user picks a bookmark folder from + # the toolbar's Bookmark button submenu. Empty string = Unfiled. + # Mirrors save_to_folder's shape so app.py can route it the same way. + bookmark_to_folder = Signal(str) blacklist_tag_requested = Signal(str) blacklist_post_requested = Signal() navigate = Signal(int) # -1 = prev, +1 = next @@ -1302,10 +1407,15 @@ class ImagePreview(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._folders_callback = None + # Bookmark folders live in a separate name space (DB-backed); the + # toolbar Bookmark-as submenu reads them via this callback so the + # preview widget stays decoupled from the Database object. + self._bookmark_folders_callback = None self._current_path: str | None = None self._current_post = None # Post object, set by app.py self._current_site_id = None # site_id for the current post self._is_saved = False # tracks library save state for context menu + self._is_bookmarked = False # tracks bookmark state for the button submenu self._current_tags: dict[str, list[str]] = {} self._current_tag_list: list[str] = [] @@ -1333,11 +1443,14 @@ class ImagePreview(QWidget): self._bookmark_btn = QPushButton("Bookmark") self._bookmark_btn.setFixedWidth(100) self._bookmark_btn.setStyleSheet(_tb_btn_style) - self._bookmark_btn.clicked.connect(self.bookmark_requested) + self._bookmark_btn.clicked.connect(self._on_bookmark_clicked) tb.addWidget(self._bookmark_btn) self._save_btn = QPushButton("Save") - self._save_btn.setFixedWidth(60) + # 75 fits "Unsave" (6 chars) cleanly across every bundled theme. + # The previous 60 was tight enough that some themes clipped the + # last character on library files where the label flips to Unsave. + self._save_btn.setFixedWidth(75) self._save_btn.setStyleSheet(_tb_btn_style) self._save_btn.clicked.connect(self._on_save_clicked) tb.addWidget(self._save_btn) @@ -1429,12 +1542,48 @@ class ImagePreview(QWidget): if action: self.blacklist_tag_requested.emit(action.text()) + def _on_bookmark_clicked(self) -> None: + """Toolbar Bookmark button — mirrors the browse-tab Bookmark-as + submenu so the preview pane has the same one-click filing flow. + + When the post is already bookmarked, the button collapses to a + flat unbookmark action (emits the same signal as before, the + existing toggle in app.py handles the removal). When not yet + bookmarked, a popup menu lets the user pick the destination + bookmark folder — the chosen name is sent through bookmark_to_folder + and app.py adds the folder + creates the bookmark. + """ + if self._is_bookmarked: + self.bookmark_requested.emit() + return + menu = QMenu(self) + unfiled = menu.addAction("Unfiled") + menu.addSeparator() + folder_actions: dict[int, str] = {} + if self._bookmark_folders_callback: + for folder in self._bookmark_folders_callback(): + a = menu.addAction(folder) + folder_actions[id(a)] = folder + menu.addSeparator() + new_action = menu.addAction("+ New Folder...") + action = menu.exec(self._bookmark_btn.mapToGlobal(self._bookmark_btn.rect().bottomLeft())) + if not action: + return + if action == unfiled: + self.bookmark_to_folder.emit("") + elif action == new_action: + name, ok = QInputDialog.getText(self, "New Bookmark Folder", "Folder name:") + if ok and name.strip(): + self.bookmark_to_folder.emit(name.strip()) + elif id(action) in folder_actions: + self.bookmark_to_folder.emit(folder_actions[id(action)]) + def _on_save_clicked(self) -> None: if self._save_btn.text() == "Unsave": self.unsave_requested.emit() return menu = QMenu(self) - unsorted = menu.addAction("Unsorted") + unsorted = menu.addAction("Unfiled") menu.addSeparator() folder_actions = {} if self._folders_callback: @@ -1456,6 +1605,7 @@ class ImagePreview(QWidget): self.save_to_folder.emit(folder_actions[id(action)]) def update_bookmark_state(self, bookmarked: bool) -> None: + self._is_bookmarked = bookmarked self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark") self._bookmark_btn.setFixedWidth(90 if bookmarked else 80) @@ -1477,6 +1627,13 @@ class ImagePreview(QWidget): def set_folders_callback(self, callback) -> None: self._folders_callback = callback + def set_bookmark_folders_callback(self, callback) -> None: + """Wire the bookmark folder list source. Called once from app.py + with self._db.get_folders. Kept separate from set_folders_callback + because library and bookmark folders are independent name spaces. + """ + self._bookmark_folders_callback = callback + def set_image(self, pixmap: QPixmap, info: str = "") -> None: self._video_player.stop() self._image_viewer.set_image(pixmap, info) @@ -1523,7 +1680,7 @@ class ImagePreview(QWidget): fav_action = menu.addAction("Bookmark") save_menu = menu.addMenu("Save to Library") - save_unsorted = save_menu.addAction("Unsorted") + save_unsorted = save_menu.addAction("Unfiled") save_menu.addSeparator() save_folder_actions = {} if self._folders_callback: