Implement automatic syncing to last read position for the most recently read books

Note that position syncing only works with user accounts (anonymous
users do not have syncing)
This commit is contained in:
Kovid Goyal 2017-04-04 09:21:21 +05:30
parent 4933604bb4
commit a4d5792e95
4 changed files with 115 additions and 18 deletions

View File

@ -147,6 +147,8 @@ def book_manifest(ctx, rd, book_id, fmt):
with lopen(mpath, 'rb') as f: with lopen(mpath, 'rb') as f:
ans = jsonlib.load(f) ans = jsonlib.load(f)
ans['metadata'] = book_as_json(db, book_id) ans['metadata'] = book_as_json(db, book_id)
user = rd.username or None
ans['last_read_positions'] = db.get_last_read_positions(book_id, fmt, user)
return ans return ans
except EnvironmentError as e: except EnvironmentError as e:
if e.errno != errno.ENOENT: if e.errno != errno.ENOENT:
@ -216,6 +218,7 @@ def set_last_read_position(ctx, rd, library_id, book_id, fmt):
raise HTTPNotFound('Invalid data') raise HTTPNotFound('Invalid data')
db.set_last_read_position( db.set_last_read_position(
book_id, fmt, user=user, device=device, cfi=cfi or None, pos_frac=pos_frac) book_id, fmt, user=user, device=device, cfi=cfi or None, pos_frac=pos_frac)
rd.outheaders['Content-type'] = 'text/plain'
return b'' return b''

View File

