From 3537db5a8c58324491fd2d3cc7aba1576de3d58d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 15 Dec 2022 17:02:26 +0530 Subject: [PATCH] Content server: When using user accounts on the homepage show recently read books from any device not just the current device. Fixes #1998557 [[feature request] Content-server viewer suggesting of book being read on other devices](https://bugs.launchpad.net/calibre/+bug/1998557) --- src/calibre/srv/books.py | 10 +++- src/calibre/srv/code.py | 4 ++ src/calibre/srv/last_read.py | 87 ++++++++++++++++++++++++++++++++ src/calibre/srv/tests/content.py | 15 ++++++ src/pyj/book_list/home.pyj | 58 +++++++++++++++------ src/pyj/session.pyj | 1 + 6 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 src/calibre/srv/last_read.py diff --git a/src/calibre/srv/books.py b/src/calibre/srv/books.py index a83059731a..515d296b7c 100644 --- a/src/calibre/srv/books.py +++ b/src/calibre/srv/books.py @@ -13,7 +13,9 @@ from threading import Lock, RLock from calibre.constants import cache_dir, iswindows from calibre.customize.ui import plugin_for_input_format +from calibre.ebooks.metadata import authors_to_string from calibre.srv.errors import BookNotFound, HTTPNotFound +from calibre.srv.last_read import last_read_cache from calibre.srv.metadata import book_as_json from calibre.srv.render_book import RENDER_VERSION from calibre.srv.routes import endpoint, json @@ -220,8 +222,14 @@ def set_last_read_position(ctx, rd, library_id, book_id, fmt): device, cfi, pos_frac = data['device'], data['cfi'], data['pos_frac'] except Exception: raise HTTPNotFound('Invalid data') + cfi = cfi or None 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, pos_frac=pos_frac) + if user: + with db.safe_read_lock: + tt = db._field_for('title', book_id) + tt += ' ' + _('by') + ' ' + authors_to_string(db._field_for('authors', book_id)) + last_read_cache().add_last_read_position(library_id, book_id, fmt, user, cfi, pos_frac, tt) rd.outheaders['Content-type'] = 'text/plain' return b'' diff --git a/src/calibre/srv/code.py b/src/calibre/srv/code.py index 0a93b525db..c706c97bd6 100644 --- a/src/calibre/srv/code.py +++ b/src/calibre/srv/code.py @@ -18,6 +18,7 @@ from calibre.srv.ajax import search_result from calibre.srv.errors import ( BookNotFound, HTTPBadRequest, HTTPForbidden, HTTPNotFound, HTTPRedirect, ) +from calibre.srv.last_read import last_read_cache from calibre.srv.metadata import ( book_as_json, categories_as_json, categories_settings, icon_map, ) @@ -167,6 +168,9 @@ def basic_interface_data(ctx, rd): 'lang_code_for_user_manual': lang_code_for_user_manual(), } ans['library_map'], ans['default_library_id'] = ctx.library_info(rd) + if ans['username']: + lrc = last_read_cache() + ans['recently_read_by_user'] = lrc.get_recently_read(ans['username']) return ans diff --git a/src/calibre/srv/last_read.py b/src/calibre/srv/last_read.py new file mode 100644 index 0000000000..df04e7a4e4 --- /dev/null +++ b/src/calibre/srv/last_read.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2022, Kovid Goyal + +import apsw +import os +from contextlib import suppress +from threading import Lock +from time import time_ns + +from calibre.constants import cache_dir + +creation_sql = ''' +CREATE TABLE IF NOT EXISTS last_read_positions ( id INTEGER PRIMARY KEY, + library_id TEXT NOT NULL, + book INTEGER NOT NULL, + format TEXT NOT NULL COLLATE NOCASE, + user TEXT NOT NULL, + cfi TEXT NOT NULL, + epoch INTEGER NOT NULL, + pos_frac REAL NOT NULL DEFAULT 0, + tooltip TEXT NOT NULL, + UNIQUE(user, library_id, book, format) +); +CREATE INDEX IF NOT EXISTS users_id ON last_read_positions (user); +''' + +lock = Lock() + + +class LastReadCache: + + def __init__(self, path='', limit=5): + self.limit = limit + self.conn = apsw.Connection(path or os.path.join(cache_dir(), 'srv-last-read.sqlite')) + self.execute(creation_sql) + + def get(self, *args, **kw): + ans = self.conn.cursor().execute(*args) + if kw.get('all', True): + return ans.fetchall() + with suppress(StopIteration, IndexError): + return next(ans)[0] + + def execute(self, sql, bindings=None): + cursor = self.conn.cursor() + return cursor.execute(sql, bindings) + + def add_last_read_position(self, library_id, book_id, fmt, user, cfi, pos_frac, tooltip): + with lock, self.conn: + if not cfi: + self.execute( + 'DELETE FROM last_read_positions WHERE library_id=? AND book=? AND format=? AND user=?', + (library_id, book_id, fmt, user)) + else: + epoch = time_ns() + self.execute( + 'INSERT OR REPLACE INTO last_read_positions(library_id,book,format,user,cfi,epoch,pos_frac,tooltip) VALUES (?,?,?,?,?,?,?,?)', + (library_id, book_id, fmt, user, cfi, epoch, pos_frac, tooltip)) + items = tuple(self.get('SELECT epoch FROM last_read_positions WHERE user=? ORDER BY epoch DESC', (user,), all=True)) + if len(items) > self.limit: + limit_epoch = items[self.limit][0] + self.execute('DELETE FROM last_read_positions WHERE user=? AND epoch <= ?', (user, limit_epoch)) + return epoch + + def get_recently_read(self, user): + with lock: + ans = [] + for library_id, book, fmt, cfi, epoch, pos_frac, tooltip in self.execute( + 'SELECT library_id,book,format,cfi,epoch,pos_frac,tooltip FROM last_read_positions WHERE user=? ORDER BY epoch DESC', (user,) + ): + ans.append({ + 'library_id': library_id, 'book_id': book, 'format': fmt, + 'cfi': cfi, 'epoch':epoch, 'pos_frac':pos_frac, 'tooltip': tooltip, + }) + return ans + + +path_cache = {} + + +def last_read_cache(path=''): + with lock: + ans = path_cache.get(path) + if ans is None: + ans = path_cache[path] = LastReadCache(path) + return ans diff --git a/src/calibre/srv/tests/content.py b/src/calibre/srv/tests/content.py index ba14345c57..ac5aa1a3a0 100644 --- a/src/calibre/srv/tests/content.py +++ b/src/calibre/srv/tests/content.py @@ -262,3 +262,18 @@ class ContentTest(LibraryBaseTest): text = 'a' * (127 * 1024) t('

