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:
parent
243a889fc1
commit
72e4d5c5a2
@ -1,4 +1,4 @@
|
||||
"""SQLite database for favorites, sites, and cache metadata."""
|
||||
"""SQLite database for bookmarks, sites, and cache metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -105,7 +105,7 @@ class Site:
|
||||
|
||||
|
||||
@dataclass
|
||||
class Favorite:
|
||||
class Bookmark:
|
||||
id: int
|
||||
site_id: int
|
||||
post_id: int
|
||||
@ -117,7 +117,11 @@ class Favorite:
|
||||
source: str | None
|
||||
cached_path: 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:
|
||||
@ -217,9 +221,9 @@ class Database:
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# -- Favorites --
|
||||
# -- Bookmarks --
|
||||
|
||||
def add_favorite(
|
||||
def add_bookmark(
|
||||
self,
|
||||
site_id: int,
|
||||
post_id: int,
|
||||
@ -231,7 +235,7 @@ class Database:
|
||||
source: str | None = None,
|
||||
cached_path: str | None = None,
|
||||
folder: str | None = None,
|
||||
) -> Favorite:
|
||||
) -> Bookmark:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
cur = self.conn.execute(
|
||||
"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),
|
||||
)
|
||||
self.conn.commit()
|
||||
return Favorite(
|
||||
return Bookmark(
|
||||
id=cur.lastrowid, # type: ignore[arg-type]
|
||||
site_id=site_id,
|
||||
post_id=post_id,
|
||||
@ -252,12 +256,15 @@ class Database:
|
||||
source=source,
|
||||
cached_path=cached_path,
|
||||
folder=folder,
|
||||
favorited_at=now,
|
||||
bookmarked_at=now,
|
||||
)
|
||||
|
||||
def add_favorites_batch(self, favorites: list[dict]) -> None:
|
||||
"""Add multiple favorites in a single transaction."""
|
||||
for fav in favorites:
|
||||
# Back-compat shim
|
||||
add_favorite = add_bookmark
|
||||
|
||||
def add_bookmarks_batch(self, bookmarks: list[dict]) -> None:
|
||||
"""Add multiple bookmarks in a single transaction."""
|
||||
for fav in bookmarks:
|
||||
self.conn.execute(
|
||||
"INSERT OR IGNORE INTO favorites "
|
||||
"(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()
|
||||
|
||||
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(
|
||||
"DELETE FROM favorites WHERE site_id = ? AND post_id = ?",
|
||||
(site_id, post_id),
|
||||
)
|
||||
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(
|
||||
"SELECT 1 FROM favorites WHERE site_id = ? AND post_id = ?",
|
||||
(site_id, post_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def get_favorites(
|
||||
# Back-compat shim
|
||||
is_favorited = is_bookmarked
|
||||
|
||||
def get_bookmarks(
|
||||
self,
|
||||
search: str | None = None,
|
||||
site_id: int | None = None,
|
||||
folder: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[Favorite]:
|
||||
) -> list[Bookmark]:
|
||||
q = "SELECT * FROM favorites WHERE 1=1"
|
||||
params: list = []
|
||||
if site_id is not None:
|
||||
@ -305,11 +321,14 @@ class Database:
|
||||
q += " ORDER BY favorited_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
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
|
||||
def _row_to_favorite(r) -> Favorite:
|
||||
return Favorite(
|
||||
def _row_to_bookmark(r) -> Bookmark:
|
||||
return Bookmark(
|
||||
id=r["id"],
|
||||
site_id=r["site_id"],
|
||||
post_id=r["post_id"],
|
||||
@ -321,20 +340,29 @@ class Database:
|
||||
source=r["source"],
|
||||
cached_path=r["cached_path"],
|
||||
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(
|
||||
"UPDATE favorites SET cached_path = ? WHERE id = ?",
|
||||
(cached_path, fav_id),
|
||||
)
|
||||
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()
|
||||
return row[0]
|
||||
|
||||
# Back-compat shim
|
||||
favorite_count = bookmark_count
|
||||
|
||||
# -- Folders --
|
||||
|
||||
def get_folders(self) -> list[str]:
|
||||
@ -363,12 +391,15 @@ class Database:
|
||||
)
|
||||
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(
|
||||
"UPDATE favorites SET folder = ? WHERE id = ?", (folder, fav_id)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# Back-compat shim
|
||||
move_favorite_to_folder = move_bookmark_to_folder
|
||||
|
||||
# -- Blacklist --
|
||||
|
||||
def add_blacklisted_tag(self, tag: str) -> None:
|
||||
|
||||
@ -43,7 +43,8 @@ from .grid import ThumbnailGrid
|
||||
from .preview import ImagePreview
|
||||
from .search import SearchBar
|
||||
from .sites import SiteManagerDialog
|
||||
from .favorites import FavoritesView
|
||||
from .bookmarks import BookmarksView
|
||||
from .library import LibraryView
|
||||
from .settings import SettingsDialog
|
||||
|
||||
log = logging.getLogger("booru")
|
||||
@ -78,8 +79,8 @@ class AsyncSignals(QObject):
|
||||
thumb_done = Signal(int, str)
|
||||
image_done = Signal(str, str)
|
||||
image_error = Signal(str)
|
||||
fav_done = Signal(int, str)
|
||||
fav_error = Signal(str)
|
||||
bookmark_done = Signal(int, str)
|
||||
bookmark_error = Signal(str)
|
||||
autocomplete_done = Signal(list)
|
||||
batch_progress = Signal(int, int) # current, total
|
||||
batch_done = Signal(str)
|
||||
@ -231,8 +232,8 @@ class BooruApp(QMainWindow):
|
||||
s.thumb_done.connect(self._on_thumb_done, Q)
|
||||
s.image_done.connect(self._on_image_done, Q)
|
||||
s.image_error.connect(self._on_image_error, Q)
|
||||
s.fav_done.connect(self._on_fav_done, Q)
|
||||
s.fav_error.connect(self._on_fav_error, Q)
|
||||
s.bookmark_done.connect(self._on_bookmark_done, Q)
|
||||
s.bookmark_error.connect(self._on_bookmark_error, Q)
|
||||
s.autocomplete_done.connect(self._on_autocomplete_done, Q)
|
||||
s.batch_progress.connect(self._on_batch_progress, Q)
|
||||
s.batch_done.connect(lambda m: self._status.showMessage(m), Q)
|
||||
@ -254,7 +255,7 @@ class BooruApp(QMainWindow):
|
||||
self._dl_progress.hide()
|
||||
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}")
|
||||
|
||||
def _run_async(self, coro_func, *args):
|
||||
@ -314,10 +315,16 @@ class BooruApp(QMainWindow):
|
||||
self._browse_btn.clicked.connect(lambda: self._switch_view(0))
|
||||
nav.addWidget(self._browse_btn)
|
||||
|
||||
self._fav_btn = QPushButton("Favorites")
|
||||
self._fav_btn.setCheckable(True)
|
||||
self._fav_btn.clicked.connect(lambda: self._switch_view(1))
|
||||
nav.addWidget(self._fav_btn)
|
||||
self._bookmark_btn = QPushButton("Bookmarks")
|
||||
self._bookmark_btn.setCheckable(True)
|
||||
self._bookmark_btn.clicked.connect(lambda: self._switch_view(1))
|
||||
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)
|
||||
|
||||
@ -338,10 +345,15 @@ class BooruApp(QMainWindow):
|
||||
self._grid.page_back.connect(self._prev_page)
|
||||
self._stack.addWidget(self._grid)
|
||||
|
||||
self._favorites_view = FavoritesView(self._db)
|
||||
self._favorites_view.favorite_selected.connect(self._on_favorite_selected)
|
||||
self._favorites_view.favorite_activated.connect(self._on_favorite_activated)
|
||||
self._stack.addWidget(self._favorites_view)
|
||||
self._bookmarks_view = BookmarksView(self._db)
|
||||
self._bookmarks_view.bookmark_selected.connect(self._on_bookmark_selected)
|
||||
self._bookmarks_view.bookmark_activated.connect(self._on_bookmark_activated)
|
||||
self._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)
|
||||
|
||||
@ -352,7 +364,7 @@ class BooruApp(QMainWindow):
|
||||
self._preview.close_requested.connect(self._close_preview)
|
||||
self._preview.open_in_default.connect(self._open_preview_in_default)
|
||||
self._preview.open_in_browser.connect(self._open_preview_in_browser)
|
||||
self._preview.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.unsave_requested.connect(self._unsave_from_preview)
|
||||
self._preview.navigate.connect(self._navigate_preview)
|
||||
@ -500,10 +512,13 @@ class BooruApp(QMainWindow):
|
||||
def _switch_view(self, index: int) -> None:
|
||||
self._stack.setCurrentIndex(index)
|
||||
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:
|
||||
self._favorites_view.refresh()
|
||||
self._favorites_view._grid.setFocus()
|
||||
self._bookmarks_view.refresh()
|
||||
self._bookmarks_view._grid.setFocus()
|
||||
elif index == 2:
|
||||
self._library_view.refresh()
|
||||
else:
|
||||
self._grid.setFocus()
|
||||
|
||||
@ -672,19 +687,19 @@ class BooruApp(QMainWindow):
|
||||
if d.exists():
|
||||
_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)
|
||||
_favs = self._db.get_favorites(site_id=site_id) if site_id else []
|
||||
# Pre-fetch bookmarks for the site once (used for folder checks)
|
||||
_favs = self._db.get_bookmarks(site_id=site_id) if site_id else []
|
||||
|
||||
for i, (post, thumb) in enumerate(zip(posts, thumbs)):
|
||||
if site_id and self._db.is_favorited(site_id, post.id):
|
||||
thumb.set_favorited(True)
|
||||
# Check if saved to library (not just cached)
|
||||
# Bookmark status (DB)
|
||||
if site_id and self._db.is_bookmarked(site_id, post.id):
|
||||
thumb.set_bookmarked(True)
|
||||
# Saved status (filesystem) — independent of bookmark
|
||||
saved = post.id in _saved_ids
|
||||
if not saved:
|
||||
# Check folders
|
||||
for f in _favs:
|
||||
if f.post_id == post.id and f.folder and f.folder in _folder_saved:
|
||||
saved = post.id in _folder_saved[f.folder]
|
||||
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
|
||||
@ -834,9 +849,9 @@ class BooruApp(QMainWindow):
|
||||
site_id = self._site_combo.currentData()
|
||||
|
||||
if self._stack.currentIndex() == 1:
|
||||
# Favorites view
|
||||
grid = self._favorites_view._grid
|
||||
favs = self._favorites_view._favorites
|
||||
# Bookmarks view
|
||||
grid = self._bookmarks_view._grid
|
||||
favs = self._bookmarks_view._bookmarks
|
||||
idx = grid.selected_index
|
||||
if 0 <= idx < len(favs):
|
||||
fav = favs[idx]
|
||||
@ -858,7 +873,7 @@ class BooruApp(QMainWindow):
|
||||
idx = self._grid.selected_index
|
||||
if 0 <= idx < len(self._posts) and site_id:
|
||||
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_dir() / f"{post.id}{ext}").exists()
|
||||
for ext in MEDIA_EXTENSIONS
|
||||
@ -871,7 +886,7 @@ class BooruApp(QMainWindow):
|
||||
)
|
||||
if saved:
|
||||
break
|
||||
self._fullscreen_window.update_state(favorited, saved)
|
||||
self._fullscreen_window.update_state(bookmarked, saved)
|
||||
else:
|
||||
self._fullscreen_window.update_state(False, False)
|
||||
|
||||
@ -897,19 +912,27 @@ class BooruApp(QMainWindow):
|
||||
current = cache_size_bytes(include_thumbnails=False)
|
||||
if current > max_bytes:
|
||||
protected = set()
|
||||
for fav in self._db.get_favorites(limit=999999):
|
||||
for fav in self._db.get_bookmarks(limit=999999):
|
||||
if fav.cached_path:
|
||||
protected.add(fav.cached_path)
|
||||
evicted = evict_oldest(max_bytes, protected)
|
||||
if evicted:
|
||||
log.info(f"Auto-evicted {evicted} cached files")
|
||||
|
||||
def _on_favorite_selected(self, fav) -> None:
|
||||
self._status.showMessage(f"Favorite #{fav.post_id}")
|
||||
self._on_favorite_activated(fav)
|
||||
def _on_library_selected(self, path: str) -> None:
|
||||
self._preview.set_media(path, Path(path).name)
|
||||
self._update_fullscreen(path, Path(path).name)
|
||||
|
||||
def _on_favorite_activated(self, fav) -> None:
|
||||
info = f"Favorite #{fav.post_id}"
|
||||
def _on_library_activated(self, path: str) -> None:
|
||||
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
|
||||
if fav.cached_path and Path(fav.cached_path).exists():
|
||||
@ -937,8 +960,8 @@ class BooruApp(QMainWindow):
|
||||
try:
|
||||
path = await download_image(fav.file_url)
|
||||
# Update cached_path in DB
|
||||
self._db.update_favorite_cache_path(fav.id, str(path))
|
||||
info = f"Favorite #{fav.post_id}"
|
||||
self._db.update_bookmark_cache_path(fav.id, str(path))
|
||||
info = f"Bookmark #{fav.post_id}"
|
||||
self._signals.image_done.emit(str(path), info)
|
||||
except Exception as e:
|
||||
self._signals.image_error.emit(str(e))
|
||||
@ -970,13 +993,13 @@ class BooruApp(QMainWindow):
|
||||
def _navigate_preview(self, direction: int) -> None:
|
||||
"""Navigate to prev/next post in the preview. direction: -1 or +1."""
|
||||
if self._stack.currentIndex() == 1:
|
||||
# Favorites view
|
||||
grid = self._favorites_view._grid
|
||||
favs = self._favorites_view._favorites
|
||||
# Bookmarks view
|
||||
grid = self._bookmarks_view._grid
|
||||
favs = self._bookmarks_view._bookmarks
|
||||
idx = grid.selected_index + direction
|
||||
if 0 <= idx < len(favs):
|
||||
grid._select(idx)
|
||||
self._on_favorite_activated(favs[idx])
|
||||
self._on_bookmark_activated(favs[idx])
|
||||
else:
|
||||
idx = self._grid.selected_index + direction
|
||||
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._prev_page()
|
||||
|
||||
def _favorite_from_preview(self) -> None:
|
||||
def _bookmark_from_preview(self) -> None:
|
||||
idx = self._grid.selected_index
|
||||
if 0 <= idx < len(self._posts):
|
||||
self._toggle_favorite(idx)
|
||||
self._toggle_bookmark(idx)
|
||||
self._update_fullscreen_state()
|
||||
|
||||
def _save_from_preview(self, folder: str) -> None:
|
||||
@ -1013,7 +1036,7 @@ class BooruApp(QMainWindow):
|
||||
site_id = self._site_combo.currentData()
|
||||
folder = None
|
||||
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:
|
||||
if f.post_id == post.id and f.folder:
|
||||
folder = f.folder
|
||||
@ -1042,7 +1065,7 @@ class BooruApp(QMainWindow):
|
||||
cols = self._grid._flow.columns
|
||||
self._fullscreen_window = FullscreenPreview(grid_cols=cols, parent=self)
|
||||
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.destroyed.connect(self._on_fullscreen_closed)
|
||||
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:
|
||||
self._navigate_preview(direction)
|
||||
# For synchronous loads (cached/favorites), update immediately
|
||||
# For synchronous loads (cached/bookmarks), update immediately
|
||||
if self._preview._current_path:
|
||||
self._update_fullscreen(
|
||||
self._preview._current_path,
|
||||
@ -1091,7 +1114,7 @@ class BooruApp(QMainWindow):
|
||||
copy_url = menu.addAction("Copy Image URL")
|
||||
copy_tags = menu.addAction("Copy Tags")
|
||||
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()
|
||||
bl_menu = menu.addMenu("Blacklist Tag")
|
||||
if post.tag_categories:
|
||||
@ -1129,7 +1152,7 @@ class BooruApp(QMainWindow):
|
||||
site_id = self._site_combo.currentData()
|
||||
folder = None
|
||||
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:
|
||||
if f.post_id == post.id and f.folder:
|
||||
folder = f.folder
|
||||
@ -1147,7 +1170,7 @@ class BooruApp(QMainWindow):
|
||||
QApplication.clipboard().setText(post.tags)
|
||||
self._status.showMessage("Tags copied")
|
||||
elif action == fav_action:
|
||||
self._toggle_favorite(index)
|
||||
self._toggle_bookmark(index)
|
||||
elif self._is_child_of_menu(action, bl_menu):
|
||||
tag = action.text()
|
||||
self._db.add_blacklisted_tag(tag)
|
||||
@ -1176,7 +1199,7 @@ class BooruApp(QMainWindow):
|
||||
count = len(posts)
|
||||
|
||||
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_unsorted = save_menu.addAction("Unsorted")
|
||||
@ -1188,7 +1211,7 @@ class BooruApp(QMainWindow):
|
||||
save_new = save_menu.addAction("+ New Folder...")
|
||||
|
||||
menu.addSeparator()
|
||||
unfav_all = menu.addAction(f"Unfavorite All ({count})")
|
||||
unfav_all = menu.addAction(f"Remove All Bookmarks ({count})")
|
||||
menu.addSeparator()
|
||||
batch_dl = menu.addAction(f"Download All ({count})...")
|
||||
copy_urls = menu.addAction("Copy All URLs")
|
||||
@ -1198,7 +1221,7 @@ class BooruApp(QMainWindow):
|
||||
return
|
||||
|
||||
if action == fav_all:
|
||||
self._bulk_favorite(indices, posts)
|
||||
self._bulk_bookmark(indices, posts)
|
||||
elif action == save_unsorted:
|
||||
self._bulk_save(indices, posts, None)
|
||||
elif action == save_new:
|
||||
@ -1225,19 +1248,19 @@ class BooruApp(QMainWindow):
|
||||
# Delete from all folders
|
||||
for folder in self._db.get_folders():
|
||||
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:
|
||||
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._clear_multi()
|
||||
self._status.showMessage(f"Unfavorited {count} posts")
|
||||
self._status.showMessage(f"Removed {count} bookmarks")
|
||||
elif action == copy_urls:
|
||||
urls = "\n".join(p.file_url for p in posts)
|
||||
QApplication.clipboard().setText(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()
|
||||
if not site_id:
|
||||
return
|
||||
@ -1245,20 +1268,20 @@ class BooruApp(QMainWindow):
|
||||
|
||||
async def _do():
|
||||
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
|
||||
try:
|
||||
path = await download_image(post.file_url)
|
||||
self._db.add_favorite(
|
||||
self._db.add_bookmark(
|
||||
site_id=site_id, post_id=post.id,
|
||||
file_url=post.file_url, preview_url=post.preview_url,
|
||||
tags=post.tags, rating=post.rating, score=post.score,
|
||||
source=post.source, cached_path=str(path),
|
||||
)
|
||||
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:
|
||||
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)
|
||||
|
||||
@ -1278,30 +1301,30 @@ class BooruApp(QMainWindow):
|
||||
dest = dest_dir / f"{post.id}{ext}"
|
||||
if not dest.exists():
|
||||
shutil.copy2(path, dest)
|
||||
if site_id and not self._db.is_favorited(site_id, post.id):
|
||||
self._db.add_favorite(
|
||||
if site_id and not self._db.is_bookmarked(site_id, post.id):
|
||||
self._db.add_bookmark(
|
||||
site_id=site_id, post_id=post.id,
|
||||
file_url=post.file_url, preview_url=post.preview_url,
|
||||
tags=post.tags, rating=post.rating, score=post.score,
|
||||
source=post.source, cached_path=str(path), folder=folder,
|
||||
)
|
||||
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:
|
||||
pass
|
||||
self._signals.batch_done.emit(f"Saved {len(posts)} to {where}")
|
||||
|
||||
self._run_async(_do)
|
||||
|
||||
def _toggle_favorite_if_not(self, post: Post) -> None:
|
||||
"""Favorite a post if not already favorited."""
|
||||
def _ensure_bookmarked(self, post: Post) -> None:
|
||||
"""Bookmark a post if not already bookmarked."""
|
||||
site_id = self._site_combo.currentData()
|
||||
if not site_id or self._db.is_favorited(site_id, post.id):
|
||||
if not site_id or self._db.is_bookmarked(site_id, post.id):
|
||||
return
|
||||
|
||||
async def _fav():
|
||||
try:
|
||||
path = await download_image(post.file_url)
|
||||
self._db.add_favorite(
|
||||
self._db.add_bookmark(
|
||||
site_id=site_id,
|
||||
post_id=post.id,
|
||||
file_url=post.file_url,
|
||||
@ -1334,11 +1357,11 @@ class BooruApp(QMainWindow):
|
||||
|
||||
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()
|
||||
if not site_id or index < 0 or index >= len(self._posts):
|
||||
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:
|
||||
if self._current_site:
|
||||
@ -1381,36 +1404,13 @@ class BooruApp(QMainWindow):
|
||||
import shutil
|
||||
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"
|
||||
self._signals.fav_done.emit(
|
||||
self._signals.bookmark_done.emit(
|
||||
self._grid.selected_index,
|
||||
f"Saved #{post.id} to {where}"
|
||||
)
|
||||
except Exception as e:
|
||||
self._signals.fav_error.emit(str(e))
|
||||
self._signals.bookmark_error.emit(str(e))
|
||||
|
||||
self._run_async(_save)
|
||||
|
||||
@ -1479,12 +1479,12 @@ class BooruApp(QMainWindow):
|
||||
def _open_settings(self) -> None:
|
||||
dlg = SettingsDialog(self._db, self)
|
||||
dlg.settings_changed.connect(self._apply_settings)
|
||||
self._favorites_imported = False
|
||||
dlg.favorites_imported.connect(lambda: setattr(self, '_favorites_imported', True))
|
||||
self._bookmarks_imported = False
|
||||
dlg.bookmarks_imported.connect(lambda: setattr(self, '_favorites_imported', True))
|
||||
dlg.exec()
|
||||
if self._favorites_imported:
|
||||
if self._bookmarks_imported:
|
||||
self._switch_view(1)
|
||||
self._favorites_view.refresh()
|
||||
self._bookmarks_view.refresh()
|
||||
|
||||
def _apply_settings(self) -> None:
|
||||
"""Re-read settings from DB and apply to UI."""
|
||||
@ -1493,7 +1493,7 @@ class BooruApp(QMainWindow):
|
||||
if idx >= 0:
|
||||
self._rating_combo.setCurrentIndex(idx)
|
||||
self._score_spin.setValue(self._db.get_setting_int("default_score"))
|
||||
self._favorites_view.refresh()
|
||||
self._bookmarks_view.refresh()
|
||||
self._status.showMessage("Settings applied")
|
||||
|
||||
# -- Fullscreen & Privacy --
|
||||
@ -1541,7 +1541,7 @@ class BooruApp(QMainWindow):
|
||||
if key == Qt.Key.Key_F and self._posts:
|
||||
idx = self._grid.selected_index
|
||||
if 0 <= idx < len(self._posts):
|
||||
self._toggle_favorite(idx)
|
||||
self._toggle_bookmark(idx)
|
||||
return
|
||||
elif key == Qt.Key.Key_I:
|
||||
self._toggle_info()
|
||||
@ -1553,35 +1553,27 @@ class BooruApp(QMainWindow):
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# -- Favorites --
|
||||
# -- Bookmarks --
|
||||
|
||||
def _toggle_favorite(self, index: int) -> None:
|
||||
def _toggle_bookmark(self, index: int) -> None:
|
||||
post = self._posts[index]
|
||||
site_id = self._site_combo.currentData()
|
||||
if not site_id:
|
||||
return
|
||||
|
||||
if self._db.is_favorited(site_id, post.id):
|
||||
# Delete from library if saved
|
||||
favs = self._db.get_favorites(site_id=site_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}")
|
||||
if self._db.is_bookmarked(site_id, post.id):
|
||||
self._db.remove_bookmark(site_id, post.id)
|
||||
self._status.showMessage(f"Unbookmarked #{post.id}")
|
||||
thumbs = self._grid._thumbs
|
||||
if 0 <= index < len(thumbs):
|
||||
thumbs[index].set_favorited(False)
|
||||
thumbs[index].set_saved_locally(False)
|
||||
thumbs[index].set_bookmarked(False)
|
||||
else:
|
||||
self._status.showMessage(f"Favoriting #{post.id}...")
|
||||
self._status.showMessage(f"Bookmarking #{post.id}...")
|
||||
|
||||
async def _fav():
|
||||
try:
|
||||
path = await download_image(post.file_url)
|
||||
self._db.add_favorite(
|
||||
self._db.add_bookmark(
|
||||
site_id=site_id,
|
||||
post_id=post.id,
|
||||
file_url=post.file_url,
|
||||
@ -1592,17 +1584,17 @@ class BooruApp(QMainWindow):
|
||||
source=post.source,
|
||||
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:
|
||||
self._signals.fav_error.emit(str(e))
|
||||
self._signals.bookmark_error.emit(str(e))
|
||||
|
||||
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)
|
||||
thumbs = self._grid._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
|
||||
if "Saved" in msg:
|
||||
thumbs[index].set_saved_locally(True)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Favorites browser widget with folder support."""
|
||||
"""Bookmarks browser widget with folder support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -23,7 +23,7 @@ from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
)
|
||||
|
||||
from ..core.db import Database, Favorite
|
||||
from ..core.db import Database, Bookmark
|
||||
from ..core.cache import download_thumbnail
|
||||
from .grid import ThumbnailGrid
|
||||
|
||||
@ -34,16 +34,16 @@ class FavThumbSignals(QObject):
|
||||
thumb_ready = Signal(int, str)
|
||||
|
||||
|
||||
class FavoritesView(QWidget):
|
||||
"""Browse and search local favorites with folder support."""
|
||||
class BookmarksView(QWidget):
|
||||
"""Browse and search local bookmarks with folder support."""
|
||||
|
||||
favorite_selected = Signal(object)
|
||||
favorite_activated = Signal(object)
|
||||
bookmark_selected = Signal(object)
|
||||
bookmark_activated = Signal(object)
|
||||
|
||||
def __init__(self, db: Database, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._db = db
|
||||
self._favorites: list[Favorite] = []
|
||||
self._bookmarks: list[Bookmark] = []
|
||||
self._signals = FavThumbSignals()
|
||||
self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection)
|
||||
|
||||
@ -65,7 +65,7 @@ class FavoritesView(QWidget):
|
||||
top.addWidget(manage_btn)
|
||||
|
||||
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)
|
||||
top.addWidget(self._search_input, stretch=1)
|
||||
|
||||
@ -93,7 +93,7 @@ class FavoritesView(QWidget):
|
||||
current = self._folder_combo.currentText()
|
||||
self._folder_combo.blockSignals(True)
|
||||
self._folder_combo.clear()
|
||||
self._folder_combo.addItem("All Favorites")
|
||||
self._folder_combo.addItem("All Bookmarks")
|
||||
self._folder_combo.addItem("Unfiled")
|
||||
for folder in self._db.get_folders():
|
||||
self._folder_combo.addItem(folder)
|
||||
@ -110,26 +110,26 @@ class FavoritesView(QWidget):
|
||||
folder_filter = None
|
||||
if folder_text == "Unfiled":
|
||||
folder_filter = "" # sentinel for NULL folder
|
||||
elif folder_text not in ("All Favorites", ""):
|
||||
elif folder_text not in ("All Bookmarks", ""):
|
||||
folder_filter = folder_text
|
||||
|
||||
if folder_filter == "":
|
||||
# Get unfiled: folder IS NULL
|
||||
self._favorites = [
|
||||
f for f in self._db.get_favorites(search=search, limit=500)
|
||||
self._bookmarks = [
|
||||
f for f in self._db.get_bookmarks(search=search, limit=500)
|
||||
if f.folder is None
|
||||
]
|
||||
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:
|
||||
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")
|
||||
thumbs = self._grid.set_posts(len(self._favorites))
|
||||
self._count_label.setText(f"{len(self._bookmarks)} bookmarks")
|
||||
thumbs = self._grid.set_posts(len(self._bookmarks))
|
||||
|
||||
from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS
|
||||
for i, (fav, thumb) in enumerate(zip(self._favorites, thumbs)):
|
||||
thumb.set_favorited(True)
|
||||
for i, (fav, thumb) in enumerate(zip(self._bookmarks, thumbs)):
|
||||
thumb.set_bookmarked(True)
|
||||
# Check if saved to library
|
||||
saved = False
|
||||
if fav.folder:
|
||||
@ -171,15 +171,15 @@ class FavoritesView(QWidget):
|
||||
self.refresh(search=text if text else None)
|
||||
|
||||
def _on_selected(self, index: int) -> None:
|
||||
if 0 <= index < len(self._favorites):
|
||||
self.favorite_selected.emit(self._favorites[index])
|
||||
if 0 <= index < len(self._bookmarks):
|
||||
self.bookmark_selected.emit(self._bookmarks[index])
|
||||
|
||||
def _on_activated(self, index: int) -> None:
|
||||
if 0 <= index < len(self._favorites):
|
||||
self.favorite_activated.emit(self._favorites[index])
|
||||
if 0 <= index < len(self._bookmarks):
|
||||
self.bookmark_activated.emit(self._bookmarks[index])
|
||||
|
||||
def _copy_to_library_unsorted(self, fav: Favorite) -> None:
|
||||
"""Copy a favorited image to the unsorted library folder."""
|
||||
def _copy_to_library_unsorted(self, fav: Bookmark) -> None:
|
||||
"""Copy a bookmarked image to the unsorted library folder."""
|
||||
from ..core.config import saved_dir
|
||||
if fav.cached_path and Path(fav.cached_path).exists():
|
||||
import shutil
|
||||
@ -188,8 +188,8 @@ class FavoritesView(QWidget):
|
||||
if not dest.exists():
|
||||
shutil.copy2(src, dest)
|
||||
|
||||
def _copy_to_library(self, fav: Favorite, folder: str) -> None:
|
||||
"""Copy a favorited image to the library folder on disk."""
|
||||
def _copy_to_library(self, fav: Bookmark, folder: str) -> None:
|
||||
"""Copy a bookmarked image to the library folder on disk."""
|
||||
from ..core.config import saved_folder_dir
|
||||
if fav.cached_path and Path(fav.cached_path).exists():
|
||||
import shutil
|
||||
@ -205,9 +205,9 @@ class FavoritesView(QWidget):
|
||||
self._refresh_folders()
|
||||
|
||||
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
|
||||
fav = self._favorites[index]
|
||||
fav = self._bookmarks[index]
|
||||
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtCore import QUrl
|
||||
@ -246,7 +246,7 @@ class FavoritesView(QWidget):
|
||||
move_new = move_menu.addAction("+ New Folder...")
|
||||
|
||||
menu.addSeparator()
|
||||
unfav = menu.addAction("Unfavorite")
|
||||
remove_bookmark = menu.addAction("Remove Bookmark")
|
||||
|
||||
action = menu.exec(pos)
|
||||
if not action:
|
||||
@ -260,7 +260,7 @@ class FavoritesView(QWidget):
|
||||
if ok and name.strip():
|
||||
self._db.add_folder(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()
|
||||
elif id(action) in save_lib_folders:
|
||||
folder_name = save_lib_folders[id(action)]
|
||||
@ -281,28 +281,26 @@ class FavoritesView(QWidget):
|
||||
elif action == copy_tags:
|
||||
QApplication.clipboard().setText(fav.tags)
|
||||
elif action == move_none:
|
||||
self._db.move_favorite_to_folder(fav.id, None)
|
||||
self._db.move_bookmark_to_folder(fav.id, None)
|
||||
self.refresh()
|
||||
elif action == move_new:
|
||||
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||||
if ok and 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.refresh()
|
||||
elif id(action) in folder_actions:
|
||||
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.refresh()
|
||||
elif action == unfav:
|
||||
from ..core.cache import delete_from_library
|
||||
delete_from_library(fav.post_id, fav.folder)
|
||||
self._db.remove_favorite(fav.site_id, fav.post_id)
|
||||
elif action == remove_bookmark:
|
||||
self._db.remove_bookmark(fav.site_id, fav.post_id)
|
||||
self.refresh()
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
@ -320,7 +318,7 @@ class FavoritesView(QWidget):
|
||||
folder_actions[id(a)] = folder
|
||||
|
||||
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)
|
||||
if not action:
|
||||
@ -340,17 +338,15 @@ class FavoritesView(QWidget):
|
||||
self.refresh()
|
||||
elif action == move_none:
|
||||
for fav in favs:
|
||||
self._db.move_favorite_to_folder(fav.id, None)
|
||||
self._db.move_bookmark_to_folder(fav.id, None)
|
||||
self.refresh()
|
||||
elif id(action) in folder_actions:
|
||||
folder_name = folder_actions[id(action)]
|
||||
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.refresh()
|
||||
elif action == unfav_all:
|
||||
from ..core.cache import delete_from_library
|
||||
elif action == remove_all:
|
||||
for fav in favs:
|
||||
delete_from_library(fav.post_id, fav.folder)
|
||||
self._db.remove_favorite(fav.site_id, fav.post_id)
|
||||
self._db.remove_bookmark(fav.site_id, fav.post_id)
|
||||
self.refresh()
|
||||
@ -27,17 +27,17 @@ class ThumbnailWidget(QWidget):
|
||||
double_clicked = Signal(int)
|
||||
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")
|
||||
_favorited_color = QColor("#ff4444")
|
||||
_bookmarked_color = QColor("#ff4444")
|
||||
|
||||
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
|
||||
savedColor = Property(QColor, _get_saved_color, _set_saved_color)
|
||||
|
||||
def _get_favorited_color(self): return self._favorited_color
|
||||
def _set_favorited_color(self, c): self._favorited_color = QColor(c) if isinstance(c, str) else c
|
||||
favoritedColor = Property(QColor, _get_favorited_color, _set_favorited_color)
|
||||
def _get_bookmarked_color(self): return self._bookmarked_color
|
||||
def _set_bookmarked_color(self, c): self._bookmarked_color = QColor(c) if isinstance(c, str) else c
|
||||
bookmarkedColor = Property(QColor, _get_bookmarked_color, _set_bookmarked_color)
|
||||
|
||||
def __init__(self, index: int, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@ -45,7 +45,7 @@ class ThumbnailWidget(QWidget):
|
||||
self._pixmap: QPixmap | None = None
|
||||
self._selected = False
|
||||
self._multi_selected = False
|
||||
self._favorited = False
|
||||
self._bookmarked = False
|
||||
self._saved_locally = False
|
||||
self._hover = False
|
||||
self._drag_start: QPoint | None = None
|
||||
@ -71,8 +71,8 @@ class ThumbnailWidget(QWidget):
|
||||
self._multi_selected = selected
|
||||
self.update()
|
||||
|
||||
def set_favorited(self, favorited: bool) -> None:
|
||||
self._favorited = favorited
|
||||
def set_bookmarked(self, bookmarked: bool) -> None:
|
||||
self._bookmarked = bookmarked
|
||||
self.update()
|
||||
|
||||
def set_saved_locally(self, saved: bool) -> None:
|
||||
@ -120,11 +120,20 @@ class ThumbnailWidget(QWidget):
|
||||
y = (self.height() - self._pixmap.height()) // 2
|
||||
p.drawPixmap(x, y, self._pixmap)
|
||||
|
||||
# Favorite/saved indicator
|
||||
if self._favorited:
|
||||
# Bookmark/saved indicators (independent dots)
|
||||
dot_x = self.width() - 14
|
||||
if self._saved_locally:
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(self._saved_color if self._saved_locally else self._favorited_color)
|
||||
p.drawEllipse(self.width() - 14, 4, 10, 10)
|
||||
p.setBrush(self._saved_color)
|
||||
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
|
||||
if self._multi_selected:
|
||||
|
||||
224
booru_viewer/gui/library.py
Normal file
224
booru_viewer/gui/library.py
Normal 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]))
|
||||
@ -26,7 +26,7 @@ class FullscreenPreview(QMainWindow):
|
||||
"""Fullscreen media viewer with navigation — images, GIFs, and video."""
|
||||
|
||||
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
|
||||
|
||||
def __init__(self, grid_cols: int = 3, parent=None) -> None:
|
||||
@ -44,10 +44,10 @@ class FullscreenPreview(QMainWindow):
|
||||
toolbar = QHBoxLayout(self._toolbar)
|
||||
toolbar.setContentsMargins(8, 4, 8, 4)
|
||||
|
||||
self._fav_btn = QPushButton("Favorite")
|
||||
self._fav_btn.setFixedWidth(80)
|
||||
self._fav_btn.clicked.connect(self.favorite_requested)
|
||||
toolbar.addWidget(self._fav_btn)
|
||||
self._bookmark_btn = QPushButton("Bookmark")
|
||||
self._bookmark_btn.setFixedWidth(80)
|
||||
self._bookmark_btn.clicked.connect(self.bookmark_requested)
|
||||
toolbar.addWidget(self._bookmark_btn)
|
||||
|
||||
self._save_btn = QPushButton("Save")
|
||||
self._save_btn.setFixedWidth(70)
|
||||
@ -80,9 +80,9 @@ class FullscreenPreview(QMainWindow):
|
||||
QApplication.instance().installEventFilter(self)
|
||||
self.showFullScreen()
|
||||
|
||||
def update_state(self, favorited: bool, saved: bool) -> None:
|
||||
self._fav_btn.setText("Unfavorite" if favorited else "Favorite")
|
||||
self._fav_btn.setFixedWidth(90 if favorited else 80)
|
||||
def update_state(self, bookmarked: bool, saved: bool) -> None:
|
||||
self._bookmark_btn.setText("Unbookmark" if bookmarked else "Bookmark")
|
||||
self._bookmark_btn.setFixedWidth(90 if bookmarked else 80)
|
||||
self._is_saved = saved
|
||||
self._save_btn.setText("Unsave" if saved else "Save")
|
||||
|
||||
@ -500,7 +500,7 @@ class ImagePreview(QWidget):
|
||||
open_in_browser = Signal()
|
||||
save_to_folder = Signal(str)
|
||||
unsave_requested = Signal()
|
||||
favorite_requested = Signal()
|
||||
bookmark_requested = Signal()
|
||||
navigate = Signal(int) # -1 = prev, +1 = next
|
||||
fullscreen_requested = Signal()
|
||||
|
||||
@ -587,7 +587,7 @@ class ImagePreview(QWidget):
|
||||
|
||||
def _on_context_menu(self, pos) -> None:
|
||||
menu = QMenu(self)
|
||||
fav_action = menu.addAction("Favorite")
|
||||
fav_action = menu.addAction("Bookmark")
|
||||
|
||||
save_menu = menu.addMenu("Save to Library")
|
||||
save_unsorted = save_menu.addAction("Unsorted")
|
||||
@ -624,7 +624,7 @@ class ImagePreview(QWidget):
|
||||
if not action:
|
||||
return
|
||||
if action == fav_action:
|
||||
self.favorite_requested.emit()
|
||||
self.bookmark_requested.emit()
|
||||
elif action == save_unsorted:
|
||||
self.save_to_folder.emit("")
|
||||
elif action == save_new:
|
||||
|
||||
@ -35,7 +35,7 @@ class SettingsDialog(QDialog):
|
||||
"""Full settings panel with tabs."""
|
||||
|
||||
settings_changed = Signal()
|
||||
favorites_imported = Signal()
|
||||
bookmarks_imported = Signal()
|
||||
|
||||
def __init__(self, db: Database, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@ -153,8 +153,8 @@ class SettingsDialog(QDialog):
|
||||
self._cache_size_label = QLabel(f"{total_mb:.1f} MB")
|
||||
stats_layout.addRow("Total size:", self._cache_size_label)
|
||||
|
||||
self._fav_count_label = QLabel(f"{self._db.favorite_count()}")
|
||||
stats_layout.addRow("Favorites:", self._fav_count_label)
|
||||
self._fav_count_label = QLabel(f"{self._db.bookmark_count()}")
|
||||
stats_layout.addRow("Bookmarks:", self._fav_count_label)
|
||||
|
||||
layout.addWidget(stats_group)
|
||||
|
||||
@ -317,12 +317,12 @@ class SettingsDialog(QDialog):
|
||||
exp_group = QGroupBox("Backup")
|
||||
exp_layout = QHBoxLayout(exp_group)
|
||||
|
||||
export_btn = QPushButton("Export Favorites")
|
||||
export_btn.clicked.connect(self._export_favorites)
|
||||
export_btn = QPushButton("Export Bookmarks")
|
||||
export_btn.clicked.connect(self._export_bookmarks)
|
||||
exp_layout.addWidget(export_btn)
|
||||
|
||||
import_btn = QPushButton("Import Favorites")
|
||||
import_btn.clicked.connect(self._import_favorites)
|
||||
import_btn = QPushButton("Import Bookmarks")
|
||||
import_btn.clicked.connect(self._import_bookmarks)
|
||||
exp_layout.addWidget(import_btn)
|
||||
|
||||
layout.addWidget(exp_group)
|
||||
@ -499,7 +499,7 @@ class SettingsDialog(QDialog):
|
||||
def _clear_image_cache(self) -> None:
|
||||
reply = QMessageBox.question(
|
||||
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,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
@ -520,9 +520,9 @@ class SettingsDialog(QDialog):
|
||||
|
||||
def _evict_now(self) -> None:
|
||||
max_bytes = self._max_cache.value() * 1024 * 1024
|
||||
# Protect favorited file paths
|
||||
# Protect bookmarked file paths
|
||||
protected = set()
|
||||
for fav in self._db.get_favorites(limit=999999):
|
||||
for fav in self._db.get_bookmarks(limit=999999):
|
||||
if fav.cached_path:
|
||||
protected.add(fav.cached_path)
|
||||
count = evict_oldest(max_bytes, protected)
|
||||
@ -559,13 +559,13 @@ class SettingsDialog(QDialog):
|
||||
from PySide6.QtCore import QUrl
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(data_dir())))
|
||||
|
||||
def _export_favorites(self) -> None:
|
||||
def _export_bookmarks(self) -> None:
|
||||
from .dialogs import save_file
|
||||
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:
|
||||
return
|
||||
favs = self._db.get_favorites(limit=999999)
|
||||
favs = self._db.get_bookmarks(limit=999999)
|
||||
data = [
|
||||
{
|
||||
"post_id": f.post_id,
|
||||
@ -577,18 +577,18 @@ class SettingsDialog(QDialog):
|
||||
"score": f.score,
|
||||
"source": f.source,
|
||||
"folder": f.folder,
|
||||
"favorited_at": f.favorited_at,
|
||||
"bookmarked_at": f.bookmarked_at,
|
||||
}
|
||||
for f in favs
|
||||
]
|
||||
with open(path, "w") as fp:
|
||||
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
|
||||
import json
|
||||
path = open_file(self, "Import Favorites", "JSON (*.json)")
|
||||
path = open_file(self, "Import Bookmarks", "JSON (*.json)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
@ -598,7 +598,7 @@ class SettingsDialog(QDialog):
|
||||
for item in data:
|
||||
try:
|
||||
folder = item.get("folder")
|
||||
self._db.add_favorite(
|
||||
self._db.add_bookmark(
|
||||
site_id=item["site_id"],
|
||||
post_id=item["post_id"],
|
||||
file_url=item["file_url"],
|
||||
@ -614,8 +614,8 @@ class SettingsDialog(QDialog):
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
QMessageBox.information(self, "Done", f"Imported {count} favorites.")
|
||||
self.favorites_imported.emit()
|
||||
QMessageBox.information(self, "Done", f"Imported {count} bookmarks.")
|
||||
self.bookmarks_imported.emit()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Error", str(e))
|
||||
|
||||
|
||||
@ -288,7 +288,7 @@ class SiteManagerDialog(QDialog):
|
||||
return
|
||||
site_id = item.data(Qt.ItemDataRole.UserRole)
|
||||
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,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "booru-viewer"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
description = "Local booru image browser with Qt6 GUI"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user