# vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal # globals: __RENDER_VERSION__ from __python__ import hash_literals import traceback from elementmaker import E from gettext import gettext as _ from ajax import ajax from book_list.constants import read_book_container_id from book_list.library_data import current_library_id, library_data from book_list.router import home, push_state, read_book_mode, update_window_title from book_list.ui import show_panel from dom import clear from modals import create_simple_dialog_markup, error_dialog from read_book.db import get_db from read_book.globals import ui_operations from read_book.view import View from utils import debounce, full_screen_element, human_readable, request_full_screen from widgets import create_button RENDER_VERSION = __RENDER_VERSION__ MATHJAX_VERSION = "__MATHJAX_VERSION__" class ReadUI: def __init__(self): self.base_url_data = {} self.current_metadata = {'title': _('Unknown book')} self.current_book_id = None self.manifest_xhr = None self.pending_load = None self.downloads_in_progress = [] self.progress_id = 'book-load-progress' self.display_id = 'book-iframe-container' self.error_id = 'book-global-error-container' self.stacked_widgets = [self.progress_id, self.display_id, self.error_id] container = document.getElementById(read_book_container_id) container.appendChild(E.div( id=self.progress_id, style='display:none; text-align: center', E.h3(style='margin-top:30vh; margin-bottom: 1ex;'), E.progress(style='margin: 1ex'), E.div(style='margin: 1ex') )) container.appendChild(E.div( id=self.error_id, style='display:none;', )) container.appendChild(E.div( id=self.display_id, style='display:none', )) self.view = View(container.lastChild) window.addEventListener('resize', debounce(self.on_resize.bind(self), 250)) self.db = get_db(self.db_initialized.bind(self), self.show_error.bind(self)) ui_operations.get_file = self.db.get_file ui_operations.get_mathjax_files = self.db.get_mathjax_files ui_operations.update_url_state = self.update_url_state.bind(self) ui_operations.update_last_read_time = self.db.update_last_read_time ui_operations.show_error = self.show_error.bind(self) ui_operations.redisplay_book = self.redisplay_book.bind(self) ui_operations.reload_book = self.reload_book.bind(self) ui_operations.forward_gesture = self.forward_gesture.bind(self) ui_operations.update_color_scheme = self.update_color_scheme.bind(self) ui_operations.update_font_size = self.update_font_size.bind(self) ui_operations.goto_bookpos = self.goto_bookpos.bind(self) ui_operations.delete_book = self.delete_book.bind(self) ui_operations.focus_iframe = self.focus_iframe.bind(self) ui_operations.toggle_toc = self.toggle_toc.bind(self) ui_operations.toggle_full_screen = self.toggle_full_screen.bind(self) def on_resize(self): self.view.on_resize() def show_stack(self, name): ans = None for w in self.stacked_widgets: d = document.getElementById(w) v = 'none' if name is w: ans = d v = 'block' d.style.display = v return ans def show_error(self, title, msg, details): div = self.show_stack(self.error_id) clear(div) if self.current_metadata: book = self.current_metadata.title elif self.current_book_id: book = _('Book id #{}').format(self.current_book_id) else: book = _('book') div.appendChild(E.div(style='padding: 2ex 2rem; display:table; margin: auto')) div = div.lastChild dp = E.div() create_simple_dialog_markup(title, _('Could not open {}. {}').format(book, msg), details, 'bug', '', dp) div.appendChild(dp) div.appendChild(E.div( style='margin-top: 1ex; padding-top: 1ex; border-top: solid 1px currentColor', _('Go back to:'), E.br(), E.br(), create_button(_('Home'), 'home', def(): home(True);), )) q = {} if self.current_book_id: q.book_id = self.current_book_id + '' q.close_action = 'home' div.lastChild.appendChild(E.span( '\xa0', create_button(_('Book list'), 'library', def(): show_panel('book_list', q, True);), )) if self.current_book_id: q.close_action = 'book_list' div.lastChild.appendChild(E.span( '\xa0', create_button(_('Book details'), 'book', def(): show_panel('book_details', q, True);), )) def init_ui(self): div = self.show_stack(self.progress_id) if self.current_metadata: div.firstChild.textContent = _( 'Downloading {0} for offline reading, please wait...').format(self.current_metadata.title) else: div.firstChild.textContent = '' pr = div.firstChild.nextSibling pr.removeAttribute('value'), pr.removeAttribute('max') div.lastChild.textContent = _('Downloading book manifest...') def show_progress_message(self, msg): div = document.getElementById(self.progress_id) div.lastChild.textContent = msg or '' def load_book(self, library_id, book_id, fmt, metadata, force_reload): self.base_url_data = {'library_id': library_id, 'book_id':book_id, 'fmt':fmt} if not self.db.initialized: self.pending_load = [book_id, fmt, metadata, force_reload] return if not self.db.is_ok: self.show_error(_('Cannot read books'), self.db.initialize_error_msg) return self.start_load(book_id, fmt, metadata, force_reload) def reload_book(self): library_id, book_id, fmt = self.base_url_data.library_id, self.base_url_data.book_id, self.base_url_data.fmt metadata = self.metadata or library_data.metadata[book_id] self.load_book(library_id, book_id, fmt, metadata, True) def redisplay_book(self): self.view.redisplay_book() def forward_gesture(self, gesture): self.view.forward_gesture(gesture) def update_font_size(self): self.view.update_font_size() def goto_bookpos(self, bookpos): return self.view.goto_bookpos(bookpos) def delete_book(self, book, proceed): self.db.delete_book(book, proceed) def focus_iframe(self): self.view.focus_iframe() def toggle_toc(self): self.view.overlay.show_toc() def toggle_full_screen(self): if full_screen_element(): document.exitFullscreen() else: request_full_screen(document.documentElement) def update_color_scheme(self): self.view.update_color_scheme() @property def url_data(self): ans = {'library_id':self.base_url_data.library_id, 'book_id':self.base_url_data.book_id, 'fmt': self.base_url_data.fmt} bookpos = self.view.currently_showing.bookpos if bookpos: ans.bookpos = bookpos return ans def db_initialized(self): if not self.db.is_ok: error_dialog(_('Could not initialize database'), self.db.initialize_error_msg) return if self.pending_load is not None: pl, self.pending_load = self.pending_load, None if self.db.initialize_error_msg: self.show_error(_('Failed to initialize IndexedDB'), self.db.initialize_error_msg) else: self.start_load(*pl) def start_load(self, book_id, fmt, metadata, force_reload): self.current_book_id = book_id metadata = metadata or library_data.metadata[book_id] self.current_metadata = metadata or {'title':_('Book id #') + book_id} update_window_title('', self.current_metadata.title) self.init_ui() if jstype(self.db) is 'string': self.show_error(_('Cannot read book'), self.db) return self.db.get_book(current_library_id(), book_id, fmt, metadata, self.got_book.bind(self, force_reload)) def got_book(self, force_reload, book): if not book.manifest or book.manifest.version is not RENDER_VERSION or not book.is_complete: # We re-download the manifest when the book is not complete to ensure we have the # correct manifest, even though doing so is not strictly necessary self.get_manifest(book, force_reload) else: self.display_book(book) def get_manifest(self, book, force_reload): library_id, book_id, fmt = book.key if self.manifest_xhr: self.manifest_xhr.abort() query = {'library_id': library_id} if force_reload: query.force_reload = '1' self.manifest_xhr = ajax(('book-manifest/' + encodeURIComponent(book_id) + '/' + encodeURIComponent(fmt)), self.got_manifest.bind(self, book), query=query) self.manifest_xhr.send() def got_manifest(self, book, end_type, xhr, ev): self.manifest_xhr = None if end_type is 'abort': return if end_type is not 'load': return self.show_error(_('Failed to load book manifest'), _('Could not open {title} as the book manifest failed to load, click "Show Details" for more information.').format(title=self.current_metadata.title), xhr.error_html) try: manifest = JSON.parse(xhr.responseText) except Exception: return self.show_error(_('Failed to load book manifest'), _('The manifest for {title} is not valid').format(title=self.current_metadata.title), traceback.format_exc()) if manifest.version is not undefined: if manifest.version is not RENDER_VERSION: print('calibre upgraded: RENDER_VERSION={} manifest.version={}'.format(RENDER_VERSION, manifest.version)) return self.show_error(_('calibre upgraded!'), _( 'A newer version of calibre is available, please click the reload button in your browser.')) self.current_metadata = manifest.metadata self.db.save_manifest(book, manifest, self.download_book.bind(self, book)) return # Book is still being processed msg = _('Downloading book manifest...') if manifest.job_status is 'finished': if manifest.aborted: return self.show_error(_('Failed to prepare book for reading'), _('Preparation of book for reading was aborted because it took too long')) if manifest.traceback: return self.show_error(_('Failed to prepare book for reading'), _( 'There was an error processing the book, click "Show details" for more information'), manifest.traceback or '') elif manifest.job_status is 'waiting': msg = _('Book is queued for processing on the server...') elif manifest.job_status is 'running': msg = _('Book is being prepared for reading on the server...') self.show_progress_message(msg) setTimeout(self.get_manifest.bind(self, book), 100) def download_book(self, book): files = book.manifest.files total = 0 cover_total_updated = False for name in files: total += files[name].size files_left = set(book.manifest.files) failed_files = [] for xhr in self.downloads_in_progress: xhr.abort() self.downloads_in_progress = [] progress = document.getElementById(self.progress_id) pbar = progress.firstChild.nextSibling library_id, book_id, fmt = book.key base_path = 'book-file/{}/{}/{}/{}/'.format(encodeURIComponent(book_id), encodeURIComponent(fmt), encodeURIComponent(book.manifest.book_hash.size), encodeURIComponent(book.manifest.book_hash.mtime)) query = {'library_id': library_id} progress_track = {} pbar.setAttribute('max', total + '') raster_cover_name = book.manifest.raster_cover_name raster_cover_size = 0 def update_progress(): x = 0 for name in progress_track: x += progress_track[name] pbar.setAttribute('value', x + '') if x is total: msg = _('Downloaded {}, saving to disk, this may take a few seconds...').format(human_readable(total)) else: msg = _('Downloaded {0}, {1} left').format(human_readable(x), human_readable(total - x)) progress.lastChild.textContent = msg def show_failure(): det = ['

