Resolve merge conflicts

This commit is contained in:
mmonkey 2021-09-16 22:58:54 -05:00
commit 04a5db5c1d
753 changed files with 106429 additions and 94299 deletions

3
.gitignore vendored
View File

@ -6,6 +6,7 @@ __pycache__/
# Distribution / packaging # Distribution / packaging
.Python .Python
.python-version
env/ env/
venv/ venv/
eggs/ eggs/
@ -31,4 +32,4 @@ cps/cache
settings.yaml settings.yaml
gdrive_credentials gdrive_credentials
client_secrets.json client_secrets.json
gmail.json

View File

@ -41,6 +41,6 @@ Open a new GitHub pull request with the patch. Ensure the PR description clearly
In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consists of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public. In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consists of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public.
Please check if your code runs on Python 2.7 (still necessary in 2020) and mainly on python 3. If possible and the feature is related to operating system functions, try to check it on Windows and Linux. Please check if your code runs with python 3, python 2 is no longer supported. If possible and the feature is related to operating system functions, try to check it on Windows and Linux.
Calibre-Web is automatically tested on Linux in combination with python 3.7. The code for testing is in a [separate repo](https://github.com/OzzieIsaacs/calibre-web-test) on Github. It uses unit tests and performs real system tests with selenium; it would be great if you could consider also writing some tests. Calibre-Web is automatically tested on Linux in combination with python 3.8. The code for testing is in a [separate repo](https://github.com/OzzieIsaacs/calibre-web-test) on Github. It uses unit tests and performs real system tests with selenium; it would be great if you could consider also writing some tests.
A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder. A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder.

View File

@ -2,6 +2,13 @@
Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using an existing [Calibre](https://calibre-ebook.com) database. Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using an existing [Calibre](https://calibre-ebook.com) database.
[![GitHub License](https://img.shields.io/github/license/janeczku/calibre-web?style=flat-square)](https://github.com/janeczku/calibre-web/blob/master/LICENSE)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/w/janeczku/calibre-web?logo=github&style=flat-square&label=commits)]()
[![GitHub all releases](https://img.shields.io/github/downloads/janeczku/calibre-web/total?logo=github&style=flat-square)](https://github.com/janeczku/calibre-web/releases)
[![PyPI](https://img.shields.io/pypi/v/calibreweb?logo=pypi&logoColor=fff&style=flat-square)](https://pypi.org/project/calibreweb/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/calibreweb?logo=pypi&logoColor=fff&style=flat-square)](https://pypi.org/project/calibreweb/)
[![Discord](https://img.shields.io/discord/838810113564344381?label=Discord&logo=discord&style=flat-square)](https://discord.gg/h2VsJ2NEfB)
*This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.* *This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.*
![Main screen](https://github.com/janeczku/calibre-web/wiki/images/main_screen.png) ![Main screen](https://github.com/janeczku/calibre-web/wiki/images/main_screen.png)
@ -12,7 +19,7 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
- full graphical setup - full graphical setup
- User management with fine-grained per-user permissions - User management with fine-grained per-user permissions
- Admin interface - Admin interface
- User Interface in czech, dutch, english, finnish, french, german, greek, hungarian, italian, japanese, khmer, polish, russian, simplified chinese, spanish, swedish, turkish, ukrainian - User Interface in brazilian, czech, dutch, english, finnish, french, german, greek, hungarian, italian, japanese, khmer, polish, russian, simplified chinese, spanish, swedish, turkish, ukrainian
- OPDS feed for eBook reader apps - OPDS feed for eBook reader apps
- Filter and search by titles, authors, tags, series and language - Filter and search by titles, authors, tags, series and language
- Create a custom book collection (shelves) - Create a custom book collection (shelves)
@ -32,12 +39,19 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
## Quick start ## Quick start
#### Install via pip
1. Install calibre web via pip with the command `pip install calibreweb` (Depending on your OS and or distro the command could also be `pip3`).
2. Optional features can also be installed via pip, please refer to [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-Windows) for details
3. Calibre-Web can be started afterwards by typing `cps` or `python3 -m cps`
#### Manual installation
1. Install dependencies by running `pip3 install --target vendor -r requirements.txt` (python3.x). Alternativly set up a python virtual environment. 1. Install dependencies by running `pip3 install --target vendor -r requirements.txt` (python3.x). Alternativly set up a python virtual environment.
2. Execute the command: `python3 cps.py` (or `nohup python3 cps.py` - recommended if you want to exit the terminal window) 2. Execute the command: `python3 cps.py` (or `nohup python3 cps.py` - recommended if you want to exit the terminal window)
3. Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
4. Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button\ Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button\
Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/Configuration#using-google-drive-integration) Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/Configuration#using-google-drive-integration)
5. Go to Login page Go to Login page
**Default admin login:**\ **Default admin login:**\
*Username:* admin\ *Username:* admin\
@ -48,7 +62,7 @@ Please note that running the above install command can fail on some versions of
## Requirements ## Requirements
python 3.x+ python 3.5+
Optionally, to enable on-the-fly conversion from one ebook format to another when using the send-to-kindle feature, or during editing of ebooks metadata: Optionally, to enable on-the-fly conversion from one ebook format to another when using the send-to-kindle feature, or during editing of ebooks metadata:
@ -80,7 +94,9 @@ Pre-built Docker images are available in these Docker Hub repositories:
+ The "path to convertertool" should be set to `/usr/bin/ebook-convert` + The "path to convertertool" should be set to `/usr/bin/ebook-convert`
+ The "path to unrar" should be set to `/usr/bin/unrar` + The "path to unrar" should be set to `/usr/bin/unrar`
# Wiki # Contact
Just reach us out on [Discord](https://discord.gg/h2VsJ2NEfB)
For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki) For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki)

5
SECURITY.md Normal file
View File

@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to ozzie.fernandez.isaacs@googlemail.com

3
cps.py
View File

@ -42,6 +42,7 @@ from cps.admin import admi
from cps.gdrive import gdrive from cps.gdrive import gdrive
from cps.editbooks import editbook from cps.editbooks import editbook
from cps.remotelogin import remotelogin from cps.remotelogin import remotelogin
from cps.search_metadata import meta
from cps.error_handler import init_errorhandler from cps.error_handler import init_errorhandler
from cps.schedule import register_jobs from cps.schedule import register_jobs
@ -71,7 +72,7 @@ def main():
app.register_blueprint(shelf) app.register_blueprint(shelf)
app.register_blueprint(admi) app.register_blueprint(admi)
app.register_blueprint(remotelogin) app.register_blueprint(remotelogin)
# if config.config_use_google_drive: app.register_blueprint(meta)
app.register_blueprint(gdrive) app.register_blueprint(gdrive)
app.register_blueprint(editbook) app.register_blueprint(editbook)
if kobo_available: if kobo_available:

View File

@ -37,6 +37,11 @@ from . import config_sql, logger, cache_buster, cli, ub, db
from .reverseproxy import ReverseProxied from .reverseproxy import ReverseProxied
from .server import WebServer from .server import WebServer
try:
import lxml
lxml_present = True
except ImportError:
lxml_present = False
mimetypes.init() mimetypes.init()
mimetypes.add_type('application/xhtml+xml', '.xhtml') mimetypes.add_type('application/xhtml+xml', '.xhtml')
@ -83,11 +88,23 @@ log = logger.create()
from . import services from . import services
db.CalibreDB.setup_db(config, cli.settingspath) db.CalibreDB.update_config(config)
db.CalibreDB.setup_db(config.config_calibre_dir, cli.settingspath)
calibre_db = db.CalibreDB() calibre_db = db.CalibreDB()
def create_app(): def create_app():
if sys.version_info < (3, 0):
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 ***')
print(
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, please update your installation to Python3 ***')
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" ***')
sys.exit(6)
app.wsgi_app = ReverseProxied(app.wsgi_app) app.wsgi_app = ReverseProxied(app.wsgi_app)
# For python2 convert path to unicode # For python2 convert path to unicode
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
@ -97,11 +114,9 @@ def create_app():
if os.environ.get('FLASK_DEBUG'): if os.environ.get('FLASK_DEBUG'):
cache_buster.init_cache_busting(app) cache_buster.init_cache_busting(app)
cache_buster.init_cache_busting(app)
log.info('Starting Calibre Web...') log.info('Starting Calibre Web...')
if sys.version_info < (3, 0):
log.info('Python2 is EOL since end of 2019, this version of Calibre-Web supporting Python2 please consider upgrading to Python3')
print('Python2 is EOL since end of 2019, this version of Calibre-Web supporting Python2 please consider upgrading to Python3')
Principal(app) Principal(app)
lm.init_app(app) lm.init_app(app)
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session)) app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
@ -126,9 +141,8 @@ def create_app():
def get_locale(): def get_locale():
# if a user is logged in, use the locale from the user settings # if a user is logged in, use the locale from the user settings
user = getattr(g, 'user', None) user = getattr(g, 'user', None)
# user = None
if user is not None and hasattr(user, "locale"): if user is not None and hasattr(user, "locale"):
if user.nickname != 'Guest': # if the account is the guest account bypass the config lang settings if user.name != 'Guest': # if the account is the guest account bypass the config lang settings
return user.locale return user.locale
preferred = list() preferred = list()
@ -147,6 +161,7 @@ def get_timezone():
user = getattr(g, 'user', None) user = getattr(g, 'user', None)
return user.timezone if user else None return user.timezone if user else None
from .updater import Updater from .updater import Updater
updater_thread = Updater() updater_thread = Updater()
updater_thread.start() updater_thread.start()

View File

@ -54,6 +54,12 @@ try:
except ImportError: except ImportError:
greenlet_Version = None greenlet_Version = None
try:
from scholarly import scholarly
scholarly_version = _(u'installed')
except ImportError:
scholarly_version = _(u'not installed')
from . import services from . import services
about = flask.Blueprint('about', __name__) about = flask.Blueprint('about', __name__)
@ -79,6 +85,7 @@ _VERSIONS = OrderedDict(
iso639=isoLanguages.__version__, iso639=isoLanguages.__version__,
pytz=pytz.__version__, pytz=pytz.__version__,
Unidecode = unidecode_version, Unidecode = unidecode_version,
Scholarly = scholarly_version,
Flask_SimpleLDAP = u'installed' if bool(services.ldap) else None, Flask_SimpleLDAP = u'installed' if bool(services.ldap) else None,
python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None, python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None,
Goodreads = u'installed' if bool(services.goodreads_support) else None, Goodreads = u'installed' if bool(services.goodreads_support) else None,

File diff suppressed because it is too large Load Diff

View File

@ -45,7 +45,7 @@ parser.add_argument('-v', '--version', action='version', help='Shows version num
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') parser.add_argument('-s', metavar='user:pass', help='Sets specific username to new password')
parser.add_argument('-f', action='store_true', help='Enables filepicker in unconfigured mode') parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
args = parser.parse_args() args = parser.parse_args()
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
@ -91,29 +91,29 @@ if (args.k and not args.c) or (not args.k and args.c):
if args.k == "": if args.k == "":
keyfilepath = "" keyfilepath = ""
# handle and check ipadress argument # handle and check ip address argument
ipadress = args.i or None ip_address = args.i or None
if ipadress: 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 ipadress: if ':' in ip_address:
socket.inet_pton(socket.AF_INET6, ipadress) socket.inet_pton(socket.AF_INET6, ip_address)
else: else:
socket.inet_pton(socket.AF_INET, ipadress) socket.inet_pton(socket.AF_INET, 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(ipadress) socket.inet_aton(ip_address)
except socket.error as err: except socket.error as err:
print(ipadress, ':', err) print(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 user_credentials = args.s or None
if user_credentials and ":" not in user_credentials: if user_credentials and ":" not in user_credentials:
print("No valid username:password format") print("No valid 'username:password' format")
sys.exit(3) sys.exit(3)
# Handles enableing of filepicker if args.f:
filepicker = args.f or None print("Warning: -f flag is depreciated and will be removed in next version")

View File

@ -105,8 +105,8 @@ def _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecu
if extension in COVER_EXTENSIONS: if extension in COVER_EXTENSIONS:
cover_data = cf.read(name) cover_data = cf.read(name)
break break
except Exception as e: except Exception as ex:
log.debug('Rarfile failed with error: %s', e) log.debug('Rarfile failed with error: %s', ex)
return cover_data return cover_data

View File

@ -20,16 +20,18 @@
from __future__ import division, print_function, unicode_literals from __future__ import division, print_function, unicode_literals
import os import os
import sys import sys
import json
from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean, BLOB, JSON from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from sqlalchemy.sql.expression import text
try: try:
# Compatibility with sqlalchemy 2.0 # Compatibility with sqlalchemy 2.0
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
except ImportError: except ImportError:
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from . import constants, cli, logger, ub from . import constants, cli, logger
log = logger.create() log = logger.create()
@ -39,7 +41,7 @@ class _Flask_Settings(_Base):
__tablename__ = 'flask_settings' __tablename__ = 'flask_settings'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
flask_session_key = Column(BLOB, default="") flask_session_key = Column(BLOB, default=b"")
def __init__(self, key): def __init__(self, key):
self.flask_session_key = key self.flask_session_key = key
@ -58,6 +60,8 @@ class _Settings(_Base):
mail_password = Column(String, default='mypassword') mail_password = Column(String, default='mypassword')
mail_from = Column(String, default='automailer <mail@example.com>') mail_from = Column(String, default='automailer <mail@example.com>')
mail_size = Column(Integer, default=25*1024*1024) mail_size = Column(Integer, default=25*1024*1024)
mail_server_type = Column(SmallInteger, default=0)
mail_gmail_token = Column(JSON, default={})
config_calibre_dir = Column(String) config_calibre_dir = Column(String)
config_port = Column(Integer, default=constants.DEFAULT_PORT) config_port = Column(Integer, default=constants.DEFAULT_PORT)
@ -129,6 +133,7 @@ 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_updatechannel = Column(Integer, default=constants.UPDATE_STABLE) config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
@ -188,7 +193,7 @@ class _ConfigSQL(object):
@staticmethod @staticmethod
def get_config_ipaddress(): def get_config_ipaddress():
return cli.ipadress 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)
@ -246,18 +251,18 @@ class _ConfigSQL(object):
return {k:v for k, v in self.__dict__.items() if k.startswith('mail_')} return {k:v for k, v in self.__dict__.items() if k.startswith('mail_')}
def get_mail_server_configured(self): def get_mail_server_configured(self):
return not bool(self.mail_server == constants.DEFAULT_MAIL_SERVER) 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))
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.
The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor. The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor.
:returns: `True` if the field has changed value :returns: `True` if the field has changed value
''' """
new_value = dictionary.get(field, default) new_value = dictionary.get(field, default)
if new_value is None: if new_value is None:
# log.debug("_ConfigSQL set_from_dictionary field '%s' not found", field)
return False return False
if field not in self.__dict__: if field not in self.__dict__:
@ -274,7 +279,6 @@ class _ConfigSQL(object):
if current_value == new_value: if current_value == new_value:
return False return False
# log.debug("_ConfigSQL set_from_dictionary '%s' = %r (was %r)", field, new_value, current_value)
setattr(self, field, new_value) setattr(self, field, new_value)
return True return True
@ -305,6 +309,9 @@ class _ConfigSQL(object):
have_metadata_db = os.path.isfile(db_file) have_metadata_db = os.path.isfile(db_file)
self.db_configured = have_metadata_db self.db_configured = have_metadata_db
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')] constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
if os.environ.get('FLASK_DEBUG'):
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
else:
# pylint: disable=access-member-before-definition # pylint: disable=access-member-before-definition
logfile = logger.setup(self.config_logfile, self.config_log_level) logfile = logger.setup(self.config_logfile, self.config_log_level)
if logfile != self.config_logfile: if logfile != self.config_logfile:
@ -341,7 +348,7 @@ class _ConfigSQL(object):
log.error(error) log.error(error)
log.warning("invalidating configuration") log.warning("invalidating configuration")
self.db_configured = False self.db_configured = False
self.config_calibre_dir = None # self.config_calibre_dir = None
self.save() self.save()
@ -352,7 +359,7 @@ def _migrate_table(session, orm_class):
if column_name[0] != '_': if column_name[0] != '_':
try: try:
session.query(column).first() session.query(column).first()
except exc.OperationalError as err: except OperationalError as err:
log.debug("%s: %s", column_name, err.args[0]) log.debug("%s: %s", column_name, err.args[0])
if column.default is not None: if column.default is not None:
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
@ -362,16 +369,23 @@ def _migrate_table(session, orm_class):
column_default = "" column_default = ""
else: else:
if isinstance(column.default.arg, bool): if isinstance(column.default.arg, bool):
column_default = ("DEFAULT %r" % int(column.default.arg)) column_default = "DEFAULT {}".format(int(column.default.arg))
else: else:
column_default = ("DEFAULT %r" % column.default.arg) column_default = "DEFAULT `{}`".format(column.default.arg)
alter_table = "ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__, if isinstance(column.type, JSON):
column_type = "JSON"
else:
column_type = column.type
alter_table = text("ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__,
column_name, column_name,
column.type, column_type,
column_default) column_default))
log.debug(alter_table) log.debug(alter_table)
session.execute(alter_table) session.execute(alter_table)
changed = True changed = True
except json.decoder.JSONDecodeError as e:
log.error("Database corrupt column: {}".format(column_name))
log.debug(e)
if changed: if changed:
try: try:
@ -430,12 +444,12 @@ def load_configuration(session):
session.commit() session.commit()
conf = _ConfigSQL(session) conf = _ConfigSQL(session)
# Migrate from global restrictions to user based restrictions # Migrate from global restrictions to user based restrictions
if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "": #if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
conf.config_denied_tags = conf.config_mature_content_tags # conf.config_denied_tags = conf.config_mature_content_tags
conf.save() # conf.save()
session.query(ub.User).filter(ub.User.mature_content != True). \ # session.query(ub.User).filter(ub.User.mature_content != True). \
update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False) # update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False)
session.commit() # session.commit()
return conf return conf
def get_flask_session_key(session): def get_flask_session_key(session):

View File

@ -20,6 +20,9 @@ from __future__ import division, print_function, unicode_literals
import sys import sys
import os import os
from collections import namedtuple from collections import namedtuple
from sqlalchemy import __version__ as sql_version
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2,0,0])
# 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'))
@ -155,7 +158,7 @@ def selected_roles(dictionary):
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, ' BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
'series_id, languages, publisher') 'series_id, languages, publisher')
STABLE_VERSION = {'version': '0.6.12 Beta'} STABLE_VERSION = {'version': '0.6.13 Beta'}
NIGHTLY_VERSION = {} NIGHTLY_VERSION = {}
NIGHTLY_VERSION[0] = '$Format:%H$' NIGHTLY_VERSION[0] = '$Format:%H$'

View File

@ -39,9 +39,9 @@ def _get_command_version(path, pattern, argument=None):
if argument: if argument:
command.append(argument) command.append(argument)
try: try:
for line in process_wait(command): match = process_wait(command, pattern=pattern)
if re.search(pattern, line): if isinstance(match, re.Match):
return line return match.string
except Exception as ex: except Exception as ex:
log.warning("%s: %s", path, ex) log.warning("%s: %s", path, ex)
return _EXECUTION_ERROR return _EXECUTION_ERROR

200
cps/db.py
View File

@ -31,6 +31,7 @@ from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
from sqlalchemy.orm import relationship, sessionmaker, scoped_session from sqlalchemy.orm import relationship, sessionmaker, scoped_session
from sqlalchemy.orm.collections import InstrumentedList from sqlalchemy.orm.collections import InstrumentedList
from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.exc import OperationalError
try: try:
# Compatibility with sqlalchemy 2.0 # Compatibility with sqlalchemy 2.0
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
@ -43,6 +44,7 @@ from flask_login import current_user
from babel import Locale as LC from babel import Locale as LC
from babel.core import UnknownLocaleError from babel.core import UnknownLocaleError
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask import flash
from . import logger, ub, isoLanguages from . import logger, ub, isoLanguages
from .pagination import Pagination from .pagination import Pagination
@ -57,7 +59,7 @@ except ImportError:
log = logger.create() log = logger.create()
cc_exceptions = ['datetime', 'comments', 'composite', 'series'] cc_exceptions = ['composite', 'series']
cc_classes = {} cc_classes = {}
Base = declarative_base() Base = declarative_base()
@ -120,6 +122,8 @@ class Identifiers(Base):
return u"Douban" return u"Douban"
elif format_type == "goodreads": elif format_type == "goodreads":
return u"Goodreads" return u"Goodreads"
elif format_type == "babelio":
return u"Babelio"
elif format_type == "google": elif format_type == "google":
return u"Google Books" return u"Google Books"
elif format_type == "kobo": elif format_type == "kobo":
@ -147,6 +151,8 @@ class Identifiers(Base):
return u"https://dx.doi.org/{0}".format(self.val) return u"https://dx.doi.org/{0}".format(self.val)
elif format_type == "goodreads": elif format_type == "goodreads":
return u"https://www.goodreads.com/book/show/{0}".format(self.val) return u"https://www.goodreads.com/book/show/{0}".format(self.val)
elif format_type == "babelio":
return u"https://www.babelio.com/livres/titre/{0}".format(self.val)
elif format_type == "douban": elif format_type == "douban":
return u"https://book.douban.com/subject/{0}".format(self.val) return u"https://book.douban.com/subject/{0}".format(self.val)
elif format_type == "google": elif format_type == "google":
@ -331,7 +337,6 @@ class Books(Base):
has_cover = Column(Integer, default=0) has_cover = Column(Integer, default=0)
uuid = Column(String) uuid = Column(String)
isbn = Column(String(collation='NOCASE'), default="") isbn = Column(String(collation='NOCASE'), default="")
# Iccn = Column(String(collation='NOCASE'), default="")
flags = Column(Integer, nullable=False, default=1) flags = Column(Integer, nullable=False, default=1)
authors = relationship('Authors', secondary=books_authors_link, backref='books') authors = relationship('Authors', secondary=books_authors_link, backref='books')
@ -393,7 +398,7 @@ class AlchemyEncoder(json.JSONEncoder):
if isinstance(o.__class__, DeclarativeMeta): if isinstance(o.__class__, DeclarativeMeta):
# an SQLAlchemy class # an SQLAlchemy class
fields = {} fields = {}
for field in [x for x in dir(o) if not x.startswith('_') and x != 'metadata']: for field in [x for x in dir(o) if not x.startswith('_') and x != 'metadata' and x!="password"]:
if field == 'books': if field == 'books':
continue continue
data = o.__getattribute__(field) data = o.__getattribute__(field)
@ -442,49 +447,13 @@ class CalibreDB():
self.instances.add(self) self.instances.add(self)
def initSession(self, expire_on_commit=True): def initSession(self, expire_on_commit=True):
self.session = self.session_factory() self.session = self.session_factory()
self.session.expire_on_commit = expire_on_commit self.session.expire_on_commit = expire_on_commit
self.update_title_sort(self.config) self.update_title_sort(self.config)
@classmethod @classmethod
def setup_db(cls, config, app_db_path): def setup_db_cc_classes(self, cc):
cls.config = config
cls.dispose()
# toDo: if db changed -> delete shelfs, delete download books, delete read boks, kobo sync??
if not config.config_calibre_dir:
config.invalidate()
return False
dbpath = os.path.join(config.config_calibre_dir, "metadata.db")
if not os.path.exists(dbpath):
config.invalidate()
return False
try:
cls.engine = create_engine('sqlite://',
echo=False,
isolation_level="SERIALIZABLE",
connect_args={'check_same_thread': False},
poolclass=StaticPool)
with cls.engine.begin() as connection:
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
conn = cls.engine.connect()
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
except Exception as e:
config.invalidate(e)
return False
config.db_configured = True
if not cc_classes:
cc = conn.execute(text("SELECT id, datatype FROM custom_columns"))
cc_ids = [] cc_ids = []
books_custom_column_links = {} books_custom_column_links = {}
for row in cc: for row in cc:
@ -504,7 +473,7 @@ class CalibreDB():
} }
books_custom_column_links[row.id] = type(str('books_custom_column_' + str(row.id) + '_link'), books_custom_column_links[row.id] = type(str('books_custom_column_' + str(row.id) + '_link'),
(Base,), dicttable) (Base,), dicttable)
else: if row.datatype in ['rating', 'text', 'enumeration']:
books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link', books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link',
Base.metadata, Base.metadata,
Column('book', Integer, ForeignKey('books.id'), Column('book', Integer, ForeignKey('books.id'),
@ -522,23 +491,25 @@ class CalibreDB():
ccdict['value'] = Column(Float) ccdict['value'] = Column(Float)
elif row.datatype == 'int': elif row.datatype == 'int':
ccdict['value'] = Column(Integer) ccdict['value'] = Column(Integer)
elif row.datatype == 'datetime':
ccdict['value'] = Column(TIMESTAMP)
elif row.datatype == 'bool': elif row.datatype == 'bool':
ccdict['value'] = Column(Boolean) ccdict['value'] = Column(Boolean)
else: else:
ccdict['value'] = Column(String) ccdict['value'] = Column(String)
if row.datatype in ['float', 'int', 'bool']: if row.datatype in ['float', 'int', 'bool', 'datetime', 'comments']:
ccdict['book'] = Column(Integer, ForeignKey('books.id')) ccdict['book'] = Column(Integer, ForeignKey('books.id'))
cc_classes[row.id] = type(str('custom_column_' + str(row.id)), (Base,), ccdict) cc_classes[row.id] = type(str('custom_column_' + str(row.id)), (Base,), ccdict)
for cc_id in cc_ids: for cc_id in cc_ids:
if (cc_id[1] == 'bool') or (cc_id[1] == 'int') or (cc_id[1] == 'float'): if cc_id[1] in ['bool', 'int', 'float', 'datetime', 'comments']:
setattr(Books, setattr(Books,
'custom_column_' + str(cc_id[0]), 'custom_column_' + str(cc_id[0]),
relationship(cc_classes[cc_id[0]], relationship(cc_classes[cc_id[0]],
primaryjoin=( primaryjoin=(
Books.id == cc_classes[cc_id[0]].book), Books.id == cc_classes[cc_id[0]].book),
backref='books')) backref='books'))
elif (cc_id[1] == 'series'): elif cc_id[1] == 'series':
setattr(Books, setattr(Books,
'custom_column_' + str(cc_id[0]), 'custom_column_' + str(cc_id[0]),
relationship(books_custom_column_links[cc_id[0]], relationship(books_custom_column_links[cc_id[0]],
@ -550,6 +521,74 @@ class CalibreDB():
secondary=books_custom_column_links[cc_id[0]], secondary=books_custom_column_links[cc_id[0]],
backref='books')) backref='books'))
return cc_classes
@classmethod
def check_valid_db(cls, config_calibre_dir, app_db_path):
if not config_calibre_dir:
return False
dbpath = os.path.join(config_calibre_dir, "metadata.db")
if not os.path.exists(dbpath):
return False
try:
check_engine = create_engine('sqlite://',
echo=False,
isolation_level="SERIALIZABLE",
connect_args={'check_same_thread': False},
poolclass=StaticPool)
with check_engine.begin() as connection:
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
check_engine.connect()
except Exception:
return False
return True
@classmethod
def update_config(cls, config):
cls.config = config
@classmethod
def setup_db(cls, config_calibre_dir, app_db_path):
# cls.config = config
cls.dispose()
# toDo: if db changed -> delete shelfs, delete download books, delete read boks, kobo sync??
if not config_calibre_dir:
cls.config.invalidate()
return False
dbpath = os.path.join(config_calibre_dir, "metadata.db")
if not os.path.exists(dbpath):
cls.config.invalidate()
return False
try:
cls.engine = create_engine('sqlite://',
echo=False,
isolation_level="SERIALIZABLE",
connect_args={'check_same_thread': False},
poolclass=StaticPool)
with cls.engine.begin() as connection:
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
conn = cls.engine.connect()
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
except Exception as ex:
cls.config.invalidate(ex)
return False
cls.config.db_configured = True
if not cc_classes:
try:
cc = conn.execute(text("SELECT id, datatype FROM custom_columns"))
cls.setup_db_cc_classes(cc)
except OperationalError as e:
log.debug_or_exception(e)
cls.session_factory = scoped_session(sessionmaker(autocommit=False, cls.session_factory = scoped_session(sessionmaker(autocommit=False,
autoflush=True, autoflush=True,
bind=cls.engine)) bind=cls.engine))
@ -595,6 +634,7 @@ class CalibreDB():
neg_content_tags_filter = false() if negtags_list == [''] else Books.tags.any(Tags.name.in_(negtags_list)) neg_content_tags_filter = false() if negtags_list == [''] else Books.tags.any(Tags.name.in_(negtags_list))
pos_content_tags_filter = true() if postags_list == [''] else Books.tags.any(Tags.name.in_(postags_list)) pos_content_tags_filter = true() if postags_list == [''] else Books.tags.any(Tags.name.in_(postags_list))
if self.config.config_restricted_column: if self.config.config_restricted_column:
try:
pos_cc_list = current_user.allowed_column_value.split(',') pos_cc_list = current_user.allowed_column_value.split(',')
pos_content_cc_filter = true() if pos_cc_list == [''] else \ pos_content_cc_filter = true() if pos_cc_list == [''] else \
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \ getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
@ -603,12 +643,37 @@ class CalibreDB():
neg_content_cc_filter = false() if neg_cc_list == [''] else \ neg_content_cc_filter = false() if neg_cc_list == [''] else \
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \ getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list)) any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list))
except (KeyError, AttributeError):
pos_content_cc_filter = false()
neg_content_cc_filter = true()
log.error(u"Custom Column No.%d is not existing in calibre database",
self.config.config_restricted_column)
flash(_("Custom Column No.%(column)d is not existing in calibre database",
column=self.config.config_restricted_column),
category="error")
else: else:
pos_content_cc_filter = true() pos_content_cc_filter = true()
neg_content_cc_filter = false() neg_content_cc_filter = false()
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter, return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
pos_content_cc_filter, ~neg_content_cc_filter, archived_filter) pos_content_cc_filter, ~neg_content_cc_filter, archived_filter)
@staticmethod
def get_checkbox_sorted(inputlist, state, offset, limit, order):
outcome = list()
elementlist = {ele.id: ele for ele in inputlist}
for entry in state:
try:
outcome.append(elementlist[entry])
except KeyError:
pass
del elementlist[entry]
for entry in elementlist:
outcome.append(elementlist[entry])
if order == "asc":
outcome.reverse()
return outcome[offset:offset + limit]
# Fill indexpage with all requested data from database # Fill indexpage with all requested data from database
def fill_indexpage(self, page, pagesize, database, db_filter, order, *join): def fill_indexpage(self, page, pagesize, database, db_filter, order, *join):
return self.fill_indexpage_with_archived_books(page, pagesize, database, db_filter, order, False, *join) return self.fill_indexpage_with_archived_books(page, pagesize, database, db_filter, order, False, *join)
@ -626,10 +691,18 @@ class CalibreDB():
randm = false() randm = false()
off = int(int(pagesize) * (page - 1)) off = int(int(pagesize) * (page - 1))
query = self.session.query(database) query = self.session.query(database)
if len(join) == 6:
query = query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4]).outerjoin(join[5])
if len(join) == 5:
query = query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4])
if len(join) == 4:
query = query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3])
if len(join) == 3: if len(join) == 3:
query = query.join(join[0], join[1]).join(join[2], isouter=True) query = query.outerjoin(join[0], join[1]).outerjoin(join[2])
elif len(join) == 2: elif len(join) == 2:
query = query.join(join[0], join[1], isouter=True) query = query.outerjoin(join[0], join[1])
elif len(join) == 1:
query = query.outerjoin(join[0])
query = query.filter(db_filter)\ query = query.filter(db_filter)\
.filter(self.common_filters(allow_show_archived)) .filter(self.common_filters(allow_show_archived))
entries = list() entries = list()
@ -638,8 +711,8 @@ class CalibreDB():
pagination = Pagination(page, pagesize, pagination = Pagination(page, pagesize,
len(query.all())) len(query.all()))
entries = query.order_by(*order).offset(off).limit(pagesize).all() entries = query.order_by(*order).offset(off).limit(pagesize).all()
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
#for book in entries: #for book in entries:
# book = self.order_authors(book) # book = self.order_authors(book)
return entries, randm, pagination return entries, randm, pagination
@ -681,23 +754,35 @@ class CalibreDB():
return self.session.query(Books) \ return self.session.query(Books) \
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first() .filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
# read search results from calibre-database and return it (function is used for feed and simple search def search_query(self, term, *join):
def get_search_results(self, term, offset=None, order=None, limit=None):
order = order or [Books.sort]
pagination = None
term.strip().lower() term.strip().lower()
self.session.connection().connection.connection.create_function("lower", 1, lcase) self.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list() q = list()
authorterms = re.split("[, ]+", term) authorterms = re.split("[, ]+", term)
for authorterm in authorterms: for authorterm in authorterms:
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%"))) q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%")))
result = self.session.query(Books).filter(self.common_filters(True)).filter( query = self.session.query(Books)
if len(join) == 6:
query = query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4]).outerjoin(join[5])
if len(join) == 3:
query = query.outerjoin(join[0], join[1]).outerjoin(join[2])
elif len(join) == 2:
query = query.outerjoin(join[0], join[1])
elif len(join) == 1:
query = query.outerjoin(join[0])
return query.filter(self.common_filters(True)).filter(
or_(Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")), or_(Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")),
Books.series.any(func.lower(Series.name).ilike("%" + term + "%")), Books.series.any(func.lower(Series.name).ilike("%" + term + "%")),
Books.authors.any(and_(*q)), Books.authors.any(and_(*q)),
Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")), Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")),
func.lower(Books.title).ilike("%" + term + "%") func.lower(Books.title).ilike("%" + term + "%")
)).order_by(*order).all() ))
# read search results from calibre-database and return it (function is used for feed and simple search
def get_search_results(self, term, offset=None, order=None, limit=None, *join):
order = order or [Books.sort]
pagination = None
result = self.search_query(term, *join).order_by(*order).all()
result_count = len(result) result_count = len(result)
if offset != None and limit != None: if offset != None and limit != None:
offset = int(offset) offset = int(offset)
@ -777,13 +862,14 @@ class CalibreDB():
def reconnect_db(self, config, app_db_path): def reconnect_db(self, config, app_db_path):
self.dispose() self.dispose()
self.engine.dispose() self.engine.dispose()
self.setup_db(config, app_db_path) self.setup_db(config.config_calibre_dir, app_db_path)
self.update_config(config)
def lcase(s): def lcase(s):
try: try:
return unidecode.unidecode(s.lower()) return unidecode.unidecode(s.lower())
except Exception as e: except Exception as ex:
log = logger.create() log = logger.create()
log.debug_or_exception(e) log.debug_or_exception(ex)
return s.lower() return s.lower()

View File

@ -22,14 +22,10 @@ import glob
import zipfile import zipfile
import json import json
from io import BytesIO from io import BytesIO
try:
from StringIO import StringIO
except ImportError:
from io import StringIO
import os import os
from flask import send_file from flask import send_file, __version__
from . import logger, config from . import logger, config
from .about import collect_stats from .about import collect_stats
@ -38,14 +34,20 @@ log = logger.create()
def assemble_logfiles(file_name): def assemble_logfiles(file_name):
log_list = sorted(glob.glob(file_name + '*'), reverse=True) log_list = sorted(glob.glob(file_name + '*'), reverse=True)
wfd = StringIO() wfd = BytesIO()
for f in log_list: for f in log_list:
with open(f, 'r') as fd: with open(f, 'rb') as fd:
shutil.copyfileobj(fd, wfd) shutil.copyfileobj(fd, wfd)
wfd.seek(0) wfd.seek(0)
if int(__version__.split('.')[0]) < 2:
return send_file(wfd, return send_file(wfd,
as_attachment=True, as_attachment=True,
attachment_filename=os.path.basename(file_name)) attachment_filename=os.path.basename(file_name))
else:
return send_file(wfd,
as_attachment=True,
download_name=os.path.basename(file_name))
def send_debug(): def send_debug():
file_list = glob.glob(logger.get_logfile(config.config_logfile) + '*') file_list = glob.glob(logger.get_logfile(config.config_logfile) + '*')
@ -60,6 +62,11 @@ def send_debug():
for fp in file_list: for fp in file_list:
zf.write(fp, os.path.basename(fp)) zf.write(fp, os.path.basename(fp))
memory_zip.seek(0) memory_zip.seek(0)
if int(__version__.split('.')[0]) < 2:
return send_file(memory_zip, return send_file(memory_zip,
as_attachment=True, as_attachment=True,
attachment_filename="Calibre-Web-debug-pack.zip") attachment_filename="Calibre-Web-debug-pack.zip")
else:
return send_file(memory_zip,
as_attachment=True,
download_name="Calibre-Web-debug-pack.zip")

View File

@ -26,7 +26,22 @@ from datetime import datetime
import json import json
from shutil import copyfile from shutil import copyfile
from uuid import uuid4 from uuid import uuid4
from markupsafe import escape
try:
from lxml.html.clean import clean_html
except ImportError:
pass
# Improve this to check if scholarly is available in a global way, like other pythonic libraries
try:
from scholarly import scholarly
have_scholar = True
except ImportError:
have_scholar = False
from babel import Locale as LC
from babel.core import UnknownLocaleError
from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import current_user, login_required from flask_login import current_user, login_required
@ -46,6 +61,8 @@ except ImportError:
pass # We're not using Python 3 pass # We're not using Python 3
editbook = Blueprint('editbook', __name__) editbook = Blueprint('editbook', __name__)
log = logger.create() log = logger.create()
@ -68,17 +85,7 @@ def edit_required(f):
return inner return inner
def search_objects_remove(db_book_object, db_type, input_elements):
# Modifies different Database objects, first check if elements have to be added to database, than check
# if elements have to be deleted, because they are no longer used
def modify_database_object(input_elements, db_book_object, db_object, db_session, db_type):
# passing input_elements not as a list may lead to undesired results
if not isinstance(input_elements, list):
raise TypeError(str(input_elements) + " should be passed as a list")
changed = False
input_elements = [x for x in input_elements if x != '']
# we have all input element (authors, series, tags) names now
# 1. search for elements to remove
del_elements = [] del_elements = []
for c_elements in db_book_object: for c_elements in db_book_object:
found = False found = False
@ -96,7 +103,10 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
# if the element was not found in the new list, add it to remove list # if the element was not found in the new list, add it to remove list
if not found: if not found:
del_elements.append(c_elements) del_elements.append(c_elements)
# 2. search for elements that need to be added return del_elements
def search_objects_add(db_book_object, db_type, input_elements):
add_elements = [] add_elements = []
for inp_element in input_elements: for inp_element in input_elements:
found = False found = False
@ -112,15 +122,21 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
break break
if not found: if not found:
add_elements.append(inp_element) add_elements.append(inp_element)
# if there are elements to remove, we remove them now return add_elements
def remove_objects(db_book_object, db_session, del_elements):
changed = False
if len(del_elements) > 0: if len(del_elements) > 0:
for del_element in del_elements: for del_element in del_elements:
db_book_object.remove(del_element) db_book_object.remove(del_element)
changed = True changed = True
if len(del_element.books) == 0: if len(del_element.books) == 0:
db_session.delete(del_element) db_session.delete(del_element)
# if there are elements to add, we add them now! return changed
if len(add_elements) > 0:
def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
changed = False
if db_type == 'languages': if db_type == 'languages':
db_filter = db_object.lang_code db_filter = db_object.lang_code
elif db_type == 'custom': elif db_type == 'custom':
@ -147,9 +163,18 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
db_session.add(new_element) db_session.add(new_element)
db_book_object.append(new_element) db_book_object.append(new_element)
else: else:
db_element = create_objects_for_addition(db_element, add_element, db_type)
changed = True
# add element to book
changed = True
db_book_object.append(db_element)
return changed
def create_objects_for_addition(db_element, add_element, db_type):
if db_type == 'custom': if db_type == 'custom':
if db_element.value != add_element: if db_element.value != add_element:
new_element.value = add_element db_element.value = add_element # ToDo: Before new_element, but this is not plausible
elif db_type == 'languages': elif db_type == 'languages':
if db_element.lang_code != add_element: if db_element.lang_code != add_element:
db_element.lang_code = add_element db_element.lang_code = add_element
@ -167,9 +192,26 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
db_element.sort = None db_element.sort = None
elif db_element.name != add_element: elif db_element.name != add_element:
db_element.name = add_element db_element.name = add_element
# add element to book return db_element
changed = True
db_book_object.append(db_element)
# Modifies different Database objects, first check if elements if elements have to be deleted,
# because they are no longer used, than check if elements have to be added to database
def modify_database_object(input_elements, db_book_object, db_object, db_session, db_type):
# passing input_elements not as a list may lead to undesired results
if not isinstance(input_elements, list):
raise TypeError(str(input_elements) + " should be passed as a list")
input_elements = [x for x in input_elements if x != '']
# we have all input element (authors, series, tags) names now
# 1. search for elements to remove
del_elements = search_objects_remove(db_book_object, db_type, input_elements)
# 2. search for elements that need to be added
add_elements = search_objects_add(db_book_object, db_type, input_elements)
# if there are elements to remove, we remove them now
changed = remove_objects(db_book_object, db_session, del_elements)
# if there are elements to add, we add them now!
if len(add_elements) > 0:
changed |= add_objects(db_book_object, db_object, db_session, db_type, add_elements)
return changed return changed
@ -288,19 +330,19 @@ def delete_book(book_id, book_format, jsonResponse):
result, error = helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper()) result, error = helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper())
if not result: if not result:
if jsonResponse: if jsonResponse:
return json.dumps({"location": url_for("editbook.edit_book"), return json.dumps([{"location": url_for("editbook.edit_book", book_id=book_id),
"type": "alert", "type": "danger",
"format": "", "format": "",
"error": error}), "message": error}])
else: else:
flash(error, category="error") flash(error, category="error")
return redirect(url_for('editbook.edit_book', book_id=book_id)) return redirect(url_for('editbook.edit_book', book_id=book_id))
if error: if error:
if jsonResponse: if jsonResponse:
warning = {"location": url_for("editbook.edit_book"), warning = {"location": url_for("editbook.edit_book", book_id=book_id),
"type": "warning", "type": "warning",
"format": "", "format": "",
"error": error} "message": error}
else: else:
flash(error, category="warning") flash(error, category="warning")
if not book_format: if not book_format:
@ -309,9 +351,18 @@ def delete_book(book_id, book_format, jsonResponse):
calibre_db.session.query(db.Data).filter(db.Data.book == book.id).\ calibre_db.session.query(db.Data).filter(db.Data.book == book.id).\
filter(db.Data.format == book_format).delete() filter(db.Data.format == book_format).delete()
calibre_db.session.commit() calibre_db.session.commit()
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
calibre_db.session.rollback() calibre_db.session.rollback()
if jsonResponse:
return json.dumps([{"location": url_for("editbook.edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": ex}])
else:
flash(str(ex), category="error")
return redirect(url_for('editbook.edit_book', book_id=book_id))
else: else:
# book not found # book not found
log.error('Book with id "%s" could not be deleted: not found', book_id) log.error('Book with id "%s" could not be deleted: not found', book_id)
@ -322,7 +373,7 @@ def render_edit_book(book_id):
cc = calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all() cc = calibre_db.session.query(db.Custom_Columns).filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).all()
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
if not book: if not book:
flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error") flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"), category="error")
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
for lang in book.languages: for lang in book.languages:
@ -403,6 +454,9 @@ def edit_book_series_index(series_index, book):
# Add default series_index to book # Add default series_index to book
modif_date = False modif_date = False
series_index = series_index or '1' series_index = series_index or '1'
if not series_index.replace('.', '', 1).isdigit():
flash(_("%(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning")
return False
if book.series_index != series_index: if book.series_index != series_index:
book.series_index = series_index book.series_index = series_index
modif_date = True modif_date = True
@ -411,6 +465,8 @@ def edit_book_series_index(series_index, book):
# Handle book comments/description # Handle book comments/description
def edit_book_comments(comments, book): def edit_book_comments(comments, book):
modif_date = False modif_date = False
if comments:
comments = clean_html(comments)
if len(book.comments): if len(book.comments):
if book.comments[0].text != comments: if book.comments[0].text != comments:
book.comments[0].text = comments book.comments[0].text = comments
@ -422,7 +478,7 @@ def edit_book_comments(comments, book):
return modif_date return modif_date
def edit_book_languages(languages, book, upload=False): def edit_book_languages(languages, book, upload=False, invalid=None):
input_languages = languages.split(',') input_languages = languages.split(',')
unknown_languages = [] unknown_languages = []
if not upload: if not upload:
@ -431,6 +487,9 @@ def edit_book_languages(languages, book, upload=False):
input_l = isoLanguages.get_valid_language_codes(get_locale(), input_languages, unknown_languages) input_l = isoLanguages.get_valid_language_codes(get_locale(), input_languages, unknown_languages)
for l in unknown_languages: for l in unknown_languages:
log.error('%s is not a valid language', l) log.error('%s is not a valid language', l)
if isinstance(invalid, list):
invalid.append(l)
else:
flash(_(u"%(langname)s is not a valid language", langname=l), category="warning") flash(_(u"%(langname)s is not a valid language", langname=l), category="warning")
# ToDo: Not working correct # ToDo: Not working correct
if upload and len(input_l) == 1: if upload and len(input_l) == 1:
@ -456,12 +515,21 @@ def edit_book_publisher(publishers, book):
return changed return changed
def edit_cc_data_number(book_id, book, c, to_save, cc_db_value, cc_string): def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
changed = False changed = False
if to_save[cc_string] == 'None': if to_save[cc_string] == 'None':
to_save[cc_string] = None to_save[cc_string] = None
elif c.datatype == 'bool': elif c.datatype == 'bool':
to_save[cc_string] = 1 if to_save[cc_string] == 'True' else 0 to_save[cc_string] = 1 if to_save[cc_string] == 'True' else 0
elif c.datatype == 'comments':
to_save[cc_string] = Markup(to_save[cc_string]).unescape()
if to_save[cc_string]:
to_save[cc_string] = clean_html(to_save[cc_string])
elif c.datatype == 'datetime':
try:
to_save[cc_string] = datetime.strptime(to_save[cc_string], "%Y-%m-%d")
except ValueError:
to_save[cc_string] = db.Books.DEFAULT_PUBDATE
if to_save[cc_string] != cc_db_value: if to_save[cc_string] != cc_db_value:
if cc_db_value is not None: if cc_db_value is not None:
@ -520,8 +588,8 @@ def edit_cc_data(book_id, book, to_save):
else: else:
cc_db_value = None cc_db_value = None
if to_save[cc_string].strip(): if to_save[cc_string].strip():
if c.datatype == 'int' or c.datatype == 'bool' or c.datatype == 'float': if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
changed, to_save = edit_cc_data_number(book_id, book, c, to_save, cc_db_value, cc_string) changed, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
else: else:
changed, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string) changed, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string)
else: else:
@ -596,9 +664,9 @@ def upload_single_file(request, book, book_id):
return redirect(url_for('web.show_book', book_id=book.id)) return redirect(url_for('web.show_book', book_id=book.id))
# Queue uploader info # Queue uploader info
uploadText=_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=book.title) link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title))
WorkerThread.add(current_user.nickname, TaskUpload( uploadText=_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link)
"<a href=\"" + url_for('web.show_book', book_id=book.id) + "\">" + uploadText + "</a>")) WorkerThread.add(current_user.name, TaskUpload(uploadText))
return uploader.process( return uploader.process(
saved_filename, *os.path.splitext(requested_file.filename), saved_filename, *os.path.splitext(requested_file.filename),
@ -622,6 +690,46 @@ def upload_cover(request, book):
return None return None
def handle_title_on_edit(book, book_title):
# handle book title
book_title = book_title.rstrip().strip()
if book.title != book_title:
if book_title == '':
book_title = _(u'Unknown')
book.title = book_title
return True
return False
def handle_author_on_edit(book, author_name, update_stored=True):
# handle author(s)
input_authors = author_name.split('&')
input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors))
# Remove duplicates in authors list
input_authors = helper.uniq(input_authors)
# we have all author names now
if input_authors == ['']:
input_authors = [_(u'Unknown')] # prevent empty Author
change = modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
# Search for each author if author is in database, if not, author name and sorted author name is generated new
# everything then is assembled for sorted author field in database
sort_authors_list = list()
for inp in input_authors:
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
if not stored_author:
stored_author = helper.get_sorted_author(inp)
else:
stored_author = stored_author.sort
sort_authors_list.append(helper.get_sorted_author(stored_author))
sort_authors = ' & '.join(sort_authors_list)
if book.author_sort != sort_authors and update_stored:
book.author_sort = sort_authors
change = True
return input_authors, change
@editbook.route("/admin/book/<int:book_id>", methods=['GET', 'POST']) @editbook.route("/admin/book/<int:book_id>", methods=['GET', 'POST'])
@login_required_if_no_ano @login_required_if_no_ano
@edit_required @edit_required
@ -639,12 +747,11 @@ def edit_book(book_id):
if request.method != 'POST': if request.method != 'POST':
return render_edit_book(book_id) return render_edit_book(book_id)
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# Book not found # Book not found
if not book: if not book:
flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error") flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"), category="error")
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
meta = upload_single_file(request, book, book_id) meta = upload_single_file(request, book, book_id)
@ -658,40 +765,13 @@ def edit_book(book_id):
edited_books_id = None edited_books_id = None
# handle book title # handle book title
if book.title != to_save["book_title"].rstrip().strip(): title_change = handle_title_on_edit(book, to_save["book_title"])
if to_save["book_title"] == '':
to_save["book_title"] = _(u'Unknown') input_authors, authorchange = handle_author_on_edit(book, to_save["author_name"])
book.title = to_save["book_title"].rstrip().strip() if authorchange or title_change:
edited_books_id = book.id edited_books_id = book.id
modif_date = True modif_date = True
# handle author(s)
input_authors = to_save["author_name"].split('&')
input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors))
# Remove duplicates in authors list
input_authors = helper.uniq(input_authors)
# we have all author names now
if input_authors == ['']:
input_authors = [_(u'Unknown')] # prevent empty Author
modif_date |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
# Search for each author if author is in database, if not, authorname and sorted authorname is generated new
# everything then is assembled for sorted author field in database
sort_authors_list = list()
for inp in input_authors:
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
if not stored_author:
stored_author = helper.get_sorted_author(inp)
else:
stored_author = stored_author.sort
sort_authors_list.append(helper.get_sorted_author(stored_author))
sort_authors = ' & '.join(sort_authors_list)
if book.author_sort != sort_authors:
edited_books_id = book.id
book.author_sort = sort_authors
modif_date = True
if config.config_use_google_drive: if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal() gdriveutils.updateGdriveCalibreFromLocal()
@ -717,10 +797,8 @@ def edit_book(book_id):
# Add default series_index to book # Add default series_index to book
modif_date |= edit_book_series_index(to_save["series_index"], book) modif_date |= edit_book_series_index(to_save["series_index"], book)
# Handle book comments/description # Handle book comments/description
modif_date |= edit_book_comments(to_save["description"], book) modif_date |= edit_book_comments(Markup(to_save['description']).unescape(), book)
# Handle identifiers # Handle identifiers
input_identifiers = identifier_list(to_save, book) input_identifiers = identifier_list(to_save, book)
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session) modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
@ -729,9 +807,16 @@ def edit_book(book_id):
modif_date |= modification modif_date |= modification
# Handle book tags # Handle book tags
modif_date |= edit_book_tags(to_save['tags'], book) modif_date |= edit_book_tags(to_save['tags'], book)
# Handle book series # Handle book series
modif_date |= edit_book_series(to_save["series"], book) modif_date |= edit_book_series(to_save["series"], book)
# handle book publisher
modif_date |= edit_book_publisher(to_save['publisher'], book)
# handle book languages
modif_date |= edit_book_languages(to_save['languages'], book)
# handle book ratings
modif_date |= edit_book_ratings(to_save, book)
# handle cc data
modif_date |= edit_cc_data(book_id, book, to_save)
if to_save["pubdate"]: if to_save["pubdate"]:
try: try:
@ -741,18 +826,6 @@ def edit_book(book_id):
else: else:
book.pubdate = db.Books.DEFAULT_PUBDATE book.pubdate = db.Books.DEFAULT_PUBDATE
# handle book publisher
modif_date |= edit_book_publisher(to_save['publisher'], book)
# handle book languages
modif_date |= edit_book_languages(to_save['languages'], book)
# handle book ratings
modif_date |= edit_book_ratings(to_save, book)
# handle cc data
modif_date |= edit_cc_data(book_id, book, to_save)
if modif_date: if modif_date:
book.last_modified = datetime.utcnow() book.last_modified = datetime.utcnow()
calibre_db.session.merge(book) calibre_db.session.merge(book)
@ -768,8 +841,8 @@ def edit_book(book_id):
calibre_db.session.rollback() calibre_db.session.rollback()
flash(error, category="error") flash(error, category="error")
return render_edit_book(book_id) return render_edit_book(book_id)
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
calibre_db.session.rollback() calibre_db.session.rollback()
flash(_("Error editing book, please check logfile for details"), category="error") flash(_("Error editing book, please check logfile for details"), category="error")
return redirect(url_for('web.show_book', book_id=book.id)) return redirect(url_for('web.show_book', book_id=book.id))
@ -885,20 +958,7 @@ def create_book_on_upload(modif_date, meta):
calibre_db.session.flush() calibre_db.session.flush()
return db_book, input_authors, title_dir return db_book, input_authors, title_dir
@editbook.route("/upload", methods=["GET", "POST"]) def file_handling_on_upload(requested_file):
@login_required_if_no_ano
@upload_required
def upload():
if not config.config_uploading:
abort(404)
if request.method == 'POST' and 'btn-upload' in request.files:
for requested_file in request.files.getlist("btn-upload"):
try:
modif_date = False
# create the function for sorting...
calibre_db.update_title_sort(config)
calibre_db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4()))
# check if file extension is correct # check if file extension is correct
if '.' in requested_file.filename: if '.' in requested_file.filename:
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower() file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
@ -906,10 +966,10 @@ def upload():
flash( flash(
_("File extension '%(ext)s' is not allowed to be uploaded to this server", _("File extension '%(ext)s' is not allowed to be uploaded to this server",
ext=file_ext), category="error") ext=file_ext), category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
else: else:
flash(_('File to be uploaded must have an extension'), category="error") flash(_('File to be uploaded must have an extension'), category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
# extract metadata from file # extract metadata from file
try: try:
@ -918,22 +978,11 @@ def upload():
log.error("File %s could not saved to temp dir", requested_file.filename) log.error("File %s could not saved to temp dir", requested_file.filename)
flash(_(u"File %(filename)s could not saved to temp dir", flash(_(u"File %(filename)s could not saved to temp dir",
filename=requested_file.filename), category="error") filename=requested_file.filename), category="error")
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
return meta, None
db_book, input_authors, title_dir = create_book_on_upload(modif_date, meta)
# Comments needs book id therfore only possible after flush
modif_date |= edit_book_comments(Markup(meta.description).unescape(), db_book)
book_id = db_book.id
title = db_book.title
error = helper.update_dir_structure_file(book_id,
config.config_calibre_dir,
input_authors[0],
meta.file_path,
title_dir + meta.extension)
def move_coverfile(meta, db_book):
# move cover to final directory, including book id # move cover to final directory, including book id
if meta.cover: if meta.cover:
coverfile = meta.cover coverfile = meta.cover
@ -950,6 +999,41 @@ def upload():
error=e), error=e),
category="error") category="error")
@editbook.route("/upload", methods=["GET", "POST"])
@login_required_if_no_ano
@upload_required
def upload():
if not config.config_uploading:
abort(404)
if request.method == 'POST' and 'btn-upload' in request.files:
for requested_file in request.files.getlist("btn-upload"):
try:
modif_date = False
# create the function for sorting...
calibre_db.update_title_sort(config)
calibre_db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4()))
meta, error = file_handling_on_upload(requested_file)
if error:
return error
db_book, input_authors, title_dir = create_book_on_upload(modif_date, meta)
# Comments needs book id therefore only possible after flush
modif_date |= edit_book_comments(Markup(meta.description).unescape(), db_book)
book_id = db_book.id
title = db_book.title
error = helper.update_dir_structure_file(book_id,
config.config_calibre_dir,
input_authors[0],
meta.file_path,
title_dir + meta.extension.lower())
move_coverfile(meta, db_book)
# save data to database, reread data # save data to database, reread data
calibre_db.session.commit() calibre_db.session.commit()
@ -957,9 +1041,9 @@ def upload():
gdriveutils.updateGdriveCalibreFromLocal() gdriveutils.updateGdriveCalibreFromLocal()
if error: if error:
flash(error, category="error") flash(error, category="error")
uploadText=_(u"File %(file)s uploaded", file=title) link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(title))
WorkerThread.add(current_user.nickname, TaskUpload( uploadText = _(u"File %(file)s uploaded", file=link)
"<a href=\"" + url_for('web.show_book', book_id=book_id) + "\">" + uploadText + "</a>")) WorkerThread.add(current_user.name, TaskUpload(uploadText))
if len(request.files.getlist("btn-upload")) < 2: if len(request.files.getlist("btn-upload")) < 2:
if current_user.role_edit() or current_user.role_admin(): if current_user.role_edit() or current_user.role_admin():
@ -988,7 +1072,7 @@ def convert_bookformat(book_id):
log.info('converting: book id: %s from: %s to: %s', book_id, book_format_from, book_format_to) log.info('converting: book id: %s from: %s to: %s', book_id, book_format_from, book_format_to)
rtn = helper.convert_book_format(book_id, config.config_calibre_dir, book_format_from.upper(), rtn = helper.convert_book_format(book_id, config.config_calibre_dir, book_format_from.upper(),
book_format_to.upper(), current_user.nickname) book_format_to.upper(), current_user.name)
if rtn is None: if rtn is None:
flash(_(u"Book successfully queued for converting to %(book_format)s", flash(_(u"Book successfully queued for converting to %(book_format)s",
@ -998,61 +1082,110 @@ def convert_bookformat(book_id):
flash(_(u"There was an error converting this book: %(res)s", res=rtn), category="error") flash(_(u"There was an error converting this book: %(res)s", res=rtn), category="error")
return redirect(url_for('editbook.edit_book', book_id=book_id)) return redirect(url_for('editbook.edit_book', book_id=book_id))
@editbook.route("/scholarsearch/<query>",methods=['GET'])
@login_required_if_no_ano
@edit_required
def scholar_search(query):
if have_scholar:
scholar_gen = scholarly.search_pubs(' '.join(query.split('+')))
i=0
result = []
for publication in scholar_gen:
del publication['source']
result.append(publication)
i+=1
if(i>=10):
break
return Response(json.dumps(result),mimetype='application/json')
else:
return "[]"
@editbook.route("/ajax/editbooks/<param>", methods=['POST']) @editbook.route("/ajax/editbooks/<param>", methods=['POST'])
@login_required_if_no_ano @login_required_if_no_ano
@edit_required @edit_required
def edit_list_book(param): def edit_list_book(param):
vals = request.form.to_dict() vals = request.form.to_dict()
book = calibre_db.get_book(vals['pk']) book = calibre_db.get_book(vals['pk'])
ret = ""
if param =='series_index': if param =='series_index':
edit_book_series_index(vals['value'], book) edit_book_series_index(vals['value'], book)
ret = Response(json.dumps({'success': True, 'newValue': book.series_index}), mimetype='application/json')
elif param =='tags': elif param =='tags':
edit_book_tags(vals['value'], book) edit_book_tags(vals['value'], book)
ret = Response(json.dumps({'success': True, 'newValue': ', '.join([tag.name for tag in book.tags])}),
mimetype='application/json')
elif param =='series': elif param =='series':
edit_book_series(vals['value'], book) edit_book_series(vals['value'], book)
ret = Response(json.dumps({'success': True, 'newValue': ', '.join([serie.name for serie in book.series])}),
mimetype='application/json')
elif param =='publishers': elif param =='publishers':
vals['publisher'] = vals['value'] edit_book_publisher(vals['value'], book)
edit_book_publisher(vals, book) ret = Response(json.dumps({'success': True,
'newValue': ', '.join([publisher.name for publisher in book.publishers])}),
mimetype='application/json')
elif param =='languages': elif param =='languages':
edit_book_languages(vals['value'], book) invalid = list()
edit_book_languages(vals['value'], book, invalid=invalid)
if invalid:
ret = Response(json.dumps({'success': False,
'msg': 'Invalid languages in request: {}'.format(','.join(invalid))}),
mimetype='application/json')
else:
lang_names = list()
for lang in book.languages:
try:
lang_names.append(LC.parse(lang.lang_code).get_language_name(get_locale()))
except UnknownLocaleError:
lang_names.append(_(isoLanguages.get(part3=lang.lang_code).name))
ret = Response(json.dumps({'success': True, 'newValue': ', '.join(lang_names)}),
mimetype='application/json')
elif param =='author_sort': elif param =='author_sort':
book.author_sort = vals['value'] book.author_sort = vals['value']
ret = Response(json.dumps({'success': True, 'newValue': book.author_sort}),
mimetype='application/json')
elif param == 'title': elif param == 'title':
book.title = vals['value'] sort = book.sort
handle_title_on_edit(book, vals.get('value', ""))
helper.update_dir_stucture(book.id, config.config_calibre_dir) helper.update_dir_stucture(book.id, config.config_calibre_dir)
ret = Response(json.dumps({'success': True, 'newValue': book.title}),
mimetype='application/json')
elif param =='sort': elif param =='sort':
book.sort = vals['value'] book.sort = vals['value']
# ToDo: edit books ret = Response(json.dumps({'success': True, 'newValue': book.sort}),
mimetype='application/json')
elif param =='authors': elif param =='authors':
input_authors = vals['value'].split('&') input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true")
input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors))
modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
sort_authors_list = list()
for inp in input_authors:
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
if not stored_author:
stored_author = helper.get_sorted_author(inp)
else:
stored_author = stored_author.sort
sort_authors_list.append(helper.get_sorted_author(stored_author))
sort_authors = ' & '.join(sort_authors_list)
if book.author_sort != sort_authors:
book.author_sort = sort_authors
helper.update_dir_stucture(book.id, config.config_calibre_dir, input_authors[0]) helper.update_dir_stucture(book.id, config.config_calibre_dir, input_authors[0])
ret = Response(json.dumps({'success': True,
'newValue': ' & '.join([author.replace('|',',') for author in input_authors])}),
mimetype='application/json')
book.last_modified = datetime.utcnow() book.last_modified = datetime.utcnow()
try:
calibre_db.session.commit() calibre_db.session.commit()
return "" # revert change for sort if automatic fields link is deactivated
if param == 'title' and vals.get('checkT') == "false":
book.sort = sort
calibre_db.session.commit()
except (OperationalError, IntegrityError) as e:
calibre_db.session.rollback()
log.error("Database error: %s", e)
return ret
@editbook.route("/ajax/sort_value/<field>/<int:bookid>") @editbook.route("/ajax/sort_value/<field>/<int:bookid>")
@login_required @login_required
def get_sorted_entry(field, bookid): def get_sorted_entry(field, bookid):
if field == 'title' or field == 'authors': if field in ['title', 'authors', 'sort', 'author_sort']:
book = calibre_db.get_filtered_book(bookid) book = calibre_db.get_filtered_book(bookid)
if book: if book:
if field == 'title': if field == 'title':
return json.dumps({'sort': book.sort}) return json.dumps({'sort': book.sort})
elif field == 'authors': elif field == 'authors':
return json.dumps({'author_sort': book.author_sort}) return json.dumps({'author_sort': book.author_sort})
if field == 'sort':
return json.dumps({'sort': book.title})
if field == 'author_sort':
return json.dumps({'author_sort': book.author})
return "" return ""
@ -1104,6 +1237,46 @@ def merge_list_book():
element.format, element.format,
element.uncompressed_size, element.uncompressed_size,
to_name)) to_name))
delete_book(from_book.id,"", True) # json_resp = delete_book(from_book.id,"", True)
return json.dumps({'success': True})
return ""
@editbook.route("/ajax/xchange", methods=['POST'])
@login_required
@edit_required
def table_xchange_author_title():
vals = request.get_json().get('xchange')
if vals:
for val in vals:
modif_date = False
book = calibre_db.get_book(val)
authors = book.title
entries = calibre_db.order_authors(book)
author_names = []
for authr in entries.authors:
author_names.append(authr.name.replace('|', ','))
title_change = handle_title_on_edit(book, " ".join(author_names))
input_authors, authorchange = handle_author_on_edit(book, authors)
if authorchange or title_change:
edited_books_id = book.id
modif_date = True
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if edited_books_id:
helper.update_dir_stucture(edited_books_id, config.config_calibre_dir, input_authors[0])
if modif_date:
book.last_modified = datetime.utcnow()
try:
calibre_db.session.commit()
except (OperationalError, IntegrityError) as e:
calibre_db.session.rollback()
log.error("Database error: %s", e)
return json.dumps({'success': False})
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
return json.dumps({'success': True}) return json.dumps({'success': True})
return "" return ""

View File

@ -87,18 +87,29 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
lang = epub_metadata['language'].split('-', 1)[0].lower() lang = epub_metadata['language'].split('-', 1)[0].lower()
epub_metadata['language'] = isoLanguages.get_lang3(lang) epub_metadata['language'] = isoLanguages.get_lang3(lang)
series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns) epub_metadata = parse_epbub_series(ns, tree, epub_metadata)
if len(series) > 0:
epub_metadata['series'] = series[0]
else:
epub_metadata['series'] = ''
series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns) coverfile = parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path)
if len(series_id) > 0:
epub_metadata['series_id'] = series_id[0]
else:
epub_metadata['series_id'] = '1'
if not epub_metadata['title']:
title = original_file_name
else:
title = epub_metadata['title']
return BookMeta(
file_path=tmp_file_path,
extension=original_file_extension,
title=title.encode('utf-8').decode('utf-8'),
author=epub_metadata['creator'].encode('utf-8').decode('utf-8'),
cover=coverfile,
description=epub_metadata['description'],
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
series=epub_metadata['series'].encode('utf-8').decode('utf-8'),
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
languages=epub_metadata['language'],
publisher="")
def parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path):
coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns) coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
coverfile = None coverfile = None
if len(coversection) > 0: if len(coversection) > 0:
@ -126,21 +137,18 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
coverfile = extractCover(epubZip, filename, "", tmp_file_path) coverfile = extractCover(epubZip, filename, "", tmp_file_path)
else: else:
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path) coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
return coverfile
if not epub_metadata['title']: def parse_epbub_series(ns, tree, epub_metadata):
title = original_file_name series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns)
if len(series) > 0:
epub_metadata['series'] = series[0]
else: else:
title = epub_metadata['title'] epub_metadata['series'] = ''
return BookMeta( series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns)
file_path=tmp_file_path, if len(series_id) > 0:
extension=original_file_extension, epub_metadata['series_id'] = series_id[0]
title=title.encode('utf-8').decode('utf-8'), else:
author=epub_metadata['creator'].encode('utf-8').decode('utf-8'), epub_metadata['series_id'] = '1'
cover=coverfile, return epub_metadata
description=epub_metadata['description'],
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
series=epub_metadata['series'].encode('utf-8').decode('utf-8'),
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
languages=epub_metadata['language'],
publisher="")

View File

@ -35,6 +35,7 @@ def error_http(error):
error_code="Error {0}".format(error.code), error_code="Error {0}".format(error.code),
error_name=error.name, error_name=error.name,
issue=False, issue=False,
unconfigured=not config.db_configured,
instance=config.config_calibre_web_title instance=config.config_calibre_web_title
), error.code ), error.code
@ -44,6 +45,7 @@ def internal_error(error):
error_code="Internal Server Error", error_code="Internal Server Error",
error_name=str(error), error_name=str(error),
issue=True, issue=True,
unconfigured=False,
error_stack=traceback.format_exc().split("\n"), error_stack=traceback.format_exc().split("\n"),
instance=config.config_calibre_web_title instance=config.config_calibre_web_title
), 500 ), 500

View File

@ -29,7 +29,7 @@ def get_fb2_info(tmp_file_path, original_file_extension):
'l': 'http://www.w3.org/1999/xlink', 'l': 'http://www.w3.org/1999/xlink',
} }
fb2_file = open(tmp_file_path) fb2_file = open(tmp_file_path, encoding="utf-8")
tree = etree.fromstring(fb2_file.read().encode()) tree = etree.fromstring(fb2_file.read().encode())
authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns) authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns)

View File

@ -74,7 +74,7 @@ def google_drive_callback():
f.write(credentials.to_json()) f.write(credentials.to_json())
except (ValueError, AttributeError) as error: except (ValueError, AttributeError) as error:
log.error(error) log.error(error)
return redirect(url_for('admin.configuration')) return redirect(url_for('admin.db_configuration'))
@gdrive.route("/watch/subscribe") @gdrive.route("/watch/subscribe")
@ -99,7 +99,7 @@ def watch_gdrive():
else: else:
flash(reason['message'], category="error") flash(reason['message'], category="error")
return redirect(url_for('admin.configuration')) return redirect(url_for('admin.db_configuration'))
@gdrive.route("/watch/revoke") @gdrive.route("/watch/revoke")
@ -115,7 +115,7 @@ def revoke_watch_gdrive():
pass pass
config.config_google_drive_watch_changes_response = {} config.config_google_drive_watch_changes_response = {}
config.save() config.save()
return redirect(url_for('admin.configuration')) return redirect(url_for('admin.db_configuration'))
@gdrive.route("/watch/callback", methods=['GET', 'POST']) @gdrive.route("/watch/callback", methods=['GET', 'POST'])
@ -155,6 +155,6 @@ def on_received_watch_confirmation():
# prevent error on windows, as os.rename does on existing files, also allow cross hdd move # prevent error on windows, as os.rename does on existing files, also allow cross hdd move
move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath) move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath)
calibre_db.reconnect_db(config, ub.app_DB_path) calibre_db.reconnect_db(config, ub.app_DB_path)
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
return '' return ''

View File

@ -34,6 +34,7 @@ try:
except ImportError: except ImportError:
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.exc import OperationalError, InvalidRequestError from sqlalchemy.exc import OperationalError, InvalidRequestError
from sqlalchemy.sql.expression import text
try: try:
from apiclient import errors from apiclient import errors
@ -168,7 +169,7 @@ class PermissionAdded(Base):
def migrate(): def migrate():
if not engine.dialect.has_table(engine.connect(), "permissions_added"): if not engine.dialect.has_table(engine.connect(), "permissions_added"):
PermissionAdded.__table__.create(bind = engine) PermissionAdded.__table__.create(bind = engine)
for sql in session.execute("select sql from sqlite_master where type='table'"): for sql in session.execute(text("select sql from sqlite_master where type='table'")):
if 'CREATE TABLE gdrive_ids' in sql[0]: if 'CREATE TABLE gdrive_ids' in sql[0]:
currUniqueConstraint = 'UNIQUE (gdrive_id)' currUniqueConstraint = 'UNIQUE (gdrive_id)'
if currUniqueConstraint in sql[0]: if currUniqueConstraint in sql[0]:
@ -202,8 +203,8 @@ def getDrive(drive=None, gauth=None):
gauth.Refresh() gauth.Refresh()
except RefreshError as e: except RefreshError as e:
log.error("Google Drive error: %s", e) log.error("Google Drive error: %s", e)
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
else: else:
# Initialize the saved creds # Initialize the saved creds
gauth.Authorize() gauth.Authorize()
@ -221,7 +222,7 @@ def listRootFolders():
drive = getDrive(Gdrive.Instance().drive) drive = getDrive(Gdrive.Instance().drive)
folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false" folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
fileList = drive.ListFile({'q': folder}).GetList() fileList = drive.ListFile({'q': folder}).GetList()
except (ServerNotFoundError, ssl.SSLError) as e: except (ServerNotFoundError, ssl.SSLError, RefreshError) as e:
log.info("GDrive Error %s" % e) log.info("GDrive Error %s" % e)
fileList = [] fileList = []
return fileList return fileList
@ -257,7 +258,12 @@ def getEbooksFolderId(drive=None):
log.error('Error gDrive, root ID not found') log.error('Error gDrive, root ID not found')
gDriveId.path = '/' gDriveId.path = '/'
session.merge(gDriveId) session.merge(gDriveId)
try:
session.commit() session.commit()
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
return gDriveId.gdrive_id return gDriveId.gdrive_id
@ -272,6 +278,7 @@ def getFile(pathId, fileName, drive):
def getFolderId(path, drive): def getFolderId(path, drive):
# drive = getDrive(drive) # drive = getDrive(drive)
try:
currentFolderId = getEbooksFolderId(drive) currentFolderId = getEbooksFolderId(drive)
sqlCheckPath = path if path[-1] == '/' else path + '/' sqlCheckPath = path if path[-1] == '/' else path + '/'
storedPathName = session.query(GdriveId).filter(GdriveId.path == sqlCheckPath).first() storedPathName = session.query(GdriveId).filter(GdriveId.path == sqlCheckPath).first()
@ -303,6 +310,10 @@ def getFolderId(path, drive):
session.commit() session.commit()
else: else:
currentFolderId = storedPathName.gdrive_id currentFolderId = storedPathName.gdrive_id
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
return currentFolderId return currentFolderId
@ -346,7 +357,7 @@ def moveGdriveFolderRemote(origin_file, target_folder):
addParents=gFileTargetDir['id'], addParents=gFileTargetDir['id'],
removeParents=previous_parents, removeParents=previous_parents,
fields='id, parents').execute() fields='id, parents').execute()
# if previous_parents has no childs anymore, delete original fileparent # if previous_parents has no children anymore, delete original fileparent
if len(children['items']) == 1: if len(children['items']) == 1:
deleteDatabaseEntry(previous_parents) deleteDatabaseEntry(previous_parents)
drive.auth.service.files().delete(fileId=previous_parents).execute() drive.auth.service.files().delete(fileId=previous_parents).execute()
@ -497,8 +508,8 @@ def getChangeById (drive, change_id):
except (errors.HttpError) as error: except (errors.HttpError) as error:
log.error(error) log.error(error)
return None return None
except Exception as e: except Exception as ex:
log.error(e) log.error(ex)
return None return None
@ -507,9 +518,10 @@ def deleteDatabaseOnChange():
try: try:
session.query(GdriveId).delete() session.query(GdriveId).delete()
session.commit() session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError) as ex:
session.rollback() session.rollback()
log.info(u"GDrive DB is not Writeable") log.debug('Database error: %s', ex)
log.error(u"GDrive DB is not Writeable")
def updateGdriveCalibreFromLocal(): def updateGdriveCalibreFromLocal():
@ -524,13 +536,23 @@ def updateDatabaseOnEdit(ID,newPath):
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first() storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
if storedPathName: if storedPathName:
storedPathName.path = sqlCheckPath storedPathName.path = sqlCheckPath
try:
session.commit() session.commit()
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
# Deletes the hashes in database of deleted book # Deletes the hashes in database of deleted book
def deleteDatabaseEntry(ID): def deleteDatabaseEntry(ID):
session.query(GdriveId).filter(GdriveId.gdrive_id == ID).delete() session.query(GdriveId).filter(GdriveId.gdrive_id == ID).delete()
try:
session.commit() session.commit()
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
# Gets cover file from gdrive # Gets cover file from gdrive
@ -547,7 +569,12 @@ def get_cover_via_gdrive(cover_path):
permissionAdded = PermissionAdded() permissionAdded = PermissionAdded()
permissionAdded.gdrive_id = df['id'] permissionAdded.gdrive_id = df['id']
session.add(permissionAdded) session.add(permissionAdded)
try:
session.commit() session.commit()
except OperationalError as ex:
log.error("gdrive.db DB is not Writeable")
log.debug('Database error: %s', ex)
session.rollback()
return df.metadata.get('webContentLink') return df.metadata.get('webContentLink')
else: else:
return None return None

View File

@ -35,9 +35,10 @@ 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_login import current_user from flask_login import current_user
from sqlalchemy.sql.expression import true, false, and_, text from sqlalchemy.sql.expression import true, false, and_, text, func
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
try: try:
from urllib.parse import quote from urllib.parse import quote
@ -98,10 +99,11 @@ def convert_book_format(book_id, calibrepath, old_book_format, new_book_format,
settings['body'] = _(u'This e-mail has been sent via Calibre-Web.') settings['body'] = _(u'This e-mail has been sent via Calibre-Web.')
else: else:
settings = dict() settings = dict()
txt = (u"%s -> %s: %s" % ( link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title)) # prevent xss
old_book_format, txt = u"{} -> {}: {}".format(
new_book_format, old_book_format.upper(),
"<a href=\"" + url_for('web.show_book', book_id=book.id) + "\">" + book.title + "</a>")) new_book_format.upper(),
link)
settings['old_book_format'] = old_book_format settings['old_book_format'] = old_book_format
settings['new_book_format'] = new_book_format settings['new_book_format'] = new_book_format
WorkerThread.add(user_id, TaskConvert(file_path, book.id, txt, settings, kindle_mail, user_id)) WorkerThread.add(user_id, TaskConvert(file_path, book.id, txt, settings, kindle_mail, user_id))
@ -215,9 +217,11 @@ def send_mail(book_id, book_format, convert, kindle_mail, calibrepath, user_id):
for entry in iter(book.data): for entry in iter(book.data):
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))
EmailText = _(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,
_(u"E-mail: %(book)s", book=book.title), _(u'This e-mail has been sent via Calibre-Web.'))) EmailText, _(u'This e-mail has been sent via Calibre-Web.')))
return return
return _(u"The requested file could not be read. Maybe wrong permissions?") return _(u"The requested file could not be read. Maybe wrong permissions?")
@ -231,16 +235,14 @@ def get_valid_filename(value, replace_whitespace=True):
value = value[:-1]+u'_' value = value[:-1]+u'_'
value = value.replace("/", "_").replace(":", "_").strip('\0') value = value.replace("/", "_").replace(":", "_").strip('\0')
if use_unidecode: if use_unidecode:
if not config.config_unicode_filename:
value = (unidecode.unidecode(value)) value = (unidecode.unidecode(value))
else: else:
value = value.replace(u'§', u'SS') value = value.replace(u'§', u'SS')
value = value.replace(u'ß', u'ss') value = value.replace(u'ß', u'ss')
value = unicodedata.normalize('NFKD', value) value = unicodedata.normalize('NFKD', value)
re_slugify = re.compile(r'[\W\s-]', re.UNICODE) re_slugify = re.compile(r'[\W\s-]', re.UNICODE)
if isinstance(value, str): # Python3 str, Python2 unicode
value = re_slugify.sub('', value) value = re_slugify.sub('', value)
else:
value = unicode(re_slugify.sub('', value))
if replace_whitespace: if replace_whitespace:
# *+:\"/<>? are replaced by _ # *+:\"/<>? are replaced by _
value = re.sub(r'[*+:\\\"/<>?]+', u'_', value, flags=re.U) value = re.sub(r'[*+:\\\"/<>?]+', u'_', value, flags=re.U)
@ -249,10 +251,7 @@ def get_valid_filename(value, replace_whitespace=True):
value = value[:128].strip() value = value[:128].strip()
if not value: if not value:
raise ValueError("Filename cannot be empty") raise ValueError("Filename cannot be empty")
if sys.version_info.major == 3:
return value return value
else:
return value.decode('utf-8')
def split_authors(values): def split_authors(values):
@ -330,9 +329,10 @@ def delete_book_file(book, calibrepath, book_format=None):
except (IOError, OSError) as e: except (IOError, OSError) as e:
log.error("Deleting authorpath for book %s failed: %s", book.id, e) log.error("Deleting authorpath for book %s failed: %s", book.id, e)
return True, None return True, None
else:
log.error("Deleting book %s failed, book path not valid: %s", book.id, book.path) log.error("Deleting book %s from database only, book path in database not valid: %s",
return True, _("Deleting book %(id)s, book path not valid: %(path)s", book.id, book.path)
return True, _("Deleting book %(id)s from database only, book path in database not valid: %(path)s",
id=book.id, id=book.id,
path=book.path) path=book.path)
@ -383,7 +383,7 @@ def update_dir_structure_file(book_id, calibrepath, first_author, orignal_filepa
# os.unlink(os.path.normcase(os.path.join(dir_name, file))) # os.unlink(os.path.normcase(os.path.join(dir_name, file)))
# change location in database to new author/title path # change location in database to new author/title path
localbook.path = os.path.join(new_authordir, new_titledir).replace('\\','/') localbook.path = os.path.join(new_authordir, new_titledir).replace('\\','/')
except OSError as ex: except (OSError) as ex:
log.error("Rename title from: %s to %s: %s", path, new_path, ex) log.error("Rename title from: %s to %s: %s", path, new_path, ex)
log.debug(ex, exc_info=True) log.debug(ex, exc_info=True)
return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s", return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
@ -398,7 +398,7 @@ def update_dir_structure_file(book_id, calibrepath, first_author, orignal_filepa
file_format.name = new_name file_format.name = new_name
if not orignal_filepath and len(os.listdir(os.path.dirname(path))) == 0: if not orignal_filepath and len(os.listdir(os.path.dirname(path))) == 0:
shutil.rmtree(os.path.dirname(path)) shutil.rmtree(os.path.dirname(path))
except OSError as ex: except (OSError) as ex:
log.error("Rename file in path %s to %s: %s", new_path, new_name, ex) log.error("Rename file in path %s to %s: %s", new_path, new_name, ex)
log.debug(ex, exc_info=True) log.debug(ex, exc_info=True)
return _("Rename file in path '%(src)s' to '%(dest)s' failed with error: %(error)s", return _("Rename file in path '%(src)s' to '%(dest)s' failed with error: %(error)s",
@ -481,8 +481,8 @@ def reset_password(user_id):
password = generate_random_password() password = generate_random_password()
existing_user.password = generate_password_hash(password) existing_user.password = generate_password_hash(password)
ub.session.commit() ub.session.commit()
send_registration_mail(existing_user.email, existing_user.nickname, password, True) send_registration_mail(existing_user.email, existing_user.name, password, True)
return 1, existing_user.nickname return 1, existing_user.name
except Exception: except Exception:
ub.session.rollback() ub.session.rollback()
return 0, None return 0, None
@ -499,11 +499,37 @@ def generate_random_password():
def uniq(inpt): def uniq(inpt):
output = [] output = []
inpt = [ " ".join(inp.split()) for inp in inpt]
for x in inpt: for x in inpt:
if x not in output: if x not in output:
output.append(x) output.append(x)
return output return output
def check_email(email):
email = valid_email(email)
if ub.session.query(ub.User).filter(func.lower(ub.User.email) == email.lower()).first():
log.error(u"Found an existing account for this e-mail address")
raise Exception(_(u"Found an existing account for this e-mail address"))
return email
def check_username(username):
username = username.strip()
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar():
log.error(u"This username is already taken")
raise Exception (_(u"This username is already taken"))
return username
def valid_email(email):
email = email.strip()
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
email):
log.error(u"Invalid e-mail address format")
raise Exception(_(u"Invalid e-mail address format"))
return email
# ################################# External interface ################################# # ################################# External interface #################################
@ -740,6 +766,7 @@ def do_download_file(book, book_format, client, data, headers):
# ToDo Check headers parameter # ToDo Check headers parameter
for element in headers: for element in headers:
response.headers[element[0]] = element[1] response.headers[element[0]] = element[1]
log.info('Downloading file: {}'.format(os.path.join(filename, data.name + "." + book_format)))
return response return response
################################## ##################################
@ -756,12 +783,11 @@ def check_unrar(unrarLocation):
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
unrarLocation = unrarLocation.encode(sys.getfilesystemencoding()) unrarLocation = unrarLocation.encode(sys.getfilesystemencoding())
unrarLocation = [unrarLocation] unrarLocation = [unrarLocation]
for lines in process_wait(unrarLocation): value = process_wait(unrarLocation, pattern='UNRAR (.*) freeware')
value = re.search('UNRAR (.*) freeware', lines, re.IGNORECASE)
if value: if value:
version = value.group(1) version = value.group(1)
log.debug("unrar version %s", version) log.debug("unrar version %s", version)
break
except (OSError, UnicodeDecodeError) as err: except (OSError, UnicodeDecodeError) as err:
log.debug_or_exception(err) log.debug_or_exception(err)
return _('Error excecuting UnRar') return _('Error excecuting UnRar')
@ -779,7 +805,6 @@ def json_serial(obj):
'seconds': obj.seconds, 'seconds': obj.seconds,
'microseconds': obj.microseconds, 'microseconds': obj.microseconds,
} }
# return obj.isoformat()
raise TypeError("Type %s not serializable" % type(obj)) raise TypeError("Type %s not serializable" % type(obj))
@ -804,7 +829,7 @@ def format_runtime(runtime):
def render_task_status(tasklist): def render_task_status(tasklist):
renderedtasklist = list() renderedtasklist = list()
for __, user, __, task in tasklist: for __, user, __, task in tasklist:
if user == current_user.nickname or current_user.role_admin(): if user == current_user.name or current_user.role_admin():
ret = {} ret = {}
if task.start_time: if task.start_time:
ret['starttime'] = format_datetime(task.start_time, format='short', locale=get_locale()) ret['starttime'] = format_datetime(task.start_time, format='short', locale=get_locale())
@ -825,7 +850,7 @@ def render_task_status(tasklist):
ret['taskMessage'] = "{}: {}".format(_(task.name), task.message) ret['taskMessage'] = "{}: {}".format(_(task.name), task.message)
ret['progress'] = "{} %".format(int(task.progress * 100)) ret['progress'] = "{} %".format(int(task.progress * 100))
ret['user'] = user ret['user'] = escape(user) # prevent xss
renderedtasklist.append(ret) renderedtasklist.append(ret)
return renderedtasklist return renderedtasklist
@ -842,8 +867,8 @@ def tags_filters():
# checks if domain is in database (including wildcards) # checks if domain is in database (including wildcards)
# example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name; # example SELECT * FROM @TABLE WHERE 'abcdefg' LIKE Name;
# from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/ # from https://code.luasoftware.com/tutorials/flask/execute-raw-sql-in-flask-sqlalchemy/
# in all calls the email address is checked for validity
def check_valid_domain(domain_text): def check_valid_domain(domain_text):
# domain_text = domain_text.split('@', 1)[-1].lower()
sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 1);" sql = "SELECT * FROM registration WHERE (:domain LIKE domain and allow = 1);"
result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all() result = ub.session.query(ub.Registration).from_statement(text(sql)).params(domain=domain_text).all()
if not len(result): if not len(result):
@ -877,6 +902,7 @@ def get_download_link(book_id, book_format, client):
if book: if book:
data1 = calibre_db.get_book_format(book.id, book_format.upper()) data1 = calibre_db.get_book_format(book.id, book_format.upper())
else: else:
log.error("Book id {} not found for downloading".format(book_id))
abort(404) abort(404)
if data1: if data1:
# collect downloaded books only for registered user and not for anonymous user # collect downloaded books only for registered user and not for anonymous user
@ -884,8 +910,8 @@ def get_download_link(book_id, book_format, client):
ub.update_download(book_id, int(current_user.id)) ub.update_download(book_id, int(current_user.id))
file_name = book.title file_name = book.title
if len(book.authors) > 0: if len(book.authors) > 0:
file_name = book.authors[0].name + '_' + file_name file_name = file_name + ' - ' + book.authors[0].name
file_name = get_valid_filename(file_name) file_name = get_valid_filename(file_name, replace_whitespace=False)
headers = Headers() headers = Headers()
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream") headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
headers["Content-Disposition"] = "attachment; filename=%s.%s; filename*=UTF-8''%s.%s" % ( headers["Content-Disposition"] = "attachment; filename=%s.%s; filename*=UTF-8''%s.%s" % (

View File

@ -63,7 +63,7 @@ def get_language_codes(locale, language_names, remainder=None):
if v in language_names: if v in language_names:
lang.append(k) lang.append(k)
language_names.remove(v) language_names.remove(v)
if remainder is not None: if remainder is not None and language_names:
remainder.extend(language_names) remainder.extend(language_names)
return lang return lang

View File

@ -5007,6 +5007,379 @@ LANGUAGE_NAMES = {
"zxx": "brak kontekstu językowego", "zxx": "brak kontekstu językowego",
"zza": "zazaki" "zza": "zazaki"
}, },
"pt_BR": {
"abk": "Abcázio",
"ace": "Achém",
"ach": "Acoli",
"ada": "Adangme",
"ady": "Adyghe",
"aar": "Afar",
"afh": "Afrihili",
"afr": "Africânder",
"ain": "Ainu (Japão)",
"aka": "Akan",
"akk": "Acadiano",
"sqi": "Albanês",
"ale": "Aleúte",
"amh": "Amárico",
"anp": "Angika",
"ara": "Arabic",
"arg": "Aragonese",
"arp": "Arapaho",
"arw": "Arawak",
"hye": "Armênio",
"asm": "Assamese",
"ast": "Asturian",
"ava": "Avaric",
"ave": "Avestan",
"awa": "Awadhi",
"aym": "Aymara",
"aze": "Azerbaijano",
"ban": "Balinês",
"bal": "Balúchi",
"bam": "Bambara",
"bas": "Basa (Cameroon)",
"bak": "Bashkir",
"eus": "Basque",
"bej": "Beja",
"bel": "Belarusian",
"bem": "Bemba (Zambia)",
"ben": "Bengali",
"bho": "Bhojpuri",
"bik": "Bikol",
"byn": "Bilin",
"bin": "Bini",
"bis": "Bislama",
"zbl": "Blissymbols",
"bos": "Bosnian",
"bra": "Braj",
"bre": "Bretão",
"bug": "Buginese",
"bul": "Búlgaro",
"bua": "Buriat",
"mya": "Birmanês",
"cad": "Caddo",
"cat": "Catalão",
"ceb": "Cebuano",
"chg": "Chagatai",
"cha": "Chamorro",
"che": "Chechen",
"chr": "Cheroqui",
"chy": "Cheyenne",
"chb": "Chibcha",
"zho": "Chinês",
"chn": "Chinook jargon",
"chp": "Chipewyan",
"cho": "Choctaw",
"chk": "Chuukese",
"chv": "Chuvash",
"cop": "Coptic",
"cor": "Cornish",
"cos": "Corsican",
"cre": "Cree",
"mus": "Creek",
"hrv": "Croata",
"ces": "Czech",
"dak": "Dacota",
"dan": "Danish",
"dar": "Dargwa",
"del": "Delaware",
"div": "Dhivehi",
"din": "Dinka",
"doi": "Dogri (macrolanguage)",
"dgr": "Dogrib",
"dua": "Duala",
"nld": "Holandês",
"dyu": "Dyula",
"dzo": "Dzongkha",
"efi": "Efik",
"egy": "Egyptian (Ancient)",
"eka": "Ekajuk",
"elx": "Elamite",
"eng": "Inglês",
"myv": "Erzya",
"epo": "Esperanto",
"est": "Estónio",
"ewe": "Ewe",
"ewo": "Ewondo",
"fan": "Fang (Equatorial Guinea)",
"fat": "Fanti",
"fao": "Faroese",
"fij": "Fijian",
"fil": "Filipino",
"fin": "Finlandês",
"fon": "Fon",
"fra": "Francês",
"fur": "Friuliano",
"ful": "Fulah",
"gaa": "Ga",
"glg": "Galician",
"lug": "Ganda",
"gay": "Gayo",
"gba": "Gbaya (Central African Republic)",
"gez": "Geez",
"kat": "Georgiano",
"deu": "Alemão",
"gil": "Gilbertês",
"gon": "Gondi",
"gor": "Gorontalo",
"got": "Gótico",
"grb": "Grebo",
"grn": "Guarani",
"guj": "Guzerate",
"gwi": "Gwichʼin",
"hai": "Haida",
"hau": "Hauçá",
"haw": "Havaiano",
"heb": "Hebraico",
"her": "Herero",
"hil": "Hiligaynon",
"hin": "Hindi",
"hmo": "Hiri Motu",
"hit": "Hitita",
"hmn": "Hmong",
"hun": "Húngaro",
"hup": "Hupa",
"iba": "Iban",
"isl": "Islandês",
"ido": "Ido",
"ibo": "Igbo",
"ilo": "Ilocano",
"ind": "Indonésio",
"inh": "Ingush",
"ina": "Interlingua (International Auxiliary Language Association)",
"ile": "Interlingue",
"iku": "Inuktitut",
"ipk": "Inupiaq",
"gle": "Irlandês",
"ita": "Italiano",
"jpn": "Japanese",
"jav": "Javanês",
"jrb": "Judeo-Arabic",
"jpr": "Judeo-Persian",
"kbd": "Kabardian",
"kab": "Kabyle",
"kac": "Kachin",
"kal": "Kalaallisut",
"xal": "Kalmyk",
"kam": "Kamba (Quênia)",
"kan": "Canarês",
"kau": "Kanuri",
"kaa": "Kara-Kalpak",
"krc": "Karachay-Balkar",
"krl": "Karelian",
"kas": "Kashmiri",
"csb": "Kashubian",
"kaw": "Kawi",
"kaz": "Cazaque",
"kha": "Khasi",
"kho": "Khotanese",
"kik": "Quicuio",
"kmb": "Quimbundo",
"kin": "Kinyarwanda",
"kir": "Quirguiz",
"tlh": "Klingon",
"kom": "Komi",
"kon": "Quicongo",
"kok": "Konkani (macrolanguage)",
"kor": "Coreano",
"kos": "Kosraean",
"kpe": "Kpelle",
"kua": "Kuanyama",
"kum": "Kumyk",
"kur": "Kurdish",
"kru": "Kurukh",
"kut": "Kutenai",
"lad": "Ladino",
"lah": "Lahnda",
"lam": "Lamba",
"lao": "Laosiano",
"lat": "Latin",
"lav": "Letão",
"lez": "Lezghian",
"lim": "Limburgan",
"lin": "Lingala",
"lit": "Lituano",
"jbo": "Lojban",
"loz": "Lozi",
"lub": "Luba-Catanga",
"lua": "Luba-Lulua",
"lui": "Luiseno",
"smj": "Lule Sami",
"lun": "Lunda",
"luo": "Luo (Kenya and Tanzania)",
"lus": "Lushai",
"ltz": "Luxembourgish",
"mkd": "Macedónio",
"mad": "Madurese",
"mag": "Magahi",
"mai": "Maithili",
"mak": "Makasar",
"mlg": "Malgaxe",
"msa": "Malay (macrolanguage)",
"mal": "Malayalam",
"mlt": "Maltese",
"mnc": "Manchu",
"mdr": "Mandar",
"man": "Mandinga",
"mni": "Manipuri",
"glv": "Manx",
"mri": "Maori",
"arn": "Mapudungun",
"mar": "Marata",
"chm": "Mari (Russia)",
"mah": "Marshallese",
"mwr": "Marwari",
"mas": "Masai",
"men": "Mende (Sierra Leone)",
"mic": "Mi'kmaq",
"min": "Minangkabau",
"mwl": "Mirandês",
"moh": "Mohawk",
"mdf": "Mocsa",
"lol": "Mongo",
"mon": "Mongolian",
"mos": "Mossi",
"mul": "Múltiplos idiomas",
"nqo": "N'Ko",
"nau": "Nauruano",
"nav": "Navajo",
"ndo": "Ndonga",
"nap": "Neapolitan",
"nia": "Nias",
"niu": "Niueano",
"zxx": "Sem conteúdo linguistico",
"nog": "Nogai",
"nor": "Norueguês",
"nob": "Norueguês, Dano",
"nno": "Norueguês, Novo",
"nym": "Nyamwezi",
"nya": "Nyanja",
"nyn": "Nyankole",
"nyo": "Nyoro",
"nzi": "Nzima",
"oci": "Occitan (post 1500)",
"oji": "Ojibwa",
"orm": "Oromo",
"osa": "Osage",
"oss": "Ossetian",
"pal": "Pálavi",
"pau": "Palauano",
"pli": "Pali",
"pam": "Pampanga",
"pag": "Pangasinense",
"pan": "Panjabi",
"pap": "Papiamento",
"fas": "Persian",
"phn": "Fenício",
"pon": "Pohnpeian",
"pol": "Polaco",
"por": "Português",
"pus": "Pushto",
"que": "Quíchua",
"raj": "Rajastani",
"rap": "Rapanui",
"ron": "Romeno",
"roh": "Romansh",
"rom": "Romany",
"run": "Rundi",
"rus": "Russo",
"smo": "Samoan",
"sad": "Sandawe",
"sag": "Sango",
"san": "Sanskrit",
"sat": "Santali",
"srd": "Sardinian",
"sas": "Sasak",
"sco": "Scots",
"sel": "Selkup",
"srp": "Sérvio",
"srr": "Serere",
"shn": "Shan",
"sna": "Shona",
"scn": "Sicilian",
"sid": "Sidamo",
"bla": "Siksika",
"snd": "Sindi",
"sin": "Cingalês",
"den": "Slave (Athapascan)",
"slk": "Eslovaco",
"slv": "Esloveno",
"sog": "Sogdian",
"som": "Somali",
"snk": "Soninke",
"spa": "Espanhol",
"srn": "Sranan Tongo",
"suk": "Sukuma",
"sux": "Sumerian",
"sun": "Sudanês",
"sus": "Sosso",
"swa": "Swahili (macrolanguage)",
"ssw": "Swati",
"swe": "Sueco",
"syr": "Siríaco",
"tgl": "Tagaloge",
"tah": "Tahitian",
"tgk": "Tajik",
"tmh": "Tamaxeque",
"tam": "Tamil",
"tat": "Tatar",
"tel": "Telugu",
"ter": "Tereno",
"tet": "Tétum",
"tha": "Tailandês",
"bod": "Tibetano",
"tig": "Tigre",
"tir": "Tigrinya",
"tem": "Timne",
"tiv": "Tiv",
"tli": "Tlingit",
"tpi": "Tok Pisin",
"tkl": "Toquelauano",
"tog": "Toganês (Nyasa)",
"ton": "Tonga (ilhas tonga)",
"tsi": "Tsimshian",
"tso": "Tsonga",
"tsn": "Tswana",
"tum": "Tumbuka",
"tur": "Turco",
"tuk": "Turcomano",
"tvl": "Tuvaluano",
"tyv": "Tuvinian",
"twi": "Twi",
"udm": "Udmurt",
"uga": "Ugarítico",
"uig": "Uighur",
"ukr": "Ucraniano",
"umb": "Umbundu",
"mis": "Idiomas sem código",
"und": "Não identificável",
"urd": "Urdu",
"uzb": "Usbeque",
"vai": "Vai",
"ven": "Venda",
"vie": "Vietnamita",
"vol": "Volapük",
"vot": "Votic",
"wln": "Walloon",
"war": "Waray (Philippines)",
"was": "Washo",
"cym": "Galês",
"wal": "Wolaytta",
"wol": "Uolofe",
"xho": "Xosa",
"sah": "Iacuto",
"yao": "Iao",
"yap": "Yapese",
"yid": "Ídiche",
"yor": "Iorubá",
"zap": "Zapoteca",
"zza": "Zaza",
"zen": "Zenaga",
"zha": "Zhuang",
"zul": "Zulu",
"zun": "Zuni"
},
"ru": { "ru": {
"aar": "Афар", "aar": "Афар",
"abk": "Абхазский", "abk": "Абхазский",

View File

@ -31,7 +31,7 @@ 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 get_locale
from flask_login import current_user from flask_login import current_user
from markupsafe import escape
from . import logger from . import logger
@ -82,7 +82,7 @@ def formatdate_filter(val):
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,
current_user.nickname current_user.name
) )
return val return val
@ -113,21 +113,25 @@ def yesno(value, yes, no):
@jinjia.app_template_filter('formatfloat') @jinjia.app_template_filter('formatfloat')
def formatfloat(value, decimals=1): def formatfloat(value, decimals=1):
formatedstring = '%d' % value value = 0 if not value else value
if (value % 1) != 0: return ('{0:.' + str(decimals) + 'f}').format(value).rstrip('0').rstrip('.')
formatedstring = ('%s.%d' % (formatedstring, (value % 1) * 10**decimals)).rstrip('0')
return formatedstring
@jinjia.app_template_filter('formatseriesindex') @jinjia.app_template_filter('formatseriesindex')
def formatseriesindex_filter(series_index): def formatseriesindex_filter(series_index):
if series_index: if series_index:
try:
if int(series_index) - series_index == 0: if int(series_index) - series_index == 0:
return int(series_index) return int(series_index)
else: else:
return series_index return series_index
except ValueError:
return series_index
return 0 return 0
@jinjia.app_template_filter('escapedlink')
def escapedlink_filter(url, 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):

View File

@ -42,11 +42,13 @@ from flask import (
from flask_login import current_user from flask_login import current_user
from werkzeug.datastructures import Headers from werkzeug.datastructures import Headers
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.sql.expression import and_ from sqlalchemy.sql.expression import and_, or_
from sqlalchemy.exc import StatementError from sqlalchemy.exc import StatementError
from sqlalchemy.sql import select
import requests import requests
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub
from .constants import sqlalchemy_version2
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
@ -81,6 +83,7 @@ CONNECTION_SPECIFIC_HEADERS = [
"transfer-encoding", "transfer-encoding",
] ]
def get_kobo_activated(): def get_kobo_activated():
return config.config_kobo_sync return config.config_kobo_sync
@ -135,6 +138,7 @@ def convert_to_kobo_timestamp_string(timestamp):
def HandleSyncRequest(): def HandleSyncRequest():
sync_token = SyncToken.SyncToken.from_headers(request.headers) sync_token = SyncToken.SyncToken.from_headers(request.headers)
log.info("Kobo library sync request received.") log.info("Kobo library sync request received.")
log.debug("SyncToken: {}".format(sync_token))
if not current_app.wsgi_app.is_proxied: if not current_app.wsgi_app.is_proxied:
log.debug('Kobo: Received unproxied request, changed request port to external server port') log.debug('Kobo: Received unproxied request, changed request port to external server port')
@ -151,33 +155,60 @@ def HandleSyncRequest():
# in case of external changes (e.g: adding a book through Calibre). # in case of external changes (e.g: adding a book through Calibre).
calibre_db.reconnect_db(config, ub.app_DB_path) calibre_db.reconnect_db(config, ub.app_DB_path)
if sync_token.books_last_id > -1: only_kobo_shelves = current_user.kobo_only_shelves_sync
changed_entries = (
calibre_db.session.query(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived) if only_kobo_shelves:
if sqlalchemy_version2:
changed_entries = select(db.Books,
ub.ArchivedBook.last_modified,
ub.BookShelf.date_added,
ub.ArchivedBook.is_archived)
else:
changed_entries = calibre_db.session.query(db.Books,
ub.ArchivedBook.last_modified,
ub.BookShelf.date_added,
ub.ArchivedBook.is_archived)
changed_entries = (changed_entries
.join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id) .join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id)
.filter(db.Books.last_modified >= sync_token.books_last_modified) .filter(or_(db.Books.last_modified > sync_token.books_last_modified,
.filter(db.Books.id>sync_token.books_last_id) ub.BookShelf.date_added > sync_token.books_last_modified))
.filter(db.Data.format.in_(KOBO_FORMATS)) .filter(db.Data.format.in_(KOBO_FORMATS)).filter(calibre_db.common_filters())
.order_by(db.Books.last_modified)
.order_by(db.Books.id) .order_by(db.Books.id)
.limit(SYNC_ITEM_LIMIT) .order_by(ub.ArchivedBook.last_modified)
.join(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)
.join(ub.Shelf)
.filter(ub.Shelf.user_id == current_user.id)
.filter(ub.Shelf.kobo_sync)
.distinct()
) )
else: else:
changed_entries = ( if sqlalchemy_version2:
calibre_db.session.query(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived) changed_entries = select(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived)
else:
changed_entries = calibre_db.session.query(db.Books,
ub.ArchivedBook.last_modified,
ub.ArchivedBook.is_archived)
changed_entries = (changed_entries
.join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id) .join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id)
.filter(db.Books.last_modified > sync_token.books_last_modified) .filter(db.Books.last_modified > sync_token.books_last_modified)
.filter(calibre_db.common_filters())
.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)
.limit(SYNC_ITEM_LIMIT)
) )
if sync_token.books_last_id > -1:
changed_entries = changed_entries.filter(db.Books.id > sync_token.books_last_id)
reading_states_in_new_entitlements = [] reading_states_in_new_entitlements = []
for book in changed_entries: if sqlalchemy_version2:
books = calibre_db.session.execute(changed_entries.limit(SYNC_ITEM_LIMIT))
else:
books = changed_entries.limit(SYNC_ITEM_LIMIT)
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 not 'KEPUB' 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.nickname) 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)
entitlement = { entitlement = {
@ -190,7 +221,14 @@ def HandleSyncRequest():
new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified) new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified)
reading_states_in_new_entitlements.append(book.Books.id) reading_states_in_new_entitlements.append(book.Books.id)
if book.Books.timestamp > sync_token.books_last_created: ts_created = book.Books.timestamp
try:
ts_created = max(ts_created, book.date_added)
except AttributeError:
pass
if ts_created > sync_token.books_last_created:
sync_results.append({"NewEntitlement": entitlement}) sync_results.append({"NewEntitlement": entitlement})
else: else:
sync_results.append({"ChangedEntitlement": entitlement}) sync_results.append({"ChangedEntitlement": entitlement})
@ -198,35 +236,59 @@ def HandleSyncRequest():
new_books_last_modified = max( new_books_last_modified = max(
book.Books.last_modified, new_books_last_modified book.Books.last_modified, new_books_last_modified
) )
new_books_last_created = max(book.Books.timestamp, new_books_last_created) try:
new_books_last_modified = max(
max_change = (changed_entries new_books_last_modified, book.date_added
.from_self()
.filter(ub.ArchivedBook.is_archived)
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc())
.first()
) )
if max_change: except AttributeError:
max_change = max_change.last_modified pass
new_books_last_created = max(ts_created, new_books_last_created)
if sqlalchemy_version2:
max_change = calibre_db.session.execute(changed_entries
.filter(ub.ArchivedBook.is_archived)
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()))\
.columns(db.Books).first()
else: else:
max_change = new_archived_last_modified max_change = changed_entries.from_self().filter(ub.ArchivedBook.is_archived) \
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
max_change = max_change.last_modified if max_change else new_archived_last_modified
new_archived_last_modified = max(new_archived_last_modified, max_change) new_archived_last_modified = max(new_archived_last_modified, max_change)
# no. of books returned # no. of books returned
book_count = changed_entries.count() if sqlalchemy_version2:
entries = calibre_db.session.execute(changed_entries).all()
# last entry: book_count = len(entries)
if book_count:
books_last_id = changed_entries.all()[-1].Books.id or -1
else: else:
books_last_id = -1 entries = changed_entries.all()
book_count = changed_entries.count()
# last entry:
books_last_id = entries[-1].Books.id or -1 if book_count else -1
# generate reading state data # generate reading state data
changed_reading_states = ( changed_reading_states = ub.session.query(ub.KoboReadingState)
ub.session.query(ub.KoboReadingState)
.filter(and_(func.datetime(ub.KoboReadingState.last_modified) > sync_token.reading_state_last_modified, if only_kobo_shelves:
ub.KoboReadingState.user_id == current_user.id, changed_reading_states = changed_reading_states.join(ub.BookShelf,
ub.KoboReadingState.book_id.notin_(reading_states_in_new_entitlements)))) ub.KoboReadingState.book_id == ub.BookShelf.book_id)\
.join(ub.Shelf)\
.filter(current_user.id == ub.Shelf.user_id)\
.filter(ub.Shelf.kobo_sync,
or_(
func.datetime(ub.KoboReadingState.last_modified) > sync_token.reading_state_last_modified,
func.datetime(ub.BookShelf.date_added) > sync_token.books_last_modified
)).distinct()
else:
changed_reading_states = changed_reading_states.filter(
func.datetime(ub.KoboReadingState.last_modified) > sync_token.reading_state_last_modified)
changed_reading_states = changed_reading_states.filter(
and_(ub.KoboReadingState.user_id == current_user.id,
ub.KoboReadingState.book_id.notin_(reading_states_in_new_entitlements)))
for kobo_reading_state in changed_reading_states.all(): for kobo_reading_state in changed_reading_states.all():
book = calibre_db.session.query(db.Books).filter(db.Books.id == kobo_reading_state.book_id).one_or_none() book = calibre_db.session.query(db.Books).filter(db.Books.id == kobo_reading_state.book_id).one_or_none()
if book: if book:
@ -237,7 +299,7 @@ def HandleSyncRequest():
}) })
new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified) new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified)
sync_shelves(sync_token, sync_results) sync_shelves(sync_token, sync_results, only_kobo_shelves)
sync_token.books_last_created = new_books_last_created sync_token.books_last_created = new_books_last_created
sync_token.books_last_modified = new_books_last_modified sync_token.books_last_modified = new_books_last_modified
@ -262,12 +324,13 @@ def generate_sync_response(sync_token, sync_results, set_cont=False):
extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode") extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode")
extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads") extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads")
except Exception as e: except Exception as ex:
log.error("Failed to receive or parse response from Kobo's sync endpoint: " + str(e)) log.error("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex))
if set_cont: if set_cont:
extra_headers["x-kobo-sync"] = "continue" extra_headers["x-kobo-sync"] = "continue"
sync_token.to_headers(extra_headers) sync_token.to_headers(extra_headers)
log.debug("Kobo Sync Content: {}".format(sync_results))
response = make_response(jsonify(sync_results), extra_headers) response = make_response(jsonify(sync_results), extra_headers)
return response return response
@ -305,7 +368,8 @@ def get_download_url_for_book(book, book_format):
book_format=book_format.lower() book_format=book_format.lower()
) )
return url_for( return url_for(
"web.download_link", "kobo.download_book",
auth_token=kobo_auth.get_auth_token(),
book_id=book.id, book_id=book.id,
book_format=book_format.lower(), book_format=book_format.lower(),
_external=True, _external=True,
@ -598,13 +662,14 @@ def HandleTagRemoveItem(tag_id):
# Add new, changed, or deleted shelves to the sync_results. # Add new, changed, or deleted shelves to the sync_results.
# Note: Public shelves that aren't owned by the user aren't supported. # Note: Public shelves that aren't owned by the user aren't supported.
def sync_shelves(sync_token, sync_results): def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
new_tags_last_modified = sync_token.tags_last_modified new_tags_last_modified = sync_token.tags_last_modified
for shelf in ub.session.query(ub.ShelfArchive).filter(func.datetime(ub.ShelfArchive.last_modified) > sync_token.tags_last_modified, for shelf in ub.session.query(ub.ShelfArchive).filter(
ub.ShelfArchive.user_id == current_user.id): func.datetime(ub.ShelfArchive.last_modified) > sync_token.tags_last_modified,
ub.ShelfArchive.user_id == current_user.id
):
new_tags_last_modified = max(shelf.last_modified, new_tags_last_modified) new_tags_last_modified = max(shelf.last_modified, new_tags_last_modified)
sync_results.append({ sync_results.append({
"DeletedTag": { "DeletedTag": {
"Tag": { "Tag": {
@ -614,8 +679,40 @@ def sync_shelves(sync_token, sync_results):
} }
}) })
for shelf in ub.session.query(ub.Shelf).filter(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified, extra_filters = []
ub.Shelf.user_id == current_user.id): if only_kobo_shelves:
for shelf in ub.session.query(ub.Shelf).filter(
func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified,
ub.Shelf.user_id == current_user.id,
not ub.Shelf.kobo_sync
):
sync_results.append({
"DeletedTag": {
"Tag": {
"Id": shelf.uuid,
"LastModified": convert_to_kobo_timestamp_string(shelf.last_modified)
}
}
})
extra_filters.append(ub.Shelf.kobo_sync)
if sqlalchemy_version2:
shelflist = ub.session.execute(select(ub.Shelf).outerjoin(ub.BookShelf).filter(
or_(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified,
func.datetime(ub.BookShelf.date_added) > sync_token.tags_last_modified),
ub.Shelf.user_id == current_user.id,
*extra_filters
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())).columns(ub.Shelf)
else:
shelflist = ub.session.query(ub.Shelf).outerjoin(ub.BookShelf).filter(
or_(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified,
func.datetime(ub.BookShelf.date_added) > sync_token.tags_last_modified),
ub.Shelf.user_id == current_user.id,
*extra_filters
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())
for shelf in shelflist:
if not shelf_lib.check_shelf_view_permissions(shelf): if not shelf_lib.check_shelf_view_permissions(shelf):
continue continue

View File

@ -155,7 +155,7 @@ def generate_auth_token(user_id):
for book in books: for book in books:
formats = [data.format for data in book.data] formats = [data.format for data in book.data]
if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats: if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats:
helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.nickname) helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
return render_title_template( return render_title_template(
"generate_kobo_auth_url.html", "generate_kobo_auth_url.html",

View File

@ -62,11 +62,11 @@ class _Logger(logging.Logger):
def debug_no_auth(self, message, *args, **kwargs): def debug_no_auth(self, message, *args, **kwargs):
message = message.strip("\r\n")
if message.startswith("send: AUTH"): if message.startswith("send: AUTH"):
self.debug(message[:16], stacklevel=2, *args, **kwargs) self.debug(message[:16], *args, **kwargs)
else: else:
self.debug(message, stacklevel=2, *args, **kwargs) self.debug(message, *args, **kwargs)
def get(name=None): def get(name=None):
@ -153,11 +153,11 @@ def setup(log_file, log_level=None):
file_handler.baseFilename = log_file file_handler.baseFilename = log_file
else: else:
try: try:
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8')
except IOError: except IOError:
if log_file == DEFAULT_LOG_FILE: if log_file == DEFAULT_LOG_FILE:
raise raise
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=50000, backupCount=2, encoding='utf-8') file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8')
log_file = "" log_file = ""
file_handler.setFormatter(FORMATTER) file_handler.setFormatter(FORMATTER)

View File

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2021 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/>.
# ComicVine api document: https://comicvine.gamespot.com/api/documentation
import requests
from cps.services.Metadata import Metadata
class ComicVine(Metadata):
__name__ = "ComicVine"
__id__ = "comicvine"
def search(self, query, __):
val = list()
apikey = "57558043c53943d5d1e96a9ad425b0eb85532ee6"
if self.active:
headers = {
'User-Agent': 'Not Evil Browser'
}
result = requests.get("https://comicvine.gamespot.com/api/search?api_key="
+ apikey + "&resources=issue&query=" + query + "&sort=name:desc&format=json", headers=headers)
for r in result.json()['results']:
seriesTitle = r['volume'].get('name', "")
if r.get('store_date'):
dateFomers = r.get('store_date')
else:
dateFomers = r.get('date_added')
v = dict()
v['id'] = r['id']
v['title'] = seriesTitle + " #" + r.get('issue_number', "0") + " - " + ( r.get('name', "") or "")
v['authors'] = r.get('authors', [])
v['description'] = r.get('description', "")
v['publisher'] = ""
v['publishedDate'] = dateFomers
v['tags'] = ["Comics", seriesTitle]
v['rating'] = 0
v['series'] = seriesTitle
v['cover'] = r['image'].get('original_url')
v['source'] = {
"id": self.__id__,
"description": "ComicVine Books",
"link": "https://comicvine.gamespot.com/"
}
v['url'] = r.get('site_detail_url', "")
val.append(v)
return val

View File

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2021 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/>.
# Google Books api document: https://developers.google.com/books/docs/v1/using
import requests
from cps.services.Metadata import Metadata
class Google(Metadata):
__name__ = "Google"
__id__ = "google"
def search(self, query, __):
if self.active:
val = list()
result = requests.get("https://www.googleapis.com/books/v1/volumes?q="+query.replace(" ","+"))
for r in result.json()['items']:
v = dict()
v['id'] = r['id']
v['title'] = r['volumeInfo']['title']
v['authors'] = r['volumeInfo'].get('authors', [])
v['description'] = r['volumeInfo'].get('description', "")
v['publisher'] = r['volumeInfo'].get('publisher', "")
v['publishedDate'] = r['volumeInfo'].get('publishedDate', "")
v['tags'] = r['volumeInfo'].get('categories', [])
v['rating'] = r['volumeInfo'].get('averageRating', 0)
if r['volumeInfo'].get('imageLinks'):
v['cover'] = r['volumeInfo']['imageLinks']['thumbnail'].replace("http://", "https://")
else:
v['cover'] = "/../../../static/generic_cover.jpg"
v['source'] = {
"id": self.__id__,
"description": "Google Books",
"link": "https://books.google.com/"}
v['url'] = "https://books.google.com/books?id=" + r['id']
val.append(v)
return val

View File

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2021 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 scholarly import scholarly
from cps.services.Metadata import Metadata
class scholar(Metadata):
__name__ = "Google Scholar"
__id__ = "googlescholar"
def search(self, query, generic_cover=""):
val = list()
if self.active:
scholar_gen = scholarly.search_pubs(' '.join(query.split('+')))
i = 0
for publication in scholar_gen:
v = dict()
v['id'] = "1234" # publication['bib'].get('title')
v['title'] = publication['bib'].get('title')
v['authors'] = publication['bib'].get('author', [])
v['description'] = publication['bib'].get('abstract', "")
v['publisher'] = publication['bib'].get('venue', "")
if publication['bib'].get('pub_year'):
v['publishedDate'] = publication['bib'].get('pub_year')+"-01-01"
else:
v['publishedDate'] = ""
v['tags'] = ""
v['ratings'] = 0
v['series'] = ""
v['cover'] = generic_cover
v['url'] = publication.get('pub_url') or publication.get('eprint_url') or "",
v['source'] = {
"id": self.__id__,
"description": "Google Scholar",
"link": "https://scholar.google.com/"
}
val.append(v)
i += 1
if (i >= 10):
break
return val

View File

@ -30,6 +30,7 @@ from flask_babel import gettext as _
from flask_dance.consumer import oauth_authorized, oauth_error from flask_dance.consumer import oauth_authorized, oauth_error
from flask_dance.contrib.github import make_github_blueprint, github from flask_dance.contrib.github import make_github_blueprint, github
from flask_dance.contrib.google import make_google_blueprint, google from flask_dance.contrib.google import make_google_blueprint, google
from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
from flask_login import login_user, current_user, login_required from flask_login import login_user, current_user, login_required
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
@ -42,6 +43,7 @@ except NameError:
oauth_check = {} oauth_check = {}
oauthblueprints = []
oauth = Blueprint('oauth', __name__) oauth = Blueprint('oauth', __name__)
log = logger.create() log = logger.create()
@ -87,7 +89,7 @@ def register_user_with_oauth(user=None):
except NoResultFound: except NoResultFound:
# no found, return error # no found, return error
return return
ub.session_commit("User {} with OAuth for provider {} registered".format(user.nickname, oauth_key)) ub.session_commit("User {} with OAuth for provider {} registered".format(user.name, oauth_key))
def logout_oauth_user(): def logout_oauth_user():
@ -133,8 +135,8 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
# already bind with user, just login # already bind with user, just login
if oauth_entry.user: if oauth_entry.user:
login_user(oauth_entry.user) login_user(oauth_entry.user)
log.debug(u"You are now logged in as: '%s'", oauth_entry.user.nickname) log.debug(u"You are now logged in as: '%s'", oauth_entry.user.name)
flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.nickname), flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.name),
category="success") category="success")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
else: else:
@ -145,9 +147,10 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
ub.session.add(oauth_entry) ub.session.add(oauth_entry)
ub.session.commit() ub.session.commit()
flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success") flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
log.info("Link to {} Succeeded".format(provider_name))
return redirect(url_for('web.profile')) return redirect(url_for('web.profile'))
except Exception as e: except Exception as ex:
log.debug_or_exception(e) log.debug_or_exception(ex)
ub.session.rollback() ub.session.rollback()
else: else:
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error") flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
@ -193,8 +196,9 @@ def unlink_oauth(provider):
ub.session.commit() ub.session.commit()
logout_oauth_user() logout_oauth_user()
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success") flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
except Exception as e: log.info("Unlink to {} Succeeded".format(oauth_check[provider]))
log.debug_or_exception(e) except Exception as ex:
log.debug_or_exception(ex)
ub.session.rollback() ub.session.rollback()
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error") flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
except NoResultFound: except NoResultFound:
@ -203,7 +207,6 @@ def unlink_oauth(provider):
return redirect(url_for('web.profile')) return redirect(url_for('web.profile'))
def generate_oauth_blueprints(): def generate_oauth_blueprints():
oauthblueprints = []
if not ub.session.query(ub.OAuthProvider).count(): if not ub.session.query(ub.OAuthProvider).count():
for provider in ("github", "google"): for provider in ("github", "google"):
oauthProvider = ub.OAuthProvider() oauthProvider = ub.OAuthProvider()
@ -257,11 +260,13 @@ if ub.oauth_support:
def github_logged_in(blueprint, token): def github_logged_in(blueprint, token):
if not token: if not token:
flash(_(u"Failed to log in with GitHub."), category="error") flash(_(u"Failed to log in with GitHub."), category="error")
log.error("Failed to log in with GitHub")
return False return False
resp = blueprint.session.get("/user") resp = blueprint.session.get("/user")
if not resp.ok: if not resp.ok:
flash(_(u"Failed to fetch user info from GitHub."), category="error") flash(_(u"Failed to fetch user info from GitHub."), category="error")
log.error("Failed to fetch user info from GitHub")
return False return False
github_info = resp.json() github_info = resp.json()
@ -273,11 +278,13 @@ if ub.oauth_support:
def google_logged_in(blueprint, token): def google_logged_in(blueprint, token):
if not token: if not token:
flash(_(u"Failed to log in with Google."), category="error") flash(_(u"Failed to log in with Google."), category="error")
log.error("Failed to log in with Google")
return False return False
resp = blueprint.session.get("/oauth2/v2/userinfo") resp = blueprint.session.get("/oauth2/v2/userinfo")
if not resp.ok: if not resp.ok:
flash(_(u"Failed to fetch user info from Google."), category="error") flash(_(u"Failed to fetch user info from Google."), category="error")
log.error("Failed to fetch user info from Google")
return False return False
google_info = resp.json() google_info = resp.json()
@ -299,39 +306,6 @@ if ub.oauth_support:
) # ToDo: Translate ) # ToDo: Translate
flash(msg, category="error") flash(msg, category="error")
@oauth.route('/link/github')
@oauth_required
def github_login():
if not github.authorized:
return redirect(url_for('github.login'))
account_info = github.get('/user')
if account_info.ok:
account_info_json = account_info.json()
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
flash(_(u"GitHub Oauth error, please retry later."), category="error")
return redirect(url_for('web.login'))
@oauth.route('/unlink/github', methods=["GET"])
@login_required
def github_login_unlink():
return unlink_oauth(oauthblueprints[0]['id'])
@oauth.route('/link/google')
@oauth_required
def google_login():
if not google.authorized:
return redirect(url_for("google.login"))
resp = google.get("/oauth2/v2/userinfo")
if resp.ok:
account_info_json = resp.json()
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
flash(_(u"Google Oauth error, please retry later."), category="error")
return redirect(url_for('web.login'))
@oauth_error.connect_via(oauthblueprints[1]['blueprint']) @oauth_error.connect_via(oauthblueprints[1]['blueprint'])
def google_error(blueprint, error, error_description=None, error_uri=None): def google_error(blueprint, error, error_description=None, error_uri=None):
msg = ( msg = (
@ -346,6 +320,48 @@ if ub.oauth_support:
flash(msg, category="error") flash(msg, category="error")
@oauth.route('/link/github')
@oauth_required
def github_login():
if not github.authorized:
return redirect(url_for('github.login'))
try:
account_info = github.get('/user')
if account_info.ok:
account_info_json = account_info.json()
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
flash(_(u"GitHub Oauth error, please retry later."), category="error")
log.error("GitHub Oauth error, please retry later")
except (InvalidGrantError, TokenExpiredError) as e:
flash(_(u"GitHub Oauth error: {}").format(e), category="error")
log.error(e)
return redirect(url_for('web.login'))
@oauth.route('/unlink/github', methods=["GET"])
@login_required
def github_login_unlink():
return unlink_oauth(oauthblueprints[0]['id'])
@oauth.route('/link/google')
@oauth_required
def google_login():
if not google.authorized:
return redirect(url_for("google.login"))
try:
resp = google.get("/oauth2/v2/userinfo")
if resp.ok:
account_info_json = resp.json()
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
flash(_(u"Google Oauth error, please retry later."), category="error")
log.error("Google Oauth error, please retry later")
except (InvalidGrantError, TokenExpiredError) as e:
flash(_(u"Google Oauth error: {}").format(e), category="error")
log.error(e)
return redirect(url_for('web.login'))
@oauth.route('/unlink/google', methods=["GET"]) @oauth.route('/unlink/google', methods=["GET"])
@login_required @login_required
def google_login_unlink(): def google_login_unlink():

View File

@ -27,7 +27,7 @@ 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 sqlalchemy.sql.expression import func, text, or_, and_ from sqlalchemy.sql.expression import func, text, or_, and_, true
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, get_locale, isoLanguages
@ -94,7 +94,45 @@ def feed_cc_search(query):
@opds.route("/opds/search", methods=["GET"]) @opds.route("/opds/search", methods=["GET"])
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_normal_search(): def feed_normal_search():
return feed_search(request.args.get("query").strip()) return feed_search(request.args.get("query", "").strip())
@opds.route("/opds/books")
@requires_basic_auth_if_no_ano
def feed_booksindex():
shift = 0
off = int(request.args.get("offset") or 0)
entries = calibre_db.session.query(func.upper(func.substr(db.Books.sort, 1, 1)).label('id'))\
.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(db.Books.sort, 1, 1))).all()
elements = []
if off == 0:
elements.append({'id': "00", 'name':_("All")})
shift = 1
for entry in entries[
off + shift - 1:
int(off + int(config.config_books_per_page) - shift)]:
elements.append({'id': entry.id, 'name': entry.id})
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries) + 1)
return render_xml_template('feed.xml',
letterelements=elements,
folder='opds.feed_letter_books',
pagination=pagination)
@opds.route("/opds/books/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_books(book_id):
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Books.sort).startswith(book_id)
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
db.Books,
letter,
[db.Books.sort])
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
@opds.route("/opds/new") @opds.route("/opds/new")
@ -150,14 +188,41 @@ def feed_hot():
@opds.route("/opds/author") @opds.route("/opds/author")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_authorindex(): def feed_authorindex():
off = request.args.get("offset") or 0 shift = 0
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\ off = int(request.args.get("offset") or 0)
.filter(calibre_db.common_filters())\ entries = calibre_db.session.query(func.upper(func.substr(db.Authors.sort, 1, 1)).label('id'))\
.group_by(text('books_authors_link.author'))\ .join(db.books_authors_link).join(db.Books).filter(calibre_db.common_filters())\
.order_by(db.Authors.sort).limit(config.config_books_per_page)\ .group_by(func.upper(func.substr(db.Authors.sort, 1, 1))).all()
.offset(off)
elements = []
if off == 0:
elements.append({'id': "00", 'name':_("All")})
shift = 1
for entry in entries[
off + shift - 1:
int(off + int(config.config_books_per_page) - shift)]:
elements.append({'id': entry.id, 'name': entry.id})
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(calibre_db.session.query(db.Authors).all())) len(entries) + 1)
return render_xml_template('feed.xml',
letterelements=elements,
folder='opds.feed_letter_author',
pagination=pagination)
@opds.route("/opds/author/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_author(book_id):
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\
.filter(calibre_db.common_filters()).filter(letter)\
.group_by(text('books_authors_link.author'))\
.order_by(db.Authors.sort)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
entries.count())
entries = entries.limit(config.config_books_per_page).offset(off).all()
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination) return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination)
@ -201,17 +266,41 @@ def feed_publisher(book_id):
@opds.route("/opds/category") @opds.route("/opds/category")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_categoryindex(): def feed_categoryindex():
shift = 0
off = int(request.args.get("offset") or 0)
entries = calibre_db.session.query(func.upper(func.substr(db.Tags.name, 1, 1)).label('id'))\
.join(db.books_tags_link).join(db.Books).filter(calibre_db.common_filters())\
.group_by(func.upper(func.substr(db.Tags.name, 1, 1))).all()
elements = []
if off == 0:
elements.append({'id': "00", 'name':_("All")})
shift = 1
for entry in entries[
off + shift - 1:
int(off + int(config.config_books_per_page) - shift)]:
elements.append({'id': entry.id, 'name': entry.id})
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries) + 1)
return render_xml_template('feed.xml',
letterelements=elements,
folder='opds.feed_letter_category',
pagination=pagination)
@opds.route("/opds/category/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_category(book_id):
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
entries = calibre_db.session.query(db.Tags)\ entries = calibre_db.session.query(db.Tags)\
.join(db.books_tags_link)\ .join(db.books_tags_link)\
.join(db.Books)\ .join(db.Books)\
.filter(calibre_db.common_filters())\ .filter(calibre_db.common_filters()).filter(letter)\
.group_by(text('books_tags_link.tag'))\ .group_by(text('books_tags_link.tag'))\
.order_by(db.Tags.name)\ .order_by(db.Tags.name)
.offset(off)\
.limit(config.config_books_per_page)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(calibre_db.session.query(db.Tags).all())) entries.count())
entries = entries.offset(off).limit(config.config_books_per_page).all()
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination) return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination)
@ -229,16 +318,40 @@ def feed_category(book_id):
@opds.route("/opds/series") @opds.route("/opds/series")
@requires_basic_auth_if_no_ano @requires_basic_auth_if_no_ano
def feed_seriesindex(): def feed_seriesindex():
shift = 0
off = int(request.args.get("offset") or 0)
entries = calibre_db.session.query(func.upper(func.substr(db.Series.sort, 1, 1)).label('id'))\
.join(db.books_series_link).join(db.Books).filter(calibre_db.common_filters())\
.group_by(func.upper(func.substr(db.Series.sort, 1, 1))).all()
elements = []
if off == 0:
elements.append({'id': "00", 'name':_("All")})
shift = 1
for entry in entries[
off + shift - 1:
int(off + int(config.config_books_per_page) - shift)]:
elements.append({'id': entry.id, 'name': entry.id})
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(entries) + 1)
return render_xml_template('feed.xml',
letterelements=elements,
folder='opds.feed_letter_series',
pagination=pagination)
@opds.route("/opds/series/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_series(book_id):
off = request.args.get("offset") or 0 off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
entries = calibre_db.session.query(db.Series)\ entries = calibre_db.session.query(db.Series)\
.join(db.books_series_link)\ .join(db.books_series_link)\
.join(db.Books)\ .join(db.Books)\
.filter(calibre_db.common_filters())\ .filter(calibre_db.common_filters()).filter(letter)\
.group_by(text('books_series_link.series'))\ .group_by(text('books_series_link.series'))\
.order_by(db.Series.sort)\ .order_by(db.Series.sort)
.offset(off).all()
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(calibre_db.session.query(db.Series).all())) entries.count())
entries = entries.offset(off).limit(config.config_books_per_page).all()
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination) return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination)
@ -269,7 +382,7 @@ def feed_ratingindex():
len(entries)) len(entries))
element = list() element = list()
for entry in entries: for entry in entries:
element.append(FeedObject(entry[0].id, "{} Stars".format(entry.name))) element.append(FeedObject(entry[0].id, _("{} Stars").format(entry.name)))
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination) return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination)
@ -428,13 +541,13 @@ def check_auth(username, password):
username = username.encode('windows-1252') username = username.encode('windows-1252')
except UnicodeEncodeError: except UnicodeEncodeError:
username = username.encode('utf-8') username = username.encode('utf-8')
user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == user = ub.session.query(ub.User).filter(func.lower(ub.User.name) ==
username.decode('utf-8').lower()).first() username.decode('utf-8').lower()).first()
if bool(user and check_password_hash(str(user.password), password)): if bool(user and check_password_hash(str(user.password), password)):
return True return True
else: else:
ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) ip_Address = request.headers.get('X-Forwarded-For', request.remote_addr)
log.warning('OPDS Login failed for user "%s" IP-address: %s', username.decode('utf-8'), ipAdress) log.warning('OPDS Login failed for user "%s" IP-address: %s', username.decode('utf-8'), ip_Address)
return False return False

View File

@ -62,7 +62,7 @@ def remote_login():
ub.session_commit() ub.session_commit()
verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true) verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true)
log.debug(u"Remot Login request with token: %s", auth_token.auth_token) log.debug(u"Remot Login request with token: %s", auth_token.auth_token)
return render_title_template('remote_login.html', title=_(u"login"), token=auth_token.auth_token, return render_title_template('remote_login.html', title=_(u"Login"), token=auth_token.auth_token,
verify_url=verify_url, page="remotelogin") verify_url=verify_url, page="remotelogin")
@ -126,11 +126,11 @@ def token_verified():
login_user(user) login_user(user)
ub.session.delete(auth_token) ub.session.delete(auth_token)
ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.nickname)) ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.name))
data['status'] = 'success' data['status'] = 'success'
log.debug(u"Remote Login for userid %s succeded", user.id) log.debug(u"Remote Login for userid %s succeded", user.id)
flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.name), category="success")
response = make_response(json.dumps(data, ensure_ascii=False)) response = make_response(json.dumps(data, ensure_ascii=False))
response.headers["Content-Type"] = "application/json; charset=utf-8" response.headers["Content-Type"] = "application/json; charset=utf-8"

View File

@ -42,6 +42,12 @@ def get_sidebar_config(kwargs=None):
sidebar.append({"glyph": "glyphicon-fire", "text": _('Hot Books'), "link": 'web.books_list', "id": "hot", sidebar.append({"glyph": "glyphicon-fire", "text": _('Hot Books'), "link": 'web.books_list', "id": "hot",
"visibility": constants.SIDEBAR_HOT, 'public': True, "page": "hot", "visibility": constants.SIDEBAR_HOT, 'public': True, "page": "hot",
"show_text": _('Show Hot Books'), "config_show": True}) "show_text": _('Show Hot Books'), "config_show": True})
if current_user.role_admin():
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous),
"page": "download", "show_text": _('Show Downloaded Books'),
"config_show": content})
else:
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list', sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list',
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous), "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous),
"page": "download", "show_text": _('Show Downloaded Books'), "page": "download", "show_text": _('Show Downloaded Books'),
@ -59,7 +65,7 @@ def get_sidebar_config(kwargs=None):
"show_text": _('Show unread'), "config_show": False}) "show_text": _('Show unread'), "config_show": False})
sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand", sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand",
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover", "visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
"show_text": _('Show random books'), "config_show": True}) "show_text": _('Show Random Books'), "config_show": True})
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat", sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
"visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category", "visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category",
"show_text": _('Show category selection'), "config_show": True}) "show_text": _('Show category selection'), "config_show": True})

118
cps/search_metadata.py Normal file
View File

@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2021 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 __future__ import division, print_function, unicode_literals
import os
import json
import importlib
import sys
import inspect
import datetime
import concurrent.futures
from flask import Blueprint, request, Response, url_for
from flask_login import current_user
from flask_login import login_required
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import OperationalError, InvalidRequestError
from . import constants, logger, ub
from cps.services.Metadata import Metadata
meta = Blueprint('metadata', __name__)
log = logger.create()
new_list = list()
meta_dir = os.path.join(constants.BASE_DIR, "cps", "metadata_provider")
modules = os.listdir(os.path.join(constants.BASE_DIR, "cps", "metadata_provider"))
for f in modules:
if os.path.isfile(os.path.join(meta_dir, f)) and not f.endswith('__init__.py'):
a = os.path.basename(f)[:-3]
try:
importlib.import_module("cps.metadata_provider." + a)
new_list.append(a)
except ImportError:
log.error("Import error for metadata source: {}".format(a))
pass
def list_classes(provider_list):
classes = list()
for element in provider_list:
for name, obj in inspect.getmembers(sys.modules["cps.metadata_provider." + element]):
if inspect.isclass(obj) and name != "Metadata" and issubclass(obj, Metadata):
classes.append(obj())
return classes
cl = list_classes(new_list)
@meta.route("/metadata/provider")
@login_required
def metadata_provider():
active = current_user.view_settings.get('metadata', {})
provider = list()
for c in cl:
ac = active.get(c.__id__, True)
provider.append({"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__})
return Response(json.dumps(provider), mimetype='application/json')
@meta.route("/metadata/provider", methods=['POST'])
@meta.route("/metadata/provider/<prov_name>", methods=['POST'])
@login_required
def metadata_change_active_provider(prov_name):
new_state = request.get_json()
active = current_user.view_settings.get('metadata', {})
active[new_state['id']] = new_state['value']
current_user.view_settings['metadata'] = active
try:
try:
flag_modified(current_user, "view_settings")
except AttributeError:
pass
ub.session.commit()
except (InvalidRequestError, OperationalError):
log.error("Invalid request received: {}".format(request))
return "Invalid request", 400
if "initial" in new_state and prov_name:
for c in cl:
if c.__id__ == prov_name:
data = c.search(new_state.get('query', ""))
break
return Response(json.dumps(data), mimetype='application/json')
return ""
@meta.route("/metadata/search", methods=['POST'])
@login_required
def metadata_search():
query = request.form.to_dict().get('query')
data = list()
active = current_user.view_settings.get('metadata', {})
if query:
static_cover = url_for('static', filename='generic_cover.jpg')
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
meta = {executor.submit(c.search, query, static_cover): c for c in cl if active.get(c.__id__, True)}
for future in concurrent.futures.as_completed(meta):
data.extend(future.result())
return Response(json.dumps(data), mimetype='application/json')

27
cps/services/Metadata.py Normal file
View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
# Copyright (C) 2021 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/>.
class Metadata():
__name__ = "Generic"
def __init__(self):
self.active = True
def set_status(self, state):
self.active = state

View File

@ -183,3 +183,12 @@ class SyncToken:
}, },
} }
return b64encode_json(token) return b64encode_json(token)
def __str__(self):
return "{},{},{},{},{},{},{}".format(self.raw_kobo_store_token,
self.books_last_created,
self.books_last_modified,
self.archive_last_modified,
self.reading_state_last_modified,
self.tags_last_modified,
self.books_last_id)

View File

@ -45,3 +45,9 @@ except ImportError as err:
log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err) log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
kobo = None kobo = None
SyncToken = None SyncToken = None
try:
from . import gmail
except ImportError as err:
log.debug("Cannot import gmail, sending books via Gmail Oauth2 Verification will not work: %s", err)
gmail = None

83
cps/services/gmail.py Normal file
View File

@ -0,0 +1,83 @@
from __future__ import print_function
import os.path
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from datetime import datetime
import base64
from flask_babel import gettext as _
from ..constants import BASE_DIR
from .. import logger
log = logger.create()
SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/userinfo.email']
def setup_gmail(token):
# If there are no (valid) credentials available, let the user log in.
creds = None
if "token" in token:
creds = Credentials(
token=token['token'],
refresh_token=token['refresh_token'],
token_uri=token['token_uri'],
client_id=token['client_id'],
client_secret=token['client_secret'],
scopes=token['scopes'],
)
creds.expiry = datetime.fromisoformat(token['expiry'])
if not creds or not creds.valid:
# don't forget to dump one more time after the refresh
# also, some file-locking routines wouldn't be needless
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
cred_file = os.path.join(BASE_DIR, 'gmail.json')
if not os.path.exists(cred_file):
raise Exception(_("Found no valid gmail.json file with OAuth information"))
flow = InstalledAppFlow.from_client_secrets_file(
os.path.join(BASE_DIR, 'gmail.json'), SCOPES)
creds = flow.run_local_server(port=0)
user_info = get_user_info(creds)
return {
'token': creds.token,
'refresh_token': creds.refresh_token,
'token_uri': creds.token_uri,
'client_id': creds.client_id,
'client_secret': creds.client_secret,
'scopes': creds.scopes,
'expiry': creds.expiry.isoformat(),
'email': user_info
}
return {}
def get_user_info(credentials):
user_info_service = build(serviceName='oauth2', version='v2',credentials=credentials)
user_info = user_info_service.userinfo().get().execute()
return user_info.get('email', "")
def send_messsage(token, msg):
log.debug("Start sending e-mail via Gmail")
creds = Credentials(
token=token['token'],
refresh_token=token['refresh_token'],
token_uri=token['token_uri'],
client_id=token['client_id'],
client_secret=token['client_secret'],
scopes=token['scopes'],
)
creds.expiry = datetime.fromisoformat(token['expiry'])
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
service = build('gmail', 'v1', credentials=creds)
message_as_bytes = msg.as_bytes() # the message should converted from string to bytes.
message_as_base64 = base64.urlsafe_b64encode(message_as_bytes) # encode in base64 (printable letters coding)
raw = message_as_base64.decode() # convert to something JSON serializable
body = {'raw': raw}
(service.users().messages().send(userId='me', body=body).execute())
log.debug("E-mail send successfully via Gmail")

View File

@ -69,6 +69,7 @@ class WorkerThread(threading.Thread):
def add(cls, user, task): def add(cls, user, task):
ins = cls.getInstance() ins = cls.getInstance()
ins.num += 1 ins.num += 1
log.debug("Add Task for user: {}: {}".format(user, task))
ins.queue.put(QueuedTask( ins.queue.put(QueuedTask(
num=ins.num, num=ins.num,
user=user, user=user,
@ -164,9 +165,9 @@ class CalibreTask:
# catch any unhandled exceptions in a task and automatically fail it # catch any unhandled exceptions in a task and automatically fail it
try: try:
self.run(*args) self.run(*args)
except Exception as e: except Exception as ex:
self._handleError(str(e)) self._handleError(str(ex))
log.debug_or_exception(e) log.debug_or_exception(ex)
self.end_time = datetime.now() self.end_time = datetime.now()
@ -209,10 +210,13 @@ class CalibreTask:
# 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)
@progress.setter '''@progress.setter
def progress(self, x): def progress(self, x):
# todo: throw error if outside of [0,1] if x > 1:
self._progress = x x = 1
if x < 0:
x = 0
self._progress = x'''
@property @property
def self_cleanup(self): def self_cleanup(self):

View File

@ -21,20 +21,20 @@
# 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 __future__ import division, print_function, unicode_literals from __future__ import division, print_function, unicode_literals
from datetime import datetime
import sys import sys
from datetime import datetime
from flask import Blueprint, request, flash, redirect, url_for from flask import Blueprint, flash, redirect, request, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_login import login_required, current_user from flask_login import current_user, login_required
from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.sql.expression import func, true from sqlalchemy.sql.expression import func, true
from sqlalchemy.exc import OperationalError, InvalidRequestError
from . import logger, ub, calibre_db, db 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__) shelf = Blueprint('shelf', __name__)
log = logger.create() log = logger.create()
@ -72,10 +72,9 @@ def add_to_shelf(shelf_id, book_id):
if not check_shelf_edit_permissions(shelf): if not check_shelf_edit_permissions(shelf):
if not xhr: if not xhr:
flash(_(u"Sorry you are not allowed to add a book to the the shelf: %(shelfname)s", shelfname=shelf.name), flash(_(u"Sorry you are not allowed to add a book to that shelf"), category="error")
category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
return "Sorry you are not allowed to add a book to the the shelf: %s" % shelf.name, 403 return "Sorry you are not allowed to add a book to the that shelf", 403
book_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id, book_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id,
ub.BookShelf.book_id == book_id).first() ub.BookShelf.book_id == book_id).first()
@ -99,12 +98,14 @@ def add_to_shelf(shelf_id, book_id):
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
log.error("Settings DB is not Writeable")
flash(_(u"Settings DB is not Writeable"), category="error") flash(_(u"Settings DB is not Writeable"), category="error")
if "HTTP_REFERER" in request.environ: if "HTTP_REFERER" in request.environ:
return redirect(request.environ["HTTP_REFERER"]) return redirect(request.environ["HTTP_REFERER"])
else: else:
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if not xhr: if not xhr:
log.debug("Book has been added to shelf: {}".format(shelf.name))
flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success") flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success")
if "HTTP_REFERER" in request.environ: if "HTTP_REFERER" in request.environ:
return redirect(request.environ["HTTP_REFERER"]) return redirect(request.environ["HTTP_REFERER"])
@ -123,6 +124,7 @@ def search_to_shelf(shelf_id):
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if not check_shelf_edit_permissions(shelf): if not check_shelf_edit_permissions(shelf):
log.warning("You are not allowed to add a book to the the shelf: {}".format(shelf.name))
flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error") flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@ -140,7 +142,7 @@ def search_to_shelf(shelf_id):
books_for_shelf = ub.searched_ids[current_user.id] books_for_shelf = ub.searched_ids[current_user.id]
if not books_for_shelf: if not books_for_shelf:
log.error("Books are already part of %s", shelf.name) log.error("Books are already part of {}".format(shelf.name))
flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error") flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@ -156,8 +158,10 @@ def search_to_shelf(shelf_id):
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success") flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
else: else:
log.error("Could not add books to shelf: {}".format(shelf.name))
flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error") flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@ -168,7 +172,7 @@ def remove_from_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest' xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf is None: if shelf is None:
log.error("Invalid shelf specified: %s", shelf_id) log.error("Invalid shelf specified: {}".format(shelf_id))
if not xhr: if not xhr:
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
return "Invalid shelf specified", 400 return "Invalid shelf specified", 400
@ -197,7 +201,8 @@ def remove_from_shelf(shelf_id, book_id):
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
if "HTTP_REFERER" in request.environ: if "HTTP_REFERER" in request.environ:
return redirect(request.environ["HTTP_REFERER"]) return redirect(request.environ["HTTP_REFERER"])
else: else:
@ -211,6 +216,7 @@ def remove_from_shelf(shelf_id, book_id):
return "", 204 return "", 204
else: else:
if not xhr: if not xhr:
log.warning("You are not allowed to remove a book from shelf: {}".format(shelf.name))
flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name), flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name),
category="error") category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@ -221,74 +227,86 @@ def remove_from_shelf(shelf_id, book_id):
@login_required @login_required
def create_shelf(): def create_shelf():
shelf = ub.Shelf() shelf = ub.Shelf()
return create_edit_shelf(shelf, title=_(u"Create a Shelf"), page="shelfcreate") return create_edit_shelf(shelf, page_title=_(u"Create a Shelf"), page="shelfcreate")
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"]) @shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
@login_required @login_required
def edit_shelf(shelf_id): def edit_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
return create_edit_shelf(shelf, title=_(u"Edit a shelf"), page="shelfedit", shelf_id=shelf_id) if not check_shelf_edit_permissions(shelf):
flash(_(u"Sorry you are not allowed to edit this shelf"), category="error")
return redirect(url_for('web.index'))
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 # if shelf ID is set, we are editing a shelf
def create_edit_shelf(shelf, title, page, shelf_id=False): 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": if request.method == "POST":
to_save = request.form.to_dict() to_save = request.form.to_dict()
if "is_public" in to_save: shelf.is_public = 1 if to_save.get("is_public") else 0
shelf.is_public = 1 if config.config_kobo_sync:
else: shelf.kobo_sync = True if to_save.get("kobo_sync") else False
shelf.is_public = 0 shelf_title = to_save.get("title", "")
if check_shelf_is_unique(shelf, to_save, shelf_id): if check_shelf_is_unique(shelf, shelf_title, shelf_id):
shelf.name = to_save["title"] shelf.name = shelf_title
# shelf.last_modified = datetime.utcnow()
if not shelf_id: if not shelf_id:
shelf.user_id = int(current_user.id) shelf.user_id = int(current_user.id)
ub.session.add(shelf) ub.session.add(shelf)
shelf_action = "created" shelf_action = "created"
flash_text = _(u"Shelf %(title)s created", title=to_save["title"]) flash_text = _(u"Shelf %(title)s created", title=shelf_title)
else: else:
shelf_action = "changed" shelf_action = "changed"
flash_text = _(u"Shelf %(title)s changed", title=to_save["title"]) flash_text = _(u"Shelf %(title)s changed", title=shelf_title)
try: try:
ub.session.commit() ub.session.commit()
log.info(u"Shelf {} {}".format(to_save["title"], shelf_action)) log.info(u"Shelf {} {}".format(shelf_title, shelf_action))
flash(flash_text, category="success") flash(flash_text, category="success")
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id)) return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
except (OperationalError, InvalidRequestError) as e: except (OperationalError, InvalidRequestError) as ex:
ub.session.rollback() ub.session.rollback()
log.debug_or_exception(e) log.debug_or_exception(ex)
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
except Exception as e: flash(_("Settings DB is not Writeable"), category="error")
except Exception as ex:
ub.session.rollback() ub.session.rollback()
log.debug_or_exception(e) log.debug_or_exception(ex)
flash(_(u"There was an error"), category="error") flash(_(u"There was an error"), category="error")
return render_title_template('shelf_edit.html', shelf=shelf, title=title, page=page) 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, to_save, shelf_id=False): def check_shelf_is_unique(shelf, title, shelf_id=False):
if shelf_id: if shelf_id:
ident = ub.Shelf.id != shelf_id ident = ub.Shelf.id != shelf_id
else: else:
ident = true() ident = true()
if shelf.is_public == 1: if shelf.is_public == 1:
is_shelf_name_unique = ub.session.query(ub.Shelf) \ is_shelf_name_unique = ub.session.query(ub.Shelf) \
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1)) \ .filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 1)) \
.filter(ident) \ .filter(ident) \
.first() is None .first() is None
if not is_shelf_name_unique: if not is_shelf_name_unique:
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]), 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") category="error")
else: else:
is_shelf_name_unique = ub.session.query(ub.Shelf) \ is_shelf_name_unique = ub.session.query(ub.Shelf) \
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) & .filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 0) &
(ub.Shelf.user_id == int(current_user.id))) \ (ub.Shelf.user_id == int(current_user.id))) \
.filter(ident) \ .filter(ident) \
.first() is None .first() is None
if not is_shelf_name_unique: if not is_shelf_name_unique:
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]), 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") category="error")
return is_shelf_name_unique return is_shelf_name_unique
@ -311,7 +329,8 @@ def delete_shelf(shelf_id):
delete_shelf_helper(cur_shelf) delete_shelf_helper(cur_shelf)
except InvalidRequestError: except InvalidRequestError:
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
@ -345,7 +364,8 @@ def order_shelf(shelf_id):
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first() shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
result = list() result = list()
@ -360,7 +380,9 @@ def order_shelf(shelf_id):
def change_shelf_order(shelf_id, order): def change_shelf_order(shelf_id, order):
result = calibre_db.session.query(db.Books).join(ub.BookShelf,ub.BookShelf.book_id == db.Books.id)\ result = calibre_db.session.query(db.Books).outerjoin(db.books_series_link,
db.Books.id == db.books_series_link.c.book)\
.outerjoin(db.Series).join(ub.BookShelf, ub.BookShelf.book_id == db.Books.id) \
.filter(ub.BookShelf.shelf == shelf_id).order_by(*order).all() .filter(ub.BookShelf.shelf == shelf_id).order_by(*order).all()
for index, entry in enumerate(result): for index, entry in enumerate(result):
book = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \ book = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \
@ -390,9 +412,11 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
if sort_param == 'old': if sort_param == 'old':
change_shelf_order(shelf_id, [db.Books.timestamp]) change_shelf_order(shelf_id, [db.Books.timestamp])
if sort_param == 'authaz': if sort_param == 'authaz':
change_shelf_order(shelf_id, [db.Books.author_sort.asc()]) change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
if sort_param == 'authza': if sort_param == 'authza':
change_shelf_order(shelf_id, [db.Books.author_sort.desc()]) change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
db.Series.name.desc(),
db.Books.series_index.desc()])
page = "shelf.html" page = "shelf.html"
pagesize = 0 pagesize = 0
else: else:
@ -415,7 +439,8 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
ub.session.commit() ub.session.commit()
except (OperationalError, InvalidRequestError): except (OperationalError, InvalidRequestError):
ub.session.rollback() ub.session.rollback()
flash(_(u"Settings DB is not Writeable"), category="error") log.error("Settings DB is not Writeable")
flash(_("Settings DB is not Writeable"), category="error")
return render_title_template(page, return render_title_template(page,
entries=result, entries=result,

Binary file not shown.

Binary file not shown.

BIN
cps/static/cmaps/78-H.bcmap Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
cps/static/cmaps/78-V.bcmap Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
cps/static/cmaps/B5-H.bcmap Normal file

Binary file not shown.

BIN
cps/static/cmaps/B5-V.bcmap Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,3 @@
àRCopyright 1990-2009 Adobe Systems Incorporated.
All rights reserved.
See ./LICENSEáCNS2-H

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More