From 4547c328bc9c3e61def8fd36e901d281fb9d7caa Mon Sep 17 00:00:00 2001 From: Michael Shavit Date: Sat, 25 Jan 2020 23:54:12 -0500 Subject: [PATCH] Delete/Restore book from Kobo device upon (un)archiving of a book in the web UI. --- cps/kobo.py | 35 ++++++++++++++++++++++++----------- cps/services/SyncToken.py | 24 +++++++++++++++++------- cps/ub.py | 1 + cps/web.py | 5 ++--- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/cps/kobo.py b/cps/kobo.py index f1533580..3c19b66e 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -39,6 +39,7 @@ from flask import ( from flask_login import login_required, current_user from werkzeug.datastructures import Headers from sqlalchemy import func +from sqlalchemy.sql.expression import or_ import requests from . import config, logger, kobo_auth, db, helper, ub @@ -119,10 +120,23 @@ def HandleSyncRequest(): archived_books = ( ub.session.query(ub.ArchivedBook) .filter(ub.ArchivedBook.user_id == int(current_user.id)) - .filter(ub.ArchivedBook.is_archived == True) .all() ) - archived_book_ids = [archived_book.book_id for archived_book in archived_books] + + # We join-in books that have had their Archived bit recently modified in order to either: + # * Restore them to the user's device. + # * Delete them from the user's device. + # (Ideally we would use a join for this logic, however cross-database joins don't look trivial in SqlAlchemy.) + recently_restored_or_archived_books = [] + archived_book_ids = {} + new_archived_last_modified = datetime.min + for archived_book in archived_books: + if archived_book.last_modified > sync_token.archive_last_modified: + recently_restored_or_archived_books.append(archived_book.book_id) + if archived_book.is_archived: + archived_book_ids[archived_book.book_id] = True + new_archived_last_modified = max( + new_archived_last_modified, archived_book.last_modified) # sqlite gives unexpected results when performing the last_modified comparison without the datetime cast. # It looks like it's treating the db.Books.last_modified field as a string and may fail @@ -130,14 +144,14 @@ def HandleSyncRequest(): changed_entries = ( db.session.query(db.Books) .join(db.Data) - .filter(func.datetime(db.Books.last_modified) > sync_token.books_last_modified) + .filter(or_(func.datetime(db.Books.last_modified) > sync_token.books_last_modified, + db.Books.id.in_(recently_restored_or_archived_books))) .filter(db.Data.format.in_(KOBO_FORMATS)) - .filter(db.Books.id.notin_(archived_book_ids)) .all() ) for book in changed_entries: entitlement = { - "BookEntitlement": create_book_entitlement(book), + "BookEntitlement": create_book_entitlement(book, archived=(book.id in archived_book_ids)), "BookMetadata": get_metadata(book), "ReadingState": reading_state(book), } @@ -153,8 +167,7 @@ def HandleSyncRequest(): sync_token.books_last_created = new_books_last_created sync_token.books_last_modified = new_books_last_modified - - # Missing feature: Detect server-side book deletions. + sync_token.archive_last_modified = new_archived_last_modified return generate_sync_response(request, sync_token, entitlements) @@ -216,7 +229,7 @@ def get_download_url_for_book(book, book_format): ) -def create_book_entitlement(book): +def create_book_entitlement(book, archived): book_uuid = book.uuid return { "Accessibility": "Full", @@ -224,10 +237,9 @@ def create_book_entitlement(book): "Created": book.timestamp, "CrossRevisionId": book_uuid, "Id": book_uuid, + "IsRemoved": archived, "IsHiddenFromArchive": False, "IsLocked": False, - # Setting this to true removes from the device. - "IsRemoved": False, "LastModified": book.last_modified, "OriginCategory": "Imported", "RevisionId": book_uuid, @@ -370,8 +382,9 @@ def HandleBookDeletionRequest(book_uuid): ) if not archived_book: archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id) - archived_book.book_id = book_id archived_book.is_archived = True + archived_book.last_modified = datetime.utcnow() + ub.session.merge(archived_book) ub.session.commit() diff --git a/cps/services/SyncToken.py b/cps/services/SyncToken.py index 21f16acc..1a9b1843 100644 --- a/cps/services/SyncToken.py +++ b/cps/services/SyncToken.py @@ -42,6 +42,13 @@ def to_epoch_timestamp(datetime_object): return (datetime_object - datetime(1970, 1, 1)).total_seconds() +def get_datetime_from_json(json_object, field_name): + try: + return datetime.utcfromtimestamp(json_object[field_name]) + except KeyError: + return datetime.min + + class SyncToken(): """ The SyncToken is used to persist state accross requests. When serialized over the response headers, the Kobo device will propagate the token onto following requests to the service. @@ -53,7 +60,8 @@ class SyncToken(): """ SYNC_TOKEN_HEADER = "x-kobo-synctoken" - VERSION = "1-0-0" + VERSION = "1-1-0" + LAST_MODIFIED_ADDED_VERSION = "1-1-0" MIN_VERSION = "1-0-0" token_schema = { @@ -68,6 +76,7 @@ class SyncToken(): "raw_kobo_store_token": {"type": "string"}, "books_last_modified": {"type": "string"}, "books_last_created": {"type": "string"}, + "archive_last_modified": {"type": "string"}, }, } @@ -76,10 +85,12 @@ class SyncToken(): raw_kobo_store_token="", books_last_created=datetime.min, books_last_modified=datetime.min, + archive_last_modified=datetime.min, ): self.raw_kobo_store_token = raw_kobo_store_token self.books_last_created = books_last_created self.books_last_modified = books_last_modified + self.archive_last_modified = archive_last_modified @staticmethod def from_headers(headers): @@ -109,12 +120,9 @@ class SyncToken(): raw_kobo_store_token = data_json["raw_kobo_store_token"] try: - books_last_modified = datetime.utcfromtimestamp( - data_json["books_last_modified"] - ) - books_last_created = datetime.utcfromtimestamp( - data_json["books_last_created"] - ) + books_last_modified = get_datetime_from_json(data_json, "books_last_modified") + books_last_created = get_datetime_from_json(data_json, "books_last_created") + archive_last_modified = get_datetime_from_json(data_json, "archive_last_modified") except TypeError: log.error("SyncToken timestamps don't parse to a datetime.") return SyncToken(raw_kobo_store_token=raw_kobo_store_token) @@ -123,6 +131,7 @@ class SyncToken(): raw_kobo_store_token=raw_kobo_store_token, books_last_created=books_last_created, books_last_modified=books_last_modified, + archive_last_modified=archive_last_modified ) def set_kobo_store_header(self, store_headers): @@ -143,6 +152,7 @@ class SyncToken(): "raw_kobo_store_token": self.raw_kobo_store_token, "books_last_modified": to_epoch_timestamp(self.books_last_modified), "books_last_created": to_epoch_timestamp(self.books_last_created), + "archive_last_modified": to_epoch_timestamp(self.archive_last_modified) }, } return b64encode_json(token) diff --git a/cps/ub.py b/cps/ub.py index 62ba82af..c1b92fb6 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -311,6 +311,7 @@ class ArchivedBook(Base): user_id = Column(Integer, ForeignKey('user.id')) book_id = Column(Integer) is_archived = Column(Boolean, unique=False) + last_modified = Column(DateTime, default=datetime.datetime.utcnow) # Baseclass representing Downloads from calibre-web in app.db diff --git a/cps/web.py b/cps/web.py index d01b4e1a..87dfd775 100644 --- a/cps/web.py +++ b/cps/web.py @@ -349,10 +349,9 @@ def toggle_archived(book_id): ub.ArchivedBook.book_id == book_id)).first() if archived_book: archived_book.is_archived = not archived_book.is_archived + archived_book.last_modified = datetime.datetime.utcnow() else: - archived_book = ub.ArchivedBook() - archived_book.user_id = int(current_user.id) - archived_book.book_id = book_id + archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id) archived_book.is_archived = True ub.session.merge(archived_book) ub.session.commit()