Decouple bookmark folders from library folders, add move-aware save + submenu pickers everywhere

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.
This commit is contained in:
pax 2026-04-07 19:50:39 -05:00
parent 3f2c8aefe3
commit 250b144806
6 changed files with 748 additions and 220 deletions

View File

@ -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: 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.""" """Delete every saved copy of `post_id` from the library.
from .config import saved_dir, saved_folder_dir
search_dir = saved_folder_dir(folder) if folder else saved_dir() Returns True if at least one file was deleted.
from .config import MEDIA_EXTENSIONS
for ext in MEDIA_EXTENSIONS: The `folder` argument is kept for back-compat with existing call sites
path = search_dir / f"{post_id}{ext}" but is now ignored we walk every library folder by post id and delete
if path.exists(): 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() path.unlink()
return True deleted = True
return False except OSError:
pass
return deleted
def cache_size_bytes(include_thumbnails: bool = True) -> int: def cache_size_bytes(include_thumbnails: bool = True) -> int:

View File

@ -109,6 +109,46 @@ def db_path() -> Path:
return data_dir() / "booru.db" 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 # Defaults
DEFAULT_THUMBNAIL_SIZE = (200, 200) DEFAULT_THUMBNAIL_SIZE = (200, 200)
DEFAULT_PAGE_SIZE = 40 DEFAULT_PAGE_SIZE = 40

View File

