Sixth and final Phase 2 site migration. The bookmarks context-menu Save As action now mirrors main_window._save_as: render the template to populate the dialog default name, then route the actual save through save_post_file with explicit_name set to whatever the user typed. Same behavior change as the browse-side Save As — Save As into saved_dir() now registers library_meta where v0.2.3 didn't. After this commit the eight save sites in main_window.py and bookmarks.py all share one implementation. The net diff of Phase 1 + Phase 2 (excluding the Phase 0 scaffolding) is a deletion in main_window.py + bookmarks.py even after adding library_save.py, which is the test for whether the refactor was the right call.
541 lines
22 KiB
Python
541 lines
22 KiB
Python
"""Bookmarks browser widget with folder support."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtCore import Qt, Signal, QObject, QTimer
|
||
from PySide6.QtGui import QPixmap
|
||
from PySide6.QtWidgets import (
|
||
QWidget,
|
||
QVBoxLayout,
|
||
QHBoxLayout,
|
||
QLineEdit,
|
||
QPushButton,
|
||
QLabel,
|
||
QComboBox,
|
||
QMenu,
|
||
QApplication,
|
||
QInputDialog,
|
||
QMessageBox,
|
||
)
|
||
|
||
from ..core.db import Database, Bookmark
|
||
from ..core.api.base import Post
|
||
from ..core.cache import download_thumbnail
|
||
from ..core.concurrency import run_on_app_loop
|
||
from .grid import ThumbnailGrid
|
||
|
||
log = logging.getLogger("booru")
|
||
|
||
|
||
class BookmarkThumbSignals(QObject):
|
||
thumb_ready = Signal(int, str)
|
||
|
||
|
||
class BookmarksView(QWidget):
|
||
"""Browse and search local bookmarks with folder support."""
|
||
|
||
bookmark_selected = Signal(object)
|
||
bookmark_activated = Signal(object)
|
||
bookmarks_changed = Signal() # emitted after bookmark add/remove/unsave
|
||
open_in_browser_requested = Signal(int, int) # (site_id, post_id)
|
||
|
||
def __init__(self, db: Database, parent: QWidget | None = None) -> None:
|
||
super().__init__(parent)
|
||
self._db = db
|
||
self._bookmarks: list[Bookmark] = []
|
||
self._signals = BookmarkThumbSignals()
|
||
self._signals.thumb_ready.connect(self._on_thumb_ready, Qt.ConnectionType.QueuedConnection)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Top bar: folder selector + search.
|
||
# 4px right margin so the rightmost button doesn't sit flush
|
||
# against the preview splitter handle.
|
||
top = QHBoxLayout()
|
||
top.setContentsMargins(0, 0, 4, 0)
|
||
|
||
# Compact horizontal padding matches the rest of the app's narrow
|
||
# toolbar buttons. Vertical padding (2px) and min-height (inherited
|
||
# from the global QPushButton rule = 16px) give a total height of
|
||
# 22px, lining up with the bundled themes' inputs/combos so the
|
||
# whole toolbar row sits at one consistent height — and matches
|
||
# what native Qt+Fusion produces with no QSS at all.
|
||
_btn_style = "padding: 2px 6px;"
|
||
|
||
self._folder_combo = QComboBox()
|
||
self._folder_combo.setMinimumWidth(120)
|
||
self._folder_combo.currentTextChanged.connect(lambda _: self.refresh())
|
||
top.addWidget(self._folder_combo)
|
||
|
||
manage_btn = QPushButton("+ Folder")
|
||
manage_btn.setToolTip("New bookmark folder")
|
||
manage_btn.setFixedWidth(75)
|
||
manage_btn.setStyleSheet(_btn_style)
|
||
manage_btn.clicked.connect(self._new_folder)
|
||
top.addWidget(manage_btn)
|
||
|
||
# Delete the currently-selected bookmark folder. Disabled when
|
||
# the combo is on a virtual entry (All Bookmarks / Unfiled).
|
||
# This only removes the DB row — bookmarks in that folder become
|
||
# Unfiled (per remove_folder's UPDATE … SET folder = NULL). The
|
||
# library filesystem is untouched: bookmark folders and library
|
||
# folders are independent name spaces.
|
||
self._delete_folder_btn = QPushButton("− Folder")
|
||
self._delete_folder_btn.setToolTip("Delete the selected bookmark folder")
|
||
self._delete_folder_btn.setFixedWidth(75)
|
||
self._delete_folder_btn.setStyleSheet(_btn_style)
|
||
self._delete_folder_btn.clicked.connect(self._delete_folder)
|
||
top.addWidget(self._delete_folder_btn)
|
||
self._folder_combo.currentTextChanged.connect(
|
||
self._update_delete_folder_enabled
|
||
)
|
||
|
||
self._search_input = QLineEdit()
|
||
self._search_input.setPlaceholderText("Search bookmarks by tag")
|
||
# Enter still triggers an immediate search.
|
||
self._search_input.returnPressed.connect(self._do_search)
|
||
# Live search via debounced timer: every keystroke restarts a
|
||
# 150ms one-shot, when the user stops typing the search runs.
|
||
# Cheap enough since each search is just one SQLite query.
|
||
self._search_debounce = QTimer(self)
|
||
self._search_debounce.setSingleShot(True)
|
||
self._search_debounce.setInterval(150)
|
||
self._search_debounce.timeout.connect(self._do_search)
|
||
self._search_input.textChanged.connect(
|
||
lambda _: self._search_debounce.start()
|
||
)
|
||
top.addWidget(self._search_input, stretch=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)
|
||
self._grid.context_requested.connect(self._on_context_menu)
|
||
self._grid.multi_context_requested.connect(self._on_multi_context_menu)
|
||
layout.addWidget(self._grid)
|
||
|
||
def _refresh_folders(self) -> None:
|
||
current = self._folder_combo.currentText()
|
||
self._folder_combo.blockSignals(True)
|
||
self._folder_combo.clear()
|
||
self._folder_combo.addItem("All Bookmarks")
|
||
self._folder_combo.addItem("Unfiled")
|
||
for folder in self._db.get_folders():
|
||
self._folder_combo.addItem(folder)
|
||
# Restore selection
|
||
idx = self._folder_combo.findText(current)
|
||
if idx >= 0:
|
||
self._folder_combo.setCurrentIndex(idx)
|
||
self._folder_combo.blockSignals(False)
|
||
self._update_delete_folder_enabled()
|
||
|
||
def _update_delete_folder_enabled(self, *_args) -> None:
|
||
"""Enable the delete-folder button only on real folder rows."""
|
||
text = self._folder_combo.currentText()
|
||
self._delete_folder_btn.setEnabled(text not in ("", "All Bookmarks", "Unfiled"))
|
||
|
||
def _delete_folder(self) -> None:
|
||
"""Delete the currently-selected bookmark folder.
|
||
|
||
Bookmarks filed under it become Unfiled (remove_folder UPDATEs
|
||
favorites.folder = NULL before DELETE FROM favorite_folders).
|
||
Library files on disk are unaffected — bookmark folders and
|
||
library folders are separate concepts after the decoupling.
|
||
"""
|
||
name = self._folder_combo.currentText()
|
||
if name in ("", "All Bookmarks", "Unfiled"):
|
||
return
|
||
reply = QMessageBox.question(
|
||
self,
|
||
"Delete Bookmark Folder",
|
||
f"Delete bookmark folder '{name}'?\n\n"
|
||
f"Bookmarks in this folder will become Unfiled. "
|
||
f"Library files on disk are not affected.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self._db.remove_folder(name)
|
||
# Drop back to All Bookmarks so the now-orphan filter doesn't
|
||
# leave the combo on a missing row.
|
||
self._folder_combo.setCurrentText("All Bookmarks")
|
||
self.refresh()
|
||
|
||
def refresh(self, search: str | None = None) -> None:
|
||
self._refresh_folders()
|
||
|
||
folder_text = self._folder_combo.currentText()
|
||
folder_filter = None
|
||
if folder_text == "Unfiled":
|
||
folder_filter = "" # sentinel for NULL folder
|
||
elif folder_text not in ("All Bookmarks", ""):
|
||
folder_filter = folder_text
|
||
|
||
if folder_filter == "":
|
||
# Get unfiled: folder IS NULL
|
||
self._bookmarks = [
|
||
f for f in self._db.get_bookmarks(search=search, limit=500)
|
||
if f.folder is None
|
||
]
|
||
elif folder_filter:
|
||
self._bookmarks = self._db.get_bookmarks(search=search, folder=folder_filter, limit=500)
|
||
else:
|
||
self._bookmarks = self._db.get_bookmarks(search=search, limit=500)
|
||
|
||
self._count_label.setText(f"{len(self._bookmarks)} bookmarks")
|
||
thumbs = self._grid.set_posts(len(self._bookmarks))
|
||
|
||
from ..core.config import find_library_files
|
||
for i, (fav, thumb) in enumerate(zip(self._bookmarks, thumbs)):
|
||
thumb.set_bookmarked(True)
|
||
# Library state is filesystem-truth and folder-agnostic now —
|
||
# walk the library by post id, ignore the bookmark's folder.
|
||
thumb.set_saved_locally(bool(find_library_files(fav.post_id)))
|
||
# Set cached path for drag-and-drop and copy
|
||
if fav.cached_path and Path(fav.cached_path).exists():
|
||
thumb._cached_path = fav.cached_path
|
||
if fav.preview_url:
|
||
self._load_thumb_async(i, fav.preview_url)
|
||
elif fav.cached_path and Path(fav.cached_path).exists():
|
||
pix = QPixmap(fav.cached_path)
|
||
if not pix.isNull():
|
||
thumb.set_pixmap(pix)
|
||
|
||
def _load_thumb_async(self, index: int, url: str) -> None:
|
||
# Schedule the download on the persistent event loop instead of
|
||
# spawning a daemon thread that runs its own throwaway loop. This
|
||
# is the fix for the loop-affinity bug where the cache module's
|
||
# shared httpx client would get bound to the throwaway loop and
|
||
# then fail every subsequent use from the persistent loop.
|
||
async def _dl():
|
||
try:
|
||
path = await download_thumbnail(url)
|
||
self._signals.thumb_ready.emit(index, str(path))
|
||
except Exception as e:
|
||
log.warning(f"Bookmark thumb {index} failed: {e}")
|
||
run_on_app_loop(_dl())
|
||
|
||
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)
|
||
|
||
def _do_search(self) -> None:
|
||
text = self._search_input.text().strip()
|
||
self.refresh(search=text if text else None)
|
||
|
||
def _on_selected(self, index: int) -> None:
|
||
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._bookmarks):
|
||
self.bookmark_activated.emit(self._bookmarks[index])
|
||
|
||
def _bookmark_to_post(self, fav: Bookmark) -> Post:
|
||
"""Adapt a Bookmark into a Post for the renderer / save flow.
|
||
|
||
The unified save_post_file flow takes a Post (because it's
|
||
called from the browse side too), so bookmarks borrow Post
|
||
shape just for the duration of the save call. Bookmark already
|
||
carries every field the renderer reads — this adapter is the
|
||
one place to update if Post's field set drifts later.
|
||
"""
|
||
return Post(
|
||
id=fav.post_id,
|
||
file_url=fav.file_url,
|
||
preview_url=fav.preview_url,
|
||
tags=fav.tags,
|
||
score=fav.score or 0,
|
||
rating=fav.rating,
|
||
source=fav.source,
|
||
tag_categories=fav.tag_categories or {},
|
||
)
|
||
|
||
def _save_bookmark_to_library(self, fav: Bookmark, folder: str | None) -> None:
|
||
"""Copy a bookmarked image into the library, optionally inside
|
||
a subfolder, routing through the unified save_post_file flow.
|
||
|
||
Fixes the latent v0.2.3 bug where bookmark→library copies
|
||
wrote files but never registered library_meta rows — those
|
||
files were on disk but invisible to Library tag-search."""
|
||
from ..core.config import saved_dir, saved_folder_dir
|
||
from ..core.library_save import save_post_file
|
||
|
||
if not (fav.cached_path and Path(fav.cached_path).exists()):
|
||
return
|
||
try:
|
||
dest_dir = saved_folder_dir(folder) if folder else saved_dir()
|
||
except ValueError:
|
||
return
|
||
src = Path(fav.cached_path)
|
||
post = self._bookmark_to_post(fav)
|
||
try:
|
||
save_post_file(src, post, dest_dir, self._db)
|
||
except Exception as e:
|
||
log.warning(f"Bookmark→library save #{fav.post_id} failed: {e}")
|
||
|
||
def _copy_to_library_unsorted(self, fav: Bookmark) -> None:
|
||
"""Copy a bookmarked image to the unsorted library folder."""
|
||
self._save_bookmark_to_library(fav, None)
|
||
|
||
def _copy_to_library(self, fav: Bookmark, folder: str) -> None:
|
||
"""Copy a bookmarked image to the named library subfolder."""
|
||
self._save_bookmark_to_library(fav, folder)
|
||
|
||
def _new_folder(self) -> None:
|
||
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||
if ok and name.strip():
|
||
try:
|
||
self._db.add_folder(name.strip())
|
||
except ValueError as e:
|
||
QMessageBox.warning(self, "Invalid Folder Name", str(e))
|
||
return
|
||
self._refresh_folders()
|
||
|
||
def _on_context_menu(self, index: int, pos) -> None:
|
||
if index < 0 or index >= len(self._bookmarks):
|
||
return
|
||
fav = self._bookmarks[index]
|
||
|
||
from PySide6.QtGui import QDesktopServices
|
||
from PySide6.QtCore import QUrl
|
||
from .dialogs import save_file
|
||
|
||
menu = QMenu(self)
|
||
|
||
open_browser = menu.addAction("Open in Browser")
|
||
open_default = menu.addAction("Open in Default App")
|
||
menu.addSeparator()
|
||
save_as = menu.addAction("Save As...")
|
||
|
||
# Save to Library submenu — folders come from the library
|
||
# filesystem, not the bookmark folder DB.
|
||
from ..core.config import library_folders, find_library_files
|
||
save_lib_menu = menu.addMenu("Save to Library")
|
||
save_lib_unsorted = save_lib_menu.addAction("Unfiled")
|
||
save_lib_menu.addSeparator()
|
||
save_lib_folders = {}
|
||
for folder in library_folders():
|
||
a = save_lib_menu.addAction(folder)
|
||
save_lib_folders[id(a)] = folder
|
||
save_lib_menu.addSeparator()
|
||
save_lib_new = save_lib_menu.addAction("+ New Folder...")
|
||
|
||
unsave_lib = None
|
||
# Only show unsave if the post is actually on disk somewhere.
|
||
if find_library_files(fav.post_id):
|
||
unsave_lib = menu.addAction("Unsave from Library")
|
||
copy_file = menu.addAction("Copy File to Clipboard")
|
||
copy_url = menu.addAction("Copy Image URL")
|
||
copy_tags = menu.addAction("Copy Tags")
|
||
|
||
# Move to folder submenu
|
||
menu.addSeparator()
|
||
move_menu = menu.addMenu("Move to Folder")
|
||
move_none = move_menu.addAction("Unfiled")
|
||
move_menu.addSeparator()
|
||
folder_actions = {}
|
||
for folder in self._db.get_folders():
|
||
a = move_menu.addAction(folder)
|
||
folder_actions[id(a)] = folder
|
||
move_menu.addSeparator()
|
||
move_new = move_menu.addAction("+ New Folder...")
|
||
|
||
menu.addSeparator()
|
||
remove_bookmark = menu.addAction("Remove Bookmark")
|
||
|
||
action = menu.exec(pos)
|
||
if not action:
|
||
return
|
||
|
||
if action == save_lib_unsorted:
|
||
self._copy_to_library_unsorted(fav)
|
||
self.refresh()
|
||
elif action == save_lib_new:
|
||
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||
if ok and name.strip():
|
||
# Validate the name via saved_folder_dir() which mkdir's
|
||
# the library subdir and runs the path-traversal check.
|
||
# No DB folder write — bookmark folders are independent.
|
||
try:
|
||
from ..core.config import saved_folder_dir
|
||
saved_folder_dir(name.strip())
|
||
except ValueError as e:
|
||
QMessageBox.warning(self, "Invalid Folder Name", str(e))
|
||
return
|
||
self._copy_to_library(fav, name.strip())
|
||
self.refresh()
|
||
elif id(action) in save_lib_folders:
|
||
folder_name = save_lib_folders[id(action)]
|
||
self._copy_to_library(fav, folder_name)
|
||
self.refresh()
|
||
elif action == open_browser:
|
||
self.open_in_browser_requested.emit(fav.site_id, fav.post_id)
|
||
elif action == open_default:
|
||
if fav.cached_path and Path(fav.cached_path).exists():
|
||
QDesktopServices.openUrl(QUrl.fromLocalFile(fav.cached_path))
|
||
elif action == save_as:
|
||
if fav.cached_path and Path(fav.cached_path).exists():
|
||
from ..core.config import render_filename_template
|
||
from ..core.library_save import save_post_file
|
||
src = Path(fav.cached_path)
|
||
post = self._bookmark_to_post(fav)
|
||
template = self._db.get_setting("library_filename_template")
|
||
default_name = render_filename_template(template, post, src.suffix)
|
||
dest = save_file(self, "Save Image", default_name, f"Images (*{src.suffix})")
|
||
if dest:
|
||
dest_path = Path(dest)
|
||
try:
|
||
save_post_file(
|
||
src, post, dest_path.parent, self._db,
|
||
explicit_name=dest_path.name,
|
||
)
|
||
except Exception as e:
|
||
log.warning(f"Bookmark Save As #{fav.post_id} failed: {e}")
|
||
elif action == unsave_lib:
|
||
from ..core.cache import delete_from_library
|
||
# delete_from_library walks every library folder by post id
|
||
# now — no folder hint needed (and fav.folder wouldn't be
|
||
# accurate anyway after the bookmark/library separation).
|
||
if delete_from_library(fav.post_id):
|
||
self.refresh()
|
||
self.bookmarks_changed.emit()
|
||
elif action == copy_file:
|
||
path = fav.cached_path
|
||
if path and Path(path).exists():
|
||
from PySide6.QtCore import QMimeData, QUrl
|
||
from PySide6.QtGui import QPixmap
|
||
mime = QMimeData()
|
||
mime.setUrls([QUrl.fromLocalFile(str(Path(path).resolve()))])
|
||
pix = QPixmap(path)
|
||
if not pix.isNull():
|
||
mime.setImageData(pix.toImage())
|
||
QApplication.clipboard().setMimeData(mime)
|
||
elif action == copy_url:
|
||
QApplication.clipboard().setText(fav.file_url)
|
||
elif action == copy_tags:
|
||
QApplication.clipboard().setText(fav.tags)
|
||
elif action == move_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():
|
||
try:
|
||
self._db.add_folder(name.strip())
|
||
except ValueError as e:
|
||
QMessageBox.warning(self, "Invalid Folder Name", str(e))
|
||
return
|
||
# Pure bookmark organization: file the bookmark, don't
|
||
# touch the library filesystem. Save to Library is now a
|
||
# separate, explicit action.
|
||
self._db.move_bookmark_to_folder(fav.id, name.strip())
|
||
self.refresh()
|
||
elif id(action) in folder_actions:
|
||
folder_name = folder_actions[id(action)]
|
||
self._db.move_bookmark_to_folder(fav.id, folder_name)
|
||
self.refresh()
|
||
elif action == remove_bookmark:
|
||
self._db.remove_bookmark(fav.site_id, fav.post_id)
|
||
self.refresh()
|
||
self.bookmarks_changed.emit()
|
||
|
||
def _on_multi_context_menu(self, indices: list, pos) -> None:
|
||
favs = [self._bookmarks[i] for i in indices if 0 <= i < len(self._bookmarks)]
|
||
if not favs:
|
||
return
|
||
|
||
from ..core.config import library_folders
|
||
|
||
menu = QMenu(self)
|
||
|
||
# Save All to Library submenu — folders are filesystem-truth.
|
||
# Conversion from a flat action to a submenu so the user can
|
||
# pick a destination instead of having "save all" silently use
|
||
# each bookmark's fav.folder (which was the cross-bleed bug).
|
||
save_lib_menu = menu.addMenu(f"Save All ({len(favs)}) to Library")
|
||
save_lib_unsorted = save_lib_menu.addAction("Unfiled")
|
||
save_lib_menu.addSeparator()
|
||
save_lib_folder_actions: dict[int, str] = {}
|
||
for folder in library_folders():
|
||
a = save_lib_menu.addAction(folder)
|
||
save_lib_folder_actions[id(a)] = folder
|
||
save_lib_menu.addSeparator()
|
||
save_lib_new = save_lib_menu.addAction("+ New Folder...")
|
||
|
||
unsave_all = menu.addAction(f"Unsave All ({len(favs)}) from Library")
|
||
menu.addSeparator()
|
||
|
||
# Move to Folder is bookmark organization — reads from the DB.
|
||
move_menu = menu.addMenu(f"Move All ({len(favs)}) to Folder")
|
||
move_none = move_menu.addAction("Unfiled")
|
||
move_menu.addSeparator()
|
||
folder_actions = {}
|
||
for folder in self._db.get_folders():
|
||
a = move_menu.addAction(folder)
|
||
folder_actions[id(a)] = folder
|
||
|
||
menu.addSeparator()
|
||
remove_all = menu.addAction(f"Remove All Bookmarks ({len(favs)})")
|
||
|
||
action = menu.exec(pos)
|
||
if not action:
|
||
return
|
||
|
||
def _save_all_into(folder_name: str | None) -> None:
|
||
for fav in favs:
|
||
if folder_name:
|
||
self._copy_to_library(fav, folder_name)
|
||
else:
|
||
self._copy_to_library_unsorted(fav)
|
||
self.refresh()
|
||
|
||
if action == save_lib_unsorted:
|
||
_save_all_into(None)
|
||
elif action == save_lib_new:
|
||
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
||
if ok and name.strip():
|
||
try:
|
||
from ..core.config import saved_folder_dir
|
||
saved_folder_dir(name.strip())
|
||
except ValueError as e:
|
||
QMessageBox.warning(self, "Invalid Folder Name", str(e))
|
||
return
|
||
_save_all_into(name.strip())
|
||
elif id(action) in save_lib_folder_actions:
|
||
_save_all_into(save_lib_folder_actions[id(action)])
|
||
elif action == unsave_all:
|
||
from ..core.cache import delete_from_library
|
||
for fav in favs:
|
||
delete_from_library(fav.post_id)
|
||
self.refresh()
|
||
self.bookmarks_changed.emit()
|
||
elif action == move_none:
|
||
for fav in favs:
|
||
self._db.move_bookmark_to_folder(fav.id, None)
|
||
self.refresh()
|
||
elif id(action) in folder_actions:
|
||
folder_name = folder_actions[id(action)]
|
||
# Bookmark organization only — Save to Library is separate.
|
||
for fav in favs:
|
||
self._db.move_bookmark_to_folder(fav.id, folder_name)
|
||
self.refresh()
|
||
elif action == remove_all:
|
||
for fav in favs:
|
||
self._db.remove_bookmark(fav.site_id, fav.post_id)
|
||
self.refresh()
|
||
self.bookmarks_changed.emit()
|