Delete/Restore book from Kobo device upon (un)archiving of a book in the web UI.

This commit is contained in:
Michael Shavit 2020-01-25 23:54:12 -05:00
parent 5027aeb3a0
commit 4547c328bc
4 changed files with 44 additions and 21 deletions

View File

@ -39,6 +39,7 @@ from flask import (
from flask_login import login_required, current_user from flask_login import login_required, current_user
from werkzeug.datastructures import Headers from werkzeug.datastructures import Headers
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.sql.expression import or_
import requests import requests
from . import config, logger, kobo_auth, db, helper, ub from . import config, logger, kobo_auth, db, helper, ub
@ -119,10 +120,23 @@ def HandleSyncRequest():
archived_books = ( archived_books = (
ub.session.query(ub.ArchivedBook) ub.session.query(ub.ArchivedBook)
.filter(ub.ArchivedBook.user_id == int(current_user.id)) .filter(ub.ArchivedBook.user_id == int(current_user.id))
.filter(ub.ArchivedBook.is_archived == True)
.all() .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. # 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 # 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 = ( changed_entries = (
db.session.query(db.Books) db.session.query(db.Books)
.join(db.Data) .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.Data.format.in_(KOBO_FORMATS))
.filter(db.Books.id.notin_(archived_book_ids))
.all() .all()
) )
for book in changed_entries: for book in changed_entries:
entitlement = { entitlement = {
"BookEntitlement": create_book_entitlement(book), "BookEntitlement": create_book_entitlement(book, archived=(book.id in archived_book_ids)),
"BookMetadata": get_metadata(book), "BookMetadata": get_metadata(book),
"ReadingState": reading_state(book), "ReadingState": reading_state(book),
} }
@ -153,8 +167,7 @@ def HandleSyncRequest():
sync_token.books_last_created = new_books_last_created sync_token.books_last_created = new_books_last_created
sync_token.books_last_modified = new_books_last_modified sync_token.books_last_modified = new_books_last_modified
sync_token.archive_last_modified = new_archived_last_modified
# Missing feature: Detect server-side book deletions.
return generate_sync_response(request, sync_token, entitlements) 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 book_uuid = book.uuid
return { return {
"Accessibility": "Full", "Accessibility": "Full",
@ -224,10 +237,9 @@ def create_book_entitlement(book):
"Created": book.timestamp, "Created": book.timestamp,
"CrossRevisionId": book_uuid, "CrossRevisionId": book_uuid,
"Id": book_uuid, "Id": book_uuid,
"IsRemoved": archived,
"IsHiddenFromArchive": False, "IsHiddenFromArchive": False,
"IsLocked": False, "IsLocked": False,
# Setting this to true removes from the device.
"IsRemoved": False,
"LastModified": book.last_modified, "LastModified": book.last_modified,
"OriginCategory": "Imported", "OriginCategory": "Imported",
"RevisionId": book_uuid, "RevisionId": book_uuid,
@ -370,8 +382,9 @@ def HandleBookDeletionRequest(book_uuid):
) )
if not archived_book: if not archived_book:
archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id) 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.is_archived = True
archived_book.last_modified = datetime.utcnow()
ub.session.merge(archived_book) ub.session.merge(archived_book)
ub.session.commit() ub.session.commit()

View File

@ -42,6 +42,13 @@ def to_epoch_timestamp(datetime_object):
return (datetime_object - datetime(1970, 1, 1)).total_seconds() 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(): class SyncToken():
""" The SyncToken is used to persist state accross requests. """ 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. 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" 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" MIN_VERSION = "1-0-0"
token_schema = { token_schema = {
@ -68,6 +76,7 @@ class SyncToken():
"raw_kobo_store_token": {"type": "string"}, "raw_kobo_store_token": {"type": "string"},
"books_last_modified": {"type": "string"}, "books_last_modified": {"type": "string"},
"books_last_created": {"type": "string"}, "books_last_created": {"type": "string"},
"archive_last_modified": {"type": "string"},
}, },
} }
@ -76,10 +85,12 @@ class SyncToken():
raw_kobo_store_token="", raw_kobo_store_token="",
books_last_created=datetime.min, books_last_created=datetime.min,
books_last_modified=datetime.min, books_last_modified=datetime.min,
archive_last_modified=datetime.min,
): ):
self.raw_kobo_store_token = raw_kobo_store_token self.raw_kobo_store_token = raw_kobo_store_token
self.books_last_created = books_last_created self.books_last_created = books_last_created
self.books_last_modified = books_last_modified self.books_last_modified = books_last_modified
self.archive_last_modified = archive_last_modified
@staticmethod @staticmethod
def from_headers(headers): def from_headers(headers):
@ -109,12 +120,9 @@ class SyncToken():
raw_kobo_store_token = data_json["raw_kobo_store_token"] raw_kobo_store_token = data_json["raw_kobo_store_token"]
try: try:
books_last_modified = datetime.utcfromtimestamp( books_last_modified = get_datetime_from_json(data_json, "books_last_modified")
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")
books_last_created = datetime.utcfromtimestamp(
data_json["books_last_created"]
)
except TypeError: except TypeError:
log.error("SyncToken timestamps don't parse to a datetime.") log.error("SyncToken timestamps don't parse to a datetime.")
return SyncToken(raw_kobo_store_token=raw_kobo_store_token) return SyncToken(raw_kobo_store_token=raw_kobo_store_token)
@ -123,6 +131,7 @@ class SyncToken():
raw_kobo_store_token=raw_kobo_store_token, raw_kobo_store_token=raw_kobo_store_token,
books_last_created=books_last_created, books_last_created=books_last_created,
books_last_modified=books_last_modified, books_last_modified=books_last_modified,
archive_last_modified=archive_last_modified
) )
def set_kobo_store_header(self, store_headers): def set_kobo_store_header(self, store_headers):
@ -143,6 +152,7 @@ class SyncToken():
"raw_kobo_store_token": self.raw_kobo_store_token, "raw_kobo_store_token": self.raw_kobo_store_token,
"books_last_modified": to_epoch_timestamp(self.books_last_modified), "books_last_modified": to_epoch_timestamp(self.books_last_modified),
"books_last_created": to_epoch_timestamp(self.books_last_created), "books_last_created": to_epoch_timestamp(self.books_last_created),
"archive_last_modified": to_epoch_timestamp(self.archive_last_modified)
}, },
} }
return b64encode_json(token) return b64encode_json(token)

View File

@ -311,6 +311,7 @@ class ArchivedBook(Base):
user_id = Column(Integer, ForeignKey('user.id')) user_id = Column(Integer, ForeignKey('user.id'))
book_id = Column(Integer) book_id = Column(Integer)
is_archived = Column(Boolean, unique=False) is_archived = Column(Boolean, unique=False)
last_modified = Column(DateTime, default=datetime.datetime.utcnow)
# Baseclass representing Downloads from calibre-web in app.db # Baseclass representing Downloads from calibre-web in app.db

View File

@ -349,10 +349,9 @@ def toggle_archived(book_id):
ub.ArchivedBook.book_id == book_id)).first() ub.ArchivedBook.book_id == book_id)).first()
if archived_book: if archived_book:
archived_book.is_archived = not archived_book.is_archived archived_book.is_archived = not archived_book.is_archived
archived_book.last_modified = datetime.datetime.utcnow()
else: else:
archived_book = ub.ArchivedBook() archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
archived_book.user_id = int(current_user.id)
archived_book.book_id = book_id
archived_book.is_archived = True archived_book.is_archived = True
ub.session.merge(archived_book) ub.session.merge(archived_book)
ub.session.commit() ub.session.commit()