@ -5,14 +5,15 @@ from __python__ import bound_methods, hash_literals
from elementmaker import E from elementmaker import E
from gettext import gettext as _ from gettext import gettext as _
from ajax import ajax
from book_list.cover_grid import BORDER_RADIUS from book_list.cover_grid import BORDER_RADIUS
from book_list.globals import get_db from book_list.globals import get_db
from book_list.router import open_book, update_window_title from book_list.router import open_book, update_window_title
from book_list.top_bar import create_top_bar from book_list.top_bar import create_top_bar
from book_list.ui import set_default_panel_handler, show_panel from book_list.ui import set_default_panel_handler, show_panel
from dom import add_extra_css, build_rule, ensure_id from dom import add_extra_css, build_rule, ensure_id
from session import get_interface_data from session import get_device_uuid, get_interface_data
from utils import conditional_timeout from utils import conditional_timeout, username_key
from widgets import create_button from widgets import create_button
CLASS_NAME = 'home-page' CLASS_NAME = 'home-page'
@ -42,6 +43,63 @@ def read_book(library_id, book_id, fmt):
open_book(book_id, fmt, library_id) open_book(book_id, fmt, library_id)
def get_last_read_position(last_read_positions, prev_last_read):
prev_epoch = prev_last_read.getTime()
dev = get_device_uuid()
newest_epoch = ans = None
for data in last_read_positions:
if data.device is not dev and data.epoch > prev_epoch:
if ans is None or data.epoch > newest_epoch:
newest_epoch = data.epoch
ans = data
return ans
def sync_data_received(library_id, lrmap, load_type, xhr, ev):
if load_type is not 'load':
print('Failed to get book sync data')
return
data = JSON.parse(xhr.responseText)
for key in data:
prev_last_read = lrmap[key]
if not prev_last_read:
continue
last_read_positions = data[key]
new_last_read = get_last_read_position(last_read_positions, prev_last_read)
if not new_last_read:
continue
last_read = new Date(new_last_read.epoch * 1000)
cfi = new_last_read.cfi
if cfi:
db = get_db()
book_id, fmt = key.partition(':')[::2]
db.update_last_read_data_from_key(library_id, int(book_id), fmt, last_read, cfi)
def sync_library_books(library_id, to_sync):
url = f'book-get-last-read-position/{library_id}/'
which = v'[]'
lrmap = {}
for key, last_read in to_sync:
library_id, book_id, fmt = key
fmt = fmt.upper()
which.push(f'{book_id}-{fmt}')
lrmap[f'{book_id}:{fmt}'] = last_read
url += which.join('_')
ajax(url, sync_data_received.bind(None, library_id, lrmap)).send()
def start_sync(to_sync):
libraries = {}
for key, last_read in to_sync:
library_id = key[0]
if not libraries[library_id]:
libraries[library_id] = v'[]'
libraries[library_id].push(v'[key, last_read]')
for lid in libraries:
sync_library_books(lid, libraries[lid])
def show_recent_stage2(books): def show_recent_stage2(books):
container = document.getElementById(this) container = document.getElementById(this)
if not container or not books.length: if not container or not books.length:
@ -54,7 +112,12 @@ def show_recent_stage2(books):
container.appendChild(E.div(style='display:flex')) container.appendChild(E.div(style='display:flex'))
images = container.lastChild images = container.lastChild
db = get_db() db = get_db()
to_sync = v'[]'
username = get_interface_data().username
for book in books: for book in books:
if username and to_sync.length < 10:
lr = book.last_read[username_key(username)] or new Date(0) # noqa: unused-local
to_sync.push(v'[book.key, lr]')
img = E.img( img = E.img(
alt=_('{} by {}').format(book.metadata.title, book.metadata.authors.join(' & ')) alt=_('{} by {}').format(book.metadata.title, book.metadata.authors.join(' & '))
) )
@ -66,6 +129,8 @@ def show_recent_stage2(books):
)) ))
if book.cover_name: if book.cover_name:
db.get_file(book, book.cover_name, show_cover.bind(img_id)) db.get_file(book, book.cover_name, show_cover.bind(img_id))
if username:
start_sync(to_sync)
container.appendChild(E.div(style='margin: 1rem 1rem', container.appendChild(E.div(style='margin: 1rem 1rem',
create_button( create_button(
_('Browse all previously downloaded books…'), _('Browse all previously downloaded books…'),

View File

@ -1,12 +1,14 @@
# vim:fileencoding=utf-8 # vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> # License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
from __python__ import hash_literals, bound_methods from __python__ import bound_methods, hash_literals
from encodings import base64decode, base64encode
from gettext import gettext as _ from gettext import gettext as _
from encodings import base64encode, base64decode
from modals import error_dialog
from book_list.router import is_reading_book 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): def upgrade_schema(idb, old_version, new_version, transaction):
@ -22,8 +24,8 @@ def upgrade_schema(idb, old_version, new_version, transaction):
# Create indices # Create indices
books_store = transaction.objectStore('books') books_store = transaction.objectStore('books')
if not books_store.indexNames.contains('last_read_index'): if not books_store.indexNames.contains('recent_index'):
books_store.createIndex('last_read_index', 'last_read') books_store.createIndex('recent_index', 'recent_date')
def file_store_name(book, name): def file_store_name(book, name):
return book.book_hash + ' ' + name return book.book_hash + ' ' + name
@ -35,8 +37,8 @@ def get_error_details(event):
elif desc.errorCode: elif desc.errorCode:
desc = desc.errorCode desc = desc.errorCode
DB_NAME = 'calibre-books-db-testing' # TODO: Remove test suffix and change version back to 1 DB_NAME = 'calibre-books-db-testingx3' # TODO: Remove test suffix and change version back to 1
DB_VERSION = 3 DB_VERSION = 1
class DB: class DB:
@ -134,6 +136,7 @@ class DB:
# The key has to be a JavaScript array as otherwise it cannot be stored # The key has to be a JavaScript array as otherwise it cannot be stored
# into indexed db, because the RapydScript list has properties that # into indexed db, because the RapydScript list has properties that
# refer to non-serializable objects like functions. # refer to non-serializable objects like functions.
book_id = int(book_id)
key = v'[library_id, book_id, fmt]' key = v'[library_id, book_id, fmt]'
self.do_op(['books'], key, _('Failed to read from the books database'), def(result): self.do_op(['books'], key, _('Failed to read from the books database'), def(result):
proceed(result or { proceed(result or {
@ -141,12 +144,13 @@ class DB:
'is_complete':False, 'is_complete':False,
'stored_files': {}, 'stored_files': {},
'book_hash':None, 'book_hash':None,
'last_read': Date(),
'metadata': metadata, 'metadata': metadata,
'manifest': None, 'manifest': None,
'cover_width': None, 'cover_width': None,
'cover_height': None, 'cover_height': None,
'cover_name': None, 'cover_name': None,
'recent_date': new Date(),
'last_read': {},
'last_read_position': {}, 'last_read_position': {},
}) })
) )
@ -162,7 +166,19 @@ class DB:
book.book_hash = manifest.book_hash.hash book.book_hash = manifest.book_hash.hash
book.stored_files = {} book.stored_files = {}
book.is_complete = False 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["metadata"]'
v'delete manifest["last_read_positions"]'
self.do_op(['books'], book, _('Failed to write to the books database'), proceed, op='put') 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): def store_file(self, book, name, xhr, proceed, is_cover):
@ -229,9 +245,20 @@ class DB:
self.do_op(['objects'], mathjax_info, _('Failed to write to the objects database'), proceed, op='put') self.do_op(['objects'], mathjax_info, _('Failed to write to the objects database'), proceed, op='put')
def update_last_read_time(self, book): def update_last_read_time(self, book):
book.last_read = Date() 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') 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): def get_file(self, book, name, proceed):
key = file_store_name(book, name) key = file_store_name(book, name)
err = _( err = _(
@ -267,7 +294,7 @@ class DB:
def get_recently_read_books(self, proceed, limit): def get_recently_read_books(self, proceed, limit):
limit = limit or 3 limit = limit or 3
c = self.idb.transaction(['books'], 'readonly').objectStore('books').index('last_read_index').openCursor(None, 'prev') c = self.idb.transaction(['books'], 'readonly').objectStore('books').index('recent_index').openCursor(None, 'prev')
books = v'[]' books = v'[]'
c.onerror = def(event): c.onerror = def(event):
err = _('Failed to read recent books from local storage') err = _('Failed to read recent books from local storage')

View File

@ -384,18 +384,20 @@ class View:
def on_update_cfi(self, data): def on_update_cfi(self, data):
self.currently_showing.bookpos = data.cfi self.currently_showing.bookpos = data.cfi
push_state(self.ui.url_data, replace=data.replace_history, mode=read_book_mode) push_state(self.ui.url_data, replace=data.replace_history, mode=read_book_mode)
unkey = username_key(get_interface_data().username) username = get_interface_data().username
unkey = username_key(username)
if not self.book.last_read_position: if not self.book.last_read_position:
self.book.last_read_position = {} self.book.last_read_position = {}
self.book.last_read_position[unkey] = data.cfi self.book.last_read_position[unkey] = data.cfi
self.ui.db.update_last_read_time(self.book) self.ui.db.update_last_read_time(self.book)
lrd = {'device':get_device_uuid(), 'cfi':data.cfi, 'pos_frac':data.progress_frac} lrd = {'device':get_device_uuid(), 'cfi':data.cfi, 'pos_frac':data.progress_frac}
key = self.book.key key = self.book.key
ajax_send('book-set-last-read-position/{library_id}/{book_id}/{fmt}'.format( if username:
library_id=key[0], book_id=key[1], fmt=key[2]), lrd, def(end_type, xhr, ev): ajax_send('book-set-last-read-position/{library_id}/{book_id}/{fmt}'.format(
if end_type is not 'load': library_id=key[0], book_id=key[1], fmt=key[2]), lrd, def(end_type, xhr, ev):
print('Failed to update last read position, AJAX call did not succeed') if end_type is not 'load':
) print('Failed to update last read position, AJAX call did not succeed')
)
def on_update_toc_position(self, data): def on_update_toc_position(self, data):
update_visible_toc_nodes(data.visible_anchors) update_visible_toc_nodes(data.visible_anchors)