2020-03-27 17:01:18 +05:30

426 lines
17 KiB
Plaintext

# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
from __python__ import bound_methods, hash_literals
from encodings import base64decode, base64encode
from gettext import gettext as _
from book_list.router import is_reading_book
from modals import error_dialog
from session import get_interface_data
from utils import username_key
def upgrade_schema(idb, old_version, new_version, transaction):
print('upgrade_schema:', old_version, new_version)
if not idb.objectStoreNames.contains('books'):
idb.createObjectStore('books', {'keyPath':'key'})
if not idb.objectStoreNames.contains('files'):
idb.createObjectStore('files')
if not idb.objectStoreNames.contains('mathjax'):
idb.createObjectStore('mathjax')
if not idb.objectStoreNames.contains('objects'):
idb.createObjectStore('objects', {'keyPath':'key'})
# Create indices
books_store = transaction.objectStore('books')
if not books_store.indexNames.contains('recent_index'):
books_store.createIndex('recent_index', 'recent_date')
def file_store_name(book, name):
return book.book_hash + ' ' + name
def get_error_details(event):
desc = event.target
if desc.error and desc.error.toString:
desc = desc.error.toString()
elif desc.errorCode:
desc = desc.errorCode
elif desc.error:
desc = desc.error
if desc.name is 'QuotaExceededError':
desc = _('Offline storage quota exceeded! Try deleting some stored books first.')
elif desc.name:
desc = desc.name
return desc or 'Unknown Error'
def new_book(key, metadata):
return {
'key':key,
'is_complete':False,
'stored_files': {},
'book_hash':None,
'metadata': metadata,
'manifest': None,
'cover_width': None,
'cover_height': None,
'cover_name': None,
'recent_date': new Date(),
'last_read': {},
'last_read_position': {},
}
DB_NAME = 'calibre'
DB_VERSION = 1
class DB:
def __init__(self, callback, show_read_book_error):
self.initialized = False
self.is_ok = False
self.initialize_error_msg = None
self.callback = callback
self.show_read_book_error = show_read_book_error
self.initialize_stage1()
def show_error(self, title, msg, det_msg):
if is_reading_book():
self.show_read_book_error(title, msg, det_msg)
else:
error_dialog(title, msg, det_msg)
def initialize_stage1(self):
if not window.indexedDB:
self.initialize_error_msg = _('Your browser does not support IndexedDB. Cannot read books. Consider using a modern browser, such as Chrome or Firefox.')
self.initialized = True
# Callers assume __init__ has finished before the callback is
# called, since we are called in __init__, only callback after
# event loop ticks over
window.setTimeout(self.callback, 0)
return
request = window.indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = def(event):
upgrade_schema(event.target.result, event.oldVersion, event.newVersion, event.target.transaction)
request.onblocked = def(event):
self.initialize_error_msg = _('Please close all other browser tabs with calibre open')
self.initialized = True
console.log(event)
self.callback()
request.onerror = def(event):
self.initialize_error_msg = _('You must allow calibre to use IndexedDB storage in your browser to read books')
self.initialized = True
console.log(event)
self.callback()
request.onsuccess = def(event):
blob = Blob(['test'], {'type':"text/plain"})
idb = event.target.result
store = idb.transaction(['files'], 'readwrite').objectStore('files')
try:
req = store.put(blob, ':-test-blob-:')
except:
self.initialize_stage2(idb, False)
return
req.onsuccess = def(event):
self.initialize_stage2(idb, True)
req.onerror = def(event):
# We use setTimeout as otherwise the idb.onerror handler is
# called with this error on Safari
setTimeout(self.initialize_stage2.bind(None, idb, False), 0)
def initialize_stage2(self, idb, supports_blobs):
self.idb = idb
self.supports_blobs = supports_blobs
self.initialized = True
self.is_ok = True
if not supports_blobs:
print('WARNING: browser does not support blob storage, calibre falling back to base64 encoding')
idb.onerror = def(event):
self.display_error(None, event)
if console.dir:
console.dir(event)
else:
console.log(event)
idb.onversionchange = def(event):
idb.close()
self.show_error(_('Database upgraded!'), _(
'A newer version of calibre is available, please click the Reload button in your browser.'))
self.callback()
def display_error(self, msg, event):
if event.already_displayed_by_calibre:
return
event.already_displayed_by_calibre = True
msg = msg or _(
'There was an error while interacting with the'
' database used to store books for offline reading. Click "Show details" for more information.')
self.show_error(_('Cannot read book'), msg, get_error_details(event))
def do_op(self, stores, data, error_msg, proceed, op='get', store=None):
store = store or stores[0]
if op is 'get':
transaction = self.idb.transaction(stores)
# Microsoft Edge currently does not support complex keys so the
# next line will throw a DataError in Edge when data is an
# object key like an array.
# https://gist.github.com/nolanlawson/a841ee23436410f37168
try:
req = transaction.objectStore(store).get(data)
except:
if /Edge\/\d+/.test(window.navigator.userAgent):
self.show_error(_('Cannot read book'), _(
'Reading of books is not supported on Microsoft Edge. Use a better'
' browser such as Google Chrome or Mozilla Firefox'))
return
raise
req.onsuccess = def(event):
proceed(req.result)
elif op is 'put':
transaction = self.idb.transaction(stores, 'readwrite')
req = transaction.objectStore(store).put(data)
req.onsuccess = proceed
req.onerror = def(event):
self.display_error(error_msg, event)
def get_book(self, library_id, book_id, fmt, metadata, proceed):
fmt = fmt.toUpperCase()
# The key has to be a JavaScript array as otherwise it cannot be stored
# into indexed db, because the RapydScript list has properties that
# refer to non-serializable objects like functions.
book_id = int(book_id)
key = v'[library_id, book_id, fmt]'
self.do_op(['books'], key, _('Failed to read from the books database'), def(result):
proceed(result or new_book(key, metadata))
)
def get_mathjax_info(self, proceed):
self.do_op(['objects'], 'mathjax-info', _('Failed to read from the objects database'), def(result):
proceed(result or {'key':'mathjax-info'})
)
def save_manifest(self, book, manifest, proceed):
book.manifest = manifest
book.metadata = manifest.metadata
book.book_hash = manifest.book_hash.hash
book.stored_files = {}
book.is_complete = False
if get_interface_data().username:
newest_epoch = newest_pos = None
for pos in manifest.last_read_positions:
if newest_epoch is None or pos.epoch > newest_epoch:
newest_epoch = pos.epoch
newest_pos = pos.cfi
if newest_pos:
unkey = username_key(get_interface_data().username)
book.last_read[unkey] = new Date(newest_epoch * 1000)
book.last_read_position[unkey] = newest_pos
v'delete manifest["metadata"]'
v'delete manifest["last_read_positions"]'
self.do_op(['books'], book, _('Failed to write to the books database'), proceed, op='put')
def store_file(self, book, name, xhr, proceed, is_cover):
store_as_text = xhr.responseType is 'text' or not xhr.responseType
fname = file_store_name(book, name)
needs_encoding = not store_as_text and not self.supports_blobs
mt = book.manifest.files[name]?.mimetype
if not mt and is_cover:
mt = 'image/jpeg'
book.stored_files[fname] = {'encoded':needs_encoding, 'mimetype':mt, 'store_as_text':store_as_text}
if is_cover:
self.store_cover(book, needs_encoding, xhr.response, name, fname, proceed)
else:
self.store_file_stage2(needs_encoding, xhr.response, name, fname, proceed)
def store_cover(self, book, needs_encoding, data, name, fname, proceed):
blob = data
if needs_encoding:
blob = Blob([data], {'type':'image/jpeg'})
url = window.URL.createObjectURL(blob)
img = new Image()
book.cover_name = name
proceeded = False
def done():
nonlocal proceeded
if not proceeded:
proceeded = True
window.URL.revokeObjectURL(url)
self.store_file_stage2(needs_encoding, data, name, fname, proceed)
img.onload = def():
book.cover_width = this.width
book.cover_height = this.height
done()
img.onerror = def():
print('WARNING: Failed to read dimensions of cover')
done()
img.src = url
def store_file_stage2(self, needs_encoding, data, name, fname, proceed):
if needs_encoding:
data = base64encode(Uint8Array(data))
req = self.idb.transaction(['files'], 'readwrite').objectStore('files').put(data, fname)
req.onsuccess = def(event):
proceed()
req.onerror = def(event):
proceed(_('Failed to store book data ({0}) with error: {1}').format(name, get_error_details(event)))
def clear_mathjax(self, proceed):
self.idb.transaction(['mathjax'], 'readwrite').objectStore('mathjax').clear().onsuccess = proceed
def store_mathjax_file(self, name, xhr, proceed):
data = xhr.response
if not self.supports_blobs:
data = base64encode(Uint8Array(data))
req = self.idb.transaction(['mathjax'], 'readwrite').objectStore('mathjax').put(data, name)
req.onsuccess = def(event): proceed()
req.onerror = def(event):
proceed(_('Failed to store mathjax file ({0}) with error: {1}').format(name, get_error_details(event)))
def finish_book(self, book, proceed):
book.is_complete = True
self.do_op(['books'], book, _('Failed to write to the books database'), proceed, op='put')
def finish_mathjax(self, mathjax_info, proceed):
self.do_op(['objects'], mathjax_info, _('Failed to write to the objects database'), proceed, op='put')
def update_last_read_time(self, book):
unkey = username_key(get_interface_data().username)
now = new Date()
book.last_read[unkey] = book.recent_date = now
self.do_op(['books'], book, _('Failed to write to the books database'), op='put')
def update_last_read_data_from_key(self, library_id, book_id, fmt, last_read, last_read_position):
unkey = username_key(get_interface_data().username)
self.get_book(library_id, book_id, fmt, None, def(book):
if book.metadata: # book exists
book.last_read[unkey] = book.recent_date = last_read
book.last_read_position[unkey] = last_read_position
self.do_op(['books'], book, _('Failed to write to the books database'), op='put')
)
def get_file(self, book, name, proceed):
key = file_store_name(book, name)
err = _(
'Failed to read the file {0} for the book {1} from the browser offline cache.'
).format(name, book.metadata.title)
self.do_op(['files'], key, err, def (result):
if not result:
# File was not found in the cache, this happens in Chrome if
# the Application Cache is full. IndexedDB put succeeds,
# but get fails. Browsers are a horrible travesty.
bad = err + _(
' This usually means the cache is close to full. Clear the browser'
' cache from the browser settings.')
self.show_error(_('Cannot read file from book'), bad)
return
fdata = book.stored_files[key]
mt = fdata.mimetype or 'application/octet-stream'
if fdata.encoded:
result = Blob([base64decode(result)], {'type':mt})
proceed(result, name, mt, book)
)
def get_mathjax_files(self, proceed):
c = self.idb.transaction('mathjax').objectStore('mathjax').openCursor()
c.onerror = def(event):
err = _('Failed to read the MathJax files from the browser offline cache.')
self.display_error(err, event)
data = {}
c.onsuccess = def(event):
cursor = event.target.result
if cursor:
name, result = cursor.key, cursor.value
if not isinstance(result, Blob):
mt = 'application/x-font-woff' if name.endswith('.woff') else 'text/javascript'
result = Blob([base64decode(result)], {'type':mt})
data[name] = result
cursor.continue()
else:
proceed(data)
def get_recently_read_books(self, proceed, limit):
limit = limit or 3
c = self.idb.transaction(['books'], 'readonly').objectStore('books').index('recent_index').openCursor(None, 'prev')
books = v'[]'
c.onerror = def(event):
err = _('Failed to read recent books from local storage')
self.display_error(err, event)
c.onsuccess = def (ev):
cursor = ev.target.result
if cursor:
books.push(cursor.value)
if books.length >= limit or not cursor:
proceed(books)
return
if cursor:
cursor.continue()
def has_book_matching(self, library_id, book_id, proceed):
# Should really be using a multiEntry index to avoid iterating over all
# books in JS, but since the number of stored books is not that large,
# I can't be bothered.
c = self.idb.transaction(['books'], 'readonly').objectStore('books').index('recent_index').openCursor(None, 'prev')
c.onerror = def(event):
proceed(False)
book_id = int(book_id)
c.onsuccess = def (ev):
cursor = ev.target.result
if cursor:
book = cursor.value
if book.key[0] is library_id and book.key[1] is book_id:
proceed(True)
return
cursor.continue()
else:
proceed(False)
def delete_book(self, book, proceed):
c = self.idb.transaction(['books', 'files'], 'readwrite')
files = c.objectStore('files')
books = c.objectStore('books')
filenames = Object.keys(book.stored_files)
c.oncomplete = def(event):
proceed(book)
c.onerror = def (event):
proceed(book, c.error.toString())
def next_step():
if filenames.length:
r = files.delete(filenames.pop())
r.onsuccess = next_step
else:
books.delete(book.key)
next_step()
def delete_books_matching(self, library_id, book_id, proceed):
c = self.idb.transaction(['books'], 'readonly').objectStore('books').index('recent_index').openCursor(None, 'prev')
c.onerror = def(event):
pass
book_id = int(book_id)
matches = v'[]'
def delete_all():
if matches.length:
book = matches.pop()
self.delete_book(book, delete_all)
else:
if proceed:
proceed()
c.onsuccess = def (ev):
cursor = ev.target.result
if cursor:
book = cursor.value
if book.key[0] is library_id and book.key[1] is book_id:
matches.push(book)
cursor.continue()
else:
delete_all()
def get_db(callback, show_read_book_error):
if not get_db.ans:
get_db.ans = DB(callback, show_read_book_error)
return get_db.ans