diff --git a/cps/__init__.py b/cps/__init__.py
old mode 100755
new mode 100644
index 41523fb6..eee38fdd
--- a/cps/__init__.py
+++ b/cps/__init__.py
@@ -33,7 +33,7 @@ from flask_login import LoginManager
from flask_babel import Babel
from flask_principal import Principal
-from . import logger, cache_buster, cli, config_sql, ub, db, services
+from . import config_sql, logger, cache_buster, cli, ub, db
from .reverseproxy import ReverseProxied
from .server import WebServer
try:
@@ -65,7 +65,6 @@ lm = LoginManager()
lm.login_view = 'web.login'
lm.anonymous_user = ub.Anonymous
-
ub.init_db(cli.settingspath)
# pylint: disable=no-member
config = config_sql.load_configuration(ub.session)
@@ -78,11 +77,12 @@ _BABEL_TRANSLATIONS = set()
log = logger.create()
+from . import services
def create_app():
try:
app.wsgi_app = ReverseProxied(ProxyFix(app.wsgi_app, x_for=1, x_host=1))
- except ValueError:
+ except (ValueError, TypeError):
app.wsgi_app = ReverseProxied(ProxyFix(app.wsgi_app))
# For python2 convert path to unicode
if sys.version_info < (3, 0):
diff --git a/cps/admin.py b/cps/admin.py
index 2bfca96b..5cec8e26 100644
--- a/cps/admin.py
+++ b/cps/admin.py
@@ -817,6 +817,9 @@ def update_mailsettings():
@admin_required
def edit_user(user_id):
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
+ if not content:
+ flash(_(u"User not found"), category="error")
+ return redirect(url_for('admin.admin'))
downloads = list()
languages = speaking_language()
translations = babel.list_translations() + [LC('en')]
@@ -933,8 +936,6 @@ def edit_user(user_id):
@login_required
@admin_required
def reset_user_password(user_id):
- if not config.config_public_reg:
- abort(404)
if current_user is not None and current_user.is_authenticated:
ret, message = reset_password(user_id)
if ret == 1:
diff --git a/cps/cli.py b/cps/cli.py
index 8d410a90..c94cb89d 100644
--- a/cps/cli.py
+++ b/cps/cli.py
@@ -34,7 +34,7 @@ def version_info():
parser = argparse.ArgumentParser(description='Calibre Web is a web app'
- ' providing a interface for browsing, reading and downloading eBooks\n', prog='cps.py')
+ ' providing a interface for browsing, reading and downloading eBooks\n', prog='cps.py')
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
parser.add_argument('-c', metavar='path',
diff --git a/cps/config_sql.py b/cps/config_sql.py
index a6c82213..241e583a 100644
--- a/cps/config_sql.py
+++ b/cps/config_sql.py
@@ -37,8 +37,6 @@ _Base = declarative_base()
class _Settings(_Base):
__tablename__ = 'settings'
- # config_is_initial = Column(Boolean, default=True)
-
id = Column(Integer, primary_key=True)
mail_server = Column(String, default=constants.DEFAULT_MAIL_SERVER)
mail_port = Column(Integer, default=25)
diff --git a/cps/constants.py b/cps/constants.py
index 5fc026ee..586172f7 100644
--- a/cps/constants.py
+++ b/cps/constants.py
@@ -80,9 +80,10 @@ MATURE_CONTENT = 1 << 11
SIDEBAR_PUBLISHER = 1 << 12
SIDEBAR_RATING = 1 << 13
SIDEBAR_FORMAT = 1 << 14
+SIDEBAR_ARCHIVED = 1 << 15
-ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_EDIT_SHELFS & ~ROLE_ANONYMOUS
-ADMIN_USER_SIDEBAR = (SIDEBAR_FORMAT << 1) - 1
+ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS
+ADMIN_USER_SIDEBAR = (SIDEBAR_ARCHIVED << 1) - 1
UPDATE_STABLE = 0 << 0
AUTO_UPDATE_STABLE = 1 << 0
@@ -112,7 +113,7 @@ del env_CALIBRE_PORT
EXTENSIONS_AUDIO = {'mp3', 'm4a', 'm4b'}
EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt'}
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx',
- 'fb2', 'html', 'rtf', 'odt', 'mp3', 'm4a', 'm4b'}
+ 'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'm4a', 'm4b'}
# EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] +
# (['rar','cbr'] if feature_support['rar'] else []))
diff --git a/cps/db.py b/cps/db.py
index 8613d57b..e758dc0a 100755
--- a/cps/db.py
+++ b/cps/db.py
@@ -25,7 +25,7 @@ import ast
from sqlalchemy import create_engine
from sqlalchemy import Table, Column, ForeignKey
-from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
+from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float, DateTime
from sqlalchemy.orm import relationship, sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base
diff --git a/cps/editbooks.py b/cps/editbooks.py
index 81262760..d66763bf 100644
--- a/cps/editbooks.py
+++ b/cps/editbooks.py
@@ -22,7 +22,7 @@
from __future__ import division, print_function, unicode_literals
import os
-import datetime
+from datetime import datetime
import json
from shutil import move, copyfile
from uuid import uuid4
@@ -47,7 +47,7 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
# passing input_elements not as a list may lead to undesired results
if not isinstance(input_elements, list):
raise TypeError(str(input_elements) + " should be passed as a list")
-
+ changed = False
input_elements = [x for x in input_elements if x != '']
# we have all input element (authors, series, tags) names now
# 1. search for elements to remove
@@ -88,6 +88,7 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
if len(del_elements) > 0:
for del_element in del_elements:
db_book_object.remove(del_element)
+ changed = True
if len(del_element.books) == 0:
db_session.delete(del_element)
# if there are elements to add, we add them now!
@@ -114,37 +115,58 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
else: # db_type should be tag or language
new_element = db_object(add_element)
if db_element is None:
+ changed = True
db_session.add(new_element)
db_book_object.append(new_element)
else:
if db_type == 'custom':
if db_element.value != add_element:
new_element.value = add_element
- # new_element = db_element
elif db_type == 'languages':
if db_element.lang_code != add_element:
db_element.lang_code = add_element
- # new_element = db_element
elif db_type == 'series':
if db_element.name != add_element:
- db_element.name = add_element # = add_element # new_element = db_object(add_element, add_element)
+ db_element.name = add_element
db_element.sort = add_element
- # new_element = db_element
elif db_type == 'author':
if db_element.name != add_element:
db_element.name = add_element
db_element.sort = add_element.replace('|', ',')
- # new_element = db_element
elif db_type == 'publisher':
if db_element.name != add_element:
db_element.name = add_element
db_element.sort = None
- # new_element = db_element
elif db_element.name != add_element:
db_element.name = add_element
- # new_element = db_element
# add element to book
+ changed = True
db_book_object.append(db_element)
+ return changed
+
+
+def modify_identifiers(input_identifiers, db_identifiers, db_session):
+ """Modify Identifiers to match input information.
+ input_identifiers is a list of read-to-persist Identifiers objects.
+ db_identifiers is a list of already persisted list of Identifiers objects."""
+ changed = False
+ input_dict = dict([ (identifier.type.lower(), identifier) for identifier in input_identifiers ])
+ db_dict = dict([ (identifier.type.lower(), identifier) for identifier in db_identifiers ])
+ # delete db identifiers not present in input or modify them with input val
+ for identifier_type, identifier in db_dict.items():
+ if identifier_type not in input_dict.keys():
+ db_session.delete(identifier)
+ changed = True
+ else:
+ input_identifier = input_dict[identifier_type]
+ identifier.type = input_identifier.type
+ identifier.val = input_identifier.val
+ # add input identifiers not present in db
+ for identifier_type, identifier in input_dict.items():
+ if identifier_type not in db_dict.keys():
+ db_session.add(identifier)
+ changed = True
+ return changed
@editbook.route("/delete//", defaults={'book_format': ""})
@@ -155,7 +177,10 @@ def delete_book(book_id, book_format):
book = db.session.query(db.Books).filter(db.Books.id == book_id).first()
if book:
try:
- helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper())
+ result, error = helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper())
+ if not result:
+ flash(error, category="error")
+ return redirect(url_for('editbook.edit_book', book_id=book_id))
if not book_format:
# delete book from Shelfs, Downloads, Read list
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book_id).delete()
@@ -177,7 +202,7 @@ def delete_book(book_id, book_format):
cc_string = "custom_column_" + str(c.id)
if not c.is_multiple:
if len(getattr(book, cc_string)) > 0:
- if c.datatype == 'bool' or c.datatype == 'int' or c.datatype == 'float':
+ if c.datatype == 'bool' or c.datatype == 'integer' or c.datatype == 'float':
del_cc = getattr(book, cc_string)[0]
getattr(book, cc_string).remove(del_cc)
log.debug('remove ' + str(c.id))
@@ -211,8 +236,10 @@ def delete_book(book_id, book_format):
# book not found
log.error('Book with id "%s" could not be deleted: not found', book_id)
if book_format:
+ flash(_('Book Format Successfully Deleted'), category="success")
return redirect(url_for('editbook.edit_book', book_id=book_id))
else:
+ flash(_('Book Successfully Deleted'), category="success")
return redirect(url_for('web.index'))
@@ -253,10 +280,57 @@ def render_edit_book(book_id):
return render_title_template('book_edit.html', book=book, authors=author_names, cc=cc,
title=_(u"edit metadata"), page="editbook",
conversion_formats=allowed_conversion_formats,
+ config=config,
source_formats=valid_source_formats)
+def edit_book_ratings(to_save, book):
+ changed = False
+ if to_save["rating"].strip():
+ old_rating = False
+ if len(book.ratings) > 0:
+ old_rating = book.ratings[0].rating
+ ratingx2 = int(float(to_save["rating"]) * 2)
+ if ratingx2 != old_rating:
+ changed = True
+ is_rating = db.session.query(db.Ratings).filter(db.Ratings.rating == ratingx2).first()
+ if is_rating:
+ book.ratings.append(is_rating)
+ else:
+ new_rating = db.Ratings(rating=ratingx2)
+ book.ratings.append(new_rating)
+ if old_rating:
+ book.ratings.remove(book.ratings[0])
+ else:
+ if len(book.ratings) > 0:
+ book.ratings.remove(book.ratings[0])
+ changed = True
+ return changed
+
+
+def edit_book_languages(to_save, book):
+ input_languages = to_save["languages"].split(',')
+ unknown_languages = []
+ input_l = isoLanguages.get_language_codes(get_locale(), input_languages, unknown_languages)
+ for l in unknown_languages:
+ log.error('%s is not a valid language', l)
+ flash(_(u"%(langname)s is not a valid language", langname=l), category="error")
+ return modify_database_object(list(input_l), book.languages, db.Languages, db.session, 'languages')
+
+
+def edit_book_publisher(to_save, book):
+ changed = False
+ if to_save["publisher"]:
+ publisher = to_save["publisher"].rstrip().strip()
+ if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name):
+ changed |= modify_database_object([publisher], book.publishers, db.Publishers, db.session, 'publisher')
+ elif len(book.publishers):
+ changed |= modify_database_object([], book.publishers, db.Publishers, db.session, 'publisher')
+ return changed
+
+
def edit_cc_data(book_id, book, to_save):
+ changed = False
cc = db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all()
for c in cc:
cc_string = "custom_column_" + str(c.id)
@@ -276,14 +350,17 @@ def edit_cc_data(book_id, book, to_save):
if cc_db_value is not None:
if to_save[cc_string] is not None:
setattr(getattr(book, cc_string)[0], 'value', to_save[cc_string])
+ changed = True
else:
del_cc = getattr(book, cc_string)[0]
getattr(book, cc_string).remove(del_cc)
db.session.delete(del_cc)
+ changed = True
else:
cc_class = db.cc_classes[c.id]
new_cc = cc_class(value=to_save[cc_string], book=book_id)
db.session.add(new_cc)
+ changed = True
else:
if c.datatype == 'rating':
@@ -295,6 +372,7 @@ def edit_cc_data(book_id, book, to_save):
getattr(book, cc_string).remove(del_cc)
if len(del_cc.books) == 0:
db.session.delete(del_cc)
+ changed = True
cc_class = db.cc_classes[c.id]
new_cc = db.session.query(cc_class).filter(
cc_class.value == to_save[cc_string].strip()).first()
@@ -302,6 +380,7 @@ def edit_cc_data(book_id, book, to_save):
if new_cc is None:
new_cc = cc_class(value=to_save[cc_string].strip())
db.session.add(new_cc)
+ changed = True
db.session.flush()
new_cc = db.session.query(cc_class).filter(
cc_class.value == to_save[cc_string].strip()).first()
@@ -314,12 +393,13 @@ def edit_cc_data(book_id, book, to_save):
getattr(book, cc_string).remove(del_cc)
if not del_cc.books or len(del_cc.books) == 0:
db.session.delete(del_cc)
+ changed = True
else:
input_tags = to_save[cc_string].split(',')
input_tags = list(map(lambda it: it.strip(), input_tags))
- modify_database_object(input_tags, getattr(book, cc_string), db.cc_classes[c.id], db.session,
+ changed |= modify_database_object(input_tags, getattr(book, cc_string), db.cc_classes[c.id], db.session,
'custom')
- return cc
+ return changed
def upload_single_file(request, book, book_id):
# Check and handle Uploaded file
@@ -394,6 +474,7 @@ def upload_cover(request, book):
@login_required_if_no_ano
@edit_required
def edit_book(book_id):
+ modif_date = False
# Show form
if request.method != 'POST':
return render_edit_book(book_id)
@@ -411,6 +492,7 @@ def edit_book(book_id):
meta = upload_single_file(request, book, book_id)
if upload_cover(request, book) is True:
book.has_cover = 1
+ modif_date = True
try:
to_save = request.form.to_dict()
merge_metadata(to_save, meta)
@@ -422,6 +504,7 @@ def edit_book(book_id):
to_save["book_title"] = _(u'Unknown')
book.title = to_save["book_title"].rstrip().strip()
edited_books_id = book.id
+ modif_date = True
# handle author(s)
input_authors = to_save["author_name"].split('&')
@@ -430,7 +513,7 @@ def edit_book(book_id):
if input_authors == ['']:
input_authors = [_(u'Unknown')] # prevent empty Author
- modify_database_object(input_authors, book.authors, db.Authors, db.session, 'author')
+ modif_date |= modify_database_object(input_authors, book.authors, db.Authors, db.session, 'author')
# Search for each author if author is in database, if not, authorname and sorted authorname is generated new
# everything then is assembled for sorted author field in database
@@ -446,7 +529,7 @@ def edit_book(book_id):
if book.author_sort != sort_authors:
edited_books_id = book.id
book.author_sort = sort_authors
-
+ modif_date = True
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
@@ -460,75 +543,60 @@ def edit_book(book_id):
result, error = helper.save_cover_from_url(to_save["cover_url"], book.path)
if result is True:
book.has_cover = 1
+ modif_date = True
else:
flash(error, category="error")
if book.series_index != to_save["series_index"]:
book.series_index = to_save["series_index"]
+ modif_date = True
# Handle book comments/description
if len(book.comments):
- book.comments[0].text = to_save["description"]
+ if book.comments[0].text != to_save["description"]:
+ book.comments[0].text = to_save["description"]
+ modif_date = True
else:
- book.comments.append(db.Comments(text=to_save["description"], book=book.id))
+ if to_save["description"]:
+ book.comments.append(db.Comments(text=to_save["description"], book=book.id))
+ modif_date = True
+
+ # Handle identifiers
+ input_identifiers = identifier_list(to_save, book)
+ modif_date |= modify_identifiers(input_identifiers, book.identifiers, db.session)
# Handle book tags
input_tags = to_save["tags"].split(',')
input_tags = list(map(lambda it: it.strip(), input_tags))
- modify_database_object(input_tags, book.tags, db.Tags, db.session, 'tags')
+ modif_date |= modify_database_object(input_tags, book.tags, db.Tags, db.session, 'tags')
# Handle book series
input_series = [to_save["series"].strip()]
input_series = [x for x in input_series if x != '']
- modify_database_object(input_series, book.series, db.Series, db.session, 'series')
+ modif_date |= modify_database_object(input_series, book.series, db.Series, db.session, 'series')
if to_save["pubdate"]:
try:
- book.pubdate = datetime.datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
+ book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
except ValueError:
book.pubdate = db.Books.DEFAULT_PUBDATE
else:
book.pubdate = db.Books.DEFAULT_PUBDATE
- if to_save["publisher"]:
- publisher = to_save["publisher"].rstrip().strip()
- if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name):
- modify_database_object([publisher], book.publishers, db.Publishers, db.session, 'publisher')
- elif len(book.publishers):
- modify_database_object([], book.publishers, db.Publishers, db.session, 'publisher')
-
+ # handle book publisher
+ modif_date |= edit_book_publisher(to_save, book)
# handle book languages
- input_languages = to_save["languages"].split(',')
- unknown_languages = []
- input_l = isoLanguages.get_language_codes(get_locale(), input_languages, unknown_languages)
- for l in unknown_languages:
- log.error('%s is not a valid language', l)
- flash(_(u"%(langname)s is not a valid language", langname=l), category="error")
- modify_database_object(list(input_l), book.languages, db.Languages, db.session, 'languages')
+ modif_date |= edit_book_languages(to_save, book)
# handle book ratings
- if to_save["rating"].strip():
- old_rating = False
- if len(book.ratings) > 0:
- old_rating = book.ratings[0].rating
- ratingx2 = int(float(to_save["rating"]) * 2)
- if ratingx2 != old_rating:
- is_rating = db.session.query(db.Ratings).filter(db.Ratings.rating == ratingx2).first()
- if is_rating:
- book.ratings.append(is_rating)
- else:
- new_rating = db.Ratings(rating=ratingx2)
- book.ratings.append(new_rating)
- if old_rating:
- book.ratings.remove(book.ratings[0])
- else:
- if len(book.ratings) > 0:
- book.ratings.remove(book.ratings[0])
+ modif_date |= edit_book_ratings(to_save, book)
# handle cc data
- edit_cc_data(book_id, book, to_save)
+ modif_date |= edit_cc_data(book_id, book, to_save)
+ if modif_date:
+ book.last_modified = datetime.utcnow()
db.session.commit()
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
@@ -561,6 +629,19 @@ def merge_metadata(to_save, meta):
to_save["description"] = to_save["description"] or Markup(
getattr(meta, 'description', '')).unescape()
+def identifier_list(to_save, book):
+ """Generate a list of Identifiers from form information"""
+ id_type_prefix = 'identifier-type-'
+ id_val_prefix = 'identifier-val-'
+ result = []
+ for type_key, type_value in to_save.items():
+ if not type_key.startswith(id_type_prefix):
+ continue
+ val_key = id_val_prefix + type_key[len(id_type_prefix):]
+ if val_key not in to_save.keys():
+ continue
+ result.append( db.Identifiers(to_save[val_key], type_value, book.id) )
+ return result
@editbook.route("/upload", methods=["GET", "POST"])
@login_required_if_no_ano
@@ -677,8 +758,9 @@ def upload():
# combine path and normalize path from windows systems
path = os.path.join(author_dir, title_dir).replace('\\', '/')
- db_book = db.Books(title, "", db_author.sort, datetime.datetime.now(), datetime.datetime(101, 1, 1),
- series_index, datetime.datetime.now(), path, has_cover, db_author, [], db_language)
+ # Calibre adds books with utc as timezone
+ db_book = db.Books(title, "", db_author.sort, datetime.utcnow(), datetime(101, 1, 1),
+ series_index, datetime.utcnow(), path, has_cover, db_author, [], db_language)
db_book.authors.append(db_author)
if db_series:
db_book.series.append(db_series)
diff --git a/cps/helper.py b/cps/helper.py
index 34fec6ee..435b1322 100644
--- a/cps/helper.py
+++ b/cps/helper.py
@@ -96,7 +96,7 @@ def convert_book_format(book_id, calibrepath, old_book_format, new_book_format,
# read settings and append converter task to queue
if kindle_mail:
settings = config.get_mail_settings()
- settings['subject'] = _('Send to Kindle') # pretranslate Subject for e-mail
+ settings['subject'] = _('Send to Kindle') # pretranslate Subject for e-mail
settings['body'] = _(u'This e-mail has been sent via Calibre-Web.')
# text = _(u"%(format)s: %(book)s", format=new_book_format, book=book.title)
else:
@@ -108,7 +108,7 @@ def convert_book_format(book_id, calibrepath, old_book_format, new_book_format,
return None
else:
error_message = _(u"%(format)s not found: %(fn)s",
- format=old_book_format, fn=data.name + "." + old_book_format.lower())
+ format=old_book_format, fn=data.name + "." + old_book_format.lower())
return error_message
@@ -141,34 +141,52 @@ def check_send_to_kindle(entry):
returns all available book formats for sending to Kindle
"""
if len(entry.data):
- bookformats=list()
+ bookformats = list()
if config.config_ebookconverter == 0:
# no converter - only for mobi and pdf formats
for ele in iter(entry.data):
if 'MOBI' in ele.format:
- bookformats.append({'format':'Mobi','convert':0,'text':_('Send %(format)s to Kindle',format='Mobi')})
+ bookformats.append({'format': 'Mobi',
+ 'convert': 0,
+ 'text': _('Send %(format)s to Kindle', format='Mobi')})
if 'PDF' in ele.format:
- bookformats.append({'format':'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')})
+ bookformats.append({'format': 'Pdf',
+ 'convert': 0,
+ 'text': _('Send %(format)s to Kindle', format='Pdf')})
if 'AZW' in ele.format:
- bookformats.append({'format':'Azw','convert':0,'text':_('Send %(format)s to Kindle',format='Azw')})
+ bookformats.append({'format': 'Azw',
+ 'convert': 0,
+ 'text': _('Send %(format)s to Kindle', format='Azw')})
else:
formats = list()
for ele in iter(entry.data):
formats.append(ele.format)
if 'MOBI' in formats:
- bookformats.append({'format': 'Mobi','convert':0,'text':_('Send %(format)s to Kindle',format='Mobi')})
+ bookformats.append({'format': 'Mobi',
+ 'convert': 0,
+ 'text': _('Send %(format)s to Kindle', format='Mobi')})
if 'AZW' in formats:
- bookformats.append({'format': 'Azw','convert':0,'text':_('Send %(format)s to Kindle',format='Azw')})
+ bookformats.append({'format': 'Azw',
+ 'convert': 0,
+ 'text': _('Send %(format)s to Kindle', format='Azw')})
if 'PDF' in formats:
- bookformats.append({'format': 'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')})
+ bookformats.append({'format': 'Pdf',
+ 'convert': 0,
+ 'text': _('Send %(format)s to Kindle', format='Pdf')})
if config.config_ebookconverter >= 1:
if 'EPUB' in formats and not 'MOBI' in formats:
- bookformats.append({'format': 'Mobi','convert':1,
- 'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Epub',format='Mobi')})
+ bookformats.append({'format': 'Mobi',
+ 'convert':1,
+ 'text': _('Convert %(orig)s to %(format)s and send to Kindle',
+ orig='Epub',
+ format='Mobi')})
if config.config_ebookconverter == 2:
if 'AZW3' in formats and not 'MOBI' in formats:
- bookformats.append({'format': 'Mobi','convert':2,
- 'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Azw3',format='Mobi')})
+ bookformats.append({'format': 'Mobi',
+ 'convert': 2,
+ 'text': _('Convert %(orig)s to %(format)s and send to Kindle',
+ orig='Azw3',
+ format='Mobi')})
return bookformats
else:
log.error(u'Cannot find book entry %d', entry.id)
@@ -202,7 +220,6 @@ def send_mail(book_id, book_format, convert, kindle_mail, calibrepath, user_id):
# returns None if success, otherwise errormessage
return convert_book_format(book_id, calibrepath, u'azw3', book_format.lower(), user_id, kindle_mail)
-
for entry in iter(book.data):
if entry.format.upper() == book_format.upper():
converted_file_name = entry.name + '.' + book_format.lower()
@@ -279,15 +296,29 @@ def delete_book_file(book, calibrepath, book_format=None):
if os.path.isdir(path):
if len(next(os.walk(path))[1]):
log.error("Deleting book %s failed, path has subfolders: %s", book.id, book.path)
- return False
- shutil.rmtree(path, ignore_errors=True)
+ return False , _("Deleting book %(id)s failed, path has subfolders: %(path)s",
+ id=book.id,
+ path=book.path)
+ try:
+ for root, __, files in os.walk(path):
+ for f in files:
+ os.unlink(os.path.join(root, f))
+ shutil.rmtree(path)
+ except (IOError, OSError) as e:
+ log.error("Deleting book %s failed: %s", book.id, e)
+ return False, _("Deleting book %(id)s failed: %(message)s", id=book.id, message=e)
authorpath = os.path.join(calibrepath, os.path.split(book.path)[0])
if not os.listdir(authorpath):
- shutil.rmtree(authorpath, ignore_errors=True)
- return True
+ try:
+ shutil.rmtree(authorpath)
+ except (IOError, OSError) as e:
+ log.error("Deleting authorpath for book %s failed: %s", book.id, e)
+ return True, None
else:
log.error("Deleting book %s failed, book path not valid: %s", book.id, book.path)
- return False
+ return False, _("Deleting book %(id)s failed, book path not valid: %(path)s",
+ id=book.id,
+ path=book.path)
def update_dir_structure_file(book_id, calibrepath, first_author):
@@ -370,7 +401,7 @@ def update_dir_structure_gdrive(book_id, first_author):
path = book.path
gd.updateDatabaseOnEdit(gFile['id'], book.path) # only child folder affected
else:
- error = _(u'File %(file)s not found on Google Drive', file=book.path) # file not found
+ error = _(u'File %(file)s not found on Google Drive', file=book.path) # file not found
if authordir != new_authordir:
gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir)
@@ -380,7 +411,7 @@ def update_dir_structure_gdrive(book_id, first_author):
path = book.path
gd.updateDatabaseOnEdit(gFile['id'], book.path)
else:
- error = _(u'File %(file)s not found on Google Drive', file=authordir) # file not found
+ error = _(u'File %(file)s not found on Google Drive', file=authordir) # file not found
# Rename all files from old names to new names
if authordir != new_authordir or titledir != new_titledir:
@@ -396,7 +427,7 @@ def update_dir_structure_gdrive(book_id, first_author):
def delete_book_gdrive(book, book_format):
- error= False
+ error = None
if book_format:
name = ''
for entry in book.data:
@@ -404,38 +435,42 @@ def delete_book_gdrive(book, book_format):
name = entry.name + '.' + book_format
gFile = gd.getFileFromEbooksFolder(book.path, name)
else:
- gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path),book.path.split('/')[1])
+ gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), book.path.split('/')[1])
if gFile:
gd.deleteDatabaseEntry(gFile['id'])
gFile.Trash()
else:
- error =_(u'Book path %(path)s not found on Google Drive', path=book.path) # file not found
- return error
+ error = _(u'Book path %(path)s not found on Google Drive', path=book.path) # file not found
+
+ return error is None, error
def reset_password(user_id):
existing_user = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
+ if not existing_user:
+ return 0, None
password = generate_random_password()
existing_user.password = generate_password_hash(password)
if not config.get_mail_server_configured():
- return (2, None)
+ return 2, None
try:
ub.session.commit()
send_registration_mail(existing_user.email, existing_user.nickname, password, True)
- return (1, existing_user.nickname)
+ return 1, existing_user.nickname
except Exception:
ub.session.rollback()
- return (0, None)
+ return 0, None
def generate_random_password():
s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
passlen = 8
- return "".join(random.sample(s,passlen ))
+ return "".join(random.sample(s, passlen))
################################## External interface
-def update_dir_stucture(book_id, calibrepath, first_author = None):
+
+def update_dir_stucture(book_id, calibrepath, first_author=None):
if config.config_use_google_drive:
return update_dir_structure_gdrive(book_id, first_author)
else:
@@ -455,23 +490,26 @@ def get_cover_on_failure(use_generic_cover):
else:
return None
+
def get_book_cover(book_id):
book = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first()
return get_book_cover_internal(book, use_generic_cover_on_failure=True)
+
def get_book_cover_with_uuid(book_uuid,
- use_generic_cover_on_failure=True):
+ use_generic_cover_on_failure=True):
book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
return get_book_cover_internal(book, use_generic_cover_on_failure)
+
def get_book_cover_internal(book,
- use_generic_cover_on_failure):
+ use_generic_cover_on_failure):
if book and book.has_cover:
if config.config_use_google_drive:
try:
if not gd.is_gdrive_ready():
return get_cover_on_failure(use_generic_cover_on_failure)
- path=gd.get_cover_via_gdrive(book.path)
+ path = gd.get_cover_via_gdrive(book.path)
if path:
return redirect(path)
else:
@@ -528,7 +566,7 @@ def save_cover(img, book_path):
return False, _("Only jpg/jpeg/png/webp files are supported as coverfile")
# convert to jpg because calibre only supports jpg
if content_type in ('image/png', 'image/webp'):
- if hasattr(img,'stream'):
+ if hasattr(img, 'stream'):
imgc = PILImage.open(img.stream)
else:
imgc = PILImage.open(io.BytesIO(img.content))
@@ -537,7 +575,7 @@ def save_cover(img, book_path):
im.save(tmp_bytesio, format='JPEG')
img._content = tmp_bytesio.getvalue()
else:
- if content_type not in ('image/jpeg'):
+ if content_type not in 'image/jpeg':
log.error("Only jpg/jpeg files are supported as coverfile")
return False, _("Only jpg/jpeg files are supported as coverfile")
@@ -555,7 +593,6 @@ def save_cover(img, book_path):
return save_cover_from_filestorage(os.path.join(config.config_calibre_dir, book_path), "cover.jpg", img)
-
def do_download_file(book, book_format, data, headers):
if config.config_use_google_drive:
startTime = time.time()
@@ -577,7 +614,6 @@ def do_download_file(book, book_format, data, headers):
##################################
-
def check_unrar(unrarLocation):
if not unrarLocation:
return
@@ -599,13 +635,12 @@ def check_unrar(unrarLocation):
return _('Error excecuting UnRar')
-
def json_serial(obj):
"""JSON serializer for objects not serializable by default json code"""
- if isinstance(obj, (datetime)):
+ if isinstance(obj, datetime):
return obj.isoformat()
- if isinstance(obj, (timedelta)):
+ if isinstance(obj, timedelta):
return {
'__type__': 'timedelta',
'days': obj.days,
@@ -613,7 +648,7 @@ def json_serial(obj):
'microseconds': obj.microseconds,
}
# return obj.isoformat()
- raise TypeError ("Type %s not serializable" % type(obj))
+ raise TypeError("Type %s not serializable" % type(obj))
# helper function for displaying the runtime of tasks
@@ -635,7 +670,7 @@ def format_runtime(runtime):
# helper function to apply localize status information in tasklist entries
def render_task_status(tasklist):
- renderedtasklist=list()
+ renderedtasklist = list()
for task in tasklist:
if task['user'] == current_user.nickname or current_user.role_admin():
if task['formStarttime']:
@@ -651,7 +686,7 @@ def render_task_status(tasklist):
task['runtime'] = format_runtime(task['formRuntime'])
# localize the task status
- if isinstance( task['stat'], int ):
+ if isinstance( task['stat'], int):
if task['stat'] == STAT_WAITING:
task['status'] = _(u'Waiting')
elif task['stat'] == STAT_FAIL:
@@ -664,14 +699,14 @@ def render_task_status(tasklist):
task['status'] = _(u'Unknown Status')
# localize the task type
- if isinstance( task['taskType'], int ):
+ if isinstance( task['taskType'], int):
if task['taskType'] == TASK_EMAIL:
task['taskMessage'] = _(u'E-mail: ') + task['taskMess']
- elif task['taskType'] == TASK_CONVERT:
+ elif task['taskType'] == TASK_CONVERT:
task['taskMessage'] = _(u'Convert: ') + task['taskMess']
- elif task['taskType'] == TASK_UPLOAD:
+ elif task['taskType'] == TASK_UPLOAD:
task['taskMessage'] = _(u'Upload: ') + task['taskMess']
- elif task['taskType'] == TASK_CONVERT_ANY:
+ elif task['taskType'] == TASK_CONVERT_ANY:
task['taskMessage'] = _(u'Convert: ') + task['taskMess']
else:
task['taskMessage'] = _(u'Unknown Task: ') + task['taskMess']
@@ -682,7 +717,19 @@ def render_task_status(tasklist):
# Language and content filters for displaying in the UI
-def common_filters():
+def common_filters(allow_show_archived=False):
+ if not allow_show_archived:
+ 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]
+ archived_filter = db.Books.id.notin_(archived_book_ids)
+ else:
+ archived_filter = true()
+
if current_user.filter_language() != "all":
lang_filter = db.Books.languages.any(db.Languages.lang_code == current_user.filter_language())
else:
@@ -695,16 +742,16 @@ def common_filters():
pos_cc_list = current_user.allowed_column_value.split(',')
pos_content_cc_filter = true() if pos_cc_list == [''] else \
getattr(db.Books, 'custom_column_' + str(config.config_restricted_column)).\
- any(db.cc_classes[config.config_restricted_column].value.in_(pos_cc_list))
+ any(db.cc_classes[config.config_restricted_column].value.in_(pos_cc_list))
neg_cc_list = current_user.denied_column_value.split(',')
neg_content_cc_filter = false() if neg_cc_list == [''] else \
getattr(db.Books, 'custom_column_' + str(config.config_restricted_column)).\
- any(db.cc_classes[config.config_restricted_column].value.in_(neg_cc_list))
+ any(db.cc_classes[config.config_restricted_column].value.in_(neg_cc_list))
else:
pos_content_cc_filter = true()
neg_content_cc_filter = false()
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
- pos_content_cc_filter, ~neg_content_cc_filter)
+ pos_content_cc_filter, ~neg_content_cc_filter, archived_filter)
def tags_filters():
@@ -719,8 +766,9 @@ def tags_filters():
# Creates for all stored languages a translated speaking name in the array for the UI
def speaking_language(languages=None):
if not languages:
- languages = db.session.query(db.Languages).join(db.books_languages_link).join(db.Books).filter(common_filters())\
- .group_by(text('books_languages_link.lang_code')).all()
+ languages = db.session.query(db.Languages).join(db.books_languages_link).join(db.Books)\
+ .filter(common_filters())\
+ .group_by(text('books_languages_link.lang_code')).all()
for lang in languages:
try:
cur_l = LC.parse(lang.lang_code)
@@ -729,6 +777,7 @@ def speaking_language(languages=None):
lang.name = _(isoLanguages.get(part3=lang.lang_code).name)
return languages
+
# checks if domain is in database (including wildcards)
# example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name;
# from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/
@@ -762,28 +811,36 @@ def order_authors(entry):
# Fill indexpage with all requested data from database
def fill_indexpage(page, database, db_filter, order, *join):
+ return fill_indexpage_with_archived_books(page, database, db_filter, order, False, *join)
+
+
+def fill_indexpage_with_archived_books(page, database, db_filter, order, allow_show_archived, *join):
if current_user.show_detail_random():
- randm = db.session.query(db.Books).filter(common_filters())\
+ randm = db.session.query(db.Books).filter(common_filters(allow_show_archived))\
.order_by(func.random()).limit(config.config_random_books)
else:
randm = false()
off = int(int(config.config_books_per_page) * (page - 1))
pagination = Pagination(page, config.config_books_per_page,
- len(db.session.query(database).filter(db_filter).filter(common_filters()).all()))
- entries = db.session.query(database).join(*join, isouter=True).filter(db_filter).filter(common_filters()).\
- order_by(*order).offset(off).limit(config.config_books_per_page).all()
+ len(db.session.query(database).filter(db_filter)
+ .filter(common_filters(allow_show_archived)).all()))
+ entries = db.session.query(database).join(*join, isouter=True).filter(db_filter)\
+ .filter(common_filters(allow_show_archived))\
+ .order_by(*order).offset(off).limit(config.config_books_per_page).all()
for book in entries:
book = order_authors(book)
return entries, randm, pagination
-def get_typeahead(database, query, replace=('',''), tag_filter=true()):
+def get_typeahead(database, query, replace=('', ''), tag_filter=true()):
query = query or ''
db.session.connection().connection.connection.create_function("lower", 1, lcase)
- entries = db.session.query(database).filter(tag_filter).filter(func.lower(database.name).ilike("%" + query + "%")).all()
+ entries = db.session.query(database).filter(tag_filter).\
+ filter(func.lower(database.name).ilike("%" + query + "%")).all()
json_dumps = json.dumps([dict(name=r.name.replace(*replace)) for r in entries])
return json_dumps
+
# read search results from calibre-database and return it (function is used for feed and simple search
def get_search_results(term):
db.session.connection().connection.connection.create_function("lower", 1, lcase)
@@ -802,6 +859,7 @@ def get_search_results(term):
func.lower(db.Books.title).ilike("%" + term + "%")
)).order_by(db.Books.sort).all()
+
def get_cc_columns():
tmpcc = db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all()
if config.config_columns_to_ignore:
@@ -814,6 +872,7 @@ def get_cc_columns():
cc = tmpcc
return cc
+
def get_download_link(book_id, book_format):
book_format = book_format.split(".")[0]
book = db.session.query(db.Books).filter(db.Books.id == book_id).filter(common_filters()).first()
@@ -838,7 +897,8 @@ def get_download_link(book_id, book_format):
else:
abort(404)
-def check_exists_book(authr,title):
+
+def check_exists_book(authr, title):
db.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list()
authorterms = re.split(r'\s*&\s*', authr)
@@ -847,11 +907,12 @@ def check_exists_book(authr,title):
return db.session.query(db.Books).filter(
and_(db.Books.authors.any(and_(*q)),
- func.lower(db.Books.title).ilike("%" + title + "%")
- )).first()
+ func.lower(db.Books.title).ilike("%" + title + "%")
+ )).first()
############### Database Helper functions
+
def lcase(s):
try:
return unidecode.unidecode(s.lower())
diff --git a/cps/jinjia.py b/cps/jinjia.py
index 5d05eeee..2c231582 100644
--- a/cps/jinjia.py
+++ b/cps/jinjia.py
@@ -80,9 +80,13 @@ def formatdate_filter(val):
formatdate = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S")
return format_date(formatdate, format='medium', locale=get_locale())
except AttributeError as e:
- log.error('Babel error: %s, Current user locale: %s, Current User: %s', e, current_user.locale, current_user.nickname)
+ log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
+ current_user.locale,
+ current_user.nickname
+ )
return formatdate
+
@jinjia.app_template_filter('formatdateinput')
def format_date_input(val):
conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val)
diff --git a/cps/kobo.py b/cps/kobo.py
index dcb2de44..2e3c1601 100644
--- a/cps/kobo.py
+++ b/cps/kobo.py
@@ -17,11 +17,15 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-import sys
import base64
+import datetime
+import itertools
+import json
+import sys
import os
import uuid
from time import gmtime, strftime
+
try:
from urllib import unquote
except ImportError:
@@ -34,20 +38,24 @@ from flask import (
jsonify,
current_app,
url_for,
- redirect
+ redirect,
+ abort
)
-
+from flask_login import current_user, login_required
from werkzeug.datastructures import Headers
from sqlalchemy import func
+from sqlalchemy.sql.expression import and_, or_
+from sqlalchemy.exc import StatementError
import requests
-from . import config, logger, kobo_auth, db, helper
+from . import config, logger, kobo_auth, db, helper, shelf as shelf_lib, ub
from .services import SyncToken as SyncToken
from .web import download_required
from .kobo_auth import requires_kobo_auth
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]}
KOBO_STOREAPI_URL = "https://storeapi.kobo.com"
+KOBO_IMAGEHOST_URL = "https://kbimages1-a.akamaihd.net"
kobo = Blueprint("kobo", __name__, url_prefix="/kobo/")
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
@@ -55,6 +63,7 @@ kobo_auth.register_url_value_preprocessor(kobo)
log = logger.create()
+
def get_store_url_for_current_request():
# Programmatically modify the current url to point to the official Kobo store
__, __, request_path_with_auth_token = request.full_path.rpartition("/kobo/")
@@ -96,9 +105,6 @@ def redirect_or_proxy_request():
if config.config_kobo_proxy:
if request.method == "GET":
return redirect(get_store_url_for_current_request(), 307)
- if request.method == "DELETE":
- log.info('Delete Book')
- return make_response(jsonify({}))
else:
# The Kobo device turns other request types into GET requests on redirects, so we instead proxy to the Kobo store ourselves.
store_response = make_request_to_kobo_store()
@@ -114,6 +120,10 @@ def redirect_or_proxy_request():
return make_response(jsonify({}))
+def convert_to_kobo_timestamp_string(timestamp):
+ return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
@kobo.route("/v1/library/sync")
@requires_kobo_auth
@download_required
@@ -128,58 +138,103 @@ def HandleSyncRequest():
new_books_last_modified = sync_token.books_last_modified
new_books_last_created = sync_token.books_last_created
- entitlements = []
+ new_reading_state_last_modified = sync_token.reading_state_last_modified
+ sync_results = []
# We reload the book database so that the user get's a fresh view of the library
# in case of external changes (e.g: adding a book through Calibre).
db.reconnect_db(config)
+ archived_books = (
+ ub.session.query(ub.ArchivedBook)
+ .filter(ub.ArchivedBook.user_id == int(current_user.id))
+ .all()
+ )
+
+ # 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.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
# the comparison because of the +00:00 suffix.
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))
.all()
)
+ reading_states_in_new_entitlements = []
for book in changed_entries:
+ kobo_reading_state = get_or_create_reading_state(book.id)
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),
}
+
+ if kobo_reading_state.last_modified > sync_token.reading_state_last_modified:
+ entitlement["ReadingState"] = get_kobo_reading_state_response(book, kobo_reading_state)
+ new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified)
+ reading_states_in_new_entitlements.append(book.id)
+
if book.timestamp > sync_token.books_last_created:
- entitlements.append({"NewEntitlement": entitlement})
+ sync_results.append({"NewEntitlement": entitlement})
else:
- entitlements.append({"ChangedEntitlement": entitlement})
+ sync_results.append({"ChangedEntitlement": entitlement})
new_books_last_modified = max(
- book.last_modified, sync_token.books_last_modified
+ book.last_modified, new_books_last_modified
)
- new_books_last_created = max(book.timestamp, sync_token.books_last_created)
+ new_books_last_created = max(book.timestamp, new_books_last_created)
+
+ changed_reading_states = (
+ ub.session.query(ub.KoboReadingState)
+ .filter(and_(func.datetime(ub.KoboReadingState.last_modified) > sync_token.reading_state_last_modified,
+ ub.KoboReadingState.user_id == current_user.id,
+ ub.KoboReadingState.book_id.notin_(reading_states_in_new_entitlements))))
+ 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_or_none()
+ if book:
+ sync_results.append({
+ "ChangedReadingState": {
+ "ReadingState": get_kobo_reading_state_response(book, kobo_reading_state)
+ }
+ })
+ 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_modified = new_books_last_modified
+ sync_token.archive_last_modified = new_archived_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, entitlements)
-
- return make_response(jsonify(entitlements))
- # Missing feature: Detect server-side book deletions.
+ return generate_sync_response(sync_token, sync_results)
-def generate_sync_response(request, sync_token, entitlements):
+def generate_sync_response(sync_token, sync_results):
extra_headers = {}
if config.config_kobo_proxy:
# Merge in sync results from the official Kobo store.
try:
store_response = make_request_to_kobo_store(sync_token)
- store_entitlements = store_response.json()
- entitlements += store_entitlements
+ store_sync_results = store_response.json()
+ sync_results += store_sync_results
sync_token.merge_from_store_response(store_response)
extra_headers["x-kobo-sync"] = store_response.headers.get("x-kobo-sync")
extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode")
@@ -189,7 +244,7 @@ def generate_sync_response(request, sync_token, entitlements):
log.error("Failed to receive or parse response from Kobo's sync endpoint: " + str(e))
sync_token.to_headers(extra_headers)
- response = make_response(jsonify(entitlements), extra_headers)
+ response = make_response(jsonify(sync_results), extra_headers)
return response
@@ -231,19 +286,18 @@ 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",
- "ActivePeriod": {"From": current_time(),},
- "Created": book.timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.datetime.now())},
+ "Created": convert_to_kobo_timestamp_string(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.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "LastModified": convert_to_kobo_timestamp_string(book.last_modified),
"OriginCategory": "Imported",
"RevisionId": book_uuid,
"Status": "Active",
@@ -316,6 +370,8 @@ def get_metadata(book):
"IsSocialEnabled": True,
"Language": "en",
"PhoneticPronunciations": {},
+ # TODO: Fix book.pubdate to return a datetime object so that we can easily
+ # convert it to the format Kobo devices expect.
"PublicationDate": book.pubdate,
"Publisher": {"Imprint": "", "Name": get_publisher(book),},
"RevisionId": book_uuid,
@@ -330,7 +386,7 @@ def get_metadata(book):
name = get_series(book)
metadata["Series"] = {
"Name": get_series(book),
- "Number": book.series_index,
+ "Number": book.series_index, # ToDo Check int() ?
"NumberFloat": float(book.series_index),
# Get a deterministic id based on the series name.
"Id": uuid.uuid3(uuid.NAMESPACE_DNS, name),
@@ -339,31 +395,399 @@ def get_metadata(book):
return metadata
-def reading_state(book):
- # TODO: Implement
- reading_state = {
- # "StatusInfo": {
- # "LastModified": get_single_cc_value(book, "lastreadtimestamp"),
- # "Status": get_single_cc_value(book, "reading_status"),
- # }
- # TODO: CurrentBookmark, Location
+@kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
+@login_required
+# Creates a Shelf with the given items, and returns the shelf's uuid.
+def HandleTagCreate():
+ # catch delete requests, otherwise the are handeld in the book delete handler
+ if request.method == "DELETE":
+ abort(405)
+ name, items = None, None
+ try:
+ shelf_request = request.json
+ name = shelf_request["Name"]
+ items = shelf_request["Items"]
+ if not name:
+ raise TypeError
+ except (KeyError, TypeError):
+ log.debug("Received malformed v1/library/tags request.")
+ abort(400, description="Malformed tags POST request. Data has empty 'Name', missing 'Name' or 'Items' field")
+
+ shelf = ub.session.query(ub.Shelf).filter(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 create shelf.")
+
+ if not shelf:
+ shelf = ub.Shelf(user_id=current_user.id, name=name, uuid=str(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/", methods=["DELETE", "PUT"])
+def HandleTagUpdate(tag_id):
+ 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 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:
+ name = None
+ try:
+ shelf_request = request.json
+ name = shelf_request["Name"]
+ except (KeyError, TypeError):
+ 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:
+ try:
+ 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))
+ except KeyError:
+ items_unknown_to_calibre.append(item)
+ return items_unknown_to_calibre
+
+
+@kobo.route("/v1/library/tags//items", methods=["POST"])
+@login_required
+def HandleTagAddItem(tag_id):
+ items = None
+ try:
+ tag_request = request.json
+ items = tag_request["Items"]
+ except (KeyError, TypeError):
+ log.debug("Received malformed v1/library/tags//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 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 collection. Silently ignoring item.")
+
+ ub.session.merge(shelf)
+ ub.session.commit()
+
+ return make_response('', 201)
+
+
+@kobo.route("/v1/library/tags//items/delete", methods=["POST"])
+@login_required
+def HandleTagRemoveItem(tag_id):
+ items = None
+ try:
+ tag_request = request.json
+ items = tag_request["Items"]
+ except (KeyError, TypeError):
+ log.debug("Received malformed v1/library/tags//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:
+ try:
+ 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()
+ except KeyError:
+ items_unknown_to_calibre.append(item)
+ 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"
}
- return reading_state
+ 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)
+ continue
+ tag["Items"].append(
+ {
+ "RevisionId": book.uuid,
+ "Type": "ProductRevisionTagItem"
+ }
+ )
+ return {"Tag": tag}
+
+
+@kobo.route("/v1/library//state", methods=["GET", "PUT"])
+@login_required
+def HandleStateRequest(book_uuid):
+ book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
+ if not book or not book.data:
+ log.info(u"Book %s not found in database", book_uuid)
+ return redirect_or_proxy_request()
+ kobo_reading_state = get_or_create_reading_state(book.id)
-@kobo.route("//image.jpg")
+ if request.method == "GET":
+ return jsonify([get_kobo_reading_state_response(book, kobo_reading_state)])
+ else:
+ update_results_response = {"EntitlementId": book_uuid}
+
+ try:
+ request_data = request.json
+ request_reading_state = request_data["ReadingStates"][0]
+
+ request_bookmark = request_reading_state["CurrentBookmark"]
+ if request_bookmark:
+ current_bookmark = kobo_reading_state.current_bookmark
+ current_bookmark.progress_percent = request_bookmark["ProgressPercent"]
+ current_bookmark.content_source_progress_percent = request_bookmark["ContentSourceProgressPercent"]
+ location = request_bookmark["Location"]
+ if location:
+ current_bookmark.location_value = location["Value"]
+ current_bookmark.location_type = location["Type"]
+ current_bookmark.location_source = location["Source"]
+ update_results_response["CurrentBookmarkResult"] = {"Result": "Success"}
+
+ request_statistics = request_reading_state["Statistics"]
+ if request_statistics:
+ statistics = kobo_reading_state.statistics
+ statistics.spent_reading_minutes = int(request_statistics["SpentReadingMinutes"])
+ statistics.remaining_time_minutes = int(request_statistics["RemainingTimeMinutes"])
+ update_results_response["StatisticsResult"] = {"Result": "Success"}
+
+ request_status_info = request_reading_state["StatusInfo"]
+ if request_status_info:
+ book_read = kobo_reading_state.book_read_link
+ new_book_read_status = get_ub_read_status(request_status_info["Status"])
+ if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \
+ and new_book_read_status != book_read.read_status:
+ book_read.times_started_reading += 1
+ book_read.last_time_started_reading = datetime.datetime.utcnow()
+ book_read.read_status = new_book_read_status
+ update_results_response["StatusInfoResult"] = {"Result": "Success"}
+ except (KeyError, TypeError, ValueError, StatementError):
+ log.debug("Received malformed v1/library//state request.")
+ ub.session.rollback()
+ abort(400, description="Malformed request data is missing 'ReadingStates' key")
+
+ ub.session.merge(kobo_reading_state)
+ ub.session.commit()
+ return jsonify({
+ "RequestResult": "Success",
+ "UpdateResults": [update_results_response],
+ })
+
+
+def get_read_status_for_kobo(ub_book_read):
+ enum_to_string_map = {
+ None: "ReadyToRead",
+ ub.ReadBook.STATUS_UNREAD: "ReadyToRead",
+ ub.ReadBook.STATUS_FINISHED: "Finished",
+ ub.ReadBook.STATUS_IN_PROGRESS: "Reading",
+ }
+ return enum_to_string_map[ub_book_read.read_status]
+
+
+def get_ub_read_status(kobo_read_status):
+ string_to_enum_map = {
+ None: None,
+ "ReadyToRead": ub.ReadBook.STATUS_UNREAD,
+ "Finished": ub.ReadBook.STATUS_FINISHED,
+ "Reading": ub.ReadBook.STATUS_IN_PROGRESS,
+ }
+ return string_to_enum_map[kobo_read_status]
+
+
+def get_or_create_reading_state(book_id):
+ book_read = ub.session.query(ub.ReadBook).filter(ub.ReadBook.book_id == book_id,
+ ub.ReadBook.user_id == current_user.id).one_or_none()
+ if not book_read:
+ book_read = ub.ReadBook(user_id=current_user.id, book_id=book_id)
+ if not book_read.kobo_reading_state:
+ kobo_reading_state = ub.KoboReadingState(user_id=book_read.user_id, book_id=book_id)
+ kobo_reading_state.current_bookmark = ub.KoboBookmark()
+ kobo_reading_state.statistics = ub.KoboStatistics()
+ book_read.kobo_reading_state = kobo_reading_state
+ ub.session.add(book_read)
+ ub.session.commit()
+ return book_read.kobo_reading_state
+
+
+def get_kobo_reading_state_response(book, kobo_reading_state):
+ return {
+ "EntitlementId": book.uuid,
+ "Created": convert_to_kobo_timestamp_string(book.timestamp),
+ "LastModified": convert_to_kobo_timestamp_string(kobo_reading_state.last_modified),
+ # AFAICT PriorityTimestamp is always equal to LastModified.
+ "PriorityTimestamp": convert_to_kobo_timestamp_string(kobo_reading_state.priority_timestamp),
+ "StatusInfo": get_status_info_response(kobo_reading_state.book_read_link),
+ "Statistics": get_statistics_response(kobo_reading_state.statistics),
+ "CurrentBookmark": get_current_bookmark_response(kobo_reading_state.current_bookmark),
+ }
+
+
+def get_status_info_response(book_read):
+ resp = {
+ "LastModified": convert_to_kobo_timestamp_string(book_read.last_modified),
+ "Status": get_read_status_for_kobo(book_read),
+ "TimesStartedReading": book_read.times_started_reading,
+ }
+ if book_read.last_time_started_reading:
+ resp["LastTimeStartedReading"] = convert_to_kobo_timestamp_string(book_read.last_time_started_reading)
+ return resp
+
+
+def get_statistics_response(statistics):
+ resp = {
+ "LastModified": convert_to_kobo_timestamp_string(statistics.last_modified),
+ }
+ if statistics.spent_reading_minutes:
+ resp["SpentReadingMinutes"] = statistics.spent_reading_minutes
+ if statistics.remaining_time_minutes:
+ resp["RemainingTimeMinutes"] = statistics.remaining_time_minutes
+ return resp
+
+
+def get_current_bookmark_response(current_bookmark):
+ resp = {
+ "LastModified": convert_to_kobo_timestamp_string(current_bookmark.last_modified),
+ }
+ if current_bookmark.progress_percent:
+ resp["ProgressPercent"] = current_bookmark.progress_percent
+ if current_bookmark.content_source_progress_percent:
+ resp["ContentSourceProgressPercent"] = current_bookmark.content_source_progress_percent
+ if current_bookmark.location_value:
+ resp["Location"] = {
+ "Value": current_bookmark.location_value,
+ "Type": current_bookmark.location_type,
+ "Source": current_bookmark.location_source,
+ }
+ return resp
+
+@kobo.route("/////image.jpg", defaults={'Quality': ""})
+@kobo.route("//////image.jpg")
@requires_kobo_auth
-def HandleCoverImageRequest(book_uuid):
+def HandleCoverImageRequest(book_uuid, width, height,Quality, isGreyscale):
book_cover = helper.get_book_cover_with_uuid(
book_uuid, use_generic_cover_on_failure=False
)
if not book_cover:
if config.config_kobo_proxy:
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
- return redirect(get_store_url_for_current_request(), 307)
+ return redirect(KOBO_IMAGEHOST_URL +
+ "/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid,
+ width=width,
+ height=height), 307)
else:
log.debug("Cover for unknown book: %s requested" % book_uuid)
- return redirect_or_proxy_request()
+ # additional proxy request make no sense, -> direct return
+ return make_response(jsonify({}))
log.debug("Cover request received for book %s" % book_uuid)
return book_cover
@@ -373,13 +797,35 @@ def TopLevelEndpoint():
return make_response(jsonify({}))
+@kobo.route("/v1/library/", methods=["DELETE"])
+@login_required
+def HandleBookDeletionRequest(book_uuid):
+ log.info("Kobo book deletion request received for book %s" % book_uuid)
+ book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
+ if not book:
+ log.info(u"Book %s not found in database", book_uuid)
+ return redirect_or_proxy_request()
+
+ book_id = book.id
+ archived_book = (
+ ub.session.query(ub.ArchivedBook)
+ .filter(ub.ArchivedBook.book_id == book_id)
+ .first()
+ )
+ if not archived_book:
+ archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
+ archived_book.is_archived = True
+ archived_book.last_modified = datetime.datetime.utcnow()
+
+ ub.session.merge(archived_book)
+ ub.session.commit()
+
+ return ("", 204)
+
+
# TODO: Implement the following routes
@kobo.route("/v1/library/", methods=["DELETE", "GET"])
-@kobo.route("/v1/library//state", methods=["PUT"])
-@kobo.route("/v1/library/tags", methods=["POST"])
-@kobo.route("/v1/library/tags/", methods=["POST"])
-@kobo.route("/v1/library/tags/", methods=["DELETE"])
-def HandleUnimplementedRequest(dummy=None, book_uuid=None, shelf_name=None, tag_id=None):
+def HandleUnimplementedRequest(dummy=None):
log.debug("Unimplemented Library Request received: %s", request.base_url)
return redirect_or_proxy_request()
@@ -399,6 +845,7 @@ def HandleUserRequest(dummy=None):
@kobo.route("/v1/products//recommendations", methods=["GET", "POST"])
@kobo.route("/v1/products//nextread", methods=["GET", "POST"])
@kobo.route("/v1/products//reviews", methods=["GET", "POST"])
+@kobo.route("/v1/products/books/series/", methods=["GET", "POST"])
@kobo.route("/v1/products/books/", methods=["GET", "POST"])
@kobo.route("/v1/products/dailydeal", methods=["GET", "POST"])
@kobo.route("/v1/products", methods=["GET", "POST"])
@@ -407,12 +854,15 @@ def HandleProductsRequest(dummy=None):
return redirect_or_proxy_request()
-@kobo.app_errorhandler(404)
+'''@kobo.errorhandler(404)
def handle_404(err):
# 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)
- log.debug("Unknown Request received: %s", request.base_url)
- return redirect_or_proxy_request()
+ if err:
+ print('404')
+ return jsonify(error=str(err)), 404
+ log.debug("Unknown Request received: %s, method: %s, data: %s", request.base_url, request.method, request.data)
+ return redirect_or_proxy_request()'''
def make_calibre_web_auth_response():
@@ -446,18 +896,23 @@ def HandleAuthRequest():
return make_calibre_web_auth_response()
-def make_calibre_web_init_response(calibre_web_url):
- resources = NATIVE_KOBO_RESOURCES(calibre_web_url)
- response = make_response(jsonify({"Resources": resources}))
- response.headers["x-kobo-apitoken"] = "e30="
- return response
-
-
@kobo.route("/v1/initialization")
@requires_kobo_auth
def HandleInitRequest():
log.info('Init')
+ kobo_resources = None
+ if config.config_kobo_proxy:
+ try:
+ store_response = make_request_to_kobo_store()
+ store_response_json = store_response.json()
+ if "Resources" in store_response_json:
+ kobo_resources = store_response_json["Resources"]
+ except:
+ log.error("Failed to receive or parse response from Kobo's init endpoint. Falling back to un-proxied mode.")
+ if not kobo_resources:
+ kobo_resources = NATIVE_KOBO_RESOURCES()
+
if not current_app.wsgi_app.is_proxied:
log.debug('Kobo: Received unproxied request, changed request port to server port')
if ':' in request.host and not request.host.endswith(']'):
@@ -469,33 +924,47 @@ def HandleInitRequest():
url_base=host,
url_port=config.config_port
)
+ kobo_resources["image_host"] = calibre_web_url
+ kobo_resources["image_url_quality_template"] = unquote(calibre_web_url +
+ url_for("kobo.HandleCoverImageRequest",
+ auth_token=kobo_auth.get_auth_token(),
+ book_uuid="{ImageId}",
+ width="{width}",
+ height="{height}",
+ Quality='{Quality}',
+ isGreyscale='isGreyscale'
+ ))
+ kobo_resources["image_url_template"] = unquote(calibre_web_url +
+ url_for("kobo.HandleCoverImageRequest",
+ auth_token=kobo_auth.get_auth_token(),
+ book_uuid="{ImageId}",
+ width="{width}",
+ height="{height}",
+ isGreyscale='false'
+ ))
else:
- calibre_web_url = url_for("web.index", _external=True).strip("/")
-
- if config.config_kobo_proxy:
- try:
- store_response = make_request_to_kobo_store()
+ kobo_resources["image_host"] = url_for("web.index", _external=True).strip("/")
+ kobo_resources["image_url_quality_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
+ auth_token=kobo_auth.get_auth_token(),
+ book_uuid="{ImageId}",
+ width="{width}",
+ height="{height}",
+ _external=True))
+ kobo_resources["image_url_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
+ auth_token=kobo_auth.get_auth_token(),
+ book_uuid="{ImageId}",
+ width="{width}",
+ height="{height}",
+ _external=True))
+
+
+ response = make_response(jsonify({"Resources": kobo_resources}))
+ response.headers["x-kobo-apitoken"] = "e30="
- store_response_json = store_response.json()
- if "Resources" in store_response_json:
- kobo_resources = store_response_json["Resources"]
- # calibre_web_url = url_for("web.index", _external=True).strip("/")
- kobo_resources["image_host"] = calibre_web_url
- kobo_resources["image_url_quality_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
- auth_token = kobo_auth.get_auth_token(),
- book_uuid="{ImageId}"))
- kobo_resources["image_url_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
- auth_token = kobo_auth.get_auth_token(),
- book_uuid="{ImageId}"))
-
- return make_response(store_response_json, store_response.status_code)
- except:
- log.error("Failed to receive or parse response from Kobo's init endpoint. Falling back to un-proxied mode.")
-
- return make_calibre_web_init_response(calibre_web_url)
+ return response
-def NATIVE_KOBO_RESOURCES(calibre_web_url):
+def NATIVE_KOBO_RESOURCES():
return {
"account_page": "https://secure.kobobooks.com/profile",
"account_page_rakuten": "https://my.rakuten.co.jp/",
@@ -546,13 +1015,6 @@ def NATIVE_KOBO_RESOURCES(calibre_web_url):
"giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader",
"giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem",
"help_page": "http://www.kobo.com/help",
- "image_host": calibre_web_url,
- "image_url_quality_template": unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
- auth_token = kobo_auth.get_auth_token(),
- book_uuid="{ImageId}")),
- "image_url_template": unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
- auth_token = kobo_auth.get_auth_token(),
- book_uuid="{ImageId}")),
"kobo_audiobooks_enabled": "False",
"kobo_audiobooks_orange_deal_enabled": "False",
"kobo_audiobooks_subscriptions_enabled": "False",
diff --git a/cps/logger.py b/cps/logger.py
index 77a721d3..3f850442 100644
--- a/cps/logger.py
+++ b/cps/logger.py
@@ -67,6 +67,8 @@ def get_level_name(level):
def is_valid_logfile(file_path):
+ if file_path == LOG_TO_STDERR or file_path == LOG_TO_STDOUT:
+ return True
if not file_path:
return True
if os.path.isdir(file_path):
@@ -105,7 +107,9 @@ def setup(log_file, log_level=None):
# avoid spamming the log with debug messages from libraries
r.setLevel(log_level)
- log_file = _absolute_log_file(log_file, DEFAULT_LOG_FILE)
+ # Otherwise name get's destroyed on windows
+ if log_file != LOG_TO_STDERR and log_file != LOG_TO_STDOUT:
+ log_file = _absolute_log_file(log_file, DEFAULT_LOG_FILE)
previous_handler = r.handlers[0] if r.handlers else None
if previous_handler:
@@ -119,7 +123,7 @@ def setup(log_file, log_level=None):
file_handler = StreamHandler(sys.stdout)
file_handler.baseFilename = log_file
else:
- file_handler = StreamHandler()
+ file_handler = StreamHandler(sys.stderr)
file_handler.baseFilename = log_file
else:
try:
diff --git a/cps/oauth.py b/cps/oauth.py
index e5c5fb5f..d754dad7 100644
--- a/cps/oauth.py
+++ b/cps/oauth.py
@@ -30,7 +30,7 @@ except ImportError:
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend
from flask_dance.consumer.storage.sqla import first, _get_real_user
from sqlalchemy.orm.exc import NoResultFound
- backend_resultcode = True # prevent storing values with this resultcode
+ backend_resultcode = True # prevent storing values with this resultcode
except ImportError:
pass
@@ -97,7 +97,7 @@ try:
def set(self, blueprint, token, user=None, user_id=None):
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
u = first(_get_real_user(ref, self.anon_user)
- for ref in (user, self.user, blueprint.config.get("user")))
+ for ref in (user, self.user, blueprint.config.get("user")))
if self.user_required and not u and not uid:
raise ValueError("Cannot set OAuth token without an associated user")
diff --git a/cps/opds.py b/cps/opds.py
index 5cfb5348..cd83709a 100644
--- a/cps/opds.py
+++ b/cps/opds.py
@@ -56,8 +56,8 @@ def requires_basic_auth_if_no_ano(f):
return decorated
-class FeedObject():
- def __init__(self,rating_id , rating_name):
+class FeedObject:
+ def __init__(self, rating_id, rating_name):
self.rating_id = rating_id
self.rating_name = rating_name
@@ -101,7 +101,7 @@ def feed_normal_search():
def feed_new():
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
- db.Books, True, [db.Books.timestamp.desc()])
+ db.Books, True, [db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@@ -119,7 +119,8 @@ def feed_discover():
def feed_best_rated():
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
- db.Books, db.Books.ratings.any(db.Ratings.rating > 9), [db.Books.timestamp.desc()])
+ db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
+ [db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@@ -153,7 +154,8 @@ def feed_hot():
def feed_authorindex():
off = request.args.get("offset") or 0
entries = db.session.query(db.Authors).join(db.books_authors_link).join(db.Books).filter(common_filters())\
- .group_by(text('books_authors_link.author')).order_by(db.Authors.sort).limit(config.config_books_per_page).offset(off)
+ .group_by(text('books_authors_link.author')).order_by(db.Authors.sort).limit(config.config_books_per_page)\
+ .offset(off)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(db.session.query(db.Authors).all()))
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination)
@@ -164,7 +166,9 @@ def feed_authorindex():
def feed_author(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
- db.Books, db.Books.authors.any(db.Authors.id == book_id), [db.Books.timestamp.desc()])
+ db.Books,
+ db.Books.authors.any(db.Authors.id == book_id),
+ [db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@@ -173,7 +177,8 @@ def feed_author(book_id):
def feed_publisherindex():
off = request.args.get("offset") or 0
entries = db.session.query(db.Publishers).join(db.books_publishers_link).join(db.Books).filter(common_filters())\
- .group_by(text('books_publishers_link.publisher')).order_by(db.Publishers.sort).limit(config.config_books_per_page).offset(off)
+ .group_by(text('books_publishers_link.publisher')).order_by(db.Publishers.sort)\
+ .limit(config.config_books_per_page).offset(off)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(db.session.query(db.Publishers).all()))
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination)
@@ -184,7 +189,8 @@ def feed_publisherindex():
def feed_publisher(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
- db.Books, db.Books.publishers.any(db.Publishers.id == book_id),
+ db.Books,
+ db.Books.publishers.any(db.Publishers.id == book_id),
[db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@@ -205,7 +211,9 @@ def feed_categoryindex():
def feed_category(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
- db.Books, db.Books.tags.any(db.Tags.id == book_id), [db.Books.timestamp.desc()])
+ db.Books,
+ db.Books.tags.any(db.Tags.id == book_id),
+ [db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@@ -225,9 +233,12 @@ def feed_seriesindex():
def feed_series(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
- db.Books, db.Books.series.any(db.Series.id == book_id), [db.Books.series_index])
+ db.Books,
+ db.Books.series.any(db.Series.id == book_id),
+ [db.Books.series_index])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
+
@opds.route("/opds/ratings")
@requires_basic_auth_if_no_ano
def feed_ratingindex():
@@ -244,16 +255,18 @@ def feed_ratingindex():
element.append(FeedObject(entry[0].id, "{} Stars".format(entry.name)))
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination)
+
@opds.route("/opds/ratings/")
@requires_basic_auth_if_no_ano
def feed_ratings(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
- db.Books, db.Books.ratings.any(db.Ratings.id == book_id),[db.Books.timestamp.desc()])
+ db.Books,
+ db.Books.ratings.any(db.Ratings.id == book_id),
+ [db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
-
@opds.route("/opds/formats")
@requires_basic_auth_if_no_ano
def feed_formatindex():
@@ -274,7 +287,9 @@ def feed_formatindex():
def feed_format(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
- db.Books, db.Books.data.any(db.Data.format == book_id.upper()), [db.Books.timestamp.desc()])
+ db.Books,
+ db.Books.data.any(db.Data.format == book_id.upper()),
+ [db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@@ -306,7 +321,9 @@ def feed_languagesindex():
def feed_languages(book_id):
off = request.args.get("offset") or 0
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
- db.Books, db.Books.languages.any(db.Languages.id == book_id), [db.Books.timestamp.desc()])
+ db.Books,
+ db.Books.languages.any(db.Languages.id == book_id),
+ [db.Books.timestamp.desc()])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@@ -326,7 +343,8 @@ def feed_shelfindex():
def feed_shelf(book_id):
off = request.args.get("offset") or 0
if current_user.is_anonymous:
- shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == book_id).first()
+ shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
+ ub.Shelf.id == book_id).first()
else:
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
ub.Shelf.id == book_id),
@@ -349,11 +367,11 @@ def feed_shelf(book_id):
@requires_basic_auth_if_no_ano
@download_required
def opds_download_link(book_id, book_format):
- return get_download_link(book_id,book_format.lower())
+ return get_download_link(book_id, book_format.lower())
@opds.route("/ajax/book//")
-@opds.route("/ajax/book/",defaults={'library': ""})
+@opds.route("/ajax/book/", defaults={'library': ""})
@requires_basic_auth_if_no_ano
def get_metadata_calibre_companion(uuid, library):
entry = db.session.query(db.Books).filter(db.Books.uuid.like("%" + uuid + "%")).first()
@@ -369,16 +387,17 @@ def get_metadata_calibre_companion(uuid, library):
def feed_search(term):
if term:
term = term.strip().lower()
- entries = get_search_results( term)
+ entries = get_search_results(term)
entriescount = len(entries) if len(entries) > 0 else 1
pagination = Pagination(1, entriescount, entriescount)
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination)
else:
return render_xml_template('feed.xml', searchterm="")
+
def check_auth(username, password):
if sys.version_info.major == 3:
- username=username.encode('windows-1252')
+ username = username.encode('windows-1252')
user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) ==
username.decode('utf-8').lower()).first()
return bool(user and check_password_hash(str(user.password), password))
@@ -392,13 +411,14 @@ def authenticate():
def render_xml_template(*args, **kwargs):
- #ToDo: return time in current timezone similar to %z
+ # ToDo: return time in current timezone similar to %z
currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00")
xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, *args, **kwargs)
response = make_response(xml)
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
return response
+
@opds.route("/opds/thumb_240_240/")
@opds.route("/opds/cover_240_240/")
@opds.route("/opds/cover_90_90/")
@@ -407,13 +427,15 @@ def render_xml_template(*args, **kwargs):
def feed_get_cover(book_id):
return get_book_cover(book_id)
+
@opds.route("/opds/readbooks")
@requires_basic_auth_if_no_ano
def feed_read_books():
off = request.args.get("offset") or 0
- result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
+ result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
return render_xml_template('feed.xml', entries=result, pagination=pagination)
+
@opds.route("/opds/unreadbooks")
@requires_basic_auth_if_no_ano
def feed_unread_books():
diff --git a/cps/server.py b/cps/server.py
old mode 100755
new mode 100644
index a108181b..d2253ab2
--- a/cps/server.py
+++ b/cps/server.py
@@ -43,7 +43,6 @@ from . import logger
log = logger.create()
-
def _readable_listen_address(address, port):
if ':' in address:
address = "[" + address + "]"
@@ -84,7 +83,8 @@ class WebServer(object):
if os.path.isfile(certfile_path) and os.path.isfile(keyfile_path):
self.ssl_args = dict(certfile=certfile_path, keyfile=keyfile_path)
else:
- log.warning('The specified paths for the ssl certificate file and/or key file seem to be broken. Ignoring ssl.')
+ log.warning('The specified paths for the ssl certificate file and/or key file seem to be broken. '
+ 'Ignoring ssl.')
log.warning('Cert path: %s', certfile_path)
log.warning('Key path: %s', keyfile_path)
diff --git a/cps/services/SyncToken.py b/cps/services/SyncToken.py
index 63d82ac0..1dd4f084 100644
--- a/cps/services/SyncToken.py
+++ b/cps/services/SyncToken.py
@@ -42,10 +42,18 @@ def to_epoch_timestamp(datetime_object):
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
-class SyncToken():
+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.
- As an example use-case, the SyncToken is used to detect books that have been added to the library since the last time the device synced to the server.
+ When serialized over the response headers, the Kobo device will propagate the token onto following
+ requests to the service. As an example use-case, the SyncToken is used to detect books that have been added
+ to the library since the last time the device synced to the server.
Attributes:
books_last_created: Datetime representing the newest book that the device knows about.
@@ -53,21 +61,26 @@ 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 = {
"type": "object",
- "properties": {"version": {"type": "string"}, "data": {"type": "object"},},
+ "properties": {"version": {"type": "string"}, "data": {"type": "object"}, },
}
# This Schema doesn't contain enough information to detect and propagate book deletions from Calibre to the device.
- # A potential solution might be to keep a list of all known book uuids in the token, and look for any missing from the db.
+ # A potential solution might be to keep a list of all known book uuids in the token, and look for any missing
+ # from the db.
data_schema_v1 = {
"type": "object",
"properties": {
"raw_kobo_store_token": {"type": "string"},
"books_last_modified": {"type": "string"},
"books_last_created": {"type": "string"},
+ "archive_last_modified": {"type": "string"},
+ "reading_state_last_modified": {"type": "string"},
+ "tags_last_modified": {"type": "string"},
},
}
@@ -76,10 +89,16 @@ class SyncToken():
raw_kobo_store_token="",
books_last_created=datetime.min,
books_last_modified=datetime.min,
+ archive_last_modified=datetime.min,
+ reading_state_last_modified=datetime.min,
+ tags_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
+ self.reading_state_last_modified = reading_state_last_modified
+ self.tags_last_modified = tags_last_modified
@staticmethod
def from_headers(headers):
@@ -109,12 +128,11 @@ 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")
+ reading_state_last_modified = get_datetime_from_json(data_json, "reading_state_last_modified")
+ tags_last_modified = get_datetime_from_json(data_json, "tags_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 +141,9 @@ 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,
+ reading_state_last_modified=reading_state_last_modified,
+ tags_last_modified=tags_last_modified
)
def set_kobo_store_header(self, store_headers):
@@ -143,6 +164,9 @@ 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),
+ "reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified),
+ "tags_last_modified": to_epoch_timestamp(self.tags_last_modified)
},
}
return b64encode_json(token)
diff --git a/cps/shelf.py b/cps/shelf.py
index c78059ca..4d6d5103 100644
--- a/cps/shelf.py
+++ b/cps/shelf.py
@@ -21,11 +21,12 @@
# along with this program. If not, see .
from __future__ import division, print_function, unicode_literals
+from datetime import datetime
from flask import Blueprint, request, flash, redirect, url_for
from flask_babel import gettext as _
from flask_login import login_required, current_user
-from sqlalchemy.sql.expression import func, or_, and_
+from sqlalchemy.sql.expression import func
from . import logger, ub, searched_ids, db
from .web import render_title_template
@@ -36,6 +37,25 @@ shelf = Blueprint('shelf', __name__)
log = logger.create()
+def check_shelf_edit_permissions(cur_shelf):
+ if not cur_shelf.is_public and not cur_shelf.user_id == int(current_user.id):
+ log.error("User %s not allowed to edit shelf %s", current_user, cur_shelf)
+ return False
+ if cur_shelf.is_public and not current_user.role_edit_shelfs():
+ log.info("User %s not allowed to edit public shelves", current_user)
+ return False
+ return True
+
+
+def check_shelf_view_permissions(cur_shelf):
+ if cur_shelf.is_public:
+ return True
+ if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
+ log.error("User is unauthorized to view non-public shelf: %s", cur_shelf)
+ return False
+ return True
+
+
@shelf.route("/shelf/add//")
@login_required
def add_to_shelf(shelf_id, book_id):
@@ -48,23 +68,15 @@ def add_to_shelf(shelf_id, book_id):
return redirect(url_for('web.index'))
return "Invalid shelf specified", 400
- if not shelf.is_public and not shelf.user_id == int(current_user.id):
- log.error("User %s not allowed to add a book to %s", current_user, shelf)
+ if not check_shelf_edit_permissions(shelf):
if not xhr:
flash(_(u"Sorry you are not allowed to add a book to the the shelf: %(shelfname)s", shelfname=shelf.name),
category="error")
return redirect(url_for('web.index'))
return "Sorry you are not allowed to add a book to the the shelf: %s" % shelf.name, 403
- if shelf.is_public and not current_user.role_edit_shelfs():
- log.info("User %s not allowed to edit public shelves", current_user)
- if not xhr:
- flash(_(u"You are not allowed to edit public shelves"), category="error")
- return redirect(url_for('web.index'))
- return "User is not allowed to edit public shelves", 403
-
book_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id,
- ub.BookShelf.book_id == book_id).first()
+ ub.BookShelf.book_id == book_id).first()
if book_in_shelf:
log.error("Book %s is already part of %s", book_id, shelf)
if not xhr:
@@ -78,8 +90,9 @@ def add_to_shelf(shelf_id, book_id):
else:
maxOrder = maxOrder[0]
- ins = ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1)
- ub.session.add(ins)
+ shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
+ shelf.last_modified = datetime.utcnow()
+ ub.session.merge(shelf)
ub.session.commit()
if not xhr:
flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success")
@@ -99,16 +112,10 @@ def search_to_shelf(shelf_id):
flash(_(u"Invalid shelf specified"), category="error")
return redirect(url_for('web.index'))
- if not shelf.is_public and not shelf.user_id == int(current_user.id):
- log.error("User %s not allowed to add a book to %s", current_user, shelf)
+ if not check_shelf_edit_permissions(shelf):
flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error")
return redirect(url_for('web.index'))
- if shelf.is_public and not current_user.role_edit_shelfs():
- log.error("User %s not allowed to edit public shelves", current_user)
- flash(_(u"User is not allowed to edit public shelves"), category="error")
- return redirect(url_for('web.index'))
-
if current_user.id in searched_ids and searched_ids[current_user.id]:
books_for_shelf = list()
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
@@ -135,8 +142,9 @@ def search_to_shelf(shelf_id):
for book in books_for_shelf:
maxOrder = maxOrder + 1
- ins = ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder)
- ub.session.add(ins)
+ shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
+ shelf.last_modified = datetime.utcnow()
+ ub.session.merge(shelf)
ub.session.commit()
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
else:
@@ -163,8 +171,7 @@ def remove_from_shelf(shelf_id, book_id):
# true 0 x 1
# false 0 x 0
- if (not shelf.is_public and shelf.user_id == int(current_user.id)) \
- or (shelf.is_public and current_user.role_edit_shelfs()):
+ if check_shelf_edit_permissions(shelf):
book_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id,
ub.BookShelf.book_id == book_id).first()
@@ -175,6 +182,7 @@ def remove_from_shelf(shelf_id, book_id):
return "Book already removed from shelf", 410
ub.session.delete(book_shelf)
+ shelf.last_modified = datetime.utcnow()
ub.session.commit()
if not xhr:
@@ -185,7 +193,6 @@ def remove_from_shelf(shelf_id, book_id):
return redirect(url_for('web.index'))
return "", 204
else:
- log.error("User %s not allowed to remove a book from %s", current_user, shelf)
if not xhr:
flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name),
category="error")
@@ -193,7 +200,6 @@ def remove_from_shelf(shelf_id, book_id):
return "Sorry you are not allowed to remove a book from this shelf: %s" % shelf.name, 403
-
@shelf.route("/shelf/create", methods=["GET", "POST"])
@login_required
def create_shelf():
@@ -212,21 +218,24 @@ def create_shelf():
.first() is None
if not is_shelf_name_unique:
- flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]), category="error")
+ flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
+ category="error")
else:
is_shelf_name_unique = ub.session.query(ub.Shelf) \
- .filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) & (ub.Shelf.user_id == int(current_user.id))) \
- .first() is None
+ .filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) &
+ (ub.Shelf.user_id == int(current_user.id)))\
+ .first() is None
if not is_shelf_name_unique:
- flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]), category="error")
+ flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
+ category="error")
if is_shelf_name_unique:
try:
ub.session.add(shelf)
ub.session.commit()
flash(_(u"Shelf %(title)s created", title=to_save["title"]), category="success")
- return redirect(url_for('shelf.show_shelf', shelf_id = shelf.id ))
+ return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
except Exception:
flash(_(u"There was an error"), category="error")
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Create a Shelf"), page="shelfcreate")
@@ -249,18 +258,22 @@ def edit_shelf(shelf_id):
.first() is None
if not is_shelf_name_unique:
- flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]), category="error")
+ flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
+ category="error")
else:
is_shelf_name_unique = ub.session.query(ub.Shelf) \
- .filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) & (ub.Shelf.user_id == int(current_user.id))) \
- .filter(ub.Shelf.id != shelf_id) \
- .first() is None
+ .filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) &
+ (ub.Shelf.user_id == int(current_user.id)))\
+ .filter(ub.Shelf.id != shelf_id)\
+ .first() is None
if not is_shelf_name_unique:
- flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]), category="error")
+ flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
+ category="error")
if is_shelf_name_unique:
shelf.name = to_save["title"]
+ shelf.last_modified = datetime.utcnow()
if "is_public" in to_save:
shelf.is_public = 1
else:
@@ -275,41 +288,33 @@ def edit_shelf(shelf_id):
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit")
+def delete_shelf_helper(cur_shelf):
+ if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
+ return
+ shelf_id = cur_shelf.id
+ ub.session.delete(cur_shelf)
+ ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
+ ub.session.add(ub.ShelfArchive(uuid=cur_shelf.uuid, user_id=cur_shelf.user_id))
+ ub.session.commit()
+ log.info("successfully deleted %s", cur_shelf)
+
+
@shelf.route("/shelf/delete/")
@login_required
def delete_shelf(shelf_id):
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
- deleted = None
- if current_user.role_admin():
- deleted = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).delete()
- else:
- if (not cur_shelf.is_public and cur_shelf.user_id == int(current_user.id)) \
- or (cur_shelf.is_public and current_user.role_edit_shelfs()):
- deleted = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
- ub.Shelf.id == shelf_id),
- and_(ub.Shelf.is_public == 1,
- ub.Shelf.id == shelf_id))).delete()
-
- if deleted:
- ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
- ub.session.commit()
- log.info("successfully deleted %s", cur_shelf)
+ delete_shelf_helper(cur_shelf)
return redirect(url_for('web.index'))
-# @shelf.route("/shelfdown/")
+
@shelf.route("/shelf/", defaults={'shelf_type': 1})
@shelf.route("/shelf//")
def show_shelf(shelf_type, shelf_id):
- if current_user.is_anonymous:
- shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == shelf_id).first()
- else:
- shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
- ub.Shelf.id == shelf_id),
- and_(ub.Shelf.is_public == 1,
- ub.Shelf.id == shelf_id))).first()
+ shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
+
result = list()
# user is allowed to access shelf
- if shelf:
+ if shelf and check_shelf_view_permissions(shelf):
page = "shelf.html" if shelf_type == 1 else 'shelfdown.html'
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id)\
@@ -325,13 +330,12 @@ def show_shelf(shelf_type, shelf_id):
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete()
ub.session.commit()
return render_title_template(page, entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name),
- shelf=shelf, page="shelf")
+ shelf=shelf, page="shelf")
else:
flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error")
return redirect(url_for("web.index"))
-
@shelf.route("/shelf/order/", methods=["GET", "POST"])
@login_required
def order_shelf(shelf_id):
@@ -343,32 +347,28 @@ def order_shelf(shelf_id):
for book in books_in_shelf:
setattr(book, 'order', to_save[str(book.book_id)])
counter += 1
+ # if order diffrent from before -> shelf.last_modified = datetime.utcnow()
ub.session.commit()
- if current_user.is_anonymous:
- shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1, ub.Shelf.id == shelf_id).first()
- else:
- shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
- ub.Shelf.id == shelf_id),
- and_(ub.Shelf.is_public == 1,
- ub.Shelf.id == shelf_id))).first()
+
+ shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
result = list()
- if shelf:
+ if shelf and check_shelf_view_permissions(shelf):
books_in_shelf2 = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \
.order_by(ub.BookShelf.order.asc()).all()
for book in books_in_shelf2:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first()
if cur_book:
- result.append({'title':cur_book.title,
- 'id':cur_book.id,
- 'author':cur_book.authors,
- 'series':cur_book.series,
- 'series_index':cur_book.series_index})
+ result.append({'title': cur_book.title,
+ 'id': cur_book.id,
+ 'author': cur_book.authors,
+ 'series': cur_book.series,
+ 'series_index': cur_book.series_index})
else:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
- result.append({'title':_('Hidden Book'),
- 'id':cur_book.id,
- 'author':[],
- 'series':[]})
+ result.append({'title': _('Hidden Book'),
+ 'id': cur_book.id,
+ 'author': [],
+ 'series': []})
return render_title_template('shelf_order.html', entries=result,
title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, page="shelforder")
diff --git a/cps/static/js/caliBlur.js b/cps/static/js/caliBlur.js
index fc2881ca..17701950 100644
--- a/cps/static/js/caliBlur.js
+++ b/cps/static/js/caliBlur.js
@@ -216,6 +216,8 @@ if ( $( 'body.book' ).length > 0 ) {
.prependTo( '[aria-label^="Download, send"]' );
$( '#have_read_cb' )
.after( ' ' );
+ $( '#archived_cb' )
+ .after( ' ' );
$( '#shelf-actions' ).prependTo( '[aria-label^="Download, send"]' );
@@ -586,6 +588,20 @@ $( '#have_read_cb:checked' ).attr({
'data-viewport': '.btn-toolbar' })
.addClass('readunread-btn-tooltip');
+ $( '#archived_cb' ).attr({
+ 'data-toggle': 'tooltip',
+ 'title': $( '#archived_cb').attr('data-unchecked'),
+ 'data-placement': 'bottom',
+ 'data-viewport': '.btn-toolbar' })
+ .addClass('readunread-btn-tooltip');
+
+ $( '#archived_cb:checked' ).attr({
+ 'data-toggle': 'tooltip',
+ 'title': $( '#archived_cb').attr('data-checked'),
+ 'data-placement': 'bottom',
+ 'data-viewport': '.btn-toolbar' })
+ .addClass('readunread-btn-tooltip');
+
$( 'button#delete' ).attr({
'data-toggle-two': 'tooltip',
'title': $( 'button#delete' ).text(), //'Delete'
@@ -601,6 +617,14 @@ $( '#have_read_cb' ).click(function() {
}
});
+$( '#archived_cb' ).click(function() {
+ if ( $( '#archived_cb:checked' ).length > 0 ) {
+ $( this ).attr('data-original-title', $('#archived_cb').attr('data-checked'));
+ } else {
+ $( this).attr('data-original-title', $('#archived_cb').attr('data-unchecked'));
+ }
+});
+
$( '.btn-group[aria-label="Edit/Delete book"] a' ).attr({
'data-toggle': 'tooltip',
'title': $( '#edit_book' ).text(), // 'Edit'
diff --git a/cps/static/js/details.js b/cps/static/js/details.js
index 491d23bb..395518cb 100644
--- a/cps/static/js/details.js
+++ b/cps/static/js/details.js
@@ -25,6 +25,14 @@ $("#have_read_cb").on("change", function() {
$(this).closest("form").submit();
});
+$(function() {
+ $("#archived_form").ajaxForm();
+});
+
+$("#archived_cb").on("change", function() {
+ $(this).closest("form").submit();
+});
+
(function() {
var templates = {
add: _.template(
diff --git a/cps/subproc_wrapper.py b/cps/subproc_wrapper.py
index 4242a83e..0ad1c1d2 100644
--- a/cps/subproc_wrapper.py
+++ b/cps/subproc_wrapper.py
@@ -45,10 +45,10 @@ def process_open(command, quotes=(), env=None, sout=subprocess.PIPE, serr=subpro
def process_wait(command, serr=subprocess.PIPE):
- '''Run command, wait for process to terminate, and return an iterator over lines of its output.'''
+ # Run command, wait for process to terminate, and return an iterator over lines of its output.
p = process_open(command, serr=serr)
p.wait()
- for l in p.stdout.readlines():
- if isinstance(l, bytes):
- l = l.decode('utf-8')
- yield l
+ for line in p.stdout.readlines():
+ if isinstance(line, bytes):
+ line = line.decode('utf-8')
+ yield line
diff --git a/cps/templates/book_edit.html b/cps/templates/book_edit.html
index 9d6dac7d..b72ef197 100644
--- a/cps/templates/book_edit.html
+++ b/cps/templates/book_edit.html
@@ -61,6 +61,21 @@
{{_('Description')}}
+
+
+
{{_('Tags')}}
@@ -169,7 +184,7 @@
{{_('Fetch Metadata')}}
{{_('Save')}}
- {{_('Cancel')}}
+ {{_('Cancel')}}
@@ -185,12 +200,20 @@
{{_('Are you really sure?')}}
+
{{_('This book will be permanently erased from database')}}
{{_('and hard disk')}}
+
+ {% if config.config_kobo_sync %}
+
+ {{_('Important Kobo Note: deleted books will remain on any paired Kobo device.')}}
+ {{_('Books must first be archived and the device synced before a book can safely be deleted.')}}
+
+ {% endif %}
@@ -277,6 +300,21 @@
'source': {{_('Source')|safe|tojson}},
};
var language = '{{ g.user.locale }}';
+
+ $("#add-identifier-line").click(function() {
+ // create a random identifier type to have a valid name in form. This will not be used when dealing with the form
+ var rand_id = Math.floor(Math.random() * 1000000).toString();
+ var line = '';
+ line += ' ';
+ line += ' ';
+ line += '{{_('Remove')}} ';
+ line += ' ';
+ $("#identifier-table").append(line);
+ });
+ function removeIdentifierLine(el) {
+ $(el).parent().parent().remove();
+ }
+
diff --git a/cps/templates/detail.html b/cps/templates/detail.html
index c336ac66..8315a8f2 100644
--- a/cps/templates/detail.html
+++ b/cps/templates/detail.html
@@ -202,6 +202,14 @@
+
+
+
{% endif %}
diff --git a/cps/templates/list.html b/cps/templates/list.html
index b1f2b9e1..bedfa7a0 100644
--- a/cps/templates/list.html
+++ b/cps/templates/list.html
@@ -4,7 +4,7 @@