{}

{}

'.format(fname, err_html) for fname, err_html in failed_files].join('') self.show_error(_('Could not download book'), _( 'Failed to download some book data, click "Show details" for more information'), det) def on_stored(err): files_left.discard(this) if err: failed_files.append([this, err]) if len(files_left): return if failed_files.length: return show_failure() self.db.finish_book(book, self.display_book.bind(self, book)) def on_complete(end_type, xhr, ev): self.downloads_in_progress.remove(xhr) progress_track[this] = raster_cover_size if this is raster_cover_name else files[this].size update_progress() if end_type is 'abort': files_left.discard(this) return if end_type is 'load': self.db.store_file(book, this, xhr, on_stored.bind(this), this is raster_cover_name) else: failed_files.append([this, xhr.error_html]) files_left.discard(this) if not len(files_left): show_failure() def on_progress(loaded, ftotal): nonlocal total, cover_total_updated, raster_cover_size if this is raster_cover_name and not cover_total_updated: raster_cover_size = ftotal cover_total_updated = True total = total - files[raster_cover_name].size + raster_cover_size pbar.setAttribute('max', total + '') progress_track[this] = loaded update_progress() def start_download(fname, path): xhr = ajax(path, on_complete.bind(fname), on_progress=on_progress.bind(fname), query=query, progress_totals_needed=fname is raster_cover_name) xhr.responseType = 'text' if not book.manifest.files[fname].is_virtualized: xhr.responseType = 'blob' if self.db.supports_blobs else 'arraybuffer' xhr.send() self.downloads_in_progress.append(xhr) if raster_cover_name: start_download(raster_cover_name, 'get/cover/' + book_id + '/' + encodeURIComponent(library_id)) for fname in files_left: if fname is not raster_cover_name: start_download(fname, base_path + encodeURIComponent(fname).replace(/%2[fF]/, '/')) def ensure_maths(self, proceed): self.db.get_mathjax_info(def(mathjax_info): if mathjax_info.version is MATHJAX_VERSION: return proceed() print('Upgrading MathJax, previous version:', mathjax_info.version) self.db.clear_mathjax(def(): self.get_mathjax_manifest(mathjax_info, proceed) ) ) def get_mathjax_manifest(self, mathjax_info, proceed): ajax('mathjax', def(end_type, xhr, event): if end_type is 'abort': return if end_type is not 'load': return self.show_error(_('Failed to load MathJax manifest'), _('Could not open {title} as the MathJax manifest failed to load, click "Show Details" for more information.').format(title=self.current_metadata.title), xhr.error_html) try: manifest = JSON.parse(xhr.responseText) except Exception: return self.show_error(_('Failed to load MathJax manifest'), _('The MathJax manifest is not valid'), traceback.format_exc()) if manifest.etag is not MATHJAX_VERSION: print('calibre upgraded: MATHJAX_VERSION={} manifest.etag={}'.format(MATHJAX_VERSION, manifest.etag)) return self.show_error(_('calibre upgraded!'), _( 'A newer version of calibre is available, please click the reload button in your browser.')) mathjax_info.version = manifest.etag mathjax_info.files = manifest.files self.download_mathjax(mathjax_info, proceed) ).send() def download_mathjax(self, mathjax_info, proceed): files = mathjax_info.files total = 0 progress_track = {} files_left = set() failed_files = [] for key in files: total += files[key] progress_track[key] = 0 files_left.add(key) progress = document.getElementById(self.progress_id) progress.firstChild.textContent = _( 'Downloading MathJax to render mathematics in this book...') pbar = progress.firstChild.nextSibling pbar.setAttribute('max', total + '') for xhr in self.downloads_in_progress: xhr.abort() self.downloads_in_progress = [] def update_progress(): x = 0 for name in progress_track: x += progress_track[name] pbar.setAttribute('value', x + '') if x is total: msg = _('Downloaded {}, saving to disk, this may take a few seconds...').format(human_readable(total)) else: msg = _('Downloaded {0}, {1} left').format(human_readable(x), human_readable(total - x)) progress.lastChild.textContent = msg def on_progress(loaded, ftotal): progress_track[this] = loaded update_progress() def show_failure(): det = ['