{0}

{0}'.format(text), [{"n":"p","x":text}, {'n':'p','x':text}]) # }}} + + def test_last_read_cache(self): # {{{ + from calibre.srv.last_read import last_read_cache, path_cache + path_cache.clear() + lrc = last_read_cache(':memory:') + epoch = lrc.add_last_read_position('lib', 1, 'FMT', 'user', 'epubcfi(/)', 0.1, 'tt') + expected = {'library_id': 'lib', 'book_id': 1, 'format': 'FMT', 'cfi': 'epubcfi(/)', 'epoch': epoch, 'pos_frac': 0.1, 'tooltip': 'tt'} + self.ae(lrc.get_recently_read('user'), [expected]) + epoch = lrc.add_last_read_position('lib', 1, 'FMT', 'user', 'epubcfi(/)', 0.2, 'tt') + expected['epoch'], expected['pos_frac'] = epoch, 0.2 + self.ae(lrc.get_recently_read('user'), [expected]) + for book_id in range(2, 7): + lrc.add_last_read_position('lib', book_id, 'FMT', 'user', 'epubcfi(/)', 0.1, 'tt') + self.ae(len(lrc.get_recently_read('user')), lrc.limit) + # }}} diff --git a/src/pyj/book_list/home.pyj b/src/pyj/book_list/home.pyj index b0cf8d0592..f8ca51f27d 100644 --- a/src/pyj/book_list/home.pyj +++ b/src/pyj/book_list/home.pyj @@ -3,16 +3,18 @@ from __python__ import bound_methods, hash_literals from elementmaker import E -from gettext import gettext as _ -from ajax import ajax_send +from ajax import absolute_path, ajax_send from book_list.cover_grid import BORDER_RADIUS from book_list.globals import get_db -from book_list.library_data import last_virtual_library_for, sync_library_books, all_libraries -from book_list.router import open_book, update_window_title +from book_list.library_data import ( + all_libraries, last_virtual_library_for, sync_library_books +) +from book_list.router import open_book, open_book_url, update_window_title from book_list.top_bar import add_button, create_top_bar from book_list.ui import set_default_panel_handler, show_panel from dom import add_extra_css, build_rule, clear, ensure_id, set_css, unique_id +from gettext import gettext as _ from modals import create_custom_dialog from session import get_device_uuid, get_interface_data from utils import conditional_timeout, safe_set_inner_html, username_key @@ -98,17 +100,44 @@ def start_sync(to_sync): sync_library_books(lid, libraries[lid], sync_data_received) -def show_recent_stage2(books): - container = document.getElementById(this) - if not container or not books.length: - return +def prepare_recent_container(container): container.style.display = 'block' container.style.borderBottom = 'solid 1px currentColor' container.style.paddingBottom = '1em' container.appendChild(E.h2(_( 'Continue reading…'))) container.appendChild(E.div(style='display:flex')) - images = container.lastChild + cover_container = container.lastChild + container.appendChild(E.div(style='margin: 1rem 1rem', + create_button( + _('Browse all downloaded books…'), + action=def(): + show_panel('local_books') + ))) + return cover_container + + +def show_recent_for_user(container_id, interface_data): + container = document.getElementById(container_id) + images = prepare_recent_container(container) + for item in interface_data.recently_read_by_user[:3]: + q = {'library_id': item.library_id} + if item.cfi: + q.bookpos = item.cfi + url_to_read = open_book_url(item.book_id, item.format, q) + images.appendChild(E.div(style='margin: 0 1em', + E.a( + title=item.tooltip, + href=url_to_read, + E.img(alt=item.tooltip, src=absolute_path(f'get/cover/{item.book_id}/{item.library_id}')) + ))) + + +def show_recent_stage2(books): + container = document.getElementById(this) + if not container or not books.length: + return + images = prepare_recent_container(container) db = get_db() to_sync = v'[]' username = get_interface_data().username @@ -129,12 +158,6 @@ def show_recent_stage2(books): if book.cover_name: db.get_file(book, book.cover_name, show_cover.bind(img_id)) start_sync(to_sync) - container.appendChild(E.div(style='margin: 1rem 1rem', - create_button( - _('Browse all downloaded books…'), - action=def(): - show_panel('local_books') - ))) def show_recent(): @@ -242,7 +265,10 @@ def init(container_id): recent = E.div(style='display:none', class_='recently-read') recent_container_id = ensure_id(recent) container.appendChild(recent) - conditional_timeout(recent_container_id, 5, show_recent) + if interface_data.username and interface_data.recently_read_by_user and interface_data.recently_read_by_user.length > 0: + show_recent_for_user(recent_container_id, interface_data) + else: + conditional_timeout(recent_container_id, 5, show_recent) # Choose library container.appendChild(E.h2(_('Choose the calibre library to browse…'))) diff --git a/src/pyj/session.pyj b/src/pyj/session.pyj index 0c1d9cd29e..f53f8d99d5 100644 --- a/src/pyj/session.pyj +++ b/src/pyj/session.pyj @@ -243,6 +243,7 @@ default_interface_data = { 'custom_list_template': None, 'num_per_page': 50, 'lang_code_for_user_manual': '', + 'recently_read_by_user': v'[]', } def get_interface_data():