v0.1.4 — Library rewrite: Browse | Bookmarks | Library

Major restructure of the favorites/library system:

- Rename "Favorites" to "Bookmarks" throughout (DB API, GUI, signals)
- Add Library tab for browsing saved files on disk with sorting
- Decouple bookmark from save — independent operations now
- Two indicators on thumbnails: star (bookmarked), green dot (saved)
- Both indicators QSS-controllable (qproperty-bookmarkedColor/savedColor)
- Unbookmarking no longer deletes saved files
- Saving no longer auto-bookmarks
- Library tab: folder sidebar, sort by date/name/size, async thumbnails
- DB table kept as "favorites" internally for migration safety
This commit is contained in:
pax 2026-04-05 01:38:41 -05:00
parent 243a889fc1
commit 72e4d5c5a2
9 changed files with 491 additions and 239 deletions

View File

@ -1,4 +1,4 @@
"""SQLite database for favorites, sites, and cache metadata.""" """SQLite database for bookmarks, sites, and cache metadata."""
from __future__ import annotations from __future__ import annotations
@ -105,7 +105,7 @@ class Site:
@dataclass @dataclass
class Favorite: class Bookmark:
id: int id: int
site_id: int site_id: int
post_id: int post_id: int
@ -117,7 +117,11 @@ class Favorite:
source: str | None source: str | None
cached_path: str | None cached_path: str | None
folder: str | None folder: str | None
favorited_at: str bookmarked_at: str
# Back-compat alias — will be removed in a future version.
Favorite = Bookmark
class Database: class Database:
@ -217,9 +221,9 @@ class Database:
) )
self.conn.commit() self.conn.commit()
# -- Favorites -- # -- Bookmarks --
def add_favorite( def add_bookmark(
self, self,
site_id: int, site_id: int,
post_id: int, post_id: int,
@ -231,7 +235,7 @@ class Database:
source: str | None = None, source: str | None = None,
cached_path: str | None = None, cached_path: str | None = None,
folder: str | None = None, folder: str | None = None,
) -> Favorite: ) -> Bookmark:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
cur = self.conn.execute( cur = self.conn.execute(
"INSERT OR IGNORE INTO favorites " "INSERT OR IGNORE INTO favorites "
@ -240,7 +244,7 @@ class Database:
(site_id, post_id, file_url, preview_url, tags, rating, score, source, cached_path, folder, now), (site_id, post_id, file_url, preview_url, tags, rating, score, source, cached_path, folder, now),
) )
self.conn.commit() self.conn.commit()
return Favorite( return Bookmark(
id=cur.lastrowid, # type: ignore[arg-type] id=cur.lastrowid, # type: ignore[arg-type]
site_id=site_id, site_id=site_id,
post_id=post_id, post_id=post_id,
@ -252,12 +256,15 @@ class Database:
source=source, source=source,
cached_path=cached_path, cached_path=cached_path,
folder=folder, folder=folder,
favorited_at=now, bookmarked_at=now,
) )
def add_favorites_batch(self, favorites: list[dict]) -> None: # Back-compat shim
"""Add multiple favorites in a single transaction.""" add_favorite = add_bookmark
for fav in favorites:
def add_bookmarks_batch(self, bookmarks: list[dict]) -> None:
"""Add multiple bookmarks in a single transaction."""
for fav in bookmarks:
self.conn.execute( self.conn.execute(
"INSERT OR IGNORE INTO favorites " "INSERT OR IGNORE INTO favorites "
"(site_id, post_id, file_url, preview_url, tags, rating, score, source, cached_path, folder, favorited_at) " "(site_id, post_id, file_url, preview_url, tags, rating, score, source, cached_path, folder, favorited_at) "
@ -268,28 +275,37 @@ class Database:
) )
self.conn.commit() self.conn.commit()
def remove_favorite(self, site_id: int, post_id: int) -> None: # Back-compat shim
add_favorites_batch = add_bookmarks_batch
def remove_bookmark(self, site_id: int, post_id: int) -> None:
self.conn.execute( self.conn.execute(
"DELETE FROM favorites WHERE site_id = ? AND post_id = ?", "DELETE FROM favorites WHERE site_id = ? AND post_id = ?",
(site_id, post_id), (site_id, post_id),
) )
self.conn.commit() self.conn.commit()
def is_favorited(self, site_id: int, post_id: int) -> bool: # Back-compat shim
remove_favorite = remove_bookmark
def is_bookmarked(self, site_id: int, post_id: int) -> bool:
row = self.conn.execute( row = self.conn.execute(
"SELECT 1 FROM favorites WHERE site_id = ? AND post_id = ?", "SELECT 1 FROM favorites WHERE site_id = ? AND post_id = ?",
(site_id, post_id), (site_id, post_id),
).fetchone() ).fetchone()
return row is not None return row is not None
def get_favorites( # Back-compat shim
is_favorited = is_bookmarked
def get_bookmarks(
self, self,
search: str | None = None, search: str | None = None,
site_id: int | None = None, site_id: int | None = None,
folder: str | None = None, folder: str | None = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
) -> list[Favorite]: ) -> list[Bookmark]:
q = "SELECT * FROM favorites WHERE 1=1" q = "SELECT * FROM favorites WHERE 1=1"
params: list = [] params: list = []
if site_id is not None: if site_id is not None:
@ -305,11 +321,14 @@ class Database:
q += " ORDER BY favorited_at DESC LIMIT ? OFFSET ?" q += " ORDER BY favorited_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset]) params.extend([limit, offset])
rows = self.conn.execute(q, params).fetchall() rows = self.conn.execute(q, params).fetchall()
return [self._row_to_favorite(r) for r in rows] return [self._row_to_bookmark(r) for r in rows]
# Back-compat shim
get_favorites = get_bookmarks
@staticmethod @staticmethod
def _row_to_favorite(r) -> Favorite: def _row_to_bookmark(r) -> Bookmark:
return Favorite( return Bookmark(
id=r["id"], id=r["id"],
site_id=r["site_id"], site_id=r["site_id"],
post_id=r["post_id"], post_id=r["post_id"],
@ -321,20 +340,29 @@ class Database:
source=r["source"], source=r["source"],
cached_path=r["cached_path"], cached_path=r["cached_path"],
folder=r["folder"] if "folder" in r.keys() else None, folder=r["folder"] if "folder" in r.keys() else None,
favorited_at=r["favorited_at"], bookmarked_at=r["favorited_at"],
) )
def update_favorite_cache_path(self, fav_id: int, cached_path: str) -> None: # Back-compat shim
_row_to_favorite = _row_to_bookmark
def update_bookmark_cache_path(self, fav_id: int, cached_path: str) -> None:
self.conn.execute( self.conn.execute(
"UPDATE favorites SET cached_path = ? WHERE id = ?", "UPDATE favorites SET cached_path = ? WHERE id = ?",
(cached_path, fav_id), (cached_path, fav_id),
) )
self.conn.commit() self.conn.commit()
def favorite_count(self) -> int: # Back-compat shim
update_favorite_cache_path = update_bookmark_cache_path
def bookmark_count(self) -> int:
row = self.conn.execute("SELECT COUNT(*) FROM favorites").fetchone() row = self.conn.execute("SELECT COUNT(*) FROM favorites").fetchone()
return row[0] return row[0]
# Back-compat shim
favorite_count = bookmark_count
# -- Folders -- # -- Folders --
def get_folders(self) -> list[str]: def get_folders(self) -> list[str]:
@ -363,12 +391,15 @@ class Database:
) )
self.conn.commit() self.conn.commit()
def move_favorite_to_folder(self, fav_id: int, folder: str | None) -> None: def move_bookmark_to_folder(self, fav_id: int, folder: str | None) -> None:
self.conn.execute( self.conn.execute(
"UPDATE favorites SET folder = ? WHERE id = ?", (folder, fav_id) "UPDATE favorites SET folder = ? WHERE id = ?", (folder, fav_id)
) )
self.conn.commit() self.conn.commit()
# Back-compat shim
move_favorite_to_folder = move_bookmark_to_folder
# -- Blacklist -- # -- Blacklist --
def add_blacklisted_tag(self, tag: str) -> None: def add_blacklisted_tag(self, tag: str) -> None:

View File

