1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net> 4 5import json 6import os 7import re 8import sys 9import time 10from collections import defaultdict, namedtuple 11from hashlib import sha256 12from qt.core import ( 13 QApplication, QCursor, QDockWidget, QEvent, QMainWindow, QMenu, QMimeData, 14 QModelIndex, QPixmap, Qt, QTimer, QToolBar, QUrl, QVBoxLayout, QWidget, 15 pyqtSignal 16) 17from threading import Thread 18 19from calibre import prints 20from calibre.constants import ismacos, iswindows 21from calibre.customize.ui import available_input_formats 22from calibre.db.annotations import merge_annotations 23from calibre.gui2 import ( 24 add_to_recent_docs, choose_files, error_dialog, sanitize_env_vars 25) 26from calibre.gui2.dialogs.drm_error import DRMErrorMessage 27from calibre.gui2.image_popup import ImagePopup 28from calibre.gui2.main_window import MainWindow 29from calibre.gui2.viewer import get_current_book_data, performance_monitor 30from calibre.gui2.viewer.annotations import ( 31 AnnotationsSaveWorker, annotations_dir, parse_annotations 32) 33from calibre.gui2.viewer.bookmarks import BookmarkManager 34from calibre.gui2.viewer.config import get_session_pref, vprefs 35from calibre.gui2.viewer.convert_book import clean_running_workers, prepare_book 36from calibre.gui2.viewer.highlights import HighlightsPanel 37from calibre.gui2.viewer.integration import ( 38 get_book_library_details, load_annotations_map_from_library 39) 40from calibre.gui2.viewer.lookup import Lookup 41from calibre.gui2.viewer.overlay import LoadingOverlay 42from calibre.gui2.viewer.search import SearchPanel 43from calibre.gui2.viewer.toc import TOC, TOCSearch, TOCView 44from calibre.gui2.viewer.toolbars import ActionsToolBar 45from calibre.gui2.viewer.web_view import WebView, get_path_for_name, set_book_path 46from calibre.utils.date import utcnow 47from calibre.utils.img import image_from_path 48from calibre.utils.ipc.simple_worker import WorkerError 49from polyglot.builtins import as_bytes, as_unicode, iteritems, itervalues 50 51 52def is_float(x): 53 try: 54 float(x) 55 return True 56 except Exception: 57 pass 58 return False 59 60 61def dock_defs(): 62 Dock = namedtuple('Dock', 'name title initial_area allowed_areas') 63 ans = {} 64 65 def d(title, name, area, allowed=Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea): 66 ans[name] = Dock(name + '-dock', title, area, allowed) 67 68 d(_('Table of Contents'), 'toc', Qt.DockWidgetArea.LeftDockWidgetArea), 69 d(_('Lookup'), 'lookup', Qt.DockWidgetArea.RightDockWidgetArea), 70 d(_('Bookmarks'), 'bookmarks', Qt.DockWidgetArea.RightDockWidgetArea) 71 d(_('Search'), 'search', Qt.DockWidgetArea.LeftDockWidgetArea) 72 d(_('Inspector'), 'inspector', Qt.DockWidgetArea.RightDockWidgetArea, Qt.DockWidgetArea.AllDockWidgetAreas) 73 d(_('Highlights'), 'highlights', Qt.DockWidgetArea.RightDockWidgetArea) 74 return ans 75 76 77def path_key(path): 78 return sha256(as_bytes(path)).hexdigest() 79 80 81class EbookViewer(MainWindow): 82 83 msg_from_anotherinstance = pyqtSignal(object) 84 book_preparation_started = pyqtSignal() 85 book_prepared = pyqtSignal(object, object) 86 MAIN_WINDOW_STATE_VERSION = 1 87 88 def __init__(self, open_at=None, continue_reading=None, force_reload=False, calibre_book_data=None): 89 MainWindow.__init__(self, None) 90 self.annotations_saver = None 91 self.calibre_book_data_for_first_book = calibre_book_data 92 self.shutting_down = self.close_forced = self.shutdown_done = False 93 self.force_reload = force_reload 94 connect_lambda(self.book_preparation_started, self, lambda self: self.loading_overlay(_( 95 'Preparing book for first read, please wait')), type=Qt.ConnectionType.QueuedConnection) 96 self.maximized_at_last_fullscreen = False 97 self.save_pos_timer = t = QTimer(self) 98 t.setSingleShot(True), t.setInterval(3000), t.setTimerType(Qt.TimerType.VeryCoarseTimer) 99 connect_lambda(t.timeout, self, lambda self: self.save_annotations(in_book_file=False)) 100 self.pending_open_at = open_at 101 self.base_window_title = _('E-book viewer') 102 self.setDockOptions(QMainWindow.DockOption.AnimatedDocks | QMainWindow.DockOption.AllowTabbedDocks | QMainWindow.DockOption.AllowNestedDocks) 103 self.setWindowTitle(self.base_window_title) 104 self.in_full_screen_mode = None 105 self.image_popup = ImagePopup(self) 106 self.actions_toolbar = at = ActionsToolBar(self) 107 at.open_book_at_path.connect(self.ask_for_open) 108 self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, at) 109 try: 110 os.makedirs(annotations_dir) 111 except OSError: 112 pass 113 self.current_book_data = {} 114 get_current_book_data(self.current_book_data) 115 self.book_prepared.connect(self.load_finished, type=Qt.ConnectionType.QueuedConnection) 116 self.dock_defs = dock_defs() 117 118 def create_dock(title, name, area, areas=Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea): 119 ans = QDockWidget(title, self) 120 ans.setObjectName(name) 121 self.addDockWidget(area, ans) 122 ans.setVisible(False) 123 ans.visibilityChanged.connect(self.dock_visibility_changed) 124 return ans 125 126 for dock_def in itervalues(self.dock_defs): 127 setattr(self, '{}_dock'.format(dock_def.name.partition('-')[0]), create_dock( 128 dock_def.title, dock_def.name, dock_def.initial_area, dock_def.allowed_areas)) 129 130 self.toc_container = w = QWidget(self) 131 w.l = QVBoxLayout(w) 132 self.toc = TOCView(w) 133 self.toc.clicked[QModelIndex].connect(self.toc_clicked) 134 self.toc.searched.connect(self.toc_searched) 135 self.toc_search = TOCSearch(self.toc, parent=w) 136 w.l.addWidget(self.toc), w.l.addWidget(self.toc_search), w.l.setContentsMargins(0, 0, 0, 0) 137 self.toc_dock.setWidget(w) 138 139 self.search_widget = w = SearchPanel(self) 140 w.search_requested.connect(self.start_search) 141 w.hide_search_panel.connect(self.search_dock.close) 142 w.count_changed.connect(self.search_results_count_changed) 143 w.goto_cfi.connect(self.goto_cfi) 144 self.search_dock.setWidget(w) 145 self.search_dock.visibilityChanged.connect(self.search_widget.visibility_changed) 146 147 self.lookup_widget = w = Lookup(self) 148 self.lookup_dock.visibilityChanged.connect(self.lookup_widget.visibility_changed) 149 self.lookup_dock.setWidget(w) 150 151 self.bookmarks_widget = w = BookmarkManager(self) 152 connect_lambda( 153 w.create_requested, self, 154 lambda self: self.web_view.trigger_shortcut('new_bookmark')) 155 w.edited.connect(self.bookmarks_edited) 156 w.activated.connect(self.bookmark_activated) 157 w.toggle_requested.connect(self.toggle_bookmarks) 158 self.bookmarks_dock.setWidget(w) 159 160 self.highlights_widget = w = HighlightsPanel(self) 161 self.highlights_dock.setWidget(w) 162 w.toggle_requested.connect(self.toggle_highlights) 163 164 self.web_view = WebView(self) 165 self.web_view.cfi_changed.connect(self.cfi_changed) 166 self.web_view.reload_book.connect(self.reload_book) 167 self.web_view.toggle_toc.connect(self.toggle_toc) 168 self.web_view.show_search.connect(self.show_search) 169 self.web_view.find_next.connect(self.search_widget.find_next_requested) 170 self.search_widget.show_search_result.connect(self.web_view.show_search_result) 171 self.web_view.search_result_not_found.connect(self.search_widget.search_result_not_found) 172 self.web_view.search_result_discovered.connect(self.search_widget.search_result_discovered) 173 self.web_view.toggle_bookmarks.connect(self.toggle_bookmarks) 174 self.web_view.toggle_highlights.connect(self.toggle_highlights) 175 self.web_view.new_bookmark.connect(self.bookmarks_widget.create_new_bookmark) 176 self.web_view.toggle_inspector.connect(self.toggle_inspector) 177 self.web_view.toggle_lookup.connect(self.toggle_lookup) 178 self.web_view.quit.connect(self.quit) 179 self.web_view.update_current_toc_nodes.connect(self.toc.update_current_toc_nodes) 180 self.web_view.toggle_full_screen.connect(self.toggle_full_screen) 181 self.web_view.ask_for_open.connect(self.ask_for_open, type=Qt.ConnectionType.QueuedConnection) 182 self.web_view.selection_changed.connect(self.lookup_widget.selected_text_changed, type=Qt.ConnectionType.QueuedConnection) 183 self.web_view.selection_changed.connect(self.highlights_widget.selected_text_changed, type=Qt.ConnectionType.QueuedConnection) 184 self.web_view.view_image.connect(self.view_image, type=Qt.ConnectionType.QueuedConnection) 185 self.web_view.copy_image.connect(self.copy_image, type=Qt.ConnectionType.QueuedConnection) 186 self.web_view.show_loading_message.connect(self.show_loading_message) 187 self.web_view.show_error.connect(self.show_error) 188 self.web_view.print_book.connect(self.print_book, type=Qt.ConnectionType.QueuedConnection) 189 self.web_view.reset_interface.connect(self.reset_interface, type=Qt.ConnectionType.QueuedConnection) 190 self.web_view.quit.connect(self.quit, type=Qt.ConnectionType.QueuedConnection) 191 self.web_view.shortcuts_changed.connect(self.shortcuts_changed) 192 self.web_view.scrollbar_context_menu.connect(self.scrollbar_context_menu) 193 self.web_view.close_prep_finished.connect(self.close_prep_finished) 194 self.web_view.highlights_changed.connect(self.highlights_changed) 195 self.web_view.edit_book.connect(self.edit_book) 196 self.actions_toolbar.initialize(self.web_view, self.search_dock.toggleViewAction()) 197 at.update_action_state(False) 198 self.setCentralWidget(self.web_view) 199 self.loading_overlay = LoadingOverlay(self) 200 self.restore_state() 201 self.actions_toolbar.update_visibility() 202 self.dock_visibility_changed() 203 self.highlights_widget.request_highlight_action.connect(self.web_view.highlight_action) 204 self.highlights_widget.web_action.connect(self.web_view.generic_action) 205 self.highlights_widget.notes_edited_signal.connect(self.notes_edited) 206 if continue_reading: 207 self.continue_reading() 208 self.setup_mouse_auto_hide() 209 210 def shortcuts_changed(self, smap): 211 rmap = defaultdict(list) 212 for k, v in iteritems(smap): 213 rmap[v].append(k) 214 self.actions_toolbar.set_tooltips(rmap) 215 self.highlights_widget.set_tooltips(rmap) 216 217 def resizeEvent(self, ev): 218 self.loading_overlay.resize(self.size()) 219 return MainWindow.resizeEvent(self, ev) 220 221 def scrollbar_context_menu(self, x, y, frac): 222 m = QMenu(self) 223 amap = {} 224 225 def a(text, name): 226 m.addAction(text) 227 amap[text] = name 228 229 a(_('Scroll here'), 'here') 230 m.addSeparator() 231 a(_('Start of book'), 'start_of_book') 232 a(_('End of book'), 'end_of_book') 233 m.addSeparator() 234 a(_('Previous section'), 'previous_section') 235 a(_('Next section'), 'next_section') 236 m.addSeparator() 237 a(_('Start of current file'), 'start_of_file') 238 a(_('End of current file'), 'end_of_file') 239 m.addSeparator() 240 a(_('Hide this scrollbar'), 'toggle_scrollbar') 241 242 q = m.exec(QCursor.pos()) 243 if not q: 244 return 245 q = amap[q.text()] 246 if q == 'here': 247 self.web_view.goto_frac(frac) 248 else: 249 self.web_view.trigger_shortcut(q) 250 251 # IPC {{{ 252 def handle_commandline_arg(self, arg): 253 if arg: 254 if os.path.isfile(arg) and os.access(arg, os.R_OK): 255 self.load_ebook(arg) 256 else: 257 prints('Cannot read from:', arg, file=sys.stderr) 258 259 def message_from_other_instance(self, msg): 260 try: 261 msg = json.loads(msg) 262 path, open_at = msg 263 except Exception as err: 264 print('Invalid message from other instance', file=sys.stderr) 265 print(err, file=sys.stderr) 266 return 267 self.load_ebook(path, open_at=open_at) 268 self.raise_() 269 self.activateWindow() 270 # }}} 271 272 # Fullscreen {{{ 273 def set_full_screen(self, on): 274 if on: 275 self.maximized_at_last_fullscreen = self.isMaximized() 276 if not self.actions_toolbar.visible_in_fullscreen: 277 self.actions_toolbar.setVisible(False) 278 self.showFullScreen() 279 else: 280 self.actions_toolbar.update_visibility() 281 if self.maximized_at_last_fullscreen: 282 self.showMaximized() 283 else: 284 self.showNormal() 285 286 def changeEvent(self, ev): 287 if ev.type() == QEvent.Type.WindowStateChange: 288 in_full_screen_mode = self.isFullScreen() 289 if self.in_full_screen_mode is None or self.in_full_screen_mode != in_full_screen_mode: 290 self.in_full_screen_mode = in_full_screen_mode 291 self.web_view.notify_full_screen_state_change(self.in_full_screen_mode) 292 return MainWindow.changeEvent(self, ev) 293 294 def toggle_full_screen(self): 295 self.set_full_screen(not self.isFullScreen()) 296 297 # }}} 298 299 # Docks (ToC, Bookmarks, Lookup, etc.) {{{ 300 301 def toggle_inspector(self): 302 visible = self.inspector_dock.toggleViewAction().isChecked() 303 self.inspector_dock.setVisible(not visible) 304 305 def toggle_toc(self): 306 is_visible = self.toc_dock.isVisible() 307 self.toc_dock.setVisible(not is_visible) 308 if not is_visible: 309 self.toc.scroll_to_current_toc_node() 310 311 def show_search(self, text, trigger=False): 312 self.search_dock.setVisible(True) 313 self.search_dock.activateWindow() 314 self.search_dock.raise_() 315 self.search_widget.focus_input(text) 316 if trigger: 317 self.search_widget.trigger() 318 319 def search_results_count_changed(self, num=-1): 320 if num < 0: 321 tt = _('Search') 322 elif num == 0: 323 tt = _('Search :: no matches') 324 elif num == 1: 325 tt = _('Search :: one match') 326 else: 327 tt = _('Search :: {} matches').format(num) 328 self.search_dock.setWindowTitle(tt) 329 330 def start_search(self, search_query): 331 name = self.web_view.current_content_file 332 if name: 333 self.web_view.get_current_cfi(self.search_widget.set_anchor_cfi) 334 self.search_widget.start_search(search_query, name) 335 self.web_view.setFocus(Qt.FocusReason.OtherFocusReason) 336 337 def toggle_bookmarks(self): 338 is_visible = self.bookmarks_dock.isVisible() 339 self.bookmarks_dock.setVisible(not is_visible) 340 if is_visible: 341 self.web_view.setFocus(Qt.FocusReason.OtherFocusReason) 342 else: 343 self.bookmarks_widget.bookmarks_list.setFocus(Qt.FocusReason.OtherFocusReason) 344 345 def toggle_highlights(self): 346 is_visible = self.highlights_dock.isVisible() 347 self.highlights_dock.setVisible(not is_visible) 348 if is_visible: 349 self.web_view.setFocus(Qt.FocusReason.OtherFocusReason) 350 else: 351 self.highlights_widget.focus() 352 353 def toggle_lookup(self, force_show=False): 354 self.lookup_dock.setVisible(force_show or not self.lookup_dock.isVisible()) 355 if force_show and self.lookup_dock.isVisible(): 356 self.lookup_widget.on_forced_show() 357 358 def toc_clicked(self, index): 359 item = self.toc_model.itemFromIndex(index) 360 self.web_view.goto_toc_node(item.node_id) 361 362 def toc_searched(self, index): 363 item = self.toc_model.itemFromIndex(index) 364 self.web_view.goto_toc_node(item.node_id) 365 366 def bookmarks_edited(self, bookmarks): 367 self.current_book_data['annotations_map']['bookmark'] = bookmarks 368 # annotations will be saved in book file on exit 369 self.save_annotations(in_book_file=False) 370 371 def goto_cfi(self, cfi, add_to_history=False): 372 self.web_view.goto_cfi(cfi, add_to_history=add_to_history) 373 374 def bookmark_activated(self, cfi): 375 self.goto_cfi(cfi, add_to_history=True) 376 377 def view_image(self, name): 378 path = get_path_for_name(name) 379 if path: 380 pmap = QPixmap() 381 if pmap.load(path): 382 self.image_popup.current_img = pmap 383 self.image_popup.current_url = QUrl.fromLocalFile(path) 384 self.image_popup() 385 else: 386 error_dialog(self, _('Invalid image'), _( 387 "Failed to load the image {}").format(name), show=True) 388 else: 389 error_dialog(self, _('Image not found'), _( 390 "Failed to find the image {}").format(name), show=True) 391 392 def copy_image(self, name): 393 path = get_path_for_name(name) 394 if not path: 395 return error_dialog(self, _('Image not found'), _( 396 "Failed to find the image {}").format(name), show=True) 397 try: 398 img = image_from_path(path) 399 except Exception: 400 return error_dialog(self, _('Invalid image'), _( 401 "Failed to load the image {}").format(name), show=True) 402 url = QUrl.fromLocalFile(path) 403 md = QMimeData() 404 md.setImageData(img) 405 md.setUrls([url]) 406 QApplication.instance().clipboard().setMimeData(md) 407 408 def dock_visibility_changed(self): 409 vmap = {dock.objectName().partition('-')[0]: dock.toggleViewAction().isChecked() for dock in self.dock_widgets} 410 self.actions_toolbar.update_dock_actions(vmap) 411 # }}} 412 413 # Load book {{{ 414 415 def show_loading_message(self, msg): 416 if msg: 417 self.loading_overlay(msg) 418 self.actions_toolbar.update_action_state(False) 419 else: 420 if not hasattr(self, 'initial_loading_performace_reported'): 421 performance_monitor('loading finished') 422 self.initial_loading_performace_reported = True 423 self.loading_overlay.hide() 424 self.actions_toolbar.update_action_state(True) 425 426 def show_error(self, title, msg, details): 427 self.loading_overlay.hide() 428 error_dialog(self, title, msg, det_msg=details or None, show=True) 429 430 def print_book(self): 431 if not hasattr(set_book_path, 'pathtoebook'): 432 error_dialog(self, _('Cannot print book'), _( 433 'No book is currently open'), show=True) 434 return 435 from .printing import print_book 436 print_book(set_book_path.pathtoebook, book_title=self.current_book_data['metadata']['title'], parent=self) 437 438 @property 439 def dock_widgets(self): 440 return self.findChildren(QDockWidget, options=Qt.FindChildOption.FindDirectChildrenOnly) 441 442 def reset_interface(self): 443 for dock in self.dock_widgets: 444 dock.setFloating(False) 445 area = self.dock_defs[dock.objectName().partition('-')[0]].initial_area 446 self.removeDockWidget(dock) 447 self.addDockWidget(area, dock) 448 dock.setVisible(False) 449 450 for toolbar in self.findChildren(QToolBar): 451 toolbar.setVisible(False) 452 self.removeToolBar(toolbar) 453 self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, toolbar) 454 455 def ask_for_open(self, path=None): 456 if path is None: 457 files = choose_files( 458 self, 'ebook viewer open dialog', 459 _('Choose e-book'), [(_('E-books'), available_input_formats())], 460 all_files=False, select_only_single_file=True) 461 if not files: 462 return 463 path = files[0] 464 self.load_ebook(path) 465 466 def continue_reading(self): 467 rl = vprefs['session_data'].get('standalone_recently_opened') 468 if rl: 469 entry = rl[0] 470 self.load_ebook(entry['pathtoebook']) 471 472 def load_ebook(self, pathtoebook, open_at=None, reload_book=False): 473 if '.' not in os.path.basename(pathtoebook): 474 pathtoebook = os.path.abspath(os.path.realpath(pathtoebook)) 475 performance_monitor('Load of book started', reset=True) 476 self.actions_toolbar.update_action_state(False) 477 self.web_view.show_home_page_on_ready = False 478 if open_at: 479 self.pending_open_at = open_at 480 self.setWindowTitle(_('Loading book') + '… — {}'.format(self.base_window_title)) 481 self.loading_overlay(_('Loading book, please wait')) 482 self.save_annotations() 483 self.current_book_data = {} 484 get_current_book_data(self.current_book_data) 485 self.search_widget.clear_searches() 486 t = Thread(name='LoadBook', target=self._load_ebook_worker, args=(pathtoebook, open_at, reload_book or self.force_reload)) 487 t.daemon = True 488 t.start() 489 490 def reload_book(self): 491 if self.current_book_data: 492 self.load_ebook(self.current_book_data['pathtoebook'], reload_book=True) 493 494 def _load_ebook_worker(self, pathtoebook, open_at, reload_book): 495 try: 496 ans = prepare_book(pathtoebook, force=reload_book, prepare_notify=self.prepare_notify) 497 except WorkerError as e: 498 self.book_prepared.emit(False, {'exception': e, 'tb': e.orig_tb, 'pathtoebook': pathtoebook}) 499 except Exception as e: 500 import traceback 501 self.book_prepared.emit(False, {'exception': e, 'tb': traceback.format_exc(), 'pathtoebook': pathtoebook}) 502 else: 503 performance_monitor('prepared emitted') 504 self.book_prepared.emit(True, {'base': ans, 'pathtoebook': pathtoebook, 'open_at': open_at, 'reloaded': reload_book}) 505 506 def prepare_notify(self): 507 self.book_preparation_started.emit() 508 509 def load_finished(self, ok, data): 510 cbd = self.calibre_book_data_for_first_book 511 self.calibre_book_data_for_first_book = None 512 if self.shutting_down: 513 return 514 open_at, self.pending_open_at = self.pending_open_at, None 515 self.web_view.clear_caches() 516 if not ok: 517 self.actions_toolbar.update_action_state(False) 518 self.setWindowTitle(self.base_window_title) 519 tb = as_unicode(data['tb'].strip(), errors='replace') 520 tb = re.split(r'^calibre\.gui2\.viewer\.convert_book\.ConversionFailure:\s*', tb, maxsplit=1, flags=re.M)[-1] 521 last_line = tuple(tb.strip().splitlines())[-1] 522 if last_line.startswith('calibre.ebooks.DRMError'): 523 DRMErrorMessage(self).exec() 524 else: 525 error_dialog(self, _('Loading book failed'), _( 526 'Failed to open the book at {0}. Click "Show details" for more info.').format(data['pathtoebook']), 527 det_msg=tb, show=True) 528 self.loading_overlay.hide() 529 self.web_view.show_home_page() 530 return 531 try: 532 set_book_path(data['base'], data['pathtoebook']) 533 except Exception: 534 if data['reloaded']: 535 raise 536 self.load_ebook(data['pathtoebook'], open_at=data['open_at'], reload_book=True) 537 return 538 if iswindows: 539 try: 540 add_to_recent_docs(data['pathtoebook']) 541 except Exception: 542 import traceback 543 traceback.print_exc() 544 self.current_book_data = data 545 get_current_book_data(self.current_book_data) 546 self.current_book_data['annotations_map'] = defaultdict(list) 547 self.current_book_data['annotations_path_key'] = path_key(data['pathtoebook']) + '.json' 548 self.load_book_data(cbd) 549 self.update_window_title() 550 initial_cfi = self.initial_cfi_for_current_book() 551 initial_position = {'type': 'cfi', 'data': initial_cfi} if initial_cfi else None 552 if open_at: 553 if open_at.startswith('toc:'): 554 initial_toc_node = self.toc_model.node_id_for_text(open_at[len('toc:'):]) 555 initial_position = {'type': 'toc', 'data': initial_toc_node} 556 elif open_at.startswith('toc-href:'): 557 initial_toc_node = self.toc_model.node_id_for_href(open_at[len('toc-href:'):], exact=True) 558 initial_position = {'type': 'toc', 'data': initial_toc_node} 559 elif open_at.startswith('toc-href-contains:'): 560 initial_toc_node = self.toc_model.node_id_for_href(open_at[len('toc-href-contains:'):], exact=False) 561 initial_position = {'type': 'toc', 'data': initial_toc_node} 562 elif open_at.startswith('epubcfi(/'): 563 initial_position = {'type': 'cfi', 'data': open_at} 564 elif open_at.startswith('ref:'): 565 initial_position = {'type': 'ref', 'data': open_at[len('ref:'):]} 566 elif is_float(open_at): 567 initial_position = {'type': 'bookpos', 'data': float(open_at)} 568 highlights = self.current_book_data['annotations_map']['highlight'] 569 self.highlights_widget.load(highlights) 570 self.web_view.start_book_load(initial_position=initial_position, highlights=highlights, current_book_data=self.current_book_data) 571 performance_monitor('webview loading requested') 572 573 def load_book_data(self, calibre_book_data=None): 574 self.current_book_data['book_library_details'] = get_book_library_details(self.current_book_data['pathtoebook']) 575 if calibre_book_data is not None: 576 self.current_book_data['calibre_book_id'] = calibre_book_data['book_id'] 577 self.current_book_data['calibre_book_uuid'] = calibre_book_data['uuid'] 578 self.current_book_data['calibre_book_fmt'] = calibre_book_data['fmt'] 579 self.current_book_data['calibre_library_id'] = calibre_book_data['library_id'] 580 self.load_book_annotations(calibre_book_data) 581 path = os.path.join(self.current_book_data['base'], 'calibre-book-manifest.json') 582 with open(path, 'rb') as f: 583 raw = f.read() 584 self.current_book_data['manifest'] = manifest = json.loads(raw) 585 toc = manifest.get('toc') 586 self.toc_model = TOC(toc) 587 self.toc.setModel(self.toc_model) 588 self.bookmarks_widget.set_bookmarks(self.current_book_data['annotations_map']['bookmark']) 589 self.current_book_data['metadata'] = set_book_path.parsed_metadata 590 self.current_book_data['manifest'] = set_book_path.parsed_manifest 591 592 def load_book_annotations(self, calibre_book_data=None): 593 amap = self.current_book_data['annotations_map'] 594 path = os.path.join(self.current_book_data['base'], 'calibre-book-annotations.json') 595 if os.path.exists(path): 596 with open(path, 'rb') as f: 597 raw = f.read() 598 merge_annotations(parse_annotations(raw), amap) 599 path = os.path.join(annotations_dir, self.current_book_data['annotations_path_key']) 600 if os.path.exists(path): 601 with open(path, 'rb') as f: 602 raw = f.read() 603 merge_annotations(parse_annotations(raw), amap) 604 if calibre_book_data is None: 605 bld = self.current_book_data['book_library_details'] 606 if bld is not None: 607 lib_amap = load_annotations_map_from_library(bld) 608 sau = get_session_pref('sync_annots_user', default='') 609 if sau: 610 other_amap = load_annotations_map_from_library(bld, user_type='web', user=sau) 611 if other_amap: 612 merge_annotations(other_amap, lib_amap) 613 if lib_amap: 614 for annot_type, annots in iteritems(lib_amap): 615 merge_annotations(annots, amap) 616 else: 617 for annot_type, annots in iteritems(calibre_book_data['annotations_map']): 618 merge_annotations(annots, amap) 619 620 def update_window_title(self): 621 try: 622 title = self.current_book_data['metadata']['title'] 623 except Exception: 624 title = _('Unknown') 625 book_format = self.current_book_data['manifest']['book_format'] 626 title = '{} [{}] — {}'.format(title, book_format, self.base_window_title) 627 self.setWindowTitle(title) 628 # }}} 629 630 # CFI management {{{ 631 def initial_cfi_for_current_book(self): 632 lrp = self.current_book_data['annotations_map']['last-read'] 633 if lrp and get_session_pref('remember_last_read', default=True): 634 lrp = lrp[0] 635 if lrp['pos_type'] == 'epubcfi': 636 return lrp['pos'] 637 638 def cfi_changed(self, cfi): 639 if not self.current_book_data: 640 return 641 self.current_book_data['annotations_map']['last-read'] = [{ 642 'pos': cfi, 'pos_type': 'epubcfi', 'timestamp': utcnow().isoformat()}] 643 self.save_pos_timer.start() 644 # }}} 645 646 # State serialization {{{ 647 def save_annotations(self, in_book_file=True): 648 if not self.current_book_data: 649 return 650 if self.annotations_saver is None: 651 self.annotations_saver = AnnotationsSaveWorker() 652 self.annotations_saver.start() 653 self.annotations_saver.save_annotations( 654 self.current_book_data, 655 in_book_file and get_session_pref('save_annotations_in_ebook', default=True), 656 get_session_pref('sync_annots_user', default='') 657 ) 658 659 def highlights_changed(self, highlights): 660 if not self.current_book_data: 661 return 662 amap = self.current_book_data['annotations_map'] 663 amap['highlight'] = highlights 664 self.highlights_widget.refresh(highlights) 665 self.save_annotations() 666 667 def notes_edited(self, uuid, notes): 668 for h in self.current_book_data['annotations_map']['highlight']: 669 if h.get('uuid') == uuid: 670 h['notes'] = notes 671 h['timestamp'] = utcnow().isoformat() 672 break 673 else: 674 return 675 self.save_annotations() 676 677 def edit_book(self, file_name, progress_frac, selected_text): 678 import subprocess 679 680 from calibre.ebooks.oeb.polish.main import SUPPORTED 681 from calibre.utils.ipc.launch import exe_path, macos_edit_book_bundle_path 682 try: 683 path = set_book_path.pathtoebook 684 except AttributeError: 685 return error_dialog(self, _('Cannot edit book'), _( 686 'No book is currently open'), show=True) 687 fmt = path.rpartition('.')[-1].upper().replace('ORIGINAL_', '') 688 if fmt not in SUPPORTED: 689 return error_dialog(self, _('Cannot edit book'), _( 690 'The book must be in the %s formats to edit.' 691 '\n\nFirst convert the book to one of these formats.' 692 ) % (_(' or ').join(SUPPORTED)), show=True) 693 exe = 'ebook-edit' 694 if ismacos: 695 exe = os.path.join(macos_edit_book_bundle_path(), exe) 696 else: 697 exe = exe_path(exe) 698 cmd = [exe] 699 if selected_text: 700 cmd += ['--select-text', selected_text] 701 from calibre.gui2.tweak_book.widgets import BusyCursor 702 with sanitize_env_vars(): 703 subprocess.Popen(cmd + [path, file_name]) 704 with BusyCursor(): 705 time.sleep(2) 706 707 def save_state(self): 708 with vprefs: 709 vprefs['main_window_state'] = bytearray(self.saveState(self.MAIN_WINDOW_STATE_VERSION)) 710 vprefs['main_window_geometry'] = bytearray(self.saveGeometry()) 711 712 def restore_state(self): 713 state = vprefs['main_window_state'] 714 geom = vprefs['main_window_geometry'] 715 if geom and get_session_pref('remember_window_geometry', default=False): 716 QApplication.instance().safe_restore_geometry(self, geom) 717 else: 718 QApplication.instance().ensure_window_on_screen(self) 719 if state: 720 self.restoreState(state, self.MAIN_WINDOW_STATE_VERSION) 721 self.inspector_dock.setVisible(False) 722 if not get_session_pref('restore_docks', True): 723 for dock_def in self.dock_defs.values(): 724 d = getattr(self, '{}_dock'.format(dock_def.name.partition('-')[0])) 725 d.setVisible(False) 726 727 def quit(self): 728 self.close() 729 730 def force_close(self): 731 if not self.close_forced: 732 self.close_forced = True 733 self.quit() 734 735 def close_prep_finished(self, cfi): 736 if cfi: 737 self.cfi_changed(cfi) 738 self.force_close() 739 740 def closeEvent(self, ev): 741 if self.shutdown_done: 742 return 743 if self.current_book_data and self.web_view.view_is_ready and not self.close_forced: 744 ev.ignore() 745 if not self.shutting_down: 746 self.shutting_down = True 747 QTimer.singleShot(2000, self.force_close) 748 self.web_view.prepare_for_close() 749 return 750 self.shutting_down = True 751 self.search_widget.shutdown() 752 self.web_view.shutdown() 753 try: 754 self.save_state() 755 self.save_annotations() 756 if self.annotations_saver is not None: 757 self.annotations_saver.shutdown() 758 self.annotations_saver = None 759 except Exception: 760 import traceback 761 traceback.print_exc() 762 clean_running_workers() 763 self.shutdown_done = True 764 return MainWindow.closeEvent(self, ev) 765 # }}} 766 767 # Auto-hide mouse cursor {{{ 768 def setup_mouse_auto_hide(self): 769 QApplication.instance().installEventFilter(self) 770 self.cursor_hidden = False 771 self.hide_cursor_timer = t = QTimer(self) 772 t.setSingleShot(True), t.setInterval(3000) 773 t.timeout.connect(self.hide_cursor) 774 t.start() 775 776 def eventFilter(self, obj, ev): 777 et = ev.type() 778 if et == QEvent.Type.MouseMove: 779 if self.cursor_hidden: 780 self.cursor_hidden = False 781 QApplication.instance().restoreOverrideCursor() 782 self.hide_cursor_timer.start() 783 elif et == QEvent.Type.FocusIn: 784 if iswindows and obj and obj.objectName() == 'EbookViewerClassWindow' and self.isFullScreen(): 785 # See https://bugs.launchpad.net/calibre/+bug/1918591 786 self.web_view.repair_after_fullscreen_switch() 787 return False 788 789 def hide_cursor(self): 790 if get_session_pref('auto_hide_mouse', True): 791 self.cursor_hidden = True 792 QApplication.instance().setOverrideCursor(Qt.CursorShape.BlankCursor) 793 # }}} 794