Merge branch 'Develop'
This commit is contained in:
commit
858d099509
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -23,6 +23,7 @@ vendor/
|
||||||
# calibre-web
|
# calibre-web
|
||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
cps/cache
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
*.bak
|
*.bak
|
||||||
|
|
66
cps.py
66
cps.py
|
@ -2,7 +2,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
# Copyright (C) 2012-2019 OzzieIsaacs
|
# Copyright (C) 2022 OzzieIsaacs
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
@ -17,66 +17,18 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
# Insert local directories into path
|
# Add local path to sys.path so we can import cps
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
path = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor'))
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
|
|
||||||
from cps import create_app
|
|
||||||
from cps import web_server
|
|
||||||
from cps.opds import opds
|
|
||||||
from cps.web import web
|
|
||||||
from cps.jinjia import jinjia
|
|
||||||
from cps.about import about
|
|
||||||
from cps.shelf import shelf
|
|
||||||
from cps.admin import admi
|
|
||||||
from cps.gdrive import gdrive
|
|
||||||
from cps.editbooks import EditBook
|
|
||||||
from cps.remotelogin import remotelogin
|
|
||||||
from cps.search_metadata import meta
|
|
||||||
from cps.error_handler import init_errorhandler
|
|
||||||
|
|
||||||
try:
|
|
||||||
from cps.kobo import kobo, get_kobo_activated
|
|
||||||
from cps.kobo_auth import kobo_auth
|
|
||||||
kobo_available = get_kobo_activated()
|
|
||||||
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
|
||||||
kobo_available = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
from cps.oauth_bb import oauth
|
|
||||||
oauth_available = True
|
|
||||||
except ImportError:
|
|
||||||
oauth_available = False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
init_errorhandler()
|
|
||||||
|
|
||||||
app.register_blueprint(web)
|
|
||||||
app.register_blueprint(opds)
|
|
||||||
app.register_blueprint(jinjia)
|
|
||||||
app.register_blueprint(about)
|
|
||||||
app.register_blueprint(shelf)
|
|
||||||
app.register_blueprint(admi)
|
|
||||||
app.register_blueprint(remotelogin)
|
|
||||||
app.register_blueprint(meta)
|
|
||||||
app.register_blueprint(gdrive)
|
|
||||||
app.register_blueprint(EditBook)
|
|
||||||
if kobo_available:
|
|
||||||
app.register_blueprint(kobo)
|
|
||||||
app.register_blueprint(kobo_auth)
|
|
||||||
if oauth_available:
|
|
||||||
app.register_blueprint(oauth)
|
|
||||||
success = web_server.start()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
|
|
||||||
|
from cps.main import main
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
138
cps/__init__.py
138
cps/__init__.py
|
@ -25,24 +25,21 @@ import sys
|
||||||
import os
|
import os
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
from babel import Locale as LC
|
from flask import Flask
|
||||||
from babel import negotiate_locale
|
|
||||||
from babel.core import UnknownLocaleError
|
|
||||||
from flask import Flask, request, g
|
|
||||||
from .MyLoginManager import MyLoginManager
|
from .MyLoginManager import MyLoginManager
|
||||||
from flask_babel import Babel
|
|
||||||
from flask_principal import Principal
|
from flask_principal import Principal
|
||||||
|
|
||||||
from . import config_sql, logger, cache_buster, cli, ub, db
|
from . import logger
|
||||||
|
from .cli import CliParameter
|
||||||
|
from .constants import CONFIG_DIR
|
||||||
from .reverseproxy import ReverseProxied
|
from .reverseproxy import ReverseProxied
|
||||||
from .server import WebServer
|
from .server import WebServer
|
||||||
from .dep_check import dependency_check
|
from .dep_check import dependency_check
|
||||||
|
from .updater import Updater
|
||||||
try:
|
from .babel import babel
|
||||||
import lxml
|
from . import config_sql
|
||||||
lxml_present = True
|
from . import cache_buster
|
||||||
except ImportError:
|
from . import ub, db
|
||||||
lxml_present = False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
@ -50,6 +47,7 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
wtf_present = False
|
wtf_present = False
|
||||||
|
|
||||||
|
|
||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
||||||
mimetypes.add_type('application/epub+zip', '.epub')
|
mimetypes.add_type('application/epub+zip', '.epub')
|
||||||
|
@ -71,6 +69,8 @@ mimetypes.add_type('application/ogg', '.oga')
|
||||||
mimetypes.add_type('text/css', '.css')
|
mimetypes.add_type('text/css', '.css')
|
||||||
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
@ -79,58 +79,69 @@ app.config.update(
|
||||||
WTF_CSRF_SSL_STRICT=False
|
WTF_CSRF_SSL_STRICT=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
lm = MyLoginManager()
|
lm = MyLoginManager()
|
||||||
lm.login_view = 'web.login'
|
|
||||||
lm.anonymous_user = ub.Anonymous
|
config = config_sql._ConfigSQL()
|
||||||
lm.session_protection = 'strong'
|
|
||||||
|
cli_param = CliParameter()
|
||||||
|
|
||||||
if wtf_present:
|
if wtf_present:
|
||||||
csrf = CSRFProtect()
|
csrf = CSRFProtect()
|
||||||
csrf.init_app(app)
|
|
||||||
else:
|
else:
|
||||||
csrf = None
|
csrf = None
|
||||||
|
|
||||||
ub.init_db(cli.settings_path)
|
calibre_db = db.CalibreDB()
|
||||||
# pylint: disable=no-member
|
|
||||||
config = config_sql.load_configuration(ub.session)
|
|
||||||
|
|
||||||
web_server = WebServer()
|
web_server = WebServer()
|
||||||
|
|
||||||
babel = Babel()
|
updater_thread = Updater()
|
||||||
_BABEL_TRANSLATIONS = set()
|
|
||||||
|
|
||||||
log = logger.create()
|
|
||||||
|
|
||||||
|
|
||||||
from . import services
|
|
||||||
|
|
||||||
db.CalibreDB.update_config(config)
|
|
||||||
db.CalibreDB.setup_db(config.config_calibre_dir, cli.settings_path)
|
|
||||||
|
|
||||||
|
|
||||||
calibre_db = db.CalibreDB()
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
|
lm.login_view = 'web.login'
|
||||||
|
lm.anonymous_user = ub.Anonymous
|
||||||
|
lm.session_protection = 'strong'
|
||||||
|
|
||||||
|
if csrf:
|
||||||
|
csrf.init_app(app)
|
||||||
|
|
||||||
|
cli_param.init()
|
||||||
|
|
||||||
|
ub.init_db(cli_param.settings_path, cli_param.user_credentials)
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
config_sql.load_configuration(config, ub.session, cli_param)
|
||||||
|
|
||||||
|
db.CalibreDB.update_config(config)
|
||||||
|
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
||||||
|
calibre_db.init_db()
|
||||||
|
|
||||||
|
updater_thread.init_updater(config, web_server)
|
||||||
|
# Perform dry run of updater and exit afterwards
|
||||||
|
if cli_param.dry_run:
|
||||||
|
updater_thread.dry_run()
|
||||||
|
sys.exit(0)
|
||||||
|
updater_thread.start()
|
||||||
|
|
||||||
if sys.version_info < (3, 0):
|
if sys.version_info < (3, 0):
|
||||||
log.info(
|
log.info(
|
||||||
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, please update your installation to Python3 ***')
|
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
|
||||||
|
'please update your installation to Python3 ***')
|
||||||
print(
|
print(
|
||||||
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, please update your installation to Python3 ***')
|
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
|
||||||
|
'please update your installation to Python3 ***')
|
||||||
web_server.stop(True)
|
web_server.stop(True)
|
||||||
sys.exit(5)
|
sys.exit(5)
|
||||||
if not lxml_present:
|
|
||||||
log.info('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***')
|
|
||||||
print('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***')
|
|
||||||
web_server.stop(True)
|
|
||||||
sys.exit(6)
|
|
||||||
if not wtf_present:
|
if not wtf_present:
|
||||||
log.info('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***')
|
log.info('*** "flask-WTF" is needed for calibre-web to run. '
|
||||||
print('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***')
|
'Please install it using pip: "pip install flask-WTF" ***')
|
||||||
|
print('*** "flask-WTF" is needed for calibre-web to run. '
|
||||||
|
'Please install it using pip: "pip install flask-WTF" ***')
|
||||||
web_server.stop(True)
|
web_server.stop(True)
|
||||||
sys.exit(7)
|
sys.exit(7)
|
||||||
for res in dependency_check() + dependency_check(True):
|
for res in dependency_check() + dependency_check(True):
|
||||||
log.info('*** "{}" version does not fit the requirements. Should: {}, Found: {}, please consider installing required version ***'
|
log.info('*** "{}" version does not fit the requirements. '
|
||||||
|
'Should: {}, Found: {}, please consider installing required version ***'
|
||||||
.format(res['name'],
|
.format(res['name'],
|
||||||
res['target'],
|
res['target'],
|
||||||
res['found']))
|
res['found']))
|
||||||
|
@ -147,8 +158,8 @@ def create_app():
|
||||||
web_server.init_app(app, config)
|
web_server.init_app(app, config)
|
||||||
|
|
||||||
babel.init_app(app)
|
babel.init_app(app)
|
||||||
_BABEL_TRANSLATIONS.update(str(item) for item in babel.list_translations())
|
|
||||||
_BABEL_TRANSLATIONS.add('en')
|
from . import services
|
||||||
|
|
||||||
if services.ldap:
|
if services.ldap:
|
||||||
services.ldap.init_app(app, config)
|
services.ldap.init_app(app, config)
|
||||||
|
@ -156,39 +167,12 @@ def create_app():
|
||||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||||
config.config_goodreads_api_secret,
|
config.config_goodreads_api_secret,
|
||||||
config.config_use_goodreads)
|
config.config_use_goodreads)
|
||||||
config.store_calibre_uuid(calibre_db, db.LibraryId)
|
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||||
|
# Register scheduled tasks
|
||||||
|
from .schedule import register_scheduled_tasks, register_startup_tasks
|
||||||
|
register_scheduled_tasks(config.schedule_reconnect)
|
||||||
|
register_startup_tasks()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@babel.localeselector
|
|
||||||
def get_locale():
|
|
||||||
# if a user is logged in, use the locale from the user settings
|
|
||||||
user = getattr(g, 'user', None)
|
|
||||||
if user is not None and hasattr(user, "locale"):
|
|
||||||
if user.name != 'Guest': # if the account is the guest account bypass the config lang settings
|
|
||||||
return user.locale
|
|
||||||
|
|
||||||
preferred = list()
|
|
||||||
if request.accept_languages:
|
|
||||||
for x in request.accept_languages.values():
|
|
||||||
try:
|
|
||||||
preferred.append(str(LC.parse(x.replace('-', '_'))))
|
|
||||||
except (UnknownLocaleError, ValueError) as e:
|
|
||||||
log.debug('Could not parse locale "%s": %s', x, e)
|
|
||||||
|
|
||||||
return negotiate_locale(preferred or ['en'], _BABEL_TRANSLATIONS)
|
|
||||||
|
|
||||||
|
|
||||||
@babel.timezoneselector
|
|
||||||
def get_timezone():
|
|
||||||
user = getattr(g, 'user', None)
|
|
||||||
return user.timezone if user else None
|
|
||||||
|
|
||||||
|
|
||||||
from .updater import Updater
|
|
||||||
updater_thread = Updater()
|
|
||||||
|
|
||||||
# Perform dry run of updater and exit afterwards
|
|
||||||
if cli.dry_run:
|
|
||||||
updater_thread.dry_run()
|
|
||||||
sys.exit(0)
|
|
||||||
updater_thread.start()
|
|
||||||
|
|
|
@ -65,13 +65,13 @@ _VERSIONS = OrderedDict(
|
||||||
SQLite=sqlite3.sqlite_version,
|
SQLite=sqlite3.sqlite_version,
|
||||||
)
|
)
|
||||||
_VERSIONS.update(ret)
|
_VERSIONS.update(ret)
|
||||||
_VERSIONS.update(uploader.get_versions(False))
|
_VERSIONS.update(uploader.get_versions())
|
||||||
|
|
||||||
|
|
||||||
def collect_stats():
|
def collect_stats():
|
||||||
_VERSIONS['ebook converter'] = _(converter.get_calibre_version())
|
_VERSIONS['ebook converter'] = converter.get_calibre_version()
|
||||||
_VERSIONS['unrar'] = _(converter.get_unrar_version())
|
_VERSIONS['unrar'] = converter.get_unrar_version()
|
||||||
_VERSIONS['kepubify'] = _(converter.get_kepubify_version())
|
_VERSIONS['kepubify'] = converter.get_kepubify_version()
|
||||||
return _VERSIONS
|
return _VERSIONS
|
||||||
|
|
||||||
|
|
||||||
|
|
919
cps/admin.py
919
cps/admin.py
File diff suppressed because it is too large
Load Diff
39
cps/babel.py
Normal file
39
cps/babel.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
from babel import negotiate_locale
|
||||||
|
from flask_babel import Babel, Locale
|
||||||
|
from babel.core import UnknownLocaleError
|
||||||
|
from flask import request, g
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
babel = Babel()
|
||||||
|
|
||||||
|
|
||||||
|
@babel.localeselector
|
||||||
|
def get_locale():
|
||||||
|
# if a user is logged in, use the locale from the user settings
|
||||||
|
user = getattr(g, 'user', None)
|
||||||
|
if user is not None and hasattr(user, "locale"):
|
||||||
|
if user.name != 'Guest': # if the account is the guest account bypass the config lang settings
|
||||||
|
return user.locale
|
||||||
|
|
||||||
|
preferred = list()
|
||||||
|
if request.accept_languages:
|
||||||
|
for x in request.accept_languages.values():
|
||||||
|
try:
|
||||||
|
preferred.append(str(Locale.parse(x.replace('-', '_'))))
|
||||||
|
except (UnknownLocaleError, ValueError) as e:
|
||||||
|
log.debug('Could not parse locale "%s": %s', x, e)
|
||||||
|
|
||||||
|
return negotiate_locale(preferred or ['en'], get_available_translations())
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_locale_language(user_language):
|
||||||
|
return Locale.parse(user_language).get_language_name(get_locale())
|
||||||
|
|
||||||
|
def get_available_locale():
|
||||||
|
return [Locale('en')] + babel.list_translations()
|
||||||
|
|
||||||
|
def get_available_translations():
|
||||||
|
return set(str(item) for item in get_available_locale())
|
111
cps/cli.py
111
cps/cli.py
|
@ -31,96 +31,99 @@ def version_info():
|
||||||
return "Calibre-Web version: %s - unkown git-clone" % _STABLE_VERSION['version']
|
return "Calibre-Web version: %s - unkown git-clone" % _STABLE_VERSION['version']
|
||||||
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1])
|
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1])
|
||||||
|
|
||||||
|
class CliParameter(object):
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='Calibre Web is a web app'
|
def init(self):
|
||||||
' providing a interface for browsing, reading and downloading eBooks\n', prog='cps.py')
|
self.arg_parser()
|
||||||
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')
|
def arg_parser(self):
|
||||||
parser.add_argument('-c', metavar='path',
|
parser = argparse.ArgumentParser(description='Calibre Web is a web app'
|
||||||
|
' 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',
|
||||||
help='path and name to SSL certfile, e.g. /opt/test.cert, works only in combination with keyfile')
|
help='path and name to SSL certfile, e.g. /opt/test.cert, works only in combination with keyfile')
|
||||||
parser.add_argument('-k', metavar='path',
|
parser.add_argument('-k', metavar='path',
|
||||||
help='path and name to SSL keyfile, e.g. /opt/test.key, works only in combination with certfile')
|
help='path and name to SSL keyfile, e.g. /opt/test.key, works only in combination with certfile')
|
||||||
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
|
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
|
||||||
version=version_info())
|
version=version_info())
|
||||||
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
||||||
parser.add_argument('-s', metavar='user:pass', help='Sets specific username to new password and exits Calibre-Web')
|
parser.add_argument('-s', metavar='user:pass',
|
||||||
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
|
help='Sets specific username to new password and exits Calibre-Web')
|
||||||
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
|
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
|
||||||
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions in advance '
|
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
|
||||||
|
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions in advance '
|
||||||
'and exits Calibre-Web')
|
'and exits Calibre-Web')
|
||||||
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
|
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE)
|
self.settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE)
|
||||||
gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE)
|
self.gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE)
|
||||||
|
|
||||||
if os.path.isdir(settings_path):
|
if os.path.isdir(self.settings_path):
|
||||||
settings_path = os.path.join(settings_path, DEFAULT_SETTINGS_FILE)
|
self.settings_path = os.path.join(self.settings_path, DEFAULT_SETTINGS_FILE)
|
||||||
|
|
||||||
if os.path.isdir(gd_path):
|
if os.path.isdir(self.gd_path):
|
||||||
gd_path = os.path.join(gd_path, DEFAULT_GDRIVE_FILE)
|
self.gd_path = os.path.join(self.gd_path, DEFAULT_GDRIVE_FILE)
|
||||||
|
|
||||||
|
|
||||||
# handle and check parameter for ssl encryption
|
# handle and check parameter for ssl encryption
|
||||||
certfilepath = None
|
self.certfilepath = None
|
||||||
keyfilepath = None
|
self.keyfilepath = None
|
||||||
if args.c:
|
if args.c:
|
||||||
if os.path.isfile(args.c):
|
if os.path.isfile(args.c):
|
||||||
certfilepath = args.c
|
self.certfilepath = args.c
|
||||||
else:
|
else:
|
||||||
print("Certfile path is invalid. Exiting...")
|
print("Certfile path is invalid. Exiting...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.c == "":
|
if args.c == "":
|
||||||
certfilepath = ""
|
self.certfilepath = ""
|
||||||
|
|
||||||
if args.k:
|
if args.k:
|
||||||
if os.path.isfile(args.k):
|
if os.path.isfile(args.k):
|
||||||
keyfilepath = args.k
|
self.keyfilepath = args.k
|
||||||
else:
|
else:
|
||||||
print("Keyfile path is invalid. Exiting...")
|
print("Keyfile path is invalid. Exiting...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if (args.k and not args.c) or (not args.k and args.c):
|
if (args.k and not args.c) or (not args.k and args.c):
|
||||||
print("Certfile and Keyfile have to be used together. Exiting...")
|
print("Certfile and Keyfile have to be used together. Exiting...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.k == "":
|
if args.k == "":
|
||||||
keyfilepath = ""
|
self.keyfilepath = ""
|
||||||
|
|
||||||
|
# dry run updater
|
||||||
# dry run updater
|
self.dry_run =args.d or None
|
||||||
dry_run = args.d or None
|
# enable reconnect endpoint for docker database reconnect
|
||||||
# enable reconnect endpoint for docker database reconnect
|
self.reconnect_enable = args.r or os.environ.get("CALIBRE_RECONNECT", None)
|
||||||
reconnect_enable = args.r or os.environ.get("CALIBRE_RECONNECT", None)
|
# load covers from localhost
|
||||||
# load covers from localhost
|
self.allow_localhost = args.l or os.environ.get("CALIBRE_LOCALHOST", None)
|
||||||
allow_localhost = args.l or os.environ.get("CALIBRE_LOCALHOST", None)
|
# handle and check ip address argument
|
||||||
# handle and check ip address argument
|
self.ip_address = args.i or None
|
||||||
ip_address = args.i or None
|
if self.ip_address:
|
||||||
|
|
||||||
|
|
||||||
if ip_address:
|
|
||||||
try:
|
try:
|
||||||
# try to parse the given ip address with socket
|
# try to parse the given ip address with socket
|
||||||
if hasattr(socket, 'inet_pton'):
|
if hasattr(socket, 'inet_pton'):
|
||||||
if ':' in ip_address:
|
if ':' in self.ip_address:
|
||||||
socket.inet_pton(socket.AF_INET6, ip_address)
|
socket.inet_pton(socket.AF_INET6, self.ip_address)
|
||||||
else:
|
else:
|
||||||
socket.inet_pton(socket.AF_INET, ip_address)
|
socket.inet_pton(socket.AF_INET, self.ip_address)
|
||||||
else:
|
else:
|
||||||
# on windows python < 3.4, inet_pton is not available
|
# on windows python < 3.4, inet_pton is not available
|
||||||
# inet_atom only handles IPv4 addresses
|
# inet_atom only handles IPv4 addresses
|
||||||
socket.inet_aton(ip_address)
|
socket.inet_aton(self.ip_address)
|
||||||
except socket.error as err:
|
except socket.error as err:
|
||||||
print(ip_address, ':', err)
|
print(self.ip_address, ':', err)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# handle and check user password argument
|
# handle and check user password argument
|
||||||
user_credentials = args.s or None
|
self.user_credentials = args.s or None
|
||||||
if user_credentials and ":" not in user_credentials:
|
if self.user_credentials and ":" not in self.user_credentials:
|
||||||
print("No valid 'username:password' format")
|
print("No valid 'username:password' format")
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
if args.f:
|
if args.f:
|
||||||
print("Warning: -f flag is depreciated and will be removed in next version")
|
print("Warning: -f flag is depreciated and will be removed in next version")
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,7 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
from . import constants, cli, logger
|
from . import constants, logger
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
@ -134,13 +134,19 @@ class _Settings(_Base):
|
||||||
config_calibre = Column(String)
|
config_calibre = Column(String)
|
||||||
config_rarfile_location = Column(String, default=None)
|
config_rarfile_location = Column(String, default=None)
|
||||||
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
||||||
config_unicode_filename =Column(Boolean, default=False)
|
config_unicode_filename = Column(Boolean, default=False)
|
||||||
|
|
||||||
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
||||||
|
|
||||||
config_reverse_proxy_login_header_name = Column(String)
|
config_reverse_proxy_login_header_name = Column(String)
|
||||||
config_allow_reverse_proxy_header_login = Column(Boolean, default=False)
|
config_allow_reverse_proxy_header_login = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
schedule_start_time = Column(Integer, default=4)
|
||||||
|
schedule_duration = Column(Integer, default=10)
|
||||||
|
schedule_generate_book_covers = Column(Boolean, default=False)
|
||||||
|
schedule_generate_series_covers = Column(Boolean, default=False)
|
||||||
|
schedule_reconnect = Column(Boolean, default=False)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__class__.__name__
|
return self.__class__.__name__
|
||||||
|
|
||||||
|
@ -148,12 +154,16 @@ class _Settings(_Base):
|
||||||
# Class holds all application specific settings in calibre-web
|
# Class holds all application specific settings in calibre-web
|
||||||
class _ConfigSQL(object):
|
class _ConfigSQL(object):
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
def __init__(self, session):
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def init_config(self, session, cli):
|
||||||
self._session = session
|
self._session = session
|
||||||
self._settings = None
|
self._settings = None
|
||||||
self.db_configured = None
|
self.db_configured = None
|
||||||
self.config_calibre_dir = None
|
self.config_calibre_dir = None
|
||||||
self.load()
|
self.load()
|
||||||
|
self.cli = cli
|
||||||
|
|
||||||
change = False
|
change = False
|
||||||
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
||||||
|
@ -171,7 +181,6 @@ class _ConfigSQL(object):
|
||||||
if change:
|
if change:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
def _read_from_storage(self):
|
def _read_from_storage(self):
|
||||||
if self._settings is None:
|
if self._settings is None:
|
||||||
log.debug("_ConfigSQL._read_from_storage")
|
log.debug("_ConfigSQL._read_from_storage")
|
||||||
|
@ -179,22 +188,21 @@ class _ConfigSQL(object):
|
||||||
return self._settings
|
return self._settings
|
||||||
|
|
||||||
def get_config_certfile(self):
|
def get_config_certfile(self):
|
||||||
if cli.certfilepath:
|
if self.cli.certfilepath:
|
||||||
return cli.certfilepath
|
return self.cli.certfilepath
|
||||||
if cli.certfilepath == "":
|
if self.cli.certfilepath == "":
|
||||||
return None
|
return None
|
||||||
return self.config_certfile
|
return self.config_certfile
|
||||||
|
|
||||||
def get_config_keyfile(self):
|
def get_config_keyfile(self):
|
||||||
if cli.keyfilepath:
|
if self.cli.keyfilepath:
|
||||||
return cli.keyfilepath
|
return self.cli.keyfilepath
|
||||||
if cli.certfilepath == "":
|
if self.cli.certfilepath == "":
|
||||||
return None
|
return None
|
||||||
return self.config_keyfile
|
return self.config_keyfile
|
||||||
|
|
||||||
@staticmethod
|
def get_config_ipaddress(self):
|
||||||
def get_config_ipaddress():
|
return self.cli.ip_address or ""
|
||||||
return cli.ip_address or ""
|
|
||||||
|
|
||||||
def _has_role(self, role_flag):
|
def _has_role(self, role_flag):
|
||||||
return constants.has_flag(self.config_default_role, role_flag)
|
return constants.has_flag(self.config_default_role, role_flag)
|
||||||
|
@ -255,6 +263,8 @@ class _ConfigSQL(object):
|
||||||
return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0)
|
return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0)
|
||||||
or (self.mail_gmail_token != {} and self.mail_server_type == 1))
|
or (self.mail_gmail_token != {} and self.mail_server_type == 1))
|
||||||
|
|
||||||
|
def get_scheduled_task_settings(self):
|
||||||
|
return {k:v for k, v in self.__dict__.items() if k.startswith('schedule_')}
|
||||||
|
|
||||||
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
|
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
|
||||||
"""Possibly updates a field of this object.
|
"""Possibly updates a field of this object.
|
||||||
|
@ -286,11 +296,10 @@ class _ConfigSQL(object):
|
||||||
def toDict(self):
|
def toDict(self):
|
||||||
storage = {}
|
storage = {}
|
||||||
for k, v in self.__dict__.items():
|
for k, v in self.__dict__.items():
|
||||||
if k[0] != '_' and not k.endswith("password") and not k.endswith("secret"):
|
if k[0] != '_' and not k.endswith("password") and not k.endswith("secret") and not k == "cli":
|
||||||
storage[k] = v
|
storage[k] = v
|
||||||
return storage
|
return storage
|
||||||
|
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
'''Load all configuration values from the underlying storage.'''
|
'''Load all configuration values from the underlying storage.'''
|
||||||
s = self._read_from_storage() # type: _Settings
|
s = self._read_from_storage() # type: _Settings
|
||||||
|
@ -411,6 +420,7 @@ def autodetect_calibre_binary():
|
||||||
return element
|
return element
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def autodetect_unrar_binary():
|
def autodetect_unrar_binary():
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
||||||
|
@ -422,6 +432,7 @@ def autodetect_unrar_binary():
|
||||||
return element
|
return element
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def autodetect_kepubify_binary():
|
def autodetect_kepubify_binary():
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
||||||
|
@ -433,6 +444,7 @@ def autodetect_kepubify_binary():
|
||||||
return element
|
return element
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _migrate_database(session):
|
def _migrate_database(session):
|
||||||
# make sure the table is created, if it does not exist
|
# make sure the table is created, if it does not exist
|
||||||
_Base.metadata.create_all(session.bind)
|
_Base.metadata.create_all(session.bind)
|
||||||
|
@ -440,14 +452,15 @@ def _migrate_database(session):
|
||||||
_migrate_table(session, _Flask_Settings)
|
_migrate_table(session, _Flask_Settings)
|
||||||
|
|
||||||
|
|
||||||
def load_configuration(session):
|
def load_configuration(conf, session, cli):
|
||||||
_migrate_database(session)
|
_migrate_database(session)
|
||||||
|
|
||||||
if not session.query(_Settings).count():
|
if not session.query(_Settings).count():
|
||||||
session.add(_Settings())
|
session.add(_Settings())
|
||||||
session.commit()
|
session.commit()
|
||||||
conf = _ConfigSQL(session)
|
# conf = _ConfigSQL()
|
||||||
return conf
|
conf.init_config(session, cli)
|
||||||
|
# return conf
|
||||||
|
|
||||||
def get_flask_session_key(_session):
|
def get_flask_session_key(_session):
|
||||||
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
||||||
|
|
|
@ -23,6 +23,9 @@ from sqlalchemy import __version__ as sql_version
|
||||||
|
|
||||||
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2, 0, 0])
|
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2, 0, 0])
|
||||||
|
|
||||||
|
# APP_MODE - production, development, or test
|
||||||
|
APP_MODE = os.environ.get('APP_MODE', 'production')
|
||||||
|
|
||||||
# if installed via pip this variable is set to true (empty file with name .HOMEDIR present)
|
# if installed via pip this variable is set to true (empty file with name .HOMEDIR present)
|
||||||
HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR'))
|
HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR'))
|
||||||
|
|
||||||
|
@ -35,6 +38,10 @@ STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
|
||||||
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
||||||
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
|
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
|
||||||
|
|
||||||
|
# Cache dir - use CACHE_DIR environment variable, otherwise use the default directory: cps/cache
|
||||||
|
DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache')
|
||||||
|
CACHE_DIR = os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR)
|
||||||
|
|
||||||
if HOME_CONFIG:
|
if HOME_CONFIG:
|
||||||
home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web")
|
home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web")
|
||||||
if not os.path.exists(home_dir):
|
if not os.path.exists(home_dir):
|
||||||
|
@ -164,6 +171,19 @@ NIGHTLY_VERSION[1] = '$Format:%cI$'
|
||||||
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
|
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
|
||||||
# NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00'
|
# NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00'
|
||||||
|
|
||||||
|
# CACHE
|
||||||
|
CACHE_TYPE_THUMBNAILS = 'thumbnails'
|
||||||
|
|
||||||
|
# Thumbnail Types
|
||||||
|
THUMBNAIL_TYPE_COVER = 1
|
||||||
|
THUMBNAIL_TYPE_SERIES = 2
|
||||||
|
THUMBNAIL_TYPE_AUTHOR = 3
|
||||||
|
|
||||||
|
# Thumbnails Sizes
|
||||||
|
COVER_THUMBNAIL_ORIGINAL = 0
|
||||||
|
COVER_THUMBNAIL_SMALL = 1
|
||||||
|
COVER_THUMBNAIL_MEDIUM = 2
|
||||||
|
COVER_THUMBNAIL_LARGE = 3
|
||||||
|
|
||||||
# clean-up the module namespace
|
# clean-up the module namespace
|
||||||
del sys, os, namedtuple
|
del sys, os, namedtuple
|
||||||
|
|
|
@ -18,7 +18,8 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from flask_babel import gettext as _
|
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from . import config, logger
|
from . import config, logger
|
||||||
from .subproc_wrapper import process_wait
|
from .subproc_wrapper import process_wait
|
||||||
|
@ -26,9 +27,9 @@ from .subproc_wrapper import process_wait
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
# _() necessary to make babel aware of string for translation
|
# strings getting translated when used
|
||||||
_NOT_INSTALLED = _('not installed')
|
_NOT_INSTALLED = N_('not installed')
|
||||||
_EXECUTION_ERROR = _('Execution permissions missing')
|
_EXECUTION_ERROR = N_('Execution permissions missing')
|
||||||
|
|
||||||
|
|
||||||
def _get_command_version(path, pattern, argument=None):
|
def _get_command_version(path, pattern, argument=None):
|
||||||
|
|
19
cps/db.py
19
cps/db.py
|
@ -25,6 +25,7 @@ from datetime import datetime
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
import unidecode
|
import unidecode
|
||||||
|
|
||||||
|
from sqlite3 import OperationalError as sqliteOperationalError
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
|
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
|
||||||
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
|
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
|
||||||
|
@ -42,6 +43,7 @@ from sqlalchemy.sql.expression import and_, true, false, text, func, or_
|
||||||
from sqlalchemy.ext.associationproxy import association_proxy
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
from flask_babel import get_locale
|
||||||
from flask import flash
|
from flask import flash
|
||||||
|
|
||||||
from . import logger, ub, isoLanguages
|
from . import logger, ub, isoLanguages
|
||||||
|
@ -88,7 +90,7 @@ books_publishers_link = Table('books_publishers_link', Base.metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LibraryId(Base):
|
class Library_Id(Base):
|
||||||
__tablename__ = 'library_id'
|
__tablename__ = 'library_id'
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
uuid = Column(String, nullable=False)
|
uuid = Column(String, nullable=False)
|
||||||
|
@ -439,10 +441,15 @@ class CalibreDB:
|
||||||
# instances alive once they reach the end of their respective scopes
|
# instances alive once they reach the end of their respective scopes
|
||||||
instances = WeakSet()
|
instances = WeakSet()
|
||||||
|
|
||||||
def __init__(self, expire_on_commit=True):
|
def __init__(self, expire_on_commit=True, init=False):
|
||||||
""" Initialize a new CalibreDB session
|
""" Initialize a new CalibreDB session
|
||||||
"""
|
"""
|
||||||
self.session = None
|
self.session = None
|
||||||
|
if init:
|
||||||
|
self.init_db(expire_on_commit)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(self, expire_on_commit=True):
|
||||||
if self._init:
|
if self._init:
|
||||||
self.init_session(expire_on_commit)
|
self.init_session(expire_on_commit)
|
||||||
|
|
||||||
|
@ -542,7 +549,7 @@ class CalibreDB:
|
||||||
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
||||||
local_session = scoped_session(sessionmaker())
|
local_session = scoped_session(sessionmaker())
|
||||||
local_session.configure(bind=connection)
|
local_session.configure(bind=connection)
|
||||||
database_uuid = local_session().query(LibraryId).one_or_none()
|
database_uuid = local_session().query(Library_Id).one_or_none()
|
||||||
# local_session.dispose()
|
# local_session.dispose()
|
||||||
|
|
||||||
check_engine.connect()
|
check_engine.connect()
|
||||||
|
@ -895,7 +902,6 @@ class CalibreDB:
|
||||||
|
|
||||||
# Creates for all stored languages a translated speaking name in the array for the UI
|
# Creates for all stored languages a translated speaking name in the array for the UI
|
||||||
def speaking_language(self, languages=None, return_all_languages=False, with_count=False, reverse_order=False):
|
def speaking_language(self, languages=None, return_all_languages=False, with_count=False, reverse_order=False):
|
||||||
from . import get_locale
|
|
||||||
|
|
||||||
if with_count:
|
if with_count:
|
||||||
if not languages:
|
if not languages:
|
||||||
|
@ -916,7 +922,7 @@ class CalibreDB:
|
||||||
.count())
|
.count())
|
||||||
if no_lang_count:
|
if no_lang_count:
|
||||||
tags.append([Category(_("None"), "none"), no_lang_count])
|
tags.append([Category(_("None"), "none"), no_lang_count])
|
||||||
return sorted(tags, key=lambda x: x[0].name, reverse=reverse_order)
|
return sorted(tags, key=lambda x: x[0].name.lower(), reverse=reverse_order)
|
||||||
else:
|
else:
|
||||||
if not languages:
|
if not languages:
|
||||||
languages = self.session.query(Languages) \
|
languages = self.session.query(Languages) \
|
||||||
|
@ -940,7 +946,10 @@ class CalibreDB:
|
||||||
return title.strip()
|
return title.strip()
|
||||||
|
|
||||||
conn = conn or self.session.connection().connection.connection
|
conn = conn or self.session.connection().connection.connection
|
||||||
|
try:
|
||||||
conn.create_function("title_sort", 1, _title_sort)
|
conn.create_function("title_sort", 1, _title_sort)
|
||||||
|
except sqliteOperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def dispose(cls):
|
def dispose(cls):
|
||||||
|
|
|
@ -5,7 +5,7 @@ import json
|
||||||
|
|
||||||
from .constants import BASE_DIR
|
from .constants import BASE_DIR
|
||||||
try:
|
try:
|
||||||
from importlib_metadata import version
|
from importlib.metadata import version
|
||||||
importlib = True
|
importlib = True
|
||||||
ImportNotFound = BaseException
|
ImportNotFound = BaseException
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
1655
cps/editbooks.py
1655
cps/editbooks.py
File diff suppressed because it is too large
Load Diff
|
@ -17,6 +17,7 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from werkzeug.exceptions import default_exceptions
|
from werkzeug.exceptions import default_exceptions
|
||||||
try:
|
try:
|
||||||
|
|
95
cps/fs.py
Normal file
95
cps/fs.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2020 mmonkey
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
from .constants import CACHE_DIR
|
||||||
|
from os import makedirs, remove
|
||||||
|
from os.path import isdir, isfile, join
|
||||||
|
from shutil import rmtree
|
||||||
|
|
||||||
|
|
||||||
|
class FileSystem:
|
||||||
|
_instance = None
|
||||||
|
_cache_dir = CACHE_DIR
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(FileSystem, cls).__new__(cls)
|
||||||
|
cls.log = logger.create()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def get_cache_dir(self, cache_type=None):
|
||||||
|
if not isdir(self._cache_dir):
|
||||||
|
try:
|
||||||
|
makedirs(self._cache_dir)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to create path {self._cache_dir} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
path = join(self._cache_dir, cache_type)
|
||||||
|
if cache_type and not isdir(path):
|
||||||
|
try:
|
||||||
|
makedirs(path)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to create path {path} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
return path if cache_type else self._cache_dir
|
||||||
|
|
||||||
|
def get_cache_file_dir(self, filename, cache_type=None):
|
||||||
|
path = join(self.get_cache_dir(cache_type), filename[:2])
|
||||||
|
if not isdir(path):
|
||||||
|
try:
|
||||||
|
makedirs(path)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to create path {path} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def get_cache_file_path(self, filename, cache_type=None):
|
||||||
|
return join(self.get_cache_file_dir(filename, cache_type), filename) if filename else None
|
||||||
|
|
||||||
|
def get_cache_file_exists(self, filename, cache_type=None):
|
||||||
|
path = self.get_cache_file_path(filename, cache_type)
|
||||||
|
return isfile(path)
|
||||||
|
|
||||||
|
def delete_cache_dir(self, cache_type=None):
|
||||||
|
if not cache_type and isdir(self._cache_dir):
|
||||||
|
try:
|
||||||
|
rmtree(self._cache_dir)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to delete path {self._cache_dir} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
path = join(self._cache_dir, cache_type)
|
||||||
|
if cache_type and isdir(path):
|
||||||
|
try:
|
||||||
|
rmtree(path)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to delete path {path} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
def delete_cache_file(self, filename, cache_type=None):
|
||||||
|
path = self.get_cache_file_path(filename, cache_type)
|
||||||
|
if isfile(path):
|
||||||
|
try:
|
||||||
|
remove(path)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to delete path {path} (Permission denied).')
|
||||||
|
raise
|
|
@ -63,7 +63,7 @@ except ImportError as err:
|
||||||
importError = err
|
importError = err
|
||||||
gdrive_support = False
|
gdrive_support = False
|
||||||
|
|
||||||
from . import logger, cli, config
|
from . import logger, cli_param, config
|
||||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
|
|
||||||
|
|
||||||
|
@ -142,7 +142,7 @@ def is_gdrive_ready():
|
||||||
return os.path.exists(SETTINGS_YAML) and os.path.exists(CREDENTIALS)
|
return os.path.exists(SETTINGS_YAML) and os.path.exists(CREDENTIALS)
|
||||||
|
|
||||||
|
|
||||||
engine = create_engine('sqlite:///{0}'.format(cli.gd_path), echo=False)
|
engine = create_engine('sqlite:///{0}'.format(cli_param.gd_path), echo=False)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
# Open session for database connection
|
# Open session for database connection
|
||||||
|
@ -190,11 +190,11 @@ def migrate():
|
||||||
session.execute('ALTER TABLE gdrive_ids2 RENAME to gdrive_ids')
|
session.execute('ALTER TABLE gdrive_ids2 RENAME to gdrive_ids')
|
||||||
break
|
break
|
||||||
|
|
||||||
if not os.path.exists(cli.gd_path):
|
if not os.path.exists(cli_param.gd_path):
|
||||||
try:
|
try:
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error("Error connect to database: {} - {}".format(cli.gd_path, ex))
|
log.error("Error connect to database: {} - {}".format(cli_param.gd_path, ex))
|
||||||
raise
|
raise
|
||||||
migrate()
|
migrate()
|
||||||
|
|
||||||
|
@ -544,6 +544,7 @@ def deleteDatabaseOnChange():
|
||||||
except (OperationalError, InvalidRequestError) as ex:
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
log.error_or_exception('Database error: {}'.format(ex))
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
def updateGdriveCalibreFromLocal():
|
def updateGdriveCalibreFromLocal():
|
||||||
|
@ -679,8 +680,3 @@ def get_error_text(client_secrets=None):
|
||||||
return 'Callback url (redirect url) is missing in client_secrets.json'
|
return 'Callback url (redirect url) is missing in client_secrets.json'
|
||||||
if client_secrets:
|
if client_secrets:
|
||||||
client_secrets.update(filedata['web'])
|
client_secrets.update(filedata['web'])
|
||||||
|
|
||||||
|
|
||||||
def get_versions():
|
|
||||||
return { # 'six': six_version,
|
|
||||||
'httplib2': httplib2_version}
|
|
||||||
|
|
228
cps/helper.py
228
cps/helper.py
|
@ -29,19 +29,17 @@ from tempfile import gettempdir
|
||||||
import requests
|
import requests
|
||||||
import unidecode
|
import unidecode
|
||||||
|
|
||||||
from babel.dates import format_datetime
|
|
||||||
from babel.units import format_unit
|
|
||||||
from flask import send_from_directory, make_response, redirect, abort, url_for
|
from flask import send_from_directory, make_response, redirect, abort, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from sqlalchemy.sql.expression import true, false, and_, text, func
|
from sqlalchemy.sql.expression import true, false, and_, or_, text, func
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
from werkzeug.datastructures import Headers
|
from werkzeug.datastructures import Headers
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import advocate
|
import advocate
|
||||||
from advocate.exceptions import UnacceptableAddressException
|
from advocate.exceptions import UnacceptableAddressException
|
||||||
|
@ -51,14 +49,15 @@ except ImportError:
|
||||||
advocate = requests
|
advocate = requests
|
||||||
UnacceptableAddressException = MissingSchema = BaseException
|
UnacceptableAddressException = MissingSchema = BaseException
|
||||||
|
|
||||||
from . import calibre_db, cli
|
from . import calibre_db, cli_param
|
||||||
from .tasks.convert import TaskConvert
|
from .tasks.convert import TaskConvert
|
||||||
from . import logger, config, get_locale, db, ub
|
from . import logger, config, db, ub, fs
|
||||||
from . import gdriveutils as gd
|
from . import gdriveutils as gd
|
||||||
from .constants import STATIC_DIR as _STATIC_DIR
|
from .constants import STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES
|
||||||
from .subproc_wrapper import process_wait
|
from .subproc_wrapper import process_wait
|
||||||
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS
|
from .services.worker import WorkerThread
|
||||||
from .tasks.mail import TaskEmail
|
from .tasks.mail import TaskEmail
|
||||||
|
from .tasks.thumbnail import TaskClearCoverThumbnailCache, TaskGenerateCoverThumbnails
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
@ -73,10 +72,10 @@ except (ImportError, RuntimeError) as e:
|
||||||
|
|
||||||
|
|
||||||
# Convert existing book entry to new format
|
# Convert existing book entry to new format
|
||||||
def convert_book_format(book_id, calibrepath, old_book_format, new_book_format, user_id, kindle_mail=None):
|
def convert_book_format(book_id, calibre_path, old_book_format, new_book_format, user_id, kindle_mail=None):
|
||||||
book = calibre_db.get_book(book_id)
|
book = calibre_db.get_book(book_id)
|
||||||
data = calibre_db.get_book_format(book.id, old_book_format)
|
data = calibre_db.get_book_format(book.id, old_book_format)
|
||||||
file_path = os.path.join(calibrepath, book.path, data.name)
|
file_path = os.path.join(calibre_path, book.path, data.name)
|
||||||
if not data:
|
if not data:
|
||||||
error_message = _(u"%(format)s format not found for book id: %(book)d", format=old_book_format, book=book_id)
|
error_message = _(u"%(format)s format not found for book id: %(book)d", format=old_book_format, book=book_id)
|
||||||
log.error("convert_book_format: %s", error_message)
|
log.error("convert_book_format: %s", error_message)
|
||||||
|
@ -109,9 +108,10 @@ def convert_book_format(book_id, calibrepath, old_book_format, new_book_format,
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Texts are not lazy translated as they are supposed to get send out as is
|
||||||
def send_test_mail(kindle_mail, user_name):
|
def send_test_mail(kindle_mail, user_name):
|
||||||
WorkerThread.add(user_name, TaskEmail(_(u'Calibre-Web test e-mail'), None, None,
|
WorkerThread.add(user_name, TaskEmail(_(u'Calibre-Web test e-mail'), None, None,
|
||||||
config.get_mail_settings(), kindle_mail, _(u"Test e-mail"),
|
config.get_mail_settings(), kindle_mail, N_(u"Test e-mail"),
|
||||||
_(u'This e-mail has been sent via Calibre-Web.')))
|
_(u'This e-mail has been sent via Calibre-Web.')))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -133,27 +133,27 @@ def send_registration_mail(e_mail, user_name, default_password, resend=False):
|
||||||
attachment=None,
|
attachment=None,
|
||||||
settings=config.get_mail_settings(),
|
settings=config.get_mail_settings(),
|
||||||
recipient=e_mail,
|
recipient=e_mail,
|
||||||
taskMessage=_(u"Registration e-mail for user: %(name)s", name=user_name),
|
task_message=N_(u"Registration e-mail for user: %(name)s", name=user_name),
|
||||||
text=txt
|
text=txt
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def check_send_to_kindle_with_converter(formats):
|
def check_send_to_kindle_with_converter(formats):
|
||||||
bookformats = list()
|
book_formats = list()
|
||||||
if 'EPUB' in formats and 'MOBI' not in formats:
|
if 'EPUB' in formats and 'MOBI' not in formats:
|
||||||
bookformats.append({'format': 'Mobi',
|
book_formats.append({'format': 'Mobi',
|
||||||
'convert': 1,
|
'convert': 1,
|
||||||
'text': _('Convert %(orig)s to %(format)s and send to Kindle',
|
'text': _('Convert %(orig)s to %(format)s and send to Kindle',
|
||||||
orig='Epub',
|
orig='Epub',
|
||||||
format='Mobi')})
|
format='Mobi')})
|
||||||
if 'AZW3' in formats and 'MOBI' not in formats:
|
if 'AZW3' in formats and 'MOBI' not in formats:
|
||||||
bookformats.append({'format': 'Mobi',
|
book_formats.append({'format': 'Mobi',
|
||||||
'convert': 2,
|
'convert': 2,
|
||||||
'text': _('Convert %(orig)s to %(format)s and send to Kindle',
|
'text': _('Convert %(orig)s to %(format)s and send to Kindle',
|
||||||
orig='Azw3',
|
orig='Azw3',
|
||||||
format='Mobi')})
|
format='Mobi')})
|
||||||
return bookformats
|
return book_formats
|
||||||
|
|
||||||
|
|
||||||
def check_send_to_kindle(entry):
|
def check_send_to_kindle(entry):
|
||||||
|
@ -161,26 +161,26 @@ def check_send_to_kindle(entry):
|
||||||
returns all available book formats for sending to Kindle
|
returns all available book formats for sending to Kindle
|
||||||
"""
|
"""
|
||||||
formats = list()
|
formats = list()
|
||||||
bookformats = list()
|
book_formats = list()
|
||||||
if len(entry.data):
|
if len(entry.data):
|
||||||
for ele in iter(entry.data):
|
for ele in iter(entry.data):
|
||||||
if ele.uncompressed_size < config.mail_size:
|
if ele.uncompressed_size < config.mail_size:
|
||||||
formats.append(ele.format)
|
formats.append(ele.format)
|
||||||
if 'MOBI' in formats:
|
if 'MOBI' in formats:
|
||||||
bookformats.append({'format': 'Mobi',
|
book_formats.append({'format': 'Mobi',
|
||||||
'convert': 0,
|
'convert': 0,
|
||||||
'text': _('Send %(format)s to Kindle', format='Mobi')})
|
'text': _('Send %(format)s to Kindle', format='Mobi')})
|
||||||
if 'PDF' in formats:
|
if 'PDF' in formats:
|
||||||
bookformats.append({'format': 'Pdf',
|
book_formats.append({'format': 'Pdf',
|
||||||
'convert': 0,
|
'convert': 0,
|
||||||
'text': _('Send %(format)s to Kindle', format='Pdf')})
|
'text': _('Send %(format)s to Kindle', format='Pdf')})
|
||||||
if 'AZW' in formats:
|
if 'AZW' in formats:
|
||||||
bookformats.append({'format': 'Azw',
|
book_formats.append({'format': 'Azw',
|
||||||
'convert': 0,
|
'convert': 0,
|
||||||
'text': _('Send %(format)s to Kindle', format='Azw')})
|
'text': _('Send %(format)s to Kindle', format='Azw')})
|
||||||
if config.config_converterpath:
|
if config.config_converterpath:
|
||||||
bookformats.extend(check_send_to_kindle_with_converter(formats))
|
book_formats.extend(check_send_to_kindle_with_converter(formats))
|
||||||
return bookformats
|
return book_formats
|
||||||
else:
|
else:
|
||||||
log.error(u'Cannot find book entry %d', entry.id)
|
log.error(u'Cannot find book entry %d', entry.id)
|
||||||
return None
|
return None
|
||||||
|
@ -190,12 +190,12 @@ def check_send_to_kindle(entry):
|
||||||
# list with supported formats
|
# list with supported formats
|
||||||
def check_read_formats(entry):
|
def check_read_formats(entry):
|
||||||
extensions_reader = {'TXT', 'PDF', 'EPUB', 'CBZ', 'CBT', 'CBR', 'DJVU'}
|
extensions_reader = {'TXT', 'PDF', 'EPUB', 'CBZ', 'CBT', 'CBR', 'DJVU'}
|
||||||
bookformats = list()
|
book_formats = list()
|
||||||
if len(entry.data):
|
if len(entry.data):
|
||||||
for ele in iter(entry.data):
|
for ele in iter(entry.data):
|
||||||
if ele.format.upper() in extensions_reader:
|
if ele.format.upper() in extensions_reader:
|
||||||
bookformats.append(ele.format.lower())
|
book_formats.append(ele.format.lower())
|
||||||
return bookformats
|
return book_formats
|
||||||
|
|
||||||
|
|
||||||
# Files are processed in the following order/priority:
|
# Files are processed in the following order/priority:
|
||||||
|
@ -217,7 +217,7 @@ def send_mail(book_id, book_format, convert, kindle_mail, calibrepath, user_id):
|
||||||
if entry.format.upper() == book_format.upper():
|
if entry.format.upper() == book_format.upper():
|
||||||
converted_file_name = entry.name + '.' + book_format.lower()
|
converted_file_name = entry.name + '.' + book_format.lower()
|
||||||
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title))
|
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title))
|
||||||
email_text = _(u"%(book)s send to Kindle", book=link)
|
email_text = N_(u"%(book)s send to Kindle", book=link)
|
||||||
WorkerThread.add(user_id, TaskEmail(_(u"Send to Kindle"), book.path, converted_file_name,
|
WorkerThread.add(user_id, TaskEmail(_(u"Send to Kindle"), book.path, converted_file_name,
|
||||||
config.get_mail_settings(), kindle_mail,
|
config.get_mail_settings(), kindle_mail,
|
||||||
email_text, _(u'This e-mail has been sent via Calibre-Web.')))
|
email_text, _(u'This e-mail has been sent via Calibre-Web.')))
|
||||||
|
@ -225,23 +225,11 @@ def send_mail(book_id, book_format, convert, kindle_mail, calibrepath, user_id):
|
||||||
return _(u"The requested file could not be read. Maybe wrong permissions?")
|
return _(u"The requested file could not be read. Maybe wrong permissions?")
|
||||||
|
|
||||||
|
|
||||||
def shorten_component(s, by_what):
|
|
||||||
l = len(s)
|
|
||||||
if l < by_what:
|
|
||||||
return s
|
|
||||||
l = (l - by_what)//2
|
|
||||||
if l <= 0:
|
|
||||||
return s
|
|
||||||
return s[:l] + s[-l:]
|
|
||||||
|
|
||||||
|
|
||||||
def get_valid_filename(value, replace_whitespace=True, chars=128):
|
def get_valid_filename(value, replace_whitespace=True, chars=128):
|
||||||
"""
|
"""
|
||||||
Returns the given string converted to a string that can be used for a clean
|
Returns the given string converted to a string that can be used for a clean
|
||||||
filename. Limits num characters to 128 max.
|
filename. Limits num characters to 128 max.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
if value[-1:] == u'.':
|
if value[-1:] == u'.':
|
||||||
value = value[:-1]+u'_'
|
value = value[:-1]+u'_'
|
||||||
value = value.replace("/", "_").replace(":", "_").strip('\0')
|
value = value.replace("/", "_").replace(":", "_").strip('\0')
|
||||||
|
@ -354,9 +342,9 @@ def edit_book_read_status(book_id, read_status=None):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# Deletes a book fro the local filestorage, returns True if deleting is successfull, otherwise false
|
# Deletes a book from the local filestorage, returns True if deleting is successful, otherwise false
|
||||||
def delete_book_file(book, calibrepath, book_format=None):
|
def delete_book_file(book, calibrepath, book_format=None):
|
||||||
# check that path is 2 elements deep, check that target path has no subfolders
|
# check that path is 2 elements deep, check that target path has no sub folders
|
||||||
if book.path.count('/') == 1:
|
if book.path.count('/') == 1:
|
||||||
path = os.path.join(calibrepath, book.path)
|
path = os.path.join(calibrepath, book.path)
|
||||||
if book_format:
|
if book_format:
|
||||||
|
@ -511,6 +499,7 @@ def upload_new_file_gdrive(book_id, first_author, renamed_author, title, title_d
|
||||||
return rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
|
return rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def update_dir_structure_gdrive(book_id, first_author, renamed_author):
|
def update_dir_structure_gdrive(book_id, first_author, renamed_author):
|
||||||
book = calibre_db.get_book(book_id)
|
book = calibre_db.get_book(book_id)
|
||||||
|
|
||||||
|
@ -690,6 +679,8 @@ def update_dir_structure(book_id,
|
||||||
|
|
||||||
|
|
||||||
def delete_book(book, calibrepath, book_format):
|
def delete_book(book, calibrepath, book_format):
|
||||||
|
if not book_format:
|
||||||
|
clear_cover_thumbnail_cache(book.id) ## here it breaks
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
return delete_book_gdrive(book, book_format)
|
return delete_book_gdrive(book, book_format)
|
||||||
else:
|
else:
|
||||||
|
@ -706,19 +697,30 @@ def get_cover_on_failure(use_generic_cover):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
def get_book_cover(book_id):
|
def get_book_cover(book_id, resolution=None):
|
||||||
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
||||||
return get_book_cover_internal(book, use_generic_cover_on_failure=True)
|
return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution)
|
||||||
|
|
||||||
|
|
||||||
def get_book_cover_with_uuid(book_uuid,
|
# Called only by kobo sync -> cover not found should be answered with 404 and not with default cover
|
||||||
use_generic_cover_on_failure=True):
|
def get_book_cover_with_uuid(book_uuid, resolution=None):
|
||||||
book = calibre_db.get_book_by_uuid(book_uuid)
|
book = calibre_db.get_book_by_uuid(book_uuid)
|
||||||
return get_book_cover_internal(book, use_generic_cover_on_failure)
|
return get_book_cover_internal(book, use_generic_cover_on_failure=False, resolution=resolution)
|
||||||
|
|
||||||
|
|
||||||
def get_book_cover_internal(book, use_generic_cover_on_failure):
|
def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None):
|
||||||
if book and book.has_cover:
|
if book and book.has_cover:
|
||||||
|
|
||||||
|
# Send the book cover thumbnail if it exists in cache
|
||||||
|
if resolution:
|
||||||
|
thumbnail = get_book_cover_thumbnail(book, resolution)
|
||||||
|
if thumbnail:
|
||||||
|
cache = fs.FileSystem()
|
||||||
|
if cache.get_cache_file_exists(thumbnail.filename, CACHE_TYPE_THUMBNAILS):
|
||||||
|
return send_from_directory(cache.get_cache_file_dir(thumbnail.filename, CACHE_TYPE_THUMBNAILS),
|
||||||
|
thumbnail.filename)
|
||||||
|
|
||||||
|
# Send the book cover from Google Drive if configured
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
try:
|
try:
|
||||||
if not gd.is_gdrive_ready():
|
if not gd.is_gdrive_ready():
|
||||||
|
@ -732,6 +734,8 @@ def get_book_cover_internal(book, use_generic_cover_on_failure):
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
return get_cover_on_failure(use_generic_cover_on_failure)
|
return get_cover_on_failure(use_generic_cover_on_failure)
|
||||||
|
|
||||||
|
# Send the book cover from the Calibre directory
|
||||||
else:
|
else:
|
||||||
cover_file_path = os.path.join(config.config_calibre_dir, book.path)
|
cover_file_path = os.path.join(config.config_calibre_dir, book.path)
|
||||||
if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")):
|
if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")):
|
||||||
|
@ -742,20 +746,67 @@ def get_book_cover_internal(book, use_generic_cover_on_failure):
|
||||||
return get_cover_on_failure(use_generic_cover_on_failure)
|
return get_cover_on_failure(use_generic_cover_on_failure)
|
||||||
|
|
||||||
|
|
||||||
|
def get_book_cover_thumbnail(book, resolution):
|
||||||
|
if book and book.has_cover:
|
||||||
|
return ub.session \
|
||||||
|
.query(ub.Thumbnail) \
|
||||||
|
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) \
|
||||||
|
.filter(ub.Thumbnail.entity_id == book.id) \
|
||||||
|
.filter(ub.Thumbnail.resolution == resolution) \
|
||||||
|
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||||
|
.first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_series_thumbnail_on_failure(series_id, resolution):
|
||||||
|
book = calibre_db.session \
|
||||||
|
.query(db.Books) \
|
||||||
|
.join(db.books_series_link) \
|
||||||
|
.join(db.Series) \
|
||||||
|
.filter(db.Series.id == series_id) \
|
||||||
|
.filter(db.Books.has_cover == 1) \
|
||||||
|
.first()
|
||||||
|
|
||||||
|
return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution)
|
||||||
|
|
||||||
|
|
||||||
|
def get_series_cover_thumbnail(series_id, resolution=None):
|
||||||
|
return get_series_cover_internal(series_id, resolution)
|
||||||
|
|
||||||
|
|
||||||
|
def get_series_cover_internal(series_id, resolution=None):
|
||||||
|
# Send the series thumbnail if it exists in cache
|
||||||
|
if resolution:
|
||||||
|
thumbnail = get_series_thumbnail(series_id, resolution)
|
||||||
|
if thumbnail:
|
||||||
|
cache = fs.FileSystem()
|
||||||
|
if cache.get_cache_file_exists(thumbnail.filename, CACHE_TYPE_THUMBNAILS):
|
||||||
|
return send_from_directory(cache.get_cache_file_dir(thumbnail.filename, CACHE_TYPE_THUMBNAILS),
|
||||||
|
thumbnail.filename)
|
||||||
|
|
||||||
|
return get_series_thumbnail_on_failure(series_id, resolution)
|
||||||
|
|
||||||
|
|
||||||
|
def get_series_thumbnail(series_id, resolution):
|
||||||
|
return ub.session \
|
||||||
|
.query(ub.Thumbnail) \
|
||||||
|
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES) \
|
||||||
|
.filter(ub.Thumbnail.entity_id == series_id) \
|
||||||
|
.filter(ub.Thumbnail.resolution == resolution) \
|
||||||
|
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||||
|
.first()
|
||||||
|
|
||||||
|
|
||||||
# saves book cover from url
|
# saves book cover from url
|
||||||
def save_cover_from_url(url, book_path):
|
def save_cover_from_url(url, book_path):
|
||||||
try:
|
try:
|
||||||
if cli.allow_localhost:
|
if cli_param.allow_localhost:
|
||||||
img = requests.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
img = requests.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
||||||
elif use_advocate:
|
elif use_advocate:
|
||||||
img = advocate.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
img = advocate.get(url, timeout=(10, 200), allow_redirects=False) # ToDo: Error Handling
|
||||||
else:
|
else:
|
||||||
log.error("python modul advocate is not installed but is needed")
|
log.error("python module advocate is not installed but is needed")
|
||||||
return False, _("Python modul 'advocate' is not installed but is needed for cover downloads")
|
return False, _("Python module 'advocate' is not installed but is needed for cover downloads")
|
||||||
img.raise_for_status()
|
img.raise_for_status()
|
||||||
# # cover_processing()
|
|
||||||
# move_coverfile(meta, db_book)
|
|
||||||
|
|
||||||
return save_cover(img, book_path)
|
return save_cover(img, book_path)
|
||||||
except (socket.gaierror,
|
except (socket.gaierror,
|
||||||
requests.exceptions.HTTPError,
|
requests.exceptions.HTTPError,
|
||||||
|
@ -904,54 +955,6 @@ def json_serial(obj):
|
||||||
raise TypeError("Type %s not serializable" % type(obj))
|
raise TypeError("Type %s not serializable" % type(obj))
|
||||||
|
|
||||||
|
|
||||||
# helper function for displaying the runtime of tasks
|
|
||||||
def format_runtime(runtime):
|
|
||||||
ret_val = ""
|
|
||||||
if runtime.days:
|
|
||||||
ret_val = format_unit(runtime.days, 'duration-day', length="long", locale=get_locale()) + ', '
|
|
||||||
mins, seconds = divmod(runtime.seconds, 60)
|
|
||||||
hours, minutes = divmod(mins, 60)
|
|
||||||
# ToDo: locale.number_symbols._data['timeSeparator'] -> localize time separator ?
|
|
||||||
if hours:
|
|
||||||
ret_val += '{:d}:{:02d}:{:02d}s'.format(hours, minutes, seconds)
|
|
||||||
elif minutes:
|
|
||||||
ret_val += '{:2d}:{:02d}s'.format(minutes, seconds)
|
|
||||||
else:
|
|
||||||
ret_val += '{:2d}s'.format(seconds)
|
|
||||||
return ret_val
|
|
||||||
|
|
||||||
|
|
||||||
# helper function to apply localize status information in tasklist entries
|
|
||||||
def render_task_status(tasklist):
|
|
||||||
renderedtasklist = list()
|
|
||||||
for __, user, __, task in tasklist:
|
|
||||||
if user == current_user.name or current_user.role_admin():
|
|
||||||
ret = {}
|
|
||||||
if task.start_time:
|
|
||||||
ret['starttime'] = format_datetime(task.start_time, format='short', locale=get_locale())
|
|
||||||
ret['runtime'] = format_runtime(task.runtime)
|
|
||||||
|
|
||||||
# localize the task status
|
|
||||||
if isinstance(task.stat, int):
|
|
||||||
if task.stat == STAT_WAITING:
|
|
||||||
ret['status'] = _(u'Waiting')
|
|
||||||
elif task.stat == STAT_FAIL:
|
|
||||||
ret['status'] = _(u'Failed')
|
|
||||||
elif task.stat == STAT_STARTED:
|
|
||||||
ret['status'] = _(u'Started')
|
|
||||||
elif task.stat == STAT_FINISH_SUCCESS:
|
|
||||||
ret['status'] = _(u'Finished')
|
|
||||||
else:
|
|
||||||
ret['status'] = _(u'Unknown Status')
|
|
||||||
|
|
||||||
ret['taskMessage'] = "{}: {}".format(_(task.name), task.message)
|
|
||||||
ret['progress'] = "{} %".format(int(task.progress * 100))
|
|
||||||
ret['user'] = escape(user) # prevent xss
|
|
||||||
renderedtasklist.append(ret)
|
|
||||||
|
|
||||||
return renderedtasklist
|
|
||||||
|
|
||||||
|
|
||||||
def tags_filters():
|
def tags_filters():
|
||||||
negtags_list = current_user.list_denied_tags()
|
negtags_list = current_user.list_denied_tags()
|
||||||
postags_list = current_user.list_allowed_tags()
|
postags_list = current_user.list_allowed_tags()
|
||||||
|
@ -998,3 +1001,28 @@ def get_download_link(book_id, book_format, client):
|
||||||
return do_download_file(book, book_format, client, data1, headers)
|
return do_download_file(book, book_format, client, data1, headers)
|
||||||
else:
|
else:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cover_thumbnail_cache(book_id):
|
||||||
|
if config.schedule_generate_book_covers:
|
||||||
|
WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_cover_thumbnail_cache(book_id):
|
||||||
|
if config.schedule_generate_book_covers:
|
||||||
|
WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True)
|
||||||
|
WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_thumbnail_cache():
|
||||||
|
WorkerThread.add(None, TaskClearCoverThumbnailCache(-1))
|
||||||
|
|
||||||
|
|
||||||
|
def add_book_to_thumbnail_cache(book_id):
|
||||||
|
if config.schedule_generate_book_covers:
|
||||||
|
WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True)
|
||||||
|
|
||||||
|
|
||||||
|
def update_thumbnail_cache():
|
||||||
|
if config.schedule_generate_book_covers:
|
||||||
|
WorkerThread.add(None, TaskGenerateCoverThumbnails())
|
||||||
|
|
|
@ -49,7 +49,7 @@ except ImportError:
|
||||||
|
|
||||||
|
|
||||||
def get_language_names(locale):
|
def get_language_names(locale):
|
||||||
return _LANGUAGE_NAMES.get(locale)
|
return _LANGUAGE_NAMES.get(str(locale))
|
||||||
|
|
||||||
|
|
||||||
def get_language_name(locale, lang_code):
|
def get_language_name(locale, lang_code):
|
||||||
|
|
|
@ -22,17 +22,17 @@
|
||||||
|
|
||||||
# custom jinja filters
|
# custom jinja filters
|
||||||
|
|
||||||
|
from markupsafe import escape
|
||||||
import datetime
|
import datetime
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from babel.dates import format_date
|
# from babel.dates import format_date
|
||||||
from flask import Blueprint, request, url_for
|
from flask import Blueprint, request, url_for
|
||||||
from flask_babel import get_locale
|
from flask_babel import format_date
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from markupsafe import escape
|
|
||||||
from . import logger
|
|
||||||
|
|
||||||
|
from . import constants, logger
|
||||||
|
|
||||||
jinjia = Blueprint('jinjia', __name__)
|
jinjia = Blueprint('jinjia', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
@ -77,7 +77,7 @@ def mimetype_filter(val):
|
||||||
@jinjia.app_template_filter('formatdate')
|
@jinjia.app_template_filter('formatdate')
|
||||||
def formatdate_filter(val):
|
def formatdate_filter(val):
|
||||||
try:
|
try:
|
||||||
return format_date(val, format='medium', locale=get_locale())
|
return format_date(val, format='medium')
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
|
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
|
||||||
current_user.locale,
|
current_user.locale,
|
||||||
|
@ -128,12 +128,55 @@ def formatseriesindex_filter(series_index):
|
||||||
return series_index
|
return series_index
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@jinjia.app_template_filter('escapedlink')
|
@jinjia.app_template_filter('escapedlink')
|
||||||
def escapedlink_filter(url, text):
|
def escapedlink_filter(url, text):
|
||||||
return "<a href='{}'>{}</a>".format(url, escape(text))
|
return "<a href='{}'>{}</a>".format(url, escape(text))
|
||||||
|
|
||||||
|
|
||||||
@jinjia.app_template_filter('uuidfilter')
|
@jinjia.app_template_filter('uuidfilter')
|
||||||
def uuidfilter(var):
|
def uuidfilter(var):
|
||||||
return uuid4()
|
return uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('cache_timestamp')
|
||||||
|
def cache_timestamp(rolling_period='month'):
|
||||||
|
if rolling_period == 'day':
|
||||||
|
return str(int(datetime.datetime.today().replace(hour=1, minute=1).timestamp()))
|
||||||
|
elif rolling_period == 'year':
|
||||||
|
return str(int(datetime.datetime.today().replace(day=1).timestamp()))
|
||||||
|
else:
|
||||||
|
return str(int(datetime.datetime.today().replace(month=1, day=1).timestamp()))
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('last_modified')
|
||||||
|
def book_last_modified(book):
|
||||||
|
return str(int(book.last_modified.timestamp()))
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('get_cover_srcset')
|
||||||
|
def get_cover_srcset(book):
|
||||||
|
srcset = list()
|
||||||
|
resolutions = {
|
||||||
|
constants.COVER_THUMBNAIL_SMALL: 'sm',
|
||||||
|
constants.COVER_THUMBNAIL_MEDIUM: 'md',
|
||||||
|
constants.COVER_THUMBNAIL_LARGE: 'lg'
|
||||||
|
}
|
||||||
|
for resolution, shortname in resolutions.items():
|
||||||
|
url = url_for('web.get_cover', book_id=book.id, resolution=shortname, c=book_last_modified(book))
|
||||||
|
srcset.append(f'{url} {resolution}x')
|
||||||
|
return ', '.join(srcset)
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('get_series_srcset')
|
||||||
|
def get_cover_srcset(series):
|
||||||
|
srcset = list()
|
||||||
|
resolutions = {
|
||||||
|
constants.COVER_THUMBNAIL_SMALL: 'sm',
|
||||||
|
constants.COVER_THUMBNAIL_MEDIUM: 'md',
|
||||||
|
constants.COVER_THUMBNAIL_LARGE: 'lg'
|
||||||
|
}
|
||||||
|
for resolution, shortname in resolutions.items():
|
||||||
|
url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp())
|
||||||
|
srcset.append(f'{url} {resolution}x')
|
||||||
|
return ', '.join(srcset)
|
||||||
|
|
46
cps/kobo.py
46
cps/kobo.py
|
@ -45,7 +45,7 @@ import requests
|
||||||
|
|
||||||
|
|
||||||
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
||||||
from .constants import sqlalchemy_version2
|
from .constants import sqlalchemy_version2, COVER_THUMBNAIL_SMALL
|
||||||
from .helper import get_download_link
|
from .helper import get_download_link
|
||||||
from .services import SyncToken as SyncToken
|
from .services import SyncToken as SyncToken
|
||||||
from .web import download_required
|
from .web import download_required
|
||||||
|
@ -186,8 +186,7 @@ def HandleSyncRequest():
|
||||||
.join(ub.Shelf)
|
.join(ub.Shelf)
|
||||||
.filter(ub.Shelf.user_id == current_user.id)
|
.filter(ub.Shelf.user_id == current_user.id)
|
||||||
.filter(ub.Shelf.kobo_sync)
|
.filter(ub.Shelf.kobo_sync)
|
||||||
.distinct()
|
.distinct())
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
if sqlalchemy_version2:
|
if sqlalchemy_version2:
|
||||||
changed_entries = select(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived)
|
changed_entries = select(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived)
|
||||||
|
@ -203,9 +202,7 @@ def HandleSyncRequest():
|
||||||
.filter(calibre_db.common_filters(allow_show_archived=True))
|
.filter(calibre_db.common_filters(allow_show_archived=True))
|
||||||
.filter(db.Data.format.in_(KOBO_FORMATS))
|
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||||
.order_by(db.Books.last_modified)
|
.order_by(db.Books.last_modified)
|
||||||
.order_by(db.Books.id)
|
.order_by(db.Books.id))
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
reading_states_in_new_entitlements = []
|
reading_states_in_new_entitlements = []
|
||||||
if sqlalchemy_version2:
|
if sqlalchemy_version2:
|
||||||
|
@ -215,7 +212,7 @@ def HandleSyncRequest():
|
||||||
log.debug("Books to Sync: {}".format(len(books.all())))
|
log.debug("Books to Sync: {}".format(len(books.all())))
|
||||||
for book in books:
|
for book in books:
|
||||||
formats = [data.format for data in book.Books.data]
|
formats = [data.format for data in book.Books.data]
|
||||||
if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats:
|
if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats:
|
||||||
helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
||||||
|
|
||||||
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
||||||
|
@ -262,7 +259,7 @@ def HandleSyncRequest():
|
||||||
.columns(db.Books).first()
|
.columns(db.Books).first()
|
||||||
else:
|
else:
|
||||||
max_change = changed_entries.from_self().filter(ub.ArchivedBook.is_archived)\
|
max_change = changed_entries.from_self().filter(ub.ArchivedBook.is_archived)\
|
||||||
.filter(ub.ArchivedBook.user_id==current_user.id) \
|
.filter(ub.ArchivedBook.user_id == current_user.id) \
|
||||||
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
|
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
|
||||||
|
|
||||||
max_change = max_change.last_modified if max_change else new_archived_last_modified
|
max_change = max_change.last_modified if max_change else new_archived_last_modified
|
||||||
|
@ -425,9 +422,9 @@ def get_author(book):
|
||||||
author_list = []
|
author_list = []
|
||||||
autor_roles = []
|
autor_roles = []
|
||||||
for author in book.authors:
|
for author in book.authors:
|
||||||
autor_roles.append({"Name":author.name}) #.encode('unicode-escape').decode('latin-1')
|
autor_roles.append({"Name": author.name})
|
||||||
author_list.append(author.name)
|
author_list.append(author.name)
|
||||||
return {"ContributorRoles": autor_roles, "Contributors":author_list}
|
return {"ContributorRoles": autor_roles, "Contributors": author_list}
|
||||||
|
|
||||||
|
|
||||||
def get_publisher(book):
|
def get_publisher(book):
|
||||||
|
@ -441,6 +438,7 @@ def get_series(book):
|
||||||
return None
|
return None
|
||||||
return book.series[0].name
|
return book.series[0].name
|
||||||
|
|
||||||
|
|
||||||
def get_seriesindex(book):
|
def get_seriesindex(book):
|
||||||
return book.series_index or 1
|
return book.series_index or 1
|
||||||
|
|
||||||
|
@ -485,7 +483,7 @@ def get_metadata(book):
|
||||||
"Language": "en",
|
"Language": "en",
|
||||||
"PhoneticPronunciations": {},
|
"PhoneticPronunciations": {},
|
||||||
"PublicationDate": convert_to_kobo_timestamp_string(book.pubdate),
|
"PublicationDate": convert_to_kobo_timestamp_string(book.pubdate),
|
||||||
"Publisher": {"Imprint": "", "Name": get_publisher(book),},
|
"Publisher": {"Imprint": "", "Name": get_publisher(book), },
|
||||||
"RevisionId": book_uuid,
|
"RevisionId": book_uuid,
|
||||||
"Title": book.title,
|
"Title": book.title,
|
||||||
"WorkId": book_uuid,
|
"WorkId": book_uuid,
|
||||||
|
@ -504,6 +502,7 @@ def get_metadata(book):
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
|
@kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
|
@ -718,7 +717,6 @@ def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
||||||
*extra_filters
|
*extra_filters
|
||||||
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())
|
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())
|
||||||
|
|
||||||
|
|
||||||
for shelf in shelflist:
|
for shelf in shelflist:
|
||||||
if not shelf_lib.check_shelf_view_permissions(shelf):
|
if not shelf_lib.check_shelf_view_permissions(shelf):
|
||||||
continue
|
continue
|
||||||
|
@ -764,6 +762,7 @@ def create_kobo_tag(shelf):
|
||||||
)
|
)
|
||||||
return {"Tag": tag}
|
return {"Tag": tag}
|
||||||
|
|
||||||
|
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/library/<book_uuid>/state", methods=["GET", "PUT"])
|
@kobo.route("/v1/library/<book_uuid>/state", methods=["GET", "PUT"])
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
|
@ -912,13 +911,12 @@ def get_current_bookmark_response(current_bookmark):
|
||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("/<book_uuid>/<width>/<height>/<isGreyscale>/image.jpg", defaults={'Quality': ""})
|
@kobo.route("/<book_uuid>/<width>/<height>/<isGreyscale>/image.jpg", defaults={'Quality': ""})
|
||||||
@kobo.route("/<book_uuid>/<width>/<height>/<Quality>/<isGreyscale>/image.jpg")
|
@kobo.route("/<book_uuid>/<width>/<height>/<Quality>/<isGreyscale>/image.jpg")
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
def HandleCoverImageRequest(book_uuid, width, height,Quality, isGreyscale):
|
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
||||||
book_cover = helper.get_book_cover_with_uuid(
|
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=COVER_THUMBNAIL_SMALL)
|
||||||
book_uuid, use_generic_cover_on_failure=False
|
|
||||||
)
|
|
||||||
if not book_cover:
|
if not book_cover:
|
||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
|
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
|
||||||
|
@ -991,8 +989,8 @@ def handle_getests():
|
||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
else:
|
else:
|
||||||
testkey = request.headers.get("X-Kobo-userkey","")
|
testkey = request.headers.get("X-Kobo-userkey", "")
|
||||||
return make_response(jsonify({"Result": "Success", "TestKey":testkey, "Tests": {}}))
|
return make_response(jsonify({"Result": "Success", "TestKey": testkey, "Tests": {}}))
|
||||||
|
|
||||||
|
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
|
@ -1160,14 +1158,16 @@ def NATIVE_KOBO_RESOURCES():
|
||||||
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
|
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
|
||||||
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
|
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
|
||||||
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
|
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
|
||||||
"facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://store.kobobooks.com/",
|
"facebook_sso_page":
|
||||||
|
"https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://store.kobobooks.com/",
|
||||||
"featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
|
"featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
|
||||||
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
|
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
|
||||||
"free_books_page": {
|
"free_books_page": {
|
||||||
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
|
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
|
||||||
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
|
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
|
||||||
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
|
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
|
||||||
"NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
|
"NL": "https://www.kobo.com/{region}/{language}/"
|
||||||
|
"List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
|
||||||
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis",
|
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis",
|
||||||
},
|
},
|
||||||
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
|
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
|
||||||
|
@ -1192,7 +1192,8 @@ def NATIVE_KOBO_RESOURCES():
|
||||||
"library_stack": "https://storeapi.kobo.com/v1/user/library/stacks/{LibraryItemId}",
|
"library_stack": "https://storeapi.kobo.com/v1/user/library/stacks/{LibraryItemId}",
|
||||||
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
|
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
|
||||||
"love_dashboard_page": "https://store.kobobooks.com/{culture}/kobosuperpoints",
|
"love_dashboard_page": "https://store.kobobooks.com/{culture}/kobosuperpoints",
|
||||||
"love_points_redemption_page": "https://store.kobobooks.com/{culture}/KoboSuperPointsRedemption?productId={ProductId}",
|
"love_points_redemption_page":
|
||||||
|
"https://store.kobobooks.com/{culture}/KoboSuperPointsRedemption?productId={ProductId}",
|
||||||
"magazine_landing_page": "https://store.kobobooks.com/emagazines",
|
"magazine_landing_page": "https://store.kobobooks.com/emagazines",
|
||||||
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
|
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
|
||||||
"oauth_host": "https://oauth.kobo.com",
|
"oauth_host": "https://oauth.kobo.com",
|
||||||
|
@ -1208,7 +1209,8 @@ def NATIVE_KOBO_RESOURCES():
|
||||||
"product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
|
"product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
|
||||||
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
|
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
|
||||||
"products": "https://storeapi.kobo.com/v1/products",
|
"products": "https://storeapi.kobo.com/v1/products",
|
||||||
"provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://store.kobobooks.com/",
|
"provider_external_sign_in_page":
|
||||||
|
"https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://store.kobobooks.com/",
|
||||||
"purchase_buy": "https://www.kobo.com/checkout/createpurchase/",
|
"purchase_buy": "https://www.kobo.com/checkout/createpurchase/",
|
||||||
"purchase_buy_templated": "https://www.kobo.com/{culture}/checkout/createpurchase/{ProductId}",
|
"purchase_buy_templated": "https://www.kobo.com/{culture}/checkout/createpurchase/{ProductId}",
|
||||||
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
|
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
|
||||||
|
|
|
@ -71,47 +71,8 @@ from flask_babel import gettext as _
|
||||||
from . import logger, config, calibre_db, db, helper, ub, lm
|
from . import logger, config, calibre_db, db, helper, ub, lm
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
def register_url_value_preprocessor(kobo):
|
|
||||||
@kobo.url_value_preprocessor
|
|
||||||
# pylint: disable=unused-variable
|
|
||||||
def pop_auth_token(__, values):
|
|
||||||
g.auth_token = values.pop("auth_token")
|
|
||||||
|
|
||||||
|
|
||||||
def disable_failed_auth_redirect_for_blueprint(bp):
|
|
||||||
lm.blueprint_login_views[bp.name] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth_token():
|
|
||||||
if "auth_token" in g:
|
|
||||||
return g.get("auth_token")
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def requires_kobo_auth(f):
|
|
||||||
@wraps(f)
|
|
||||||
def inner(*args, **kwargs):
|
|
||||||
auth_token = get_auth_token()
|
|
||||||
if auth_token is not None:
|
|
||||||
user = (
|
|
||||||
ub.session.query(ub.User)
|
|
||||||
.join(ub.RemoteAuthToken)
|
|
||||||
.filter(ub.RemoteAuthToken.auth_token == auth_token).filter(ub.RemoteAuthToken.token_type==1)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if user is not None:
|
|
||||||
login_user(user)
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
log.debug("Received Kobo request without a recognizable auth token.")
|
|
||||||
return abort(401)
|
|
||||||
return inner
|
|
||||||
|
|
||||||
|
|
||||||
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
||||||
|
|
||||||
|
|
||||||
|
@ -165,3 +126,40 @@ def delete_auth_token(user_id):
|
||||||
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
||||||
|
|
||||||
return ub.session_commit()
|
return ub.session_commit()
|
||||||
|
|
||||||
|
|
||||||
|
def disable_failed_auth_redirect_for_blueprint(bp):
|
||||||
|
lm.blueprint_login_views[bp.name] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_token():
|
||||||
|
if "auth_token" in g:
|
||||||
|
return g.get("auth_token")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def register_url_value_preprocessor(kobo):
|
||||||
|
@kobo.url_value_preprocessor
|
||||||
|
# pylint: disable=unused-variable
|
||||||
|
def pop_auth_token(__, values):
|
||||||
|
g.auth_token = values.pop("auth_token")
|
||||||
|
|
||||||
|
|
||||||
|
def requires_kobo_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def inner(*args, **kwargs):
|
||||||
|
auth_token = get_auth_token()
|
||||||
|
if auth_token is not None:
|
||||||
|
user = (
|
||||||
|
ub.session.query(ub.User)
|
||||||
|
.join(ub.RemoteAuthToken)
|
||||||
|
.filter(ub.RemoteAuthToken.auth_token == auth_token).filter(ub.RemoteAuthToken.token_type==1)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if user is not None:
|
||||||
|
login_user(user)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
log.debug("Received Kobo request without a recognizable auth token.")
|
||||||
|
return abort(401)
|
||||||
|
return inner
|
||||||
|
|
73
cps/main.py
Normal file
73
cps/main.py
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2012-2022 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import create_app
|
||||||
|
from .jinjia import jinjia
|
||||||
|
from .remotelogin import remotelogin
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
from .web import web
|
||||||
|
from .opds import opds
|
||||||
|
from .admin import admi
|
||||||
|
from .gdrive import gdrive
|
||||||
|
from .editbooks import editbook
|
||||||
|
from .about import about
|
||||||
|
from .search import search
|
||||||
|
from .search_metadata import meta
|
||||||
|
from .shelf import shelf
|
||||||
|
from .tasks_status import tasks
|
||||||
|
from .error_handler import init_errorhandler
|
||||||
|
try:
|
||||||
|
from .kobo import kobo, get_kobo_activated
|
||||||
|
from .kobo_auth import kobo_auth
|
||||||
|
kobo_available = get_kobo_activated()
|
||||||
|
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
||||||
|
kobo_available = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .oauth_bb import oauth
|
||||||
|
oauth_available = True
|
||||||
|
except ImportError:
|
||||||
|
oauth_available = False
|
||||||
|
|
||||||
|
from . import web_server
|
||||||
|
init_errorhandler()
|
||||||
|
|
||||||
|
app.register_blueprint(search)
|
||||||
|
app.register_blueprint(tasks)
|
||||||
|
app.register_blueprint(web)
|
||||||
|
app.register_blueprint(opds)
|
||||||
|
app.register_blueprint(jinjia)
|
||||||
|
app.register_blueprint(about)
|
||||||
|
app.register_blueprint(shelf)
|
||||||
|
app.register_blueprint(admi)
|
||||||
|
app.register_blueprint(remotelogin)
|
||||||
|
app.register_blueprint(meta)
|
||||||
|
app.register_blueprint(gdrive)
|
||||||
|
app.register_blueprint(editbook)
|
||||||
|
if kobo_available:
|
||||||
|
app.register_blueprint(kobo)
|
||||||
|
app.register_blueprint(kobo_auth)
|
||||||
|
if oauth_available:
|
||||||
|
app.register_blueprint(oauth)
|
||||||
|
success = web_server.start()
|
||||||
|
sys.exit(0 if success else 1)
|
|
@ -19,17 +19,11 @@
|
||||||
from flask import session
|
from flask import session
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user
|
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
|
||||||
backend_resultcode = False # prevent storing values with this resultcode
|
|
||||||
except ImportError:
|
|
||||||
# fails on flask-dance >1.3, due to renaming
|
|
||||||
try:
|
|
||||||
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend
|
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend
|
||||||
from flask_dance.consumer.storage.sqla import first, _get_real_user
|
from flask_dance.consumer.storage.sqla import first, _get_real_user
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
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:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
33
cps/opds.py
33
cps/opds.py
|
@ -26,15 +26,18 @@ from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, request, render_template, Response, g, make_response, abort
|
from flask import Blueprint, request, render_template, Response, g, make_response, abort
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
from flask_babel import get_locale
|
||||||
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from . import constants, logger, config, db, calibre_db, ub, services, get_locale, isoLanguages
|
|
||||||
|
from . import constants, logger, config, db, calibre_db, ub, services, isoLanguages
|
||||||
from .helper import get_download_link, get_book_cover
|
from .helper import get_download_link, get_book_cover
|
||||||
from .pagination import Pagination
|
from .pagination import Pagination
|
||||||
from .web import render_read_books
|
from .web import render_read_books
|
||||||
from .usermanagement import load_user_from_request
|
from .usermanagement import load_user_from_request
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
opds = Blueprint('opds', __name__)
|
opds = Blueprint('opds', __name__)
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
@ -53,20 +56,6 @@ def requires_basic_auth_if_no_ano(f):
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
class FeedObject:
|
|
||||||
def __init__(self, rating_id, rating_name):
|
|
||||||
self.rating_id = rating_id
|
|
||||||
self.rating_name = rating_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self):
|
|
||||||
return self.rating_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
return self.rating_name
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/")
|
@opds.route("/opds/")
|
||||||
@opds.route("/opds")
|
@opds.route("/opds")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
|
@ -465,6 +454,20 @@ def feed_unread_books():
|
||||||
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
class FeedObject:
|
||||||
|
def __init__(self, rating_id, rating_name):
|
||||||
|
self.rating_id = rating_id
|
||||||
|
self.rating_name = rating_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
return self.rating_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self.rating_name
|
||||||
|
|
||||||
|
|
||||||
def feed_search(term):
|
def feed_search(term):
|
||||||
if term:
|
if term:
|
||||||
entries, __, ___ = calibre_db.get_search_results(term, config=config)
|
entries, __, ___ = calibre_db.get_search_results(term, config=config)
|
||||||
|
|
|
@ -29,7 +29,6 @@
|
||||||
|
|
||||||
from urllib.parse import urlparse, urljoin
|
from urllib.parse import urlparse, urljoin
|
||||||
|
|
||||||
|
|
||||||
from flask import request, url_for, redirect
|
from flask import request, url_for, redirect
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,9 +16,8 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import render_template, request
|
from flask import render_template, g, abort, request
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask import g, abort
|
|
||||||
from werkzeug.local import LocalProxy
|
from werkzeug.local import LocalProxy
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
|
|
97
cps/schedule.py
Normal file
97
cps/schedule.py
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2020 mmonkey
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from . import config, constants
|
||||||
|
from .services.background_scheduler import BackgroundScheduler, use_APScheduler
|
||||||
|
from .tasks.database import TaskReconnectDatabase
|
||||||
|
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
||||||
|
from .services.worker import WorkerThread
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduled_tasks(reconnect=True):
|
||||||
|
tasks = list()
|
||||||
|
# config.schedule_reconnect or
|
||||||
|
# Reconnect Calibre database (metadata.db)
|
||||||
|
if reconnect:
|
||||||
|
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
||||||
|
|
||||||
|
# Generate all missing book cover thumbnails
|
||||||
|
if config.schedule_generate_book_covers:
|
||||||
|
tasks.append([lambda: TaskClearCoverThumbnailCache(0), 'delete superfluous book covers', True])
|
||||||
|
tasks.append([lambda: TaskGenerateCoverThumbnails(), 'generate book covers', False])
|
||||||
|
|
||||||
|
# Generate all missing series thumbnails
|
||||||
|
if config.schedule_generate_series_covers:
|
||||||
|
tasks.append([lambda: TaskGenerateSeriesThumbnails(), 'generate book covers', False])
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
def end_scheduled_tasks():
|
||||||
|
worker = WorkerThread.get_instance()
|
||||||
|
for __, __, __, task, __ in worker.tasks:
|
||||||
|
if task.scheduled and task.is_cancellable:
|
||||||
|
worker.end_task(task.id)
|
||||||
|
|
||||||
|
|
||||||
|
def register_scheduled_tasks(reconnect=True):
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
|
||||||
|
if scheduler:
|
||||||
|
# Remove all existing jobs
|
||||||
|
scheduler.remove_all_jobs()
|
||||||
|
|
||||||
|
start = config.schedule_start_time
|
||||||
|
duration = config.schedule_duration
|
||||||
|
|
||||||
|
# Register scheduled tasks
|
||||||
|
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger='cron', hour=start)
|
||||||
|
end_time = calclulate_end_time(start, duration)
|
||||||
|
scheduler.schedule(func=end_scheduled_tasks, trigger='cron', name="end scheduled task", hour=end_time.hour,
|
||||||
|
minute=end_time.minute)
|
||||||
|
|
||||||
|
# Kick-off tasks, if they should currently be running
|
||||||
|
if should_task_be_running(start, duration):
|
||||||
|
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(reconnect))
|
||||||
|
|
||||||
|
|
||||||
|
def register_startup_tasks():
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
|
||||||
|
if scheduler:
|
||||||
|
start = config.schedule_start_time
|
||||||
|
duration = config.schedule_duration
|
||||||
|
|
||||||
|
# Run scheduled tasks immediately for development and testing
|
||||||
|
# Ignore tasks that should currently be running, as these will be added when registering scheduled tasks
|
||||||
|
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
|
||||||
|
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
||||||
|
|
||||||
|
|
||||||
|
def should_task_be_running(start, duration):
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
start_time = datetime.datetime.now().replace(hour=start, minute=0, second=0, microsecond=0)
|
||||||
|
end_time = start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
||||||
|
return start_time < now < end_time
|
||||||
|
|
||||||
|
def calclulate_end_time(start, duration):
|
||||||
|
start_time = datetime.datetime.now().replace(hour=start, minute=0)
|
||||||
|
return start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
||||||
|
|
418
cps/search.py
Normal file
418
cps/search.py
Normal file
|
@ -0,0 +1,418 @@
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2022 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint, request, redirect, url_for, flash
|
||||||
|
from flask import session as flask_session
|
||||||
|
from flask_login import current_user
|
||||||
|
from flask_babel import format_date
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from sqlalchemy.sql.expression import func, not_, and_, or_, text
|
||||||
|
from sqlalchemy.sql.functions import coalesce
|
||||||
|
|
||||||
|
from . import logger, db, calibre_db, config, ub
|
||||||
|
from .usermanagement import login_required_if_no_ano
|
||||||
|
from .render_template import render_title_template
|
||||||
|
from .pagination import Pagination
|
||||||
|
|
||||||
|
search = Blueprint('search', __name__)
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
@search.route("/search", methods=["GET"])
|
||||||
|
@login_required_if_no_ano
|
||||||
|
def simple_search():
|
||||||
|
term = request.args.get("query")
|
||||||
|
if term:
|
||||||
|
return redirect(url_for('web.books_list', data="search", sort_param='stored', query=term.strip()))
|
||||||
|
else:
|
||||||
|
return render_title_template('search.html',
|
||||||
|
searchterm="",
|
||||||
|
result_count=0,
|
||||||
|
title=_(u"Search"),
|
||||||
|
page="search")
|
||||||
|
|
||||||
|
|
||||||
|
@search.route("/advsearch", methods=['POST'])
|
||||||
|
@login_required_if_no_ano
|
||||||
|
def advanced_search():
|
||||||
|
values = dict(request.form)
|
||||||
|
params = ['include_tag', 'exclude_tag', 'include_serie', 'exclude_serie', 'include_shelf', 'exclude_shelf',
|
||||||
|
'include_language', 'exclude_language', 'include_extension', 'exclude_extension']
|
||||||
|
for param in params:
|
||||||
|
values[param] = list(request.form.getlist(param))
|
||||||
|
flask_session['query'] = json.dumps(values)
|
||||||
|
return redirect(url_for('web.books_list', data="advsearch", sort_param='stored', query=""))
|
||||||
|
|
||||||
|
|
||||||
|
@search.route("/advsearch", methods=['GET'])
|
||||||
|
@login_required_if_no_ano
|
||||||
|
def advanced_search_form():
|
||||||
|
# Build custom columns names
|
||||||
|
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
||||||
|
return render_prepare_search_form(cc)
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_custom_columns(cc, term, q):
|
||||||
|
for c in cc:
|
||||||
|
if c.datatype == "datetime":
|
||||||
|
custom_start = term.get('custom_column_' + str(c.id) + '_start')
|
||||||
|
custom_end = term.get('custom_column_' + str(c.id) + '_end')
|
||||||
|
if custom_start:
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
func.datetime(db.cc_classes[c.id].value) >= func.datetime(custom_start)))
|
||||||
|
if custom_end:
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end)))
|
||||||
|
else:
|
||||||
|
custom_query = term.get('custom_column_' + str(c.id))
|
||||||
|
if custom_query != '' and custom_query is not None:
|
||||||
|
if c.datatype == 'bool':
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
db.cc_classes[c.id].value == (custom_query == "True")))
|
||||||
|
elif c.datatype == 'int' or c.datatype == 'float':
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
db.cc_classes[c.id].value == custom_query))
|
||||||
|
elif c.datatype == 'rating':
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
db.cc_classes[c.id].value == int(float(custom_query) * 2)))
|
||||||
|
else:
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
func.lower(db.cc_classes[c.id].value).ilike("%" + custom_query + "%")))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_language(q, include_languages_inputs, exclude_languages_inputs):
|
||||||
|
if current_user.filter_language() != "all":
|
||||||
|
q = q.filter(db.Books.languages.any(db.Languages.lang_code == current_user.filter_language()))
|
||||||
|
else:
|
||||||
|
for language in include_languages_inputs:
|
||||||
|
q = q.filter(db.Books.languages.any(db.Languages.id == language))
|
||||||
|
for language in exclude_languages_inputs:
|
||||||
|
q = q.filter(not_(db.Books.series.any(db.Languages.id == language)))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_ratings(q, rating_high, rating_low):
|
||||||
|
if rating_high:
|
||||||
|
rating_high = int(rating_high) * 2
|
||||||
|
q = q.filter(db.Books.ratings.any(db.Ratings.rating <= rating_high))
|
||||||
|
if rating_low:
|
||||||
|
rating_low = int(rating_low) * 2
|
||||||
|
q = q.filter(db.Books.ratings.any(db.Ratings.rating >= rating_low))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_read_status(q, read_status):
|
||||||
|
if read_status:
|
||||||
|
if config.config_read_column:
|
||||||
|
try:
|
||||||
|
if read_status == "True":
|
||||||
|
q = q.join(db.cc_classes[config.config_read_column], isouter=True) \
|
||||||
|
.filter(db.cc_classes[config.config_read_column].value == True)
|
||||||
|
else:
|
||||||
|
q = q.join(db.cc_classes[config.config_read_column], isouter=True) \
|
||||||
|
.filter(coalesce(db.cc_classes[config.config_read_column].value, False) != True)
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
log.error(u"Custom Column No.%d is not existing in calibre database", config.config_read_column)
|
||||||
|
flash(_("Custom Column No.%(column)d is not existing in calibre database",
|
||||||
|
column=config.config_read_column),
|
||||||
|
category="error")
|
||||||
|
return q
|
||||||
|
else:
|
||||||
|
if read_status == "True":
|
||||||
|
q = q.join(ub.ReadBook, db.Books.id == ub.ReadBook.book_id, isouter=True) \
|
||||||
|
.filter(ub.ReadBook.user_id == int(current_user.id),
|
||||||
|
ub.ReadBook.read_status == ub.ReadBook.STATUS_FINISHED)
|
||||||
|
else:
|
||||||
|
q = q.join(ub.ReadBook, db.Books.id == ub.ReadBook.book_id, isouter=True) \
|
||||||
|
.filter(ub.ReadBook.user_id == int(current_user.id),
|
||||||
|
coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_extension(q, include_extension_inputs, exclude_extension_inputs):
|
||||||
|
for extension in include_extension_inputs:
|
||||||
|
q = q.filter(db.Books.data.any(db.Data.format == extension))
|
||||||
|
for extension in exclude_extension_inputs:
|
||||||
|
q = q.filter(not_(db.Books.data.any(db.Data.format == extension)))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_tag(q, include_tag_inputs, exclude_tag_inputs):
|
||||||
|
for tag in include_tag_inputs:
|
||||||
|
q = q.filter(db.Books.tags.any(db.Tags.id == tag))
|
||||||
|
for tag in exclude_tag_inputs:
|
||||||
|
q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag)))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_serie(q, include_series_inputs, exclude_series_inputs):
|
||||||
|
for serie in include_series_inputs:
|
||||||
|
q = q.filter(db.Books.series.any(db.Series.id == serie))
|
||||||
|
for serie in exclude_series_inputs:
|
||||||
|
q = q.filter(not_(db.Books.series.any(db.Series.id == serie)))
|
||||||
|
return q
|
||||||
|
|
||||||
|
def adv_search_shelf(q, include_shelf_inputs, exclude_shelf_inputs):
|
||||||
|
q = q.outerjoin(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)\
|
||||||
|
.filter(or_(ub.BookShelf.shelf == None, ub.BookShelf.shelf.notin_(exclude_shelf_inputs)))
|
||||||
|
if len(include_shelf_inputs) > 0:
|
||||||
|
q = q.filter(ub.BookShelf.shelf.in_(include_shelf_inputs))
|
||||||
|
return q
|
||||||
|
|
||||||
|
def extend_search_term(searchterm,
|
||||||
|
author_name,
|
||||||
|
book_title,
|
||||||
|
publisher,
|
||||||
|
pub_start,
|
||||||
|
pub_end,
|
||||||
|
tags,
|
||||||
|
rating_high,
|
||||||
|
rating_low,
|
||||||
|
read_status,
|
||||||
|
):
|
||||||
|
searchterm.extend((author_name.replace('|', ','), book_title, publisher))
|
||||||
|
if pub_start:
|
||||||
|
try:
|
||||||
|
searchterm.extend([_(u"Published after ") +
|
||||||
|
format_date(datetime.strptime(pub_start, "%Y-%m-%d"),
|
||||||
|
format='medium')])
|
||||||
|
except ValueError:
|
||||||
|
pub_start = u""
|
||||||
|
if pub_end:
|
||||||
|
try:
|
||||||
|
searchterm.extend([_(u"Published before ") +
|
||||||
|
format_date(datetime.strptime(pub_end, "%Y-%m-%d"),
|
||||||
|
format='medium')])
|
||||||
|
except ValueError:
|
||||||
|
pub_end = u""
|
||||||
|
elements = {'tag': db.Tags, 'serie':db.Series, 'shelf':ub.Shelf}
|
||||||
|
for key, db_element in elements.items():
|
||||||
|
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all()
|
||||||
|
searchterm.extend(tag.name for tag in tag_names)
|
||||||
|
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['exclude_' + key])).all()
|
||||||
|
searchterm.extend(tag.name for tag in tag_names)
|
||||||
|
language_names = calibre_db.session.query(db.Languages). \
|
||||||
|
filter(db.Languages.id.in_(tags['include_language'])).all()
|
||||||
|
if language_names:
|
||||||
|
language_names = calibre_db.speaking_language(language_names)
|
||||||
|
searchterm.extend(language.name for language in language_names)
|
||||||
|
language_names = calibre_db.session.query(db.Languages). \
|
||||||
|
filter(db.Languages.id.in_(tags['exclude_language'])).all()
|
||||||
|
if language_names:
|
||||||
|
language_names = calibre_db.speaking_language(language_names)
|
||||||
|
searchterm.extend(language.name for language in language_names)
|
||||||
|
if rating_high:
|
||||||
|
searchterm.extend([_(u"Rating <= %(rating)s", rating=rating_high)])
|
||||||
|
if rating_low:
|
||||||
|
searchterm.extend([_(u"Rating >= %(rating)s", rating=rating_low)])
|
||||||
|
if read_status:
|
||||||
|
searchterm.extend([_(u"Read Status = %(status)s", status=read_status)])
|
||||||
|
searchterm.extend(ext for ext in tags['include_extension'])
|
||||||
|
searchterm.extend(ext for ext in tags['exclude_extension'])
|
||||||
|
# handle custom columns
|
||||||
|
searchterm = " + ".join(filter(None, searchterm))
|
||||||
|
return searchterm, pub_start, pub_end
|
||||||
|
|
||||||
|
|
||||||
|
def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||||
|
sort = order[0] if order else [db.Books.sort]
|
||||||
|
pagination = None
|
||||||
|
|
||||||
|
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
||||||
|
calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
|
||||||
|
if not config.config_read_column:
|
||||||
|
query = (calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, ub.ReadBook).select_from(db.Books)
|
||||||
|
.outerjoin(ub.ReadBook, and_(db.Books.id == ub.ReadBook.book_id,
|
||||||
|
int(current_user.id) == ub.ReadBook.user_id)))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
read_column = cc[config.config_read_column]
|
||||||
|
query = (calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, read_column.value)
|
||||||
|
.select_from(db.Books)
|
||||||
|
.outerjoin(read_column, read_column.book == db.Books.id))
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
log.error("Custom Column No.%d is not existing in calibre database", config.config_read_column)
|
||||||
|
# Skip linking read column
|
||||||
|
query = calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, None)
|
||||||
|
query = query.outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||||
|
int(current_user.id) == ub.ArchivedBook.user_id))
|
||||||
|
|
||||||
|
q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\
|
||||||
|
.outerjoin(db.Series)\
|
||||||
|
.filter(calibre_db.common_filters(True))
|
||||||
|
|
||||||
|
# parse multi selects to a complete dict
|
||||||
|
tags = dict()
|
||||||
|
elements = ['tag', 'serie', 'shelf', 'language', 'extension']
|
||||||
|
for element in elements:
|
||||||
|
tags['include_' + element] = term.get('include_' + element)
|
||||||
|
tags['exclude_' + element] = term.get('exclude_' + element)
|
||||||
|
|
||||||
|
author_name = term.get("author_name")
|
||||||
|
book_title = term.get("book_title")
|
||||||
|
publisher = term.get("publisher")
|
||||||
|
pub_start = term.get("publishstart")
|
||||||
|
pub_end = term.get("publishend")
|
||||||
|
rating_low = term.get("ratinghigh")
|
||||||
|
rating_high = term.get("ratinglow")
|
||||||
|
description = term.get("comment")
|
||||||
|
read_status = term.get("read_status")
|
||||||
|
if author_name:
|
||||||
|
author_name = author_name.strip().lower().replace(',', '|')
|
||||||
|
if book_title:
|
||||||
|
book_title = book_title.strip().lower()
|
||||||
|
if publisher:
|
||||||
|
publisher = publisher.strip().lower()
|
||||||
|
|
||||||
|
search_term = []
|
||||||
|
cc_present = False
|
||||||
|
for c in cc:
|
||||||
|
if c.datatype == "datetime":
|
||||||
|
column_start = term.get('custom_column_' + str(c.id) + '_start')
|
||||||
|
column_end = term.get('custom_column_' + str(c.id) + '_end')
|
||||||
|
if column_start:
|
||||||
|
search_term.extend([u"{} >= {}".format(c.name,
|
||||||
|
format_date(datetime.strptime(column_start, "%Y-%m-%d").date(),
|
||||||
|
format='medium')
|
||||||
|
)])
|
||||||
|
cc_present = True
|
||||||
|
if column_end:
|
||||||
|
search_term.extend([u"{} <= {}".format(c.name,
|
||||||
|
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
||||||
|
format='medium')
|
||||||
|
)])
|
||||||
|
cc_present = True
|
||||||
|
elif term.get('custom_column_' + str(c.id)):
|
||||||
|
search_term.extend([(u"{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||||
|
cc_present = True
|
||||||
|
|
||||||
|
if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \
|
||||||
|
or rating_high or description or cc_present or read_status:
|
||||||
|
search_term, pub_start, pub_end = extend_search_term(search_term,
|
||||||
|
author_name,
|
||||||
|
book_title,
|
||||||
|
publisher,
|
||||||
|
pub_start,
|
||||||
|
pub_end,
|
||||||
|
tags,
|
||||||
|
rating_high,
|
||||||
|
rating_low,
|
||||||
|
read_status)
|
||||||
|
if author_name:
|
||||||
|
q = q.filter(db.Books.authors.any(func.lower(db.Authors.name).ilike("%" + author_name + "%")))
|
||||||
|
if book_title:
|
||||||
|
q = q.filter(func.lower(db.Books.title).ilike("%" + book_title + "%"))
|
||||||
|
if pub_start:
|
||||||
|
q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start))
|
||||||
|
if pub_end:
|
||||||
|
q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end))
|
||||||
|
q = adv_search_read_status(q, read_status)
|
||||||
|
if publisher:
|
||||||
|
q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + publisher + "%")))
|
||||||
|
q = adv_search_tag(q, tags['include_tag'], tags['exclude_tag'])
|
||||||
|
q = adv_search_serie(q, tags['include_serie'], tags['exclude_serie'])
|
||||||
|
q = adv_search_shelf(q, tags['include_shelf'], tags['exclude_shelf'])
|
||||||
|
q = adv_search_extension(q, tags['include_extension'], tags['exclude_extension'])
|
||||||
|
q = adv_search_language(q, tags['include_language'], tags['exclude_language'])
|
||||||
|
q = adv_search_ratings(q, rating_high, rating_low)
|
||||||
|
|
||||||
|
if description:
|
||||||
|
q = q.filter(db.Books.comments.any(func.lower(db.Comments.text).ilike("%" + description + "%")))
|
||||||
|
|
||||||
|
# search custom columns
|
||||||
|
try:
|
||||||
|
q = adv_search_custom_columns(cc, term, q)
|
||||||
|
except AttributeError as ex:
|
||||||
|
log.debug_or_exception(ex)
|
||||||
|
flash(_("Error on search for custom columns, please restart Calibre-Web"), category="error")
|
||||||
|
|
||||||
|
q = q.order_by(*sort).all()
|
||||||
|
flask_session['query'] = json.dumps(term)
|
||||||
|
ub.store_combo_ids(q)
|
||||||
|
result_count = len(q)
|
||||||
|
if offset is not None and limit is not None:
|
||||||
|
offset = int(offset)
|
||||||
|
limit_all = offset + int(limit)
|
||||||
|
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
||||||
|
else:
|
||||||
|
offset = 0
|
||||||
|
limit_all = result_count
|
||||||
|
entries = calibre_db.order_authors(q[offset:limit_all], list_return=True, combined=True)
|
||||||
|
return render_title_template('search.html',
|
||||||
|
adv_searchterm=search_term,
|
||||||
|
pagination=pagination,
|
||||||
|
entries=entries,
|
||||||
|
result_count=result_count,
|
||||||
|
title=_(u"Advanced Search"), page="advsearch",
|
||||||
|
order=order[1])
|
||||||
|
|
||||||
|
|
||||||
|
def render_prepare_search_form(cc):
|
||||||
|
# prepare data for search-form
|
||||||
|
tags = calibre_db.session.query(db.Tags)\
|
||||||
|
.join(db.books_tags_link)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(text('books_tags_link.tag'))\
|
||||||
|
.order_by(db.Tags.name).all()
|
||||||
|
series = calibre_db.session.query(db.Series)\
|
||||||
|
.join(db.books_series_link)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(text('books_series_link.series'))\
|
||||||
|
.order_by(db.Series.name)\
|
||||||
|
.filter(calibre_db.common_filters()).all()
|
||||||
|
shelves = ub.session.query(ub.Shelf)\
|
||||||
|
.filter(or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == int(current_user.id)))\
|
||||||
|
.order_by(ub.Shelf.name).all()
|
||||||
|
extensions = calibre_db.session.query(db.Data)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(db.Data.format)\
|
||||||
|
.order_by(db.Data.format).all()
|
||||||
|
if current_user.filter_language() == u"all":
|
||||||
|
languages = calibre_db.speaking_language()
|
||||||
|
else:
|
||||||
|
languages = None
|
||||||
|
return render_title_template('search_form.html', tags=tags, languages=languages, extensions=extensions,
|
||||||
|
series=series,shelves=shelves, title=_(u"Advanced Search"), cc=cc, page="advsearch")
|
||||||
|
|
||||||
|
|
||||||
|
def render_search_results(term, offset=None, order=None, limit=None):
|
||||||
|
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
|
||||||
|
entries, result_count, pagination = calibre_db.get_search_results(term,
|
||||||
|
config,
|
||||||
|
offset,
|
||||||
|
order,
|
||||||
|
limit,
|
||||||
|
*join)
|
||||||
|
return render_title_template('search.html',
|
||||||
|
searchterm=term,
|
||||||
|
pagination=pagination,
|
||||||
|
query=term,
|
||||||
|
adv_searchterm=term,
|
||||||
|
entries=entries,
|
||||||
|
result_count=result_count,
|
||||||
|
title=_(u"Search"),
|
||||||
|
page="search",
|
||||||
|
order=order[1])
|
||||||
|
|
||||||
|
|
|
@ -22,17 +22,16 @@ import inspect
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
# from time import time
|
|
||||||
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, request, url_for
|
from flask import Blueprint, Response, request, url_for
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
from flask_babel import get_locale
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from cps.services.Metadata import Metadata
|
from cps.services.Metadata import Metadata
|
||||||
from . import constants, get_locale, logger, ub, web_server
|
from . import constants, logger, ub, web_server
|
||||||
|
|
||||||
# current_milli_time = lambda: int(round(time() * 1000))
|
# current_milli_time = lambda: int(round(time() * 1000))
|
||||||
|
|
||||||
|
@ -57,9 +56,10 @@ for f in modules:
|
||||||
try:
|
try:
|
||||||
importlib.import_module("cps.metadata_provider." + a)
|
importlib.import_module("cps.metadata_provider." + a)
|
||||||
new_list.append(a)
|
new_list.append(a)
|
||||||
except (ImportError, IndentationError, SyntaxError) as e:
|
except (IndentationError, SyntaxError) as e:
|
||||||
log.error("Import error for metadata source: {} - {}".format(a, e))
|
log.error("Syntax error for metadata source: {} - {}".format(a, e))
|
||||||
pass
|
except ImportError as e:
|
||||||
|
log.debug("Import error for metadata source: {} - {}".format(a, e))
|
||||||
|
|
||||||
|
|
||||||
def list_classes(provider_list):
|
def list_classes(provider_list):
|
||||||
|
|
|
@ -18,11 +18,10 @@
|
||||||
|
|
||||||
from .. import logger
|
from .. import logger
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
try:
|
||||||
try: from . import goodreads_support
|
from . import goodreads_support
|
||||||
except ImportError as err:
|
except ImportError as err:
|
||||||
log.debug("Cannot import goodreads, showing authors-metadata will not work: %s", err)
|
log.debug("Cannot import goodreads, showing authors-metadata will not work: %s", err)
|
||||||
goodreads_support = None
|
goodreads_support = None
|
||||||
|
|
84
cps/services/background_scheduler.py
Normal file
84
cps/services/background_scheduler.py
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2020 mmonkey
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
|
||||||
|
from .. import logger
|
||||||
|
from .worker import WorkerThread
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler as BScheduler
|
||||||
|
use_APScheduler = True
|
||||||
|
except (ImportError, RuntimeError) as e:
|
||||||
|
use_APScheduler = False
|
||||||
|
log = logger.create()
|
||||||
|
log.info('APScheduler not found. Unable to schedule tasks.')
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundScheduler:
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if not use_APScheduler:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(BackgroundScheduler, cls).__new__(cls)
|
||||||
|
cls.log = logger.create()
|
||||||
|
cls.scheduler = BScheduler()
|
||||||
|
cls.scheduler.start()
|
||||||
|
|
||||||
|
atexit.register(lambda: cls.scheduler.shutdown())
|
||||||
|
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def schedule(self, func, trigger, name=None, **trigger_args):
|
||||||
|
if use_APScheduler:
|
||||||
|
return self.scheduler.add_job(func=func, trigger=trigger, name=name, **trigger_args)
|
||||||
|
|
||||||
|
# Expects a lambda expression for the task
|
||||||
|
def schedule_task(self, task, user=None, name=None, hidden=False, trigger='cron', **trigger_args):
|
||||||
|
if use_APScheduler:
|
||||||
|
def scheduled_task():
|
||||||
|
worker_task = task()
|
||||||
|
worker_task.scheduled = True
|
||||||
|
WorkerThread.add(user, worker_task, hidden=hidden)
|
||||||
|
return self.schedule(func=scheduled_task, trigger=trigger, name=name, **trigger_args)
|
||||||
|
|
||||||
|
# Expects a list of lambda expressions for the tasks
|
||||||
|
def schedule_tasks(self, tasks, user=None, trigger='cron', **trigger_args):
|
||||||
|
if use_APScheduler:
|
||||||
|
for task in tasks:
|
||||||
|
self.schedule_task(task[0], user=user, trigger=trigger, name=task[1], hidden=task[2], **trigger_args)
|
||||||
|
|
||||||
|
# Expects a lambda expression for the task
|
||||||
|
def schedule_task_immediately(self, task, user=None, name=None, hidden=False):
|
||||||
|
if use_APScheduler:
|
||||||
|
def immediate_task():
|
||||||
|
WorkerThread.add(user, task(), hidden)
|
||||||
|
return self.schedule(func=immediate_task, trigger='date', name=name)
|
||||||
|
|
||||||
|
# Expects a list of lambda expressions for the tasks
|
||||||
|
def schedule_tasks_immediately(self, tasks, user=None):
|
||||||
|
if use_APScheduler:
|
||||||
|
for task in tasks:
|
||||||
|
self.schedule_task_immediately(task[0], user, name="immediately " + task[1], hidden=task[2])
|
||||||
|
|
||||||
|
# Remove all jobs
|
||||||
|
def remove_all_jobs(self):
|
||||||
|
self.scheduler.remove_all_jobs()
|
|
@ -37,11 +37,13 @@ STAT_WAITING = 0
|
||||||
STAT_FAIL = 1
|
STAT_FAIL = 1
|
||||||
STAT_STARTED = 2
|
STAT_STARTED = 2
|
||||||
STAT_FINISH_SUCCESS = 3
|
STAT_FINISH_SUCCESS = 3
|
||||||
|
STAT_ENDED = 4
|
||||||
|
STAT_CANCELLED = 5
|
||||||
|
|
||||||
# Only retain this many tasks in dequeued list
|
# Only retain this many tasks in dequeued list
|
||||||
TASK_CLEANUP_TRIGGER = 20
|
TASK_CLEANUP_TRIGGER = 20
|
||||||
|
|
||||||
QueuedTask = namedtuple('QueuedTask', 'num, user, added, task')
|
QueuedTask = namedtuple('QueuedTask', 'num, user, added, task, hidden')
|
||||||
|
|
||||||
|
|
||||||
def _get_main_thread():
|
def _get_main_thread():
|
||||||
|
@ -51,7 +53,6 @@ def _get_main_thread():
|
||||||
raise Exception("main thread not found?!")
|
raise Exception("main thread not found?!")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ImprovedQueue(queue.Queue):
|
class ImprovedQueue(queue.Queue):
|
||||||
def to_list(self):
|
def to_list(self):
|
||||||
"""
|
"""
|
||||||
|
@ -61,12 +62,13 @@ class ImprovedQueue(queue.Queue):
|
||||||
with self.mutex:
|
with self.mutex:
|
||||||
return list(self.queue)
|
return list(self.queue)
|
||||||
|
|
||||||
|
|
||||||
# Class for all worker tasks in the background
|
# Class for all worker tasks in the background
|
||||||
class WorkerThread(threading.Thread):
|
class WorkerThread(threading.Thread):
|
||||||
_instance = None
|
_instance = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def getInstance(cls):
|
def get_instance(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = WorkerThread()
|
cls._instance = WorkerThread()
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
@ -82,15 +84,17 @@ class WorkerThread(threading.Thread):
|
||||||
self.start()
|
self.start()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def add(cls, user, task):
|
def add(cls, user, task, hidden=False):
|
||||||
ins = cls.getInstance()
|
ins = cls.get_instance()
|
||||||
ins.num += 1
|
ins.num += 1
|
||||||
log.debug("Add Task for user: {} - {}".format(user, task))
|
username = user if user is not None else 'System'
|
||||||
|
log.debug("Add Task for user: {} - {}".format(username, task))
|
||||||
ins.queue.put(QueuedTask(
|
ins.queue.put(QueuedTask(
|
||||||
num=ins.num,
|
num=ins.num,
|
||||||
user=user,
|
user=username,
|
||||||
added=datetime.now(),
|
added=datetime.now(),
|
||||||
task=task,
|
task=task,
|
||||||
|
hidden=hidden
|
||||||
))
|
))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -111,10 +115,10 @@ class WorkerThread(threading.Thread):
|
||||||
if delta > TASK_CLEANUP_TRIGGER:
|
if delta > TASK_CLEANUP_TRIGGER:
|
||||||
ret = alive
|
ret = alive
|
||||||
else:
|
else:
|
||||||
# otherwise, lop off the oldest dead tasks until we hit the target trigger
|
# otherwise, loop off the oldest dead tasks until we hit the target trigger
|
||||||
ret = sorted(dead, key=lambda x: x.task.end_time)[-TASK_CLEANUP_TRIGGER:] + alive
|
ret = sorted(dead, key=lambda y: y.task.end_time)[-TASK_CLEANUP_TRIGGER:] + alive
|
||||||
|
|
||||||
self.dequeued = sorted(ret, key=lambda x: x.num)
|
self.dequeued = sorted(ret, key=lambda y: y.num)
|
||||||
|
|
||||||
# Main thread loop starting the different tasks
|
# Main thread loop starting the different tasks
|
||||||
def run(self):
|
def run(self):
|
||||||
|
@ -141,11 +145,21 @@ class WorkerThread(threading.Thread):
|
||||||
|
|
||||||
# sometimes tasks (like Upload) don't actually have work to do and are created as already finished
|
# sometimes tasks (like Upload) don't actually have work to do and are created as already finished
|
||||||
if item.task.stat is STAT_WAITING:
|
if item.task.stat is STAT_WAITING:
|
||||||
# CalibreTask.start() should wrap all exceptions in it's own error handling
|
# CalibreTask.start() should wrap all exceptions in its own error handling
|
||||||
item.task.start(self)
|
item.task.start(self)
|
||||||
|
|
||||||
|
# remove self_cleanup tasks and hidden "System Tasks" from list
|
||||||
|
if item.task.self_cleanup or item.hidden:
|
||||||
|
self.dequeued.remove(item)
|
||||||
|
|
||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
|
|
||||||
|
def end_task(self, task_id):
|
||||||
|
ins = self.get_instance()
|
||||||
|
for __, __, __, task, __ in ins.tasks:
|
||||||
|
if str(task.id) == str(task_id) and task.is_cancellable:
|
||||||
|
task.stat = STAT_CANCELLED if task.stat == STAT_WAITING else STAT_ENDED
|
||||||
|
|
||||||
|
|
||||||
class CalibreTask:
|
class CalibreTask:
|
||||||
__metaclass__ = abc.ABCMeta
|
__metaclass__ = abc.ABCMeta
|
||||||
|
@ -158,10 +172,12 @@ class CalibreTask:
|
||||||
self.end_time = None
|
self.end_time = None
|
||||||
self.message = message
|
self.message = message
|
||||||
self.id = uuid.uuid4()
|
self.id = uuid.uuid4()
|
||||||
|
self.self_cleanup = False
|
||||||
|
self._scheduled = False
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
"""Provides the caller some human-readable name for this class"""
|
"""The main entry-point for this task"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
|
@ -169,6 +185,11 @@ class CalibreTask:
|
||||||
"""Provides the caller some human-readable name for this class"""
|
"""Provides the caller some human-readable name for this class"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def is_cancellable(self):
|
||||||
|
"""Does this task gracefully handle being cancelled (STAT_ENDED, STAT_CANCELLED)?"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
def start(self, *args):
|
def start(self, *args):
|
||||||
self.start_time = datetime.now()
|
self.start_time = datetime.now()
|
||||||
self.stat = STAT_STARTED
|
self.stat = STAT_STARTED
|
||||||
|
@ -219,15 +240,23 @@ class CalibreTask:
|
||||||
We have a separate dictating this because there may be certain tasks that want to override this
|
We have a separate dictating this because there may be certain tasks that want to override this
|
||||||
"""
|
"""
|
||||||
# By default, we're good to clean a task if it's "Done"
|
# By default, we're good to clean a task if it's "Done"
|
||||||
return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL)
|
return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL, STAT_ENDED, STAT_CANCELLED)
|
||||||
|
|
||||||
'''@progress.setter
|
@property
|
||||||
def progress(self, x):
|
def self_cleanup(self):
|
||||||
if x > 1:
|
return self._self_cleanup
|
||||||
x = 1
|
|
||||||
if x < 0:
|
@self_cleanup.setter
|
||||||
x = 0
|
def self_cleanup(self, is_self_cleanup):
|
||||||
self._progress = x'''
|
self._self_cleanup = is_self_cleanup
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheduled(self):
|
||||||
|
return self._scheduled
|
||||||
|
|
||||||
|
@scheduled.setter
|
||||||
|
def scheduled(self, is_scheduled):
|
||||||
|
self._scheduled = is_scheduled
|
||||||
|
|
||||||
def _handleError(self, error_message):
|
def _handleError(self, error_message):
|
||||||
self.stat = STAT_FAIL
|
self.stat = STAT_FAIL
|
||||||
|
|
219
cps/shelf.py
219
cps/shelf.py
|
@ -33,27 +33,9 @@ from . import calibre_db, config, db, logger, ub
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
from .usermanagement import login_required_if_no_ano
|
from .usermanagement import login_required_if_no_ano
|
||||||
|
|
||||||
shelf = Blueprint('shelf', __name__)
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
shelf = Blueprint('shelf', __name__)
|
||||||
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 {} not allowed to edit shelf: {}".format(current_user.id, cur_shelf.name))
|
|
||||||
return False
|
|
||||||
if cur_shelf.is_public and not current_user.role_edit_shelfs():
|
|
||||||
log.info("User {} not allowed to edit public shelves".format(current_user.id))
|
|
||||||
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: {}".format(cur_shelf.name))
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||||
|
@ -238,96 +220,6 @@ def edit_shelf(shelf_id):
|
||||||
return create_edit_shelf(shelf, page_title=_(u"Edit a shelf"), page="shelfedit", shelf_id=shelf_id)
|
return create_edit_shelf(shelf, page_title=_(u"Edit a shelf"), page="shelfedit", shelf_id=shelf_id)
|
||||||
|
|
||||||
|
|
||||||
# if shelf ID is set, we are editing a shelf
|
|
||||||
def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
|
||||||
sync_only_selected_shelves = current_user.kobo_only_shelves_sync
|
|
||||||
# calibre_db.session.query(ub.Shelf).filter(ub.Shelf.user_id == current_user.id).filter(ub.Shelf.kobo_sync).count()
|
|
||||||
if request.method == "POST":
|
|
||||||
to_save = request.form.to_dict()
|
|
||||||
if not current_user.role_edit_shelfs() and to_save.get("is_public") == "on":
|
|
||||||
flash(_(u"Sorry you are not allowed to create a public shelf"), category="error")
|
|
||||||
return redirect(url_for('web.index'))
|
|
||||||
is_public = 1 if to_save.get("is_public") == "on" else 0
|
|
||||||
if config.config_kobo_sync:
|
|
||||||
shelf.kobo_sync = True if to_save.get("kobo_sync") else False
|
|
||||||
if shelf.kobo_sync:
|
|
||||||
ub.session.query(ub.ShelfArchive).filter(ub.ShelfArchive.user_id == current_user.id).filter(
|
|
||||||
ub.ShelfArchive.uuid == shelf.uuid).delete()
|
|
||||||
ub.session_commit()
|
|
||||||
shelf_title = to_save.get("title", "")
|
|
||||||
if check_shelf_is_unique(shelf, shelf_title, is_public, shelf_id):
|
|
||||||
shelf.name = shelf_title
|
|
||||||
shelf.is_public = is_public
|
|
||||||
if not shelf_id:
|
|
||||||
shelf.user_id = int(current_user.id)
|
|
||||||
ub.session.add(shelf)
|
|
||||||
shelf_action = "created"
|
|
||||||
flash_text = _(u"Shelf %(title)s created", title=shelf_title)
|
|
||||||
else:
|
|
||||||
shelf_action = "changed"
|
|
||||||
flash_text = _(u"Shelf %(title)s changed", title=shelf_title)
|
|
||||||
try:
|
|
||||||
ub.session.commit()
|
|
||||||
log.info(u"Shelf {} {}".format(shelf_title, shelf_action))
|
|
||||||
flash(flash_text, category="success")
|
|
||||||
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
|
||||||
except (OperationalError, InvalidRequestError) as ex:
|
|
||||||
ub.session.rollback()
|
|
||||||
log.error_or_exception(ex)
|
|
||||||
log.error_or_exception("Settings Database error: {}".format(ex))
|
|
||||||
flash(_(u"Database error: %(error)s.", error=ex.orig), category="error")
|
|
||||||
except Exception as ex:
|
|
||||||
ub.session.rollback()
|
|
||||||
log.error_or_exception(ex)
|
|
||||||
flash(_(u"There was an error"), category="error")
|
|
||||||
return render_title_template('shelf_edit.html',
|
|
||||||
shelf=shelf,
|
|
||||||
title=page_title,
|
|
||||||
page=page,
|
|
||||||
kobo_sync_enabled=config.config_kobo_sync,
|
|
||||||
sync_only_selected_shelves=sync_only_selected_shelves)
|
|
||||||
|
|
||||||
|
|
||||||
def check_shelf_is_unique(shelf, title, is_public, shelf_id=False):
|
|
||||||
if shelf_id:
|
|
||||||
ident = ub.Shelf.id != shelf_id
|
|
||||||
else:
|
|
||||||
ident = true()
|
|
||||||
if is_public == 1:
|
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
|
||||||
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 1)) \
|
|
||||||
.filter(ident) \
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
log.error("A public shelf with the name '{}' already exists.".format(title))
|
|
||||||
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=title),
|
|
||||||
category="error")
|
|
||||||
else:
|
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
|
||||||
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 0) &
|
|
||||||
(ub.Shelf.user_id == int(current_user.id))) \
|
|
||||||
.filter(ident) \
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
log.error("A private shelf with the name '{}' already exists.".format(title))
|
|
||||||
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=title),
|
|
||||||
category="error")
|
|
||||||
return is_shelf_name_unique
|
|
||||||
|
|
||||||
|
|
||||||
def delete_shelf_helper(cur_shelf):
|
|
||||||
if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
|
|
||||||
return False
|
|
||||||
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("successfully deleted Shelf {}".format(cur_shelf.name))
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_shelf(shelf_id):
|
def delete_shelf(shelf_id):
|
||||||
|
@ -392,6 +284,115 @@ def order_shelf(shelf_id):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
|
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 {} not allowed to edit shelf: {}".format(current_user.id, cur_shelf.name))
|
||||||
|
return False
|
||||||
|
if cur_shelf.is_public and not current_user.role_edit_shelfs():
|
||||||
|
log.info("User {} not allowed to edit public shelves".format(current_user.id))
|
||||||
|
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: {}".format(cur_shelf.name))
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# if shelf ID is set, we are editing a shelf
|
||||||
|
def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
||||||
|
sync_only_selected_shelves = current_user.kobo_only_shelves_sync
|
||||||
|
# calibre_db.session.query(ub.Shelf).filter(ub.Shelf.user_id == current_user.id).filter(ub.Shelf.kobo_sync).count()
|
||||||
|
if request.method == "POST":
|
||||||
|
to_save = request.form.to_dict()
|
||||||
|
if not current_user.role_edit_shelfs() and to_save.get("is_public") == "on":
|
||||||
|
flash(_(u"Sorry you are not allowed to create a public shelf"), category="error")
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
is_public = 1 if to_save.get("is_public") == "on" else 0
|
||||||
|
if config.config_kobo_sync:
|
||||||
|
shelf.kobo_sync = True if to_save.get("kobo_sync") else False
|
||||||
|
if shelf.kobo_sync:
|
||||||
|
ub.session.query(ub.ShelfArchive).filter(ub.ShelfArchive.user_id == current_user.id).filter(
|
||||||
|
ub.ShelfArchive.uuid == shelf.uuid).delete()
|
||||||
|
ub.session_commit()
|
||||||
|
shelf_title = to_save.get("title", "")
|
||||||
|
if check_shelf_is_unique(shelf_title, is_public, shelf_id):
|
||||||
|
shelf.name = shelf_title
|
||||||
|
shelf.is_public = is_public
|
||||||
|
if not shelf_id:
|
||||||
|
shelf.user_id = int(current_user.id)
|
||||||
|
ub.session.add(shelf)
|
||||||
|
shelf_action = "created"
|
||||||
|
flash_text = _(u"Shelf %(title)s created", title=shelf_title)
|
||||||
|
else:
|
||||||
|
shelf_action = "changed"
|
||||||
|
flash_text = _(u"Shelf %(title)s changed", title=shelf_title)
|
||||||
|
try:
|
||||||
|
ub.session.commit()
|
||||||
|
log.info(u"Shelf {} {}".format(shelf_title, shelf_action))
|
||||||
|
flash(flash_text, category="success")
|
||||||
|
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
||||||
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
log.error_or_exception("Settings Database error: {}".format(ex))
|
||||||
|
flash(_(u"Database error: %(error)s.", error=ex.orig), category="error")
|
||||||
|
except Exception as ex:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
flash(_(u"There was an error"), category="error")
|
||||||
|
return render_title_template('shelf_edit.html',
|
||||||
|
shelf=shelf,
|
||||||
|
title=page_title,
|
||||||
|
page=page,
|
||||||
|
kobo_sync_enabled=config.config_kobo_sync,
|
||||||
|
sync_only_selected_shelves=sync_only_selected_shelves)
|
||||||
|
|
||||||
|
|
||||||
|
def check_shelf_is_unique(title, is_public, shelf_id=False):
|
||||||
|
if shelf_id:
|
||||||
|
ident = ub.Shelf.id != shelf_id
|
||||||
|
else:
|
||||||
|
ident = true()
|
||||||
|
if is_public == 1:
|
||||||
|
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||||
|
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 1)) \
|
||||||
|
.filter(ident) \
|
||||||
|
.first() is None
|
||||||
|
|
||||||
|
if not is_shelf_name_unique:
|
||||||
|
log.error("A public shelf with the name '{}' already exists.".format(title))
|
||||||
|
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=title),
|
||||||
|
category="error")
|
||||||
|
else:
|
||||||
|
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||||
|
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 0) &
|
||||||
|
(ub.Shelf.user_id == int(current_user.id))) \
|
||||||
|
.filter(ident) \
|
||||||
|
.first() is None
|
||||||
|
|
||||||
|
if not is_shelf_name_unique:
|
||||||
|
log.error("A private shelf with the name '{}' already exists.".format(title))
|
||||||
|
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=title),
|
||||||
|
category="error")
|
||||||
|
return is_shelf_name_unique
|
||||||
|
|
||||||
|
|
||||||
|
def delete_shelf_helper(cur_shelf):
|
||||||
|
if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
|
||||||
|
return False
|
||||||
|
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("successfully deleted Shelf {}".format(cur_shelf.name))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def change_shelf_order(shelf_id, order):
|
def change_shelf_order(shelf_id, order):
|
||||||
result = calibre_db.session.query(db.Books).outerjoin(db.books_series_link,
|
result = calibre_db.session.query(db.Books).outerjoin(db.books_series_link,
|
||||||
db.Books.id == db.books_series_link.c.book)\
|
db.Books.id == db.books_series_link.c.book)\
|
||||||
|
|
|
@ -5150,7 +5150,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
|
||||||
pointer-events: none
|
pointer-events: none
|
||||||
}
|
}
|
||||||
|
|
||||||
#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover {
|
#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, #cancelTaskModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover {
|
||||||
cursor: pointer
|
cursor: pointer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5237,7 +5237,11 @@ body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > d
|
||||||
margin-bottom: 20px
|
margin-bottom: 20px
|
||||||
}
|
}
|
||||||
|
|
||||||
body.admin:not(.modal-open) .btn-default {
|
body.admin > div.container-fluid div.scheduled_tasks_details {
|
||||||
|
margin-bottom: 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
body.admin .btn-default {
|
||||||
margin-bottom: 10px
|
margin-bottom: 10px
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5468,7 +5472,7 @@ body.admin.modal-open .navbar {
|
||||||
z-index: 0 !important
|
z-index: 0 !important
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal {
|
#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal, #cancelTaskModal {
|
||||||
top: 0;
|
top: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-top: 70px;
|
padding-top: 70px;
|
||||||
|
@ -5478,7 +5482,7 @@ body.admin.modal-open .navbar {
|
||||||
background: rgba(0, 0, 0, .5)
|
background: rgba(0, 0, 0, .5)
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before {
|
#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before, #cancelTaskModal:before {
|
||||||
content: "\E208";
|
content: "\E208";
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -5500,18 +5504,18 @@ body.admin.modal-open .navbar {
|
||||||
z-index: 99
|
z-index: 99
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before {
|
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before {
|
||||||
-webkit-transform: translate(0, 0);
|
-webkit-transform: translate(0, 0);
|
||||||
-ms-transform: translate(0, 0);
|
-ms-transform: translate(0, 0);
|
||||||
transform: translate(0, 0)
|
transform: translate(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog {
|
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog {
|
||||||
width: 450px;
|
width: 450px;
|
||||||
margin: auto
|
margin: auto
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content {
|
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content {
|
||||||
max-height: calc(100% - 90px);
|
max-height: calc(100% - 90px);
|
||||||
-webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
|
-webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
|
box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
|
||||||
|
@ -5522,7 +5526,7 @@ body.admin.modal-open .navbar {
|
||||||
width: 450px
|
width: 450px
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header {
|
#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header {
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-radius: 3px 3px 0 0;
|
border-radius: 3px 3px 0 0;
|
||||||
line-height: 1.71428571;
|
line-height: 1.71428571;
|
||||||
|
@ -5535,7 +5539,7 @@ body.admin.modal-open .navbar {
|
||||||
text-align: left
|
text-align: left
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before {
|
#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before {
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
@ -5564,6 +5568,11 @@ body.admin.modal-open .navbar {
|
||||||
font-family: plex-icons-new, serif
|
font-family: plex-icons-new, serif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before {
|
||||||
|
content: "\EA6D";
|
||||||
|
font-family: plex-icons-new, serif
|
||||||
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog > .modal-content > .modal-header:after {
|
#RestartDialog > .modal-dialog > .modal-content > .modal-header:after {
|
||||||
content: "Restart Calibre-Web";
|
content: "Restart Calibre-Web";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -5588,7 +5597,13 @@ body.admin.modal-open .navbar {
|
||||||
font-size: 20px
|
font-size: 20px
|
||||||
}
|
}
|
||||||
|
|
||||||
#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile {
|
#cancelTaskModal > .modal-dialog > .modal-content > .modal-header:after {
|
||||||
|
content: "Delete Book";
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile {
|
||||||
display: none
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5602,7 +5617,7 @@ body.admin.modal-open .navbar {
|
||||||
text-align: left
|
text-align: left
|
||||||
}
|
}
|
||||||
|
|
||||||
#ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body {
|
#ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body {
|
||||||
padding: 20px 20px 40px;
|
padding: 20px 20px 40px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6em;
|
line-height: 1.6em;
|
||||||
|
@ -5612,7 +5627,7 @@ body.admin.modal-open .navbar {
|
||||||
text-align: left
|
text-align: left
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p {
|
#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body > p {
|
||||||
padding: 20px 20px 0 0;
|
padding: 20px 20px 0 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6em;
|
line-height: 1.6em;
|
||||||
|
@ -5621,7 +5636,7 @@ body.admin.modal-open .navbar {
|
||||||
background: #282828
|
background: #282828
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default {
|
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default {
|
||||||
float: right;
|
float: right;
|
||||||
z-index: 9;
|
z-index: 9;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -5669,6 +5684,18 @@ body.admin.modal-open .navbar {
|
||||||
border-radius: 3px
|
border-radius: 3px
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger {
|
||||||
|
float: right;
|
||||||
|
z-index: 9;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 0 10px;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.33;
|
||||||
|
border-radius: 3px
|
||||||
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart) {
|
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart) {
|
||||||
margin: 25px 0 0 10px
|
margin: 25px 0 0 10px
|
||||||
}
|
}
|
||||||
|
@ -5681,7 +5708,11 @@ body.admin.modal-open .navbar {
|
||||||
margin: 0 0 0 10px
|
margin: 0 0 0 10px
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover {
|
#cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default {
|
||||||
|
margin: 0 0 0 10px
|
||||||
|
}
|
||||||
|
|
||||||
|
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover {
|
||||||
background-color: hsla(0, 0%, 100%, .3)
|
background-color: hsla(0, 0%, 100%, .3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7303,11 +7334,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
||||||
background-color: transparent !important
|
background-color: transparent !important
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog {
|
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog {
|
||||||
max-width: calc(100vw - 40px)
|
max-width: calc(100vw - 40px)
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content {
|
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content {
|
||||||
max-width: calc(100vw - 40px);
|
max-width: calc(100vw - 40px);
|
||||||
left: 0
|
left: 0
|
||||||
}
|
}
|
||||||
|
@ -7457,7 +7488,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
||||||
padding: 30px 15px
|
padding: 30px 15px
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before {
|
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 34px
|
right: 34px
|
||||||
}
|
}
|
||||||
|
|
|
@ -474,6 +474,17 @@ $(function() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
$("#admin_refresh_cover_cache").click(function() {
|
||||||
|
confirmDialog("admin_refresh_cover_cache", "GeneralChangeModal", 0, function () {
|
||||||
|
$.ajax({
|
||||||
|
method:"post",
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
|
dataType: "json",
|
||||||
|
url: getPath() + "/ajax/updateThumbnails",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$("#restart_database").click(function() {
|
$("#restart_database").click(function() {
|
||||||
$("#DialogHeader").addClass("hidden");
|
$("#DialogHeader").addClass("hidden");
|
||||||
$("#DialogFinished").addClass("hidden");
|
$("#DialogFinished").addClass("hidden");
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported TableActions, RestrictionActions, EbookActions, responseHandler */
|
/* exported TableActions, RestrictionActions, EbookActions, TaskActions, responseHandler */
|
||||||
/* global getPath, confirmDialog */
|
/* global getPath, confirmDialog */
|
||||||
|
|
||||||
var selections = [];
|
var selections = [];
|
||||||
|
@ -42,6 +42,24 @@ $(function() {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$("#cancel_task_confirm").click(function() {
|
||||||
|
//get data-id attribute of the clicked element
|
||||||
|
var taskId = $(this).data("task-id");
|
||||||
|
$.ajax({
|
||||||
|
method: "post",
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
|
dataType: "json",
|
||||||
|
url: window.location.pathname + "/../ajax/canceltask",
|
||||||
|
data: JSON.stringify({"task_id": taskId}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
//triggered when modal is about to be shown
|
||||||
|
$("#cancelTaskModal").on("show.bs.modal", function(e) {
|
||||||
|
//get data-id attribute of the clicked element and store in button
|
||||||
|
var taskId = $(e.relatedTarget).data("task-id");
|
||||||
|
$(e.currentTarget).find("#cancel_task_confirm").data("task-id", taskId);
|
||||||
|
});
|
||||||
|
|
||||||
$("#books-table").on("check.bs.table check-all.bs.table uncheck.bs.table uncheck-all.bs.table",
|
$("#books-table").on("check.bs.table check-all.bs.table uncheck.bs.table uncheck-all.bs.table",
|
||||||
function (e, rowsAfter, rowsBefore) {
|
function (e, rowsAfter, rowsBefore) {
|
||||||
var rows = rowsAfter;
|
var rows = rowsAfter;
|
||||||
|
@ -532,7 +550,7 @@ $(function() {
|
||||||
|
|
||||||
$("#user-table").on("click-cell.bs.table", function (field, value, row, $element) {
|
$("#user-table").on("click-cell.bs.table", function (field, value, row, $element) {
|
||||||
if (value === "denied_column_value") {
|
if (value === "denied_column_value") {
|
||||||
ConfirmDialog("btndeluser", "GeneralDeleteModal", $element.id, user_handle);
|
confirmDialog("btndeluser", "GeneralDeleteModal", $element.id, user_handle);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -582,6 +600,7 @@ function handle_header_buttons () {
|
||||||
$(".header_select").removeAttr("disabled");
|
$(".header_select").removeAttr("disabled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Function for deleting domain restrictions */
|
/* Function for deleting domain restrictions */
|
||||||
function TableActions (value, row) {
|
function TableActions (value, row) {
|
||||||
return [
|
return [
|
||||||
|
@ -619,6 +638,19 @@ function UserActions (value, row) {
|
||||||
].join("");
|
].join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Function for cancelling tasks */
|
||||||
|
function TaskActions (value, row) {
|
||||||
|
var cancellableStats = [0, 1, 2];
|
||||||
|
if (row.task_id && row.is_cancellable && cancellableStats.includes(row.stat)) {
|
||||||
|
return [
|
||||||
|
"<div class=\"danger task-cancel\" data-toggle=\"modal\" data-target=\"#cancelTaskModal\" data-task-id=\"" + row.task_id + "\" title=\"Cancel\">",
|
||||||
|
"<i class=\"glyphicon glyphicon-ban-circle\"></i>",
|
||||||
|
"</div>"
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/* Function for keeping checked rows */
|
/* Function for keeping checked rows */
|
||||||
function responseHandler(res) {
|
function responseHandler(res) {
|
||||||
$.each(res.rows, function (i, row) {
|
$.each(res.rows, function (i, row) {
|
||||||
|
|
|
@ -18,12 +18,12 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from glob import glob
|
from glob import glob
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from cps.services.worker import CalibreTask
|
from cps.services.worker import CalibreTask
|
||||||
from cps import db
|
from cps import db
|
||||||
|
@ -41,10 +41,10 @@ log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
class TaskConvert(CalibreTask):
|
class TaskConvert(CalibreTask):
|
||||||
def __init__(self, file_path, bookid, taskMessage, settings, kindle_mail, user=None):
|
def __init__(self, file_path, book_id, task_message, settings, kindle_mail, user=None):
|
||||||
super(TaskConvert, self).__init__(taskMessage)
|
super(TaskConvert, self).__init__(task_message)
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.bookid = bookid
|
self.book_id = book_id
|
||||||
self.title = ""
|
self.title = ""
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.kindle_mail = kindle_mail
|
self.kindle_mail = kindle_mail
|
||||||
|
@ -55,10 +55,10 @@ class TaskConvert(CalibreTask):
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
self.worker_thread = worker_thread
|
self.worker_thread = worker_thread
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
worker_db = db.CalibreDB(expire_on_commit=False)
|
worker_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
cur_book = worker_db.get_book(self.bookid)
|
cur_book = worker_db.get_book(self.book_id)
|
||||||
self.title = cur_book.title
|
self.title = cur_book.title
|
||||||
data = worker_db.get_book_format(self.bookid, self.settings['old_book_format'])
|
data = worker_db.get_book_format(self.book_id, self.settings['old_book_format'])
|
||||||
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
|
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
|
||||||
data.name + "." + self.settings['old_book_format'].lower())
|
data.name + "." + self.settings['old_book_format'].lower())
|
||||||
if df:
|
if df:
|
||||||
|
@ -89,7 +89,7 @@ class TaskConvert(CalibreTask):
|
||||||
# if we're sending to kindle after converting, create a one-off task and run it immediately
|
# if we're sending to kindle after converting, create a one-off task and run it immediately
|
||||||
# todo: figure out how to incorporate this into the progress
|
# todo: figure out how to incorporate this into the progress
|
||||||
try:
|
try:
|
||||||
EmailText = _(u"%(book)s send to Kindle", book=escape(self.title))
|
EmailText = N_(u"%(book)s send to Kindle", book=escape(self.title))
|
||||||
worker_thread.add(self.user, TaskEmail(self.settings['subject'],
|
worker_thread.add(self.user, TaskEmail(self.settings['subject'],
|
||||||
self.results["path"],
|
self.results["path"],
|
||||||
filename,
|
filename,
|
||||||
|
@ -104,9 +104,9 @@ class TaskConvert(CalibreTask):
|
||||||
|
|
||||||
def _convert_ebook_format(self):
|
def _convert_ebook_format(self):
|
||||||
error_message = None
|
error_message = None
|
||||||
local_db = db.CalibreDB(expire_on_commit=False)
|
local_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
file_path = self.file_path
|
file_path = self.file_path
|
||||||
book_id = self.bookid
|
book_id = self.book_id
|
||||||
format_old_ext = u'.' + self.settings['old_book_format'].lower()
|
format_old_ext = u'.' + self.settings['old_book_format'].lower()
|
||||||
format_new_ext = u'.' + self.settings['new_book_format'].lower()
|
format_new_ext = u'.' + self.settings['new_book_format'].lower()
|
||||||
|
|
||||||
|
@ -114,7 +114,7 @@ class TaskConvert(CalibreTask):
|
||||||
# if it does - mark the conversion task as complete and return a success
|
# if it does - mark the conversion task as complete and return a success
|
||||||
# this will allow send to kindle workflow to continue to work
|
# this will allow send to kindle workflow to continue to work
|
||||||
if os.path.isfile(file_path + format_new_ext) or\
|
if os.path.isfile(file_path + format_new_ext) or\
|
||||||
local_db.get_book_format(self.bookid, self.settings['new_book_format']):
|
local_db.get_book_format(self.book_id, self.settings['new_book_format']):
|
||||||
log.info("Book id %d already converted to %s", book_id, format_new_ext)
|
log.info("Book id %d already converted to %s", book_id, format_new_ext)
|
||||||
cur_book = local_db.get_book(book_id)
|
cur_book = local_db.get_book(book_id)
|
||||||
self.title = cur_book.title
|
self.title = cur_book.title
|
||||||
|
@ -133,7 +133,7 @@ class TaskConvert(CalibreTask):
|
||||||
local_db.session.rollback()
|
local_db.session.rollback()
|
||||||
log.error("Database error: %s", e)
|
log.error("Database error: %s", e)
|
||||||
local_db.session.close()
|
local_db.session.close()
|
||||||
self._handleError(error_message)
|
self._handleError(N_("Database error: %(error)s.", error=e))
|
||||||
return
|
return
|
||||||
self._handleSuccess()
|
self._handleSuccess()
|
||||||
local_db.session.close()
|
local_db.session.close()
|
||||||
|
@ -150,8 +150,7 @@ class TaskConvert(CalibreTask):
|
||||||
else:
|
else:
|
||||||
# check if calibre converter-executable is existing
|
# check if calibre converter-executable is existing
|
||||||
if not os.path.exists(config.config_converterpath):
|
if not os.path.exists(config.config_converterpath):
|
||||||
# ToDo Text is not translated
|
self._handleError(N_(u"Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
||||||
self._handleError(_(u"Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
|
||||||
return
|
return
|
||||||
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext)
|
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext)
|
||||||
|
|
||||||
|
@ -184,11 +183,11 @@ class TaskConvert(CalibreTask):
|
||||||
self._handleSuccess()
|
self._handleSuccess()
|
||||||
return os.path.basename(file_path + format_new_ext)
|
return os.path.basename(file_path + format_new_ext)
|
||||||
else:
|
else:
|
||||||
error_message = _('%(format)s format not found on disk', format=format_new_ext.upper())
|
error_message = N_('%(format)s format not found on disk', format=format_new_ext.upper())
|
||||||
local_db.session.close()
|
local_db.session.close()
|
||||||
log.info("ebook converter failed with error while converting book")
|
log.info("ebook converter failed with error while converting book")
|
||||||
if not error_message:
|
if not error_message:
|
||||||
error_message = _('Ebook converter failed with unknown error')
|
error_message = N_('Ebook converter failed with unknown error')
|
||||||
self._handleError(error_message)
|
self._handleError(error_message)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -198,7 +197,7 @@ class TaskConvert(CalibreTask):
|
||||||
try:
|
try:
|
||||||
p = process_open(command, quotes)
|
p = process_open(command, quotes)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
return 1, _(u"Kepubify-converter failed: %(error)s", error=e)
|
return 1, N_(u"Kepubify-converter failed: %(error)s", error=e)
|
||||||
self.progress = 0.01
|
self.progress = 0.01
|
||||||
while True:
|
while True:
|
||||||
nextline = p.stdout.readlines()
|
nextline = p.stdout.readlines()
|
||||||
|
@ -219,7 +218,7 @@ class TaskConvert(CalibreTask):
|
||||||
copyfile(converted_file[0], (file_path + format_new_ext))
|
copyfile(converted_file[0], (file_path + format_new_ext))
|
||||||
os.unlink(converted_file[0])
|
os.unlink(converted_file[0])
|
||||||
else:
|
else:
|
||||||
return 1, _(u"Converted file not found or more than one file in folder %(folder)s",
|
return 1, N_(u"Converted file not found or more than one file in folder %(folder)s",
|
||||||
folder=os.path.dirname(file_path))
|
folder=os.path.dirname(file_path))
|
||||||
return check, None
|
return check, None
|
||||||
|
|
||||||
|
@ -243,7 +242,7 @@ class TaskConvert(CalibreTask):
|
||||||
|
|
||||||
p = process_open(command, quotes, newlines=False)
|
p = process_open(command, quotes, newlines=False)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
return 1, _(u"Ebook-converter failed: %(error)s", error=e)
|
return 1, N_(u"Ebook-converter failed: %(error)s", error=e)
|
||||||
|
|
||||||
while p.poll() is None:
|
while p.poll() is None:
|
||||||
nextline = p.stdout.readline()
|
nextline = p.stdout.readline()
|
||||||
|
@ -266,12 +265,16 @@ class TaskConvert(CalibreTask):
|
||||||
ele = ele.decode('utf-8', errors="ignore").strip('\n')
|
ele = ele.decode('utf-8', errors="ignore").strip('\n')
|
||||||
log.debug(ele)
|
log.debug(ele)
|
||||||
if not ele.startswith('Traceback') and not ele.startswith(' File'):
|
if not ele.startswith('Traceback') and not ele.startswith(' File'):
|
||||||
error_message = _("Calibre failed with error: %(error)s", error=ele)
|
error_message = N_("Calibre failed with error: %(error)s", error=ele)
|
||||||
return check, error_message
|
return check, error_message
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return "Convert"
|
return N_("Convert")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Convert {} {}".format(self.bookid, self.kindle_mail)
|
return "Convert {} {}".format(self.book_id, self.kindle_mail)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellable(self):
|
||||||
|
return False
|
||||||
|
|
51
cps/tasks/database.py
Normal file
51
cps/tasks/database.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2020 mmonkey
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
|
from cps import config, logger
|
||||||
|
from cps.services.worker import CalibreTask
|
||||||
|
|
||||||
|
|
||||||
|
class TaskReconnectDatabase(CalibreTask):
|
||||||
|
def __init__(self, task_message=N_('Reconnecting Calibre database')):
|
||||||
|
super(TaskReconnectDatabase, self).__init__(task_message)
|
||||||
|
self.log = logger.create()
|
||||||
|
self.listen_address = config.get_config_ipaddress()
|
||||||
|
self.listen_port = config.config_port
|
||||||
|
|
||||||
|
|
||||||
|
def run(self, worker_thread):
|
||||||
|
address = self.listen_address if self.listen_address else 'localhost'
|
||||||
|
port = self.listen_port if self.listen_port else 8083
|
||||||
|
|
||||||
|
try:
|
||||||
|
urlopen('http://' + address + ':' + str(port) + '/reconnect')
|
||||||
|
self._handleSuccess()
|
||||||
|
except Exception as ex:
|
||||||
|
self._handleError('Unable to reconnect Calibre database: ' + str(ex))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return "Reconnect Database"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellable(self):
|
||||||
|
return False
|
|
@ -24,12 +24,10 @@ import mimetypes
|
||||||
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import parseaddr
|
from email.utils import formatdate, parseaddr
|
||||||
|
|
||||||
|
|
||||||
from email import encoders
|
|
||||||
from email.utils import formatdate, make_msgid
|
|
||||||
from email.generator import Generator
|
from email.generator import Generator
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
from email.utils import formatdate
|
||||||
|
|
||||||
from cps.services.worker import CalibreTask
|
from cps.services.worker import CalibreTask
|
||||||
from cps.services import gmail
|
from cps.services import gmail
|
||||||
|
@ -111,13 +109,13 @@ class EmailSSL(EmailBase, smtplib.SMTP_SSL):
|
||||||
|
|
||||||
|
|
||||||
class TaskEmail(CalibreTask):
|
class TaskEmail(CalibreTask):
|
||||||
def __init__(self, subject, filepath, attachment, settings, recipient, taskMessage, text, internal=False):
|
def __init__(self, subject, filepath, attachment, settings, recipient, task_message, text, internal=False):
|
||||||
super(TaskEmail, self).__init__(taskMessage)
|
super(TaskEmail, self).__init__(task_message)
|
||||||
self.subject = subject
|
self.subject = subject
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.filepath = filepath
|
self.filepath = filepath
|
||||||
self.recipent = recipient
|
self.recipient = recipient
|
||||||
self.text = text
|
self.text = text
|
||||||
self.asyncSMTP = None
|
self.asyncSMTP = None
|
||||||
self.results = dict()
|
self.results = dict()
|
||||||
|
@ -139,7 +137,7 @@ class TaskEmail(CalibreTask):
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
# message = MIMEMultipart()
|
# message = MIMEMultipart()
|
||||||
message['From'] = self.settings["mail_from"]
|
message['From'] = self.settings["mail_from"]
|
||||||
message['To'] = self.recipent
|
message['To'] = self.recipient
|
||||||
message['Subject'] = self.subject
|
message['Subject'] = self.subject
|
||||||
message['Date'] = formatdate(localtime=True)
|
message['Date'] = formatdate(localtime=True)
|
||||||
message['Message-Id'] = "{}@{}".format(uuid.uuid4(), self.get_msgid_domain()) # f"<{uuid.uuid4()}@{get_msgid_domain(from_)}>" # make_msgid('calibre-web')
|
message['Message-Id'] = "{}@{}".format(uuid.uuid4(), self.get_msgid_domain()) # f"<{uuid.uuid4()}@{get_msgid_domain(from_)}>" # make_msgid('calibre-web')
|
||||||
|
@ -212,7 +210,7 @@ class TaskEmail(CalibreTask):
|
||||||
gen = Generator(fp, mangle_from_=False)
|
gen = Generator(fp, mangle_from_=False)
|
||||||
gen.flatten(msg)
|
gen.flatten(msg)
|
||||||
|
|
||||||
self.asyncSMTP.sendmail(self.settings["mail_from"], self.recipent, fp.getvalue())
|
self.asyncSMTP.sendmail(self.settings["mail_from"], self.recipient, fp.getvalue())
|
||||||
self.asyncSMTP.quit()
|
self.asyncSMTP.quit()
|
||||||
self._handleSuccess()
|
self._handleSuccess()
|
||||||
log.debug("E-mail send successfully")
|
log.debug("E-mail send successfully")
|
||||||
|
@ -264,7 +262,11 @@ class TaskEmail(CalibreTask):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return "E-mail"
|
return N_("E-mail")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellable(self):
|
||||||
|
return False
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "E-mail {}, {}".format(self.name, self.subject)
|
return "E-mail {}, {}".format(self.name, self.subject)
|
||||||
|
|
514
cps/tasks/thumbnail.py
Normal file
514
cps/tasks/thumbnail.py
Normal file
|
@ -0,0 +1,514 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2020 monkey
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import os
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
from .. import constants
|
||||||
|
from cps import config, db, fs, gdriveutils, logger, ub
|
||||||
|
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import func, text, or_
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
|
try:
|
||||||
|
from wand.image import Image
|
||||||
|
use_IM = True
|
||||||
|
except (ImportError, RuntimeError) as e:
|
||||||
|
use_IM = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_resize_height(resolution):
|
||||||
|
return int(225 * resolution)
|
||||||
|
|
||||||
|
|
||||||
|
def get_resize_width(resolution, original_width, original_height):
|
||||||
|
height = get_resize_height(resolution)
|
||||||
|
percent = (height / float(original_height))
|
||||||
|
width = int((float(original_width) * float(percent)))
|
||||||
|
return width if width % 2 == 0 else width + 1
|
||||||
|
|
||||||
|
|
||||||
|
def get_best_fit(width, height, image_width, image_height):
|
||||||
|
resize_width = int(width / 2.0)
|
||||||
|
resize_height = int(height / 2.0)
|
||||||
|
aspect_ratio = image_width / image_height
|
||||||
|
|
||||||
|
# If this image's aspect ratio is different from the first image, then resize this image
|
||||||
|
# to fill the width and height of the first image
|
||||||
|
if aspect_ratio < width / height:
|
||||||
|
resize_width = int(width / 2.0)
|
||||||
|
resize_height = image_height * int(width / 2.0) / image_width
|
||||||
|
|
||||||
|
elif aspect_ratio > width / height:
|
||||||
|
resize_width = image_width * int(height / 2.0) / image_height
|
||||||
|
resize_height = int(height / 2.0)
|
||||||
|
|
||||||
|
return {'width': resize_width, 'height': resize_height}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskGenerateCoverThumbnails(CalibreTask):
|
||||||
|
def __init__(self, book_id=-1, task_message=''):
|
||||||
|
super(TaskGenerateCoverThumbnails, self).__init__(task_message)
|
||||||
|
self.log = logger.create()
|
||||||
|
self.book_id = book_id
|
||||||
|
self.app_db_session = ub.get_new_session_instance()
|
||||||
|
# self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
|
self.cache = fs.FileSystem()
|
||||||
|
self.resolutions = [
|
||||||
|
constants.COVER_THUMBNAIL_SMALL,
|
||||||
|
constants.COVER_THUMBNAIL_MEDIUM
|
||||||
|
]
|
||||||
|
|
||||||
|
def run(self, worker_thread):
|
||||||
|
if use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
||||||
|
self.message = 'Scanning Books'
|
||||||
|
books_with_covers = self.get_books_with_covers(self.book_id)
|
||||||
|
count = len(books_with_covers)
|
||||||
|
|
||||||
|
total_generated = 0
|
||||||
|
for i, book in enumerate(books_with_covers):
|
||||||
|
|
||||||
|
# Generate new thumbnails for missing covers
|
||||||
|
generated = self.create_book_cover_thumbnails(book)
|
||||||
|
|
||||||
|
# Increment the progress
|
||||||
|
self.progress = (1.0 / count) * i
|
||||||
|
|
||||||
|
if generated > 0:
|
||||||
|
total_generated += generated
|
||||||
|
self.message = N_(u'Generated %(count)s cover thumbnails', count=total_generated)
|
||||||
|
|
||||||
|
# Check if job has been cancelled or ended
|
||||||
|
if self.stat == STAT_CANCELLED:
|
||||||
|
self.log.info(f'GenerateCoverThumbnails task has been cancelled.')
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.stat == STAT_ENDED:
|
||||||
|
self.log.info(f'GenerateCoverThumbnails task has been ended.')
|
||||||
|
return
|
||||||
|
|
||||||
|
if total_generated == 0:
|
||||||
|
self.self_cleanup = True
|
||||||
|
|
||||||
|
self._handleSuccess()
|
||||||
|
self.app_db_session.remove()
|
||||||
|
|
||||||
|
def get_books_with_covers(self, book_id=-1):
|
||||||
|
filter_exp = (db.Books.id == book_id) if book_id != -1 else True
|
||||||
|
calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
|
books_cover = calibre_db.session.query(db.Books).filter(db.Books.has_cover == 1).filter(filter_exp).all()
|
||||||
|
calibre_db.session.close()
|
||||||
|
return books_cover
|
||||||
|
|
||||||
|
def get_book_cover_thumbnails(self, book_id):
|
||||||
|
return self.app_db_session \
|
||||||
|
.query(ub.Thumbnail) \
|
||||||
|
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
|
||||||
|
.filter(ub.Thumbnail.entity_id == book_id) \
|
||||||
|
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def create_book_cover_thumbnails(self, book):
|
||||||
|
generated = 0
|
||||||
|
book_cover_thumbnails = self.get_book_cover_thumbnails(book.id)
|
||||||
|
|
||||||
|
# Generate new thumbnails for missing covers
|
||||||
|
resolutions = list(map(lambda t: t.resolution, book_cover_thumbnails))
|
||||||
|
missing_resolutions = list(set(self.resolutions).difference(resolutions))
|
||||||
|
for resolution in missing_resolutions:
|
||||||
|
generated += 1
|
||||||
|
self.create_book_cover_single_thumbnail(book, resolution)
|
||||||
|
|
||||||
|
# Replace outdated or missing thumbnails
|
||||||
|
for thumbnail in book_cover_thumbnails:
|
||||||
|
if book.last_modified > thumbnail.generated_at:
|
||||||
|
generated += 1
|
||||||
|
self.update_book_cover_thumbnail(book, thumbnail)
|
||||||
|
|
||||||
|
elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
|
||||||
|
generated += 1
|
||||||
|
self.update_book_cover_thumbnail(book, thumbnail)
|
||||||
|
return generated
|
||||||
|
|
||||||
|
def create_book_cover_single_thumbnail(self, book, resolution):
|
||||||
|
thumbnail = ub.Thumbnail()
|
||||||
|
thumbnail.type = constants.THUMBNAIL_TYPE_COVER
|
||||||
|
thumbnail.entity_id = book.id
|
||||||
|
thumbnail.format = 'jpeg'
|
||||||
|
thumbnail.resolution = resolution
|
||||||
|
|
||||||
|
self.app_db_session.add(thumbnail)
|
||||||
|
try:
|
||||||
|
self.app_db_session.commit()
|
||||||
|
self.generate_book_thumbnail(book, thumbnail)
|
||||||
|
except Exception as ex:
|
||||||
|
self.log.debug('Error creating book thumbnail: ' + str(ex))
|
||||||
|
self._handleError('Error creating book thumbnail: ' + str(ex))
|
||||||
|
self.app_db_session.rollback()
|
||||||
|
|
||||||
|
def update_book_cover_thumbnail(self, book, thumbnail):
|
||||||
|
thumbnail.generated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.app_db_session.commit()
|
||||||
|
self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||||
|
self.generate_book_thumbnail(book, thumbnail)
|
||||||
|
except Exception as ex:
|
||||||
|
self.log.debug('Error updating book thumbnail: ' + str(ex))
|
||||||
|
self._handleError('Error updating book thumbnail: ' + str(ex))
|
||||||
|
self.app_db_session.rollback()
|
||||||
|
|
||||||
|
def generate_book_thumbnail(self, book, thumbnail):
|
||||||
|
if book and thumbnail:
|
||||||
|
if config.config_use_google_drive:
|
||||||
|
if not gdriveutils.is_gdrive_ready():
|
||||||
|
raise Exception('Google Drive is configured but not ready')
|
||||||
|
|
||||||
|
web_content_link = gdriveutils.get_cover_via_gdrive(book.path)
|
||||||
|
if not web_content_link:
|
||||||
|
raise Exception('Google Drive cover url not found')
|
||||||
|
|
||||||
|
stream = None
|
||||||
|
try:
|
||||||
|
stream = urlopen(web_content_link)
|
||||||
|
with Image(file=stream) as img:
|
||||||
|
height = get_resize_height(thumbnail.resolution)
|
||||||
|
if img.height > height:
|
||||||
|
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||||
|
img.resize(width=width, height=height, filter='lanczos')
|
||||||
|
img.format = thumbnail.format
|
||||||
|
filename = self.cache.get_cache_file_path(thumbnail.filename,
|
||||||
|
constants.CACHE_TYPE_THUMBNAILS)
|
||||||
|
img.save(filename=filename)
|
||||||
|
except Exception as ex:
|
||||||
|
# Bubble exception to calling function
|
||||||
|
self.log.debug('Error generating thumbnail file: ' + str(ex))
|
||||||
|
raise ex
|
||||||
|
finally:
|
||||||
|
if stream is not None:
|
||||||
|
stream.close()
|
||||||
|
else:
|
||||||
|
book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg')
|
||||||
|
if not os.path.isfile(book_cover_filepath):
|
||||||
|
raise Exception('Book cover file not found')
|
||||||
|
|
||||||
|
with Image(filename=book_cover_filepath) as img:
|
||||||
|
height = get_resize_height(thumbnail.resolution)
|
||||||
|
if img.height > height:
|
||||||
|
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||||
|
img.resize(width=width, height=height, filter='lanczos')
|
||||||
|
img.format = thumbnail.format
|
||||||
|
filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||||
|
img.save(filename=filename)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return N_('Cover Thumbnails')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.book_id > 0:
|
||||||
|
return "Add Cover Thumbnails for Book {}".format(self.book_id)
|
||||||
|
else:
|
||||||
|
return "Generate Cover Thumbnails"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellable(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TaskGenerateSeriesThumbnails(CalibreTask):
|
||||||
|
def __init__(self, task_message=''):
|
||||||
|
super(TaskGenerateSeriesThumbnails, self).__init__(task_message)
|
||||||
|
self.log = logger.create()
|
||||||
|
self.app_db_session = ub.get_new_session_instance()
|
||||||
|
self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
|
self.cache = fs.FileSystem()
|
||||||
|
self.resolutions = [
|
||||||
|
constants.COVER_THUMBNAIL_SMALL,
|
||||||
|
constants.COVER_THUMBNAIL_MEDIUM,
|
||||||
|
]
|
||||||
|
|
||||||
|
def run(self, worker_thread):
|
||||||
|
if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
||||||
|
self.message = 'Scanning Series'
|
||||||
|
all_series = self.get_series_with_four_plus_books()
|
||||||
|
count = len(all_series)
|
||||||
|
|
||||||
|
total_generated = 0
|
||||||
|
for i, series in enumerate(all_series):
|
||||||
|
generated = 0
|
||||||
|
series_thumbnails = self.get_series_thumbnails(series.id)
|
||||||
|
series_books = self.get_series_books(series.id)
|
||||||
|
|
||||||
|
# Generate new thumbnails for missing covers
|
||||||
|
resolutions = list(map(lambda t: t.resolution, series_thumbnails))
|
||||||
|
missing_resolutions = list(set(self.resolutions).difference(resolutions))
|
||||||
|
for resolution in missing_resolutions:
|
||||||
|
generated += 1
|
||||||
|
self.create_series_thumbnail(series, series_books, resolution)
|
||||||
|
|
||||||
|
# Replace outdated or missing thumbnails
|
||||||
|
for thumbnail in series_thumbnails:
|
||||||
|
if any(book.last_modified > thumbnail.generated_at for book in series_books):
|
||||||
|
generated += 1
|
||||||
|
self.update_series_thumbnail(series_books, thumbnail)
|
||||||
|
|
||||||
|
elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
|
||||||
|
generated += 1
|
||||||
|
self.update_series_thumbnail(series_books, thumbnail)
|
||||||
|
|
||||||
|
# Increment the progress
|
||||||
|
self.progress = (1.0 / count) * i
|
||||||
|
|
||||||
|
if generated > 0:
|
||||||
|
total_generated += generated
|
||||||
|
self.message = N_('Generated {0} series thumbnails').format(total_generated)
|
||||||
|
|
||||||
|
# Check if job has been cancelled or ended
|
||||||
|
if self.stat == STAT_CANCELLED:
|
||||||
|
self.log.info(f'GenerateSeriesThumbnails task has been cancelled.')
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.stat == STAT_ENDED:
|
||||||
|
self.log.info(f'GenerateSeriesThumbnails task has been ended.')
|
||||||
|
return
|
||||||
|
|
||||||
|
if total_generated == 0:
|
||||||
|
self.self_cleanup = True
|
||||||
|
|
||||||
|
self._handleSuccess()
|
||||||
|
self.app_db_session.remove()
|
||||||
|
|
||||||
|
def get_series_with_four_plus_books(self):
|
||||||
|
return self.calibre_db.session \
|
||||||
|
.query(db.Series) \
|
||||||
|
.join(db.books_series_link) \
|
||||||
|
.join(db.Books) \
|
||||||
|
.filter(db.Books.has_cover == 1) \
|
||||||
|
.group_by(text('books_series_link.series')) \
|
||||||
|
.having(func.count('book_series_link') > 3) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def get_series_books(self, series_id):
|
||||||
|
return self.calibre_db.session \
|
||||||
|
.query(db.Books) \
|
||||||
|
.join(db.books_series_link) \
|
||||||
|
.join(db.Series) \
|
||||||
|
.filter(db.Books.has_cover == 1) \
|
||||||
|
.filter(db.Series.id == series_id) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def get_series_thumbnails(self, series_id):
|
||||||
|
return self.app_db_session \
|
||||||
|
.query(ub.Thumbnail) \
|
||||||
|
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES) \
|
||||||
|
.filter(ub.Thumbnail.entity_id == series_id) \
|
||||||
|
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def create_series_thumbnail(self, series, series_books, resolution):
|
||||||
|
thumbnail = ub.Thumbnail()
|
||||||
|
thumbnail.type = constants.THUMBNAIL_TYPE_SERIES
|
||||||
|
thumbnail.entity_id = series.id
|
||||||
|
thumbnail.format = 'jpeg'
|
||||||
|
thumbnail.resolution = resolution
|
||||||
|
|
||||||
|
self.app_db_session.add(thumbnail)
|
||||||
|
try:
|
||||||
|
self.app_db_session.commit()
|
||||||
|
self.generate_series_thumbnail(series_books, thumbnail)
|
||||||
|
except Exception as ex:
|
||||||
|
self.log.debug('Error creating book thumbnail: ' + str(ex))
|
||||||
|
self._handleError('Error creating book thumbnail: ' + str(ex))
|
||||||
|
self.app_db_session.rollback()
|
||||||
|
|
||||||
|
def update_series_thumbnail(self, series_books, thumbnail):
|
||||||
|
thumbnail.generated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.app_db_session.commit()
|
||||||
|
self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||||
|
self.generate_series_thumbnail(series_books, thumbnail)
|
||||||
|
except Exception as ex:
|
||||||
|
self.log.debug('Error updating book thumbnail: ' + str(ex))
|
||||||
|
self._handleError('Error updating book thumbnail: ' + str(ex))
|
||||||
|
self.app_db_session.rollback()
|
||||||
|
|
||||||
|
def generate_series_thumbnail(self, series_books, thumbnail):
|
||||||
|
# Get the last four books in the series based on series_index
|
||||||
|
books = sorted(series_books, key=lambda b: float(b.series_index), reverse=True)[:4]
|
||||||
|
|
||||||
|
top = 0
|
||||||
|
left = 0
|
||||||
|
width = 0
|
||||||
|
height = 0
|
||||||
|
with Image() as canvas:
|
||||||
|
for book in books:
|
||||||
|
if config.config_use_google_drive:
|
||||||
|
if not gdriveutils.is_gdrive_ready():
|
||||||
|
raise Exception('Google Drive is configured but not ready')
|
||||||
|
|
||||||
|
web_content_link = gdriveutils.get_cover_via_gdrive(book.path)
|
||||||
|
if not web_content_link:
|
||||||
|
raise Exception('Google Drive cover url not found')
|
||||||
|
|
||||||
|
stream = None
|
||||||
|
try:
|
||||||
|
stream = urlopen(web_content_link)
|
||||||
|
with Image(file=stream) as img:
|
||||||
|
# Use the first image in this set to determine the width and height to scale the
|
||||||
|
# other images in this set
|
||||||
|
if width == 0 or height == 0:
|
||||||
|
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||||
|
height = get_resize_height(thumbnail.resolution)
|
||||||
|
canvas.blank(width, height)
|
||||||
|
|
||||||
|
dimensions = get_best_fit(width, height, img.width, img.height)
|
||||||
|
|
||||||
|
# resize and crop the image
|
||||||
|
img.resize(width=int(dimensions['width']), height=int(dimensions['height']),
|
||||||
|
filter='lanczos')
|
||||||
|
img.crop(width=int(width / 2.0), height=int(height / 2.0), gravity='center')
|
||||||
|
|
||||||
|
# add the image to the canvas
|
||||||
|
canvas.composite(img, left, top)
|
||||||
|
|
||||||
|
except Exception as ex:
|
||||||
|
self.log.debug('Error generating thumbnail file: ' + str(ex))
|
||||||
|
raise ex
|
||||||
|
finally:
|
||||||
|
if stream is not None:
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg')
|
||||||
|
if not os.path.isfile(book_cover_filepath):
|
||||||
|
raise Exception('Book cover file not found')
|
||||||
|
|
||||||
|
with Image(filename=book_cover_filepath) as img:
|
||||||
|
# Use the first image in this set to determine the width and height to scale the
|
||||||
|
# other images in this set
|
||||||
|
if width == 0 or height == 0:
|
||||||
|
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||||
|
height = get_resize_height(thumbnail.resolution)
|
||||||
|
canvas.blank(width, height)
|
||||||
|
|
||||||
|
dimensions = get_best_fit(width, height, img.width, img.height)
|
||||||
|
|
||||||
|
# resize and crop the image
|
||||||
|
img.resize(width=int(dimensions['width']), height=int(dimensions['height']), filter='lanczos')
|
||||||
|
img.crop(width=int(width / 2.0), height=int(height / 2.0), gravity='center')
|
||||||
|
|
||||||
|
# add the image to the canvas
|
||||||
|
canvas.composite(img, left, top)
|
||||||
|
|
||||||
|
# set the coordinates for the next iteration
|
||||||
|
if left == 0 and top == 0:
|
||||||
|
left = int(width / 2.0)
|
||||||
|
elif left == int(width / 2.0) and top == 0:
|
||||||
|
left = 0
|
||||||
|
top = int(height / 2.0)
|
||||||
|
else:
|
||||||
|
left = int(width / 2.0)
|
||||||
|
|
||||||
|
canvas.format = thumbnail.format
|
||||||
|
filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||||
|
canvas.save(filename=filename)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return N_('Cover Thumbnails')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "GenerateSeriesThumbnails"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellable(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TaskClearCoverThumbnailCache(CalibreTask):
|
||||||
|
def __init__(self, book_id, task_message=N_('Clearing cover thumbnail cache')):
|
||||||
|
super(TaskClearCoverThumbnailCache, self).__init__(task_message)
|
||||||
|
self.log = logger.create()
|
||||||
|
self.book_id = book_id
|
||||||
|
self.app_db_session = ub.get_new_session_instance()
|
||||||
|
self.cache = fs.FileSystem()
|
||||||
|
|
||||||
|
def run(self, worker_thread):
|
||||||
|
if self.app_db_session:
|
||||||
|
if self.book_id == 0: # delete superfluous thumbnails
|
||||||
|
calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
|
thumbnails = (calibre_db.session.query(ub.Thumbnail)
|
||||||
|
.join(db.Books, ub.Thumbnail.entity_id == db.Books.id, isouter=True)
|
||||||
|
.filter(db.Books.id == None)
|
||||||
|
.all())
|
||||||
|
calibre_db.session.close()
|
||||||
|
elif self.book_id > 0: # make sure single book is selected
|
||||||
|
thumbnails = self.get_thumbnails_for_book(self.book_id)
|
||||||
|
if self.book_id < 0:
|
||||||
|
self.delete_all_thumbnails()
|
||||||
|
else:
|
||||||
|
for thumbnail in thumbnails:
|
||||||
|
self.delete_thumbnail(thumbnail)
|
||||||
|
self._handleSuccess()
|
||||||
|
self.app_db_session.remove()
|
||||||
|
|
||||||
|
def get_thumbnails_for_book(self, book_id):
|
||||||
|
return self.app_db_session \
|
||||||
|
.query(ub.Thumbnail) \
|
||||||
|
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
|
||||||
|
.filter(ub.Thumbnail.entity_id == book_id) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def delete_thumbnail(self, thumbnail):
|
||||||
|
try:
|
||||||
|
self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||||
|
self.app_db_session \
|
||||||
|
.query(ub.Thumbnail) \
|
||||||
|
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
|
||||||
|
.filter(ub.Thumbnail.entity_id == thumbnail.entity_id) \
|
||||||
|
.delete()
|
||||||
|
self.app_db_session.commit()
|
||||||
|
except Exception as ex:
|
||||||
|
self.log.debug('Error deleting book thumbnail: ' + str(ex))
|
||||||
|
self._handleError('Error deleting book thumbnail: ' + str(ex))
|
||||||
|
|
||||||
|
def delete_all_thumbnails(self):
|
||||||
|
try:
|
||||||
|
self.app_db_session.query(ub.Thumbnail).filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER).delete()
|
||||||
|
self.app_db_session.commit()
|
||||||
|
self.cache.delete_cache_dir(constants.CACHE_TYPE_THUMBNAILS)
|
||||||
|
except Exception as ex:
|
||||||
|
self.log.debug('Error deleting thumbnail directory: ' + str(ex))
|
||||||
|
self._handleError('Error deleting thumbnail directory: ' + str(ex))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return N_('Cover Thumbnails')
|
||||||
|
|
||||||
|
# needed for logging
|
||||||
|
def __str__(self):
|
||||||
|
if self.book_id > 0:
|
||||||
|
return "Replace/Delete Cover Thumbnails for book " + str(self.book_id)
|
||||||
|
else:
|
||||||
|
return "Delete Thumbnail cache directory"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellable(self):
|
||||||
|
return False
|
|
@ -17,11 +17,14 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from cps.services.worker import CalibreTask, STAT_FINISH_SUCCESS
|
from cps.services.worker import CalibreTask, STAT_FINISH_SUCCESS
|
||||||
|
|
||||||
class TaskUpload(CalibreTask):
|
class TaskUpload(CalibreTask):
|
||||||
def __init__(self, taskMessage, book_title):
|
def __init__(self, task_message, book_title):
|
||||||
super(TaskUpload, self).__init__(taskMessage)
|
super(TaskUpload, self).__init__(task_message)
|
||||||
self.start_time = self.end_time = datetime.now()
|
self.start_time = self.end_time = datetime.now()
|
||||||
self.stat = STAT_FINISH_SUCCESS
|
self.stat = STAT_FINISH_SUCCESS
|
||||||
self.progress = 1
|
self.progress = 1
|
||||||
|
@ -32,7 +35,11 @@ class TaskUpload(CalibreTask):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return "Upload"
|
return N_("Upload")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Upload {}".format(self.book_title)
|
return "Upload {}".format(self.book_title)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellable(self):
|
||||||
|
return False
|
||||||
|
|
106
cps/tasks_status.py
Normal file
106
cps/tasks_status.py
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2022 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from markupsafe import escape
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from flask_babel import format_datetime
|
||||||
|
from babel.units import format_unit
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
from .render_template import render_title_template
|
||||||
|
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \
|
||||||
|
STAT_CANCELLED
|
||||||
|
|
||||||
|
tasks = Blueprint('tasks', __name__)
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
@tasks.route("/ajax/emailstat")
|
||||||
|
@login_required
|
||||||
|
def get_email_status_json():
|
||||||
|
tasks = WorkerThread.get_instance().tasks
|
||||||
|
return jsonify(render_task_status(tasks))
|
||||||
|
|
||||||
|
|
||||||
|
@tasks.route("/tasks")
|
||||||
|
@login_required
|
||||||
|
def get_tasks_status():
|
||||||
|
# if current user admin, show all email, otherwise only own emails
|
||||||
|
tasks = WorkerThread.get_instance().tasks
|
||||||
|
answer = render_task_status(tasks)
|
||||||
|
return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks")
|
||||||
|
|
||||||
|
|
||||||
|
# helper function to apply localize status information in tasklist entries
|
||||||
|
def render_task_status(tasklist):
|
||||||
|
rendered_tasklist = list()
|
||||||
|
for __, user, __, task, __ in tasklist:
|
||||||
|
if user == current_user.name or current_user.role_admin():
|
||||||
|
ret = {}
|
||||||
|
if task.start_time:
|
||||||
|
ret['starttime'] = format_datetime(task.start_time, format='short')
|
||||||
|
ret['runtime'] = format_runtime(task.runtime)
|
||||||
|
|
||||||
|
# localize the task status
|
||||||
|
if isinstance(task.stat, int):
|
||||||
|
if task.stat == STAT_WAITING:
|
||||||
|
ret['status'] = _(u'Waiting')
|
||||||
|
elif task.stat == STAT_FAIL:
|
||||||
|
ret['status'] = _(u'Failed')
|
||||||
|
elif task.stat == STAT_STARTED:
|
||||||
|
ret['status'] = _(u'Started')
|
||||||
|
elif task.stat == STAT_FINISH_SUCCESS:
|
||||||
|
ret['status'] = _(u'Finished')
|
||||||
|
elif task.stat == STAT_ENDED:
|
||||||
|
ret['status'] = _(u'Ended')
|
||||||
|
elif task.stat == STAT_CANCELLED:
|
||||||
|
ret['status'] = _(u'Cancelled')
|
||||||
|
else:
|
||||||
|
ret['status'] = _(u'Unknown Status')
|
||||||
|
|
||||||
|
ret['taskMessage'] = "{}: {}".format(task.name, task.message) if task.message else task.name
|
||||||
|
ret['progress'] = "{} %".format(int(task.progress * 100))
|
||||||
|
ret['user'] = escape(user) # prevent xss
|
||||||
|
|
||||||
|
# Hidden fields
|
||||||
|
ret['task_id'] = task.id
|
||||||
|
ret['stat'] = task.stat
|
||||||
|
ret['is_cancellable'] = task.is_cancellable
|
||||||
|
|
||||||
|
rendered_tasklist.append(ret)
|
||||||
|
|
||||||
|
return rendered_tasklist
|
||||||
|
|
||||||
|
|
||||||
|
# helper function for displaying the runtime of tasks
|
||||||
|
def format_runtime(runtime):
|
||||||
|
ret_val = ""
|
||||||
|
if runtime.days:
|
||||||
|
ret_val = format_unit(runtime.days, 'duration-day', length="long") + ', '
|
||||||
|
minutes, seconds = divmod(runtime.seconds, 60)
|
||||||
|
hours, minutes = divmod(minutes, 60)
|
||||||
|
# ToDo: locale.number_symbols._data['timeSeparator'] -> localize time separator ?
|
||||||
|
if hours:
|
||||||
|
ret_val += '{:d}:{:02d}:{:02d}s'.format(hours, minutes, seconds)
|
||||||
|
elif minutes:
|
||||||
|
ret_val += '{:2d}:{:02d}s'.format(minutes, seconds)
|
||||||
|
else:
|
||||||
|
ret_val += '{:2d}s'.format(seconds)
|
||||||
|
return ret_val
|
|
@ -161,7 +161,40 @@
|
||||||
<a class="btn btn-default" id="view_config" href="{{url_for('admin.view_configuration')}}">{{_('Edit UI Configuration')}}</a>
|
<a class="btn btn-default" id="view_config" href="{{url_for('admin.view_configuration')}}">{{_('Edit UI Configuration')}}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if feature_support['scheduler'] %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h2>{{_('Scheduled Tasks')}}</h2>
|
||||||
|
<div class="col-xs-12 col-sm-12 scheduled_tasks_details">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-6 col-sm-3">{{_('Time at which tasks start to run')}}</div>
|
||||||
|
<div class="col-xs-6 col-sm-3">{{schedule_time}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-6 col-sm-3">{{_('Maximum tasks duration')}}</div>
|
||||||
|
<div class="col-xs-6 col-sm-3">{{schedule_duration}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-6 col-sm-3">{{_('Generate book cover thumbnails')}}</div>
|
||||||
|
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_book_covers) }}</div>
|
||||||
|
</div>
|
||||||
|
<!--div class="row">
|
||||||
|
<div class="col-xs-6 col-sm-3">{{_('Generate series cover thumbnails')}}</div>
|
||||||
|
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_series_covers) }}</div>
|
||||||
|
</div-->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-6 col-sm-3">{{_('Reconnect to Calibre Library')}}</div>
|
||||||
|
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_reconnect) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-default scheduledtasks" id="admin_edit_scheduled_tasks" href="{{url_for('admin.edit_scheduledtasks')}}">{{_('Edit Scheduled Tasks Settings')}}</a>
|
||||||
|
{% if config.schedule_generate_book_covers %}
|
||||||
|
<a class="btn btn-default" id="admin_refresh_cover_cache">{{_('Refresh Thumbnail Cover Cache')}}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="row form-group">
|
<div class="row form-group">
|
||||||
<h2>{{_('Administration')}}</h2>
|
<h2>{{_('Administration')}}</h2>
|
||||||
<a class="btn btn-default" id="debug" href="{{url_for('admin.download_debug')}}">{{_('Download Debug Package')}}</a>
|
<a class="btn btn-default" id="debug" href="{{url_for('admin.download_debug')}}">{{_('Download Debug Package')}}</a>
|
||||||
|
@ -169,13 +202,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="row form-group">
|
<div class="row form-group">
|
||||||
<div class="btn btn-default" id="restart_database" data-toggle="modal" data-target="#StatusDialog">{{_('Reconnect Calibre Database')}}</div>
|
<div class="btn btn-default" id="restart_database" data-toggle="modal" data-target="#StatusDialog">{{_('Reconnect Calibre Database')}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row form-group">
|
||||||
<div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div>
|
<div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div>
|
||||||
<div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div>
|
<div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2>{{_('Update')}}</h2>
|
<h2>{{_('Version Information')}}</h2>
|
||||||
<table class="table table-striped" id="update_table">
|
<table class="table table-striped" id="update_table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -252,3 +287,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block modal %}
|
||||||
|
{{ change_confirm_modal() }}
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<span class="img" title="{{entry.Books.title}}">
|
<span class="img" title="{{entry.Books.title}}">
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.Books.id) }}" />
|
{{ image.book_cover(entry.Books, alt=author.name|safe) }}
|
||||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
{% if book %}
|
{% if book %}
|
||||||
<div class="col-sm-3 col-lg-3 col-xs-12">
|
<div class="col-sm-3 col-lg-3 col-xs-12">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<img id="detailcover" title="{{book.title}}" src="{{ url_for('web.get_cover', book_id=book.id, edit=1|uuidfilter) }}" alt="{{ book.title }}"/>
|
<!-- Always use full-sized image for the book edit page -->
|
||||||
|
<img id="detailcover" title="{{book.title}}" src="{{url_for('web.get_cover', book_id=book.id, resolution='og', c=book|last_modified)}}" />
|
||||||
</div>
|
</div>
|
||||||
{% if g.user.role_delete_books() %}
|
{% if g.user.role_delete_books() %}
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3 col-lg-3 col-xs-5">
|
<div class="col-sm-3 col-lg-3 col-xs-5">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<img id="detailcover" title="{{entry.title}}" src="{{ url_for('web.get_cover', book_id=entry.id, edit=1|uuidfilter) }}" alt="{{ entry.title }}" />
|
<!-- Always use full-sized image for the detail page -->
|
||||||
|
<img id="detailcover" title="{{entry.title}}" src="{{url_for('web.get_cover', book_id=entry.id, resolution='og', c=entry|last_modified)}}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-9 col-lg-9 book-meta">
|
<div class="col-sm-9 col-lg-9 book-meta">
|
||||||
|
@ -70,9 +71,9 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.audioentries|length > 0 and g.user.role_viewer() %}
|
{% if entry.audio_entries|length > 0 and g.user.role_viewer() %}
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
{% if entry.audioentries|length > 1 %}
|
{% if entry.audio_entries|length > 1 %}
|
||||||
<button id="listen-in-browser" type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button id="listen-in-browser" type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
<span class="glyphicon glyphicon-music"></span> {{_('Listen in Browser')}}
|
<span class="glyphicon glyphicon-music"></span> {{_('Listen in Browser')}}
|
||||||
<span class="caret"></span>
|
<span class="caret"></span>
|
||||||
|
@ -85,13 +86,13 @@
|
||||||
<ul class="dropdown-menu" aria-labelledby="listen-in-browser">
|
<ul class="dropdown-menu" aria-labelledby="listen-in-browser">
|
||||||
|
|
||||||
{% for format in entry.data %}
|
{% for format in entry.data %}
|
||||||
{% if format.format|lower in entry.audioentries %}
|
{% if format.format|lower in entry.audio_entries %}
|
||||||
<li><a target="_blank" href="{{ url_for('web.read_book', book_id=entry.id, book_format=format.format|lower) }}">{{format.format|lower }}</a></li>
|
<li><a target="_blank" href="{{ url_for('web.read_book', book_id=entry.id, book_format=format.format|lower) }}">{{format.format|lower }}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a target="_blank" href="{{url_for('web.read_book', book_id=entry.id, book_format=entry.audioentries[0])}}" id="listenbtn" class="btn btn-primary" role="button"><span class="glyphicon glyphicon-music"></span> {{_('Listen in Browser')}} - {{entry.audioentries[0]}}</a>
|
<a target="_blank" href="{{url_for('web.read_book', book_id=entry.id, book_format=entry.audio_entries[0])}}" id="listenbtn" class="btn btn-primary" role="button"><span class="glyphicon glyphicon-music"></span> {{_('Listen in Browser')}} - {{entry.audio_entries[0]}}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
{% import 'image.html' as image %}
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
{% import 'image.html' as image %}
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1 class="{{page}}">{{_(title)}}</h1>
|
<h1 class="{{page}}">{{_(title)}}</h1>
|
||||||
|
@ -27,7 +28,7 @@
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<a href="{{url_for('web.books_list', data=data, sort_param='stored', book_id=entry[0].series[0].id )}}">
|
<a href="{{url_for('web.books_list', data=data, sort_param='stored', book_id=entry[0].series[0].id )}}">
|
||||||
<span class="img" title="{{entry[0].series[0].name}}">
|
<span class="img" title="{{entry[0].series[0].name}}">
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry[3]) }}" alt="{{ entry[0].series[0].name }}"/>
|
{{ image.series(entry[0].series[0], alt=entry[0].series[0].name|shortentitle) }}
|
||||||
<span class="badge">{{entry.count}}</span>
|
<span class="badge">{{entry.count}}</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
20
cps/templates/image.html
Normal file
20
cps/templates/image.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{% macro book_cover(book, alt=None) -%}
|
||||||
|
{%- set image_title = book.title if book.title else book.name -%}
|
||||||
|
{%- set image_alt = alt if alt else image_title -%}
|
||||||
|
{% set srcset = book|get_cover_srcset %}
|
||||||
|
<img
|
||||||
|
srcset="{{ srcset }}"
|
||||||
|
src="{{ url_for('web.get_cover', book_id=book.id, resolution='og', c=book|last_modified) }}"
|
||||||
|
alt="{{ image_alt }}"
|
||||||
|
/>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro series(series, alt=None) -%}
|
||||||
|
{%- set image_alt = alt if alt else image_title -%}
|
||||||
|
{% set srcset = series|get_series_srcset %}
|
||||||
|
<img
|
||||||
|
srcset="{{ srcset }}"
|
||||||
|
src="{{ url_for('web.get_series_cover', series_id=series.id, resolution='og', c='day'|cache_timestamp) }}"
|
||||||
|
alt="{{ book_title }}"
|
||||||
|
/>
|
||||||
|
{%- endmacro %}
|
|
@ -1,3 +1,4 @@
|
||||||
|
{% import 'image.html' as image %}
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% if g.user.show_detail_random() and page != "discover" %}
|
{% if g.user.show_detail_random() and page != "discover" %}
|
||||||
|
@ -9,7 +10,7 @@
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<span class="img" title="{{ entry.Books.title }}">
|
<span class="img" title="{{ entry.Books.title }}">
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.Books.id) }}" alt="{{ entry.Books.title }}" />
|
{{ image.book_cover(entry.Books) }}
|
||||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
@ -92,7 +93,7 @@
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<span class="img" title="{{ entry.Books.title }}">
|
<span class="img" title="{{ entry.Books.title }}">
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.Books.id) }}" alt="{{ entry.Books.title }}"/>
|
{{ image.book_cover(entry.Books) }}
|
||||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
{% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal, change_confirm_modal %}
|
{% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal, change_confirm_modal %}
|
||||||
|
{% import 'image.html' as image %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ g.user.locale }}">
|
<html lang="{{ g.user.locale }}">
|
||||||
<head>
|
<head>
|
||||||
|
@ -40,7 +41,7 @@
|
||||||
<a class="navbar-brand" href="{{url_for('web.index')}}">{{instance}}</a>
|
<a class="navbar-brand" href="{{url_for('web.index')}}">{{instance}}</a>
|
||||||
</div>
|
</div>
|
||||||
{% if g.user.is_authenticated or g.allow_anonymous %}
|
{% if g.user.is_authenticated or g.allow_anonymous %}
|
||||||
<form class="navbar-form navbar-left" role="search" action="{{url_for('web.search')}}" method="GET">
|
<form class="navbar-form navbar-left" role="search" action="{{url_for('search.simple_search')}}" method="GET">
|
||||||
<div class="form-group input-group input-group-sm">
|
<div class="form-group input-group input-group-sm">
|
||||||
<label for="query" class="sr-only">{{_('Search')}}</label>
|
<label for="query" class="sr-only">{{_('Search')}}</label>
|
||||||
<input type="text" class="form-control" id="query" name="query" placeholder="{{_('Search Library')}}" value="{{searchterm}}">
|
<input type="text" class="form-control" id="query" name="query" placeholder="{{_('Search Library')}}" value="{{searchterm}}">
|
||||||
|
@ -53,7 +54,7 @@
|
||||||
<div class="navbar-collapse collapse">
|
<div class="navbar-collapse collapse">
|
||||||
{% if g.user.is_authenticated or g.allow_anonymous %}
|
{% if g.user.is_authenticated or g.allow_anonymous %}
|
||||||
<ul class="nav navbar-nav ">
|
<ul class="nav navbar-nav ">
|
||||||
<li><a href="{{url_for('web.advanced_search')}}" id="advanced_search"><span class="glyphicon glyphicon-search"></span><span class="hidden-sm"> {{_('Advanced Search')}}</span></a></li>
|
<li><a href="{{url_for('search.advanced_search')}}" id="advanced_search"><span class="glyphicon glyphicon-search"></span><span class="hidden-sm"> {{_('Advanced Search')}}</span></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul class="nav navbar-nav navbar-right" id="main-nav">
|
<ul class="nav navbar-nav navbar-right" id="main-nav">
|
||||||
|
@ -70,7 +71,7 @@
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not g.user.is_anonymous and not simple%}
|
{% if not g.user.is_anonymous and not simple%}
|
||||||
<li><a id="top_tasks" href="{{url_for('web.get_tasks_status')}}"><span class="glyphicon glyphicon-tasks"></span> <span class="hidden-sm">{{_('Tasks')}}</span></a></li>
|
<li><a id="top_tasks" href="{{url_for('tasks.get_tasks_status')}}"><span class="glyphicon glyphicon-tasks"></span> <span class="hidden-sm">{{_('Tasks')}}</span></a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if g.user.role_admin() %}
|
{% if g.user.role_admin() %}
|
||||||
<li><a id="top_admin" data-text="{{_('Settings')}}" href="{{url_for('admin.admin')}}"><span class="glyphicon glyphicon-dashboard"></span> <span class="hidden-sm">{{_('Admin')}}</span></a></li>
|
<li><a id="top_admin" data-text="{{_('Settings')}}" href="{{url_for('admin.admin')}}"><span class="glyphicon glyphicon-dashboard"></span> <span class="hidden-sm">{{_('Admin')}}</span></a></li>
|
||||||
|
|
44
cps/templates/schedule_edit.html
Normal file
44
cps/templates/schedule_edit.html
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block header %}
|
||||||
|
<link href="{{ url_for('static', filename='css/libs/bootstrap-table.min.css') }}" rel="stylesheet">
|
||||||
|
<link href="{{ url_for('static', filename='css/libs/bootstrap-editable.css') }}" rel="stylesheet">
|
||||||
|
{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="discover">
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
<form role="form" class="col-md-10 col-lg-6" method="POST" autocomplete="off">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule_start_time">{{_('Time at which tasks start to run')}}</label>
|
||||||
|
<select name="schedule_start_time" id="schedule_start_time" class="form-control">
|
||||||
|
{% for n in starttime %}
|
||||||
|
<option value="{{n[0]}}" {% if config.schedule_start_time == n[0] %}selected{% endif %}>{{n[1]}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule_duration">{{_('Maximum tasks duration')}}</label>
|
||||||
|
<select name="schedule_duration" id="schedule_duration" class="form-control">
|
||||||
|
{% for n in duration %}
|
||||||
|
<option value="{{n[0]}}" {% if config.schedule_duration == n[0] %}selected{% endif %}>{{n[1]}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="checkbox" id="schedule_generate_book_covers" name="schedule_generate_book_covers" {% if config.schedule_generate_book_covers %}checked{% endif %}>
|
||||||
|
<label for="schedule_generate_book_covers">{{_('Generate Book Cover Thumbnails')}}</label>
|
||||||
|
</div>
|
||||||
|
<!--div class="form-group">
|
||||||
|
<input type="checkbox" id="schedule_generate_series_covers" name="schedule_generate_series_covers" {% if config.schedule_generate_series_covers %}checked{% endif %}>
|
||||||
|
<label for="schedule_generate_series_covers">{{_('Generate Series Cover Thumbnails')}}</label>
|
||||||
|
</div-->
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="checkbox" id="schedule_reconnect" name="schedule_reconnect" {% if config.schedule_reconnect %}checked{% endif %}>
|
||||||
|
<label for="schedule_reconnect">{{_('Reconnect to Calibre Library')}}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" name="submit" value="submit" class="btn btn-default">{{_('Save')}}</button>
|
||||||
|
<a href="{{ url_for('admin.admin') }}" id="email_back" class="btn btn-default">{{_('Cancel')}}</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -1,3 +1,4 @@
|
||||||
|
{% import 'image.html' as image %}
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="discover">
|
<div class="discover">
|
||||||
|
@ -45,7 +46,7 @@
|
||||||
{% if entry.Books.has_cover is defined %}
|
{% if entry.Books.has_cover is defined %}
|
||||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<span class="img" title="{{entry.Books.title}}" >
|
<span class="img" title="{{entry.Books.title}}" >
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.Books.id) }}" alt="{{ entry.Books.title }}" />
|
{{ image.book_cover(entry.Books) }}
|
||||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1 class="{{page}}">{{title}}</h1>
|
<h1 class="{{page}}">{{title}}</h1>
|
||||||
<div class="col-md-10 col-lg-6">
|
<div class="col-md-10 col-lg-6">
|
||||||
<form role="form" id="search" action="{{ url_for('web.advanced_search_form') }}" method="POST">
|
<form role="form" id="search" action="{{ url_for('search.advanced_search_form') }}" method="POST">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="book_title">{{_('Book Title')}}</label>
|
<label for="book_title">{{_('Book Title')}}</label>
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
{% import 'image.html' as image %}
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="discover">
|
<div class="discover">
|
||||||
|
@ -34,7 +35,7 @@
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<span class="img" title="{{entry.Books.title}}" >
|
<span class="img" title="{{entry.Books.title}}" >
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.Books.id) }}" alt="{{ entry.Books.title }}" />
|
{{ image.book_cover(entry.Books) }}
|
||||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
{% if version %}
|
{% if version %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{library}}</th>
|
<th>{{library}}</th>
|
||||||
<td>{{_(version)}}</td>
|
<td>{{version}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="discover">
|
<div class="discover">
|
||||||
<h2>{{_('Tasks')}}</h2>
|
<h2>{{_('Tasks')}}</h2>
|
||||||
<table class="table table-no-bordered" id="tasktable" data-url="{{ url_for('web.get_email_status_json') }}" data-sort-name="starttime" data-sort-order="asc" data-locale="{{ g.user.locale }}">
|
<table class="table table-no-bordered" id="tasktable" data-url="{{ url_for('tasks.get_email_status_json') }}" data-sort-name="starttime" data-sort-order="asc" data-locale="{{ g.user.locale }}">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{% if g.user.role_admin() %}
|
{% if g.user.role_admin() %}
|
||||||
|
@ -16,6 +16,9 @@
|
||||||
<th data-halign="right" data-align="right" data-field="progress" data-sortable="true" data-sorter="elementSorter">{{_('Progress')}}</th>
|
<th data-halign="right" data-align="right" data-field="progress" data-sortable="true" data-sorter="elementSorter">{{_('Progress')}}</th>
|
||||||
<th data-halign="right" data-align="right" data-field="runtime" data-sortable="true" data-sort-name="rt">{{_('Run Time')}}</th>
|
<th data-halign="right" data-align="right" data-field="runtime" data-sortable="true" data-sort-name="rt">{{_('Run Time')}}</th>
|
||||||
<th data-halign="right" data-align="right" data-field="starttime" data-sortable="true" data-sort-name="id">{{_('Start Time')}}</th>
|
<th data-halign="right" data-align="right" data-field="starttime" data-sortable="true" data-sort-name="id">{{_('Start Time')}}</th>
|
||||||
|
{% if g.user.role_admin() %}
|
||||||
|
<th data-halign="right" data-align="right" data-formatter="TaskActions" data-switchable="false">{{_('Actions')}}</th>
|
||||||
|
{% endif %}
|
||||||
<th data-field="id" data-visible="false"></th>
|
<th data-field="id" data-visible="false"></th>
|
||||||
<th data-field="rt" data-visible="false"></th>
|
<th data-field="rt" data-visible="false"></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -23,6 +26,30 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block modal %}
|
||||||
|
{{ delete_book() }}
|
||||||
|
{% if g.user.role_admin() %}
|
||||||
|
<div class="modal fade" id="cancelTaskModal" role="dialog" aria-labelledby="metaCancelTaskLabel">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-center">
|
||||||
|
<span>{{_('Are you really sure?')}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<p>
|
||||||
|
<span>{{_('This task will be cancelled. Any progress made by this task will be saved.')}}</span>
|
||||||
|
<span>{{_('If this is a scheduled task, it will be re-ran during the next scheduled time.')}}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<input type="button" class="btn btn-danger" value="{{_('Ok')}}" name="cancel_task_confirm" id="cancel_task_confirm" data-dismiss="modal">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-locale-all.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-locale-all.min.js') }}"></script>
|
||||||
|
|
43
cps/ub.py
43
cps/ub.py
|
@ -17,6 +17,7 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import atexit
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import datetime
|
import datetime
|
||||||
|
@ -52,7 +53,7 @@ except ImportError:
|
||||||
from sqlalchemy.orm import backref, relationship, sessionmaker, Session, scoped_session
|
from sqlalchemy.orm import backref, relationship, sessionmaker, Session, scoped_session
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from . import constants, logger, cli
|
from . import constants, logger
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
@ -512,6 +513,28 @@ class RemoteAuthToken(Base):
|
||||||
return '<Token %r>' % self.id
|
return '<Token %r>' % self.id
|
||||||
|
|
||||||
|
|
||||||
|
def filename(context):
|
||||||
|
file_format = context.get_current_parameters()['format']
|
||||||
|
if file_format == 'jpeg':
|
||||||
|
return context.get_current_parameters()['uuid'] + '.jpg'
|
||||||
|
else:
|
||||||
|
return context.get_current_parameters()['uuid'] + '.' + file_format
|
||||||
|
|
||||||
|
|
||||||
|
class Thumbnail(Base):
|
||||||
|
__tablename__ = 'thumbnail'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
entity_id = Column(Integer)
|
||||||
|
uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True)
|
||||||
|
format = Column(String, default='jpeg')
|
||||||
|
type = Column(SmallInteger, default=constants.THUMBNAIL_TYPE_COVER)
|
||||||
|
resolution = Column(SmallInteger, default=constants.COVER_THUMBNAIL_SMALL)
|
||||||
|
filename = Column(String, default=filename)
|
||||||
|
generated_at = Column(DateTime, default=lambda: datetime.datetime.utcnow())
|
||||||
|
expiration = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
# Add missing tables during migration of database
|
# Add missing tables during migration of database
|
||||||
def add_missing_tables(engine, _session):
|
def add_missing_tables(engine, _session):
|
||||||
if not engine.dialect.has_table(engine.connect(), "book_read_link"):
|
if not engine.dialect.has_table(engine.connect(), "book_read_link"):
|
||||||
|
@ -526,6 +549,8 @@ def add_missing_tables(engine, _session):
|
||||||
KoboStatistics.__table__.create(bind=engine)
|
KoboStatistics.__table__.create(bind=engine)
|
||||||
if not engine.dialect.has_table(engine.connect(), "archived_book"):
|
if not engine.dialect.has_table(engine.connect(), "archived_book"):
|
||||||
ArchivedBook.__table__.create(bind=engine)
|
ArchivedBook.__table__.create(bind=engine)
|
||||||
|
if not engine.dialect.has_table(engine.connect(), "thumbnail"):
|
||||||
|
Thumbnail.__table__.create(bind=engine)
|
||||||
if not engine.dialect.has_table(engine.connect(), "registration"):
|
if not engine.dialect.has_table(engine.connect(), "registration"):
|
||||||
Registration.__table__.create(bind=engine)
|
Registration.__table__.create(bind=engine)
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
|
@ -791,7 +816,7 @@ def init_db_thread():
|
||||||
return Session()
|
return Session()
|
||||||
|
|
||||||
|
|
||||||
def init_db(app_db_path):
|
def init_db(app_db_path, user_credentials=None):
|
||||||
# Open session for database connection
|
# Open session for database connection
|
||||||
global session
|
global session
|
||||||
global app_DB_path
|
global app_DB_path
|
||||||
|
@ -812,8 +837,8 @@ def init_db(app_db_path):
|
||||||
create_admin_user(session)
|
create_admin_user(session)
|
||||||
create_anonymous_user(session)
|
create_anonymous_user(session)
|
||||||
|
|
||||||
if cli.user_credentials:
|
if user_credentials:
|
||||||
username, password = cli.user_credentials.split(':', 1)
|
username, password = user_credentials.split(':', 1)
|
||||||
user = session.query(User).filter(func.lower(User.name) == username.lower()).first()
|
user = session.query(User).filter(func.lower(User.name) == username.lower()).first()
|
||||||
if user:
|
if user:
|
||||||
if not password:
|
if not password:
|
||||||
|
@ -831,6 +856,16 @@ def init_db(app_db_path):
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
|
|
||||||
|
def get_new_session_instance():
|
||||||
|
new_engine = create_engine(u'sqlite:///{0}'.format(app_DB_path), echo=False)
|
||||||
|
new_session = scoped_session(sessionmaker())
|
||||||
|
new_session.configure(bind=new_engine)
|
||||||
|
|
||||||
|
atexit.register(lambda: new_session.remove() if new_session else True)
|
||||||
|
|
||||||
|
return new_session
|
||||||
|
|
||||||
|
|
||||||
def dispose():
|
def dispose():
|
||||||
global session
|
global session
|
||||||
|
|
||||||
|
|
|
@ -28,10 +28,10 @@ from io import BytesIO
|
||||||
from tempfile import gettempdir
|
from tempfile import gettempdir
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from babel.dates import format_datetime
|
from flask_babel import format_datetime
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from . import constants, logger, config, web_server
|
from . import constants, logger # config, web_server
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
@ -58,15 +58,19 @@ class Updater(threading.Thread):
|
||||||
self.status = -1
|
self.status = -1
|
||||||
self.updateIndex = None
|
self.updateIndex = None
|
||||||
|
|
||||||
|
def init_updater(self, config, web_server):
|
||||||
|
self.config = config
|
||||||
|
self.web_server = web_server
|
||||||
|
|
||||||
def get_current_version_info(self):
|
def get_current_version_info(self):
|
||||||
if config.config_updatechannel == constants.UPDATE_STABLE:
|
if self.config.config_updatechannel == constants.UPDATE_STABLE:
|
||||||
return self._stable_version_info()
|
return self._stable_version_info()
|
||||||
return self._nightly_version_info()
|
return self._nightly_version_info()
|
||||||
|
|
||||||
def get_available_updates(self, request_method, locale):
|
def get_available_updates(self, request_method):
|
||||||
if config.config_updatechannel == constants.UPDATE_STABLE:
|
if self.config.config_updatechannel == constants.UPDATE_STABLE:
|
||||||
return self._stable_available_updates(request_method)
|
return self._stable_available_updates(request_method)
|
||||||
return self._nightly_available_updates(request_method, locale)
|
return self._nightly_available_updates(request_method)
|
||||||
|
|
||||||
def do_work(self):
|
def do_work(self):
|
||||||
try:
|
try:
|
||||||
|
@ -95,7 +99,7 @@ class Updater(threading.Thread):
|
||||||
self.status = 6
|
self.status = 6
|
||||||
log.debug(u'Preparing restart of server')
|
log.debug(u'Preparing restart of server')
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
web_server.stop(True)
|
self.web_server.stop(True)
|
||||||
self.status = 7
|
self.status = 7
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
return True
|
return True
|
||||||
|
@ -262,8 +266,9 @@ class Updater(threading.Thread):
|
||||||
if additional_path:
|
if additional_path:
|
||||||
exclude.append(additional_path)
|
exclude.append(additional_path)
|
||||||
exclude = tuple(exclude)
|
exclude = tuple(exclude)
|
||||||
# check if we are in a package, rename cps.py to __init__.py
|
# check if we are in a package, rename cps.py to __init__.py and __main__.py
|
||||||
if constants.HOME_CONFIG:
|
if constants.HOME_CONFIG:
|
||||||
|
shutil.copy(os.path.join(source, 'cps.py'), os.path.join(source, '__main__.py'))
|
||||||
shutil.move(os.path.join(source, 'cps.py'), os.path.join(source, '__init__.py'))
|
shutil.move(os.path.join(source, 'cps.py'), os.path.join(source, '__init__.py'))
|
||||||
|
|
||||||
for root, dirs, files in os.walk(destination, topdown=True):
|
for root, dirs, files in os.walk(destination, topdown=True):
|
||||||
|
@ -331,7 +336,7 @@ class Updater(threading.Thread):
|
||||||
print("\n*** Finished ***")
|
print("\n*** Finished ***")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _populate_parent_commits(update_data, status, locale, tz, parents):
|
def _populate_parent_commits(update_data, status, tz, parents):
|
||||||
try:
|
try:
|
||||||
parent_commit = update_data['parents'][0]
|
parent_commit = update_data['parents'][0]
|
||||||
# limit the maximum search depth
|
# limit the maximum search depth
|
||||||
|
@ -356,7 +361,7 @@ class Updater(threading.Thread):
|
||||||
parent_commit_date = datetime.datetime.strptime(
|
parent_commit_date = datetime.datetime.strptime(
|
||||||
parent_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz
|
parent_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz
|
||||||
parent_commit_date = format_datetime(
|
parent_commit_date = format_datetime(
|
||||||
parent_commit_date, format='short', locale=locale)
|
parent_commit_date, format='short')
|
||||||
|
|
||||||
parents.append([parent_commit_date,
|
parents.append([parent_commit_date,
|
||||||
parent_data['message'].replace('\r\n', '<p>').replace('\n', '<p>')])
|
parent_data['message'].replace('\r\n', '<p>').replace('\n', '<p>')])
|
||||||
|
@ -398,7 +403,7 @@ class Updater(threading.Thread):
|
||||||
os.sep + 'gdrive_credentials', os.sep + 'settings.yaml', os.sep + 'venv', os.sep + 'virtualenv',
|
os.sep + 'gdrive_credentials', os.sep + 'settings.yaml', os.sep + 'venv', os.sep + 'virtualenv',
|
||||||
os.sep + 'access.log', os.sep + 'access.log1', os.sep + 'access.log2',
|
os.sep + 'access.log', os.sep + 'access.log1', os.sep + 'access.log2',
|
||||||
os.sep + '.calibre-web.log.swp', os.sep + '_sqlite3.so', os.sep + 'cps' + os.sep + '.HOMEDIR',
|
os.sep + '.calibre-web.log.swp', os.sep + '_sqlite3.so', os.sep + 'cps' + os.sep + '.HOMEDIR',
|
||||||
os.sep + 'gmail.json', os.sep + 'exclude.txt'
|
os.sep + 'gmail.json', os.sep + 'exclude.txt', os.sep + 'cps' + os.sep + 'cache'
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(constants.BASE_DIR, "exclude.txt"), "r") as f:
|
with open(os.path.join(constants.BASE_DIR, "exclude.txt"), "r") as f:
|
||||||
|
@ -414,7 +419,7 @@ class Updater(threading.Thread):
|
||||||
log_function("Excluded file list for updater not found, or not accessible")
|
log_function("Excluded file list for updater not found, or not accessible")
|
||||||
return excluded_files
|
return excluded_files
|
||||||
|
|
||||||
def _nightly_available_updates(self, request_method, locale):
|
def _nightly_available_updates(self, request_method):
|
||||||
tz = datetime.timedelta(seconds=time.timezone if (time.localtime().tm_isdst == 0) else time.altzone)
|
tz = datetime.timedelta(seconds=time.timezone if (time.localtime().tm_isdst == 0) else time.altzone)
|
||||||
if request_method == "GET":
|
if request_method == "GET":
|
||||||
repository_url = _REPOSITORY_API_URL
|
repository_url = _REPOSITORY_API_URL
|
||||||
|
@ -455,14 +460,14 @@ class Updater(threading.Thread):
|
||||||
update_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz
|
update_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz
|
||||||
parents.append(
|
parents.append(
|
||||||
[
|
[
|
||||||
format_datetime(new_commit_date, format='short', locale=locale),
|
format_datetime(new_commit_date, format='short'),
|
||||||
update_data['message'],
|
update_data['message'],
|
||||||
update_data['sha']
|
update_data['sha']
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
# it only makes sense to analyze the parents if we know the current commit hash
|
# it only makes sense to analyze the parents if we know the current commit hash
|
||||||
if status['current_commit_hash'] != '':
|
if status['current_commit_hash'] != '':
|
||||||
parents = self._populate_parent_commits(update_data, status, locale, tz, parents)
|
parents = self._populate_parent_commits(update_data, status, tz, parents)
|
||||||
status['history'] = parents[::-1]
|
status['history'] = parents[::-1]
|
||||||
except (IndexError, KeyError):
|
except (IndexError, KeyError):
|
||||||
status['success'] = False
|
status['success'] = False
|
||||||
|
@ -591,7 +596,7 @@ class Updater(threading.Thread):
|
||||||
return json.dumps(status)
|
return json.dumps(status)
|
||||||
|
|
||||||
def _get_request_path(self):
|
def _get_request_path(self):
|
||||||
if config.config_updatechannel == constants.UPDATE_STABLE:
|
if self.config.config_updatechannel == constants.UPDATE_STABLE:
|
||||||
return self.updateFile
|
return self.updateFile
|
||||||
return _REPOSITORY_API_URL + '/zipball/master'
|
return _REPOSITORY_API_URL + '/zipball/master'
|
||||||
|
|
||||||
|
@ -619,7 +624,7 @@ class Updater(threading.Thread):
|
||||||
status['message'] = _(u'HTTP Error') + ': ' + commit['message']
|
status['message'] = _(u'HTTP Error') + ': ' + commit['message']
|
||||||
else:
|
else:
|
||||||
status['message'] = _(u'HTTP Error') + ': ' + str(e)
|
status['message'] = _(u'HTTP Error') + ': ' + str(e)
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError as e:
|
||||||
status['message'] = _(u'Connection error')
|
status['message'] = _(u'Connection error')
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
status['message'] = _(u'Timeout while establishing connection')
|
status['message'] = _(u'Timeout while establishing connection')
|
||||||
|
|
|
@ -27,12 +27,6 @@ from .helper import split_authors
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
from lxml.etree import LXML_VERSION as lxmlversion
|
|
||||||
except ImportError:
|
|
||||||
lxmlversion = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from wand.image import Image, Color
|
from wand.image import Image, Color
|
||||||
from wand import version as ImageVersion
|
from wand import version as ImageVersion
|
||||||
|
@ -101,7 +95,7 @@ def default_meta(tmp_file_path, original_file_name, original_file_extension):
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=original_file_name,
|
title=original_file_name,
|
||||||
author=_(u'Unknown'),
|
author=_(u'Unknown'),
|
||||||
cover=None, #pdf_preview(tmp_file_path, original_file_name),
|
cover=None,
|
||||||
description="",
|
description="",
|
||||||
tags="",
|
tags="",
|
||||||
series="",
|
series="",
|
||||||
|
@ -237,29 +231,12 @@ def pdf_preview(tmp_file_path, tmp_dir):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_versions(all=True):
|
def get_versions():
|
||||||
ret = dict()
|
ret = dict()
|
||||||
if not use_generic_pdf_cover:
|
if not use_generic_pdf_cover:
|
||||||
ret['Image Magick'] = ImageVersion.MAGICK_VERSION
|
ret['Image Magick'] = ImageVersion.MAGICK_VERSION
|
||||||
else:
|
else:
|
||||||
ret['Image Magick'] = u'not installed'
|
ret['Image Magick'] = u'not installed'
|
||||||
if all:
|
|
||||||
if not use_generic_pdf_cover:
|
|
||||||
ret['Wand'] = ImageVersion.VERSION
|
|
||||||
else:
|
|
||||||
ret['Wand'] = u'not installed'
|
|
||||||
if use_pdf_meta:
|
|
||||||
ret['PyPdf'] = PyPdfVersion
|
|
||||||
else:
|
|
||||||
ret['PyPdf'] = u'not installed'
|
|
||||||
if lxmlversion:
|
|
||||||
ret['lxml'] = '.'.join(map(str, lxmlversion))
|
|
||||||
else:
|
|
||||||
ret['lxml'] = u'not installed'
|
|
||||||
if comic.use_comic_meta:
|
|
||||||
ret['Comic_API'] = comic.comic_version or u'installed'
|
|
||||||
else:
|
|
||||||
ret['Comic_API'] = u'not installed'
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
|
428
cps/web.py
428
cps/web.py
|
@ -1,5 +1,3 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
|
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
|
||||||
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
|
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
|
||||||
|
@ -21,40 +19,37 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import chardet # dependency of requests
|
import chardet # dependency of requests
|
||||||
import copy
|
import copy
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from babel.dates import format_date
|
|
||||||
from babel import Locale
|
|
||||||
from flask import Blueprint, jsonify
|
from flask import Blueprint, jsonify
|
||||||
from flask import request, redirect, send_from_directory, make_response, flash, abort, url_for
|
from flask import request, redirect, send_from_directory, make_response, flash, abort, url_for
|
||||||
from flask import session as flask_session
|
from flask import session as flask_session
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
from flask_babel import get_locale
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError
|
from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError
|
||||||
from sqlalchemy.sql.expression import text, func, false, not_, and_, or_
|
from sqlalchemy.sql.expression import text, func, false, not_, and_
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
from sqlalchemy.sql.functions import coalesce
|
from sqlalchemy.sql.functions import coalesce
|
||||||
|
|
||||||
from .services.worker import WorkerThread
|
|
||||||
|
|
||||||
from werkzeug.datastructures import Headers
|
from werkzeug.datastructures import Headers
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
from . import constants, logger, isoLanguages, services
|
from . import constants, logger, isoLanguages, services
|
||||||
from . import babel, db, ub, config, get_locale, app
|
from . import db, ub, config, app
|
||||||
from . import calibre_db, kobo_sync_status
|
from . import calibre_db, kobo_sync_status
|
||||||
|
from .search import render_search_results, render_adv_search_results
|
||||||
from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download
|
from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download
|
||||||
from .helper import check_valid_domain, render_task_status, check_email, check_username, \
|
from .helper import check_valid_domain, check_email, check_username, \
|
||||||
get_book_cover, get_download_link, send_mail, generate_random_password, \
|
get_book_cover, get_series_cover_thumbnail, get_download_link, send_mail, generate_random_password, \
|
||||||
send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password, valid_email, \
|
send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password, valid_email, \
|
||||||
edit_book_read_status
|
edit_book_read_status
|
||||||
from .pagination import Pagination
|
from .pagination import Pagination
|
||||||
from .redirect import redirect_back
|
from .redirect import redirect_back
|
||||||
|
from .babel import get_available_locale
|
||||||
from .usermanagement import login_required_if_no_ano
|
from .usermanagement import login_required_if_no_ano
|
||||||
from .kobo_sync_status import remove_synced_book
|
from .kobo_sync_status import remove_synced_book
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
|
@ -75,6 +70,8 @@ except ImportError:
|
||||||
oauth_check = {}
|
oauth_check = {}
|
||||||
register_user_with_oauth = logout_oauth_user = get_oauth_status = None
|
register_user_with_oauth = logout_oauth_user = get_oauth_status = None
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from natsort import natsorted as sort
|
from natsort import natsorted as sort
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
@ -102,6 +99,7 @@ def add_security_headers(resp):
|
||||||
|
|
||||||
|
|
||||||
web = Blueprint('web', __name__)
|
web = Blueprint('web', __name__)
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
@ -134,7 +132,7 @@ def viewer_required(f):
|
||||||
@web.route("/ajax/emailstat")
|
@web.route("/ajax/emailstat")
|
||||||
@login_required
|
@login_required
|
||||||
def get_email_status_json():
|
def get_email_status_json():
|
||||||
tasks = WorkerThread.getInstance().tasks
|
tasks = WorkerThread.get_instance().tasks
|
||||||
return jsonify(render_task_status(tasks))
|
return jsonify(render_task_status(tasks))
|
||||||
|
|
||||||
|
|
||||||
|
@ -770,57 +768,6 @@ def render_archived_books(page, sort_param):
|
||||||
title=name, page=page_name, order=sort_param[1])
|
title=name, page=page_name, order=sort_param[1])
|
||||||
|
|
||||||
|
|
||||||
def render_prepare_search_form(cc):
|
|
||||||
# prepare data for search-form
|
|
||||||
tags = calibre_db.session.query(db.Tags) \
|
|
||||||
.join(db.books_tags_link) \
|
|
||||||
.join(db.Books) \
|
|
||||||
.filter(calibre_db.common_filters()) \
|
|
||||||
.group_by(text('books_tags_link.tag')) \
|
|
||||||
.order_by(db.Tags.name).all()
|
|
||||||
series = calibre_db.session.query(db.Series) \
|
|
||||||
.join(db.books_series_link) \
|
|
||||||
.join(db.Books) \
|
|
||||||
.filter(calibre_db.common_filters()) \
|
|
||||||
.group_by(text('books_series_link.series')) \
|
|
||||||
.order_by(db.Series.name) \
|
|
||||||
.filter(calibre_db.common_filters()).all()
|
|
||||||
shelves = ub.session.query(ub.Shelf) \
|
|
||||||
.filter(or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == int(current_user.id))) \
|
|
||||||
.order_by(ub.Shelf.name).all()
|
|
||||||
extensions = calibre_db.session.query(db.Data) \
|
|
||||||
.join(db.Books) \
|
|
||||||
.filter(calibre_db.common_filters()) \
|
|
||||||
.group_by(db.Data.format) \
|
|
||||||
.order_by(db.Data.format).all()
|
|
||||||
if current_user.filter_language() == u"all":
|
|
||||||
languages = calibre_db.speaking_language()
|
|
||||||
else:
|
|
||||||
languages = None
|
|
||||||
return render_title_template('search_form.html', tags=tags, languages=languages, extensions=extensions,
|
|
||||||
series=series, shelves=shelves, title=_(u"Advanced Search"), cc=cc, page="advsearch")
|
|
||||||
|
|
||||||
|
|
||||||
def render_search_results(term, offset=None, order=None, limit=None):
|
|
||||||
join = db.books_series_link, db.books_series_link.c.book == db.Books.id, db.Series
|
|
||||||
entries, result_count, pagination = calibre_db.get_search_results(term,
|
|
||||||
config,
|
|
||||||
offset,
|
|
||||||
order,
|
|
||||||
limit,
|
|
||||||
*join)
|
|
||||||
return render_title_template('search.html',
|
|
||||||
searchterm=term,
|
|
||||||
pagination=pagination,
|
|
||||||
query=term,
|
|
||||||
adv_searchterm=term,
|
|
||||||
entries=entries,
|
|
||||||
result_count=result_count,
|
|
||||||
title=_(u"Search"),
|
|
||||||
page="search",
|
|
||||||
order=order[1])
|
|
||||||
|
|
||||||
|
|
||||||
# ################################### View Books list ##################################################################
|
# ################################### View Books list ##################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@ -1013,7 +960,7 @@ def publisher_list():
|
||||||
.count())
|
.count())
|
||||||
if no_publisher_count:
|
if no_publisher_count:
|
||||||
entries.append([db.Category(_("None"), "-1"), no_publisher_count])
|
entries.append([db.Category(_("None"), "-1"), no_publisher_count])
|
||||||
entries = sorted(entries, key=lambda x: x[0].name, reverse=not order_no)
|
entries = sorted(entries, key=lambda x: x[0].name.lower(), reverse=not order_no)
|
||||||
char_list = generate_char_list(entries)
|
char_list = generate_char_list(entries)
|
||||||
return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=char_list,
|
return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=char_list,
|
||||||
title=_(u"Publishers"), page="publisherlist", data="publisher", order=order_no)
|
title=_(u"Publishers"), page="publisherlist", data="publisher", order=order_no)
|
||||||
|
@ -1043,7 +990,7 @@ def series_list():
|
||||||
.count())
|
.count())
|
||||||
if no_series_count:
|
if no_series_count:
|
||||||
entries.append([db.Category(_("None"), "-1"), no_series_count])
|
entries.append([db.Category(_("None"), "-1"), no_series_count])
|
||||||
entries = sorted(entries, key=lambda x: x[0].name, reverse=not order_no)
|
entries = sorted(entries, key=lambda x: x[0].name.lower(), reverse=not order_no)
|
||||||
return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=char_list,
|
return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=char_list,
|
||||||
title=_(u"Series"), page="serieslist", data="series", order=order_no)
|
title=_(u"Series"), page="serieslist", data="series", order=order_no)
|
||||||
else:
|
else:
|
||||||
|
@ -1145,7 +1092,7 @@ def category_list():
|
||||||
.count())
|
.count())
|
||||||
if no_tag_count:
|
if no_tag_count:
|
||||||
entries.append([db.Category(_("None"), "-1"), no_tag_count])
|
entries.append([db.Category(_("None"), "-1"), no_tag_count])
|
||||||
entries = sorted(entries, key=lambda x: x[0].name, reverse=not order_no)
|
entries = sorted(entries, key=lambda x: x[0].name.lower(), reverse=not order_no)
|
||||||
char_list = generate_char_list(entries)
|
char_list = generate_char_list(entries)
|
||||||
return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=char_list,
|
return render_title_template('list.html', entries=entries, folder='web.books_list', charlist=char_list,
|
||||||
title=_(u"Categories"), page="catlist", data="category", order=order_no)
|
title=_(u"Categories"), page="catlist", data="category", order=order_no)
|
||||||
|
@ -1153,329 +1100,38 @@ def category_list():
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
# ################################### Task functions ################################################################
|
|
||||||
|
|
||||||
|
|
||||||
@web.route("/tasks")
|
|
||||||
@login_required
|
|
||||||
def get_tasks_status():
|
|
||||||
# if current user admin, show all email, otherwise only own emails
|
|
||||||
tasks = WorkerThread.getInstance().tasks
|
|
||||||
answer = render_task_status(tasks)
|
|
||||||
return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks")
|
|
||||||
|
|
||||||
|
|
||||||
# ################################### Search functions ################################################################
|
|
||||||
|
|
||||||
@web.route("/search", methods=["GET"])
|
|
||||||
@login_required_if_no_ano
|
|
||||||
def search():
|
|
||||||
term = request.args.get("query")
|
|
||||||
if term:
|
|
||||||
return redirect(url_for('web.books_list', data="search", sort_param='stored', query=term.strip()))
|
|
||||||
else:
|
|
||||||
return render_title_template('search.html',
|
|
||||||
searchterm="",
|
|
||||||
result_count=0,
|
|
||||||
title=_(u"Search"),
|
|
||||||
page="search")
|
|
||||||
|
|
||||||
|
|
||||||
@web.route("/advsearch", methods=['POST'])
|
|
||||||
@login_required_if_no_ano
|
|
||||||
def advanced_search():
|
|
||||||
values = dict(request.form)
|
|
||||||
params = ['include_tag', 'exclude_tag', 'include_serie', 'exclude_serie', 'include_shelf', 'exclude_shelf',
|
|
||||||
'include_language', 'exclude_language', 'include_extension', 'exclude_extension']
|
|
||||||
for param in params:
|
|
||||||
values[param] = list(request.form.getlist(param))
|
|
||||||
flask_session['query'] = json.dumps(values)
|
|
||||||
return redirect(url_for('web.books_list', data="advsearch", sort_param='stored', query=""))
|
|
||||||
|
|
||||||
|
|
||||||
def adv_search_custom_columns(cc, term, q):
|
|
||||||
for c in cc:
|
|
||||||
if c.datatype == "datetime":
|
|
||||||
custom_start = term.get('custom_column_' + str(c.id) + '_start')
|
|
||||||
custom_end = term.get('custom_column_' + str(c.id) + '_end')
|
|
||||||
if custom_start:
|
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
|
||||||
func.datetime(db.cc_classes[c.id].value) >= func.datetime(custom_start)))
|
|
||||||
if custom_end:
|
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
|
||||||
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end)))
|
|
||||||
else:
|
|
||||||
custom_query = term.get('custom_column_' + str(c.id))
|
|
||||||
if custom_query != '' and custom_query is not None:
|
|
||||||
if c.datatype == 'bool':
|
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
|
||||||
db.cc_classes[c.id].value == (custom_query == "True")))
|
|
||||||
elif c.datatype == 'int' or c.datatype == 'float':
|
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
|
||||||
db.cc_classes[c.id].value == custom_query))
|
|
||||||
elif c.datatype == 'rating':
|
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
|
||||||
db.cc_classes[c.id].value == int(float(custom_query) * 2)))
|
|
||||||
else:
|
|
||||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
|
||||||
func.lower(db.cc_classes[c.id].value).ilike("%" + custom_query + "%")))
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
def adv_search_read_status(q, read_status):
|
|
||||||
if read_status:
|
|
||||||
if config.config_read_column:
|
|
||||||
try:
|
|
||||||
if read_status == "True":
|
|
||||||
q = q.join(db.cc_classes[config.config_read_column], isouter=True) \
|
|
||||||
.filter(db.cc_classes[config.config_read_column].value == True)
|
|
||||||
else:
|
|
||||||
q = q.join(db.cc_classes[config.config_read_column], isouter=True) \
|
|
||||||
.filter(coalesce(db.cc_classes[config.config_read_column].value, False) != True)
|
|
||||||
except (KeyError, AttributeError, IndexError):
|
|
||||||
log.error(
|
|
||||||
"Custom Column No.{} is not existing in calibre database".format(config.config_read_column))
|
|
||||||
flash(_("Custom Column No.%(column)d is not existing in calibre database",
|
|
||||||
column=config.config_read_column),
|
|
||||||
category="error")
|
|
||||||
return q
|
|
||||||
else:
|
|
||||||
if read_status == "True":
|
|
||||||
q = q.join(ub.ReadBook, db.Books.id == ub.ReadBook.book_id, isouter=True) \
|
|
||||||
.filter(ub.ReadBook.user_id == int(current_user.id),
|
|
||||||
ub.ReadBook.read_status == ub.ReadBook.STATUS_FINISHED)
|
|
||||||
else:
|
|
||||||
q = q.join(ub.ReadBook, db.Books.id == ub.ReadBook.book_id, isouter=True) \
|
|
||||||
.filter(ub.ReadBook.user_id == int(current_user.id),
|
|
||||||
coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED)
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
def adv_search_language(q, include_languages_inputs, exclude_languages_inputs):
|
|
||||||
if current_user.filter_language() != "all":
|
|
||||||
q = q.filter(db.Books.languages.any(db.Languages.lang_code == current_user.filter_language()))
|
|
||||||
else:
|
|
||||||
return adv_search_text(q, include_languages_inputs, exclude_languages_inputs, db.Languages.id)
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
def adv_search_ratings(q, rating_high, rating_low):
|
|
||||||
if rating_high:
|
|
||||||
rating_high = int(rating_high) * 2
|
|
||||||
q = q.filter(db.Books.ratings.any(db.Ratings.rating <= rating_high))
|
|
||||||
if rating_low:
|
|
||||||
rating_low = int(rating_low) * 2
|
|
||||||
q = q.filter(db.Books.ratings.any(db.Ratings.rating >= rating_low))
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
def adv_search_text(q, include_inputs, exclude_inputs, data_table):
|
|
||||||
for inp in include_inputs:
|
|
||||||
q = q.filter(getattr(db.Books, data_table.class_.__tablename__).any(data_table == inp))
|
|
||||||
for excl in exclude_inputs:
|
|
||||||
q = q.filter(not_(getattr(db.Books, data_table.class_.__tablename__).any(data_table == excl)))
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
def adv_search_shelf(q, include_shelf_inputs, exclude_shelf_inputs):
|
|
||||||
q = q.outerjoin(ub.BookShelf, db.Books.id == ub.BookShelf.book_id) \
|
|
||||||
.filter(or_(ub.BookShelf.shelf == None, ub.BookShelf.shelf.notin_(exclude_shelf_inputs)))
|
|
||||||
if len(include_shelf_inputs) > 0:
|
|
||||||
q = q.filter(ub.BookShelf.shelf.in_(include_shelf_inputs))
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
def extend_search_term(searchterm,
|
|
||||||
author_name,
|
|
||||||
book_title,
|
|
||||||
publisher,
|
|
||||||
pub_start,
|
|
||||||
pub_end,
|
|
||||||
tags,
|
|
||||||
rating_high,
|
|
||||||
rating_low,
|
|
||||||
read_status,
|
|
||||||
):
|
|
||||||
searchterm.extend((author_name.replace('|', ','), book_title, publisher))
|
|
||||||
if pub_start:
|
|
||||||
try:
|
|
||||||
searchterm.extend([_(u"Published after ") +
|
|
||||||
format_date(datetime.strptime(pub_start, "%Y-%m-%d"),
|
|
||||||
format='medium', locale=get_locale())])
|
|
||||||
except ValueError:
|
|
||||||
pub_start = u""
|
|
||||||
if pub_end:
|
|
||||||
try:
|
|
||||||
searchterm.extend([_(u"Published before ") +
|
|
||||||
format_date(datetime.strptime(pub_end, "%Y-%m-%d"),
|
|
||||||
format='medium', locale=get_locale())])
|
|
||||||
except ValueError:
|
|
||||||
pub_end = u""
|
|
||||||
elements = {'tag': db.Tags, 'serie': db.Series, 'shelf': ub.Shelf}
|
|
||||||
for key, db_element in elements.items():
|
|
||||||
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all()
|
|
||||||
searchterm.extend(tag.name for tag in tag_names)
|
|
||||||
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['exclude_' + key])).all()
|
|
||||||
searchterm.extend(tag.name for tag in tag_names)
|
|
||||||
language_names = calibre_db.session.query(db.Languages). \
|
|
||||||
filter(db.Languages.id.in_(tags['include_language'])).all()
|
|
||||||
if language_names:
|
|
||||||
language_names = calibre_db.speaking_language(language_names)
|
|
||||||
searchterm.extend(language.name for language in language_names)
|
|
||||||
language_names = calibre_db.session.query(db.Languages). \
|
|
||||||
filter(db.Languages.id.in_(tags['exclude_language'])).all()
|
|
||||||
if language_names:
|
|
||||||
language_names = calibre_db.speaking_language(language_names)
|
|
||||||
searchterm.extend(language.name for language in language_names)
|
|
||||||
if rating_high:
|
|
||||||
searchterm.extend([_(u"Rating <= %(rating)s", rating=rating_high)])
|
|
||||||
if rating_low:
|
|
||||||
searchterm.extend([_(u"Rating >= %(rating)s", rating=rating_low)])
|
|
||||||
if read_status:
|
|
||||||
searchterm.extend([_(u"Read Status = %(status)s", status=read_status)])
|
|
||||||
searchterm.extend(ext for ext in tags['include_extension'])
|
|
||||||
searchterm.extend(ext for ext in tags['exclude_extension'])
|
|
||||||
# handle custom columns
|
|
||||||
searchterm = " + ".join(filter(None, searchterm))
|
|
||||||
return searchterm, pub_start, pub_end
|
|
||||||
|
|
||||||
|
|
||||||
def render_adv_search_results(term, offset=None, order=None, limit=None):
|
|
||||||
sort_param = order[0] if order else [db.Books.sort]
|
|
||||||
pagination = None
|
|
||||||
|
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
|
||||||
calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
|
|
||||||
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
|
||||||
q = query.outerjoin(db.books_series_link, db.books_series_link.c.book == db.Books.id) \
|
|
||||||
.outerjoin(db.Series) \
|
|
||||||
.filter(calibre_db.common_filters(True))
|
|
||||||
|
|
||||||
# parse multiselects to a complete dict
|
|
||||||
tags = dict()
|
|
||||||
elements = ['tag', 'serie', 'shelf', 'language', 'extension']
|
|
||||||
for element in elements:
|
|
||||||
tags['include_' + element] = term.get('include_' + element)
|
|
||||||
tags['exclude_' + element] = term.get('exclude_' + element)
|
|
||||||
|
|
||||||
author_name = term.get("author_name")
|
|
||||||
book_title = term.get("book_title")
|
|
||||||
publisher = term.get("publisher")
|
|
||||||
pub_start = term.get("publishstart")
|
|
||||||
pub_end = term.get("publishend")
|
|
||||||
rating_low = term.get("ratinghigh")
|
|
||||||
rating_high = term.get("ratinglow")
|
|
||||||
description = term.get("comment")
|
|
||||||
read_status = term.get("read_status")
|
|
||||||
if author_name:
|
|
||||||
author_name = author_name.strip().lower().replace(',', '|')
|
|
||||||
if book_title:
|
|
||||||
book_title = book_title.strip().lower()
|
|
||||||
if publisher:
|
|
||||||
publisher = publisher.strip().lower()
|
|
||||||
|
|
||||||
search_term = []
|
|
||||||
cc_present = False
|
|
||||||
for c in cc:
|
|
||||||
if c.datatype == "datetime":
|
|
||||||
column_start = term.get('custom_column_' + str(c.id) + '_start')
|
|
||||||
column_end = term.get('custom_column_' + str(c.id) + '_end')
|
|
||||||
if column_start:
|
|
||||||
search_term.extend([u"{} >= {}".format(c.name,
|
|
||||||
format_date(datetime.strptime(column_start, "%Y-%m-%d").date(),
|
|
||||||
format='medium',
|
|
||||||
locale=get_locale())
|
|
||||||
)])
|
|
||||||
cc_present = True
|
|
||||||
if column_end:
|
|
||||||
search_term.extend([u"{} <= {}".format(c.name,
|
|
||||||
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
|
||||||
format='medium',
|
|
||||||
locale=get_locale())
|
|
||||||
)])
|
|
||||||
cc_present = True
|
|
||||||
elif term.get('custom_column_' + str(c.id)):
|
|
||||||
search_term.extend([(u"{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
|
||||||
cc_present = True
|
|
||||||
|
|
||||||
if any(tags.values()) or author_name or book_title or \
|
|
||||||
publisher or pub_start or pub_end or rating_low or rating_high \
|
|
||||||
or description or cc_present or read_status:
|
|
||||||
search_term, pub_start, pub_end = extend_search_term(search_term,
|
|
||||||
author_name,
|
|
||||||
book_title,
|
|
||||||
publisher,
|
|
||||||
pub_start,
|
|
||||||
pub_end,
|
|
||||||
tags,
|
|
||||||
rating_high,
|
|
||||||
rating_low,
|
|
||||||
read_status)
|
|
||||||
# q = q.filter()
|
|
||||||
if author_name:
|
|
||||||
q = q.filter(db.Books.authors.any(func.lower(db.Authors.name).ilike("%" + author_name + "%")))
|
|
||||||
if book_title:
|
|
||||||
q = q.filter(func.lower(db.Books.title).ilike("%" + book_title + "%"))
|
|
||||||
if pub_start:
|
|
||||||
q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start))
|
|
||||||
if pub_end:
|
|
||||||
q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end))
|
|
||||||
q = adv_search_read_status(q, read_status)
|
|
||||||
if publisher:
|
|
||||||
q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + publisher + "%")))
|
|
||||||
q = adv_search_text(q, tags['include_tag'], tags['exclude_tag'], db.Tags.id)
|
|
||||||
q = adv_search_text(q, tags['include_serie'], tags['exclude_serie'], db.Series.id)
|
|
||||||
q = adv_search_text(q, tags['include_extension'], tags['exclude_extension'], db.Data.format)
|
|
||||||
q = adv_search_shelf(q, tags['include_shelf'], tags['exclude_shelf'])
|
|
||||||
q = adv_search_language(q, tags['include_language'], tags['exclude_language'])
|
|
||||||
q = adv_search_ratings(q, rating_high, rating_low, )
|
|
||||||
|
|
||||||
if description:
|
|
||||||
q = q.filter(db.Books.comments.any(func.lower(db.Comments.text).ilike("%" + description + "%")))
|
|
||||||
|
|
||||||
# search custom columns
|
|
||||||
try:
|
|
||||||
q = adv_search_custom_columns(cc, term, q)
|
|
||||||
except AttributeError as ex:
|
|
||||||
log.error_or_exception(ex)
|
|
||||||
flash(_("Error on search for custom columns, please restart Calibre-Web"), category="error")
|
|
||||||
|
|
||||||
q = q.order_by(*sort_param).all()
|
|
||||||
flask_session['query'] = json.dumps(term)
|
|
||||||
ub.store_combo_ids(q)
|
|
||||||
result_count = len(q)
|
|
||||||
if offset is not None and limit is not None:
|
|
||||||
offset = int(offset)
|
|
||||||
limit_all = offset + int(limit)
|
|
||||||
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
|
||||||
else:
|
|
||||||
offset = 0
|
|
||||||
limit_all = result_count
|
|
||||||
entries = calibre_db.order_authors(q[offset:limit_all], list_return=True, combined=True)
|
|
||||||
return render_title_template('search.html',
|
|
||||||
adv_searchterm=search_term,
|
|
||||||
pagination=pagination,
|
|
||||||
entries=entries,
|
|
||||||
result_count=result_count,
|
|
||||||
title=_(u"Advanced Search"), page="advsearch",
|
|
||||||
order=order[1])
|
|
||||||
|
|
||||||
|
|
||||||
@web.route("/advsearch", methods=['GET'])
|
|
||||||
@login_required_if_no_ano
|
|
||||||
def advanced_search_form():
|
|
||||||
# Build custom columns names
|
|
||||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
|
||||||
return render_prepare_search_form(cc)
|
|
||||||
|
|
||||||
|
|
||||||
# ################################### Download/Send ##################################################################
|
# ################################### Download/Send ##################################################################
|
||||||
|
|
||||||
|
|
||||||
@web.route("/cover/<int:book_id>")
|
@web.route("/cover/<int:book_id>")
|
||||||
|
@web.route("/cover/<int:book_id>/<string:resolution>")
|
||||||
@login_required_if_no_ano
|
@login_required_if_no_ano
|
||||||
def get_cover(book_id):
|
def get_cover(book_id, resolution=None):
|
||||||
return get_book_cover(book_id)
|
resolutions = {
|
||||||
|
'og': constants.COVER_THUMBNAIL_ORIGINAL,
|
||||||
|
'sm': constants.COVER_THUMBNAIL_SMALL,
|
||||||
|
'md': constants.COVER_THUMBNAIL_MEDIUM,
|
||||||
|
'lg': constants.COVER_THUMBNAIL_LARGE,
|
||||||
|
}
|
||||||
|
cover_resolution = resolutions.get(resolution, None)
|
||||||
|
return get_book_cover(book_id, cover_resolution)
|
||||||
|
|
||||||
|
|
||||||
|
@web.route("/series_cover/<int:series_id>")
|
||||||
|
@web.route("/series_cover/<int:series_id>/<string:resolution>")
|
||||||
|
@login_required_if_no_ano
|
||||||
|
def get_series_cover(series_id, resolution=None):
|
||||||
|
resolutions = {
|
||||||
|
'og': constants.COVER_THUMBNAIL_ORIGINAL,
|
||||||
|
'sm': constants.COVER_THUMBNAIL_SMALL,
|
||||||
|
'md': constants.COVER_THUMBNAIL_MEDIUM,
|
||||||
|
'lg': constants.COVER_THUMBNAIL_LARGE,
|
||||||
|
}
|
||||||
|
cover_resolution = resolutions.get(resolution, None)
|
||||||
|
return get_series_cover_thumbnail(series_id, cover_resolution)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@web.route("/robots.txt")
|
@web.route("/robots.txt")
|
||||||
|
@ -1761,7 +1417,7 @@ def change_profile(kobo_support, local_oauth_check, oauth_status, translations,
|
||||||
@login_required
|
@login_required
|
||||||
def profile():
|
def profile():
|
||||||
languages = calibre_db.speaking_language()
|
languages = calibre_db.speaking_language()
|
||||||
translations = babel.list_translations() + [Locale('en')]
|
translations = get_available_locale()
|
||||||
kobo_support = feature_support['kobo'] and config.config_kobo_sync
|
kobo_support = feature_support['kobo'] and config.config_kobo_sync
|
||||||
if feature_support['oauth'] and config.config_login_type == 2:
|
if feature_support['oauth'] and config.config_login_type == 2:
|
||||||
oauth_status = get_oauth_status()
|
oauth_status = get_oauth_status()
|
||||||
|
@ -1868,10 +1524,10 @@ def show_book(book_id):
|
||||||
entry.kindle_list = check_send_to_kindle(entry)
|
entry.kindle_list = check_send_to_kindle(entry)
|
||||||
entry.reader_list = check_read_formats(entry)
|
entry.reader_list = check_read_formats(entry)
|
||||||
|
|
||||||
entry.audioentries = []
|
entry.audio_entries = []
|
||||||
for media_format in entry.data:
|
for media_format in entry.data:
|
||||||
if media_format.format.lower() in constants.EXTENSIONS_AUDIO:
|
if media_format.format.lower() in constants.EXTENSIONS_AUDIO:
|
||||||
entry.audioentries.append(media_format.format.lower())
|
entry.audio_entries.append(media_format.format.lower())
|
||||||
|
|
||||||
return render_title_template('detail.html',
|
return render_title_template('detail.html',
|
||||||
entry=entry,
|
entry=entry,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# GDrive Integration
|
# GDrive Integration
|
||||||
google-api-python-client>=1.7.11,<2.44.0
|
google-api-python-client>=1.7.11,<2.50.0
|
||||||
gevent>20.6.0,<22.0.0
|
gevent>20.6.0,<22.0.0
|
||||||
greenlet>=0.4.17,<1.2.0
|
greenlet>=0.4.17,<1.2.0
|
||||||
httplib2>=0.9.2,<0.21.0
|
httplib2>=0.9.2,<0.21.0
|
||||||
|
@ -13,7 +13,7 @@ rsa>=3.4.2,<4.9.0
|
||||||
|
|
||||||
# Gmail
|
# Gmail
|
||||||
google-auth-oauthlib>=0.4.3,<0.6.0
|
google-auth-oauthlib>=0.4.3,<0.6.0
|
||||||
google-api-python-client>=1.7.11,<2.44.0
|
google-api-python-client>=1.7.11,<2.50.0
|
||||||
|
|
||||||
# goodreads
|
# goodreads
|
||||||
goodreads>=0.3.2,<0.4.0
|
goodreads>=0.3.2,<0.4.0
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
APScheduler>=3.6.3,<3.10.0
|
||||||
werkzeug<2.1.0
|
werkzeug<2.1.0
|
||||||
Babel>=1.3,<3.0
|
Babel>=1.3,<3.0
|
||||||
Flask-Babel>=0.11.1,<2.1.0
|
Flask-Babel>=0.11.1,<2.1.0
|
||||||
|
|
|
@ -38,6 +38,7 @@ console_scripts =
|
||||||
[options]
|
[options]
|
||||||
include_package_data = True
|
include_package_data = True
|
||||||
install_requires =
|
install_requires =
|
||||||
|
APScheduler>=3.6.3,<3.10.0
|
||||||
werkzeug<2.1.0
|
werkzeug<2.1.0
|
||||||
Babel>=1.3,<3.0
|
Babel>=1.3,<3.0
|
||||||
Flask-Babel>=0.11.1,<2.1.0
|
Flask-Babel>=0.11.1,<2.1.0
|
||||||
|
@ -61,7 +62,7 @@ install_requires =
|
||||||
|
|
||||||
[options.extras_require]
|
[options.extras_require]
|
||||||
gdrive =
|
gdrive =
|
||||||
google-api-python-client>=1.7.11,<2.44.0
|
google-api-python-client>=1.7.11,<2.50.0
|
||||||
gevent>20.6.0,<22.0.0
|
gevent>20.6.0,<22.0.0
|
||||||
greenlet>=0.4.17,<1.2.0
|
greenlet>=0.4.17,<1.2.0
|
||||||
httplib2>=0.9.2,<0.21.0
|
httplib2>=0.9.2,<0.21.0
|
||||||
|
@ -74,7 +75,7 @@ gdrive =
|
||||||
rsa>=3.4.2,<4.9.0
|
rsa>=3.4.2,<4.9.0
|
||||||
gmail =
|
gmail =
|
||||||
google-auth-oauthlib>=0.4.3,<0.6.0
|
google-auth-oauthlib>=0.4.3,<0.6.0
|
||||||
google-api-python-client>=1.7.11,<2.44.0
|
google-api-python-client>=1.7.11,<2.50.0
|
||||||
goodreads =
|
goodreads =
|
||||||
goodreads>=0.3.2,<0.4.0
|
goodreads>=0.3.2,<0.4.0
|
||||||
python-Levenshtein>=0.12.0,<0.13.0
|
python-Levenshtein>=0.12.0,<0.13.0
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user