@ -43,7 +43,8 @@ from .grid import ThumbnailGrid
from .preview import ImagePreview from .preview import ImagePreview
from .search import SearchBar from .search import SearchBar
from .sites import SiteManagerDialog from .sites import SiteManagerDialog
from .favorites import FavoritesView from .bookmarks import BookmarksView
from .library import LibraryView
from .settings import SettingsDialog from .settings import SettingsDialog
log = logging.getLogger("booru") log = logging.getLogger("booru")
@ -78,8 +79,8 @@ class AsyncSignals(QObject):
thumb_done = Signal(int, str) thumb_done = Signal(int, str)
image_done = Signal(str, str) image_done = Signal(str, str)
image_error = Signal(str) image_error = Signal(str)
fav_done = Signal(int, str) bookmark_done = Signal(int, str)
fav_error = Signal(str) bookmark_error = Signal(str)
autocomplete_done = Signal(list) autocomplete_done = Signal(list)
batch_progress = Signal(int, int) # current, total batch_progress = Signal(int, int) # current, total
batch_done = Signal(str) batch_done = Signal(str)
@ -231,8 +232,8 @@ class BooruApp(QMainWindow):
s.thumb_done.connect(self._on_thumb_done, Q) s.thumb_done.connect(self._on_thumb_done, Q)
s.image_done.connect(self._on_image_done, Q) s.image_done.connect(self._on_image_done, Q)
s.image_error.connect(self._on_image_error, Q) s.image_error.connect(self._on_image_error, Q)
s.fav_done.connect(self._on_fav_done, Q) s.bookmark_done.connect(self._on_bookmark_done, Q)
s.fav_error.connect(self._on_fav_error, Q) s.bookmark_error.connect(self._on_bookmark_error, Q)
s.autocomplete_done.connect(self._on_autocomplete_done, Q) s.autocomplete_done.connect(self._on_autocomplete_done, Q)
s.batch_progress.connect(self._on_batch_progress, Q) s.batch_progress.connect(self._on_batch_progress, Q)
s.batch_done.connect(lambda m: self._status.showMessage(m), Q) s.batch_done.connect(lambda m: self._status.showMessage(m), Q)
@ -254,7 +255,7 @@ class BooruApp(QMainWindow):
self._dl_progress.hide() self._dl_progress.hide()
self._status.showMessage(f"Error: {e}") self._status.showMessage(f"Error: {e}")
def _on_fav_error(self, e: str) -> None: def _on_bookmark_error(self, e: str) -> None:
self._status.showMessage(f"Error: {e}") self._status.showMessage(f"Error: {e}")
def _run_async(self, coro_func, *args): def _run_async(self, coro_func, *args):
@ -314,10 +315,16 @@ class BooruApp(QMainWindow):
self._browse_btn.clicked.connect(lambda: self._switch_view(0)) self._browse_btn.clicked.connect(lambda: self._switch_view(0))
nav.addWidget(self._browse_btn) nav.addWidget(self._browse_btn)
self._fav_btn = QPushButton("Favorites") self._bookmark_btn = QPushButton("Bookmarks")
self._fav_btn.setCheckable(True) self._bookmark_btn.setCheckable(True)
self._fav_btn.clicked.connect(lambda: self._switch_view(1)) self._bookmark_btn.clicked.connect(lambda: self._switch_view(1))
nav.addWidget(self._fav_btn) nav.addWidget(self._bookmark_btn)
self._library_btn = QPushButton("Library")
self._library_btn.setCheckable(True)
self._library_btn.setFixedWidth(60)
self._library_btn.clicked.connect(lambda: self._switch_view(2))
nav.addWidget(self._library_btn)
layout.addLayout(nav) layout.addLayout(nav)
@ -338,10 +345,15 @@ class BooruApp(QMainWindow):
self._grid.page_back.connect(self._prev_page) self._grid.page_back.connect(self._prev_page)
self._stack.addWidget(self._grid) self._stack.addWidget(self._grid)
self._favorites_view = FavoritesView(self._db) self._bookmarks_view = BookmarksView(self._db)
self._favorites_view.favorite_selected.connect(self._on_favorite_selected) self._bookmarks_view.bookmark_selected.connect(self._on_bookmark_selected)
self._favorites_view.favorite_activated.connect(self._on_favorite_activated) self._bookmarks_view.bookmark_activated.connect(self._on_bookmark_activated)
self._stack.addWidget(self._favorites_view) self._stack.addWidget(self._bookmarks_view)
self._library_view = LibraryView(self._db)
self._library_view.file_selected.connect(self._on_library_selected)
self._library_view.file_activated.connect(self._on_library_activated)
self._stack.addWidget(self._library_view)
self._splitter.addWidget(self._stack) self._splitter.addWidget(self._stack)
@ -352,7 +364,7 @@ class BooruApp(QMainWindow):
self._preview.close_requested.connect(self._close_preview) self._preview.close_requested.connect(self._close_preview)
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.favorite_requested.connect(self._favorite_from_preview) self._preview.bookmark_requested.connect(self._bookmark_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.navigate.connect(self._navigate_preview) self._preview.navigate.connect(self._navigate_preview)
@ -500,10 +512,13 @@ class BooruApp(QMainWindow):
def _switch_view(self, index: int) -> None: def _switch_view(self, index: int) -> None:
self._stack.setCurrentIndex(index) self._stack.setCurrentIndex(index)
self._browse_btn.setChecked(index == 0) self._browse_btn.setChecked(index == 0)
self._fav_btn.setChecked(index == 1) self._bookmark_btn.setChecked(index == 1)
self._library_btn.setChecked(index == 2)
if index == 1: if index == 1:
self._favorites_view.refresh() self._bookmarks_view.refresh()
self._favorites_view._grid.setFocus() self._bookmarks_view._grid.setFocus()
elif index == 2:
self._library_view.refresh()
else: else:
self._grid.setFocus() self._grid.setFocus()
@ -672,19 +687,19 @@ class BooruApp(QMainWindow):
if d.exists(): if d.exists():
_folder_saved[folder] = {int(f.stem) for f in d.iterdir() if f.is_file() and f.stem.isdigit()} _folder_saved[folder] = {int(f.stem) for f in d.iterdir() if f.is_file() and f.stem.isdigit()}
# Pre-fetch favorites for the site once (used for folder checks) # Pre-fetch bookmarks for the site once (used for folder checks)
_favs = self._db.get_favorites(site_id=site_id) if site_id else [] _favs = self._db.get_bookmarks(site_id=site_id) if site_id else []
for i, (post, thumb) in enumerate(zip(posts, thumbs)): for i, (post, thumb) in enumerate(zip(posts, thumbs)):
if site_id and self._db.is_favorited(site_id, post.id): # Bookmark status (DB)
thumb.set_favorited(True) if site_id and self._db.is_bookmarked(site_id, post.id):
# Check if saved to library (not just cached) thumb.set_bookmarked(True)
# Saved status (filesystem) — independent of bookmark
saved = post.id in _saved_ids saved = post.id in _saved_ids
if not saved: if not saved:
# Check folders for folder_name, folder_ids in _folder_saved.items():
for f in _favs: if post.id in folder_ids:
if f.post_id == post.id and f.folder and f.folder in _folder_saved: saved = True
saved = post.id in _folder_saved[f.folder]
break break
thumb.set_saved_locally(saved) thumb.set_saved_locally(saved)
# Set drag path from cache # Set drag path from cache
@ -834,9 +849,9 @@ class BooruApp(QMainWindow):
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()
if self._stack.currentIndex() == 1: if self._stack.currentIndex() == 1:
# Favorites view # Bookmarks view
grid = self._favorites_view._grid grid = self._bookmarks_view._grid
favs = self._favorites_view._favorites favs = self._bookmarks_view._bookmarks
idx = grid.selected_index idx = grid.selected_index
if 0 <= idx < len(favs): if 0 <= idx < len(favs):
fav = favs[idx] fav = favs[idx]
@ -858,7 +873,7 @@ class BooruApp(QMainWindow):
idx = self._grid.selected_index idx = self._grid.selected_index
if 0 <= idx < len(self._posts) and site_id: if 0 <= idx < len(self._posts) and site_id:
post = self._posts[idx] post = self._posts[idx]
favorited = self._db.is_favorited(site_id, post.id) bookmarked = self._db.is_bookmarked(site_id, post.id)
saved = any( saved = any(
(saved_dir() / f"{post.id}{ext}").exists() (saved_dir() / f"{post.id}{ext}").exists()
for ext in MEDIA_EXTENSIONS for ext in MEDIA_EXTENSIONS
@ -871,7 +886,7 @@ class BooruApp(QMainWindow):
) )
if saved: if saved:
break break
self._fullscreen_window.update_state(favorited, saved) self._fullscreen_window.update_state(bookmarked, saved)
else: else:
self._fullscreen_window.update_state(False, False) self._fullscreen_window.update_state(False, False)
@ -897,19 +912,27 @@ class BooruApp(QMainWindow):
current = cache_size_bytes(include_thumbnails=False) current = cache_size_bytes(include_thumbnails=False)
if current > max_bytes: if current > max_bytes:
protected = set() protected = set()
for fav in self._db.get_favorites(limit=999999): for fav in self._db.get_bookmarks(limit=999999):
if fav.cached_path: if fav.cached_path:
protected.add(fav.cached_path) protected.add(fav.cached_path)
evicted = evict_oldest(max_bytes, protected) evicted = evict_oldest(max_bytes, protected)
if evicted: if evicted:
log.info(f"Auto-evicted {evicted} cached files") log.info(f"Auto-evicted {evicted} cached files")
def _on_favorite_selected(self, fav) -> None: def _on_library_selected(self, path: str) -> None:
self._status.showMessage(f"Favorite #{fav.post_id}") self._preview.set_media(path, Path(path).name)
self._on_favorite_activated(fav) self._update_fullscreen(path, Path(path).name)
def _on_favorite_activated(self, fav) -> None: def _on_library_activated(self, path: str) -> None:
info = f"Favorite #{fav.post_id}" self._preview.set_media(path, Path(path).name)
self._update_fullscreen(path, Path(path).name)
def _on_bookmark_selected(self, fav) -> None:
self._status.showMessage(f"Bookmark #{fav.post_id}")
self._on_bookmark_activated(fav)
def _on_bookmark_activated(self, fav) -> None:
info = f"Bookmark #{fav.post_id}"
# Try local cache first # Try local cache first
if fav.cached_path and Path(fav.cached_path).exists(): if fav.cached_path and Path(fav.cached_path).exists():
@ -937,8 +960,8 @@ class BooruApp(QMainWindow):
try: try:
path = await download_image(fav.file_url) path = await download_image(fav.file_url)
# Update cached_path in DB # Update cached_path in DB
self._db.update_favorite_cache_path(fav.id, str(path)) self._db.update_bookmark_cache_path(fav.id, str(path))
info = f"Favorite #{fav.post_id}" info = f"Bookmark #{fav.post_id}"
self._signals.image_done.emit(str(path), info) self._signals.image_done.emit(str(path), info)
except Exception as e: except Exception as e:
self._signals.image_error.emit(str(e)) self._signals.image_error.emit(str(e))
@ -970,13 +993,13 @@ class BooruApp(QMainWindow):
def _navigate_preview(self, direction: int) -> None: def _navigate_preview(self, direction: int) -> None:
"""Navigate to prev/next post in the preview. direction: -1 or +1.""" """Navigate to prev/next post in the preview. direction: -1 or +1."""
if self._stack.currentIndex() == 1: if self._stack.currentIndex() == 1:
# Favorites view # Bookmarks view
grid = self._favorites_view._grid grid = self._bookmarks_view._grid
favs = self._favorites_view._favorites favs = self._bookmarks_view._bookmarks
idx = grid.selected_index + direction idx = grid.selected_index + direction
if 0 <= idx < len(favs): if 0 <= idx < len(favs):
grid._select(idx) grid._select(idx)
self._on_favorite_activated(favs[idx]) self._on_bookmark_activated(favs[idx])
else: else:
idx = self._grid.selected_index + direction idx = self._grid.selected_index + direction
log.info(f"Navigate: direction={direction} current={self._grid.selected_index} next={idx} total={len(self._posts)}") log.info(f"Navigate: direction={direction} current={self._grid.selected_index} next={idx} total={len(self._posts)}")
@ -990,10 +1013,10 @@ class BooruApp(QMainWindow):
self._nav_page_turn = "last" self._nav_page_turn = "last"
self._prev_page() self._prev_page()
def _favorite_from_preview(self) -> None: def _bookmark_from_preview(self) -> None:
idx = self._grid.selected_index idx = self._grid.selected_index
if 0 <= idx < len(self._posts): if 0 <= idx < len(self._posts):
self._toggle_favorite(idx) self._toggle_bookmark(idx)
self._update_fullscreen_state() self._update_fullscreen_state()
def _save_from_preview(self, folder: str) -> None: def _save_from_preview(self, folder: str) -> None:
@ -1013,7 +1036,7 @@ class BooruApp(QMainWindow):
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()
folder = None folder = None
if site_id: if site_id:
favs = self._db.get_favorites(site_id=site_id) favs = self._db.get_bookmarks(site_id=site_id)
for f in favs: for f in favs:
if f.post_id == post.id and f.folder: if f.post_id == post.id and f.folder:
folder = f.folder folder = f.folder
@ -1042,7 +1065,7 @@ class BooruApp(QMainWindow):
cols = self._grid._flow.columns cols = self._grid._flow.columns
self._fullscreen_window = FullscreenPreview(grid_cols=cols, parent=self) self._fullscreen_window = FullscreenPreview(grid_cols=cols, parent=self)
self._fullscreen_window.navigate.connect(self._navigate_fullscreen) self._fullscreen_window.navigate.connect(self._navigate_fullscreen)
self._fullscreen_window.favorite_requested.connect(self._favorite_from_preview) self._fullscreen_window.bookmark_requested.connect(self._bookmark_from_preview)
self._fullscreen_window.save_toggle_requested.connect(self._save_toggle_from_slideshow) self._fullscreen_window.save_toggle_requested.connect(self._save_toggle_from_slideshow)
self._fullscreen_window.destroyed.connect(self._on_fullscreen_closed) self._fullscreen_window.destroyed.connect(self._on_fullscreen_closed)
self._fullscreen_window.set_media(path, self._preview._info_label.text()) self._fullscreen_window.set_media(path, self._preview._info_label.text())
@ -1053,7 +1076,7 @@ class BooruApp(QMainWindow):
def _navigate_fullscreen(self, direction: int) -> None: def _navigate_fullscreen(self, direction: int) -> None:
self._navigate_preview(direction) self._navigate_preview(direction)
# For synchronous loads (cached/favorites), update immediately # For synchronous loads (cached/bookmarks), update immediately
if self._preview._current_path: if self._preview._current_path:
self._update_fullscreen( self._update_fullscreen(
self._preview._current_path, self._preview._current_path,
@ -1091,7 +1114,7 @@ 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("Unfavorite" if self._is_current_favorited(index) else "Favorite") fav_action = menu.addAction("Remove Bookmark" if self._is_current_bookmarked(index) else "Bookmark")
menu.addSeparator() menu.addSeparator()
bl_menu = menu.addMenu("Blacklist Tag") bl_menu = menu.addMenu("Blacklist Tag")
if post.tag_categories: if post.tag_categories:
@ -1129,7 +1152,7 @@ class BooruApp(QMainWindow):
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()
folder = None folder = None
if site_id: if site_id:
favs = self._db.get_favorites(site_id=site_id) favs = self._db.get_bookmarks(site_id=site_id)
for f in favs: for f in favs:
if f.post_id == post.id and f.folder: if f.post_id == post.id and f.folder:
folder = f.folder folder = f.folder
@ -1147,7 +1170,7 @@ class BooruApp(QMainWindow):
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 action == fav_action:
self._toggle_favorite(index) self._toggle_bookmark(index)
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)
@ -1176,7 +1199,7 @@ class BooruApp(QMainWindow):
count = len(posts) count = len(posts)
menu = QMenu(self) menu = QMenu(self)
fav_all = menu.addAction(f"Favorite 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("Unsorted")
@ -1188,7 +1211,7 @@ class BooruApp(QMainWindow):
save_new = save_menu.addAction("+ New Folder...") save_new = save_menu.addAction("+ New Folder...")
menu.addSeparator() menu.addSeparator()
unfav_all = menu.addAction(f"Unfavorite All ({count})") unfav_all = menu.addAction(f"Remove All Bookmarks ({count})")
menu.addSeparator() menu.addSeparator()
batch_dl = menu.addAction(f"Download All ({count})...") batch_dl = menu.addAction(f"Download All ({count})...")
copy_urls = menu.addAction("Copy All URLs") copy_urls = menu.addAction("Copy All URLs")
@ -1198,7 +1221,7 @@ class BooruApp(QMainWindow):
return return
if action == fav_all: if action == fav_all:
self._bulk_favorite(indices, posts) self._bulk_bookmark(indices, posts)
elif action == save_unsorted: elif action == save_unsorted:
self._bulk_save(indices, posts, None) self._bulk_save(indices, posts, None)
elif action == save_new: elif action == save_new:
@ -1225,19 +1248,19 @@ class BooruApp(QMainWindow):
# Delete from all folders # Delete from all folders
for folder in self._db.get_folders(): for folder in self._db.get_folders():
delete_from_library(post.id, folder) delete_from_library(post.id, folder)
self._db.remove_favorite(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):
self._grid._thumbs[idx].set_favorited(False) self._grid._thumbs[idx].set_bookmarked(False)
self._grid._thumbs[idx].set_saved_locally(False) self._grid._thumbs[idx].set_saved_locally(False)
self._grid._clear_multi() self._grid._clear_multi()
self._status.showMessage(f"Unfavorited {count} posts") self._status.showMessage(f"Removed {count} bookmarks")
elif action == copy_urls: elif action == copy_urls:
urls = "\n".join(p.file_url for p in posts) urls = "\n".join(p.file_url for p in posts)
QApplication.clipboard().setText(urls) QApplication.clipboard().setText(urls)
self._status.showMessage(f"Copied {count} URLs") self._status.showMessage(f"Copied {count} URLs")
def _bulk_favorite(self, indices: list[int], posts: list[Post]) -> None: def _bulk_bookmark(self, indices: list[int], posts: list[Post]) -> None:
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()
if not site_id: if not site_id:
return return
@ -1245,20 +1268,20 @@ class BooruApp(QMainWindow):
async def _do(): async def _do():
for i, (idx, post) in enumerate(zip(indices, posts)): for i, (idx, post) in enumerate(zip(indices, posts)):
if self._db.is_favorited(site_id, post.id): if self._db.is_bookmarked(site_id, post.id):
continue continue
try: try:
path = await download_image(post.file_url) path = await download_image(post.file_url)
self._db.add_favorite( self._db.add_bookmark(
site_id=site_id, post_id=post.id, site_id=site_id, post_id=post.id,
file_url=post.file_url, preview_url=post.preview_url, file_url=post.file_url, preview_url=post.preview_url,
tags=post.tags, rating=post.rating, score=post.score, tags=post.tags, rating=post.rating, score=post.score,
source=post.source, cached_path=str(path), source=post.source, cached_path=str(path),
) )
self._signals.fav_done.emit(idx, f"Favorited {i+1}/{len(posts)}") self._signals.bookmark_done.emit(idx, f"Bookmarked {i+1}/{len(posts)}")
except Exception: except Exception:
pass pass
self._signals.batch_done.emit(f"Favorited {len(posts)} posts") self._signals.batch_done.emit(f"Bookmarked {len(posts)} posts")
self._run_async(_do) self._run_async(_do)
@ -1278,30 +1301,30 @@ class BooruApp(QMainWindow):
dest = dest_dir / f"{post.id}{ext}" dest = dest_dir / f"{post.id}{ext}"
if not dest.exists(): if not dest.exists():
shutil.copy2(path, dest) shutil.copy2(path, dest)
if site_id and not self._db.is_favorited(site_id, post.id): if site_id and not self._db.is_bookmarked(site_id, post.id):
self._db.add_favorite( self._db.add_bookmark(
site_id=site_id, post_id=post.id, site_id=site_id, post_id=post.id,
file_url=post.file_url, preview_url=post.preview_url, file_url=post.file_url, preview_url=post.preview_url,
tags=post.tags, rating=post.rating, score=post.score, tags=post.tags, rating=post.rating, score=post.score,
source=post.source, cached_path=str(path), folder=folder, source=post.source, cached_path=str(path), folder=folder,
) )
self._signals.fav_done.emit(idx, f"Saved {i+1}/{len(posts)} to {where}") self._signals.bookmark_done.emit(idx, f"Saved {i+1}/{len(posts)} to {where}")
except Exception: except Exception:
pass pass
self._signals.batch_done.emit(f"Saved {len(posts)} to {where}") self._signals.batch_done.emit(f"Saved {len(posts)} to {where}")
self._run_async(_do) self._run_async(_do)
def _toggle_favorite_if_not(self, post: Post) -> None: def _ensure_bookmarked(self, post: Post) -> None:
"""Favorite a post if not already favorited.""" """Bookmark a post if not already bookmarked."""
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()
if not site_id or self._db.is_favorited(site_id, post.id): if not site_id or self._db.is_bookmarked(site_id, post.id):
return return
async def _fav(): async def _fav():
try: try:
path = await download_image(post.file_url) path = await download_image(post.file_url)
self._db.add_favorite( self._db.add_bookmark(
site_id=site_id, site_id=site_id,
post_id=post.id, post_id=post.id,
file_url=post.file_url, file_url=post.file_url,
@ -1334,11 +1357,11 @@ class BooruApp(QMainWindow):
self._run_async(_batch) self._run_async(_batch)
def _is_current_favorited(self, index: int) -> bool: def _is_current_bookmarked(self, index: int) -> bool:
site_id = self._site_combo.currentData() site_id = self._site_combo.currentData()
if not site_id or index < 0 or index >= len(self._posts): if not site_id or index < 0 or index >= len(self._posts):
return False return False
return self._db.is_favorited(site_id, self._posts[index].id) return self._db.is_bookmarked(site_id, self._posts[index].id)
def _open_in_browser(self, post: Post) -> None: def _open_in_browser(self, post: Post) -> None:
if self._current_site: if self._current_site:
@ -1381,36 +1404,13 @@ class BooruApp(QMainWindow):
import shutil import shutil
shutil.copy2(path, dest) shutil.copy2(path, dest)
# Also favorite it with the folder
site_id = self._site_combo.currentData()
if site_id and not self._db.is_favorited(site_id, post.id):
self._db.add_favorite(
site_id=site_id,
post_id=post.id,
file_url=post.file_url,
preview_url=post.preview_url,
tags=post.tags,
rating=post.rating,
score=post.score,
source=post.source,
cached_path=str(path),
folder=folder,
)
elif site_id and folder:
# Already favorited, just update the folder
favs = self._db.get_favorites(site_id=site_id)
for f in favs:
if f.post_id == post.id:
self._db.move_favorite_to_folder(f.id, folder)
break
where = folder or "Unsorted" where = folder or "Unsorted"
self._signals.fav_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}"
) )
except Exception as e: except Exception as e:
self._signals.fav_error.emit(str(e)) self._signals.bookmark_error.emit(str(e))
self._run_async(_save) self._run_async(_save)
@ -1479,12 +1479,12 @@ class BooruApp(QMainWindow):
def _open_settings(self) -> None: def _open_settings(self) -> None:
dlg = SettingsDialog(self._db, self) dlg = SettingsDialog(self._db, self)
dlg.settings_changed.connect(self._apply_settings) dlg.settings_changed.connect(self._apply_settings)
self._favorites_imported = False self._bookmarks_imported = False
dlg.favorites_imported.connect(lambda: setattr(self, '_favorites_imported', True)) dlg.bookmarks_imported.connect(lambda: setattr(self, '_favorites_imported', True))
dlg.exec() dlg.exec()
if self._favorites_imported: if self._bookmarks_imported:
self._switch_view(1) self._switch_view(1)
self._favorites_view.refresh() self._bookmarks_view.refresh()
def _apply_settings(self) -> None: def _apply_settings(self) -> None:
"""Re-read settings from DB and apply to UI.""" """Re-read settings from DB and apply to UI."""
@ -1493,7 +1493,7 @@ class BooruApp(QMainWindow):
if idx >= 0: if idx >= 0:
self._rating_combo.setCurrentIndex(idx) self._rating_combo.setCurrentIndex(idx)
self._score_spin.setValue(self._db.get_setting_int("default_score")) self._score_spin.setValue(self._db.get_setting_int("default_score"))
self._favorites_view.refresh() self._bookmarks_view.refresh()
self._status.showMessage("Settings applied") self._status.showMessage("Settings applied")
# -- Fullscreen & Privacy -- # -- Fullscreen & Privacy --
@ -1541,7 +1541,7 @@ class BooruApp(QMainWindow):
if key == Qt.Key.Key_F and self._posts: if key == Qt.Key.Key_F and self._posts:
idx = self._grid.selected_index idx = self._grid.selected_index
if 0 <= idx < len(self._posts): if 0 <= idx < len(self._posts):
self._toggle_favorite(idx) self._toggle_bookmark(idx)
return return
elif key == Qt.Key.Key_I: elif key == Qt.Key.Key_I:
self._toggle_info() self._toggle_info()
@ -1553,35 +1553,27 @@ class BooruApp(QMainWindow):
return return
super().keyPressEvent(event) super().keyPressEvent(event)
# -- Favorites -- # -- Bookmarks --
def _toggle_favorite(self, index: int) -> None: def _toggle_bookmark(self, index: int) -> None:
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:
return return
if self._db.is_favorited(site_id, post.id): if self._db.is_bookmarked(site_id, post.id):
# Delete from library if saved self._db.remove_bookmark(site_id, post.id)
favs = self._db.get_favorites(site_id=site_id) self._status.showMessage(f"Unbookmarked #{post.id}")
for f in favs:
if f.post_id == post.id:
from ..core.cache import delete_from_library
delete_from_library(post.id, f.folder)
break
self._db.remove_favorite(site_id, post.id)
self._status.showMessage(f"Unfavorited #{post.id}")
thumbs = self._grid._thumbs thumbs = self._grid._thumbs
if 0 <= index < len(thumbs): if 0 <= index < len(thumbs):
thumbs[index].set_favorited(False) thumbs[index].set_bookmarked(False)
thumbs[index].set_saved_locally(False)
else: else:
self._status.showMessage(f"Favoriting #{post.id}...") self._status.showMessage(f"Bookmarking #{post.id}...")
async def _fav(): async def _fav():
try: try:
path = await download_image(post.file_url) path = await download_image(post.file_url)
self._db.add_favorite( self._db.add_bookmark(
site_id=site_id, site_id=site_id,
post_id=post.id, post_id=post.id,
file_url=post.file_url, file_url=post.file_url,
@ -1592,17 +1584,17 @@ class BooruApp(QMainWindow):
source=post.source, source=post.source,
cached_path=str(path), cached_path=str(path),
) )
self._signals.fav_done.emit(index, f"Favorited #{post.id}") self._signals.bookmark_done.emit(index, f"Bookmarked #{post.id}")
except Exception as e: except Exception as e:
self._signals.fav_error.emit(str(e)) self._signals.bookmark_error.emit(str(e))
self._run_async(_fav) self._run_async(_fav)
def _on_fav_done(self, index: int, msg: str) -> None: def _on_bookmark_done(self, index: int, msg: str) -> None:
self._status.showMessage(msg) self._status.showMessage(msg)
thumbs = self._grid._thumbs thumbs = self._grid._thumbs
if 0 <= index < len(thumbs): if 0 <= index < len(thumbs):
thumbs[index].set_favorited(True) thumbs[index].set_bookmarked(True)
# Only green if actually saved to library, not just cached # Only green if actually saved to library, not just cached
if "Saved" in msg: if "Saved" in msg:
thumbs[index].set_saved_locally(True) thumbs[index].set_saved_locally(True)

View File

@ -1,4 +1,4 @@
"""Favorites browser widget with folder support.""" """Bookmarks browser widget with folder support."""
from __future__ import annotations from __future__ import annotations
@ -23,7 +23,7 @@ from PySide6.QtWidgets import (
QMessageBox, QMessageBox,
) )
from ..core.db import Database, Favorite from ..core.db import Database, Bookmark
from ..core.cache import download_thumbnail from ..core.cache import download_thumbnail
from .grid import ThumbnailGrid from .grid import ThumbnailGrid
@ -34,16 +34,16 @@ class FavThumbSignals(QObject):
thumb_ready = Signal(int, str) thumb_ready = Signal(int, str)
class FavoritesView(QWidget): class BookmarksView(QWidget):
"""Browse and search local favorites with folder support.""" """Browse and search local bookmarks with folder support."""
favorite_selected = Signal(object) bookmark_selected = Signal(object)
favorite_activated = Signal(object) bookmark_activated = Signal(object)
def __init__(self, db: Database, parent: QWidget | None = None) -> None: def __init__(self, db: Database, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._db = db self._db = db
self._favorites: list[Favorite] = [] self._bookmarks: list[Bookmark] = []
self._signals = FavThumbSignals() self._signals = FavThumbSignals()
self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection) self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection)
@ -65,7 +65,7 @@ class FavoritesView(QWidget):
top.addWidget(manage_btn) top.addWidget(manage_btn)
self._search_input = QLineEdit() self._search_input = QLineEdit()
self._search_input.setPlaceholderText("Search favorites by tag...") self._search_input.setPlaceholderText("Search bookmarks by tag...")
self._search_input.returnPressed.connect(self._do_search) self._search_input.returnPressed.connect(self._do_search)
top.addWidget(self._search_input, stretch=1) top.addWidget(self._search_input, stretch=1)
@ -93,7 +93,7 @@ class FavoritesView(QWidget):
current = self._folder_combo.currentText() current = self._folder_combo.currentText()
self._folder_combo.blockSignals(True) self._folder_combo.blockSignals(True)
self._folder_combo.clear() self._folder_combo.clear()
self._folder_combo.addItem("All Favorites") self._folder_combo.addItem("All Bookmarks")
self._folder_combo.addItem("Unfiled") self._folder_combo.addItem("Unfiled")
for folder in self._db.get_folders(): for folder in self._db.get_folders():
self._folder_combo.addItem(folder) self._folder_combo.addItem(folder)
@ -110,26 +110,26 @@ class FavoritesView(QWidget):
folder_filter = None folder_filter = None
if folder_text == "Unfiled": if folder_text == "Unfiled":
folder_filter = "" # sentinel for NULL folder folder_filter = "" # sentinel for NULL folder
elif folder_text not in ("All Favorites", ""): elif folder_text not in ("All Bookmarks", ""):
folder_filter = folder_text folder_filter = folder_text
if folder_filter == "": if folder_filter == "":
# Get unfiled: folder IS NULL # Get unfiled: folder IS NULL
self._favorites = [ self._bookmarks = [
f for f in self._db.get_favorites(search=search, limit=500) f for f in self._db.get_bookmarks(search=search, limit=500)
if f.folder is None if f.folder is None
] ]
elif folder_filter: elif folder_filter:
self._favorites = self._db.get_favorites(search=search, folder=folder_filter, limit=500) self._bookmarks = self._db.get_bookmarks(search=search, folder=folder_filter, limit=500)
else: else:
self._favorites = self._db.get_favorites(search=search, limit=500) self._bookmarks = self._db.get_bookmarks(search=search, limit=500)
self._count_label.setText(f"{len(self._favorites)} favorites") self._count_label.setText(f"{len(self._bookmarks)} bookmarks")
thumbs = self._grid.set_posts(len(self._favorites)) thumbs = self._grid.set_posts(len(self._bookmarks))
from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS
for i, (fav, thumb) in enumerate(zip(self._favorites, thumbs)): for i, (fav, thumb) in enumerate(zip(self._bookmarks, thumbs)):
thumb.set_favorited(True) thumb.set_bookmarked(True)
# Check if saved to library # Check if saved to library
saved = False saved = False
if fav.folder: if fav.folder:
@ -171,15 +171,15 @@ class FavoritesView(QWidget):
self.refresh(search=text if text else None) self.refresh(search=text if text else None)
def _on_selected(self, index: int) -> None: def _on_selected(self, index: int) -> None:
if 0 <= index < len(self._favorites): if 0 <= index < len(self._bookmarks):
self.favorite_selected.emit(self._favorites[index]) self.bookmark_selected.emit(self._bookmarks[index])
def _on_activated(self, index: int) -> None: def _on_activated(self, index: int) -> None:
if 0 <= index < len(self._favorites): if 0 <= index < len(self._bookmarks):
self.favorite_activated.emit(self._favorites[index]) self.bookmark_activated.emit(self._bookmarks[index])
def _copy_to_library_unsorted(self, fav: Favorite) -> None: def _copy_to_library_unsorted(self, fav: Bookmark) -> None:
"""Copy a favorited image to the unsorted library folder.""" """Copy a bookmarked image to the unsorted library folder."""
from ..core.config import saved_dir from ..core.config import saved_dir
if fav.cached_path and Path(fav.cached_path).exists(): if fav.cached_path and Path(fav.cached_path).exists():
import shutil import shutil
@ -188,8 +188,8 @@ class FavoritesView(QWidget):
if not dest.exists(): if not dest.exists():
shutil.copy2(src, dest) shutil.copy2(src, dest)
def _copy_to_library(self, fav: Favorite, folder: str) -> None: def _copy_to_library(self, fav: Bookmark, folder: str) -> None:
"""Copy a favorited image to the library folder on disk.""" """Copy a bookmarked image to the library folder on disk."""
from ..core.config import saved_folder_dir from ..core.config import saved_folder_dir
if fav.cached_path and Path(fav.cached_path).exists(): if fav.cached_path and Path(fav.cached_path).exists():
import shutil import shutil
@ -205,9 +205,9 @@ class FavoritesView(QWidget):
self._refresh_folders() self._refresh_folders()
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._favorites): if index < 0 or index >= len(self._bookmarks):
return return
fav = self._favorites[index] fav = self._bookmarks[index]
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl from PySide6.QtCore import QUrl
@ -246,7 +246,7 @@ class FavoritesView(QWidget):
move_new = move_menu.addAction("+ New Folder...") move_new = move_menu.addAction("+ New Folder...")
menu.addSeparator() menu.addSeparator()
unfav = menu.addAction("Unfavorite") remove_bookmark = menu.addAction("Remove Bookmark")
action = menu.exec(pos) action = menu.exec(pos)
if not action: if not action:
@ -260,7 +260,7 @@ class FavoritesView(QWidget):
if ok and name.strip(): if ok and name.strip():
self._db.add_folder(name.strip()) self._db.add_folder(name.strip())
self._copy_to_library(fav, name.strip()) self._copy_to_library(fav, name.strip())
self._db.move_favorite_to_folder(fav.id, 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)]
@ -281,28 +281,26 @@ class FavoritesView(QWidget):
elif action == copy_tags: elif action == copy_tags:
QApplication.clipboard().setText(fav.tags) QApplication.clipboard().setText(fav.tags)
elif action == move_none: elif action == move_none:
self._db.move_favorite_to_folder(fav.id, None) self._db.move_bookmark_to_folder(fav.id, None)
self.refresh() self.refresh()
elif action == move_new: elif action == move_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():
self._db.add_folder(name.strip()) self._db.add_folder(name.strip())
self._db.move_favorite_to_folder(fav.id, name.strip()) self._db.move_bookmark_to_folder(fav.id, name.strip())
self._copy_to_library(fav, 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_favorite_to_folder(fav.id, folder_name) self._db.move_bookmark_to_folder(fav.id, folder_name)
self._copy_to_library(fav, folder_name) self._copy_to_library(fav, folder_name)
self.refresh() self.refresh()
elif action == unfav: elif action == remove_bookmark:
from ..core.cache import delete_from_library self._db.remove_bookmark(fav.site_id, fav.post_id)
delete_from_library(fav.post_id, fav.folder)
self._db.remove_favorite(fav.site_id, fav.post_id)
self.refresh() self.refresh()
def _on_multi_context_menu(self, indices: list, pos) -> None: def _on_multi_context_menu(self, indices: list, pos) -> None:
favs = [self._favorites[i] for i in indices if 0 <= i < len(self._favorites)] favs = [self._bookmarks[i] for i in indices if 0 <= i < len(self._bookmarks)]
if not favs: if not favs:
return return
@ -320,7 +318,7 @@ class FavoritesView(QWidget):
folder_actions[id(a)] = folder folder_actions[id(a)] = folder
menu.addSeparator() menu.addSeparator()
unfav_all = menu.addAction(f"Unfavorite All ({len(favs)})") remove_all = menu.addAction(f"Remove All Bookmarks ({len(favs)})")
action = menu.exec(pos) action = menu.exec(pos)
if not action: if not action:
@ -340,17 +338,15 @@ class FavoritesView(QWidget):
self.refresh() self.refresh()
elif action == move_none: elif action == move_none:
for fav in favs: for fav in favs:
self._db.move_favorite_to_folder(fav.id, None) self._db.move_bookmark_to_folder(fav.id, None)
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)]
for fav in favs: for fav in favs:
self._db.move_favorite_to_folder(fav.id, folder_name) self._db.move_bookmark_to_folder(fav.id, folder_name)
self._copy_to_library(fav, folder_name) self._copy_to_library(fav, folder_name)
self.refresh() self.refresh()
elif action == unfav_all: elif action == remove_all:
from ..core.cache import delete_from_library
for fav in favs: for fav in favs:
delete_from_library(fav.post_id, fav.folder) self._db.remove_bookmark(fav.site_id, fav.post_id)
self._db.remove_favorite(fav.site_id, fav.post_id)
self.refresh() self.refresh()