{}

{}

'.format(fname, err_html) for fname, err_html in failed_files].join('') self.show_error(_('Could not download MathJax'), _( 'Failed to download some MathJax data, click "Show details" for more information'), det) def on_complete(end_type, xhr, ev): self.downloads_in_progress.remove(xhr) progress_track[this] = files[this] update_progress() if end_type is 'abort': files_left.discard(this) return if end_type is 'load': self.db.store_mathjax_file(this, xhr, on_stored.bind(this)) else: failed_files.append([this, xhr.error_html]) files_left.discard(this) if not len(files_left): show_failure() def on_stored(err): files_left.discard(this) if err: failed_files.append([this, err]) if len(files_left): return if failed_files.length: return show_failure() self.db.finish_mathjax(mathjax_info, proceed) def start_download(name): path = 'mathjax/' + name xhr = ajax(path, on_complete.bind(name), on_progress=on_progress.bind(name), progress_totals_needed=False) xhr.responseType = 'blob' if self.db.supports_blobs else 'arraybuffer' xhr.send() self.downloads_in_progress.push(xhr) for fname in files_left: start_download(fname) def display_book(self, book): if book.manifest.has_maths: self.ensure_maths(self.display_book_stage2.bind(self, book)) else: self.display_book_stage2(book) def display_book_stage2(self, book): self.current_metadata = book.metadata update_window_title('', self.current_metadata.title) self.show_stack(self.display_id) self.view.display_book(book) def apply_url_state(self, current_query): same = True current_state = self.url_data same = current_query.library_id is current_state.library_id and str(current_query.book_id) is str(current_state.book_id) and current_query.fmt is current_state.fmt self.view.overlay.hide() window.scrollTo(0, 0) # Ensure we are at the top of the window if same: if current_state.bookpos is not current_query.bookpos and current_query.bookpos: self.view.goto_bookpos(current_query.bookpos) else: self.load_book(current_query.library_id, int(current_query.book_id), current_query.fmt, library_data.metadata[current_query.book_id]) def update_url_state(self, replace): push_state(self.url_data, replace=replace, mode=read_book_mode)