1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPL v3 Copyright: 2013, Kovid Goyal <kovid at kovidgoyal.net> 4 5 6import json 7from operator import itemgetter 8from qt.core import ( 9 QAction, QComboBox, QGridLayout, QHBoxLayout, QIcon, QInputDialog, 10 QItemSelectionModel, QLabel, QListWidget, QListWidgetItem, QPushButton, Qt, 11 QWidget, pyqtSignal 12) 13 14from calibre.gui2 import choose_files, choose_save_file 15from calibre.gui2.dialogs.confirm_delete import confirm 16from calibre.gui2.viewer.shortcuts import get_shortcut_for 17from calibre.gui2.viewer.web_view import vprefs 18from calibre.utils.date import EPOCH, utcnow 19from calibre.utils.icu import primary_sort_key 20 21 22class BookmarksList(QListWidget): 23 24 changed = pyqtSignal() 25 bookmark_activated = pyqtSignal(object) 26 27 def __init__(self, parent=None): 28 QListWidget.__init__(self, parent) 29 self.setAlternatingRowColors(True) 30 self.setStyleSheet('QListView::item { padding: 0.5ex }') 31 self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) 32 self.ac_edit = ac = QAction(QIcon(I('edit_input.png')), _('Rename this bookmark'), self) 33 self.addAction(ac) 34 self.ac_delete = ac = QAction(QIcon(I('trash.png')), _('Remove this bookmark'), self) 35 self.addAction(ac) 36 37 @property 38 def current_non_removed_item(self): 39 ans = self.currentItem() 40 if ans is not None: 41 bm = ans.data(Qt.ItemDataRole.UserRole) 42 if not bm.get('removed'): 43 return ans 44 45 def keyPressEvent(self, ev): 46 if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): 47 i = self.current_non_removed_item 48 if i is not None: 49 self.bookmark_activated.emit(i) 50 ev.accept() 51 return 52 if ev.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): 53 i = self.current_non_removed_item 54 if i is not None: 55 self.ac_delete.trigger() 56 ev.accept() 57 return 58 return QListWidget.keyPressEvent(self, ev) 59 60 def activate_related_bookmark(self, delta=1): 61 if not self.count(): 62 return 63 items = [self.item(r) for r in range(self.count())] 64 row = self.currentRow() 65 current_item = items[row] 66 items = [i for i in items if not i.isHidden()] 67 count = len(items) 68 if not count: 69 return 70 row = items.index(current_item) 71 nrow = (row + delta + count) % count 72 self.setCurrentItem(items[nrow]) 73 self.bookmark_activated.emit(self.currentItem()) 74 75 def next_bookmark(self): 76 self.activate_related_bookmark() 77 78 def previous_bookmark(self): 79 self.activate_related_bookmark(-1) 80 81 82class BookmarkManager(QWidget): 83 84 edited = pyqtSignal(object) 85 activated = pyqtSignal(object) 86 create_requested = pyqtSignal() 87 toggle_requested = pyqtSignal() 88 89 def __init__(self, parent): 90 QWidget.__init__(self, parent) 91 self.l = l = QGridLayout(self) 92 l.setContentsMargins(0, 0, 0, 0) 93 self.setLayout(l) 94 self.toc = parent.toc 95 96 self.bookmarks_list = bl = BookmarksList(self) 97 bl.itemChanged.connect(self.item_changed) 98 l.addWidget(bl, 0, 0, 1, -1) 99 bl.itemClicked.connect(self.item_activated) 100 bl.bookmark_activated.connect(self.item_activated) 101 bl.changed.connect(lambda : self.edited.emit(self.get_bookmarks())) 102 bl.ac_edit.triggered.connect(self.edit_bookmark) 103 bl.ac_delete.triggered.connect(self.delete_bookmark) 104 105 self.la = la = QLabel(_( 106 'Double click to edit the bookmarks')) 107 la.setWordWrap(True) 108 l.addWidget(la, l.rowCount(), 0, 1, -1) 109 110 self.button_new = b = QPushButton(QIcon(I('bookmarks.png')), _('&New'), self) 111 b.clicked.connect(self.create_requested) 112 b.setToolTip(_('Create a new bookmark at the current location')) 113 l.addWidget(b) 114 115 self.button_delete = b = QPushButton(QIcon(I('trash.png')), _('&Remove'), self) 116 b.setToolTip(_('Remove the currently selected bookmark')) 117 b.clicked.connect(self.delete_bookmark) 118 l.addWidget(b, l.rowCount() - 1, 1) 119 120 self.button_prev = b = QPushButton(QIcon(I('back.png')), _('Pre&vious'), self) 121 b.clicked.connect(self.bookmarks_list.previous_bookmark) 122 l.addWidget(b) 123 124 self.button_next = b = QPushButton(QIcon(I('forward.png')), _('Nex&t'), self) 125 b.clicked.connect(self.bookmarks_list.next_bookmark) 126 l.addWidget(b, l.rowCount() - 1, 1) 127 128 la = QLabel(_('&Sort by:')) 129 self.sort_by = sb = QComboBox(self) 130 la.setBuddy(sb) 131 sb.addItem(_('Title'), 'title') 132 sb.addItem(_('Position in book'), 'pos') 133 sb.addItem(_('Date'), 'timestamp') 134 sb.setToolTip(_('Change how the bookmarks are sorted')) 135 i = sb.findData(vprefs['bookmarks_sort']) 136 if i > -1: 137 sb.setCurrentIndex(i) 138 h = QHBoxLayout() 139 h.addWidget(la), h.addWidget(sb, 10) 140 l.addLayout(h, l.rowCount(), 0, 1, 2) 141 sb.currentIndexChanged.connect(self.sort_by_changed) 142 143 self.button_export = b = QPushButton(_('E&xport'), self) 144 b.clicked.connect(self.export_bookmarks) 145 l.addWidget(b, l.rowCount(), 0) 146 147 self.button_import = b = QPushButton(_('&Import'), self) 148 b.clicked.connect(self.import_bookmarks) 149 l.addWidget(b, l.rowCount() - 1, 1) 150 151 def item_activated(self, item): 152 bm = self.item_to_bm(item) 153 self.activated.emit(bm['pos']) 154 155 @property 156 def current_sort_by(self): 157 return self.sort_by.currentData() 158 159 def sort_by_changed(self): 160 vprefs['bookmarks_sort'] = self.current_sort_by 161 self.set_bookmarks(self.get_bookmarks()) 162 163 def set_bookmarks(self, bookmarks=()): 164 csb = self.current_sort_by 165 if csb in ('name', 'title'): 166 sk = lambda x: primary_sort_key(x['title']) 167 elif csb == 'timestamp': 168 sk = itemgetter('timestamp') 169 else: 170 from calibre.ebooks.epub.cfi.parse import cfi_sort_key 171 defval = cfi_sort_key('/99999999') 172 173 def pos_key(b): 174 if b.get('pos_type') == 'epubcfi': 175 return cfi_sort_key(b['pos'], only_path=False) 176 return defval 177 sk = pos_key 178 179 bookmarks = sorted(bookmarks, key=sk) 180 current_bookmark_id = self.current_bookmark_id 181 self.bookmarks_list.clear() 182 for bm in bookmarks: 183 i = QListWidgetItem(bm['title']) 184 i.setData(Qt.ItemDataRole.ToolTipRole, bm['title']) 185 i.setData(Qt.ItemDataRole.UserRole, self.bm_to_item(bm)) 186 i.setFlags(i.flags() | Qt.ItemFlag.ItemIsEditable) 187 self.bookmarks_list.addItem(i) 188 if bm.get('removed'): 189 i.setHidden(True) 190 for i in range(self.bookmarks_list.count()): 191 item = self.bookmarks_list.item(i) 192 if not item.isHidden(): 193 self.bookmarks_list.setCurrentItem(item, QItemSelectionModel.SelectionFlag.ClearAndSelect) 194 break 195 if current_bookmark_id is not None: 196 self.current_bookmark_id = current_bookmark_id 197 198 @property 199 def current_bookmark_id(self): 200 item = self.bookmarks_list.currentItem() 201 if item is not None: 202 return item.data(Qt.ItemDataRole.DisplayRole) 203 204 @current_bookmark_id.setter 205 def current_bookmark_id(self, val): 206 for i, q in enumerate(self): 207 if q['title'] == val: 208 item = self.bookmarks_list.item(i) 209 self.bookmarks_list.setCurrentItem(item, QItemSelectionModel.SelectionFlag.ClearAndSelect) 210 self.bookmarks_list.scrollToItem(item) 211 212 def set_current_bookmark(self, bm): 213 for i, q in enumerate(self): 214 if bm == q: 215 l = self.bookmarks_list 216 item = l.item(i) 217 l.setCurrentItem(item, QItemSelectionModel.SelectionFlag.ClearAndSelect) 218 l.scrollToItem(item) 219 220 def __iter__(self): 221 for i in range(self.bookmarks_list.count()): 222 yield self.item_to_bm(self.bookmarks_list.item(i)) 223 224 def uniqify_bookmark_title(self, base): 225 remove = [] 226 for i in range(self.bookmarks_list.count()): 227 item = self.bookmarks_list.item(i) 228 bm = item.data(Qt.ItemDataRole.UserRole) 229 if bm.get('removed') and bm['title'] == base: 230 remove.append(i) 231 for i in reversed(remove): 232 self.bookmarks_list.takeItem(i) 233 all_titles = {bm['title'] for bm in self.get_bookmarks()} 234 c = 0 235 q = base 236 while q in all_titles: 237 c += 1 238 q = '{} #{}'.format(base, c) 239 return q 240 241 def item_changed(self, item): 242 self.bookmarks_list.blockSignals(True) 243 title = str(item.data(Qt.ItemDataRole.DisplayRole)) or _('Unknown') 244 title = self.uniqify_bookmark_title(title) 245 item.setData(Qt.ItemDataRole.DisplayRole, title) 246 item.setData(Qt.ItemDataRole.ToolTipRole, title) 247 bm = item.data(Qt.ItemDataRole.UserRole) 248 bm['title'] = title 249 bm['timestamp'] = utcnow().isoformat() 250 item.setData(Qt.ItemDataRole.UserRole, bm) 251 self.bookmarks_list.blockSignals(False) 252 self.edited.emit(self.get_bookmarks()) 253 254 def delete_bookmark(self): 255 item = self.bookmarks_list.current_non_removed_item 256 if item is not None: 257 bm = item.data(Qt.ItemDataRole.UserRole) 258 if confirm( 259 _('Are you sure you want to delete the bookmark: {0}?').format(bm['title']), 260 'delete-bookmark-from-viewer', parent=self, config_set=vprefs 261 ): 262 bm['removed'] = True 263 bm['timestamp'] = utcnow().isoformat() 264 self.bookmarks_list.blockSignals(True) 265 item.setData(Qt.ItemDataRole.UserRole, bm) 266 self.bookmarks_list.blockSignals(False) 267 item.setHidden(True) 268 self.edited.emit(self.get_bookmarks()) 269 270 def edit_bookmark(self): 271 item = self.bookmarks_list.current_non_removed_item 272 if item is not None: 273 self.bookmarks_list.editItem(item) 274 275 def bm_to_item(self, bm): 276 return bm.copy() 277 278 def item_to_bm(self, item): 279 return item.data(Qt.ItemDataRole.UserRole).copy() 280 281 def get_bookmarks(self): 282 return list(self) 283 284 def export_bookmarks(self): 285 filename = choose_save_file( 286 self, 'export-viewer-bookmarks', _('Export bookmarks'), 287 filters=[(_('Saved bookmarks'), ['calibre-bookmarks'])], all_files=False, initial_filename='bookmarks.calibre-bookmarks') 288 if filename: 289 bm = [x for x in self.get_bookmarks() if not x.get('removed')] 290 data = json.dumps({'type': 'bookmarks', 'entries': bm}, indent=True) 291 if not isinstance(data, bytes): 292 data = data.encode('utf-8') 293 with lopen(filename, 'wb') as fileobj: 294 fileobj.write(data) 295 296 def import_bookmarks(self): 297 files = choose_files(self, 'export-viewer-bookmarks', _('Import bookmarks'), 298 filters=[(_('Saved bookmarks'), ['calibre-bookmarks'])], all_files=False, select_only_single_file=True) 299 if not files: 300 return 301 filename = files[0] 302 303 imported = None 304 with lopen(filename, 'rb') as fileobj: 305 imported = json.load(fileobj) 306 307 def import_old_bookmarks(imported): 308 try: 309 for bm in imported: 310 if 'title' not in bm: 311 return 312 except Exception: 313 return 314 315 bookmarks = self.get_bookmarks() 316 for bm in imported: 317 if bm['title'] == 'calibre_current_page_bookmark': 318 continue 319 epubcfi = 'epubcfi(/{}/{})'.format((bm['spine'] + 1) * 2, bm['pos'].lstrip('/')) 320 q = {'pos_type': 'epubcfi', 'pos': epubcfi, 'timestamp': EPOCH.isoformat(), 'title': bm['title']} 321 if q not in bookmarks: 322 bookmarks.append(q) 323 self.set_bookmarks(bookmarks) 324 self.edited.emit(self.get_bookmarks()) 325 326 def import_current_bookmarks(imported): 327 if imported.get('type') != 'bookmarks': 328 return 329 bookmarks = self.get_bookmarks() 330 for bm in imported['entries']: 331 if bm not in bookmarks: 332 bookmarks.append(bm) 333 self.set_bookmarks(bookmarks) 334 self.edited.emit(self.get_bookmarks()) 335 336 if imported is not None: 337 if isinstance(imported, list): 338 import_old_bookmarks(imported) 339 else: 340 import_current_bookmarks(imported) 341 342 def create_new_bookmark(self, pos_data): 343 base_default_title = self.toc.model().title_for_current_node or _('Bookmark') 344 all_titles = {bm['title'] for bm in self.get_bookmarks()} 345 c = 0 346 while True: 347 c += 1 348 default_title = '{} #{}'.format(base_default_title, c) 349 if default_title not in all_titles: 350 break 351 352 title, ok = QInputDialog.getText(self, _('Add bookmark'), 353 _('Enter title for bookmark:'), text=pos_data.get('selected_text') or default_title) 354 title = str(title).strip() 355 if not ok or not title: 356 return 357 title = self.uniqify_bookmark_title(title) 358 cfi = (pos_data.get('selection_bounds') or {}).get('start') or pos_data['cfi'] 359 bm = { 360 'title': title, 361 'pos_type': 'epubcfi', 362 'pos': cfi, 363 'timestamp': utcnow().isoformat(), 364 } 365 bookmarks = self.get_bookmarks() 366 bookmarks.append(bm) 367 self.set_bookmarks(bookmarks) 368 self.set_current_bookmark(bm) 369 self.edited.emit(bookmarks) 370 371 def keyPressEvent(self, ev): 372 sc = get_shortcut_for(self, ev) 373 if ev.key() == Qt.Key.Key_Escape or sc == 'toggle_bookmarks': 374 self.toggle_requested.emit() 375 return 376 if sc == 'new_bookmark': 377 self.create_requested.emit() 378 return 379 return QWidget.keyPressEvent(self, ev) 380