@ -512,6 +512,7 @@ class BooruApp(QMainWindow):
self._preview.open_in_default.connect(self._open_preview_in_default) self._preview.open_in_default.connect(self._open_preview_in_default)
self._preview.open_in_browser.connect(self._open_preview_in_browser) self._preview.open_in_browser.connect(self._open_preview_in_browser)
self._preview.bookmark_requested.connect(self._bookmark_from_preview) 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.save_to_folder.connect(self._save_from_preview)
self._preview.unsave_requested.connect(self._unsave_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_tag_requested.connect(self._blacklist_tag_from_popout)
@ -519,7 +520,13 @@ class BooruApp(QMainWindow):
self._preview.navigate.connect(self._navigate_preview) self._preview.navigate.connect(self._navigate_preview)
self._preview.play_next_requested.connect(self._on_video_end_next) self._preview.play_next_requested.connect(self._on_video_end_next)
self._preview.fullscreen_requested.connect(self._open_fullscreen_preview) 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 self._fullscreen_window = None
# Wide enough that the preview toolbar (Bookmark, Save, BL Tag, # Wide enough that the preview toolbar (Bookmark, Save, BL Tag,
# BL Post, [stretch], Popout) has room to lay out all five buttons # 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._library_view._grid.clear_selection()
self._preview._current_post = None self._preview._current_post = None
self._preview._current_site_id = 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 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._bookmark_btn.setVisible(not is_library)
self._preview._bl_tag_btn.setVisible(not is_library) self._preview._bl_tag_btn.setVisible(not is_library)
self._preview._bl_post_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 # Clear loading after a brief delay so scroll signals don't re-trigger
QTimer.singleShot(100, self._clear_loading) 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 from ..core.cache import cached_path_for, cache_dir
site_id = self._site_combo.currentData() 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() _sd = saved_dir()
_saved_ids: set[int] = set() _saved_ids: set[int] = set()
if _sd.exists(): if _sd.is_dir():
_saved_ids = {int(f.stem) for f in _sd.iterdir() if f.is_file() and f.stem.isdigit()} for entry in _sd.iterdir():
_folder_saved: dict[str, set[int]] = {} if entry.is_file() and entry.stem.isdigit():
for folder in self._db.get_folders(): _saved_ids.add(int(entry.stem))
d = saved_folder_dir(folder) elif entry.is_dir():
if d.exists(): for sub in entry.iterdir():
_folder_saved[folder] = {int(f.stem) for f in d.iterdir() if f.is_file() and f.stem.isdigit()} 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 # 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 # so the per-post check below is an O(1) membership test instead of
@ -1062,14 +1081,9 @@ class BooruApp(QMainWindow):
# Bookmark status (DB) # Bookmark status (DB)
if post.id in _bookmarked_ids: if post.id in _bookmarked_ids:
thumb.set_bookmarked(True) thumb.set_bookmarked(True)
# Saved status (filesystem) — independent of bookmark # Saved status (filesystem) — _saved_ids already covers both
saved = post.id in _saved_ids # the unsorted root and every library subdirectory.
if not saved: thumb.set_saved_locally(post.id in _saved_ids)
for folder_name, folder_ids in _folder_saved.items():
if post.id in folder_ids:
saved = True
break
thumb.set_saved_locally(saved)
# Set drag path from cache # Set drag path from cache
cached = cached_path_for(post.file_url) cached = cached_path_for(post.file_url)
if cached.name in _cached_names: if cached.name in _cached_names:
@ -1366,61 +1380,39 @@ class BooruApp(QMainWindow):
if self._fullscreen_window and self._fullscreen_window.isVisible(): if self._fullscreen_window and self._fullscreen_window.isVisible():
self._preview._video_player.stop() self._preview._video_player.stop()
self._fullscreen_window.set_media(path, info) self._fullscreen_window.set_media(path, info)
# Show/hide action buttons based on current tab # Bookmark / BL Tag / BL Post hidden on the library tab (no
show = self._stack.currentIndex() != 2 # site/post id to act on for local-only files). Save stays
self._fullscreen_window._bookmark_btn.setVisible(show) # visible — it acts as Unsave for the library file currently
self._fullscreen_window._save_btn.setVisible(show) # being viewed, matching the embedded preview's library mode.
self._fullscreen_window._bl_tag_btn.setVisible(show) show_full = self._stack.currentIndex() != 2
self._fullscreen_window._bl_post_btn.setVisible(show) self._fullscreen_window._bookmark_btn.setVisible(show_full)
if show: 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() self._update_fullscreen_state()
def _update_fullscreen_state(self) -> None: 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: if not self._fullscreen_window:
return return
from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS self._fullscreen_window.update_state(
site_id = self._site_combo.currentData() self._preview._is_bookmarked,
self._preview._is_saved,
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 post = self._preview._current_post
if post: if post is not None:
s_id = self._preview._current_site_id or site_id self._fullscreen_window.set_post_tags(
bookmarked = bool(s_id and self._db.is_bookmarked(s_id, post.id)) post.tag_categories or {}, post.tag_list
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)
def _on_image_done(self, path: str, info: str) -> None: def _on_image_done(self, path: str, info: str) -> None:
self._dl_progress.hide() self._dl_progress.hide()
@ -1552,15 +1544,10 @@ class BooruApp(QMainWindow):
self._update_fullscreen(fav.cached_path, info) self._update_fullscreen(fav.cached_path, info)
return return
# Try saved library # Try saved library — walk by post id; the file may live in any
from ..core.config import saved_dir, saved_folder_dir # library folder regardless of which bookmark folder fav is in.
search_dirs = [saved_dir()] from ..core.config import find_library_files
if fav.folder: for path in find_library_files(fav.post_id):
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._set_preview_media(str(path), info)
self._update_fullscreen(str(path), info) self._update_fullscreen(str(path), info)
return return
@ -1917,20 +1904,14 @@ class BooruApp(QMainWindow):
) )
def _is_post_saved(self, post_id: int) -> bool: def _is_post_saved(self, post_id: int) -> bool:
"""Check if a post is saved in the library (any folder).""" """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() Walks the library by post id rather than consulting the bookmark
if _sd.exists(): folder list library folders are filesystem-truth now, and a
for ext in MEDIA_EXTENSIONS: post can be in any folder regardless of bookmark state.
if (_sd / f"{post_id}{ext}").exists(): """
return True from ..core.config import find_library_files
for folder in self._db.get_folders(): return bool(find_library_files(post_id))
d = saved_folder_dir(folder)
if d.exists():
for ext in MEDIA_EXTENSIONS:
if (d / f"{post_id}{ext}").exists():
return True
return False
def _get_preview_post(self): def _get_preview_post(self):
"""Get the post currently shown in the preview, from grid or stored ref.""" """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: if self._stack.currentIndex() == 1:
self._bookmarks_view.refresh() 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: def _save_from_preview(self, folder: str) -> None:
post, idx = self._get_preview_post() post, idx = self._get_preview_post()
if post: if post:
target = folder if folder else None target = folder if folder else None
if folder and folder not in self._db.get_folders(): # _save_to_library calls saved_folder_dir() which mkdir's the
self._db.add_folder(folder) # target directory itself — no need to register it in the
# bookmark folders DB table (those are unrelated now).
self._save_to_library(post, target) self._save_to_library(post, target)
# State updates happen in _on_bookmark_done after async save completes # State updates happen in _on_bookmark_done after async save completes
@ -1983,25 +2013,10 @@ class BooruApp(QMainWindow):
post, idx = self._get_preview_post() post, idx = self._get_preview_post()
if not post: if not post:
return 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 from ..core.cache import delete_from_library
# Check all folders for saved files deleted = delete_from_library(post.id)
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
if deleted: if deleted:
self._status.showMessage(f"Removed #{post.id} from library") self._status.showMessage(f"Removed #{post.id} from library")
self._preview.update_save_state(False) self._preview.update_save_state(False)
@ -2023,12 +2038,6 @@ class BooruApp(QMainWindow):
self._status.showMessage(f"#{post.id} not in library") self._status.showMessage(f"#{post.id} not in library")
self._update_fullscreen_state() 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: def _blacklist_tag_from_popout(self, tag: str) -> None:
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Blacklist Tag", 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 = FullscreenPreview(grid_cols=cols, show_actions=show_actions, monitor=monitor, parent=self)
self._fullscreen_window.navigate.connect(self._navigate_fullscreen) self._fullscreen_window.navigate.connect(self._navigate_fullscreen)
self._fullscreen_window.play_next_requested.connect(self._on_video_end_next) 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: if show_actions:
self._fullscreen_window.bookmark_requested.connect(self._bookmark_from_preview) 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_tag_requested.connect(self._blacklist_tag_from_popout)
self._fullscreen_window.blacklist_post_requested.connect(self._blacklist_post_from_popout) self._fullscreen_window.blacklist_post_requested.connect(self._blacklist_post_from_popout)
self._fullscreen_window.closed.connect(self._on_fullscreen_closed) self._fullscreen_window.closed.connect(self._on_fullscreen_closed)
@ -2131,7 +2152,9 @@ class BooruApp(QMainWindow):
pass pass
sv.media_ready.connect(_seek_when_ready) sv.media_ready.connect(_seek_when_ready)
self._fullscreen_window.set_media(path, info) self._fullscreen_window.set_media(path, info)
if show_actions: # 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() self._update_fullscreen_state()
def _on_fullscreen_closed(self) -> None: def _on_fullscreen_closed(self) -> None:
@ -2205,12 +2228,14 @@ class BooruApp(QMainWindow):
menu.addSeparator() menu.addSeparator()
save_as = menu.addAction("Save As...") 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_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_menu.addSeparator()
save_lib_folders = {} save_lib_folders = {}
for folder in self._db.get_folders(): for folder in library_folders():
a = save_lib_menu.addAction(folder) a = save_lib_menu.addAction(folder)
save_lib_folders[id(a)] = folder save_lib_folders[id(a)] = folder
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
@ -2223,7 +2248,29 @@ class BooruApp(QMainWindow):
copy_url = menu.addAction("Copy Image URL") copy_url = menu.addAction("Copy Image URL")
copy_tags = menu.addAction("Copy Tags") copy_tags = menu.addAction("Copy Tags")
menu.addSeparator() 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() menu.addSeparator()
bl_menu = menu.addMenu("Blacklist Tag") bl_menu = menu.addMenu("Blacklist Tag")
if post.tag_categories: if post.tag_categories:
@ -2252,8 +2299,12 @@ class BooruApp(QMainWindow):
from PySide6.QtWidgets import QInputDialog, QMessageBox from PySide6.QtWidgets import QInputDialog, QMessageBox
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip(): if ok and name.strip():
# _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: try:
self._db.add_folder(name.strip()) from ..core.config import saved_folder_dir
saved_folder_dir(name.strip())
except ValueError as e: except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e)) QMessageBox.warning(self, "Invalid Folder Name", str(e))
return return
@ -2271,8 +2322,25 @@ class BooruApp(QMainWindow):
elif action == copy_tags: elif action == copy_tags:
QApplication.clipboard().setText(post.tags) QApplication.clipboard().setText(post.tags)
self._status.showMessage("Tags copied") 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) 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): elif self._is_child_of_menu(action, bl_menu):
tag = action.text() tag = action.text()
self._db.add_blacklisted_tag(tag) self._db.add_blacklisted_tag(tag)
@ -2361,9 +2429,10 @@ class BooruApp(QMainWindow):
fav_all = menu.addAction(f"Bookmark All ({count})") fav_all = menu.addAction(f"Bookmark All ({count})")
save_menu = menu.addMenu(f"Save All to Library ({count})") save_menu = menu.addMenu(f"Save All to Library ({count})")
save_unsorted = save_menu.addAction("Unsorted") save_unsorted = save_menu.addAction("Unfiled")
save_folder_actions = {} 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) a = save_menu.addAction(folder)
save_folder_actions[id(a)] = folder save_folder_actions[id(a)] = folder
save_menu.addSeparator() save_menu.addSeparator()
@ -2388,7 +2457,8 @@ class BooruApp(QMainWindow):
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip(): if ok and name.strip():
try: try:
self._db.add_folder(name.strip()) from ..core.config import saved_folder_dir
saved_folder_dir(name.strip())
except ValueError as e: except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e)) QMessageBox.warning(self, "Invalid Folder Name", str(e))
return return
@ -2404,13 +2474,9 @@ class BooruApp(QMainWindow):
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()
if site_id: if site_id:
from ..core.cache import delete_from_library from ..core.cache import delete_from_library
from ..core.config import saved_dir, saved_folder_dir
for post in posts: for post in posts:
# Delete from unsorted library # Single call now walks every library folder by post id.
delete_from_library(post.id, None) delete_from_library(post.id)
# Delete from all folders
for folder in self._db.get_folders():
delete_from_library(post.id, folder)
self._db.remove_bookmark(site_id, post.id) self._db.remove_bookmark(site_id, post.id)
for idx in indices: for idx in indices:
if 0 <= idx < len(self._grid._thumbs): if 0 <= idx < len(self._grid._thumbs):
@ -2451,7 +2517,7 @@ class BooruApp(QMainWindow):
def _bulk_save(self, indices: list[int], posts: list[Post], folder: str | None) -> None: def _bulk_save(self, indices: list[int], posts: list[Post], folder: str | None) -> None:
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()
where = folder or "Unsorted" where = folder or "Unfiled"
self._status.showMessage(f"Saving {len(posts)} to {where}...") self._status.showMessage(f"Saving {len(posts)} to {where}...")
async def _do(): async def _do():
@ -2568,19 +2634,74 @@ class BooruApp(QMainWindow):
self._status.showMessage("Image not cached yet — double-click to download first") self._status.showMessage("Image not cached yet — double-click to download first")
def _save_to_library(self, post: Post, folder: str | None) -> None: def _save_to_library(self, post: Post, folder: str | None) -> None:
"""Download and save image to the library folder structure.""" """Save (or relocate) an image in the library folder structure.
from ..core.config import saved_dir, saved_folder_dir
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...") self._status.showMessage(f"Saving #{post.id} to library...")
async def _save(): # 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: try:
path = await download_image(post.file_url)
ext = Path(path).suffix
if folder: if folder:
dest_dir = saved_folder_dir(folder) dest_dir = saved_folder_dir(folder)
else: else:
dest_dir = saved_dir() 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:
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:
# 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}" dest = dest_dir / f"{post.id}{ext}"
if not dest.exists(): if not dest.exists():
import shutil import shutil
@ -2607,7 +2728,7 @@ class BooruApp(QMainWindow):
source=post.source, file_url=post.file_url, source=post.source, file_url=post.file_url,
) )
where = folder or "Unsorted" where = folder or "Unfiled"
self._signals.bookmark_done.emit( self._signals.bookmark_done.emit(
self._grid.selected_index, self._grid.selected_index,
f"Saved #{post.id} to {where}" f"Saved #{post.id} to {where}"
@ -2837,7 +2958,14 @@ class BooruApp(QMainWindow):
# -- Bookmarks -- # -- 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] post = self._posts[index]
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()
if not site_id: if not site_id:
@ -2865,9 +2993,11 @@ class BooruApp(QMainWindow):
score=post.score, score=post.score,
source=post.source, source=post.source,
cached_path=str(path), cached_path=str(path),
folder=folder,
tag_categories=post.tag_categories, 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: except Exception as e:
self._signals.bookmark_error.emit(str(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: def _on_bookmark_done(self, index: int, msg: str) -> None:
self._status.showMessage(f"{len(self._posts)} results — {msg}") 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:]) is_batch = "/" in msg and any(c.isdigit() for c in msg.split("/")[0][-2:])
thumbs = self._grid._thumbs thumbs = self._grid._thumbs
if 0 <= index < len(thumbs): if 0 <= index < len(thumbs):

View File

@ -71,12 +71,28 @@ class BookmarksView(QWidget):
top.addWidget(self._folder_combo) top.addWidget(self._folder_combo)
manage_btn = QPushButton("+ Folder") manage_btn = QPushButton("+ Folder")
manage_btn.setToolTip("New folder") manage_btn.setToolTip("New bookmark folder")
manage_btn.setFixedWidth(75) manage_btn.setFixedWidth(75)
manage_btn.setStyleSheet(_btn_style) manage_btn.setStyleSheet(_btn_style)
manage_btn.clicked.connect(self._new_folder) manage_btn.clicked.connect(self._new_folder)
top.addWidget(manage_btn) 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 = QLineEdit()
self._search_input.setPlaceholderText("Search bookmarks by tag") self._search_input.setPlaceholderText("Search bookmarks by tag")
# Enter still triggers an immediate search. # Enter still triggers an immediate search.
@ -120,6 +136,39 @@ class BookmarksView(QWidget):
if idx >= 0: if idx >= 0:
self._folder_combo.setCurrentIndex(idx) self._folder_combo.setCurrentIndex(idx)
self._folder_combo.blockSignals(False) 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: def refresh(self, search: str | None = None) -> None:
self._refresh_folders() self._refresh_folders()
@ -145,22 +194,12 @@ class BookmarksView(QWidget):
self._count_label.setText(f"{len(self._bookmarks)} bookmarks") self._count_label.setText(f"{len(self._bookmarks)} bookmarks")
thumbs = self._grid.set_posts(len(self._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)): for i, (fav, thumb) in enumerate(zip(self._bookmarks, thumbs)):
thumb.set_bookmarked(True) thumb.set_bookmarked(True)
# Check if saved to library # Library state is filesystem-truth and folder-agnostic now —
saved = False # walk the library by post id, ignore the bookmark's folder.
if fav.folder: thumb.set_saved_locally(bool(find_library_files(fav.post_id)))
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)
# Set cached path for drag-and-drop and copy # Set cached path for drag-and-drop and copy
if fav.cached_path and Path(fav.cached_path).exists(): if fav.cached_path and Path(fav.cached_path).exists():
thumb._cached_path = fav.cached_path thumb._cached_path = fav.cached_path
@ -250,31 +289,22 @@ class BookmarksView(QWidget):
menu.addSeparator() menu.addSeparator()
save_as = menu.addAction("Save As...") 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_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_menu.addSeparator()
save_lib_folders = {} save_lib_folders = {}
for folder in self._db.get_folders(): for folder in library_folders():
a = save_lib_menu.addAction(folder) a = save_lib_menu.addAction(folder)
save_lib_folders[id(a)] = folder save_lib_folders[id(a)] = folder
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...") save_lib_new = save_lib_menu.addAction("+ New Folder...")
unsave_lib = None unsave_lib = None
# Only show unsave if the post is saved locally # Only show unsave if the post is actually on disk somewhere.
from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS if find_library_files(fav.post_id):
_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:
unsave_lib = menu.addAction("Unsave from Library") unsave_lib = menu.addAction("Unsave from Library")
copy_file = menu.addAction("Copy File to Clipboard") copy_file = menu.addAction("Copy File to Clipboard")
copy_url = menu.addAction("Copy Image URL") copy_url = menu.addAction("Copy Image URL")
@ -305,13 +335,16 @@ class BookmarksView(QWidget):
elif action == save_lib_new: elif action == save_lib_new:
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip(): if ok and name.strip():
# 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: try:
self._db.add_folder(name.strip()) from ..core.config import saved_folder_dir
saved_folder_dir(name.strip())
except ValueError as e: except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e)) QMessageBox.warning(self, "Invalid Folder Name", str(e))
return return
self._copy_to_library(fav, name.strip()) self._copy_to_library(fav, name.strip())
self._db.move_bookmark_to_folder(fav.id, name.strip())
self.refresh() self.refresh()
elif id(action) in save_lib_folders: elif id(action) in save_lib_folders:
folder_name = save_lib_folders[id(action)] folder_name = save_lib_folders[id(action)]
@ -331,7 +364,10 @@ class BookmarksView(QWidget):
shutil.copy2(src, dest) shutil.copy2(src, dest)
elif action == unsave_lib: elif action == unsave_lib:
from ..core.cache import delete_from_library 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.refresh()
self.bookmarks_changed.emit() self.bookmarks_changed.emit()
elif action == copy_file: elif action == copy_file:
@ -360,13 +396,14 @@ class BookmarksView(QWidget):
except ValueError as e: except ValueError as e:
QMessageBox.warning(self, "Invalid Folder Name", str(e)) QMessageBox.warning(self, "Invalid Folder Name", str(e))
return 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._db.move_bookmark_to_folder(fav.id, name.strip())
self._copy_to_library(fav, name.strip())
self.refresh() self.refresh()
elif id(action) in folder_actions: elif id(action) in folder_actions:
folder_name = folder_actions[id(action)] folder_name = folder_actions[id(action)]
self._db.move_bookmark_to_folder(fav.id, folder_name) self._db.move_bookmark_to_folder(fav.id, folder_name)
self._copy_to_library(fav, folder_name)
self.refresh() self.refresh()
elif action == remove_bookmark: elif action == remove_bookmark:
self._db.remove_bookmark(fav.site_id, fav.post_id) self._db.remove_bookmark(fav.site_id, fav.post_id)
@ -378,11 +415,28 @@ class BookmarksView(QWidget):
if not favs: if not favs:
return return
from ..core.config import library_folders
menu = QMenu(self) 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") unsave_all = menu.addAction(f"Unsave All ({len(favs)}) from Library")
menu.addSeparator() menu.addSeparator()
# Move to Folder is bookmark organization — reads from the DB.
move_menu = menu.addMenu(f"Move All ({len(favs)}) to Folder") move_menu = menu.addMenu(f"Move All ({len(favs)}) to Folder")
move_none = move_menu.addAction("Unfiled") move_none = move_menu.addAction("Unfiled")
move_menu.addSeparator() move_menu.addSeparator()
@ -398,17 +452,32 @@ class BookmarksView(QWidget):
if not action: if not action:
return return
if action == save_all: def _save_all_into(folder_name: str | None) -> None:
for fav in favs: for fav in favs:
if fav.folder: if folder_name:
self._copy_to_library(fav, fav.folder) self._copy_to_library(fav, folder_name)
else: else:
self._copy_to_library_unsorted(fav) self._copy_to_library_unsorted(fav)
self.refresh() 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: elif action == unsave_all:
from ..core.cache import delete_from_library from ..core.cache import delete_from_library
for fav in favs: for fav in favs:
delete_from_library(fav.post_id, fav.folder) delete_from_library(fav.post_id)
self.refresh() self.refresh()
self.bookmarks_changed.emit() self.bookmarks_changed.emit()
elif action == move_none: elif action == move_none:
@ -417,9 +486,9 @@ class BookmarksView(QWidget):
self.refresh() self.refresh()
elif id(action) in folder_actions: elif id(action) in folder_actions:
folder_name = folder_actions[id(action)] folder_name = folder_actions[id(action)]
# Bookmark organization only — Save to Library is separate.
for fav in favs: for fav in favs:
self._db.move_bookmark_to_folder(fav.id, folder_name) self._db.move_bookmark_to_folder(fav.id, folder_name)
self._copy_to_library(fav, folder_name)
self.refresh() self.refresh()
elif action == remove_all: elif action == remove_all:
for fav in favs: for fav in favs:

View File

@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
QComboBox, QComboBox,
QMenu, QMenu,
QMessageBox, QMessageBox,
QInputDialog,
QApplication, QApplication,
) )
@ -194,7 +195,7 @@ class LibraryView(QWidget):
self._folder_combo.blockSignals(True) self._folder_combo.blockSignals(True)
self._folder_combo.clear() self._folder_combo.clear()
self._folder_combo.addItem("All Files") self._folder_combo.addItem("All Files")
self._folder_combo.addItem("Unsorted") self._folder_combo.addItem("Unfiled")
root = saved_dir() root = saved_dir()
if root.is_dir(): if root.is_dir():
@ -217,7 +218,7 @@ class LibraryView(QWidget):
if folder_text == "All Files": if folder_text == "All Files":
return self._collect_recursive(root) return self._collect_recursive(root)
elif folder_text == "Unsorted": elif folder_text == "Unfiled":
return self._collect_top_level(root) return self._collect_top_level(root)
else: else:
sub = root / folder_text sub = root / folder_text
@ -347,6 +348,76 @@ class LibraryView(QWidget):
if 0 <= index < len(self._files): if 0 <= index < len(self._files):
self.file_activated.emit(str(self._files[index])) 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: def _on_context_menu(self, index: int, pos) -> None:
if index < 0 or index >= len(self._files): if index < 0 or index >= len(self._files):
return return
@ -361,6 +432,25 @@ class LibraryView(QWidget):
menu.addSeparator() menu.addSeparator()
copy_file = menu.addAction("Copy File to Clipboard") copy_file = menu.addAction("Copy File to Clipboard")
copy_path = menu.addAction("Copy File Path") 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() menu.addSeparator()
delete_action = menu.addAction("Delete from Library") delete_action = menu.addAction("Delete from Library")
@ -372,6 +462,14 @@ class LibraryView(QWidget):
QDesktopServices.openUrl(QUrl.fromLocalFile(str(filepath))) QDesktopServices.openUrl(QUrl.fromLocalFile(str(filepath)))
elif action == open_folder: elif action == open_folder:
QDesktopServices.openUrl(QUrl.fromLocalFile(str(filepath.parent))) 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: elif action == copy_file:
from PySide6.QtCore import QMimeData from PySide6.QtCore import QMimeData
from PySide6.QtGui import QPixmap as _QP from PySide6.QtGui import QPixmap as _QP
@ -403,13 +501,36 @@ class LibraryView(QWidget):
return return
menu = QMenu(self) 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") delete_all = menu.addAction(f"Delete {len(files)} files from Library")
action = menu.exec(pos) action = menu.exec(pos)
if not action: if not action:
return 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( reply = QMessageBox.question(
self, "Confirm", f"Delete {len(files)} files from library?", self, "Confirm", f"Delete {len(files)} files from library?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,

View File

@ -38,7 +38,15 @@ class FullscreenPreview(QMainWindow):
navigate = Signal(int) # direction: -1/+1 for left/right, -cols/+cols for up/down 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) play_next_requested = Signal() # video ended in "Next" mode (wrap-aware)
bookmark_requested = Signal() 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_tag_requested = Signal(str) # tag name
blacklist_post_requested = Signal() blacklist_post_requested = Signal()
privacy_requested = Signal() privacy_requested = Signal()
@ -89,16 +97,26 @@ class FullscreenPreview(QMainWindow):
# short labels in narrow fixed slots. # short labels in narrow fixed slots.
_tb_btn_style = "padding: 2px 6px;" _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 = QPushButton("Bookmark")
self._bookmark_btn.setMaximumWidth(90) self._bookmark_btn.setMaximumWidth(90)
self._bookmark_btn.setStyleSheet(_tb_btn_style) 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) toolbar.addWidget(self._bookmark_btn)
self._save_btn = QPushButton("Save") self._save_btn = QPushButton("Save")
self._save_btn.setMaximumWidth(70) self._save_btn.setMaximumWidth(70)
self._save_btn.setStyleSheet(_tb_btn_style) 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) toolbar.addWidget(self._save_btn)
self._is_saved = False self._is_saved = False
@ -117,8 +135,11 @@ class FullscreenPreview(QMainWindow):
toolbar.addWidget(self._bl_post_btn) toolbar.addWidget(self._bl_post_btn)
if not show_actions: 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._bookmark_btn.hide()
self._save_btn.hide()
self._bl_tag_btn.hide() self._bl_tag_btn.hide()
self._bl_post_btn.hide() self._bl_post_btn.hide()
@ -238,11 +259,91 @@ class FullscreenPreview(QMainWindow):
self.blacklist_tag_requested.emit(action.text()) self.blacklist_tag_requested.emit(action.text())
def update_state(self, bookmarked: bool, saved: bool) -> None: 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.setText("Unbookmark" if bookmarked else "Bookmark")
self._bookmark_btn.setMaximumWidth(90 if bookmarked else 80) self._bookmark_btn.setMaximumWidth(90 if bookmarked else 80)
self._is_saved = saved self._is_saved = saved
self._save_btn.setText("Unsave" if saved else "Save") 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: def set_media(self, path: str, info: str = "") -> None:
self._info_label.setText(info) self._info_label.setText(info)
ext = Path(path).suffix.lower() ext = Path(path).suffix.lower()
@ -1293,6 +1394,10 @@ class ImagePreview(QWidget):
save_to_folder = Signal(str) save_to_folder = Signal(str)
unsave_requested = Signal() unsave_requested = Signal()
bookmark_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_tag_requested = Signal(str)
blacklist_post_requested = Signal() blacklist_post_requested = Signal()
navigate = Signal(int) # -1 = prev, +1 = next navigate = Signal(int) # -1 = prev, +1 = next
@ -1302,10 +1407,15 @@ class ImagePreview(QWidget):
def __init__(self, parent: QWidget | None = None) -> None: def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._folders_callback = None 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_path: str | None = None
self._current_post = None # Post object, set by app.py self._current_post = None # Post object, set by app.py
self._current_site_id = None # site_id for the current post self._current_site_id = None # site_id for the current post
self._is_saved = False # tracks library save state for context menu 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_tags: dict[str, list[str]] = {}
self._current_tag_list: list[str] = [] self._current_tag_list: list[str] = []
@ -1333,11 +1443,14 @@ class ImagePreview(QWidget):
self._bookmark_btn = QPushButton("Bookmark") self._bookmark_btn = QPushButton("Bookmark")
self._bookmark_btn.setFixedWidth(100) self._bookmark_btn.setFixedWidth(100)
self._bookmark_btn.setStyleSheet(_tb_btn_style) 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) tb.addWidget(self._bookmark_btn)
self._save_btn = QPushButton("Save") 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.setStyleSheet(_tb_btn_style)
self._save_btn.clicked.connect(self._on_save_clicked) self._save_btn.clicked.connect(self._on_save_clicked)
tb.addWidget(self._save_btn) tb.addWidget(self._save_btn)
@ -1429,12 +1542,48 @@ class ImagePreview(QWidget):
if action: if action:
self.blacklist_tag_requested.emit(action.text()) 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: def _on_save_clicked(self) -> None:
if self._save_btn.text() == "Unsave": if self._save_btn.text() == "Unsave":
self.unsave_requested.emit() self.unsave_requested.emit()
return return
menu = QMenu(self) menu = QMenu(self)
unsorted = menu.addAction("Unsorted") unsorted = menu.addAction("Unfiled")
menu.addSeparator() menu.addSeparator()
folder_actions = {} folder_actions = {}
if self._folders_callback: if self._folders_callback:
@ -1456,6 +1605,7 @@ class ImagePreview(QWidget):
self.save_to_folder.emit(folder_actions[id(action)]) self.save_to_folder.emit(folder_actions[id(action)])
def update_bookmark_state(self, bookmarked: bool) -> None: def update_bookmark_state(self, bookmarked: bool) -> None:
self._is_bookmarked = bookmarked
self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark") self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark")
self._bookmark_btn.setFixedWidth(90 if bookmarked else 80) self._bookmark_btn.setFixedWidth(90 if bookmarked else 80)
@ -1477,6 +1627,13 @@ class ImagePreview(QWidget):
def set_folders_callback(self, callback) -> None: def set_folders_callback(self, callback) -> None:
self._folders_callback = callback 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: def set_image(self, pixmap: QPixmap, info: str = "") -> None:
self._video_player.stop() self._video_player.stop()
self._image_viewer.set_image(pixmap, info) self._image_viewer.set_image(pixmap, info)
@ -1523,7 +1680,7 @@ class ImagePreview(QWidget):
fav_action = menu.addAction("Bookmark") fav_action = menu.addAction("Bookmark")
save_menu = menu.addMenu("Save to Library") save_menu = menu.addMenu("Save to Library")
save_unsorted = save_menu.addAction("Unsorted") save_unsorted = save_menu.addAction("Unfiled")
save_menu.addSeparator() save_menu.addSeparator()
save_folder_actions = {} save_folder_actions = {}
if self._folders_callback: if self._folders_callback: