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