View File

@ -27,17 +27,17 @@ class ThumbnailWidget(QWidget):
double_clicked = Signal(int) double_clicked = Signal(int)
right_clicked = Signal(int, object) # index, QPoint right_clicked = Signal(int, object) # index, QPoint
# QSS-controllable dot colors: qproperty-savedColor / qproperty-favoritedColor # QSS-controllable dot colors: qproperty-savedColor / qproperty-bookmarkedColor
_saved_color = QColor("#22cc22") _saved_color = QColor("#22cc22")
_favorited_color = QColor("#ff4444") _bookmarked_color = QColor("#ff4444")
def _get_saved_color(self): return self._saved_color def _get_saved_color(self): return self._saved_color
def _set_saved_color(self, c): self._saved_color = QColor(c) if isinstance(c, str) else c def _set_saved_color(self, c): self._saved_color = QColor(c) if isinstance(c, str) else c
savedColor = Property(QColor, _get_saved_color, _set_saved_color) savedColor = Property(QColor, _get_saved_color, _set_saved_color)
def _get_favorited_color(self): return self._favorited_color def _get_bookmarked_color(self): return self._bookmarked_color
def _set_favorited_color(self, c): self._favorited_color = QColor(c) if isinstance(c, str) else c def _set_bookmarked_color(self, c): self._bookmarked_color = QColor(c) if isinstance(c, str) else c
favoritedColor = Property(QColor, _get_favorited_color, _set_favorited_color) bookmarkedColor = Property(QColor, _get_bookmarked_color, _set_bookmarked_color)
def __init__(self, index: int, parent: QWidget | None = None) -> None: def __init__(self, index: int, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -45,7 +45,7 @@ class ThumbnailWidget(QWidget):
self._pixmap: QPixmap | None = None self._pixmap: QPixmap | None = None
self._selected = False self._selected = False
self._multi_selected = False self._multi_selected = False
self._favorited = False self._bookmarked = False
self._saved_locally = False self._saved_locally = False
self._hover = False self._hover = False
self._drag_start: QPoint | None = None self._drag_start: QPoint | None = None
@ -71,8 +71,8 @@ class ThumbnailWidget(QWidget):
self._multi_selected = selected self._multi_selected = selected
self.update() self.update()
def set_favorited(self, favorited: bool) -> None: def set_bookmarked(self, bookmarked: bool) -> None:
self._favorited = favorited self._bookmarked = bookmarked
self.update() self.update()
def set_saved_locally(self, saved: bool) -> None: def set_saved_locally(self, saved: bool) -> None:
@ -120,11 +120,20 @@ class ThumbnailWidget(QWidget):
y = (self.height() - self._pixmap.height()) // 2 y = (self.height() - self._pixmap.height()) // 2
p.drawPixmap(x, y, self._pixmap) p.drawPixmap(x, y, self._pixmap)
# Favorite/saved indicator # Bookmark/saved indicators (independent dots)
if self._favorited: dot_x = self.width() - 14
if self._saved_locally:
p.setPen(Qt.PenStyle.NoPen) p.setPen(Qt.PenStyle.NoPen)
p.setBrush(self._saved_color if self._saved_locally else self._favorited_color) p.setBrush(self._saved_color)
p.drawEllipse(self.width() - 14, 4, 10, 10) p.drawEllipse(dot_x, 4, 10, 10)
dot_x -= 14
if self._bookmarked:
from PySide6.QtGui import QFont
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(self._bookmarked_color)
p.setFont(QFont(p.font().family(), 10))
p.setPen(self._bookmarked_color)
p.drawText(dot_x - 2, 14, "\u2605")
# Multi-select checkmark # Multi-select checkmark
if self._multi_selected: if self._multi_selected:

224
booru_viewer/gui/library.py Normal file
View File

@ -0,0 +1,224 @@
"""Library browser widget — browse saved files on disk."""
from __future__ import annotations
import logging
import os
import threading
from pathlib import Path
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QPushButton,
QLabel,
QComboBox,
)
from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS, thumbnails_dir
from .grid import ThumbnailGrid
log = logging.getLogger("booru")
LIBRARY_THUMB_SIZE = 180
class _LibThumbSignals(QObject):
thumb_ready = Signal(int, str)
class LibraryView(QWidget):
"""Browse files saved to the library on disk."""
file_selected = Signal(str)
file_activated = Signal(str)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._files: list[Path] = []
self._signals = _LibThumbSignals()
self._signals.thumb_ready.connect(
self._on_thumb_ready, Qt.ConnectionType.QueuedConnection
)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# --- Top bar ---
top = QHBoxLayout()
top.setContentsMargins(0, 0, 0, 0)
self._folder_combo = QComboBox()
self._folder_combo.setMinimumWidth(140)
self._folder_combo.currentTextChanged.connect(lambda _: self.refresh())
top.addWidget(self._folder_combo)
self._sort_combo = QComboBox()
self._sort_combo.addItems(["Date", "Name", "Size"])
self._sort_combo.setFixedWidth(80)
self._sort_combo.currentTextChanged.connect(lambda _: self.refresh())
top.addWidget(self._sort_combo)
refresh_btn = QPushButton("Refresh")
refresh_btn.setFixedWidth(65)
refresh_btn.clicked.connect(self.refresh)
top.addWidget(refresh_btn)
top.addStretch(1)
layout.addLayout(top)
# --- Count label ---
self._count_label = QLabel()
layout.addWidget(self._count_label)
# --- Grid ---
self._grid = ThumbnailGrid()
self._grid.post_selected.connect(self._on_selected)
self._grid.post_activated.connect(self._on_activated)
layout.addWidget(self._grid)
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def refresh(self) -> None:
"""Scan the selected folder, sort, display thumbnails."""
self._refresh_folders()
self._files = self._scan_files()
self._sort_files()
self._count_label.setText(f"{len(self._files)} files")
thumbs = self._grid.set_posts(len(self._files))
lib_thumb_dir = thumbnails_dir() / "library"
lib_thumb_dir.mkdir(parents=True, exist_ok=True)
for i, (filepath, thumb) in enumerate(zip(self._files, thumbs)):
thumb._cached_path = str(filepath)
cached_thumb = lib_thumb_dir / f"{filepath.stem}.jpg"
if cached_thumb.exists():
pix = QPixmap(str(cached_thumb))
if not pix.isNull():
thumb.set_pixmap(pix)
else:
self._generate_thumb_async(i, filepath, cached_thumb)
# ------------------------------------------------------------------
# Folder list
# ------------------------------------------------------------------
def _refresh_folders(self) -> None:
current = self._folder_combo.currentText()
self._folder_combo.blockSignals(True)
self._folder_combo.clear()
self._folder_combo.addItem("All Files")
self._folder_combo.addItem("Unsorted")
root = saved_dir()
if root.is_dir():
for entry in sorted(root.iterdir()):
if entry.is_dir():
self._folder_combo.addItem(entry.name)
idx = self._folder_combo.findText(current)
if idx >= 0:
self._folder_combo.setCurrentIndex(idx)
self._folder_combo.blockSignals(False)
# ------------------------------------------------------------------
# File scanning
# ------------------------------------------------------------------
def _scan_files(self) -> list[Path]:
root = saved_dir()
folder_text = self._folder_combo.currentText()
if folder_text == "All Files":
return self._collect_recursive(root)
elif folder_text == "Unsorted":
return self._collect_top_level(root)
else:
sub = root / folder_text
if sub.is_dir():
return self._collect_top_level(sub)
return []
@staticmethod
def _collect_recursive(directory: Path) -> list[Path]:
files: list[Path] = []
for dirpath, _dirnames, filenames in os.walk(directory):
for name in filenames:
p = Path(dirpath) / name
if p.suffix.lower() in MEDIA_EXTENSIONS:
files.append(p)
return files
@staticmethod
def _collect_top_level(directory: Path) -> list[Path]:
if not directory.is_dir():
return []
return [
p
for p in directory.iterdir()
if p.is_file() and p.suffix.lower() in MEDIA_EXTENSIONS
]
# ------------------------------------------------------------------
# Sorting
# ------------------------------------------------------------------
def _sort_files(self) -> None:
mode = self._sort_combo.currentText()
if mode == "Name":
self._files.sort(key=lambda p: p.name.lower())
elif mode == "Size":
self._files.sort(key=lambda p: p.stat().st_size, reverse=True)
else:
# Date — newest first
self._files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
# ------------------------------------------------------------------
# Async thumbnail generation
# ------------------------------------------------------------------
def _generate_thumb_async(
self, index: int, source: Path, dest: Path
) -> None:
def _work() -> None:
try:
from PIL import Image
with Image.open(source) as img:
img.thumbnail(
(LIBRARY_THUMB_SIZE, LIBRARY_THUMB_SIZE), Image.LANCZOS
)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(str(dest), "JPEG", quality=85)
self._signals.thumb_ready.emit(index, str(dest))
except Exception as e:
log.warning("Library thumb %d (%s) failed: %s", index, source.name, e)
threading.Thread(target=_work, daemon=True).start()
def _on_thumb_ready(self, index: int, path: str) -> None:
thumbs = self._grid._thumbs
if 0 <= index < len(thumbs):
pix = QPixmap(path)
if not pix.isNull():
thumbs[index].set_pixmap(pix)
# ------------------------------------------------------------------
# Selection signals
# ------------------------------------------------------------------
def _on_selected(self, index: int) -> None:
if 0 <= index < len(self._files):
self.file_selected.emit(str(self._files[index]))
def _on_activated(self, index: int) -> None:
if 0 <= index < len(self._files):
self.file_activated.emit(str(self._files[index]))

