Commit d597e05f authored by Ozzieisaacs's avatar Ozzieisaacs

Merge remote-tracking branch 'reading_state' into Develop

# Conflicts:
#	cps/kobo.py
parents 98dc9913 8e1641da
This diff is collapsed.
...@@ -42,6 +42,13 @@ def to_epoch_timestamp(datetime_object): ...@@ -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(): ...@@ -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(): ...@@ -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"},
"reading_state_last_modified": {"type": "string"},
}, },
} }
...@@ -76,10 +85,13 @@ class SyncToken(): ...@@ -76,10 +85,13 @@ 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,
reading_state_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.reading_state_last_modified = reading_state_last_modified
@staticmethod @staticmethod
def from_headers(headers): def from_headers(headers):
...@@ -109,12 +121,9 @@ class SyncToken(): ...@@ -109,12 +121,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")
) reading_state_last_modified = get_datetime_from_json(data_json, "reading_state_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 +132,7 @@ class SyncToken(): ...@@ -123,6 +132,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,
reading_state_last_modified=reading_state_last_modified
) )
def set_kobo_store_header(self, store_headers): def set_kobo_store_header(self, store_headers):
...@@ -143,6 +153,7 @@ class SyncToken(): ...@@ -143,6 +153,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),
"reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified)
}, },
} }
return b64encode_json(token) return b64encode_json(token)
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
from __future__ import division, print_function, unicode_literals from __future__ import division, print_function, unicode_literals
import os import os
import datetime import datetime
import itertools
from binascii import hexlify from binascii import hexlify
from flask import g from flask import g
...@@ -31,11 +32,12 @@ try: ...@@ -31,11 +32,12 @@ try:
oauth_support = True oauth_support = True
except ImportError: except ImportError:
oauth_support = False oauth_support = False
from sqlalchemy import create_engine, exc, exists from sqlalchemy import create_engine, exc, exists, event
from sqlalchemy import Column, ForeignKey from sqlalchemy import Column, ForeignKey
from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float
from sqlalchemy.orm import relationship, sessionmaker from sqlalchemy.orm import backref, foreign, relationship, remote, sessionmaker, Session
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.expression import and_
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from . import constants # , config from . import constants # , config
...@@ -284,10 +286,19 @@ class BookShelf(Base): ...@@ -284,10 +286,19 @@ class BookShelf(Base):
class ReadBook(Base): class ReadBook(Base):
__tablename__ = 'book_read_link' __tablename__ = 'book_read_link'
STATUS_UNREAD = 0
STATUS_FINISHED = 1
STATUS_IN_PROGRESS = 2
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
book_id = Column(Integer, unique=False) book_id = Column(Integer, unique=False)
user_id = Column(Integer, ForeignKey('user.id'), unique=False) user_id = Column(Integer, ForeignKey('user.id'), unique=False)
is_read = Column(Boolean, unique=False) read_status = Column(Integer, unique=False, default=STATUS_UNREAD, nullable=False)
kobo_reading_state = relationship("KoboReadingState", uselist=False, primaryjoin="and_(ReadBook.user_id == foreign(KoboReadingState.user_id), "
"ReadBook.book_id == foreign(KoboReadingState.book_id))", cascade="all", backref=backref("book_read_link", uselist=False))
last_modified = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
last_time_started_reading = Column(DateTime, nullable=True)
times_started_reading = Column(Integer, default=0, nullable=False)
class Bookmark(Base): class Bookmark(Base):
...@@ -300,6 +311,54 @@ class Bookmark(Base): ...@@ -300,6 +311,54 @@ class Bookmark(Base):
bookmark_key = Column(String) bookmark_key = Column(String)
# The Kobo ReadingState API keeps track of 4 timestamped entities:
# ReadingState, StatusInfo, Statistics, CurrentBookmark
# Which we map to the following 4 tables:
# KoboReadingState, ReadBook, KoboStatistics and KoboBookmark
class KoboReadingState(Base):
__tablename__ = 'kobo_reading_state'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('user.id'))
book_id = Column(Integer)
last_modified = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
priority_timestamp = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
current_bookmark = relationship("KoboBookmark", uselist=False, backref="kobo_reading_state", cascade="all")
statistics = relationship("KoboStatistics", uselist=False, backref="kobo_reading_state", cascade="all")
class KoboBookmark(Base):
__tablename__ = 'kobo_bookmark'
id = Column(Integer, primary_key=True)
kobo_reading_state_id = Column(Integer, ForeignKey('kobo_reading_state.id'))
last_modified = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
location_source = Column(String)
location_type = Column(String)
location_value = Column(String)
progress_percent = Column(Float)
content_source_progress_percent = Column(Float)
class KoboStatistics(Base):
__tablename__ = 'kobo_statistics'
id = Column(Integer, primary_key=True)
kobo_reading_state_id = Column(Integer, ForeignKey('kobo_reading_state.id'))
last_modified = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
remaining_time_minutes = Column(Integer)
spent_reading_minutes = Column(Integer)
# Updates the last_modified timestamp in the KoboReadingState table if any of its children tables are modified.
@event.listens_for(Session, 'before_flush')
def receive_before_flush(session, flush_context, instances):
for change in itertools.chain(session.new, session.dirty):
if isinstance(change, (ReadBook, KoboStatistics, KoboBookmark)):
if change.kobo_reading_state:
change.kobo_reading_state.last_modified = datetime.datetime.utcnow()
# Baseclass representing Downloads from calibre-web in app.db # Baseclass representing Downloads from calibre-web in app.db
class Downloads(Base): class Downloads(Base):
__tablename__ = 'downloads' __tablename__ = 'downloads'
...@@ -352,6 +411,12 @@ def migrate_Database(session): ...@@ -352,6 +411,12 @@ def migrate_Database(session):
ReadBook.__table__.create(bind=engine) ReadBook.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "bookmark"): if not engine.dialect.has_table(engine.connect(), "bookmark"):
Bookmark.__table__.create(bind=engine) Bookmark.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "kobo_reading_state"):
KoboReadingState.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "kobo_bookmark"):
KoboBookmark.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "kobo_statistics"):
KoboStatistics.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "registration"): if not engine.dialect.has_table(engine.connect(), "registration"):
ReadBook.__table__.create(bind=engine) ReadBook.__table__.create(bind=engine)
conn = engine.connect() conn = engine.connect()
...@@ -373,6 +438,16 @@ def migrate_Database(session): ...@@ -373,6 +438,16 @@ def migrate_Database(session):
conn.execute("ALTER TABLE remote_auth_token ADD column 'token_type' INTEGER DEFAULT 0") conn.execute("ALTER TABLE remote_auth_token ADD column 'token_type' INTEGER DEFAULT 0")
conn.execute("update remote_auth_token set 'token_type' = 0") conn.execute("update remote_auth_token set 'token_type' = 0")
session.commit() session.commit()
try:
session.query(exists().where(ReadBook.read_status)).scalar()
except exc.OperationalError:
conn = engine.connect()
conn.execute("ALTER TABLE book_read_link ADD column 'read_status' INTEGER DEFAULT 0")
conn.execute("UPDATE book_read_link SET 'read_status' = 1 WHERE is_read")
conn.execute("ALTER TABLE book_read_link ADD column 'last_modified' DATETIME")
conn.execute("ALTER TABLE book_read_link ADD column 'last_time_started_reading' DATETIME")
conn.execute("ALTER TABLE book_read_link ADD column 'times_started_reading' INTEGER DEFAULT 0")
session.commit()
# Handle table exists, but no content # Handle table exists, but no content
cnt = session.query(Registration).count() cnt = session.query(Registration).count()
......
...@@ -314,13 +314,19 @@ def toggle_read(book_id): ...@@ -314,13 +314,19 @@ def toggle_read(book_id):
book = ub.session.query(ub.ReadBook).filter(and_(ub.ReadBook.user_id == int(current_user.id), book = ub.session.query(ub.ReadBook).filter(and_(ub.ReadBook.user_id == int(current_user.id),
ub.ReadBook.book_id == book_id)).first() ub.ReadBook.book_id == book_id)).first()
if book: if book:
book.is_read = not book.is_read if book.read_status == ub.ReadBook.STATUS_FINISHED:
book.read_status = ub.ReadBook.STATUS_UNREAD
else:
book.read_status = ub.ReadBook.STATUS_FINISHED
else: else:
readBook = ub.ReadBook() readBook = ub.ReadBook(user_id=current_user.id, book_id = book_id)
readBook.user_id = int(current_user.id) readBook.read_status = ub.ReadBook.STATUS_FINISHED
readBook.book_id = book_id
readBook.is_read = True
book = readBook book = readBook
if not book.kobo_reading_state:
kobo_reading_state = ub.KoboReadingState(user_id=current_user.id, book_id=book_id)
kobo_reading_state.current_bookmark = ub.KoboBookmark()
kobo_reading_state.statistics = ub.KoboStatistics()
book.kobo_reading_state = kobo_reading_state
ub.session.merge(book) ub.session.merge(book)
ub.session.commit() ub.session.commit()
else: else:
...@@ -980,7 +986,7 @@ def render_read_books(page, are_read, as_xml=False, order=None, *args, **kwargs) ...@@ -980,7 +986,7 @@ def render_read_books(page, are_read, as_xml=False, order=None, *args, **kwargs)
order = order or [] order = order or []
if not config.config_read_column: if not config.config_read_column:
readBooks = ub.session.query(ub.ReadBook).filter(ub.ReadBook.user_id == int(current_user.id))\ readBooks = ub.session.query(ub.ReadBook).filter(ub.ReadBook.user_id == int(current_user.id))\
.filter(ub.ReadBook.is_read == True).all() .filter(ub.ReadBook.read_status == ub.ReadBook.STATUS_FINISHED).all()
readBookIds = [x.book_id for x in readBooks] readBookIds = [x.book_id for x in readBooks]
else: else:
try: try:
...@@ -1448,7 +1454,8 @@ def show_book(book_id): ...@@ -1448,7 +1454,8 @@ def show_book(book_id):
if not config.config_read_column: if not config.config_read_column:
matching_have_read_book = ub.session.query(ub.ReadBook).\ matching_have_read_book = ub.session.query(ub.ReadBook).\
filter(and_(ub.ReadBook.user_id == int(current_user.id), ub.ReadBook.book_id == book_id)).all() filter(and_(ub.ReadBook.user_id == int(current_user.id), ub.ReadBook.book_id == book_id)).all()
have_read = len(matching_have_read_book) > 0 and matching_have_read_book[0].is_read have_read = len(
matching_have_read_book) > 0 and matching_have_read_book[0].read_status == ub.ReadBook.STATUS_FINISHED
else: else:
try: try:
matching_have_read_book = getattr(entries, 'custom_column_'+str(config.config_read_column)) matching_have_read_book = getattr(entries, 'custom_column_'+str(config.config_read_column))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment