Delete/Restore book from Kobo device upon (un)archiving of a book in the web UI.
This commit is contained in:
parent
5027aeb3a0
commit
4547c328bc
35
cps/kobo.py
35
cps/kobo.py
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in New Issue
Block a user