calibre/src/pyj/read_book/ui.pyj
Kovid Goyal 5fe9010e74
...
2022-03-14 20:11:35 +05:30

664 lines
28 KiB
Text

# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
# globals: __RENDER_VERSION__
from __python__ import hash_literals
from elementmaker import E
import traceback
from ajax import ajax, ajax_send
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 gettext import gettext as _
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.tts import Client
from read_book.view import View
from session import get_interface_data
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)
self.tts_client = Client()
self.windows_to_listen_for_messages_from = []
window.addEventListener('resize', debounce(self.on_resize.bind(self), 250))
window.addEventListener('message', self.message_from_other_window.bind(self))
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.update_reading_rates = self.update_reading_rates.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_cfi = self.goto_cfi.bind(self)
ui_operations.goto_frac = self.goto_frac.bind(self)
ui_operations.goto_book_position = self.goto_book_position.bind(self)
ui_operations.goto_reference = self.goto_reference.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)
ui_operations.annots_changed = self.annots_changed.bind(self)
ui_operations.annotations_synced = self.annotations_synced.bind(self)
ui_operations.wait_for_messages_from = self.wait_for_messages_from.bind(self)
ui_operations.stop_waiting_for_messages_from = self.stop_waiting_for_messages_from.bind(self)
ui_operations.update_metadata = self.update_metadata.bind(self)
ui_operations.close_book = self.close_book.bind(self)
ui_operations.copy_image = self.copy_image.bind(self)
ui_operations.view_image = self.view_image.bind(self)
ui_operations.speak_simple_text = self.speak_simple_text.bind(self)
ui_operations.tts = self.tts.bind(self)
ui_operations.search_result_discovered = self.view.search_overlay.search_result_discovered
ui_operations.search_result_not_found = self.view.search_overlay.search_result_not_found
ui_operations.find_next = self.view.search_overlay.find_next
ui_operations.open_url = def(url):
window.open(url, '_blank')
ui_operations.show_help = def(which):
if which is 'viewer':
path = '/viewer.html'
else:
return
if get_interface_data().lang_code_for_user_manual:
path = f'/{get_interface_data().lang_code_for_user_manual}{path}'
url = 'https://manual.calibre-ebook.com' + path
window.open(url, '_blank')
ui_operations.copy_selection = def(text, html):
# try using document.execCommand which on chrome allows the
# copy on non-secure origin if this is close to a user
# interaction event
active_elem = document.activeElement
ta = document.createElement("textarea")
ta.value = text
ta.style.position = 'absolute'
ta.style.top = window.innerHeight + 'px'
ta.style.left = window.innerWidth + 'px'
document.body.appendChild(ta)
ta.focus()
ta.select()
ok = False
try:
ok = document.execCommand('copy')
except:
pass
document.body.removeChild(ta)
if active_elem:
active_elem.focus()
if ok:
return
if not window.navigator.clipboard:
return error_dialog(_('No clipboard access'), _(
'Your browser requires you to access the Content server over an HTTPS connection'
' to be able to copy to clipboard.'))
window.navigator.clipboard.writeText(text or '').then(def (): pass;, def():
error_dialog(_('Could not copy to clipboard'), _('No permission to write to clipboard'))
)
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 close_book(self):
self.base_url_data = {}
def copy_image(self, image_file_name):
if not self.view?.book:
return
ui_operations.get_file(
self.view.book, image_file_name, def(blob, name, mimetype):
try:
window.navigator.clipboard.write([new ClipboardItem({mimetype: blob})]) # noqa
except Exception as err:
error_dialog(_('Could not copy image'), str(err))
)
def view_image(self, image_file_name):
if not self.view?.book:
return
ui_operations.get_file(
self.view.book, image_file_name, def(blob, name, mimetype):
url = window.URL.createObjectURL(blob)
window.open(url)
)
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_cfi(self, bookpos):
return self.view.goto_cfi(bookpos)
def goto_frac(self, frac):
return self.view.goto_frac(frac)
def goto_book_position(self, bpos):
return self.view.goto_book_position(bpos)
def goto_reference(self, ref):
return self.view.goto_reference(ref)
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()
def annots_changed(self, amap):
library_id = self.base_url_data.library_id
book_id = self.base_url_data.book_id
fmt = self.base_url_data.fmt
self.db.update_annotations_data_from_key(library_id, book_id, fmt, {'annotations_map': amap})
ajax_send(f'book-update-annotations/{library_id}/{book_id}/{fmt}', amap, def (): pass;)
def annotations_synced(self, amap):
library_id = self.base_url_data.library_id
book_id = self.base_url_data.book_id
fmt = self.base_url_data.fmt
self.db.update_annotations_data_from_key(library_id, book_id, fmt, {'annotations_map': amap})
@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'),
_('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
if not raster_cover_name:
nnum = 1
base = 'raster-cover-image-{}.jpg'
inserted_name = base.format(nnum)
while inserted_name in files_left:
nnum += 1
inserted_name = base.format(nnum)
raster_cover_name = inserted_name
files_left.add(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 = ['<h4>{}</h4><div>{}</div><hr>'.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 len(queued):
for fname in queued:
start_download(fname, base_path + encodeURIComponent(fname).replace(/%2[fF]/g, '/'))
queued.discard(fname)
break
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 or 0) + 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))
count = 0
queued = set()
for fname in files_left:
if fname is not raster_cover_name:
count += 1
# Chrome starts killing AJAX requests if there are too many in flight, unlike Firefox
# which is smart enough to queue them
if count < 20:
start_download(fname, base_path + encodeURIComponent(fname).replace(/%2[fF]/g, '/'))
else:
queued.add(fname)
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'),
_('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 = ['<h4>{}</h4><div>{}</div><hr>'.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_cfi(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)
def update_metadata(self, book, metadata):
self.db.update_metadata(book, metadata)
def wait_for_messages_from(self, w, callback):
self.windows_to_listen_for_messages_from.push(v'[w, callback]')
def stop_waiting_for_messages_from(self, w):
self.windows_to_listen_for_messages_from = [x for x in self.windows_to_listen_for_messages_from if x[0] is not w]
def message_from_other_window(self, msg):
if not self.windows_to_listen_for_messages_from.length:
return
old = self.windows_to_listen_for_messages_from
for x in old:
w, callback = x
if w is msg.source:
callback(msg)
def check_for_speech_capability(self):
if not window.speechSynthesis:
error_dialog(_('No speech support'), _(
'Your browser does not have support for Text-to-Speech'))
return False
return True
def speak_simple_text(self, text):
if not self.check_for_speech_capability():
return
self.tts_client.speak_simple_text(text)
def tts(self, event, data):
if not self.check_for_speech_capability():
return
if event is 'play':
self.tts_client.speak_marked_text(data.marked_text, self.view.read_aloud.handle_tts_event)
else:
getattr(self.tts_client, event)()
def update_reading_rates(self, rates):
if not self.view?.book:
return
book = self.view.book
self.db.save_reading_rates(book, rates)