Hardcover metadata and read sync
This commit is contained in:
@@ -62,6 +62,7 @@ feature_support = {
|
||||
'ldap': bool(services.ldap),
|
||||
'goodreads': bool(services.goodreads_support),
|
||||
'kobo': bool(services.kobo),
|
||||
'hardcover' : bool(services.hardcover),
|
||||
'updater': constants.UPDATER_AVAILABLE,
|
||||
'gmail': bool(services.gmail),
|
||||
'scheduler': schedule.use_APScheduler,
|
||||
@@ -1831,6 +1832,7 @@ def _configuration_update_helper():
|
||||
reboot_required |= _config_checkbox_int(to_save, "config_kobo_sync")
|
||||
_config_int(to_save, "config_external_port")
|
||||
_config_checkbox_int(to_save, "config_kobo_proxy")
|
||||
_config_checkbox_int(to_save, "config_hardcover_sync")
|
||||
|
||||
if "config_upload_formats" in to_save:
|
||||
to_save["config_upload_formats"] = ','.join(
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2019 OzzieIsaacs, pwr
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.sql.expression import text
|
||||
from sqlalchemy import exists
|
||||
from cryptography.fernet import Fernet
|
||||
import cryptography.exceptions
|
||||
from base64 import urlsafe_b64decode
|
||||
try:
|
||||
# Compatibility with sqlalchemy 2.0
|
||||
from sqlalchemy.orm import declarative_base
|
||||
except ImportError:
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from . import constants, logger
|
||||
from .subproc_wrapper import process_wait
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
_Base = declarative_base()
|
||||
|
||||
|
||||
class _Flask_Settings(_Base):
|
||||
__tablename__ = 'flask_settings'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
flask_session_key = Column(BLOB, default=b"")
|
||||
|
||||
def __init__(self, key):
|
||||
super().__init__()
|
||||
self.flask_session_key = key
|
||||
|
||||
|
||||
# Baseclass for representing settings in app.db with email server settings and Calibre database settings
|
||||
# (application settings)
|
||||
class _Settings(_Base):
|
||||
__tablename__ = 'settings'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
mail_server = Column(String, default=constants.DEFAULT_MAIL_SERVER)
|
||||
mail_port = Column(Integer, default=25)
|
||||
mail_use_ssl = Column(SmallInteger, default=0)
|
||||
mail_login = Column(String, default='mail@example.com')
|
||||
mail_password_e = Column(String)
|
||||
mail_password = Column(String)
|
||||
mail_from = Column(String, default='automailer <mail@example.com>')
|
||||
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_uuid = Column(String)
|
||||
config_calibre_split = Column(Boolean, default=False)
|
||||
config_calibre_split_dir = Column(String)
|
||||
config_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||
config_external_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||
config_certfile = Column(String)
|
||||
config_keyfile = Column(String)
|
||||
config_trustedhosts = Column(String, default='')
|
||||
config_calibre_web_title = Column(String, default='Calibre-Web')
|
||||
config_books_per_page = Column(Integer, default=60)
|
||||
config_random_books = Column(Integer, default=4)
|
||||
config_authors_max = Column(Integer, default=0)
|
||||
config_read_column = Column(Integer, default=0)
|
||||
config_title_regex = Column(String,
|
||||
default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine'
|
||||
r'|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
|
||||
config_theme = Column(Integer, default=0)
|
||||
|
||||
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
|
||||
config_logfile = Column(String, default=logger.DEFAULT_LOG_FILE)
|
||||
config_access_log = Column(SmallInteger, default=0)
|
||||
config_access_logfile = Column(String, default=logger.DEFAULT_ACCESS_LOG)
|
||||
|
||||
config_uploading = Column(SmallInteger, default=0)
|
||||
config_anonbrowse = Column(SmallInteger, default=0)
|
||||
config_public_reg = Column(SmallInteger, default=0)
|
||||
config_remote_login = Column(Boolean, default=False)
|
||||
config_kobo_sync = Column(Boolean, default=False)
|
||||
config_hardcover_sync = Column(Boolean, default=False)
|
||||
|
||||
config_default_role = Column(SmallInteger, default=0)
|
||||
config_default_show = Column(SmallInteger, default=constants.ADMIN_USER_SIDEBAR)
|
||||
config_default_language = Column(String(3), default="all")
|
||||
config_default_locale = Column(String(2), default="en")
|
||||
config_columns_to_ignore = Column(String)
|
||||
|
||||
config_denied_tags = Column(String, default="")
|
||||
config_allowed_tags = Column(String, default="")
|
||||
config_restricted_column = Column(SmallInteger, default=0)
|
||||
config_denied_column_value = Column(String, default="")
|
||||
config_allowed_column_value = Column(String, default="")
|
||||
|
||||
config_use_google_drive = Column(Boolean, default=False)
|
||||
config_google_drive_folder = Column(String)
|
||||
config_google_drive_watch_changes_response = Column(JSON, default={})
|
||||
|
||||
config_use_goodreads = Column(Boolean, default=False)
|
||||
config_goodreads_api_key = Column(String)
|
||||
config_register_email = Column(Boolean, default=False)
|
||||
config_login_type = Column(Integer, default=0)
|
||||
|
||||
config_kobo_proxy = Column(Boolean, default=False)
|
||||
|
||||
config_ldap_provider_url = Column(String, default='example.org')
|
||||
config_ldap_port = Column(SmallInteger, default=389)
|
||||
config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE)
|
||||
config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org')
|
||||
config_ldap_serv_password_e = Column(String)
|
||||
config_ldap_serv_password = Column(String)
|
||||
config_ldap_encryption = Column(SmallInteger, default=0)
|
||||
config_ldap_cacert_path = Column(String, default="")
|
||||
config_ldap_cert_path = Column(String, default="")
|
||||
config_ldap_key_path = Column(String, default="")
|
||||
config_ldap_dn = Column(String, default='dc=example,dc=org')
|
||||
config_ldap_user_object = Column(String, default='uid=%s')
|
||||
config_ldap_member_user_object = Column(String, default='')
|
||||
config_ldap_openldap = Column(Boolean, default=True)
|
||||
config_ldap_group_object_filter = Column(String, default='(&(objectclass=posixGroup)(cn=%s))')
|
||||
config_ldap_group_members_field = Column(String, default='memberUid')
|
||||
config_ldap_group_name = Column(String, default='calibreweb')
|
||||
|
||||
config_kepubifypath = Column(String, default=None)
|
||||
config_converterpath = Column(String, default=None)
|
||||
config_binariesdir = Column(String, default=None)
|
||||
config_calibre = Column(String)
|
||||
config_rarfile_location = Column(String, default=None)
|
||||
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
||||
config_unicode_filename = Column(Boolean, default=False)
|
||||
config_embed_metadata = Column(Boolean, default=True)
|
||||
|
||||
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
||||
|
||||
config_reverse_proxy_login_header_name = Column(String)
|
||||
config_allow_reverse_proxy_header_login = Column(Boolean, default=False)
|
||||
|
||||
schedule_start_time = Column(Integer, default=4)
|
||||
schedule_duration = Column(Integer, default=10)
|
||||
schedule_generate_book_covers = Column(Boolean, default=False)
|
||||
schedule_generate_series_covers = Column(Boolean, default=False)
|
||||
schedule_reconnect = Column(Boolean, default=False)
|
||||
schedule_metadata_backup = Column(Boolean, default=False)
|
||||
|
||||
config_password_policy = Column(Boolean, default=True)
|
||||
config_password_min_length = Column(Integer, default=8)
|
||||
config_password_number = Column(Boolean, default=True)
|
||||
config_password_lower = Column(Boolean, default=True)
|
||||
config_password_upper = Column(Boolean, default=True)
|
||||
config_password_character = Column(Boolean, default=True)
|
||||
config_password_special = Column(Boolean, default=True)
|
||||
config_session = Column(Integer, default=1)
|
||||
config_ratelimiter = Column(Boolean, default=True)
|
||||
config_limiter_uri = Column(String, default="")
|
||||
config_limiter_options = Column(String, default="")
|
||||
config_check_extensions = Column(Boolean, default=True)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
|
||||
# Class holds all application specific settings in calibre-web
|
||||
class ConfigSQL(object):
|
||||
# pylint: disable=no-member
|
||||
def __init__(self):
|
||||
self.__dict__["dirty"] = list()
|
||||
|
||||
def init_config(self, session, secret_key, cli):
|
||||
self._session = session
|
||||
self._settings = None
|
||||
self.db_configured = None
|
||||
self.config_calibre_dir = None
|
||||
self._fernet = Fernet(secret_key)
|
||||
self.cli = cli
|
||||
self.load()
|
||||
|
||||
change = False
|
||||
|
||||
if self.config_binariesdir is None:
|
||||
change = True
|
||||
self.config_binariesdir = autodetect_calibre_binaries()
|
||||
self.config_converterpath = autodetect_converter_binary(self.config_binariesdir)
|
||||
|
||||
if self.config_kepubifypath is None:
|
||||
change = True
|
||||
self.config_kepubifypath = autodetect_kepubify_binary()
|
||||
|
||||
if self.config_rarfile_location is None:
|
||||
change = True
|
||||
self.config_rarfile_location = autodetect_unrar_binary()
|
||||
if change:
|
||||
self.save()
|
||||
|
||||
def _read_from_storage(self):
|
||||
if self._settings is None:
|
||||
log.debug("_ConfigSQL._read_from_storage")
|
||||
self._settings = self._session.query(_Settings).first()
|
||||
return self._settings
|
||||
|
||||
def get_config_certfile(self):
|
||||
if self.cli.certfilepath:
|
||||
return self.cli.certfilepath
|
||||
if self.cli.certfilepath == "":
|
||||
return None
|
||||
return self.config_certfile
|
||||
|
||||
def get_config_keyfile(self):
|
||||
if self.cli.keyfilepath:
|
||||
return self.cli.keyfilepath
|
||||
if self.cli.certfilepath == "":
|
||||
return None
|
||||
return self.config_keyfile
|
||||
|
||||
def get_config_ipaddress(self):
|
||||
return self.cli.ip_address or ""
|
||||
|
||||
def _has_role(self, role_flag):
|
||||
return constants.has_flag(self.config_default_role, role_flag)
|
||||
|
||||
def role_admin(self):
|
||||
return self._has_role(constants.ROLE_ADMIN)
|
||||
|
||||
def role_download(self):
|
||||
return self._has_role(constants.ROLE_DOWNLOAD)
|
||||
|
||||
def role_viewer(self):
|
||||
return self._has_role(constants.ROLE_VIEWER)
|
||||
|
||||
def role_upload(self):
|
||||
return self._has_role(constants.ROLE_UPLOAD)
|
||||
|
||||
def role_edit(self):
|
||||
return self._has_role(constants.ROLE_EDIT)
|
||||
|
||||
def role_passwd(self):
|
||||
return self._has_role(constants.ROLE_PASSWD)
|
||||
|
||||
def role_edit_shelfs(self):
|
||||
return self._has_role(constants.ROLE_EDIT_SHELFS)
|
||||
|
||||
def role_delete_books(self):
|
||||
return self._has_role(constants.ROLE_DELETE_BOOKS)
|
||||
|
||||
def show_element_new_user(self, value):
|
||||
return constants.has_flag(self.config_default_show, value)
|
||||
|
||||
def show_detail_random(self):
|
||||
return self.show_element_new_user(constants.DETAIL_RANDOM)
|
||||
|
||||
def list_denied_tags(self):
|
||||
mct = self.config_denied_tags or ""
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_allowed_tags(self):
|
||||
mct = self.config_allowed_tags or ""
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_denied_column_values(self):
|
||||
mct = self.config_denied_column_value or ""
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_allowed_column_values(self):
|
||||
mct = self.config_allowed_column_value or ""
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def get_log_level(self):
|
||||
return logger.get_level_name(self.config_log_level)
|
||||
|
||||
def get_mail_settings(self):
|
||||
return {k: v for k, v in self.__dict__.items() if k.startswith('mail_')}
|
||||
|
||||
def get_mail_server_configured(self):
|
||||
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 get_scheduled_task_settings(self):
|
||||
return {k: v for k, v in self.__dict__.items() if k.startswith('schedule_')}
|
||||
|
||||
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
|
||||
"""Possibly updates a field of this object.
|
||||
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
|
||||
"""
|
||||
new_value = dictionary.get(field, default)
|
||||
if new_value is None:
|
||||
return False
|
||||
|
||||
if field not in self.__dict__:
|
||||
log.warning("_ConfigSQL trying to set unknown field '%s' = %r", field, new_value)
|
||||
return False
|
||||
|
||||
if convertor is not None:
|
||||
if encode:
|
||||
new_value = convertor(new_value.encode(encode))
|
||||
else:
|
||||
new_value = convertor(new_value)
|
||||
|
||||
current_value = self.__dict__.get(field)
|
||||
if current_value == new_value:
|
||||
return False
|
||||
|
||||
setattr(self, field, new_value)
|
||||
return True
|
||||
|
||||
def to_dict(self):
|
||||
storage = {}
|
||||
for k, v in self.__dict__.items():
|
||||
if k[0] != '_' and not k.endswith("_e") and not k == "cli":
|
||||
storage[k] = v
|
||||
return storage
|
||||
|
||||
def load(self):
|
||||
"""Load all configuration values from the underlying storage."""
|
||||
s = self._read_from_storage() # type: _Settings
|
||||
for k, v in s.__dict__.items():
|
||||
if k[0] != '_':
|
||||
if v is None:
|
||||
# if the storage column has no value, apply the (possible) default
|
||||
column = s.__class__.__dict__.get(k)
|
||||
if column.default is not None:
|
||||
v = column.default.arg
|
||||
if k.endswith("_e") and v is not None:
|
||||
try:
|
||||
setattr(self, k, self._fernet.decrypt(v).decode())
|
||||
except cryptography.fernet.InvalidToken:
|
||||
setattr(self, k, "")
|
||||
else:
|
||||
setattr(self, k, v)
|
||||
|
||||
have_metadata_db = bool(self.config_calibre_dir)
|
||||
if have_metadata_db:
|
||||
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
||||
have_metadata_db = os.path.isfile(db_file)
|
||||
self.db_configured = have_metadata_db
|
||||
|
||||
from . import cli_param
|
||||
if os.environ.get('FLASK_DEBUG'):
|
||||
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
||||
else:
|
||||
# pylint: disable=access-member-before-definition
|
||||
logfile = logger.setup(cli_param.logpath or self.config_logfile, self.config_log_level)
|
||||
if logfile != os.path.abspath(self.config_logfile):
|
||||
if logfile != os.path.abspath(cli_param.logpath):
|
||||
log.warning("Log path %s not valid, falling back to default", self.config_logfile)
|
||||
self.config_logfile = logfile
|
||||
s.config_logfile = logfile
|
||||
self._session.merge(s)
|
||||
try:
|
||||
self._session.commit()
|
||||
except OperationalError as e:
|
||||
log.error('Database error: %s', e)
|
||||
self._session.rollback()
|
||||
self.__dict__["dirty"] = list()
|
||||
|
||||
def save(self):
|
||||
"""Apply all configuration values to the underlying storage."""
|
||||
s = self._read_from_storage() # type: _Settings
|
||||
|
||||
for k in self.dirty:
|
||||
if k[0] == '_':
|
||||
continue
|
||||
if hasattr(s, k):
|
||||
if k.endswith("_e"):
|
||||
setattr(s, k, self._fernet.encrypt(self.__dict__[k].encode()))
|
||||
else:
|
||||
setattr(s, k, self.__dict__[k])
|
||||
|
||||
log.debug("_ConfigSQL updating storage")
|
||||
self._session.merge(s)
|
||||
try:
|
||||
self._session.commit()
|
||||
except OperationalError as e:
|
||||
log.error('Database error: %s', e)
|
||||
self._session.rollback()
|
||||
self.load()
|
||||
|
||||
def invalidate(self, error=None):
|
||||
if error:
|
||||
log.error(error)
|
||||
log.warning("invalidating configuration")
|
||||
self.db_configured = False
|
||||
self.save()
|
||||
|
||||
def get_book_path(self):
|
||||
return self.config_calibre_split_dir if self.config_calibre_split_dir else self.config_calibre_dir
|
||||
|
||||
def store_calibre_uuid(self, calibre_db, Library_table):
|
||||
from . import app
|
||||
try:
|
||||
with app.app_context():
|
||||
calibre_uuid = calibre_db.session.query(Library_table).one_or_none()
|
||||
if self.config_calibre_uuid != calibre_uuid.uuid:
|
||||
self.config_calibre_uuid = calibre_uuid.uuid
|
||||
self.save()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def __setattr__(self, attr_name, attr_value):
|
||||
super().__setattr__(attr_name, attr_value)
|
||||
self.__dict__["dirty"].append(attr_name)
|
||||
|
||||
|
||||
def _encrypt_fields(session, secret_key):
|
||||
try:
|
||||
session.query(exists().where(_Settings.mail_password_e)).scalar()
|
||||
except OperationalError:
|
||||
with session.bind.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE settings ADD column 'mail_password_e' String"))
|
||||
conn.execute(text("ALTER TABLE settings ADD column 'config_ldap_serv_password_e' String"))
|
||||
session.commit()
|
||||
crypter = Fernet(secret_key)
|
||||
settings = session.query(_Settings.mail_password, _Settings.config_ldap_serv_password).first()
|
||||
if settings.mail_password:
|
||||
session.query(_Settings).update(
|
||||
{_Settings.mail_password_e: crypter.encrypt(settings.mail_password.encode())})
|
||||
if settings.config_ldap_serv_password:
|
||||
session.query(_Settings).update(
|
||||
{_Settings.config_ldap_serv_password_e: crypter.encrypt(settings.config_ldap_serv_password.encode())})
|
||||
session.commit()
|
||||
|
||||
|
||||
def _migrate_table(session, orm_class, secret_key=None):
|
||||
if secret_key:
|
||||
_encrypt_fields(session, secret_key)
|
||||
changed = False
|
||||
|
||||
for column_name, column in orm_class.__dict__.items():
|
||||
if column_name[0] != '_':
|
||||
try:
|
||||
session.query(column).first()
|
||||
except OperationalError as err:
|
||||
log.debug("%s: %s", column_name, err.args[0])
|
||||
if column.default is None:
|
||||
column_default = ""
|
||||
else:
|
||||
if isinstance(column.default.arg, bool):
|
||||
column_default = "DEFAULT {}".format(int(column.default.arg))
|
||||
else:
|
||||
column_default = "DEFAULT `{}`".format(column.default.arg)
|
||||
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_type,
|
||||
column_default))
|
||||
log.debug(alter_table)
|
||||
session.execute(alter_table)
|
||||
changed = True
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
log.error("Database corrupt column: {}".format(column_name))
|
||||
log.debug(e)
|
||||
|
||||
if changed:
|
||||
try:
|
||||
session.commit()
|
||||
except OperationalError:
|
||||
session.rollback()
|
||||
|
||||
|
||||
def autodetect_calibre_binaries():
|
||||
if sys.platform == "win32":
|
||||
calibre_path = ["C:\\program files\\calibre\\",
|
||||
"C:\\program files(x86)\\calibre\\",
|
||||
"C:\\program files(x86)\\calibre2\\",
|
||||
"C:\\program files\\calibre2\\"]
|
||||
elif sys.platform.startswith("freebsd"):
|
||||
calibre_path = ["/usr/local/bin/"]
|
||||
else:
|
||||
calibre_path = ["/opt/calibre/"]
|
||||
for element in calibre_path:
|
||||
supported_binary_paths = [os.path.join(element, binary)
|
||||
for binary in constants.SUPPORTED_CALIBRE_BINARIES.values()]
|
||||
if all(os.path.isfile(binary_path) and os.access(binary_path, os.X_OK)
|
||||
for binary_path in supported_binary_paths):
|
||||
values = [process_wait([binary_path, "--version"],
|
||||
pattern=r'\(calibre (.*)\)') for binary_path in supported_binary_paths]
|
||||
if all(values):
|
||||
version = values[0].group(1)
|
||||
log.debug("calibre version %s", version)
|
||||
return element
|
||||
return ""
|
||||
|
||||
|
||||
def autodetect_converter_binary(calibre_path):
|
||||
if sys.platform == "win32":
|
||||
converter_path = os.path.join(calibre_path, "ebook-convert.exe")
|
||||
else:
|
||||
converter_path = os.path.join(calibre_path, "ebook-convert")
|
||||
if calibre_path and os.path.isfile(converter_path) and os.access(converter_path, os.X_OK):
|
||||
return converter_path
|
||||
return ""
|
||||
|
||||
|
||||
def autodetect_unrar_binary():
|
||||
if sys.platform == "win32":
|
||||
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
||||
"C:\\program files(x86)\\WinRar\\unRAR.exe"]
|
||||
elif sys.platform.startswith("freebsd"):
|
||||
calibre_path = ["/usr/local/bin/unrar"]
|
||||
else:
|
||||
calibre_path = ["/usr/bin/unrar"]
|
||||
for element in calibre_path:
|
||||
if os.path.isfile(element) and os.access(element, os.X_OK):
|
||||
return element
|
||||
return ""
|
||||
|
||||
|
||||
def autodetect_kepubify_binary():
|
||||
if sys.platform == "win32":
|
||||
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
||||
"C:\\program files(x86)\\kepubify\\kepubify-windows-64Bit.exe"]
|
||||
elif sys.platform.startswith("freebsd"):
|
||||
calibre_path = ["/usr/local/bin/kepubify"]
|
||||
else:
|
||||
calibre_path = ["/opt/kepubify/kepubify-linux-64bit", "/opt/kepubify/kepubify-linux-32bit"]
|
||||
for element in calibre_path:
|
||||
if os.path.isfile(element) and os.access(element, os.X_OK):
|
||||
return element
|
||||
return ""
|
||||
|
||||
|
||||
def _migrate_database(session, secret_key):
|
||||
# make sure the table is created, if it does not exist
|
||||
_Base.metadata.create_all(session.bind)
|
||||
_migrate_table(session, _Settings, secret_key)
|
||||
_migrate_table(session, _Flask_Settings)
|
||||
|
||||
|
||||
def load_configuration(session, secret_key):
|
||||
_migrate_database(session, secret_key)
|
||||
if not session.query(_Settings).count():
|
||||
session.add(_Settings())
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_flask_session_key(_session):
|
||||
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
||||
if flask_settings is None:
|
||||
flask_settings = _Flask_Settings(os.urandom(32))
|
||||
_session.add(flask_settings)
|
||||
_session.commit()
|
||||
return flask_settings.flask_session_key
|
||||
|
||||
|
||||
def get_encryption_key(key_path):
|
||||
key_file = os.path.join(key_path, ".key")
|
||||
generate = True
|
||||
error = ""
|
||||
key = None
|
||||
if os.path.exists(key_file) and os.path.getsize(key_file) > 32:
|
||||
with open(key_file, "rb") as f:
|
||||
key = f.read()
|
||||
try:
|
||||
urlsafe_b64decode(key)
|
||||
generate = False
|
||||
except ValueError:
|
||||
pass
|
||||
if generate:
|
||||
key = Fernet.generate_key()
|
||||
try:
|
||||
with open(key_file, "wb") as f:
|
||||
f.write(key)
|
||||
except PermissionError as e:
|
||||
error = e
|
||||
return key, error
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2022 quarz12
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import concurrent.futures
|
||||
import requests
|
||||
from bs4 import BeautifulSoup as BS # requirement
|
||||
from typing import List, Optional
|
||||
|
||||
try:
|
||||
import cchardet #optional for better speed
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
import cps.logger as logger
|
||||
|
||||
#from time import time
|
||||
from operator import itemgetter
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class Amazon(Metadata):
|
||||
__name__ = "Amazon"
|
||||
__id__ = "amazon"
|
||||
headers = {'upgrade-insecure-requests': '1',
|
||||
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0',
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Alt-Used' : 'www.amazon.com',
|
||||
'Priority' : 'u=0, i',
|
||||
'accept-encoding': 'gzip, deflate, br, zstd',
|
||||
'accept-language': 'en-US,en;q=0.9'}
|
||||
session = requests.Session()
|
||||
session.headers=headers
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en",**kwargs
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
def inner(link, index) -> [dict, int]:
|
||||
with self.session as session:
|
||||
try:
|
||||
r = session.get(f"https://www.amazon.com/{link}")
|
||||
r.raise_for_status()
|
||||
except Exception as ex:
|
||||
log.warning(ex)
|
||||
return []
|
||||
long_soup = BS(r.text, "lxml") #~4sec :/
|
||||
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-ppd_csm_instrumentation_wrapper"})
|
||||
if soup2 is None:
|
||||
return []
|
||||
try:
|
||||
match = MetaRecord(
|
||||
title = "",
|
||||
authors = "",
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description="Amazon Books",
|
||||
link="https://amazon.com/"
|
||||
),
|
||||
url = f"https://www.amazon.com{link}",
|
||||
#the more searches the slower, these are too hard to find in reasonable time or might not even exist
|
||||
publisher= "", # very unreliable
|
||||
publishedDate= "", # very unreliable
|
||||
id = None, # ?
|
||||
tags = [] # dont exist on amazon
|
||||
)
|
||||
|
||||
try:
|
||||
match.description = "\n".join(
|
||||
soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\
|
||||
.replace("\xa0"," ")[:-9].strip().strip("\n")
|
||||
except (AttributeError, TypeError):
|
||||
return [] # if there is no description it is not a book and therefore should be ignored
|
||||
try:
|
||||
match.title = soup2.find("span", attrs={"id": "productTitle"}).text
|
||||
except (AttributeError, TypeError):
|
||||
match.title = ""
|
||||
try:
|
||||
match.authors = [next(
|
||||
filter(lambda i: i != " " and i != "\n" and not i.startswith("{"),
|
||||
x.findAll(string=True))).strip()
|
||||
for x in soup2.findAll("span", attrs={"class": "author"})]
|
||||
except (AttributeError, TypeError, StopIteration):
|
||||
match.authors = ""
|
||||
try:
|
||||
match.rating = int(
|
||||
soup2.find("span", class_="a-icon-alt").text.split(" ")[0].split(".")[
|
||||
0]) # first number in string
|
||||
except (AttributeError, ValueError):
|
||||
match.rating = 0
|
||||
try:
|
||||
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image"})["src"]
|
||||
except (AttributeError, TypeError):
|
||||
match.cover = ""
|
||||
return match, index
|
||||
except Exception as e:
|
||||
log.error_or_exception(e)
|
||||
return []
|
||||
|
||||
val = list()
|
||||
if self.active:
|
||||
try:
|
||||
results = self.session.get(
|
||||
f"https://www.amazon.com/s?k={query.replace(' ', '+')}&i=digital-text&sprefix={query.replace(' ', '+')}"
|
||||
f"%2Cdigital-text&ref=nb_sb_noss",
|
||||
headers=self.headers)
|
||||
results.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
log.error_or_exception(e)
|
||||
return []
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return []
|
||||
soup = BS(results.text, 'html.parser')
|
||||
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in
|
||||
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:3])}
|
||||
val = list(map(lambda x : x.result(), concurrent.futures.as_completed(fut)))
|
||||
result = list(filter(lambda x: x, val))
|
||||
return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance
|
||||
@@ -0,0 +1,92 @@
|
||||
# -*- 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
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from cps import logger
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class ComicVine(Metadata):
|
||||
__name__ = "ComicVine"
|
||||
__id__ = "comicvine"
|
||||
DESCRIPTION = "ComicVine Books"
|
||||
META_URL = "https://comicvine.gamespot.com/"
|
||||
API_KEY = "57558043c53943d5d1e96a9ad425b0eb85532ee6"
|
||||
BASE_URL = (
|
||||
f"https://comicvine.gamespot.com/api/search?api_key={API_KEY}"
|
||||
f"&resources=issue&query="
|
||||
)
|
||||
QUERY_PARAMS = "&sort=name:desc&format=json"
|
||||
HEADERS = {"User-Agent": "Not Evil Browser"}
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en",**kwargs
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
val = list()
|
||||
if self.active:
|
||||
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||
if title_tokens:
|
||||
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||
query = "%20".join(tokens)
|
||||
try:
|
||||
result = requests.get(
|
||||
f"{ComicVine.BASE_URL}{query}{ComicVine.QUERY_PARAMS}",
|
||||
headers=ComicVine.HEADERS,
|
||||
)
|
||||
result.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
for result in result.json()["results"]:
|
||||
match = self._parse_search_result(
|
||||
result=result, generic_cover=generic_cover, locale=locale
|
||||
)
|
||||
val.append(match)
|
||||
return val
|
||||
|
||||
def _parse_search_result(
|
||||
self, result: Dict, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
series = result["volume"].get("name", "")
|
||||
series_index = result.get("issue_number", 0)
|
||||
issue_name = result.get("name", "")
|
||||
match = MetaRecord(
|
||||
id=result["id"],
|
||||
title=f"{series}#{series_index} - {issue_name}",
|
||||
authors=result.get("authors", []),
|
||||
url=result.get("site_detail_url", ""),
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description=ComicVine.DESCRIPTION,
|
||||
link=ComicVine.META_URL,
|
||||
),
|
||||
series=series,
|
||||
)
|
||||
match.cover = result["image"].get("original_url", generic_cover)
|
||||
match.description = result.get("description", "")
|
||||
match.publishedDate = result.get("store_date", result.get("date_added"))
|
||||
match.series_index = series_index
|
||||
match.tags = ["Comics", series]
|
||||
match.identifiers = {"comicvine": match.id}
|
||||
return match
|
||||
@@ -0,0 +1,260 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2022 xlivevil
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import re
|
||||
from concurrent import futures
|
||||
from typing import List, Optional
|
||||
|
||||
import requests
|
||||
from html2text import HTML2Text
|
||||
from lxml import etree
|
||||
|
||||
from cps import logger
|
||||
from cps.services.Metadata import Metadata, MetaRecord, MetaSourceInfo
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
def html2text(html: str) -> str:
|
||||
|
||||
h2t = HTML2Text()
|
||||
h2t.body_width = 0
|
||||
h2t.single_line_break = True
|
||||
h2t.emphasis_mark = "*"
|
||||
return h2t.handle(html)
|
||||
|
||||
|
||||
class Douban(Metadata):
|
||||
__name__ = "豆瓣"
|
||||
__id__ = "douban"
|
||||
DESCRIPTION = "豆瓣"
|
||||
META_URL = "https://book.douban.com/"
|
||||
SEARCH_JSON_URL = "https://www.douban.com/j/search"
|
||||
SEARCH_URL = "https://www.douban.com/search"
|
||||
|
||||
ID_PATTERN = re.compile(r"sid: (?P<id>\d+),")
|
||||
AUTHORS_PATTERN = re.compile(r"作者|译者")
|
||||
PUBLISHER_PATTERN = re.compile(r"出版社")
|
||||
SUBTITLE_PATTERN = re.compile(r"副标题")
|
||||
PUBLISHED_DATE_PATTERN = re.compile(r"出版年")
|
||||
SERIES_PATTERN = re.compile(r"丛书")
|
||||
IDENTIFIERS_PATTERN = re.compile(r"ISBN|统一书号")
|
||||
CRITERIA_PATTERN = re.compile("criteria = '(.+)'")
|
||||
|
||||
TITTLE_XPATH = "//span[@property='v:itemreviewed']"
|
||||
COVER_XPATH = "//a[@class='nbg']"
|
||||
INFO_XPATH = "//*[@id='info']//span[@class='pl']"
|
||||
TAGS_XPATH = "//a[contains(@class, 'tag')]"
|
||||
DESCRIPTION_XPATH = "//div[@id='link-report']//div[@class='intro']"
|
||||
RATING_XPATH = "//div[@class='rating_self clearfix']/strong"
|
||||
|
||||
session = requests.Session()
|
||||
session.headers = {
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 Edg/98.0.1108.56',
|
||||
}
|
||||
|
||||
def search(self,
|
||||
query: str,
|
||||
generic_cover: str = "",
|
||||
locale: str = "en",**kwargs) -> List[MetaRecord]:
|
||||
val = []
|
||||
if self.active:
|
||||
log.debug(f"start searching {query} on douban")
|
||||
if title_tokens := list(
|
||||
self.get_title_tokens(query, strip_joiners=False)):
|
||||
query = "+".join(title_tokens)
|
||||
|
||||
book_id_list = self._get_book_id_list_from_html(query)
|
||||
|
||||
if not book_id_list:
|
||||
log.debug("No search results in Douban")
|
||||
return []
|
||||
|
||||
with futures.ThreadPoolExecutor(
|
||||
max_workers=5, thread_name_prefix='douban') as executor:
|
||||
|
||||
fut = [
|
||||
executor.submit(self._parse_single_book, book_id,
|
||||
generic_cover) for book_id in book_id_list
|
||||
]
|
||||
|
||||
val = [
|
||||
future.result() for future in futures.as_completed(fut)
|
||||
if future.result()
|
||||
]
|
||||
|
||||
return val
|
||||
|
||||
def _get_book_id_list_from_html(self, query: str) -> List[str]:
|
||||
try:
|
||||
r = self.session.get(self.SEARCH_URL,
|
||||
params={
|
||||
"cat": 1001,
|
||||
"q": query
|
||||
})
|
||||
r.raise_for_status()
|
||||
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return []
|
||||
|
||||
html = etree.HTML(r.content.decode("utf8"))
|
||||
result_list = html.xpath(self.COVER_XPATH)
|
||||
|
||||
return [
|
||||
self.ID_PATTERN.search(item.get("onclick")).group("id")
|
||||
for item in result_list[:10]
|
||||
if self.ID_PATTERN.search(item.get("onclick"))
|
||||
]
|
||||
|
||||
def _get_book_id_list_from_json(self, query: str) -> List[str]:
|
||||
try:
|
||||
r = self.session.get(self.SEARCH_JSON_URL,
|
||||
params={
|
||||
"cat": 1001,
|
||||
"q": query
|
||||
})
|
||||
r.raise_for_status()
|
||||
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return []
|
||||
|
||||
results = r.json()
|
||||
if results["total"] == 0:
|
||||
return []
|
||||
|
||||
return [
|
||||
self.ID_PATTERN.search(item).group("id")
|
||||
for item in results["items"][:10] if self.ID_PATTERN.search(item)
|
||||
]
|
||||
|
||||
def _parse_single_book(self,
|
||||
id: str,
|
||||
generic_cover: str = "") -> Optional[MetaRecord]:
|
||||
url = f"https://book.douban.com/subject/{id}/"
|
||||
log.debug(f"start parsing {url}")
|
||||
|
||||
try:
|
||||
r = self.session.get(url)
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
|
||||
match = MetaRecord(
|
||||
id=id,
|
||||
title="",
|
||||
authors=[],
|
||||
url=url,
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description=self.DESCRIPTION,
|
||||
link=self.META_URL,
|
||||
),
|
||||
)
|
||||
|
||||
decode_content = r.content.decode("utf8")
|
||||
html = etree.HTML(decode_content)
|
||||
|
||||
match.title = html.xpath(self.TITTLE_XPATH)[0].text
|
||||
match.cover = html.xpath(
|
||||
self.COVER_XPATH)[0].attrib["href"] or generic_cover
|
||||
try:
|
||||
rating_num = float(html.xpath(self.RATING_XPATH)[0].text.strip())
|
||||
except Exception:
|
||||
rating_num = 0
|
||||
match.rating = int(-1 * rating_num // 2 * -1) if rating_num else 0
|
||||
|
||||
tag_elements = html.xpath(self.TAGS_XPATH)
|
||||
if len(tag_elements):
|
||||
match.tags = [tag_element.text for tag_element in tag_elements]
|
||||
else:
|
||||
match.tags = self._get_tags(decode_content)
|
||||
|
||||
description_element = html.xpath(self.DESCRIPTION_XPATH)
|
||||
if len(description_element):
|
||||
match.description = html2text(
|
||||
etree.tostring(description_element[-1]).decode("utf8"))
|
||||
|
||||
info = html.xpath(self.INFO_XPATH)
|
||||
|
||||
for element in info:
|
||||
text = element.text
|
||||
if self.AUTHORS_PATTERN.search(text):
|
||||
next_element = element.getnext()
|
||||
while next_element is not None and next_element.tag != "br":
|
||||
match.authors.append(next_element.text)
|
||||
next_element = next_element.getnext()
|
||||
elif self.PUBLISHER_PATTERN.search(text):
|
||||
if publisher := element.tail.strip():
|
||||
match.publisher = publisher
|
||||
else:
|
||||
match.publisher = element.getnext().text
|
||||
elif self.SUBTITLE_PATTERN.search(text):
|
||||
match.title = f'{match.title}:{element.tail.strip()}'
|
||||
elif self.PUBLISHED_DATE_PATTERN.search(text):
|
||||
match.publishedDate = self._clean_date(element.tail.strip())
|
||||
elif self.SERIES_PATTERN.search(text):
|
||||
match.series = element.getnext().text
|
||||
elif i_type := self.IDENTIFIERS_PATTERN.search(text):
|
||||
match.identifiers[i_type.group()] = element.tail.strip()
|
||||
|
||||
return match
|
||||
|
||||
@staticmethod
|
||||
def _clean_date(date: str) -> str:
|
||||
"""
|
||||
Clean up the date string to be in the format YYYY-MM-DD
|
||||
|
||||
Examples of possible patterns:
|
||||
'2014-7-16', '1988年4月', '1995-04', '2021-8', '2020-12-1', '1996年',
|
||||
'1972', '2004/11/01', '1959年3月北京第1版第1印'
|
||||
"""
|
||||
year = date[:4]
|
||||
moon = "01"
|
||||
day = "01"
|
||||
|
||||
if len(date) > 5:
|
||||
digit = []
|
||||
ls = []
|
||||
for i in range(5, len(date)):
|
||||
if date[i].isdigit():
|
||||
digit.append(date[i])
|
||||
elif digit:
|
||||
ls.append("".join(digit) if len(digit) ==
|
||||
2 else f"0{digit[0]}")
|
||||
digit = []
|
||||
if digit:
|
||||
ls.append("".join(digit) if len(digit) ==
|
||||
2 else f"0{digit[0]}")
|
||||
|
||||
moon = ls[0]
|
||||
if len(ls) > 1:
|
||||
day = ls[1]
|
||||
|
||||
return f"{year}-{moon}-{day}"
|
||||
|
||||
def _get_tags(self, text: str) -> List[str]:
|
||||
tags = []
|
||||
if criteria := self.CRITERIA_PATTERN.search(text):
|
||||
tags.extend(
|
||||
item.replace('7:', '') for item in criteria.group().split('|')
|
||||
if item.startswith('7:'))
|
||||
|
||||
return tags
|
||||
@@ -0,0 +1,129 @@
|
||||
# -*- 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
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from cps import logger
|
||||
from cps.isoLanguages import get_lang3, get_language_name
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class Google(Metadata):
|
||||
__name__ = "Google"
|
||||
__id__ = "google"
|
||||
DESCRIPTION = "Google Books"
|
||||
META_URL = "https://books.google.com/"
|
||||
BOOK_URL = "https://books.google.com/books?id="
|
||||
SEARCH_URL = "https://www.googleapis.com/books/v1/volumes?q="
|
||||
ISBN_TYPE = "ISBN_13"
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en",**kwargs
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
val = list()
|
||||
if self.active:
|
||||
|
||||
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||
if title_tokens:
|
||||
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||
query = "+".join(tokens)
|
||||
try:
|
||||
results = requests.get(Google.SEARCH_URL + query)
|
||||
results.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return []
|
||||
for result in results.json().get("items", []):
|
||||
val.append(
|
||||
self._parse_search_result(
|
||||
result=result, generic_cover=generic_cover, locale=locale
|
||||
)
|
||||
)
|
||||
return val
|
||||
|
||||
def _parse_search_result(
|
||||
self, result: Dict, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
match = MetaRecord(
|
||||
id=result["id"],
|
||||
title=result["volumeInfo"]["title"],
|
||||
authors=result["volumeInfo"].get("authors", []),
|
||||
url=Google.BOOK_URL + result["id"],
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description=Google.DESCRIPTION,
|
||||
link=Google.META_URL,
|
||||
),
|
||||
)
|
||||
|
||||
match.cover = self._parse_cover(result=result, generic_cover=generic_cover)
|
||||
match.description = result["volumeInfo"].get("description", "")
|
||||
match.languages = self._parse_languages(result=result, locale=locale)
|
||||
match.publisher = result["volumeInfo"].get("publisher", "")
|
||||
try:
|
||||
datetime.strptime(result["volumeInfo"].get("publishedDate", ""), "%Y-%m-%d")
|
||||
match.publishedDate = result["volumeInfo"].get("publishedDate", "")
|
||||
except ValueError:
|
||||
match.publishedDate = ""
|
||||
match.rating = result["volumeInfo"].get("averageRating", 0)
|
||||
match.series, match.series_index = "", 1
|
||||
match.tags = result["volumeInfo"].get("categories", [])
|
||||
|
||||
match.identifiers = {"google": match.id}
|
||||
match = self._parse_isbn(result=result, match=match)
|
||||
return match
|
||||
|
||||
@staticmethod
|
||||
def _parse_isbn(result: Dict, match: MetaRecord) -> MetaRecord:
|
||||
identifiers = result["volumeInfo"].get("industryIdentifiers", [])
|
||||
for identifier in identifiers:
|
||||
if identifier.get("type") == Google.ISBN_TYPE:
|
||||
match.identifiers["isbn"] = identifier.get("identifier")
|
||||
break
|
||||
return match
|
||||
|
||||
@staticmethod
|
||||
def _parse_cover(result: Dict, generic_cover: str) -> str:
|
||||
if result["volumeInfo"].get("imageLinks"):
|
||||
cover_url = result["volumeInfo"]["imageLinks"]["thumbnail"]
|
||||
|
||||
# strip curl in cover
|
||||
cover_url = cover_url.replace("&edge=curl", "")
|
||||
|
||||
# request 800x900 cover image (higher resolution)
|
||||
cover_url += "&fife=w800-h900"
|
||||
|
||||
return cover_url.replace("http://", "https://")
|
||||
return generic_cover
|
||||
|
||||
@staticmethod
|
||||
def _parse_languages(result: Dict, locale: str) -> List[str]:
|
||||
language_iso2 = result["volumeInfo"].get("language", "")
|
||||
languages = (
|
||||
[get_language_name(locale, get_lang3(language_iso2))]
|
||||
if language_iso2
|
||||
else []
|
||||
)
|
||||
return languages
|
||||
@@ -0,0 +1,255 @@
|
||||
# -*- 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/>.
|
||||
|
||||
# Hardcover api document: https://Hardcover.gamespot.com/api/documentation
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from cps import logger
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
from importlib import reload
|
||||
|
||||
from flask import g
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class Hardcover(Metadata):
|
||||
__name__ = "Hardcover"
|
||||
__id__ = "hardcover"
|
||||
DESCRIPTION = "Hardcover Books"
|
||||
META_URL = "https://hardcover.app"
|
||||
BASE_URL = "https://api.hardcover.app/v1/graphql"
|
||||
# SEARCH_QUERY = """{
|
||||
# books(
|
||||
# where: {title: {_eq: "%s"}}
|
||||
# limit: 10
|
||||
# order_by: {users_read_count: desc}
|
||||
# ) {
|
||||
# title
|
||||
# book_series {
|
||||
# series {
|
||||
# name
|
||||
# }
|
||||
# position
|
||||
# }
|
||||
# cached_contributors
|
||||
# id
|
||||
# cached_image
|
||||
# slug
|
||||
# description
|
||||
# release_date
|
||||
# cached_tags
|
||||
# }
|
||||
# }"""
|
||||
SEARCH_QUERY = """query Search($query: String!) {
|
||||
search(query: $query, query_type: "Book", per_page: 50) {
|
||||
results
|
||||
}
|
||||
}
|
||||
"""
|
||||
EDITION_QUERY = """query getEditions($query: Int!) {
|
||||
books(
|
||||
where: { id: { _eq: $query } }
|
||||
order_by: { users_read_count: desc_nulls_last }
|
||||
) {
|
||||
title
|
||||
slug
|
||||
id
|
||||
|
||||
book_series {
|
||||
series {
|
||||
name
|
||||
}
|
||||
position
|
||||
}
|
||||
rating
|
||||
editions(
|
||||
where: {
|
||||
_or: [{ reading_format_id: { _neq: 2 } }, { edition_format: { _is_null: true } }]
|
||||
}
|
||||
order_by: [{ reading_format_id: desc_nulls_last },{users_count: desc_nulls_last }]
|
||||
) {
|
||||
id
|
||||
isbn_13
|
||||
isbn_10
|
||||
title
|
||||
reading_format_id
|
||||
contributions {
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
image {
|
||||
url
|
||||
}
|
||||
language {
|
||||
code3
|
||||
}
|
||||
publisher {
|
||||
name
|
||||
}
|
||||
release_date
|
||||
|
||||
}
|
||||
description
|
||||
cached_tags(path: "Genre")
|
||||
}
|
||||
}
|
||||
"""
|
||||
HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
FORMATS = ["","Physical Book","","","E-Book"] # Map reading_format_id to text equivelant.
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en", **kwargs
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
token = kwargs.get("token")
|
||||
if not token:
|
||||
log.warning("Hardcover token not set for user")
|
||||
return None
|
||||
val = list()
|
||||
if self.active:
|
||||
try:
|
||||
if (token == ""):
|
||||
raise Exception("Current user does not have Hardcover API token")
|
||||
else:
|
||||
edition_seach = query.split(":")[0] == "hardcover-id"
|
||||
Hardcover.HEADERS["Authorization"] = "Bearer %s" % token
|
||||
result = requests.post(
|
||||
Hardcover.BASE_URL,
|
||||
json={
|
||||
"query":Hardcover.SEARCH_QUERY if not edition_seach else Hardcover.EDITION_QUERY,
|
||||
"variables":{"query":query if not edition_seach else query.split(":")[1]}
|
||||
},
|
||||
headers=Hardcover.HEADERS,
|
||||
)
|
||||
result.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
if edition_seach:
|
||||
result = result.json()["data"]["books"][0]
|
||||
log.debug(result)
|
||||
val = self._parse_edition_results(result=result, generic_cover=generic_cover, locale=locale)
|
||||
else:
|
||||
for result in result.json()["data"]["search"]["results"]["hits"]:
|
||||
match = self._parse_title_result(
|
||||
result=result, generic_cover=generic_cover, locale=locale
|
||||
)
|
||||
val.append(match)
|
||||
return val
|
||||
|
||||
def _parse_title_result(
|
||||
self, result: Dict, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
series = result["document"].get("featured_series",{}).get("series_name", "")
|
||||
series_index = result["document"].get("featured_series",{}).get("position", "")
|
||||
match = MetaRecord(
|
||||
id=result["document"].get("id",""),
|
||||
title=result["document"].get("title",""),
|
||||
authors=result["document"].get("author_names", []),
|
||||
url=self._parse_title_url(result, ""),
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description=Hardcover.DESCRIPTION,
|
||||
link=Hardcover.META_URL,
|
||||
),
|
||||
series=series,
|
||||
)
|
||||
# TODO Add parse cover function to get better size
|
||||
match.cover = result["document"]["image"].get("url", generic_cover)
|
||||
|
||||
match.description = result["document"].get("description","")
|
||||
match.publishedDate = result["document"].get(
|
||||
"release_date", "")
|
||||
match.series_index = series_index
|
||||
match.tags = result["document"].get("genres",[])
|
||||
match.identifiers = {
|
||||
"hardcover-id": match.id,
|
||||
"hardcover-slug": result["document"].get("slug", "")
|
||||
}
|
||||
return match
|
||||
|
||||
def _parse_edition_results(
|
||||
self, result: Dict, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
editions = list()
|
||||
id = result.get("id","")
|
||||
for edition in result["editions"]:
|
||||
match = MetaRecord(
|
||||
id=id,
|
||||
title=edition.get("title",""),
|
||||
authors=self._parse_edition_authors(edition,[]),
|
||||
url=self._parse_edition_url(edition, ""),
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description=Hardcover.DESCRIPTION,
|
||||
link=Hardcover.META_URL,
|
||||
),
|
||||
series=result.get("book_series",[{}])[0].get("series",{}).get("name", ""),
|
||||
)
|
||||
# TODO Add parse cover function to get better size
|
||||
match.cover = (edition.get("image") or {}).get("url", generic_cover)
|
||||
match.description = result.get("description","")
|
||||
match.publisher = (edition.get("publisher") or {}).get("name","")
|
||||
match.publishedDate = edition.get("release_date", "")
|
||||
match.series_index = result.get("book_series",[{}])[0].get("position", "")
|
||||
match.tags = self._parse_tags(result,[])
|
||||
match.languages = (edition.get("language") or {}).get("code3","")
|
||||
match.identifiers = {
|
||||
"hardcover-id": id,
|
||||
"hardcover-slug": result.get("slug", ""),
|
||||
"hardcover-edition": edition.get("id",""),
|
||||
"isbn": (edition.get("isbn_13",edition.get("isbn_10")) or "")
|
||||
}
|
||||
match.format = Hardcover.FORMATS[edition.get("reading_format_id",0)]
|
||||
editions.append(match)
|
||||
return editions
|
||||
|
||||
@staticmethod
|
||||
def _parse_title_url(result: Dict, url: str) -> str:
|
||||
hardcover_slug = result["document"].get("slug", "")
|
||||
if hardcover_slug:
|
||||
return f"https://hardcover.app/books/{hardcover_slug}"
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def _parse_edition_url(edition: Dict, url: str) -> str:
|
||||
hardcover_edition = edition.get("id", "")
|
||||
if hardcover_edition:
|
||||
return f"https://hardcover.app/books/jurassic-park/editions/{hardcover_edition}"
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def _parse_edition_authors(edition: Dict, authors: List[str]) -> List[str]:
|
||||
try:
|
||||
return [author["author"]["name"] for author in edition.get("contributions",[]) if "author" in author and "name" in author["author"]]
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return authors
|
||||
|
||||
@staticmethod
|
||||
def _parse_tags(result: Dict, tags: List[str]) -> List[str]:
|
||||
try:
|
||||
return [item["tag"] for item in result["cached_tags"] if "tag" in item]
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return tags
|
||||
@@ -0,0 +1,359 @@
|
||||
# -*- 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/>.
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from dateutil import parser
|
||||
from html2text import HTML2Text
|
||||
from lxml.html import HtmlElement, fromstring, tostring
|
||||
from markdown2 import Markdown
|
||||
|
||||
from cps import logger
|
||||
from cps.isoLanguages import get_language_name
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
|
||||
log = logger.create()
|
||||
|
||||
SYMBOLS_TO_TRANSLATE = (
|
||||
"öÖüÜóÓőŐúÚéÉáÁűŰíÍąĄćĆęĘłŁńŃóÓśŚźŹżŻ",
|
||||
"oOuUoOoOuUeEaAuUiIaAcCeElLnNoOsSzZzZ",
|
||||
)
|
||||
SYMBOL_TRANSLATION_MAP = dict(
|
||||
[(ord(a), ord(b)) for (a, b) in zip(*SYMBOLS_TO_TRANSLATE)]
|
||||
)
|
||||
|
||||
|
||||
def get_int_or_float(value: str) -> Union[int, float]:
|
||||
number_as_float = float(value)
|
||||
number_as_int = int(number_as_float)
|
||||
return number_as_int if number_as_float == number_as_int else number_as_float
|
||||
|
||||
|
||||
def strip_accents(s: Optional[str]) -> Optional[str]:
|
||||
return s.translate(SYMBOL_TRANSLATION_MAP) if s is not None else s
|
||||
|
||||
|
||||
def sanitize_comments_html(html: str) -> str:
|
||||
text = html2text(html)
|
||||
md = Markdown()
|
||||
html = md.convert(text)
|
||||
return html
|
||||
|
||||
|
||||
def html2text(html: str) -> str:
|
||||
# replace <u> tags with <span> as <u> becomes emphasis in html2text
|
||||
if isinstance(html, bytes):
|
||||
html = html.decode("utf-8")
|
||||
html = re.sub(
|
||||
r"<\s*(?P<solidus>/?)\s*[uU]\b(?P<rest>[^>]*)>",
|
||||
r"<\g<solidus>span\g<rest>>",
|
||||
html,
|
||||
)
|
||||
h2t = HTML2Text()
|
||||
h2t.body_width = 0
|
||||
h2t.single_line_break = True
|
||||
h2t.emphasis_mark = "*"
|
||||
return h2t.handle(html)
|
||||
|
||||
|
||||
class LubimyCzytac(Metadata):
|
||||
__name__ = "LubimyCzytac.pl"
|
||||
__id__ = "lubimyczytac"
|
||||
|
||||
BASE_URL = "https://lubimyczytac.pl"
|
||||
|
||||
BOOK_SEARCH_RESULT_XPATH = (
|
||||
"*//div[@class='listSearch']//div[@class='authorAllBooks__single']"
|
||||
)
|
||||
SINGLE_BOOK_RESULT_XPATH = ".//div[contains(@class,'authorAllBooks__singleText')]"
|
||||
TITLE_PATH = "/div/a[contains(@class,'authorAllBooks__singleTextTitle')]"
|
||||
TITLE_TEXT_PATH = f"{TITLE_PATH}//text()"
|
||||
URL_PATH = f"{TITLE_PATH}/@href"
|
||||
AUTHORS_PATH = "/div/a[contains(@href,'autor')]//text()"
|
||||
|
||||
SIBLINGS = "/following-sibling::dd"
|
||||
|
||||
CONTAINER = "//section[@class='container book']"
|
||||
PUBLISHER = f"{CONTAINER}//dt[contains(text(),'Wydawnictwo:')]{SIBLINGS}/a/text()"
|
||||
LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()"
|
||||
DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']"
|
||||
SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]/text()"
|
||||
TRANSLATOR = f"{CONTAINER}//dt[contains(text(),'Tłumacz:')]{SIBLINGS}/a/text()"
|
||||
|
||||
DETAILS = "//div[@id='book-details']"
|
||||
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
||||
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
|
||||
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
|
||||
TAGS = "//a[contains(@href,'/ksiazki/t/')]/text()" # "//nav[@aria-label='breadcrumbs']//a[contains(@href,'/ksiazki/k/')]/span/text()"
|
||||
|
||||
|
||||
RATING = "//meta[@property='books:rating:value']/@content"
|
||||
COVER = "//meta[@property='og:image']/@content"
|
||||
ISBN = "//meta[@property='books:isbn']/@content"
|
||||
META_TITLE = "//meta[@property='og:description']/@content"
|
||||
|
||||
SUMMARY = "//script[@type='application/ld+json']//text()"
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en",**kwargs
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
if self.active:
|
||||
try:
|
||||
result = requests.get(self._prepare_query(title=query))
|
||||
result.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
root = fromstring(result.text)
|
||||
lc_parser = LubimyCzytacParser(root=root, metadata=self)
|
||||
matches = lc_parser.parse_search_results()
|
||||
if matches:
|
||||
with ThreadPool(processes=10) as pool:
|
||||
final_matches = pool.starmap(
|
||||
lc_parser.parse_single_book,
|
||||
[(match, generic_cover, locale) for match in matches],
|
||||
)
|
||||
return final_matches
|
||||
return matches
|
||||
|
||||
def _prepare_query(self, title: str) -> str:
|
||||
query = ""
|
||||
characters_to_remove = r"\?()\/"
|
||||
pattern = "[" + characters_to_remove + "]"
|
||||
title = re.sub(pattern, "", title)
|
||||
title = title.replace("_", " ")
|
||||
if '"' in title or ",," in title:
|
||||
title = title.split('"')[0].split(",,")[0]
|
||||
|
||||
if "/" in title:
|
||||
title_tokens = [
|
||||
token for token in title.lower().split(" ") if len(token) > 1
|
||||
]
|
||||
else:
|
||||
title_tokens = list(self.get_title_tokens(title, strip_joiners=False))
|
||||
if title_tokens:
|
||||
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||
query = query + "%20".join(tokens)
|
||||
if not query:
|
||||
return ""
|
||||
return f"{LubimyCzytac.BASE_URL}/szukaj/ksiazki?phrase={query}"
|
||||
|
||||
|
||||
class LubimyCzytacParser:
|
||||
PAGES_TEMPLATE = "<p id='strony'>Książka ma {0} stron(y).</p>"
|
||||
TRANSLATOR_TEMPLATE = "<p id='translator'>Tłumacz: {0}</p>"
|
||||
PUBLISH_DATE_TEMPLATE = "<p id='pierwsze_wydanie'>Data pierwszego wydania: {0}</p>"
|
||||
PUBLISH_DATE_PL_TEMPLATE = (
|
||||
"<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>"
|
||||
)
|
||||
|
||||
def __init__(self, root: HtmlElement, metadata: Metadata) -> None:
|
||||
self.root = root
|
||||
self.metadata = metadata
|
||||
|
||||
def parse_search_results(self) -> List[MetaRecord]:
|
||||
matches = []
|
||||
results = self.root.xpath(LubimyCzytac.BOOK_SEARCH_RESULT_XPATH)
|
||||
for result in results:
|
||||
title = self._parse_xpath_node(
|
||||
root=result,
|
||||
xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}"
|
||||
f"{LubimyCzytac.TITLE_TEXT_PATH}",
|
||||
)
|
||||
|
||||
book_url = self._parse_xpath_node(
|
||||
root=result,
|
||||
xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}"
|
||||
f"{LubimyCzytac.URL_PATH}",
|
||||
)
|
||||
authors = self._parse_xpath_node(
|
||||
root=result,
|
||||
xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}"
|
||||
f"{LubimyCzytac.AUTHORS_PATH}",
|
||||
take_first=False,
|
||||
)
|
||||
if not all([title, book_url, authors]):
|
||||
continue
|
||||
matches.append(
|
||||
MetaRecord(
|
||||
id=book_url.replace(f"/ksiazka/", "").split("/")[0],
|
||||
title=title,
|
||||
authors=[strip_accents(author) for author in authors],
|
||||
url=LubimyCzytac.BASE_URL + book_url,
|
||||
source=MetaSourceInfo(
|
||||
id=self.metadata.__id__,
|
||||
description=self.metadata.__name__,
|
||||
link=LubimyCzytac.BASE_URL,
|
||||
),
|
||||
)
|
||||
)
|
||||
return matches
|
||||
|
||||
def parse_single_book(
|
||||
self, match: MetaRecord, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
try:
|
||||
response = requests.get(match.url)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
self.root = fromstring(response.text)
|
||||
match.cover = self._parse_cover(generic_cover=generic_cover)
|
||||
match.description = self._parse_description()
|
||||
match.languages = self._parse_languages(locale=locale)
|
||||
match.publisher = self._parse_publisher()
|
||||
match.publishedDate = self._parse_from_summary(attribute_name="datePublished")
|
||||
match.rating = self._parse_rating()
|
||||
match.series, match.series_index = self._parse_series()
|
||||
match.tags = self._parse_tags()
|
||||
match.identifiers = {
|
||||
"isbn": self._parse_isbn(),
|
||||
"lubimyczytac": match.id,
|
||||
}
|
||||
return match
|
||||
|
||||
def _parse_xpath_node(
|
||||
self,
|
||||
xpath: str,
|
||||
root: HtmlElement = None,
|
||||
take_first: bool = True,
|
||||
strip_element: bool = True,
|
||||
) -> Optional[Union[str, List[str]]]:
|
||||
root = root if root is not None else self.root
|
||||
node = root.xpath(xpath)
|
||||
if not node:
|
||||
return None
|
||||
return (
|
||||
(node[0].strip() if strip_element else node[0])
|
||||
if take_first
|
||||
else [x.strip() for x in node]
|
||||
)
|
||||
|
||||
def _parse_cover(self, generic_cover) -> Optional[str]:
|
||||
return (
|
||||
self._parse_xpath_node(xpath=LubimyCzytac.COVER, take_first=True)
|
||||
or generic_cover
|
||||
)
|
||||
|
||||
def _parse_publisher(self) -> Optional[str]:
|
||||
return self._parse_xpath_node(xpath=LubimyCzytac.PUBLISHER, take_first=True)
|
||||
|
||||
def _parse_languages(self, locale: str) -> List[str]:
|
||||
languages = list()
|
||||
lang = self._parse_xpath_node(xpath=LubimyCzytac.LANGUAGES, take_first=True)
|
||||
if lang:
|
||||
if "polski" in lang:
|
||||
languages.append("pol")
|
||||
if "angielski" in lang:
|
||||
languages.append("eng")
|
||||
return [get_language_name(locale, language) for language in languages]
|
||||
|
||||
def _parse_series(self) -> Tuple[Optional[str], Optional[Union[float, int]]]:
|
||||
series_index = 0
|
||||
series = self._parse_xpath_node(xpath=LubimyCzytac.SERIES, take_first=True)
|
||||
if series:
|
||||
if "tom " in series:
|
||||
series_name, series_info = series.split(" (tom ", 1)
|
||||
series_info = series_info.replace(" ", "").replace(")", "")
|
||||
# Check if book is not a bundle, i.e. chapter 1-3
|
||||
if "-" in series_info:
|
||||
series_info = series_info.split("-", 1)[0]
|
||||
if series_info.replace(".", "").isdigit() is True:
|
||||
series_index = get_int_or_float(series_info)
|
||||
return series_name, series_index
|
||||
return None, None
|
||||
|
||||
def _parse_tags(self) -> List[str]:
|
||||
tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False)
|
||||
if tags:
|
||||
return [
|
||||
strip_accents(w.replace(", itd.", " itd."))
|
||||
for w in tags
|
||||
if isinstance(w, str)
|
||||
]
|
||||
return None
|
||||
|
||||
def _parse_from_summary(self, attribute_name: str) -> Optional[str]:
|
||||
value = None
|
||||
summary_text = self._parse_xpath_node(xpath=LubimyCzytac.SUMMARY)
|
||||
if summary_text:
|
||||
data = json.loads(summary_text)
|
||||
value = data.get(attribute_name)
|
||||
return value.strip() if value is not None else value
|
||||
|
||||
def _parse_rating(self) -> Optional[str]:
|
||||
rating = self._parse_xpath_node(xpath=LubimyCzytac.RATING)
|
||||
return round(float(rating.replace(",", ".")) / 2) if rating else rating
|
||||
|
||||
def _parse_date(self, xpath="first_publish") -> Optional[datetime.datetime]:
|
||||
options = {
|
||||
"first_publish": LubimyCzytac.FIRST_PUBLISH_DATE,
|
||||
"first_publish_pl": LubimyCzytac.FIRST_PUBLISH_DATE_PL,
|
||||
}
|
||||
date = self._parse_xpath_node(xpath=options.get(xpath))
|
||||
return parser.parse(date) if date else None
|
||||
|
||||
def _parse_isbn(self) -> Optional[str]:
|
||||
return self._parse_xpath_node(xpath=LubimyCzytac.ISBN)
|
||||
|
||||
def _parse_description(self) -> str:
|
||||
description = ""
|
||||
description_node = self._parse_xpath_node(
|
||||
xpath=LubimyCzytac.DESCRIPTION, strip_element=False
|
||||
)
|
||||
if description_node is not None:
|
||||
for source in self.root.xpath('//p[@class="source"]'):
|
||||
source.getparent().remove(source)
|
||||
description = tostring(description_node, method="html")
|
||||
description = sanitize_comments_html(description)
|
||||
|
||||
else:
|
||||
description_node = self._parse_xpath_node(xpath=LubimyCzytac.META_TITLE)
|
||||
if description_node is not None:
|
||||
description = description_node
|
||||
description = sanitize_comments_html(description)
|
||||
description = self._add_extra_info_to_description(description=description)
|
||||
return description
|
||||
|
||||
def _add_extra_info_to_description(self, description: str) -> str:
|
||||
pages = self._parse_from_summary(attribute_name="numberOfPages")
|
||||
if pages:
|
||||
description += LubimyCzytacParser.PAGES_TEMPLATE.format(pages)
|
||||
|
||||
first_publish_date = self._parse_date()
|
||||
if first_publish_date:
|
||||
description += LubimyCzytacParser.PUBLISH_DATE_TEMPLATE.format(
|
||||
first_publish_date.strftime("%d.%m.%Y")
|
||||
)
|
||||
|
||||
first_publish_date_pl = self._parse_date(xpath="first_publish_pl")
|
||||
if first_publish_date_pl:
|
||||
description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format(
|
||||
first_publish_date_pl.strftime("%d.%m.%Y")
|
||||
)
|
||||
translator = self._parse_xpath_node(xpath=LubimyCzytac.TRANSLATOR)
|
||||
if translator:
|
||||
description += LubimyCzytacParser.TRANSLATOR_TEMPLATE.format(translator)
|
||||
|
||||
|
||||
return description
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- 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/>.
|
||||
import itertools
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
try:
|
||||
from fake_useragent.errors import FakeUserAgentError
|
||||
except (ImportError):
|
||||
FakeUserAgentError = BaseException
|
||||
try:
|
||||
from scholarly import scholarly
|
||||
except FakeUserAgentError:
|
||||
raise ImportError("No module named 'scholarly'")
|
||||
|
||||
from cps import logger
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class scholar(Metadata):
|
||||
__name__ = "Google Scholar"
|
||||
__id__ = "googlescholar"
|
||||
META_URL = "https://scholar.google.com/"
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en",**kwargs
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
val = list()
|
||||
if self.active:
|
||||
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||
if title_tokens:
|
||||
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||
query = " ".join(tokens)
|
||||
try:
|
||||
scholarly.set_timeout(20)
|
||||
scholarly.set_retries(2)
|
||||
scholar_gen = itertools.islice(scholarly.search_pubs(query), 10)
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return list()
|
||||
for result in scholar_gen:
|
||||
match = self._parse_search_result(
|
||||
result=result, generic_cover="", locale=locale
|
||||
)
|
||||
val.append(match)
|
||||
return val
|
||||
|
||||
def _parse_search_result(
|
||||
self, result: Dict, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
match = MetaRecord(
|
||||
id=result.get("pub_url", result.get("eprint_url", "")),
|
||||
title=result["bib"].get("title"),
|
||||
authors=result["bib"].get("author", []),
|
||||
url=result.get("pub_url", result.get("eprint_url", "")),
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__, description=self.__name__, link=scholar.META_URL
|
||||
),
|
||||
)
|
||||
|
||||
match.cover = result.get("image", {}).get("original_url", generic_cover)
|
||||
match.description = unquote(result["bib"].get("abstract", ""))
|
||||
match.publisher = result["bib"].get("venue", "")
|
||||
match.publishedDate = result["bib"].get("pub_year") + "-01-01"
|
||||
match.identifiers = {"scholar": match.id}
|
||||
return match
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- 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/>.
|
||||
|
||||
import concurrent.futures
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, request, url_for, make_response, jsonify
|
||||
from .cw_login import current_user
|
||||
from flask_babel import get_locale
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from cps.services.Metadata import Metadata
|
||||
from . import constants, logger, ub, web_server
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
|
||||
meta = Blueprint("metadata", __name__)
|
||||
|
||||
log = logger.create()
|
||||
|
||||
try:
|
||||
from dataclasses import asdict
|
||||
except ImportError:
|
||||
log.info('*** "dataclasses" is needed for calibre-web to run. Please install it using pip: "pip install dataclasses" ***')
|
||||
print('*** "dataclasses" is needed for calibre-web to run. Please install it using pip: "pip install dataclasses" ***')
|
||||
web_server.stop(True)
|
||||
sys.exit(6)
|
||||
|
||||
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 (IndentationError, SyntaxError) as e:
|
||||
log.error("Syntax error for metadata source: {} - {}".format(a, e))
|
||||
except ImportError as e:
|
||||
log.debug("Import error for metadata source: {} - {}".format(a, e))
|
||||
|
||||
|
||||
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")
|
||||
@user_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 make_response(jsonify(provider))
|
||||
|
||||
|
||||
@meta.route("/metadata/provider", methods=["POST"])
|
||||
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
|
||||
@user_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:
|
||||
data = []
|
||||
provider = next((c for c in cl if c.__id__ == prov_name), None)
|
||||
if provider is not None:
|
||||
data = provider.search(new_state.get("query", ""))
|
||||
return make_response(jsonify([asdict(x) for x in data]))
|
||||
return ""
|
||||
|
||||
|
||||
@meta.route("/metadata/search", methods=["POST"])
|
||||
@user_login_required
|
||||
def metadata_search():
|
||||
query = request.form.to_dict().get("query")
|
||||
data = list()
|
||||
active = current_user.view_settings.get("metadata", {})
|
||||
locale = get_locale()
|
||||
if query:
|
||||
static_cover = url_for("static", filename="generic_cover.jpg")
|
||||
# ret = cl[0].search(query, static_cover, locale)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
meta = {
|
||||
executor.submit(c.search, query, static_cover, locale, token=getattr(current_user,f'{c.__id__}_token',None)): c
|
||||
for c in cl
|
||||
if active.get(c.__id__, True)
|
||||
}
|
||||
for future in concurrent.futures.as_completed(meta):
|
||||
data.extend([asdict(x) for x in future.result() if x])
|
||||
return make_response(jsonify(data))
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- 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/>.
|
||||
import abc
|
||||
import dataclasses
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, Generator, List, Optional, Union
|
||||
|
||||
from cps import constants
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class MetaSourceInfo:
|
||||
id: str
|
||||
description: str
|
||||
link: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class MetaRecord:
|
||||
id: Union[str, int]
|
||||
title: str
|
||||
authors: List[str]
|
||||
url: str
|
||||
source: MetaSourceInfo
|
||||
cover: str = os.path.join(constants.STATIC_DIR, 'generic_cover.jpg')
|
||||
description: Optional[str] = ""
|
||||
series: Optional[str] = None
|
||||
series_index: Optional[Union[int, float]] = 0
|
||||
identifiers: Dict[str, Union[str, int]] = dataclasses.field(default_factory=dict)
|
||||
publisher: Optional[str] = None
|
||||
publishedDate: Optional[str] = None
|
||||
rating: Optional[int] = 0
|
||||
languages: Optional[List[str]] = dataclasses.field(default_factory=list)
|
||||
tags: Optional[List[str]] = dataclasses.field(default_factory=list)
|
||||
format: Optional[str] = None
|
||||
|
||||
|
||||
class Metadata:
|
||||
__name__ = "Generic"
|
||||
__id__ = "generic"
|
||||
|
||||
def __init__(self):
|
||||
self.active = True
|
||||
|
||||
def set_status(self, state):
|
||||
self.active = state
|
||||
|
||||
@abc.abstractmethod
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_title_tokens(
|
||||
title: str, strip_joiners: bool = True
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
Taken from calibre source code
|
||||
It's a simplified (cut out what is unnecessary) version of
|
||||
https://github.com/kovidgoyal/calibre/blob/99d85b97918625d172227c8ffb7e0c71794966c0/
|
||||
src/calibre/ebooks/metadata/sources/base.py#L363-L367
|
||||
(src/calibre/ebooks/metadata/sources/base.py - lines 363-398)
|
||||
"""
|
||||
title_patterns = [
|
||||
(re.compile(pat, re.IGNORECASE), repl)
|
||||
for pat, repl in [
|
||||
# Remove things like: (2010) (Omnibus) etc.
|
||||
(
|
||||
r"(?i)[({\[](\d{4}|omnibus|anthology|hardcover|"
|
||||
r"audiobook|audio\scd|paperback|turtleback|"
|
||||
r"mass\s*market|edition|ed\.)[\])}]",
|
||||
"",
|
||||
),
|
||||
# Remove any strings that contain the substring edition inside
|
||||
# parentheses
|
||||
(r"(?i)[({\[].*?(edition|ed.).*?[\]})]", ""),
|
||||
# Remove commas used a separators in numbers
|
||||
(r"(\d+),(\d+)", r"\1\2"),
|
||||
# Remove hyphens only if they have whitespace before them
|
||||
(r"(\s-)", " "),
|
||||
# Replace other special chars with a space
|
||||
(r"""[:,;!@$%^&*(){}.`~"\s\[\]/]《》「」“”""", " "),
|
||||
]
|
||||
]
|
||||
|
||||
for pat, repl in title_patterns:
|
||||
title = pat.sub(repl, title)
|
||||
|
||||
tokens = title.split()
|
||||
for token in tokens:
|
||||
token = token.strip().strip('"').strip("'")
|
||||
if token and (
|
||||
not strip_joiners or token.lower() not in ("a", "and", "the", "&")
|
||||
):
|
||||
yield token
|
||||
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2019 pwr
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from .. import logger
|
||||
|
||||
log = logger.create()
|
||||
|
||||
try:
|
||||
from . import goodreads_support
|
||||
except ImportError as err:
|
||||
log.debug("Cannot import goodreads, showing authors-metadata will not work: %s", err)
|
||||
goodreads_support = None
|
||||
|
||||
|
||||
try:
|
||||
from . import simpleldap as ldap
|
||||
from .simpleldap import ldapVersion
|
||||
except ImportError as err:
|
||||
log.debug("Cannot import simpleldap, logging in with ldap will not work: %s", err)
|
||||
ldap = None
|
||||
ldapVersion = None
|
||||
|
||||
try:
|
||||
from . import SyncToken as SyncToken
|
||||
kobo = True
|
||||
except ImportError as err:
|
||||
log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
|
||||
kobo = 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
|
||||
|
||||
try:
|
||||
from . import hardcover
|
||||
except ImportError as err:
|
||||
log.debug("Cannot import hardcover, syncing Kobo read progress to Hardcover will not work: %s", err)
|
||||
hardcover = None
|
||||
@@ -0,0 +1,232 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018-2019 OzzieIsaacs, pwr
|
||||
#
|
||||
# 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 datetime import datetime
|
||||
import requests
|
||||
|
||||
from .. import logger
|
||||
|
||||
log = logger.create()
|
||||
|
||||
GRAPHQL_ENDPOINT = "https://api.hardcover.app/v1/graphql"
|
||||
|
||||
USER_BOOK_FRAGMENT = """
|
||||
fragment userBookFragment on user_books {
|
||||
id
|
||||
status_id
|
||||
book_id
|
||||
book {
|
||||
slug
|
||||
title
|
||||
}
|
||||
edition {
|
||||
id
|
||||
pages
|
||||
}
|
||||
user_book_reads(order_by: {started_at: desc}, limit: 1) {
|
||||
id
|
||||
started_at
|
||||
finished_at
|
||||
edition_id
|
||||
progress_pages
|
||||
}
|
||||
}"""
|
||||
|
||||
class HardcoverClient:
|
||||
def __init__(self, token):
|
||||
self.endpoint = GRAPHQL_ENDPOINT
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization" : f"Bearer {token}"
|
||||
}
|
||||
self.privacy = self.get_privacy()
|
||||
|
||||
def get_privacy(self):
|
||||
query = """
|
||||
{
|
||||
me {
|
||||
account_privacy_setting_id
|
||||
}
|
||||
}"""
|
||||
response = self.execute(query)
|
||||
return (response.get("me")[0] or [{}]).get("account_privacy_setting_id",1)
|
||||
|
||||
def get_user_book(self, ids):
|
||||
query = ""
|
||||
variables={}
|
||||
if "hardcover-edition" in ids:
|
||||
query = """
|
||||
query ($query: Int) {
|
||||
me {
|
||||
user_books(where: {edition_id: {_eq: $query}}) {
|
||||
...userBookFragment
|
||||
}
|
||||
}
|
||||
}"""
|
||||
variables["query"] = ids["hardcover-edition"]
|
||||
elif "hardcover-id" in ids:
|
||||
query = """
|
||||
query ($query: Int) {
|
||||
me {
|
||||
user_books(where: {book: {id: {_eq: $query}}}) {
|
||||
...userBookFragment
|
||||
}
|
||||
}
|
||||
}"""
|
||||
variables["query"] = ids["hardcover-id"]
|
||||
elif "hardcover-slug" in ids:
|
||||
query = """
|
||||
query ($slug: String!) {
|
||||
me {
|
||||
user_books(where: {book: {slug: {_eq: $query}}}) {
|
||||
...userBookFragment
|
||||
}
|
||||
}
|
||||
}"""
|
||||
variables["query"] = ids["hardcover-slug"]
|
||||
query += USER_BOOK_FRAGMENT
|
||||
response = self.execute(query,variables)
|
||||
return next(iter(response.get("me")[0].get("user_books")),None)
|
||||
|
||||
|
||||
# TODO Add option for autocreate if missing books instead of forcing it.
|
||||
def update_reading_progress(self, identifiers, progress_percent):
|
||||
ids = self.parse_identifiers(identifiers)
|
||||
book = self.get_user_book(ids)
|
||||
if not book:
|
||||
book = self.add_book(ids, status=2)
|
||||
if book.get("status_id") is not 2:
|
||||
book = self.change_book_status(book, 2)
|
||||
pages = round(book.get("edition",{}).get("pages",0))
|
||||
if pages:
|
||||
pages_read = pages * (progress_percent / 100)
|
||||
read = next(iter(book.get("user_book_reads")),None)
|
||||
if not read:
|
||||
read = self.add_read(book, pages_read)
|
||||
mutation = """
|
||||
mutation ($readId: Int!, $pages: Int, $editionId: Int, $startedAt: date, $finishedAt: date) {
|
||||
update_user_book_read(id: $readId, object: {
|
||||
progress_pages: $pages,
|
||||
edition_id: $editionId,
|
||||
started_at: $startedAt,
|
||||
finished_at: $finishedAt
|
||||
}) {
|
||||
id
|
||||
}
|
||||
}"""
|
||||
variables = {
|
||||
"readId": int(read.get("id")),
|
||||
"pages": pages_read,
|
||||
"editionId": int(book.get("edition").get("id")),
|
||||
"startedAt":read.get("started_at",datetime.now().strftime("%Y-%m-%d")),
|
||||
"finishedAt": datetime.now().strftime("%Y-%m-%d") if progress_percent is 100 else None
|
||||
}
|
||||
if progress_percent is 100:
|
||||
self.change_book_status(book, 3)
|
||||
return self.execute(query=mutation, variables=variables)
|
||||
return
|
||||
|
||||
def change_book_status(self, book, status):
|
||||
mutation = """
|
||||
mutation ($id:Int!, $status_id: Int!) {
|
||||
update_user_book(id: $id, object: {status_id: $status_id}) {
|
||||
error
|
||||
user_book {
|
||||
...userBookFragment
|
||||
}
|
||||
}
|
||||
}""" + USER_BOOK_FRAGMENT
|
||||
variables = {
|
||||
"id":book.get("id"),
|
||||
"status_id":status
|
||||
}
|
||||
response = self.execute(query=mutation, variables=variables)
|
||||
return response.get("update_user_book",{}).get("user_book",{})
|
||||
|
||||
def add_book(self, identifiers, status=1):
|
||||
ids = self.parse_identifiers(identifiers)
|
||||
mutation = """
|
||||
mutation ($object: UserBookCreateInput!) {
|
||||
insert_user_book(object: $object) {
|
||||
error
|
||||
user_book {
|
||||
...userBookFragment
|
||||
}
|
||||
}
|
||||
}""" + USER_BOOK_FRAGMENT
|
||||
variables = {
|
||||
"object": {
|
||||
"book_id":int(ids.get("hardcover-id")),
|
||||
"edition_id":int(ids.get("hardcover-edition")) if ids.get("hardcover-edition") else None,
|
||||
"status_id": status,
|
||||
"privacy_setting_id": self.privacy
|
||||
}
|
||||
}
|
||||
response = self.execute(query=mutation, variables=variables)
|
||||
return response.get("insert_user_book",{}).get("user_book",{})
|
||||
|
||||
def add_read(self, book, pages=0):
|
||||
mutation = """
|
||||
mutation ($id: Int!, $pages: Int, $editionId: Int, $startedAt: date) {
|
||||
insert_user_book_read(user_book_id: $id, user_book_read: {
|
||||
progress_pages: $pages,
|
||||
edition_id: $editionId,
|
||||
started_at: $startedAt,
|
||||
}) {
|
||||
error
|
||||
user_book_read {
|
||||
id
|
||||
started_at
|
||||
finished_at
|
||||
edition_id
|
||||
progress_pages
|
||||
}
|
||||
}
|
||||
}"""
|
||||
variables = {
|
||||
"id":int(book.get("id")),
|
||||
"editionId":int(book.get("edition").get("id")) if book.get("edition").get("id") else None,
|
||||
"pages": pages,
|
||||
"startedAt": datetime.now().strftime("%Y-%m-%d")
|
||||
}
|
||||
response = self.execute(query=mutation, variables=variables)
|
||||
return response.get("insert_user_book_read").get("user_book_read")
|
||||
|
||||
def parse_identifiers(self, identifiers):
|
||||
if type(identifiers) != dict:
|
||||
return {id.type:id.val for id in identifiers if "hardcover" in id.type}
|
||||
return identifiers
|
||||
|
||||
def execute(self, query, variables=None):
|
||||
payload = {
|
||||
"query": query,
|
||||
"variables": variables or {}
|
||||
}
|
||||
|
||||
|
||||
response = requests.post(self.endpoint, json=payload, headers=self.headers)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise Exception(f"HTTP error occurred: {e}")
|
||||
|
||||
result = response.json()
|
||||
if "errors" in result:
|
||||
raise Exception(f"GraphQL error: {result['errors']}")
|
||||
|
||||
return result.get("data", {})
|
||||
@@ -0,0 +1,492 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
|
||||
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
|
||||
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
|
||||
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
|
||||
# apetresc, nanu-c, mutschler
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, flash, redirect, request, url_for, abort
|
||||
from flask_babel import gettext as _
|
||||
from .cw_login import current_user
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
from sqlalchemy.sql.expression import func, true
|
||||
|
||||
from . import calibre_db, config, db, logger, ub
|
||||
from .render_template import render_title_template
|
||||
from .usermanagement import login_required_if_no_ano, user_login_required
|
||||
from .services import hardcover
|
||||
log = logger.create()
|
||||
|
||||
shelf = Blueprint('shelf', __name__)
|
||||
|
||||
|
||||
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||
@user_login_required
|
||||
def add_to_shelf(shelf_id, book_id):
|
||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if shelf is None:
|
||||
log.error("Invalid shelf specified: %s", shelf_id)
|
||||
if not xhr:
|
||||
flash(_("Invalid shelf specified"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
return "Invalid shelf specified", 400
|
||||
|
||||
if not check_shelf_edit_permissions(shelf):
|
||||
if not xhr:
|
||||
flash(_("Sorry you are not allowed to add a book to that shelf"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
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,
|
||||
ub.BookShelf.book_id == book_id).first()
|
||||
if book_in_shelf:
|
||||
log.error("Book %s is already part of %s", book_id, shelf)
|
||||
if not xhr:
|
||||
flash(_("Book is already part of the shelf: %(shelfname)s", shelfname=shelf.name), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
return "Book is already part of the shelf: %s" % shelf.name, 400
|
||||
|
||||
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()
|
||||
if maxOrder[0] is None:
|
||||
maxOrder = 0
|
||||
else:
|
||||
maxOrder = maxOrder[0]
|
||||
|
||||
book = calibre_db.session.query(db.Books).filter(db.Books.id == book_id).one_or_none()
|
||||
if not book:
|
||||
log.error("Invalid Book Id: %s. Could not be added to shelf %s", book_id, shelf.name)
|
||||
if not xhr:
|
||||
flash(_("%(book_id)s is a invalid Book Id. Could not be added to Shelf", book_id=book_id),
|
||||
category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
return "%s is a invalid Book Id. Could not be added to Shelf" % book_id, 400
|
||||
|
||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
try:
|
||||
ub.session.merge(shelf)
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
if "HTTP_REFERER" in request.environ:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
return redirect(url_for('web.index'))
|
||||
if not xhr:
|
||||
log.debug("Book has been added to shelf: {}".format(shelf.name))
|
||||
flash(_("Book has been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
||||
if "HTTP_REFERER" in request.environ:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
return redirect(url_for('web.index'))
|
||||
if shelf.kobo_sync and config.config_hardcover_sync and bool(hardcover):
|
||||
hardcoverClient = hardcover.HardcoverClient(current_user.hardcover_token)
|
||||
hardcoverClient.add_book(book.identifiers)
|
||||
return "", 204
|
||||
|
||||
|
||||
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
||||
@user_login_required
|
||||
def search_to_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if shelf is None:
|
||||
log.error("Invalid shelf specified: {}".format(shelf_id))
|
||||
flash(_("Invalid shelf specified"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
if not check_shelf_edit_permissions(shelf):
|
||||
log.warning("You are not allowed to add a book to the shelf".format(shelf.name))
|
||||
flash(_("You are not allowed to add a book to the shelf"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]:
|
||||
books_for_shelf = list()
|
||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
|
||||
if books_in_shelf:
|
||||
book_ids = list()
|
||||
for book_id in books_in_shelf:
|
||||
book_ids.append(book_id.book_id)
|
||||
for searchid in ub.searched_ids[current_user.id]:
|
||||
if searchid not in book_ids:
|
||||
books_for_shelf.append(searchid)
|
||||
else:
|
||||
books_for_shelf = ub.searched_ids[current_user.id]
|
||||
|
||||
if not books_for_shelf:
|
||||
log.error("Books are already part of {}".format(shelf.name))
|
||||
flash(_("Books are already part of the shelf: %(name)s", name=shelf.name), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()[0] or 0
|
||||
|
||||
for book in books_for_shelf:
|
||||
maxOrder += 1
|
||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
try:
|
||||
ub.session.merge(shelf)
|
||||
ub.session.commit()
|
||||
flash(_("Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
else:
|
||||
log.error("Could not add books to shelf: {}".format(shelf.name))
|
||||
flash(_("Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
|
||||
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||
@user_login_required
|
||||
def remove_from_shelf(shelf_id, book_id):
|
||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if shelf is None:
|
||||
log.error("Invalid shelf specified: {}".format(shelf_id))
|
||||
if not xhr:
|
||||
return redirect(url_for('web.index'))
|
||||
return "Invalid shelf specified", 400
|
||||
|
||||
# if shelf is public and use is allowed to edit shelfs, or if shelf is private and user is owner
|
||||
# allow editing shelfs
|
||||
# result shelf public user allowed user owner
|
||||
# false 1 0 x
|
||||
# true 1 1 x
|
||||
# true 0 x 1
|
||||
# false 0 x 0
|
||||
|
||||
if check_shelf_edit_permissions(shelf):
|
||||
book_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id,
|
||||
ub.BookShelf.book_id == book_id).first()
|
||||
|
||||
if book_shelf is None:
|
||||
log.error("Book %s already removed from %s", book_id, shelf)
|
||||
if not xhr:
|
||||
return redirect(url_for('web.index'))
|
||||
return "Book already removed from shelf", 410
|
||||
|
||||
try:
|
||||
ub.session.delete(book_shelf)
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
if "HTTP_REFERER" in request.environ:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
return redirect(url_for('web.index'))
|
||||
if not xhr:
|
||||
flash(_("Book has been removed from shelf: %(sname)s", sname=shelf.name), category="success")
|
||||
if "HTTP_REFERER" in request.environ:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
return redirect(url_for('web.index'))
|
||||
return "", 204
|
||||
else:
|
||||
if not xhr:
|
||||
log.warning("You are not allowed to remove a book from shelf: {}".format(shelf.name))
|
||||
flash(_("Sorry you are not allowed to remove a book from this shelf"),
|
||||
category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
return "Sorry you are not allowed to remove a book from this shelf", 403
|
||||
|
||||
|
||||
@shelf.route("/shelf/create", methods=["GET", "POST"])
|
||||
@user_login_required
|
||||
def create_shelf():
|
||||
shelf = ub.Shelf()
|
||||
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
|
||||
|
||||
|
||||
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
||||
@user_login_required
|
||||
def edit_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if not check_shelf_edit_permissions(shelf):
|
||||
flash(_("Sorry you are not allowed to edit this shelf"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
return create_edit_shelf(shelf, page_title=_("Edit a shelf"), page="shelfedit", shelf_id=shelf_id)
|
||||
|
||||
|
||||
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
||||
@user_login_required
|
||||
def delete_shelf(shelf_id):
|
||||
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
try:
|
||||
if not delete_shelf_helper(cur_shelf):
|
||||
flash(_("Error deleting Shelf"), category="error")
|
||||
else:
|
||||
flash(_("Shelf successfully deleted"), category="success")
|
||||
except InvalidRequestError as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
|
||||
@shelf.route("/simpleshelf/<int:shelf_id>")
|
||||
@login_required_if_no_ano
|
||||
def show_simpleshelf(shelf_id):
|
||||
return render_show_shelf(2, shelf_id, 1, None)
|
||||
|
||||
|
||||
@shelf.route("/shelf/<int:shelf_id>", defaults={"sort_param": "stored", 'page': 1})
|
||||
@shelf.route("/shelf/<int:shelf_id>/<sort_param>", defaults={'page': 1})
|
||||
@shelf.route("/shelf/<int:shelf_id>/<sort_param>/<int:page>")
|
||||
@login_required_if_no_ano
|
||||
def show_shelf(shelf_id, sort_param, page):
|
||||
return render_show_shelf(1, shelf_id, page, sort_param)
|
||||
|
||||
|
||||
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
|
||||
@user_login_required
|
||||
def order_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if shelf and check_shelf_view_permissions(shelf):
|
||||
if request.method == "POST":
|
||||
to_save = request.form.to_dict()
|
||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by(
|
||||
ub.BookShelf.order.asc()).all()
|
||||
counter = 0
|
||||
for book in books_in_shelf:
|
||||
setattr(book, 'order', to_save[str(book.book_id)])
|
||||
counter += 1
|
||||
# if order different from before -> shelf.last_modified = datetime.now(timezone.utc)
|
||||
try:
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
|
||||
result = list()
|
||||
if shelf:
|
||||
result = calibre_db.session.query(db.Books) \
|
||||
.join(ub.BookShelf, ub.BookShelf.book_id == db.Books.id, isouter=True) \
|
||||
.add_columns(calibre_db.common_filters().label("visible")) \
|
||||
.filter(ub.BookShelf.shelf == shelf_id).order_by(ub.BookShelf.order.asc()).all()
|
||||
return render_title_template('shelf_order.html', entries=result,
|
||||
title=_("Change order of Shelf: '%(name)s'", name=shelf.name),
|
||||
shelf=shelf, page="shelforder")
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
|
||||
def check_shelf_edit_permissions(cur_shelf):
|
||||
if not cur_shelf.is_public and not cur_shelf.user_id == int(current_user.id):
|
||||
log.error("User {} not allowed to edit shelf: {}".format(current_user.id, cur_shelf.name))
|
||||
return False
|
||||
if cur_shelf.is_public and not current_user.role_edit_shelfs():
|
||||
log.info("User {} not allowed to edit public shelves".format(current_user.id))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_shelf_view_permissions(cur_shelf):
|
||||
try:
|
||||
if cur_shelf.is_public:
|
||||
return True
|
||||
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
|
||||
log.error("User is unauthorized to view non-public shelf: {}".format(cur_shelf.name))
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error(e)
|
||||
return True
|
||||
|
||||
|
||||
# if shelf ID is set, we are editing a shelf
|
||||
def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
||||
sync_only_selected_shelves = current_user.kobo_only_shelves_sync
|
||||
# calibre_db.session.query(ub.Shelf).filter(ub.Shelf.user_id == current_user.id).filter(ub.Shelf.kobo_sync).count()
|
||||
if request.method == "POST":
|
||||
to_save = request.form.to_dict()
|
||||
if not current_user.role_edit_shelfs() and to_save.get("is_public") == "on":
|
||||
flash(_("Sorry you are not allowed to create a public shelf"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
is_public = 1 if to_save.get("is_public") == "on" else 0
|
||||
if config.config_kobo_sync:
|
||||
shelf.kobo_sync = True if to_save.get("kobo_sync") else False
|
||||
if shelf.kobo_sync:
|
||||
ub.session.query(ub.ShelfArchive).filter(ub.ShelfArchive.user_id == current_user.id).filter(
|
||||
ub.ShelfArchive.uuid == shelf.uuid).delete()
|
||||
ub.session_commit()
|
||||
shelf_title = to_save.get("title", "")
|
||||
if check_shelf_is_unique(shelf_title, is_public, shelf_id):
|
||||
shelf.name = shelf_title
|
||||
shelf.is_public = is_public
|
||||
if not shelf_id:
|
||||
shelf.user_id = int(current_user.id)
|
||||
ub.session.add(shelf)
|
||||
shelf_action = "created"
|
||||
flash_text = _("Shelf %(title)s created", title=shelf_title)
|
||||
else:
|
||||
shelf_action = "changed"
|
||||
flash_text = _("Shelf %(title)s changed", title=shelf_title)
|
||||
try:
|
||||
ub.session.commit()
|
||||
log.info("Shelf {} {}".format(shelf_title, shelf_action))
|
||||
flash(flash_text, category="success")
|
||||
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
||||
except (OperationalError, InvalidRequestError) as ex:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception(ex)
|
||||
log.error_or_exception("Settings Database error: {}".format(ex))
|
||||
flash(_("Oops! Database Error: %(error)s.", error=ex.orig), category="error")
|
||||
except Exception as ex:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception(ex)
|
||||
flash(_("There was an error"), category="error")
|
||||
return render_title_template('shelf_edit.html',
|
||||
shelf=shelf,
|
||||
title=page_title,
|
||||
page=page,
|
||||
kobo_sync_enabled=config.config_kobo_sync,
|
||||
sync_only_selected_shelves=sync_only_selected_shelves)
|
||||
|
||||
|
||||
def check_shelf_is_unique(title, is_public, shelf_id=False):
|
||||
if shelf_id:
|
||||
ident = ub.Shelf.id != shelf_id
|
||||
else:
|
||||
ident = true()
|
||||
if is_public == 1:
|
||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 1)) \
|
||||
.filter(ident) \
|
||||
.first() is None
|
||||
|
||||
if not is_shelf_name_unique:
|
||||
log.error("A public shelf with the name '{}' already exists.".format(title))
|
||||
flash(_("A public shelf with the name '%(title)s' already exists.", title=title),
|
||||
category="error")
|
||||
else:
|
||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 0) &
|
||||
(ub.Shelf.user_id == int(current_user.id))) \
|
||||
.filter(ident) \
|
||||
.first() is None
|
||||
|
||||
if not is_shelf_name_unique:
|
||||
log.error("A private shelf with the name '{}' already exists.".format(title))
|
||||
flash(_("A private shelf with the name '%(title)s' already exists.", title=title),
|
||||
category="error")
|
||||
return is_shelf_name_unique
|
||||
|
||||
|
||||
def delete_shelf_helper(cur_shelf):
|
||||
if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
|
||||
return False
|
||||
shelf_id = cur_shelf.id
|
||||
ub.session.delete(cur_shelf)
|
||||
ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
|
||||
ub.session.add(ub.ShelfArchive(uuid=cur_shelf.uuid, user_id=cur_shelf.user_id))
|
||||
ub.session_commit("successfully deleted Shelf {}".format(cur_shelf.name))
|
||||
return True
|
||||
|
||||
|
||||
def change_shelf_order(shelf_id, order):
|
||||
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()
|
||||
for index, entry in enumerate(result):
|
||||
book = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \
|
||||
.filter(ub.BookShelf.book_id == entry.id).first()
|
||||
book.order = index
|
||||
ub.session_commit("Shelf-id:{} - Order changed".format(shelf_id))
|
||||
|
||||
|
||||
def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
status = current_user.get_view_property("shelf", 'man')
|
||||
# check user is allowed to access shelf
|
||||
if shelf and check_shelf_view_permissions(shelf):
|
||||
if shelf_type == 1:
|
||||
if status != 'on':
|
||||
if sort_param == 'stored':
|
||||
sort_param = current_user.get_view_property("shelf", 'stored')
|
||||
else:
|
||||
current_user.set_view_property("shelf", 'stored', sort_param)
|
||||
if sort_param == 'pubnew':
|
||||
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
|
||||
if sort_param == 'pubold':
|
||||
change_shelf_order(shelf_id, [db.Books.pubdate])
|
||||
if sort_param == 'shelfnew':
|
||||
change_shelf_order(shelf_id, [ub.BookShelf.date_added.desc()])
|
||||
if sort_param == 'shelfold':
|
||||
change_shelf_order(shelf_id, [ub.BookShelf.date_added])
|
||||
if sort_param == 'abc':
|
||||
change_shelf_order(shelf_id, [db.Books.sort])
|
||||
if sort_param == 'zyx':
|
||||
change_shelf_order(shelf_id, [db.Books.sort.desc()])
|
||||
if sort_param == 'new':
|
||||
change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
|
||||
if sort_param == 'old':
|
||||
change_shelf_order(shelf_id, [db.Books.timestamp])
|
||||
if sort_param == 'authaz':
|
||||
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
|
||||
if sort_param == 'authza':
|
||||
change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
|
||||
db.Series.name.desc(),
|
||||
db.Books.series_index.desc()])
|
||||
page = "shelf.html"
|
||||
pagesize = 0
|
||||
else:
|
||||
pagesize = sys.maxsize
|
||||
page = 'shelfdown.html'
|
||||
|
||||
result, __, pagination = calibre_db.fill_indexpage(page_no, pagesize,
|
||||
db.Books,
|
||||
ub.BookShelf.shelf == shelf_id,
|
||||
[ub.BookShelf.order.asc()],
|
||||
True, config.config_read_column,
|
||||
ub.BookShelf, ub.BookShelf.book_id == db.Books.id)
|
||||
# delete shelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
|
||||
wrong_entries = calibre_db.session.query(ub.BookShelf) \
|
||||
.join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \
|
||||
.filter(db.Books.id == None).all()
|
||||
for entry in wrong_entries:
|
||||
log.info('Not existing book {} in {} deleted'.format(entry.book_id, shelf))
|
||||
try:
|
||||
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == entry.book_id).delete()
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||
|
||||
return render_title_template(page,
|
||||
entries=result,
|
||||
pagination=pagination,
|
||||
title=_("Shelf: '%(name)s'", name=shelf.name),
|
||||
shelf=shelf,
|
||||
page="shelf",
|
||||
status=status,
|
||||
order=sort_param)
|
||||
else:
|
||||
flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error")
|
||||
return redirect(url_for("web.index"))
|
||||
@@ -313,7 +313,7 @@ span.glyphicon.glyphicon-tags {
|
||||
position: relative;
|
||||
top: -20px;
|
||||
/*left: auto;
|
||||
right: 2px;*/
|
||||
right: 2px;*/
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: inline-block;
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block flash %}
|
||||
<div id="spinning_success" class="row-fluid text-center" style="display:none;">
|
||||
<div class="alert alert-info"><img id="img-spinner" src="{{ url_for('static', filename='css/libs/images/loading-icon.gif') }}"/></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
<h2>{{title}}</h2>
|
||||
<form role="form" method="POST" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="panel-group col-md-11 col-lg-8">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a class="accordion-toggle" data-toggle="collapse" href="#collapseone">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
{{_('Server Configuration')}}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="collapseone" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label for="config_port">{{_('Server Port')}}</label>
|
||||
<input type="number" min="1" max="65535" class="form-control" name="config_port" id="config_port" value="{% if config.config_port != None %}{{ config.config_port }}{% endif %}" autocomplete="off" required>
|
||||
</div>
|
||||
<label for="config_certfile">{{_('SSL certfile location (leave it empty for non-SSL Servers)')}}</label>
|
||||
<div class="form-group input-group">
|
||||
<input type="text" class="form-control" id="config_certfile" name="config_certfile" value="{% if config.config_certfile != None %}{{ config.config_certfile }}{% endif %}" autocomplete="off">
|
||||
<span class="input-group-btn">
|
||||
<button type="button" data-toggle="modal" data-link="config_certfile" data-target="#fileModal" id="certfile_path" class="btn btn-default"><span class="glyphicon glyphicon-folder-open"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
<label for="config_keyfile" >{{_('SSL Keyfile location (leave it empty for non-SSL Servers)')}}</label>
|
||||
<div class="form-group input-group">
|
||||
<input type="text" class="form-control" id="config_keyfile" name="config_keyfile" value="{% if config.config_keyfile != None %}{{ config.config_keyfile }}{% endif %}" autocomplete="off">
|
||||
<span class="input-group-btn">
|
||||
<button type="button" id="keyfile_path" data-toggle="modal" data-link="config_keyfile" data-target="#fileModal" class="btn btn-default"><span class="glyphicon glyphicon-folder-open"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_updatechannel">{{_('Update Channel')}}</label>
|
||||
<select name="config_updatechannel" id="config_updatechannel" class="form-control">
|
||||
<option value="0" {% if config.config_updatechannel == 0 %}selected{% endif %}>{{_('Stable')}}</option>
|
||||
<option value="2" {% if config.config_updatechannel == 2 %}selected{% endif %}>{{_('Nightly')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_trustedhosts">{{_('Trusted Hosts (Comma Separated)')}}</label>
|
||||
<input type="text" class="form-control" id="config_trustedhosts" name="config_trustedhosts" value="{% if config.trustedhosts != None %}{{ config.config_trustedhosts }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a class="accordion-toggle" data-toggle="collapse" href="#collapsetwo">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
{{_('Logfile Configuration')}}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="collapsetwo" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label for="config_log_level">{{_('Log Level')}}</label>
|
||||
<select name="config_log_level" id="config_log_level" class="form-control">
|
||||
<option value="10" {% if config.config_log_level == 10 %}selected{% endif %}>DEBUG</option>
|
||||
<option value="20" {% if config.config_log_level == 20 or config.config_log_level == None %}selected{% endif %}>INFO</option>
|
||||
<option value="30" {% if config.config_log_level == 30 %}selected{% endif %}>WARNING</option>
|
||||
<option value="40" {% if config.config_log_level == 40 %}selected{% endif %}>ERROR</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_logfile">{{_('Location and name of logfile (calibre-web.log for no entry)')}}</label>
|
||||
<input type="text" class="form-control" name="config_logfile" id="config_logfile" value="{% if config.config_logfile != None %}{{ config.config_logfile }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_access_log" name="config_access_log" {% if config.config_access_log %}checked{% endif %}>
|
||||
<label for="config_access_log">{{_('Enable Access Log')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_access_logfile">{{_('Location and name of access logfile (access.log for no entry)')}}</label>
|
||||
<input type="text" class="form-control" name="config_access_logfile" id="config_access_logfile" value="{% if config.config_access_logfile != None %}{{ config.config_access_logfile }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a class="accordion-toggle" data-toggle="collapse" href="#collapsefour">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
{{_('Feature Configuration')}}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="collapsefour" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_unicode_filename" name="config_unicode_filename" {% if config.config_unicode_filename %}checked{% endif %}>
|
||||
<label for="config_unicode_filename">{{_('Convert non-English characters in title and author while saving to disk')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_embed_metadata" name="config_embed_metadata" {% if config.config_embed_metadata %}checked{% endif %}>
|
||||
<label for="config_embed_metadata">{{_('Embed Metadata to Ebook File on Download/Conversion/e-mail (needs Calibre/Kepubify binaries)')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_uploading" data-control="upload_settings" name="config_uploading" {% if config.config_uploading %}checked{% endif %}>
|
||||
<label for="config_uploading">{{_('Enable Uploads')}} {{_('(Please ensure that users also have upload permissions)')}}</label>
|
||||
</div>
|
||||
<div data-related="upload_settings">
|
||||
<div class="form-group">
|
||||
<label for="config_upload_formats">{{_('Allowed Upload Fileformats')}}</label>
|
||||
<input type="text" class="form-control" name="config_upload_formats" id="config_upload_formats" value="{% if config.config_upload_formats != None %}{{ config.config_upload_formats }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_anonbrowse" name="config_anonbrowse" {% if config.config_anonbrowse %}checked{% endif %}>
|
||||
<label for="config_anonbrowse">{{_('Enable Anonymous Browsing')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_public_reg" data-control="register_settings" name="config_public_reg" {% if config.config_public_reg %}checked{% endif %}>
|
||||
<label for="config_public_reg">{{_('Enable Public Registration')}}</label>
|
||||
</div>
|
||||
<div data-related="register_settings">
|
||||
<div class="form-group intend-form">
|
||||
<input type="checkbox" id="config_register_email" name="config_register_email" {% if config.config_register_email %}checked{% endif %}>
|
||||
<label for="config_register_email">{{_('Use Email as Username')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_remote_login" name="config_remote_login" {% if config.config_remote_login %}checked{% endif %}>
|
||||
<label for="config_remote_login">{{_('Enable Magic Link Remote Login')}}</label>
|
||||
</div>
|
||||
{% if feature_support['kobo'] %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_kobo_sync" name="config_kobo_sync" data-control="kobo-settings" {% if config.config_kobo_sync %}checked{% endif %}>
|
||||
<label for="config_kobo_sync">{{_('Enable Kobo sync')}}</label>
|
||||
</div>
|
||||
<div data-related="kobo-settings">
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<input type="checkbox" id="config_kobo_proxy" name="config_kobo_proxy" {% if config.config_kobo_proxy %}checked{% endif %}>
|
||||
<label for="config_kobo_proxy">{{_('Proxy unknown requests to Kobo Store')}}</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<label for="config_external_port">{{_('Server External Port (for port forwarded API calls)')}}</label>
|
||||
<input type="number" min="1" max="65535" class="form-control" name="config_external_port" id="config_external_port" value="{% if config.config_external_port != None %}{{ config.config_external_port }}{% endif %}" autocomplete="off" required>
|
||||
</div>
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<input type="checkbox" id="config_hardcover_sync" name="config_hardcover_sync" {% if config.config_hardcover_sync %}checked{% endif %}>
|
||||
<label for="config_hardcover_sync">{{_('Sync Kobo read progress to Hardcover (Requires API key per user)')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if feature_support['goodreads'] %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_use_goodreads" name="config_use_goodreads" data-control="goodreads-settings" {% if config.config_use_goodreads %}checked{% endif %}>
|
||||
<label for="config_use_goodreads">{{_('Use Goodreads')}}</label>
|
||||
</div>
|
||||
<div data-related="goodreads-settings">
|
||||
<div class="form-group">
|
||||
<label for="config_goodreads_api_key">{{_('Goodreads API Key')}}</label>
|
||||
<input type="text" class="form-control" id="config_goodreads_api_key" name="config_goodreads_api_key" value="{% if config.config_goodreads_api_key != None %}{{ config.config_goodreads_api_key }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_allow_reverse_proxy_header_login" name="config_allow_reverse_proxy_header_login" data-control="reverse-proxy-login-settings" {% if config.config_allow_reverse_proxy_header_login %}checked{% endif %}>
|
||||
<label for="config_allow_reverse_proxy_header_login">{{_('Allow Reverse Proxy Authentication')}}</label>
|
||||
</div>
|
||||
<div data-related="reverse-proxy-login-settings">
|
||||
<div class="form-group">
|
||||
<label for="config_reverse_proxy_login_header_name">{{_('Reverse Proxy Header Name')}}</label>
|
||||
<input type="text" class="form-control" id="config_reverse_proxy_login_header_name" name="config_reverse_proxy_login_header_name" value="{% if config.config_reverse_proxy_login_header_name != None %}{{ config.config_reverse_proxy_login_header_name }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
{% if not config.config_is_initial %}
|
||||
{% if feature_support['ldap'] or feature_support['oauth'] %}
|
||||
<div class="form-group">
|
||||
<label for="config_login_type">{{_('Login type')}}</label>
|
||||
<select name="config_login_type" id="config_login_type" class="form-control" data-control="login-settings">
|
||||
<option value="0" {% if config.config_login_type == 0 %}selected{% endif %}>{{_('Use Standard Authentication')}}</option>
|
||||
{% if feature_support['ldap'] %}
|
||||
<option value="1" {% if config.config_login_type == 1 %}selected{% endif %}>{{_('Use LDAP Authentication')}}</option>
|
||||
{% endif %}
|
||||
{% if feature_support['oauth'] %}
|
||||
<option value="2" {% if config.config_login_type == 2 %}selected{% endif %}>{{_('Use OAuth')}}</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
{% if feature_support['ldap'] %}
|
||||
<div data-related="login-settings-1">
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_provider_url">{{_('LDAP Server Host Name or IP Address')}}</label>
|
||||
<input type="text" class="form-control" id="config_ldap_provider_url" name="config_ldap_provider_url" value="{% if config.config_ldap_provider_url != None %}{{ config.config_ldap_provider_url }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_port">{{_('LDAP Server Port')}}</label>
|
||||
<input type="number" min="1" max="65535" class="form-control" id="config_ldap_port" name="config_ldap_port" value="{% if config.config_ldap_port != None %}{{ config.config_ldap_port }}{% endif %}" autocomplete="off" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_encryption">{{_('LDAP Encryption')}}</label>
|
||||
<select name="config_ldap_encryption" id="config_ldap_encryption" class="form-control" data-controlall="ldap-cert-settings">
|
||||
<option value="0" {% if config.config_ldap_encryption == 0 %}selected{% endif %}>{{ _('None') }}</option>
|
||||
<option value="1" {% if config.config_ldap_encryption == 1 %}selected{% endif %}>{{ _('TLS') }}</option>
|
||||
<option value="2" {% if config.config_ldap_encryption == 2 %}selected{% endif %}>{{ _('SSL') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div data-related="ldap-cert-settings">
|
||||
<label for="config_ldap_cacert_path" >{{_('LDAP CACertificate Path (Only needed for Client Certificate Authentication)')}}</label>
|
||||
<div class="form-group input-group">
|
||||
<input type="text" class="form-control" id="config_ldap_cacert_path" name="config_ldap_cacert_path" value="{% if config.config_ldap_cacert_path != None %}{{ config.config_ldap_cacert_path }}{% endif %}" autocomplete="off">
|
||||
<span class="input-group-btn">
|
||||
<button type="button" id="library_path" data-toggle="modal" data-link="config_ldap_cacert_path" data-target="#fileModal" class="btn btn-default"><span class="glyphicon glyphicon-folder-open"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
<label for="config_ldap_cert_path">{{_('LDAP Certificate Path (Only needed for Client Certificate Authentication)')}}</label>
|
||||
<div class="form-group input-group">
|
||||
<input type="text" class="form-control" id="config_ldap_cert_path" name="config_ldap_cert_path" value="{% if config.config_ldap_cert_path != None %}{{ config.config_ldap_cert_path }}{% endif %}" autocomplete="off">
|
||||
<span class="input-group-btn">
|
||||
<button type="button" id="library_path" data-toggle="modal" data-link="config_ldap_cert_path" data-target="#fileModal" class="btn btn-default"><span class="glyphicon glyphicon-folder-open"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
<label for="config_ldap_key_path">{{_('LDAP Keyfile Path (Only needed for Client Certificate Authentication)')}}</label>
|
||||
<div class="form-group input-group">
|
||||
<input type="text" class="form-control" id="config_ldap_key_path" name="config_ldap_key_path" value="{% if config.config_ldap_key_path != None %}{{ config.config_ldap_key_path }}{% endif %}" autocomplete="off">
|
||||
<span class="input-group-btn">
|
||||
<button type="button" id="library_path" data-toggle="modal" data-link="config_ldap_key_path" data-target="#fileModal" class="btn btn-default"><span class="glyphicon glyphicon-folder-open"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_authentication">{{_('LDAP Authentication')}}</label>
|
||||
<select name="config_ldap_authentication" id="config_ldap_authentication" class="form-control" data-control="ldap-auth-password" data-controlall="ldap-auth-settings">
|
||||
<option value="0" {% if config.config_ldap_authentication == 0 %}selected{% endif %}>{{ _('Anonymous') }}</option>
|
||||
<option value="1" {% if config.config_ldap_authentication == 1 %}selected{% endif %}>{{ _('Unauthenticated') }}</option>
|
||||
<option value="2" {% if config.config_ldap_authentication == 2 %}selected{% endif %}>{{ _('Simple') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div data-related="ldap-auth-settings">
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_serv_username">{{_('LDAP Administrator Username')}}</label>
|
||||
<input type="text" class="form-control" id="config_ldap_serv_username" name="config_ldap_serv_username" value="{% if config.config_ldap_serv_username != None %}{{ config.config_ldap_serv_username }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div data-related="ldap-auth-password-2">
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_serv_password_e">{{_('LDAP Administrator Password')}}</label>
|
||||
<input type="password" class="form-control" id="config_ldap_serv_password_e" name="config_ldap_serv_password_e" value="" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_dn">{{_('LDAP Distinguished Name (DN)')}}</label>
|
||||
<input type="text" class="form-control" id="config_ldap_dn" name="config_ldap_dn" value="{% if config.config_ldap_dn != None %}{{ config.config_ldap_dn }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_user_object">{{_('LDAP User Object Filter')}}</label>
|
||||
<input type="text" class="form-control" id="config_ldap_user_object" name="config_ldap_user_object" value="{% if config.config_ldap_user_object != None %}{{ config.config_ldap_user_object }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_ldap_openldap" name="config_ldap_openldap" {% if config.config_ldap_openldap %}checked{% endif %}>
|
||||
<label for="config_ldap_openldap">{{_('LDAP Server is OpenLDAP?')}}</label>
|
||||
</div>
|
||||
<h4 class="text-center">{{_('Following Settings are Needed For User Import')}}</h4>
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_group_object_filter">{{_('LDAP Group Object Filter')}}</label>
|
||||
<input type="text" class="form-control" id="config_ldap_group_object_filter" name="config_ldap_group_object_filter" value="{% if config.config_ldap_group_object_filter != None %}{{ config.config_ldap_group_object_filter }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_group_name">{{_('LDAP Group Name')}}</label>
|
||||
<input type="text" class="form-control" id="config_ldap_group_name" name="config_ldap_group_name" value="{% if config.config_ldap_group_name != None %}{{ config.config_ldap_group_name }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_group_members_field">{{_('LDAP Group Members Field')}}</label>
|
||||
<input type="text" class="form-control" id="config_ldap_group_members_field" name="config_ldap_group_members_field" value="{% if config.config_ldap_group_members_field != None %}{{ config.config_ldap_group_members_field }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_import_user_filter">{{_('LDAP Member User Filter Detection')}}</label>
|
||||
<select name="ldap_import_user_filter" id="ldap_import_user_filter" class="form-control" data-control="ldap_member_user_object">
|
||||
<option value="0" {% if config.config_ldap_member_user_object == "" %}selected{% endif %}>{{ _('Autodetect') }}</option>
|
||||
<option value="1" {% if config.config_ldap_member_user_object %}selected{% endif %}>{{ _('Custom Filter') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div data-related="ldap_member_user_object-1">
|
||||
<div class="form-group">
|
||||
<label for="config_ldap_member_user_object">{{_('LDAP Member User Filter')}}</label>
|
||||
<input type="text" class="form-control" id="config_ldap_member_user_object" name="config_ldap_member_user_object" value="{% if config.config_ldap_member_user_object != None %}{{ config.config_ldap_member_user_object }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if feature_support['oauth'] %}
|
||||
<div data-related="login-settings-2">
|
||||
{% for prov in provider %}
|
||||
<div class="form-group">
|
||||
<a href="{{prov['obtain_link']}}" target="_blank">{{_('Obtain %(provider)s OAuth Credential', provider=prov['provider_name'])}}</a>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_{{ prov['id'] }}_oauth_client_id">{{_('%(provider)s OAuth Client Id', provider=prov['provider_name'])}}</label>
|
||||
<input type="text" class="form-control" id="config_{{ prov['id'] }}_oauth_client_id" name="config_{{ prov['id'] }}_oauth_client_id" value="{% if prov['oauth_client_id']%}{{ prov['oauth_client_id'] }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_{{ prov['id'] }}_oauth_client_secret">{{_('%(provider)s OAuth Client Secret', provider=prov['provider_name'])}}</label>
|
||||
<input type="text" class="form-control" id="config_{{ prov['id'] }}_oauth_client_secret" name="config_{{ prov['id'] }}_oauth_client_secret" value="{% if prov['oauth_client_secret']%}{{ prov['oauth_client_secret'] }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a class="accordion-toggle" data-toggle="collapse" href="#collapsefive">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
{{_('External binaries')}}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="collapsefive" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<label for="config_binariesdir">{{_('Path to Calibre Binaries')}}</label>
|
||||
<div class="form-group input-group">
|
||||
<input type="text" class="form-control" id="config_binariesdir" name="config_binariesdir" value="{% if config.config_binariesdir != None %}{{ config.config_binariesdir }}{% endif %}" autocomplete="off">
|
||||
<span class="input-group-btn">
|
||||
<button type="button" data-toggle="modal" id="binaries_modal_path" data-link="config_binariesdir" data-folderonly="true" data-target="#fileModal" class="btn btn-default"><span class="glyphicon glyphicon-folder-open"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_calibre">{{_('Calibre E-Book Converter Settings')}}</label>
|
||||
<input type="text" class="form-control" id="config_calibre" name="config_calibre" value="{% if config.config_calibre != None %}{{ config.config_calibre }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<label for="config_kepubifypath">{{_('Path to Kepubify E-Book Converter')}}</label>
|
||||
<div class="form-group input-group">
|
||||
<input type="text" class="form-control" id="config_kepubifypath" name="config_kepubifypath" value="{% if config.config_kepubifypath != None %}{{ config.config_kepubifypath }}{% endif %}" autocomplete="off">
|
||||
<span class="input-group-btn">
|
||||
<button type="button" id="kepubify_path" data-toggle="modal" data-link="config_kepubifypath" data-target="#fileModal" class="btn btn-default"><span class="glyphicon glyphicon-folder-open"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
{% if feature_support['rar'] %}
|
||||
<label for="config_rarfile_location">{{_('Location of Unrar binary')}}</label>
|
||||
<div class="form-group input-group">
|
||||
<input type="text" class="form-control" id="config_rarfile_location" name="config_rarfile_location" value="{% if config.config_rarfile_location != None %}{{ config.config_rarfile_location }}{% endif %}" autocomplete="off">
|
||||
<span class="input-group-btn">
|
||||
<button type="button" id="unrar_path" data-toggle="modal" data-link="config_rarfile_location" data-target="#fileModal" class="btn btn-default"><span class="glyphicon glyphicon-folder-open"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a class="accordion-toggle" data-toggle="collapse" href="#collapsesix">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
{{_('Security Settings')}}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="collapsesix" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_ratelimiter" name="config_ratelimiter" {% if config.config_ratelimiter %}checked{% endif %}>
|
||||
<label for="config_ratelimiter">{{_('Limit failed login attempts')}}</label>
|
||||
</div>
|
||||
<div data-related="ratelimiter_settings">
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<label for="config_calibre">{{_('Configure Backend for Limiter')}}</label>
|
||||
<input type="text" class="form-control" id="config_limiter_uri" name="config_limiter_uri" value="{% if config.config_limiter_uri != None %}{{ config.config_limiter_uri }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<label for="config_calibre">{{_('Options for Limiter Backend')}}</label>
|
||||
<input type="text" class="form-control" id="config_limiter_options" name="config_limiter_options" value="{% if config.config_limiter_options != None %}{{ config.config_limiter_options }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_check_extensions" name="config_check_extensions" {% if config.config_check_extensions %}checked{% endif %}>
|
||||
<label for="config_check_extensions">{{_('Check if file extensions matches file content on upload')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_session">{{_('Session protection')}}</label>
|
||||
<select name="config_session" id="config_session" class="form-control">
|
||||
<option value="0" {% if config.config_session == 0 %}selected{% endif %}>{{_('Basic')}}</option>
|
||||
<option value="1" {% if config.config_session == 1 %}selected{% endif %}>{{_('Strong')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_password_policy" data-control="password_settings" name="config_password_policy" {% if config.config_password_policy %}checked{% endif %}>
|
||||
<label for="config_password_policy">{{_('User Password policy')}}</label>
|
||||
</div>
|
||||
<div data-related="password_settings">
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<label for="config_password_min_length">{{_('Minimum password length')}}</label>
|
||||
<input type="number" min="1" max="40" class="form-control" name="config_password_min_length" id="config_password_min_length" value="{% if config.config_password_min_length != None %}{{ config.config_password_min_length }}{% endif %}" autocomplete="off" required>
|
||||
</div>
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<input type="checkbox" id="config_password_number" name="config_password_number" {% if config.config_password_number %}checked{% endif %}>
|
||||
<label for="config_password_number">{{_('Enforce number')}}</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<input type="checkbox" id="config_password_lower" name="config_password_lower" {% if config.config_password_lower %}checked{% endif %}>
|
||||
<label for="config_password_lower">{{_('Enforce lowercase characters')}}</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<input type="checkbox" id="config_password_upper" name="config_password_upper" {% if config.config_password_upper %}checked{% endif %}>
|
||||
<label for="config_password_upper">{{_('Enforce uppercase characters')}}</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<input type="checkbox" id="config_password_character" name="config_password_character" {% if config.config_password_character %}checked{% endif %}>
|
||||
<label for="config_password_character">{{_('Enforce characters (needed For Chinese/Japanese/Korean Characters)')}}</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-left:10px;">
|
||||
<input type="checkbox" id="config_password_special" name="config_password_special" {% if config.config_password_special %}checked{% endif %}>
|
||||
<label for="config_password_special">{{_('Enforce special characters')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<button type="button" name="submit" id="config_submit" class="btn btn-default">{{_('Save')}}</button>
|
||||
<a href="{{ url_for('admin.admin') }}" id="config_back" class="btn btn-default">{{_('Cancel')}}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block modal %}
|
||||
{{ filechooser_modal() }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,377 @@
|
||||
{% extends is_xhr|yesno("fragment.html", "layout.html") %}
|
||||
{% block header %}
|
||||
<meta property="og:type" content="book" />
|
||||
<meta property="og:title" content="{{ entry.title|truncate(35) }}" />
|
||||
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %}
|
||||
<meta property="og:description" content="{{ entry.comments[0].text|striptags|truncate(65) }}" />
|
||||
<meta property="og:image" content="{{url_for('web.get_cover', book_id=entry.id, resolution='og', c=entry|last_modified)}}" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="single">
|
||||
<div class="row">
|
||||
<div class="col-sm-3 col-lg-3 col-xs-5">
|
||||
<div class="cover">
|
||||
<!-- Always use full-sized image for the detail page -->
|
||||
<img id="detailcover" title="{{ entry.title }}"
|
||||
src="{{ url_for('web.get_cover', book_id=entry.id, resolution='og', c=entry|last_modified) }}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-9 col-lg-9 book-meta">
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group" aria-label="Download, send to eReader, reading">
|
||||
{% if current_user.role_download() %}
|
||||
{% if entry.data|length %}
|
||||
<div class="btn-group" role="group">
|
||||
{% if entry.data|length < 2 %}
|
||||
<button id="Download" type="button" class="btn btn-primary">
|
||||
{{ _('Download') }} :
|
||||
</button>
|
||||
{% for format in entry.data %}
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}"
|
||||
id="btnGroupDrop1{{ format.format|lower }}" class="btn btn-primary"
|
||||
role="button">
|
||||
<span class="glyphicon glyphicon-download"></span>{{ format.format }}
|
||||
({{ format.uncompressed_size|filesizeformat }})
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<button id="btnGroupDrop1" type="button" class="btn btn-primary dropdown-toggle"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="glyphicon glyphicon-download"></span> {{ _('Download') }}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
|
||||
{% for format in entry.data %}
|
||||
<li>
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}">{{ format.format }}
|
||||
({{ format.uncompressed_size|filesizeformat }})</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if current_user.kindle_mail and entry.email_share_list %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if entry.email_share_list.__len__() == 1 %}
|
||||
<div class="btn-group" role="group">
|
||||
<button id="sendbtn" class="btn btn-primary sendbtn-form" data-href="{{url_for('web.send_to_ereader', book_id=entry.id, book_format=entry.email_share_list[0]['format'], convert=entry.email_share_list[0]['convert'])}}">
|
||||
<span class="glyphicon glyphicon-send"></span> {{entry.email_share_list[0]['text']}}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="btn-group" role="group">
|
||||
<button id="sendbtn2" type="button" class="btn btn-primary dropdown-toggle"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="glyphicon glyphicon-send"></span>{{ _('Send to eReader') }}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="send-to-ereader">
|
||||
{% for format in entry.email_share_list %}
|
||||
<li>
|
||||
<a class="sendbtn-form" data-href="{{url_for('web.send_to_ereader', book_id=entry.id, book_format=format['format'], convert=format['convert'])}}">{{ format['text'] }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if entry.reader_list and current_user.role_viewer() %}
|
||||
<div class="btn-group" role="group">
|
||||
{% if entry.reader_list|length > 1 %}
|
||||
<button id="read-in-browser" type="button" class="btn btn-primary dropdown-toggle"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="glyphicon glyphicon-book"></span> {{ _('Read in Browser') }}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="read-in-browser">
|
||||
{% for format in entry.reader_list %}
|
||||
<li><a target="_blank"
|
||||
href="{{ url_for('web.read_book', book_id=entry.id, book_format=format) }}">{{ format }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<a target="_blank"
|
||||
href="{{ url_for('web.read_book', book_id=entry.id, book_format=entry.reader_list[0]) }}"
|
||||
id="readbtn" class="btn btn-primary" role="button"><span
|
||||
class="glyphicon glyphicon-book"></span> {{ _('Read in Browser') }}
|
||||
- {{ entry.reader_list[0] }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if entry.audio_entries|length > 0 and current_user.role_viewer() %}
|
||||
<div class="btn-group" role="group">
|
||||
{% if entry.audio_entries|length > 1 %}
|
||||
<button id="listen-in-browser" type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="glyphicon glyphicon-music"></span> {{ _('Listen in Browser') }}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="listen-in-browser">
|
||||
{% for format in entry.reader_list %}
|
||||
<li><a target="_blank" href="{{ url_for('web.read_book', book_id=entry.id, book_format=format) }}">{{ format }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<ul class="dropdown-menu" aria-labelledby="listen-in-browser">
|
||||
|
||||
{% for format in entry.data %}
|
||||
{% if format.format|lower in entry.audio_entries %}
|
||||
<li><a target="_blank"
|
||||
href="{{ url_for('web.read_book', book_id=entry.id, book_format=format.format|lower) }}">{{ format.format|lower }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<a target="_blank" href="{{ url_for('web.read_book', book_id=entry.id, book_format=entry.audio_entries[0]) }}" id="listenbtn" class="btn btn-primary" role="button"><span class="glyphicon glyphicon-music"></span> {{ _('Listen in Browser') }} - {{ entry.audio_entries[0] }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<h2 id="title">{{ entry.title }}</h2>
|
||||
<p class="author">
|
||||
{% for author in entry.ordered_authors %}
|
||||
<a href="{{ url_for('web.books_list', data='author', sort_param='stored', book_id=author.id ) }}">{{ author.name.replace('|',',') }}</a>
|
||||
{% if not loop.last %}
|
||||
&
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% if entry.ratings.__len__() > 0 %}
|
||||
<div class="rating">
|
||||
<p>
|
||||
{% for number in range((entry.ratings[0].rating/2)|int(2)) %}
|
||||
<span class="glyphicon glyphicon-star good"></span>
|
||||
{% if loop.last and loop.index < 5 %}
|
||||
{% for numer in range(5 - loop.index) %}
|
||||
<span class="glyphicon glyphicon-star-empty"></span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if entry.series|length > 0 %}
|
||||
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=(url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id)|escapedlink(entry.series[0].name))|safe) }}</p>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if entry.languages|length > 0 %}
|
||||
<div class="languages">
|
||||
<p>
|
||||
<span class="label label-default">{{_('Language')}}: {% for language in entry.languages %}{{language.language_name}}{% if not loop.last %}, {% endif %}{% endfor %}</span>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if entry.identifiers|length > 0 %}
|
||||
<div class="identifiers">
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-link"></span>
|
||||
{% for identifier in entry.identifiers if identifier.__repr__() != identifier.val %}
|
||||
<a href="{{ identifier|escape }}" target="_blank" class="btn btn-xs btn-success"
|
||||
role="button">{{ identifier.format_type() }}</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if entry.tags|length > 0 %}
|
||||
|
||||
<div class="tags">
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-tags"></span>
|
||||
|
||||
{% for tag in entry.tags %}
|
||||
<a href="{{ url_for('web.books_list', data='category', sort_param='stored', book_id=tag.id) }}"
|
||||
class="btn btn-xs btn-info" role="button">{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if entry.publishers|length > 0 %}
|
||||
<div class="publishers">
|
||||
<p>
|
||||
<span>{{ _('Publisher') }}:
|
||||
<a href="{{ url_for('web.books_list', data='publisher', sort_param='stored', book_id=entry.publishers[0].id ) }}">{{ entry.publishers[0].name }}</a>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if (entry.pubdate|string)[:10] != '0101-01-01' %}
|
||||
<div class="publishing-date">
|
||||
<p>{{ _('Published') }}: {{ entry.pubdate|formatdate }} </p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cc|length > 0 %}
|
||||
|
||||
|
||||
{% for c in cc %}
|
||||
{% if entry['custom_column_' ~ c.id]|length > 0 %}
|
||||
<div class="real_custom_columns">
|
||||
{{ c.name }}:
|
||||
{% for column in entry['custom_column_' ~ c.id] %}
|
||||
{% if c.datatype == 'rating' %}
|
||||
{{ (column.value / 2)|formatfloat }}
|
||||
{% else %}
|
||||
{% if c.datatype == 'bool' %}
|
||||
{% if column.value == true %}
|
||||
<span class="glyphicon glyphicon-ok"></span>
|
||||
{% else %}
|
||||
<span class="glyphicon glyphicon-remove"></span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if c.datatype == 'float' %}
|
||||
{{ column.value|formatfloat(2) }}
|
||||
{% elif c.datatype == 'datetime' %}
|
||||
{{ column.value|formatdate }}
|
||||
{% elif c.datatype == 'comments' %}
|
||||
{{ column.value|safe }}
|
||||
{% elif c.datatype == 'series' %}
|
||||
{{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }}
|
||||
{% elif c.datatype == 'text' %}
|
||||
{{ column.value.strip() }}{% if not loop.last %}, {% endif %}
|
||||
{% else %}
|
||||
{{ column.value }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if not current_user.is_anonymous %}
|
||||
|
||||
<div class="custom_columns">
|
||||
<p>
|
||||
<form id="have_read_form" action="{{ url_for('web.toggle_read', book_id=entry.id) }}"
|
||||
method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label class="block-label">
|
||||
<input id="have_read_cb" data-checked="{{ _('Mark As Unread') }}"
|
||||
data-unchecked="{{ _('Mark As Read') }}" type="checkbox"
|
||||
{% if entry.read_status %}checked{% endif %}>
|
||||
<span data-toggle="tooltip" title="{{_('Mark Book as Read or Unread')}}">{{ _('Read') }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</p>
|
||||
{% if current_user.check_visibility(32768) %}
|
||||
<p>
|
||||
<form id="archived_form" action="{{ url_for('web.toggle_archived', book_id=entry.id) }}"
|
||||
method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label class="block-label">
|
||||
<input id="archived_cb" data-checked="{{ _('Restore from archive') }}"
|
||||
data-unchecked="{{ _('Add to archive') }}" type="checkbox"
|
||||
{% if entry.is_archived %}checked{% endif %}>
|
||||
<span data-toggle="tooltip" title="{{_('Mark Book as archived or not, to hide it in Calibre-Web and delete it from Kobo Reader')}}">{{ _('Archive') }}</span>
|
||||
</label>
|
||||
</form>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %}
|
||||
<div class="comments">
|
||||
<h3 id="decription">{{ _('Description:') }}</h3>
|
||||
{{ entry.comments[0].text|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="more-stuff">
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.shelf.all() or g.shelves_access %}
|
||||
<div id="shelf-actions" class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group" aria-label="Add to shelves">
|
||||
<button id="add-to-shelf" type="button"
|
||||
class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
<span class="glyphicon glyphicon-list"></span> {{ _('Add to shelf') }}
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul id="add-to-shelves" class="dropdown-menu" aria-labelledby="add-to-shelf">
|
||||
{% for shelf in g.shelves_access %}
|
||||
{% if not shelf.id in books_shelfs and ( not shelf.is_public or current_user.role_edit_shelfs() ) %}
|
||||
<li>
|
||||
<a data-href="{{ url_for('shelf.add_to_shelf', book_id=entry.id, shelf_id=shelf.id) }}"
|
||||
data-remove-href="{{ url_for('shelf.remove_from_shelf', book_id=entry.id, shelf_id=shelf.id) }}"
|
||||
data-shelf-action="add"
|
||||
>
|
||||
{{ shelf.name }}{% if shelf.is_public == 1 %}
|
||||
{{ _('(Public)') }}{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div id="remove-from-shelves" class="btn-group" role="group"
|
||||
aria-label="Remove from shelves">
|
||||
{% if books_shelfs %}
|
||||
{% for shelf in g.shelves_access %}
|
||||
{% if shelf.id in books_shelfs %}
|
||||
<a data-href="{{ url_for('shelf.remove_from_shelf', book_id=entry.id, shelf_id=shelf.id) }}"
|
||||
data-add-href="{{ url_for('shelf.add_to_shelf', book_id=entry.id, shelf_id=shelf.id) }}"
|
||||
class="btn btn-sm btn-default" role="button"
|
||||
data-shelf-action="remove"
|
||||
>
|
||||
<span {% if not shelf.is_public or current_user.role_edit_shelfs() %}
|
||||
class="glyphicon glyphicon-remove"
|
||||
{% endif %}></span> {{ shelf.name }}{% if shelf.is_public == 1 %} {{ _('(Public)') }}{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="shelf-action-errors" class="pull-left" role="alert"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% if current_user.role_edit() %}
|
||||
<div class="col-sm-12">
|
||||
<div class="btn-group" role="group" aria-label="Edit/Delete book">
|
||||
<a href="{{ url_for('edit-book.show_edit_book', book_id=entry.id) }}"
|
||||
class="btn btn-sm btn-primary" id="edit_book" role="button"><span
|
||||
class="glyphicon glyphicon-edit"></span> {{ _('Edit Metadata') }}</a>
|
||||
</div>
|
||||
<div class="btn btn-default" data-back="{{ url_for('web.index') }}" id="back">{{_('Cancel')}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script type="text/template" id="template-shelf-add">
|
||||
<li>
|
||||
<a data-href="<%= add %>" data-remove-href="<%= remove %>" data-shelf-action="add">
|
||||
<%= content %>
|
||||
</a>
|
||||
</li>
|
||||
</script>
|
||||
<script type="text/template" id="template-shelf-remove">
|
||||
<a data-href="<%= remove %>" data-add-href="<%= add %>" class="btn btn-sm btn-default"
|
||||
data-shelf-action="remove">
|
||||
<span class="glyphicon glyphicon-remove"></span> <%= content %>
|
||||
</a>
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/details.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/fullscreen.js') }}"></script>
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,189 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
<h1>{{title}}</h1>
|
||||
<form role="form" method="POST" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="col-md-10 col-lg-8">
|
||||
{% if new_user or ( current_user and content.name != "Guest" and current_user.role_admin() ) %}
|
||||
<div class="form-group required">
|
||||
<label for="name">{{_('Username')}}</label>
|
||||
<input type="text" class="form-control" name="name" id="name" value="{{ content.name if content.name != None }}" autocomplete="off">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label for="email">{{_('Email')}}</label>
|
||||
<input type="email" class="form-control" name="email" id="email" value="{{ content.email if content.email != None }}" autocomplete="off">
|
||||
</div>
|
||||
{% if ( current_user and current_user.role_passwd() or current_user.role_admin() ) and not content.role_anonymous() %}
|
||||
{% if current_user and current_user.role_admin() and not new_user and not profile and ( mail_configured and content.email if content.email != None ) %}
|
||||
<a class="btn btn-default postAction" id="resend_password" role="button" data-action="{{url_for('admin.reset_user_password', user_id = content.id) }}">{{_('Reset user Password')}}</a>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label for="password">{{_('Password')}}</label>
|
||||
<input type="password" class="form-control" name="password" id="password" data-lang="{{ current_user.locale }}" data-verify="{{ config.config_password_policy }}" {% if config.config_password_policy %} data-min={{ config.config_password_min_length }} data-word={{ config.config_password_character }} data-special={{ config.config_password_special }} data-upper={{ config.config_password_upper }} data-lower={{ config.config_password_lower }} data-number={{ config.config_password_number }}{% endif %} value="" autocomplete="off">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label for="kindle_mail">{{_('Send to eReader Email Address. Use comma to separate emails for multiple eReaders')}}</label>
|
||||
<input type="email" class="form-control" name="kindle_mail" id="kindle_mail" value="{{ content.kindle_mail if content.kindle_mail != None }}">
|
||||
</div>
|
||||
{% if not content.role_anonymous() %}
|
||||
<div class="form-group">
|
||||
<label for="locale">{{_('Language')}}</label>
|
||||
<select name="locale" id="locale" class="form-control">
|
||||
{% for translation in translations %}
|
||||
<option value="{{translation}}" {% if translation|string == content.locale %}selected{% endif %}>{{ translation.display_name|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="default_language">{{_('Language of Books')}}</label>
|
||||
<select name="default_language" id="default_language" class="form-control">
|
||||
<option value="all" {% if content.default_language == "all" %}selected{% endif %}>{{ _('Show All') }}</option>
|
||||
{% for language in languages %}
|
||||
<option value="{{ language.lang_code }}" {% if content.default_language == language.lang_code %}selected{% endif %}>{{ language.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if registered_oauth.keys()| length > 0 and not new_user and profile %}
|
||||
{% for id, name in registered_oauth.items() %}
|
||||
<div class="form-group">
|
||||
<label>{{ name }} {{_('OAuth Settings')}}</label>
|
||||
{% if id not in oauth_status %}
|
||||
<a href="{{ url_for('oauth.'+ name +'_login') }}" id="config_{{ id }}_oauth" class="btn btn-primary">{{_('Link')}}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('oauth.'+ name +'_login_unlink') }}" id="config_{{ id }}_oauth" class="btn btn-primary">{{_('Unlink')}}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if kobo_support and hardcover_support and not new_user %}
|
||||
<div class="form-group">
|
||||
<label for="hardcover_token">{{_('Hardcover API token')}}</label>
|
||||
<input type="text" class="form-control" name="hardcover_token" id="hardcover_token" value="{{ content.hardcover_token if content.hardcover_token != None }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if kobo_support and not new_user %}
|
||||
<label>{{ _('Kobo Sync Token')}}</label>
|
||||
<div class="form-group col">
|
||||
<a class="btn btn-default" id="config_create_kobo_token" data-toggle="modal" data-target="#modal_kobo_token" data-remote="false" href="{{ url_for('kobo_auth.generate_auth_token', user_id=content.id) }}">{{_('Create/View')}}</a>
|
||||
<div class="btn btn-danger" id="config_delete_kobo_token" data-value="{{ content.id }}" data-remote="false" {% if not content.remote_auth_token.first() %} style="display: none;" {% endif %}>{{_('Delete')}}</div>
|
||||
</div>
|
||||
<div class="form-group col">
|
||||
<div class="btn btn-default" id="kobo_full_sync" data-value="{% if current_user.role_admin() %}{{ content.id }}{% else %}0{% endif %}" {% if not content.remote_auth_token.first() %} style="display: none;" {% endif %}>{{_('Force full kobo sync')}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-sm-6">
|
||||
{% for element in sidebar %}
|
||||
{% if element['config_show'] %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="show_{{element['visibility']}}" id="show_{{element['visibility']}}" {% if content.check_visibility(element['visibility']) %}checked{% endif %}>
|
||||
<label for="show_{{element['visibility']}}">{{element['show_text']}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if content.show_detail_random() %}checked{% endif %}>
|
||||
<label for="Show_detail_random">{{_('Show Random Books in Detail View')}}</label>
|
||||
</div>
|
||||
{% if ( current_user and current_user.role_admin() and not new_user ) and not simple %}
|
||||
<a href="#" id="get_user_tags" class="btn btn-default" data-id="{{content.id}}" data-toggle="modal" data-target="#restrictModal">{{_('Add Allowed/Denied Tags')}}</a>
|
||||
<a href="#" id="get_user_column_values" data-id="{{content.id}}" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/Denied Custom Column Values')}}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
{% if current_user and current_user.role_admin() and not profile %}
|
||||
{% if not content.role_anonymous() %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="admin_role" id="admin_role" {% if content.role_admin() %}checked{% endif %}>
|
||||
<label for="admin_role">{{_('Admin User')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="download_role" id="download_role" {% if content.role_download() %}checked{% endif %}>
|
||||
<label for="download_role">{{_('Allow Downloads')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="viewer_role" id="viewer_role" {% if content.role_viewer() %}checked{% endif %}>
|
||||
<label for="viewer_role">{{_('Allow eBook Viewer')}}</label>
|
||||
</div>
|
||||
{% if config.config_uploading %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="upload_role" id="upload_role" {% if content.role_upload() %}checked{% endif %}>
|
||||
<label for="upload_role">{{_('Allow Uploads')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="edit_role" data-control="edit_settings" id="edit_role" {% if content.role_edit() %}checked{% endif %}>
|
||||
<label for="edit_role">{{_('Allow Edit')}}</label>
|
||||
</div>
|
||||
<div data-related="edit_settings">
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="delete_role" id="delete_role" {% if content.role_delete_books() %}checked{% endif %}>
|
||||
<label for="delete_role">{{_('Allow Delete Books')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% if not content.role_anonymous() %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="passwd_role" id="passwd_role" {% if content.role_passwd() %}checked{% endif %}>
|
||||
<label for="passwd_role">{{_('Allow Changing Password')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="edit_shelf_role" id="edit_shelf_role" {% if content.role_edit_shelfs() %}checked{% endif %}>
|
||||
<label for="edit_shelf_role">{{_('Allow Editing Public Shelves')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if kobo_support and not content.role_anonymous() and not simple%}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="kobo_only_shelves_sync" id="kobo_only_shelves_sync" {% if content.kobo_only_shelves_sync %}checked{% endif %}>
|
||||
<label for="kobo_only_shelves_sync">{{_('Sync only books in selected shelves with Kobo')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<div id="user_submit" class="btn btn-default">{{_('Save')}}</div>
|
||||
{% if not profile %}
|
||||
<div class="btn btn-default" data-back="{{ url_for('admin.admin') }}" id="back">{{_('Cancel')}}</div>
|
||||
{% endif %}
|
||||
{% if current_user and current_user.role_admin() and not profile and not new_user and not content.role_anonymous() %}
|
||||
<div class="btn btn-danger" id="btndeluser" data-value="{{ content.id }}" data-remote="false" >{{_('Delete User')}}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="modal_kobo_token" tabindex="-1" role="dialog" aria-labelledby="kobo_tokenModalLabel">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title" id="kobo_tokenModalLabel">{{_('Generate Kobo Auth URL')}}</h4>
|
||||
</div>
|
||||
<div class="modal-body">...</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="kobo_close" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block modal %}
|
||||
{{ restrict_modal() }}
|
||||
{{ delete_confirm_modal() }}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-editable.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-editable.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/pwstrength/i18next.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/pwstrength/i18nextHttpBackend.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/pwstrength/pwstrength-bootstrap.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/password.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/table.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,778 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2012-2019 mutschler, jkrehm, cervinko, janeczku, OzzieIsaacs, csitko
|
||||
# ok11, issmirnov, idalin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import itertools
|
||||
import uuid
|
||||
from flask import session as flask_session
|
||||
from binascii import hexlify
|
||||
|
||||
from .cw_login import AnonymousUserMixin, current_user
|
||||
from .cw_login import user_logged_in
|
||||
|
||||
try:
|
||||
from flask_dance.consumer.backend.sqla import OAuthConsumerMixin
|
||||
oauth_support = True
|
||||
except ImportError as e:
|
||||
# fails on flask-dance >1.3, due to renaming
|
||||
try:
|
||||
from flask_dance.consumer.storage.sqla import OAuthConsumerMixin
|
||||
oauth_support = True
|
||||
except ImportError as e:
|
||||
OAuthConsumerMixin = BaseException
|
||||
oauth_support = False
|
||||
from sqlalchemy import create_engine, exc, exists, event, text
|
||||
from sqlalchemy import Column, ForeignKey
|
||||
from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from sqlalchemy.sql.expression import func
|
||||
try:
|
||||
# Compatibility with sqlalchemy 2.0
|
||||
from sqlalchemy.orm import declarative_base
|
||||
except ImportError:
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import backref, relationship, sessionmaker, Session, scoped_session
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from . import constants, logger
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
|
||||
session = None
|
||||
app_DB_path = None
|
||||
Base = declarative_base()
|
||||
searched_ids = {}
|
||||
|
||||
logged_in = dict()
|
||||
|
||||
|
||||
def signal_store_user_session(object, user):
|
||||
store_user_session()
|
||||
|
||||
|
||||
def store_user_session():
|
||||
_user = flask_session.get('_user_id', "")
|
||||
_id = flask_session.get('_id', "")
|
||||
_random = flask_session.get('_random', "")
|
||||
if flask_session.get('_user_id', ""):
|
||||
try:
|
||||
if not check_user_session(_user, _id, _random):
|
||||
expiry = int((datetime.now() + timedelta(days=31)).timestamp())
|
||||
user_session = User_Sessions(_user, _id, _random, expiry)
|
||||
session.add(user_session)
|
||||
session.commit()
|
||||
log.debug("Login and store session : " + _id)
|
||||
else:
|
||||
log.debug("Found stored session: " + _id)
|
||||
except (exc.OperationalError, exc.InvalidRequestError) as e:
|
||||
session.rollback()
|
||||
log.exception(e)
|
||||
else:
|
||||
log.error("No user id in session")
|
||||
|
||||
|
||||
def delete_user_session(user_id, session_key):
|
||||
try:
|
||||
log.debug("Deleted session_key: " + session_key)
|
||||
session.query(User_Sessions).filter(User_Sessions.user_id == user_id,
|
||||
User_Sessions.session_key == session_key).delete()
|
||||
session.commit()
|
||||
except (exc.OperationalError, exc.InvalidRequestError) as ex:
|
||||
session.rollback()
|
||||
log.exception(ex)
|
||||
|
||||
|
||||
def check_user_session(user_id, session_key, random):
|
||||
try:
|
||||
found = session.query(User_Sessions).filter(User_Sessions.user_id==user_id,
|
||||
User_Sessions.session_key==session_key,
|
||||
User_Sessions.random == random,
|
||||
).one_or_none()
|
||||
if found is not None:
|
||||
new_expiry = int((datetime.now() + timedelta(days=31)).timestamp())
|
||||
if new_expiry - found.expiry > 86400:
|
||||
found.expiry = new_expiry
|
||||
session.merge(found)
|
||||
session.commit()
|
||||
return bool(found)
|
||||
except (exc.OperationalError, exc.InvalidRequestError) as e:
|
||||
session.rollback()
|
||||
log.exception(e)
|
||||
return False
|
||||
|
||||
|
||||
user_logged_in.connect(signal_store_user_session)
|
||||
|
||||
def store_ids(result):
|
||||
ids = list()
|
||||
for element in result:
|
||||
ids.append(element.id)
|
||||
searched_ids[current_user.id] = ids
|
||||
|
||||
def store_combo_ids(result):
|
||||
ids = list()
|
||||
for element in result:
|
||||
ids.append(element[0].id)
|
||||
searched_ids[current_user.id] = ids
|
||||
|
||||
|
||||
class UserBase:
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return self.is_active
|
||||
|
||||
def _has_role(self, role_flag):
|
||||
return constants.has_flag(self.role, role_flag)
|
||||
|
||||
def role_admin(self):
|
||||
return self._has_role(constants.ROLE_ADMIN)
|
||||
|
||||
def role_download(self):
|
||||
return self._has_role(constants.ROLE_DOWNLOAD)
|
||||
|
||||
def role_upload(self):
|
||||
return self._has_role(constants.ROLE_UPLOAD)
|
||||
|
||||
def role_edit(self):
|
||||
return self._has_role(constants.ROLE_EDIT)
|
||||
|
||||
def role_passwd(self):
|
||||
return self._has_role(constants.ROLE_PASSWD)
|
||||
|
||||
def role_anonymous(self):
|
||||
return self._has_role(constants.ROLE_ANONYMOUS)
|
||||
|
||||
def role_edit_shelfs(self):
|
||||
return self._has_role(constants.ROLE_EDIT_SHELFS)
|
||||
|
||||
def role_delete_books(self):
|
||||
return self._has_role(constants.ROLE_DELETE_BOOKS)
|
||||
|
||||
def role_viewer(self):
|
||||
return self._has_role(constants.ROLE_VIEWER)
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return self.role_anonymous()
|
||||
|
||||
def get_id(self):
|
||||
return str(self.id)
|
||||
|
||||
def filter_language(self):
|
||||
return self.default_language
|
||||
|
||||
def check_visibility(self, value):
|
||||
if value == constants.SIDEBAR_RECENT:
|
||||
return True
|
||||
return constants.has_flag(self.sidebar_view, value)
|
||||
|
||||
def show_detail_random(self):
|
||||
return self.check_visibility(constants.DETAIL_RANDOM)
|
||||
|
||||
def list_denied_tags(self):
|
||||
mct = self.denied_tags or ""
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_allowed_tags(self):
|
||||
mct = self.allowed_tags or ""
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_denied_column_values(self):
|
||||
mct = self.denied_column_value or ""
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_allowed_column_values(self):
|
||||
mct = self.allowed_column_value or ""
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def get_view_property(self, page, prop):
|
||||
if not self.view_settings.get(page):
|
||||
return None
|
||||
return self.view_settings[page].get(prop)
|
||||
|
||||
def set_view_property(self, page, prop, value):
|
||||
if not self.view_settings.get(page):
|
||||
self.view_settings[page] = dict()
|
||||
self.view_settings[page][prop] = value
|
||||
try:
|
||||
flag_modified(self, "view_settings")
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
session.commit()
|
||||
except (exc.OperationalError, exc.InvalidRequestError) as e:
|
||||
session.rollback()
|
||||
log.error_or_exception(e)
|
||||
|
||||
def __repr__(self):
|
||||
return '<User %r>' % self.name
|
||||
|
||||
|
||||
# Baseclass for Users in Calibre-Web, settings which are depending on certain users are stored here. It is derived from
|
||||
# User Base (all access methods are declared there)
|
||||
class User(UserBase, Base):
|
||||
__tablename__ = 'user'
|
||||
__table_args__ = {'sqlite_autoincrement': True}
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(64), unique=True)
|
||||
email = Column(String(120), unique=True, default="")
|
||||
role = Column(SmallInteger, default=constants.ROLE_USER)
|
||||
password = Column(String)
|
||||
kindle_mail = Column(String(120), default="")
|
||||
shelf = relationship('Shelf', backref='user', lazy='dynamic', order_by='Shelf.name')
|
||||
downloads = relationship('Downloads', backref='user', lazy='dynamic')
|
||||
locale = Column(String(2), default="en")
|
||||
sidebar_view = Column(Integer, default=1)
|
||||
default_language = Column(String(3), default="all")
|
||||
denied_tags = Column(String, default="")
|
||||
allowed_tags = Column(String, default="")
|
||||
denied_column_value = Column(String, default="")
|
||||
allowed_column_value = Column(String, default="")
|
||||
remote_auth_token = relationship('RemoteAuthToken', backref='user', lazy='dynamic')
|
||||
view_settings = Column(JSON, default={})
|
||||
kobo_only_shelves_sync = Column(Integer, default=0)
|
||||
hardcover_token = Column(String, unique=True, default="")
|
||||
|
||||
|
||||
if oauth_support:
|
||||
class OAuth(OAuthConsumerMixin, Base):
|
||||
provider_user_id = Column(String(256))
|
||||
user_id = Column(Integer, ForeignKey(User.id))
|
||||
user = relationship(User)
|
||||
|
||||
|
||||
class OAuthProvider(Base):
|
||||
__tablename__ = 'oauthProvider'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
provider_name = Column(String)
|
||||
oauth_client_id = Column(String)
|
||||
oauth_client_secret = Column(String)
|
||||
active = Column(Boolean)
|
||||
|
||||
|
||||
# Class for anonymous user is derived from User base and completely overrides methods and properties for the
|
||||
# anonymous user
|
||||
class Anonymous(AnonymousUserMixin, UserBase):
|
||||
def __init__(self):
|
||||
self.kobo_only_shelves_sync = None
|
||||
self.view_settings = None
|
||||
self.allowed_column_value = None
|
||||
self.allowed_tags = None
|
||||
self.denied_tags = None
|
||||
self.kindle_mail = None
|
||||
self.locale = None
|
||||
self.default_language = None
|
||||
self.sidebar_view = None
|
||||
self.id = None
|
||||
self.role = None
|
||||
self.name = None
|
||||
self.loadSettings()
|
||||
|
||||
def loadSettings(self):
|
||||
data = session.query(User).filter(User.role.op('&')(constants.ROLE_ANONYMOUS) == constants.ROLE_ANONYMOUS)\
|
||||
.first() # type: User
|
||||
self.name = data.name
|
||||
self.role = data.role
|
||||
self.id=data.id
|
||||
self.sidebar_view = data.sidebar_view
|
||||
self.default_language = data.default_language
|
||||
self.locale = data.locale
|
||||
self.kindle_mail = data.kindle_mail
|
||||
self.denied_tags = data.denied_tags
|
||||
self.allowed_tags = data.allowed_tags
|
||||
self.denied_column_value = data.denied_column_value
|
||||
self.allowed_column_value = data.allowed_column_value
|
||||
self.view_settings = data.view_settings
|
||||
self.kobo_only_shelves_sync = data.kobo_only_shelves_sync
|
||||
|
||||
def role_admin(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return False
|
||||
|
||||
def get_view_property(self, page, prop):
|
||||
if 'view' in flask_session:
|
||||
if not flask_session['view'].get(page):
|
||||
return None
|
||||
return flask_session['view'][page].get(prop)
|
||||
return None
|
||||
|
||||
def set_view_property(self, page, prop, value):
|
||||
if not 'view' in flask_session:
|
||||
flask_session['view'] = dict()
|
||||
if not flask_session['view'].get(page):
|
||||
flask_session['view'][page] = dict()
|
||||
flask_session['view'][page][prop] = value
|
||||
|
||||
class User_Sessions(Base):
|
||||
__tablename__ = 'user_session'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
session_key = Column(String, default="")
|
||||
random = Column(String, default="")
|
||||
expiry = Column(Integer)
|
||||
|
||||
|
||||
def __init__(self, user_id, session_key, random, expiry):
|
||||
super().__init__()
|
||||
self.user_id = user_id
|
||||
self.session_key = session_key
|
||||
self.random = random
|
||||
self.expiry = expiry
|
||||
|
||||
|
||||
# Baseclass representing Shelfs in calibre-web in app.db
|
||||
class Shelf(Base):
|
||||
__tablename__ = 'shelf'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
uuid = Column(String, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String)
|
||||
is_public = Column(Integer, default=0)
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
kobo_sync = Column(Boolean, default=False)
|
||||
books = relationship("BookShelf", backref="ub_shelf", cascade="all, delete-orphan", lazy="dynamic")
|
||||
created = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def __repr__(self):
|
||||
return '<Shelf %d:%r>' % (self.id, self.name)
|
||||
|
||||
|
||||
# Baseclass representing Relationship between books and Shelfs in Calibre-Web in app.db (N:M)
|
||||
class BookShelf(Base):
|
||||
__tablename__ = 'book_shelf_link'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
book_id = Column(Integer)
|
||||
order = Column(Integer)
|
||||
shelf = Column(Integer, ForeignKey('shelf.id'))
|
||||
date_added = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def __repr__(self):
|
||||
return '<Book %r>' % self.id
|
||||
|
||||
|
||||
# This table keeps track of deleted Shelves so that deletes can be propagated to any paired Kobo device.
|
||||
class ShelfArchive(Base):
|
||||
__tablename__ = 'shelf_archive'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
uuid = Column(String)
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ReadBook(Base):
|
||||
__tablename__ = 'book_read_link'
|
||||
|
||||
STATUS_UNREAD = 0
|
||||
STATUS_FINISHED = 1
|
||||
STATUS_IN_PROGRESS = 2
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
book_id = Column(Integer, unique=False)
|
||||
user_id = Column(Integer, ForeignKey('user.id'), unique=False)
|
||||
read_status = Column(Integer, unique=False, default=STATUS_UNREAD, nullable=False)
|
||||
kobo_reading_state = relationship("KoboReadingState", uselist=False,
|
||||
primaryjoin="and_(ReadBook.user_id == foreign(KoboReadingState.user_id), "
|
||||
"ReadBook.book_id == foreign(KoboReadingState.book_id))",
|
||||
cascade="all",
|
||||
backref=backref("book_read_link",
|
||||
uselist=False))
|
||||
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
last_time_started_reading = Column(DateTime, nullable=True)
|
||||
times_started_reading = Column(Integer, default=0, nullable=False)
|
||||
|
||||
|
||||
class Bookmark(Base):
|
||||
__tablename__ = 'bookmark'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
book_id = Column(Integer)
|
||||
format = Column(String(collation='NOCASE'))
|
||||
bookmark_key = Column(String)
|
||||
|
||||
|
||||
# Baseclass representing books that are archived on the user's Kobo device.
|
||||
class ArchivedBook(Base):
|
||||
__tablename__ = 'archived_book'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
book_id = Column(Integer)
|
||||
is_archived = Column(Boolean, unique=False)
|
||||
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class KoboSyncedBooks(Base):
|
||||
__tablename__ = 'kobo_synced_books'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
book_id = Column(Integer)
|
||||
|
||||
# The Kobo ReadingState API keeps track of 4 timestamped entities:
|
||||
# ReadingState, StatusInfo, Statistics, CurrentBookmark
|
||||
# Which we map to the following 4 tables:
|
||||
# KoboReadingState, ReadBook, KoboStatistics and KoboBookmark
|
||||
class KoboReadingState(Base):
|
||||
__tablename__ = 'kobo_reading_state'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
book_id = Column(Integer)
|
||||
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
priority_timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
current_bookmark = relationship("KoboBookmark", uselist=False, backref="kobo_reading_state", cascade="all, delete")
|
||||
statistics = relationship("KoboStatistics", uselist=False, backref="kobo_reading_state", cascade="all, delete")
|
||||
|
||||
|
||||
class KoboBookmark(Base):
|
||||
__tablename__ = 'kobo_bookmark'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
kobo_reading_state_id = Column(Integer, ForeignKey('kobo_reading_state.id'))
|
||||
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
location_source = Column(String)
|
||||
location_type = Column(String)
|
||||
location_value = Column(String)
|
||||
progress_percent = Column(Float)
|
||||
content_source_progress_percent = Column(Float)
|
||||
|
||||
|
||||
class KoboStatistics(Base):
|
||||
__tablename__ = 'kobo_statistics'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
kobo_reading_state_id = Column(Integer, ForeignKey('kobo_reading_state.id'))
|
||||
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
remaining_time_minutes = Column(Integer)
|
||||
spent_reading_minutes = Column(Integer)
|
||||
|
||||
|
||||
# Updates the last_modified timestamp in the KoboReadingState table if any of its children tables are modified.
|
||||
@event.listens_for(Session, 'before_flush')
|
||||
def receive_before_flush(session, flush_context, instances):
|
||||
for change in itertools.chain(session.new, session.dirty):
|
||||
if isinstance(change, (ReadBook, KoboStatistics, KoboBookmark)):
|
||||
if change.kobo_reading_state:
|
||||
change.kobo_reading_state.last_modified = datetime.now(timezone.utc)
|
||||
# Maintain the last_modified_bit for the Shelf table.
|
||||
for change in itertools.chain(session.new, session.deleted):
|
||||
if isinstance(change, BookShelf):
|
||||
change.ub_shelf.last_modified = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# Baseclass representing Downloads from calibre-web in app.db
|
||||
class Downloads(Base):
|
||||
__tablename__ = 'downloads'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
book_id = Column(Integer)
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
|
||||
def __repr__(self):
|
||||
return '<Download %r' % self.book_id
|
||||
|
||||
|
||||
# Baseclass representing allowed domains for registration
|
||||
class Registration(Base):
|
||||
__tablename__ = 'registration'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
domain = Column(String)
|
||||
allow = Column(Integer)
|
||||
|
||||
def __repr__(self):
|
||||
return "<Registration('{0}')>".format(self.domain)
|
||||
|
||||
|
||||
class RemoteAuthToken(Base):
|
||||
__tablename__ = 'remote_auth_token'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
auth_token = Column(String, unique=True)
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
verified = Column(Boolean, default=False)
|
||||
expiration = Column(DateTime)
|
||||
token_type = Column(Integer, default=0)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.auth_token = (hexlify(os.urandom(4))).decode('utf-8')
|
||||
self.expiration = datetime.now() + timedelta(minutes=10) # 10 min from now
|
||||
|
||||
def __repr__(self):
|
||||
return '<Token %r>' % self.id
|
||||
|
||||
|
||||
def filename(context):
|
||||
file_format = context.get_current_parameters()['format']
|
||||
if file_format == 'jpeg':
|
||||
return context.get_current_parameters()['uuid'] + '.jpg'
|
||||
else:
|
||||
return context.get_current_parameters()['uuid'] + '.' + file_format
|
||||
|
||||
|
||||
class Thumbnail(Base):
|
||||
__tablename__ = 'thumbnail'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
entity_id = Column(Integer)
|
||||
uuid = Column(String, default=lambda: str(uuid.uuid4()), unique=True)
|
||||
format = Column(String, default='jpeg')
|
||||
type = Column(SmallInteger, default=constants.THUMBNAIL_TYPE_COVER)
|
||||
resolution = Column(SmallInteger, default=constants.COVER_THUMBNAIL_SMALL)
|
||||
filename = Column(String, default=filename)
|
||||
generated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
expiration = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
# Add missing tables during migration of database
|
||||
def add_missing_tables(engine, _session):
|
||||
if not engine.dialect.has_table(engine.connect(), "archived_book"):
|
||||
ArchivedBook.__table__.create(bind=engine)
|
||||
if not engine.dialect.has_table(engine.connect(), "thumbnail"):
|
||||
Thumbnail.__table__.create(bind=engine)
|
||||
|
||||
|
||||
# migrate all settings missing in registration table
|
||||
def migrate_registration_table(engine, _session):
|
||||
try:
|
||||
# Handle table exists, but no content
|
||||
cnt = _session.query(Registration).count()
|
||||
if not cnt:
|
||||
with engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
conn.execute(text("insert into registration (domain, allow) values('%.%',1)"))
|
||||
trans.commit()
|
||||
except exc.OperationalError: # Database is not writeable
|
||||
print('Settings database is not writeable. Exiting...')
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def migrate_user_session_table(engine, _session):
|
||||
try:
|
||||
_session.query(exists().where(User_Sessions.random)).scalar()
|
||||
_session.commit()
|
||||
except exc.OperationalError: # Database is not compatible, some columns are missing
|
||||
with engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
conn.execute(text("ALTER TABLE user_session ADD column 'random' String"))
|
||||
conn.execute(text("ALTER TABLE user_session ADD column 'expiry' Integer"))
|
||||
trans.commit()
|
||||
|
||||
def migrate_user_table(engine, _session):
|
||||
try:
|
||||
_session.query(exists().where(User.hardcover_token)).scalar()
|
||||
_session.commit()
|
||||
except exc.OperationalError: # Database is not compatible, some columns are missing
|
||||
with engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
conn.execute(text("ALTER TABLE user ADD column 'hardcover_token' String"))
|
||||
trans.commit()
|
||||
|
||||
# Migrate database to current version, has to be updated after every database change. Currently migration from
|
||||
# maybe 4/5 versions back to current should work.
|
||||
# Migration is done by checking if relevant columns are existing, and then adding rows with SQL commands
|
||||
def migrate_Database(_session):
|
||||
engine = _session.bind
|
||||
add_missing_tables(engine, _session)
|
||||
migrate_registration_table(engine, _session)
|
||||
migrate_user_session_table(engine, _session)
|
||||
|
||||
|
||||
def clean_database(_session):
|
||||
# Remove expired remote login tokens
|
||||
now = datetime.now()
|
||||
try:
|
||||
_session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).\
|
||||
filter(RemoteAuthToken.token_type != 1).delete()
|
||||
_session.commit()
|
||||
except exc.OperationalError: # Database is not writeable
|
||||
print('Settings database is not writeable. Exiting...')
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# Save downloaded books per user in calibre-web's own database
|
||||
def update_download(book_id, user_id):
|
||||
check = session.query(Downloads).filter(Downloads.user_id == user_id).filter(Downloads.book_id == book_id).first()
|
||||
|
||||
if not check:
|
||||
new_download = Downloads(user_id=user_id, book_id=book_id)
|
||||
session.add(new_download)
|
||||
try:
|
||||
session.commit()
|
||||
except exc.OperationalError:
|
||||
session.rollback()
|
||||
|
||||
|
||||
# Delete non existing downloaded books in calibre-web's own database
|
||||
def delete_download(book_id):
|
||||
session.query(Downloads).filter(book_id == Downloads.book_id).delete()
|
||||
try:
|
||||
session.commit()
|
||||
except exc.OperationalError:
|
||||
session.rollback()
|
||||
|
||||
# Generate user Guest (translated text), as anonymous user, no rights
|
||||
def create_anonymous_user(_session):
|
||||
user = User()
|
||||
user.name = "Guest"
|
||||
user.email = 'no@email'
|
||||
user.role = constants.ROLE_ANONYMOUS
|
||||
user.password = ''
|
||||
|
||||
_session.add(user)
|
||||
try:
|
||||
_session.commit()
|
||||
except Exception:
|
||||
_session.rollback()
|
||||
|
||||
|
||||
# Generate User admin with admin123 password, and access to everything
|
||||
def create_admin_user(_session):
|
||||
user = User()
|
||||
user.name = "admin"
|
||||
user.email = "admin@example.org"
|
||||
user.role = constants.ADMIN_USER_ROLES
|
||||
user.sidebar_view = constants.ADMIN_USER_SIDEBAR
|
||||
|
||||
user.password = generate_password_hash(constants.DEFAULT_PASSWORD)
|
||||
|
||||
_session.add(user)
|
||||
try:
|
||||
_session.commit()
|
||||
except Exception:
|
||||
_session.rollback()
|
||||
|
||||
def init_db_thread():
|
||||
global app_DB_path
|
||||
engine = create_engine('sqlite:///{0}'.format(app_DB_path), echo=False)
|
||||
|
||||
Session = scoped_session(sessionmaker())
|
||||
Session.configure(bind=engine)
|
||||
return Session()
|
||||
|
||||
|
||||
def init_db(app_db_path):
|
||||
# Open session for database connection
|
||||
global session
|
||||
global app_DB_path
|
||||
|
||||
app_DB_path = app_db_path
|
||||
engine = create_engine('sqlite:///{0}'.format(app_db_path), echo=False)
|
||||
|
||||
Session = scoped_session(sessionmaker())
|
||||
Session.configure(bind=engine)
|
||||
session = Session()
|
||||
|
||||
if os.path.exists(app_db_path):
|
||||
Base.metadata.create_all(engine)
|
||||
migrate_Database(session)
|
||||
clean_database(session)
|
||||
else:
|
||||
Base.metadata.create_all(engine)
|
||||
create_admin_user(session)
|
||||
create_anonymous_user(session)
|
||||
|
||||
def password_change(user_credentials=None):
|
||||
if user_credentials:
|
||||
username, password = user_credentials.split(':', 1)
|
||||
user = session.query(User).filter(func.lower(User.name) == username.lower()).first()
|
||||
if user:
|
||||
if not password:
|
||||
print("Empty password is not allowed")
|
||||
sys.exit(4)
|
||||
try:
|
||||
from .helper import valid_password
|
||||
user.password = generate_password_hash(valid_password(password))
|
||||
except Exception:
|
||||
print("Password doesn't comply with password validation rules")
|
||||
sys.exit(4)
|
||||
if session_commit() == "":
|
||||
print("Password for user '{}' changed".format(username))
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("Failed changing password")
|
||||
sys.exit(3)
|
||||
else:
|
||||
print("Username '{}' not valid, can't change password".format(username))
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
def get_new_session_instance():
|
||||
new_engine = create_engine('sqlite:///{0}'.format(app_DB_path), echo=False)
|
||||
new_session = scoped_session(sessionmaker())
|
||||
new_session.configure(bind=new_engine)
|
||||
|
||||
atexit.register(lambda: new_session.remove() if new_session else True)
|
||||
|
||||
return new_session
|
||||
|
||||
|
||||
def dispose():
|
||||
global session
|
||||
|
||||
old_session = session
|
||||
session = None
|
||||
if old_session:
|
||||
try:
|
||||
old_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
if old_session.bind:
|
||||
try:
|
||||
old_session.bind.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def session_commit(success=None, _session=None):
|
||||
s = _session if _session else session
|
||||
try:
|
||||
s.commit()
|
||||
if success:
|
||||
log.info(success)
|
||||
except (exc.OperationalError, exc.InvalidRequestError) as e:
|
||||
s.rollback()
|
||||
log.error_or_exception(e)
|
||||
return ""
|
||||
@@ -70,7 +70,8 @@ from .string_helper import strip_whitespaces
|
||||
feature_support = {
|
||||
'ldap': bool(services.ldap),
|
||||
'goodreads': bool(services.goodreads_support),
|
||||
'kobo': bool(services.kobo)
|
||||
'kobo': bool(services.kobo),
|
||||
'hardcover' : bool(services.hardcover)
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1232,7 +1233,7 @@ def serve_book(book_id, book_format, anyname):
|
||||
try:
|
||||
headers = Headers()
|
||||
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
|
||||
if not range_header:
|
||||
if not range_header:
|
||||
log.info('Serving book: %s', data.name)
|
||||
headers['Accept-Ranges'] = 'bytes'
|
||||
df = getFileFromEbooksFolder(book.path, data.name + "." + book_format)
|
||||
@@ -1497,7 +1498,7 @@ def logout():
|
||||
|
||||
|
||||
# ################################### Users own configuration #########################################################
|
||||
def change_profile(kobo_support, local_oauth_check, oauth_status, translations, languages):
|
||||
def change_profile(kobo_support, hardcover_support, local_oauth_check, oauth_status, translations, languages):
|
||||
to_save = request.form.to_dict()
|
||||
current_user.random_books = 0
|
||||
try:
|
||||
@@ -1525,6 +1526,7 @@ def change_profile(kobo_support, local_oauth_check, oauth_status, translations,
|
||||
current_user.kobo_only_shelves_sync = int(to_save.get("kobo_only_shelves_sync") == "on") or 0
|
||||
if old_state == 0 and current_user.kobo_only_shelves_sync == 1:
|
||||
kobo_sync_status.update_on_sync_shelfs(current_user.id)
|
||||
current_user.hardcover_token = to_save.get("hardcover_token","").replace("Bearer ","")
|
||||
|
||||
except Exception as ex:
|
||||
flash(str(ex), category="error")
|
||||
@@ -1537,6 +1539,7 @@ def change_profile(kobo_support, local_oauth_check, oauth_status, translations,
|
||||
title=_("%(name)s's Profile", name=current_user.name),
|
||||
page="me",
|
||||
kobo_support=kobo_support,
|
||||
hardcover_support=hardcover_support,
|
||||
registered_oauth=local_oauth_check,
|
||||
oauth_status=oauth_status)
|
||||
|
||||
@@ -1568,6 +1571,7 @@ def profile():
|
||||
languages = calibre_db.speaking_language()
|
||||
translations = get_available_locale()
|
||||
kobo_support = feature_support['kobo'] and config.config_kobo_sync
|
||||
hardcover_support = feature_support['hardcover'] and config.config_hardcover_sync
|
||||
if feature_support['oauth'] and config.config_login_type == 2:
|
||||
oauth_status = get_oauth_status()
|
||||
local_oauth_check = oauth_check
|
||||
@@ -1576,7 +1580,7 @@ def profile():
|
||||
local_oauth_check = {}
|
||||
|
||||
if request.method == "POST":
|
||||
change_profile(kobo_support, local_oauth_check, oauth_status, translations, languages)
|
||||
change_profile(kobo_support, hardcover_support, local_oauth_check, oauth_status, translations, languages)
|
||||
return render_title_template("user_edit.html",
|
||||
translations=translations,
|
||||
profile=1,
|
||||
@@ -1584,6 +1588,7 @@ def profile():
|
||||
content=current_user,
|
||||
config=config,
|
||||
kobo_support=kobo_support,
|
||||
hardcover_support=hardcover_support,
|
||||
title=_("%(name)s's Profile", name=current_user.name),
|
||||
page="me",
|
||||
registered_oauth=local_oauth_check,
|
||||
|
||||
Reference in New Issue
Block a user