View File

@ -26,7 +26,7 @@ class FullscreenPreview(QMainWindow):
"""Fullscreen media viewer with navigation — images, GIFs, and video.""" """Fullscreen media viewer with navigation — images, GIFs, and video."""
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
favorite_requested = Signal() bookmark_requested = Signal()
save_toggle_requested = Signal() # save or unsave depending on state save_toggle_requested = Signal() # save or unsave depending on state
def __init__(self, grid_cols: int = 3, parent=None) -> None: def __init__(self, grid_cols: int = 3, parent=None) -> None:
@ -44,10 +44,10 @@ class FullscreenPreview(QMainWindow):
toolbar = QHBoxLayout(self._toolbar) toolbar = QHBoxLayout(self._toolbar)
toolbar.setContentsMargins(8, 4, 8, 4) toolbar.setContentsMargins(8, 4, 8, 4)
self._fav_btn = QPushButton("Favorite") self._bookmark_btn = QPushButton("Bookmark")
self._fav_btn.setFixedWidth(80) self._bookmark_btn.setFixedWidth(80)
self._fav_btn.clicked.connect(self.favorite_requested) self._bookmark_btn.clicked.connect(self.bookmark_requested)
toolbar.addWidget(self._fav_btn) toolbar.addWidget(self._bookmark_btn)
self._save_btn = QPushButton("Save") self._save_btn = QPushButton("Save")
self._save_btn.setFixedWidth(70) self._save_btn.setFixedWidth(70)
@ -80,9 +80,9 @@ class FullscreenPreview(QMainWindow):
QApplication.instance().installEventFilter(self) QApplication.instance().installEventFilter(self)
self.showFullScreen() self.showFullScreen()
def update_state(self, favorited: bool, saved: bool) -> None: def update_state(self, bookmarked: bool, saved: bool) -> None:
self._fav_btn.setText("Unfavorite" if favorited else "Favorite") self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark")
self._fav_btn.setFixedWidth(90 if favorited else 80) self._bookmark_btn.setFixedWidth(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")
@ -500,7 +500,7 @@ class ImagePreview(QWidget):
open_in_browser = Signal() open_in_browser = Signal()
save_to_folder = Signal(str) save_to_folder = Signal(str)
unsave_requested = Signal() unsave_requested = Signal()
favorite_requested = Signal() bookmark_requested = Signal()
navigate = Signal(int) # -1 = prev, +1 = next navigate = Signal(int) # -1 = prev, +1 = next
fullscreen_requested = Signal() fullscreen_requested = Signal()
@ -587,7 +587,7 @@ class ImagePreview(QWidget):
def _on_context_menu(self, pos) -> None: def _on_context_menu(self, pos) -> None:
menu = QMenu(self) menu = QMenu(self)
fav_action = menu.addAction("Favorite") 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("Unsorted")
@ -624,7 +624,7 @@ class ImagePreview(QWidget):
if not action: if not action:
return return
if action == fav_action: if action == fav_action:
self.favorite_requested.emit() self.bookmark_requested.emit()
elif action == save_unsorted: elif action == save_unsorted:
self.save_to_folder.emit("") self.save_to_folder.emit("")
elif action == save_new: elif action == save_new:

View File

@ -35,7 +35,7 @@ class SettingsDialog(QDialog):
"""Full settings panel with tabs.""" """Full settings panel with tabs."""
settings_changed = Signal() settings_changed = Signal()
favorites_imported = Signal() bookmarks_imported = Signal()
def __init__(self, db: Database, parent: QWidget | None = None) -> None: def __init__(self, db: Database, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -153,8 +153,8 @@ class SettingsDialog(QDialog):
self._cache_size_label = QLabel(f"{total_mb:.1f} MB") self._cache_size_label = QLabel(f"{total_mb:.1f} MB")
stats_layout.addRow("Total size:", self._cache_size_label) stats_layout.addRow("Total size:", self._cache_size_label)
self._fav_count_label = QLabel(f"{self._db.favorite_count()}") self._fav_count_label = QLabel(f"{self._db.bookmark_count()}")
stats_layout.addRow("Favorites:", self._fav_count_label) stats_layout.addRow("Bookmarks:", self._fav_count_label)
layout.addWidget(stats_group) layout.addWidget(stats_group)
@ -317,12 +317,12 @@ class SettingsDialog(QDialog):
exp_group = QGroupBox("Backup") exp_group = QGroupBox("Backup")
exp_layout = QHBoxLayout(exp_group) exp_layout = QHBoxLayout(exp_group)
export_btn = QPushButton("Export Favorites") export_btn = QPushButton("Export Bookmarks")
export_btn.clicked.connect(self._export_favorites) export_btn.clicked.connect(self._export_bookmarks)
exp_layout.addWidget(export_btn) exp_layout.addWidget(export_btn)
import_btn = QPushButton("Import Favorites") import_btn = QPushButton("Import Bookmarks")
import_btn.clicked.connect(self._import_favorites) import_btn.clicked.connect(self._import_bookmarks)
exp_layout.addWidget(import_btn) exp_layout.addWidget(import_btn)
layout.addWidget(exp_group) layout.addWidget(exp_group)
@ -499,7 +499,7 @@ class SettingsDialog(QDialog):
def _clear_image_cache(self) -> None: def _clear_image_cache(self) -> None:
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Confirm", self, "Confirm",
"Delete all cached images? (Favorites stay in the database but cached files are removed.)", "Delete all cached images? (Bookmarks stay in the database but cached files are removed.)",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
@ -520,9 +520,9 @@ class SettingsDialog(QDialog):
def _evict_now(self) -> None: def _evict_now(self) -> None:
max_bytes = self._max_cache.value() * 1024 * 1024 max_bytes = self._max_cache.value() * 1024 * 1024
# Protect favorited file paths # Protect bookmarked file paths
protected = set() protected = set()
for fav in self._db.get_favorites(limit=999999): for fav in self._db.get_bookmarks(limit=999999):
if fav.cached_path: if fav.cached_path:
protected.add(fav.cached_path) protected.add(fav.cached_path)
count = evict_oldest(max_bytes, protected) count = evict_oldest(max_bytes, protected)
@ -559,13 +559,13 @@ class SettingsDialog(QDialog):
from PySide6.QtCore import QUrl from PySide6.QtCore import QUrl
QDesktopServices.openUrl(QUrl.fromLocalFile(str(data_dir()))) QDesktopServices.openUrl(QUrl.fromLocalFile(str(data_dir())))
def _export_favorites(self) -> None: def _export_bookmarks(self) -> None:
from .dialogs import save_file from .dialogs import save_file
import json import json
path = save_file(self, "Export Favorites", "favorites.json", "JSON (*.json)") path = save_file(self, "Export Bookmarks", "bookmarks.json", "JSON (*.json)")
if not path: if not path:
return return
favs = self._db.get_favorites(limit=999999) favs = self._db.get_bookmarks(limit=999999)
data = [ data = [
{ {
"post_id": f.post_id, "post_id": f.post_id,
@ -577,18 +577,18 @@ class SettingsDialog(QDialog):
"score": f.score, "score": f.score,
"source": f.source, "source": f.source,
"folder": f.folder, "folder": f.folder,
"favorited_at": f.favorited_at, "bookmarked_at": f.bookmarked_at,
} }
for f in favs for f in favs
] ]
with open(path, "w") as fp: with open(path, "w") as fp:
json.dump(data, fp, indent=2) json.dump(data, fp, indent=2)
QMessageBox.information(self, "Done", f"Exported {len(data)} favorites.") QMessageBox.information(self, "Done", f"Exported {len(data)} bookmarks.")
def _import_favorites(self) -> None: def _import_bookmarks(self) -> None:
from .dialogs import open_file from .dialogs import open_file
import json import json
path = open_file(self, "Import Favorites", "JSON (*.json)") path = open_file(self, "Import Bookmarks", "JSON (*.json)")
if not path: if not path:
return return
try: try:
@ -598,7 +598,7 @@ class SettingsDialog(QDialog):
for item in data: for item in data:
try: try:
folder = item.get("folder") folder = item.get("folder")
self._db.add_favorite( self._db.add_bookmark(
site_id=item["site_id"], site_id=item["site_id"],
post_id=item["post_id"], post_id=item["post_id"],
file_url=item["file_url"], file_url=item["file_url"],
@ -614,8 +614,8 @@ class SettingsDialog(QDialog):
count += 1 count += 1
except Exception: except Exception:
pass pass
QMessageBox.information(self, "Done", f"Imported {count} favorites.") QMessageBox.information(self, "Done", f"Imported {count} bookmarks.")
self.favorites_imported.emit() self.bookmarks_imported.emit()
except Exception as e: except Exception as e:
QMessageBox.warning(self, "Error", str(e)) QMessageBox.warning(self, "Error", str(e))

View File

@ -288,7 +288,7 @@ class SiteManagerDialog(QDialog):
return return
site_id = item.data(Qt.ItemDataRole.UserRole) site_id = item.data(Qt.ItemDataRole.UserRole)
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Confirm", "Remove this site and all its favorites?", self, "Confirm", "Remove this site and all its bookmarks?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "booru-viewer" name = "booru-viewer"
version = "0.1.3" version = "0.1.4"
description = "Local booru image browser with Qt6 GUI" description = "Local booru image browser with Qt6 GUI"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [