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:
parent
3f2c8aefe3
commit
250b144806
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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._update_fullscreen_state()
|
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:
|
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
|
post = self._preview._current_post
|
||||||
grid = self._bookmarks_view._grid
|
if post is not None:
|
||||||
favs = self._bookmarks_view._bookmarks
|
self._fullscreen_window.set_post_tags(
|
||||||
idx = grid.selected_index
|
post.tag_categories or {}, post.tag_list
|
||||||
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)
|
|
||||||
|
|
||||||
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,18 +1544,13 @@ 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))
|
self._set_preview_media(str(path), info)
|
||||||
for d in search_dirs:
|
self._update_fullscreen(str(path), info)
|
||||||
for ext in MEDIA_EXTENSIONS:
|
return
|
||||||
path = d / f"{fav.post_id}{ext}"
|
|
||||||
if path.exists():
|
|
||||||
self._set_preview_media(str(path), info)
|
|
||||||
self._update_fullscreen(str(path), info)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Download it
|
# Download it
|
||||||
self._status.showMessage(f"Downloading #{fav.post_id}...")
|
self._status.showMessage(f"Downloading #{fav.post_id}...")
|
||||||
@ -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,8 +2152,10 @@ 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
|
||||||
self._update_fullscreen_state()
|
# (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:
|
def _on_fullscreen_closed(self) -> None:
|
||||||
# Persist popout window state to DB
|
# Persist popout window state to DB
|
||||||
@ -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,23 +2634,78 @@ 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...")
|
||||||
|
|
||||||
|
# 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():
|
async def _save():
|
||||||
try:
|
try:
|
||||||
path = await download_image(post.file_url)
|
if existing is not None:
|
||||||
ext = Path(path).suffix
|
# Already in the library — relocate instead of re-saving.
|
||||||
if folder:
|
if existing.parent.resolve() != dest_resolved:
|
||||||
dest_dir = saved_folder_dir(folder)
|
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:
|
else:
|
||||||
dest_dir = saved_dir()
|
# Not in the library yet — pull from cache and copy in.
|
||||||
dest = dest_dir / f"{post.id}{ext}"
|
path = await download_image(post.file_url)
|
||||||
if not dest.exists():
|
ext = Path(path).suffix
|
||||||
import shutil
|
dest = dest_dir / f"{post.id}{ext}"
|
||||||
shutil.copy2(path, dest)
|
if not dest.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(path, dest)
|
||||||
|
|
||||||
# Copy browse thumbnail to library thumbnail cache
|
# Copy browse thumbnail to library thumbnail cache
|
||||||
if post.preview_url:
|
if post.preview_url:
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user