@ -17,9 +17,11 @@
# You should have received a copy of the GNU General Public License
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import datetime
import datetime
import itertools
import json
import sys
import sys
import base64
import os
import os
import uuid
import uuid
from time import gmtime , strftime
from time import gmtime , strftime
@ -45,7 +47,7 @@ from sqlalchemy import func
from sqlalchemy . sql . expression import and_ , or_
from sqlalchemy . sql . expression import and_ , or_
import requests
import requests
from . import config , logger , kobo_auth , db , helper , ub
from . import config , logger , kobo_auth , db , helper , shelf as shelf_lib , ub
from . services import SyncToken as SyncToken
from . services import SyncToken as SyncToken
from . web import download_required
from . web import download_required
from . kobo_auth import requires_kobo_auth
from . kobo_auth import requires_kobo_auth
@ -120,6 +122,7 @@ def redirect_or_proxy_request():
def convert_to_kobo_timestamp_string ( timestamp ) :
def convert_to_kobo_timestamp_string ( timestamp ) :
return timestamp . strftime ( " % Y- % m- %d T % H: % M: % SZ " )
return timestamp . strftime ( " % Y- % m- %d T % H: % M: % SZ " )
@kobo.route ( " /v1/library/sync " )
@kobo.route ( " /v1/library/sync " )
@requires_kobo_auth
@requires_kobo_auth
@download_required
@download_required
@ -203,7 +206,8 @@ def HandleSyncRequest():
ub . KoboReadingState . user_id == current_user . id ,
ub . KoboReadingState . user_id == current_user . id ,
ub . KoboReadingState . book_id . notin_ ( reading_states_in_new_entitlements ) ) ) )
ub . KoboReadingState . book_id . notin_ ( reading_states_in_new_entitlements ) ) ) )
for kobo_reading_state in changed_reading_states . all ( ) :
for kobo_reading_state in changed_reading_states . all ( ) :
book = db . session . query ( db . Books ) . filter ( db . Books . id == kobo_reading_state . book_id ) . one ( )
book = db . session . query ( db . Books ) . filter ( db . Books . id == kobo_reading_state . book_id ) . one_or_none ( )
if book :
sync_results . append ( {
sync_results . append ( {
" ChangedReadingState " : {
" ChangedReadingState " : {
" ReadingState " : get_kobo_reading_state_response ( book , kobo_reading_state )
" ReadingState " : get_kobo_reading_state_response ( book , kobo_reading_state )
@ -211,17 +215,15 @@ def HandleSyncRequest():
} )
} )
new_reading_state_last_modified = max ( new_reading_state_last_modified , kobo_reading_state . last_modified )
new_reading_state_last_modified = max ( new_reading_state_last_modified , kobo_reading_state . last_modified )
sync_shelves ( sync_token , sync_results )
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
sync_token . archive_last_modified = new_archived_last_modified
sync_token . reading_state_last_modified = new_reading_state_last_modified
sync_token . reading_state_last_modified = new_reading_state_last_modified
if config . config_kobo_proxy :
return generate_sync_response ( request , sync_token , sync_results )
return generate_sync_response ( request , sync_token , sync_results )
return make_response ( jsonify ( sync_results ) )
# Missing feature: Detect server-side book deletions.
def generate_sync_response ( request , sync_token , sync_results ) :
def generate_sync_response ( request , sync_token , sync_results ) :
extra_headers = { }
extra_headers = { }
@ -392,6 +394,222 @@ def get_metadata(book):
return metadata
return metadata
@kobo.route ( " /v1/library/tags " , methods = [ " POST " ] )
@login_required
# Creates a Shelf with the given items, and returns the shelf's uuid.
def HandleTagCreate ( ) :
shelf_request = request . json
name , items = None , None
try :
name = shelf_request [ " Name " ]
items = shelf_request [ " Items " ]
except KeyError :
log . debug ( " Received malformed v1/library/tags request. " )
abort ( 400 , description = " Malformed tags POST request. Data is missing ' Name ' or ' Items ' field " )
shelf = ub . session . query ( ub . Shelf ) . filter ( and_ ( ub . Shelf . name ) == name , ub . Shelf . user_id ==
current_user . id ) . one_or_none ( )
if shelf and not shelf_lib . check_shelf_edit_permissions ( shelf ) :
abort ( 401 , description = " User is unauthaurized to edit shelf. " )
if not shelf :
shelf = ub . Shelf ( user_id = current_user . id , name = name , uuid = uuid . uuid4 ( ) )
ub . session . add ( shelf )
items_unknown_to_calibre = add_items_to_shelf ( items , shelf )
if items_unknown_to_calibre :
log . debug ( " Received request to add unknown books to a collection. Silently ignoring items. " )
ub . session . commit ( )
return make_response ( jsonify ( str ( shelf . uuid ) ) , 201 )
@kobo.route ( " /v1/library/tags/<tag_id> " , methods = [ " DELETE " , " PUT " ] )
def HandleTagUpdate ( tag_id ) :
shelf = ub . session . query ( ub . Shelf ) . filter ( and_ ( ub . Shelf . uuid ) == tag_id ,
ub . Shelf . user_id == current_user . id ) . one_or_none ( )
if not shelf :
log . debug ( " Received Kobo tag update request on a collection unknown to CalibreWeb " )
if config . config_kobo_proxy :
return redirect_or_proxy_request ( )
else :
abort ( 404 , description = " Collection isn ' t known to CalibreWeb " )
if not shelf_lib . check_shelf_edit_permissions ( shelf ) :
abort ( 401 , description = " User is unauthaurized to edit shelf. " )
if request . method == " DELETE " :
shelf_lib . delete_shelf_helper ( shelf )
else :
shelf_request = request . json
name = None
try :
name = shelf_request [ " Name " ]
except KeyError :
log . debug ( " Received malformed v1/library/tags rename request. " )
abort ( 400 , description = " Malformed tags POST request. Data is missing ' Name ' field " )
shelf . name = name
ub . session . merge ( shelf )
ub . session . commit ( )
return make_response ( ' ' , 200 )
# Adds items to the given shelf.
def add_items_to_shelf ( items , shelf ) :
book_ids_already_in_shelf = set ( [ book_shelf . book_id for book_shelf in shelf . books ] )
items_unknown_to_calibre = [ ]
for item in items :
if item [ " Type " ] != " ProductRevisionTagItem " :
items_unknown_to_calibre . append ( item )
continue
book = db . session . query ( db . Books ) . filter ( db . Books . uuid == item [ " RevisionId " ] ) . one_or_none ( )
if not book :
items_unknown_to_calibre . append ( item )
continue
book_id = book . id
if book_id not in book_ids_already_in_shelf :
shelf . books . append ( ub . BookShelf ( book_id = book_id ) )
return items_unknown_to_calibre
@kobo.route ( " /v1/library/tags/<tag_id>/items " , methods = [ " POST " ] )
@login_required
def HandleTagAddItem ( tag_id ) :
tag_request = request . json
items = None
try :
items = tag_request [ " Items " ]
except KeyError :
log . debug ( " Received malformed v1/library/tags/<tag_id>/items/delete request. " )
abort ( 400 , description = " Malformed tags POST request. Data is missing ' Items ' field " )
shelf = ub . session . query ( ub . Shelf ) . filter ( and_ ( ub . Shelf . uuid ) == tag_id ,
ub . Shelf . user_id == current_user . id ) . one_or_none ( )
if not shelf :
log . debug ( " Received Kobo request on a collection unknown to CalibreWeb " )
abort ( 404 , description = " Collection isn ' t known to CalibreWeb " )
if not shelf_lib . check_shelf_edit_permissions ( shelf ) :
abort ( 401 , description = " User is unauthaurized to edit shelf. " )
items_unknown_to_calibre = add_items_to_shelf ( items , shelf )
if items_unknown_to_calibre :
log . debug ( " Received request to add an unknown book to a collecition. Silently ignoring item. " )
ub . session . merge ( shelf )
ub . session . commit ( )
return make_response ( ' ' , 201 )
@kobo.route ( " /v1/library/tags/<tag_id>/items/delete " , methods = [ " POST " ] )
@login_required
def HandleTagRemoveItem ( tag_id ) :
tag_request = request . json
items = None
try :
items = tag_request [ " Items " ]
except KeyError :
log . debug ( " Received malformed v1/library/tags/<tag_id>/items/delete request. " )
abort ( 400 , description = " Malformed tags POST request. Data is missing ' Items ' field " )
shelf = ub . session . query ( ub . Shelf ) . filter ( ub . Shelf . uuid == tag_id ,
ub . Shelf . user_id == current_user . id ) . one_or_none ( )
if not shelf :
log . debug (
" Received a request to remove an item from a Collection unknown to CalibreWeb. " )
abort ( 404 , description = " Collection isn ' t known to CalibreWeb " )
if not shelf_lib . check_shelf_edit_permissions ( shelf ) :
abort ( 401 , description = " User is unauthaurized to edit shelf. " )
items_unknown_to_calibre = [ ]
for item in items :
if item [ " Type " ] != " ProductRevisionTagItem " :
items_unknown_to_calibre . append ( item )
continue
book = db . session . query ( db . Books ) . filter ( db . Books . uuid == item [ " RevisionId " ] ) . one_or_none ( )
if not book :
items_unknown_to_calibre . append ( item )
continue
shelf . books . filter ( ub . BookShelf . book_id == book . id ) . delete ( )
ub . session . commit ( )
if items_unknown_to_calibre :
log . debug ( " Received request to remove an unknown book to a collecition. Silently ignoring item. " )
return make_response ( ' ' , 200 )
# Add new, changed, or deleted shelves to the sync_results.
# Note: Public shelves that aren't owned by the user aren't supported.
def sync_shelves ( sync_token , sync_results ) :
new_tags_last_modified = sync_token . tags_last_modified
for shelf in ub . session . query ( ub . ShelfArchive ) . filter ( func . datetime ( ub . ShelfArchive . last_modified ) > sync_token . tags_last_modified , ub . ShelfArchive . user_id == current_user . id ) :
new_tags_last_modified = max ( shelf . last_modified , new_tags_last_modified )
sync_results . append ( {
" DeletedTag " : {
" Tag " : {
" Id " : shelf . uuid ,
" LastModified " : convert_to_kobo_timestamp_string ( shelf . last_modified )
}
}
} )
for shelf in ub . session . query ( ub . Shelf ) . filter ( func . datetime ( ub . Shelf . last_modified ) > sync_token . tags_last_modified , ub . Shelf . user_id == current_user . id ) :
if not shelf_lib . check_shelf_view_permissions ( shelf ) :
continue
new_tags_last_modified = max ( shelf . last_modified , new_tags_last_modified )
tag = create_kobo_tag ( shelf )
if not tag :
continue
if shelf . created > sync_token . tags_last_modified :
sync_results . append ( {
" NewTag " : tag
} )
else :
sync_results . append ( {
" ChangedTag " : tag
} )
sync_token . tags_last_modified = new_tags_last_modified
ub . session . commit ( )
# Creates a Kobo "Tag" object from a ub.Shelf object
def create_kobo_tag ( shelf ) :
tag = {
" Created " : convert_to_kobo_timestamp_string ( shelf . created ) ,
" Id " : shelf . uuid ,
" Items " : [ ] ,
" LastModified " : convert_to_kobo_timestamp_string ( shelf . last_modified ) ,
" Name " : shelf . name ,
" Type " : " UserTag "
}
for book_shelf in shelf . books :
book = db . session . query ( db . Books ) . filter ( db . Books . id == book_shelf . book_id ) . one_or_none ( )
if not book :
log . info ( u " Book (id: %s ) in BookShelf (id: %s ) not found in book database " , book_shelf . book_id , shelf . id )
return None
tag [ " Items " ] . append (
{
" RevisionId " : book . uuid ,
" Type " : " ProductRevisionTagItem "
}
)
return { " Tag " : tag }
@kobo.route ( " /v1/library/<book_uuid>/state " , methods = [ " GET " , " PUT " ] )
@kobo.route ( " /v1/library/<book_uuid>/state " , methods = [ " GET " , " PUT " ] )
@login_required
@login_required
def HandleStateRequest ( book_uuid ) :
def HandleStateRequest ( book_uuid ) :
@ -589,10 +807,7 @@ def HandleBookDeletionRequest(book_uuid):
# TODO: Implement the following routes
# TODO: Implement the following routes
@kobo.route ( " /v1/library/<dummy> " , methods = [ " DELETE " , " GET " ] )
@kobo.route ( " /v1/library/<dummy> " , methods = [ " DELETE " , " GET " ] )
@kobo.route ( " /v1/library/tags " , methods = [ " POST " ] )
def HandleUnimplementedRequest ( dummy = None ) :
@kobo.route ( " /v1/library/tags/<shelf_name> " , methods = [ " POST " ] )
@kobo.route ( " /v1/library/tags/<tag_id> " , methods = [ " DELETE " ] )
def HandleUnimplementedRequest ( dummy = None , book_uuid = None , shelf_name = None , tag_id = None ) :
log . debug ( " Unimplemented Library Request received: %s " , request . base_url )
log . debug ( " Unimplemented Library Request received: %s " , request . base_url )
return redirect_or_proxy_request ( )
return redirect_or_proxy_request ( )
@ -612,6 +827,7 @@ def HandleUserRequest(dummy=None):
@kobo.route ( " /v1/products/<dummy>/recommendations " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/<dummy>/recommendations " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/<dummy>/nextread " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/<dummy>/nextread " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/<dummy>/reviews " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/<dummy>/reviews " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/books/series/<dummy> " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/books/<dummy> " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/books/<dummy> " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/dailydeal " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products/dailydeal " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products " , methods = [ " GET " , " POST " ] )
@kobo.route ( " /v1/products " , methods = [ " GET " , " POST " ] )
@ -624,7 +840,7 @@ def HandleProductsRequest(dummy=None):
def handle_404 ( err ) :
def handle_404 ( err ) :
# This handler acts as a catch-all for endpoints that we don't have an interest in
# This handler acts as a catch-all for endpoints that we don't have an interest in
# implementing (e.g: v1/analytics/gettests, v1/user/recommendations, etc)
# implementing (e.g: v1/analytics/gettests, v1/user/recommendations, etc)
log . debug ( " Unknown Request received: %s ", request . base_url )
log . debug ( " Unknown Request received: %s , method: %s , data: %s ", request . base_url , request . method , request . data )
return redirect_or_proxy_request ( )
return redirect_or_proxy_request ( )