mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Start work on storing books in IndexedDB for offline reading
This commit is contained in:
parent
ac8363713b
commit
a3d7de7e11
@ -8,10 +8,11 @@ from hashlib import sha1
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from cPickle import dumps
|
from cPickle import dumps
|
||||||
import errno, os, tempfile, shutil, time
|
import errno, os, tempfile, shutil, time, json as jsonlib
|
||||||
|
|
||||||
from calibre.constants import cache_dir, iswindows
|
from calibre.constants import cache_dir, iswindows
|
||||||
from calibre.customize.ui import plugin_for_input_format
|
from calibre.customize.ui import plugin_for_input_format
|
||||||
|
from calibre.srv.metadata import book_as_json
|
||||||
from calibre.srv.render_book import RENDER_VERSION
|
from calibre.srv.render_book import RENDER_VERSION
|
||||||
from calibre.srv.errors import HTTPNotFound
|
from calibre.srv.errors import HTTPNotFound
|
||||||
from calibre.srv.routes import endpoint, json
|
from calibre.srv.routes import endpoint, json
|
||||||
@ -69,7 +70,8 @@ def queue_job(ctx, copy_format_to, bhash, fmt, book_id, size, mtime):
|
|||||||
with os.fdopen(fd, 'wb') as f:
|
with os.fdopen(fd, 'wb') as f:
|
||||||
copy_format_to(f)
|
copy_format_to(f)
|
||||||
tdir = tempfile.mkdtemp('', '', tdir)
|
tdir = tempfile.mkdtemp('', '', tdir)
|
||||||
job_id = ctx.start_job('Render book %s (%s)' % (book_id, fmt), 'calibre.srv.render_book', 'render', args=(pathtoebook, tdir, (size, mtime)),
|
job_id = ctx.start_job('Render book %s (%s)' % (book_id, fmt), 'calibre.srv.render_book', 'render', args=(
|
||||||
|
pathtoebook, tdir, {'size':size, 'mtime':mtime, 'hash':bhash}),
|
||||||
job_done_callback=job_done, job_data=(bhash, pathtoebook, tdir))
|
job_done_callback=job_done, job_data=(bhash, pathtoebook, tdir))
|
||||||
queued_jobs[bhash] = job_id
|
queued_jobs[bhash] = job_id
|
||||||
return job_id
|
return job_id
|
||||||
@ -127,7 +129,10 @@ def book_manifest(ctx, rd, book_id, fmt):
|
|||||||
mpath = abspath(os.path.join(books_cache_dir(), 'f', bhash, 'calibre-book-manifest.json'))
|
mpath = abspath(os.path.join(books_cache_dir(), 'f', bhash, 'calibre-book-manifest.json'))
|
||||||
try:
|
try:
|
||||||
os.utime(mpath, None)
|
os.utime(mpath, None)
|
||||||
return lopen(mpath, 'rb')
|
with lopen(mpath, 'rb') as f:
|
||||||
|
ans = jsonlib.load(f)
|
||||||
|
ans['metadata'] = book_as_json(db, book_id)
|
||||||
|
return ans
|
||||||
except EnvironmentError as e:
|
except EnvironmentError as e:
|
||||||
if e.errno != errno.ENOENT:
|
if e.errno != errno.ENOENT:
|
||||||
raise
|
raise
|
||||||
|
@ -20,7 +20,7 @@ from calibre.ebooks.oeb.polish.utils import guess_type
|
|||||||
from calibre.utils.short_uuid import uuid4
|
from calibre.utils.short_uuid import uuid4
|
||||||
from calibre.utils.logging import default_log
|
from calibre.utils.logging import default_log
|
||||||
|
|
||||||
RENDER_VERSION = 1
|
RENDER_VERSION = 1 # Also change this in read_book.ui.pyj
|
||||||
|
|
||||||
def encode_component(x):
|
def encode_component(x):
|
||||||
return x.replace(',', ',c').replace('|', ',p')
|
return x.replace(',', ',c').replace('|', ',p')
|
||||||
|
@ -393,7 +393,7 @@ class BookDetailsPanel:
|
|||||||
self.download_format(fmt)
|
self.download_format(fmt)
|
||||||
|
|
||||||
def read_format(self, fmt):
|
def read_format(self, fmt):
|
||||||
pass
|
get_boss().read_book(self.current_book_id, fmt, self.interface_data.metadata[self.current_book_id])
|
||||||
|
|
||||||
def read_book(self):
|
def read_book(self):
|
||||||
book_id = self.current_book_id
|
book_id = self.current_book_id
|
||||||
|
@ -12,6 +12,7 @@ from utils import parse_url_params
|
|||||||
from book_list.globals import get_session_data, set_boss, set_current_query
|
from book_list.globals import get_session_data, set_boss, set_current_query
|
||||||
from book_list.theme import get_color
|
from book_list.theme import get_color
|
||||||
from book_list.ui import UI
|
from book_list.ui import UI
|
||||||
|
from read_book.ui import ReadUI
|
||||||
|
|
||||||
class Boss:
|
class Boss:
|
||||||
|
|
||||||
@ -28,6 +29,9 @@ class Boss:
|
|||||||
div = E.div(id='book-list-container')
|
div = E.div(id='book-list-container')
|
||||||
document.body.appendChild(div)
|
document.body.appendChild(div)
|
||||||
self.ui = UI(interface_data, div)
|
self.ui = UI(interface_data, div)
|
||||||
|
div = E.div(id='read-book-container', style="display:none")
|
||||||
|
document.body.appendChild(div)
|
||||||
|
self.read_ui = ReadUI(interface_data, div)
|
||||||
window.onerror = self.onerror.bind(self)
|
window.onerror = self.onerror.bind(self)
|
||||||
self.history_count = 0
|
self.history_count = 0
|
||||||
data = parse_url_params()
|
data = parse_url_params()
|
||||||
@ -36,10 +40,20 @@ class Boss:
|
|||||||
if not data.mode or data.mode is 'book_list':
|
if not data.mode or data.mode is 'book_list':
|
||||||
if data.panel is not self.ui.current_panel:
|
if data.panel is not self.ui.current_panel:
|
||||||
self.ui.show_panel(data.panel, push_state=False)
|
self.ui.show_panel(data.panel, push_state=False)
|
||||||
|
elif data.mode is 'read_book':
|
||||||
|
self.current_mode = data.mode
|
||||||
|
self.apply_mode()
|
||||||
|
self.read_book(int(data.book_id), data.fmt)
|
||||||
setTimeout(def():
|
setTimeout(def():
|
||||||
window.onpopstate = self.onpopstate.bind(self)
|
window.onpopstate = self.onpopstate.bind(self)
|
||||||
, 0) # We do this after event loop ticks over to avoid catching popstate events that some browsers send on page load
|
, 0) # We do this after event loop ticks over to avoid catching popstate events that some browsers send on page load
|
||||||
|
|
||||||
|
def apply_mode(self, mode):
|
||||||
|
mode = mode or self.current_mode
|
||||||
|
divid = 'read-book-container' if mode is 'read_book' else 'book-list-container'
|
||||||
|
for x in ['book-list-container', 'read-book-container']:
|
||||||
|
document.getElementById(x).style.display = 'block' if x is divid else 'none'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_history(self):
|
def has_history(self):
|
||||||
return self.history_count > 0
|
return self.history_count > 0
|
||||||
@ -62,14 +76,23 @@ class Boss:
|
|||||||
def onpopstate(self, ev):
|
def onpopstate(self, ev):
|
||||||
data = parse_url_params()
|
data = parse_url_params()
|
||||||
set_current_query(data)
|
set_current_query(data)
|
||||||
mode = data.mode or 'book_list'
|
self.current_mode = mode = data.mode or 'book_list'
|
||||||
self.history_count -= 1
|
self.history_count -= 1
|
||||||
|
self.apply_mode()
|
||||||
if mode is 'book_list':
|
if mode is 'book_list':
|
||||||
search = data.search or ''
|
search = data.search or ''
|
||||||
if data.panel is not self.ui.current_panel:
|
if data.panel is not self.ui.current_panel:
|
||||||
self.ui.show_panel(data.panel, push_state=False)
|
self.ui.show_panel(data.panel, push_state=False)
|
||||||
if search is not self.ui.books_view.interface_data.search_result.query:
|
if search is not self.ui.books_view.interface_data.search_result.query:
|
||||||
self.ui.books_view.change_search(search, push_state=False, panel_to_show=data.panel)
|
self.ui.books_view.change_search(search, push_state=False, panel_to_show=data.panel)
|
||||||
|
elif mode is 'read_book':
|
||||||
|
self.read_book(int(data.book_id), data.fmt)
|
||||||
|
|
||||||
|
def read_book(self, book_id, fmt, metadata):
|
||||||
|
self.current_mode = 'read_book'
|
||||||
|
self.apply_mode()
|
||||||
|
self.push_state(extra_query_data={'book_id':book_id, 'fmt':fmt})
|
||||||
|
self.read_ui.load_book(book_id, fmt, metadata)
|
||||||
|
|
||||||
def change_books(self, data):
|
def change_books(self, data):
|
||||||
data.search_result.sort = str.split(data.search_result.sort, ',')[:2].join(',')
|
data.search_result.sort = str.split(data.search_result.sort, ',')[:2].join(',')
|
||||||
@ -84,20 +107,20 @@ class Boss:
|
|||||||
|
|
||||||
def push_state(self, replace=False, extra_query_data=None):
|
def push_state(self, replace=False, extra_query_data=None):
|
||||||
query = {}
|
query = {}
|
||||||
|
idata = self.interface_data
|
||||||
if extra_query_data:
|
if extra_query_data:
|
||||||
for k in extra_query_data:
|
for k in extra_query_data:
|
||||||
query[k] = extra_query_data[k]
|
query[k] = extra_query_data[k]
|
||||||
if self.current_mode is 'book_list':
|
if self.current_mode is 'book_list':
|
||||||
if self.ui.current_panel is not self.ui.ROOT_PANEL:
|
if self.ui.current_panel is not self.ui.ROOT_PANEL:
|
||||||
query.panel = self.ui.current_panel
|
query.panel = self.ui.current_panel
|
||||||
else:
|
|
||||||
query.current_mode = self.current_mode
|
|
||||||
idata = self.interface_data
|
|
||||||
if idata.library_id is not idata.default_library:
|
|
||||||
query.library_id = idata.library_id
|
|
||||||
sq = idata.search_result.query
|
sq = idata.search_result.query
|
||||||
if sq:
|
if sq:
|
||||||
query.search = sq
|
query.search = sq
|
||||||
|
else:
|
||||||
|
query.mode = self.current_mode
|
||||||
|
if idata.library_id is not idata.default_library:
|
||||||
|
query.library_id = idata.library_id
|
||||||
set_current_query(query)
|
set_current_query(query)
|
||||||
query = encode_query(query) or '?'
|
query = encode_query(query) or '?'
|
||||||
if replace:
|
if replace:
|
||||||
|
5
src/pyj/read_book/__init__.pyj
Normal file
5
src/pyj/read_book/__init__.pyj
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# vim:fileencoding=utf-8
|
||||||
|
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
|
||||||
|
|
91
src/pyj/read_book/db.pyj
Normal file
91
src/pyj/read_book/db.pyj
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# vim:fileencoding=utf-8
|
||||||
|
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
from gettext import gettext as _
|
||||||
|
from modals import error_dialog
|
||||||
|
|
||||||
|
def upgrade_schema(idb, old_version, new_version):
|
||||||
|
if old_version < 2:
|
||||||
|
idb.createObjectStore('books', {'keyPath':'key'})
|
||||||
|
idb.createObjectStore('files')
|
||||||
|
|
||||||
|
class DB:
|
||||||
|
|
||||||
|
DB_NAME = 'calibre-read-books-db-test' # TODO: Remove test suffix
|
||||||
|
|
||||||
|
def __init__(self, idb, interface_data):
|
||||||
|
self.interface_data = interface_data
|
||||||
|
self.idb = idb
|
||||||
|
|
||||||
|
idb.onerror = def(event):
|
||||||
|
self.display_error(None, event)
|
||||||
|
if console.dir:
|
||||||
|
console.dir(event)
|
||||||
|
else:
|
||||||
|
console.log(event)
|
||||||
|
|
||||||
|
idb.onversionchange = def(event):
|
||||||
|
self.db.close()
|
||||||
|
error_dialog(_('Database upgraded!'), _(
|
||||||
|
'A newer version of calibre is available, please click the reload button in your browser.'))
|
||||||
|
|
||||||
|
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.')
|
||||||
|
desc = event.target
|
||||||
|
if desc.error and desc.error.toString:
|
||||||
|
desc = desc.error.toString()
|
||||||
|
elif desc.errorCode:
|
||||||
|
desc = desc.errorCode
|
||||||
|
error_dialog(_('Cannot read book'), msg, desc)
|
||||||
|
|
||||||
|
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(transaction)
|
||||||
|
req = transaction.objectStore(store).get(data)
|
||||||
|
req.onsuccess = def(event): proceed(req.result)
|
||||||
|
elif op is 'put':
|
||||||
|
transaction = self.idb.transaction(transaction, 'readwrite')
|
||||||
|
req = transaction.objectStore(store).put(data)
|
||||||
|
req.onsuccess = proceed
|
||||||
|
req.onerror = def(event):
|
||||||
|
self.display_error(error_msg, event)
|
||||||
|
|
||||||
|
def get_book(self, book_id, fmt, metadata, proceed):
|
||||||
|
fmt = fmt.toUpperCase()
|
||||||
|
key = [self.interface_data.library_id, book_id, fmt]
|
||||||
|
self.do_op(['books'], key, _('Failed to read from the books database'), def(result):
|
||||||
|
proceed(result or {
|
||||||
|
'key':key,
|
||||||
|
'is_complete':False,
|
||||||
|
'stores_blobs': True,
|
||||||
|
'book_hash':None,
|
||||||
|
'last_read': Date(),
|
||||||
|
'metadata': metadata,
|
||||||
|
'manifest': None,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_manifest(self, book, manifest, proceed):
|
||||||
|
book.manifest = manifest
|
||||||
|
book.metadata = manifest.metadata
|
||||||
|
v'delete manifest["metadata"]'
|
||||||
|
self.do_op(['books'], book, _('Failed to write to the books database'), proceed, op='put')
|
||||||
|
|
||||||
|
def create_db(ui, interface_data):
|
||||||
|
if not window.indexedDB:
|
||||||
|
ui.db = _('Your browser does not support IndexedDB. Cannot read books. Consider using a modern browser, such as Firefox, Chrome or Edge.')
|
||||||
|
return
|
||||||
|
request = window.indexedDB.open(DB.DB_NAME, 2)
|
||||||
|
request.onerror = def(event):
|
||||||
|
ui.db = _('You must allow calibre to use IndexedDB storage in your browser to read books')
|
||||||
|
request.onsuccess = def(event):
|
||||||
|
ui.db = DB(event.target.result, interface_data)
|
||||||
|
ui.db_initialized()
|
||||||
|
request.onupgradeneeded = def(event):
|
||||||
|
upgrade_schema(event.target.result, event.oldVersion, event.newVersion)
|
76
src/pyj/read_book/ui.pyj
Normal file
76
src/pyj/read_book/ui.pyj
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# vim:fileencoding=utf-8
|
||||||
|
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
from ajax import ajax
|
||||||
|
from gettext import gettext as _
|
||||||
|
from modals import error_dialog
|
||||||
|
from read_book.db import create_db
|
||||||
|
|
||||||
|
RENDER_VERSION = 1 # Also change this in render_book.py
|
||||||
|
|
||||||
|
class ReadUI:
|
||||||
|
|
||||||
|
def __init__(self, interface_data, container):
|
||||||
|
self.interface_data = interface_data
|
||||||
|
self.db = None
|
||||||
|
self.current_metadata = None
|
||||||
|
self.manifest_xhr = None
|
||||||
|
create_db(self, interface_data)
|
||||||
|
self.pending_load = None
|
||||||
|
|
||||||
|
def load_book(self, book_id, fmt, metadata):
|
||||||
|
if self.db is None:
|
||||||
|
self.pending_load = [book_id, fmt, metadata]
|
||||||
|
return
|
||||||
|
self.start_load(book_id, fmt, metadata)
|
||||||
|
|
||||||
|
def db_initialized(self):
|
||||||
|
if self.pending_load is not None:
|
||||||
|
pl, self.pending_load = self.pending_load, None
|
||||||
|
self.start_load(*pl)
|
||||||
|
|
||||||
|
def start_load(self, book_id, fmt, metadata):
|
||||||
|
if type(self.db) is 'string':
|
||||||
|
error_dialog(_('Cannot read book'), self.db)
|
||||||
|
return
|
||||||
|
metadata = metadata or self.interface_data.metadata[book_id]
|
||||||
|
self.current_metadata = metadata or {'title':_('Current Book')}
|
||||||
|
self.db.get_book(book_id, fmt, metadata, self.got_book.bind(self))
|
||||||
|
|
||||||
|
def got_book(self, book):
|
||||||
|
if not book.manifest or book.manifest.version != RENDER_VERSION:
|
||||||
|
self.get_manifest(book)
|
||||||
|
return
|
||||||
|
self.download_book(book)
|
||||||
|
|
||||||
|
def get_manifest(self, book):
|
||||||
|
library_id, book_id, fmt = book.key
|
||||||
|
if self.manifest_xhr:
|
||||||
|
self.manifest_xhr.abort()
|
||||||
|
query = {'library_id': library_id}
|
||||||
|
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 error_dialog(_('Failed to load book manifest'), str.format(
|
||||||
|
_('Could not open {title} as book manifest failed to load, click "Show Details" for more information.'), title=self.current_metadata.title),
|
||||||
|
xhr.error_html)
|
||||||
|
try:
|
||||||
|
manifest = JSON.parse(xhr.responseText)
|
||||||
|
except Exception as err:
|
||||||
|
return error_dialog(_('Failed to load book manifest'), str.format(
|
||||||
|
_('The manifest for {title} is not valid'), title=self.current_metadata.title),
|
||||||
|
err.stack or err.toString())
|
||||||
|
if manifest.version != RENDER_VERSION:
|
||||||
|
return error_dialog(_('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))
|
||||||
|
|
||||||
|
def download_book(self, book):
|
||||||
|
pass
|
Loading…
x
Reference in New Issue
Block a user