Merge branch 'main' into fix_for_syno_wsl2

This commit is contained in:
FennyFatal
2025-05-22 07:39:55 -06:00
committed by GitHub
33 changed files with 7804 additions and 552 deletions
+8 -1
View File
@@ -1 +1,8 @@
*.sh text eol=lf
# Enforce LF for all shell scripts and related files
*.sh text eol=lf
*.bash text eol=lf
*.env text eol=lf
*.conf text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
**/run text eol=lf
+14 -7
View File
@@ -28,6 +28,7 @@ ARG VERSION
ARG CALIBREWEB_RELEASE=0.6.24
ARG LSCW_RELEASE=0.6.24-ls304
ARG UNIVERSAL_CALIBRE_RELEASE=7.16.0
ARG KEPUBIFY_RELEASE=v4.0.4
LABEL build_version="Version:- ${VERSION}"
LABEL build_date="${BUILD_DATE}"
LABEL CW-Stock-version="${CALIBREWEB_RELEASE}"
@@ -88,15 +89,21 @@ RUN \
pip install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/ubuntu/ -r \
requirements.txt -r \
optional-requirements.txt && \
# STEP 1.8 - Installs the latest release of kepubify
echo "***install kepubify" && \
if [ -z ${KEPUBIFY_RELEASE+x} ]; then \
# STEP 1.8 - Installs kepubify
echo "**** install kepubify ****" && \
if [[ $KEPUBIFY_RELEASE == 'newest' ]]; then \
KEPUBIFY_RELEASE=$(curl -sX GET "https://api.github.com/repos/pgaskin/kepubify/releases/latest" \
| awk '/tag_name/{print $4;exit}' FS='[""]'); \
fi && \
curl -o \
/usr/bin/kepubify -L \
https://github.com/pgaskin/kepubify/releases/download/${KEPUBIFY_RELEASE}/kepubify-linux-64bit && \
if [ "$(uname -m)" == "x86_64" ]; then \
curl -o \
/usr/bin/kepubify -L \
https://github.com/pgaskin/kepubify/releases/download/${KEPUBIFY_RELEASE}/kepubify-linux-64bit; \
elif [ "$(uname -m)" == "aarch64" ]; then \
curl -o \
/usr/bin/kepubify -L \
https://github.com/pgaskin/kepubify/releases/download/${KEPUBIFY_RELEASE}/kepubify-linux-arm64; \
fi && \
# STEP 2 - Install Calibre-Web Automated
echo "~~~~ CWA Install - installing additional required packages ~~~~" && \
# STEP 2.1 - Install additional required packages
@@ -205,4 +212,4 @@ COPY --from=unrar /usr/bin/unrar-ubuntu /usr/bin/unrar
EXPOSE 8083
VOLUME /config
VOLUME /cwa-book-ingest
VOLUME /calibre-library
VOLUME /calibre-library
+13 -6
View File
@@ -27,6 +27,7 @@ ARG BUILD_DATE
ARG VERSION
ARG CALIBREWEB_RELEASE=0.6.23
ARG LSCW_RELEASE=0.6.23-ls291
ARG KEPUBIFY_RELEASE=v4.0.4
LABEL build_version="Version:- ${VERSION}"
LABEL build_date="${BUILD_DATE}"
LABEL CW-Stock-version="${CALIBREWEB_RELEASE}"
@@ -87,15 +88,21 @@ RUN \
pip install -U --no-cache-dir --find-links https://wheel-index.linuxserver.io/ubuntu/ -r \
requirements.txt -r \
optional-requirements.txt && \
# STEP 1.8 - Installs the latest release of kepubify
echo "***install kepubify" && \
if [ -z ${KEPUBIFY_RELEASE+x} ]; then \
# STEP 1.8 - Installs kepubify
echo "**** install kepubify ****" && \
if [[ $KEPUBIFY_RELEASE == 'newest' ]]; then \
KEPUBIFY_RELEASE=$(curl -sX GET "https://api.github.com/repos/pgaskin/kepubify/releases/latest" \
| awk '/tag_name/{print $4;exit}' FS='[""]'); \
fi && \
curl -o \
/usr/bin/kepubify -L \
https://github.com/pgaskin/kepubify/releases/download/${KEPUBIFY_RELEASE}/kepubify-linux-64bit && \
if [ "$(uname -m)" == "x86_64" ]; then \
curl -o \
/usr/bin/kepubify -L \
https://github.com/pgaskin/kepubify/releases/download/${KEPUBIFY_RELEASE}/kepubify-linux-64bit; \
elif [ "$(uname -m)" == "aarch64" ]; then \
curl -o \
/usr/bin/kepubify -L \
https://github.com/pgaskin/kepubify/releases/download/${KEPUBIFY_RELEASE}/kepubify-linux-arm64; \
fi && \
# STEP 2 - Install Calibre-Web Automated
echo "~~~~ CWA Install - installing additional required packages ~~~~" && \
# STEP 2.1 - Install additional required packages
+2 -2
View File
@@ -277,7 +277,7 @@ And just like that, Calibre-Web Automated should be up and running! **HOWEVER**
## _Calibre-Web Quick Start Guide_
1. Open your browser and navigate to http://localhost:8084 or http://localhost:8084/opds for the OPDS catalog
1. Open your browser and navigate to http://localhost:8083 or http://localhost:8083/opds for the OPDS catalog
2. Log in with the default admin credentials (_below_)
3. Configure your Calibre-Web Automated instance via the Admin Page
- A guide to what all of the stock CW Settings do can be found [here](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration)
@@ -363,4 +363,4 @@ Check out [Post-Install Tasks Here](#post-install-tasks) when necessary.
- CWA is really lucky to have a very passionate and active community of people that really help shape CWA into what it is today
- If you have any ideas or want to contribute to the project, you're more than welcome to! We accept anyone regardless of skill level of expertise!
- If you've got a good idea or want to simply suggest improvements, simply get in touch with us on the Discord Server [here](https://discord.gg/EjgSeek94R)!
- If you've got a good idea or want to simply suggest improvements, simply get in touch with us on the Discord Server [here](https://discord.gg/EjgSeek94R)!
+7 -1
View File
@@ -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,
@@ -189,8 +190,8 @@ def reconnect():
@admi.route("/ajax/updateThumbnails", methods=['POST'])
@admin_required
@user_login_required
@admin_required
def update_thumbnails():
content = config.get_scheduled_task_settings()
if content['schedule_generate_book_covers']:
@@ -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(
@@ -1868,6 +1870,10 @@ def _configuration_update_helper():
services.goodreads_support.connect(config.config_goodreads_api_key,
config.config_use_goodreads)
# Hardcover configuration
_config_checkbox(to_save, "config_use_hardcover")
_config_string(to_save, "config_hardcover_token")
_config_int(to_save, "config_updatechannel")
# Reverse proxy login configuration
+590
View File
@@ -0,0 +1,590 @@
# -*- 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_hardcover_token = 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,239 @@
# -*- 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
import requests
from cps import logger, config
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
from cps.isoLanguages import get_language_name
from ..cw_login import current_user
from os import getenv
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 = """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"
) -> Optional[List[MetaRecord]]:
val = list()
if self.active:
try:
token = current_user.hardcover_token or config.config_hardcover_token or getenv("HARDCOVER_TOKEN")
if not token:
self.set_status(False)
raise Exception("Hardcover token not set for user, and no global token provided.")
edition_seach = query.split(":")[0] == "hardcover-id"
Hardcover.HEADERS["Authorization"] = "Bearer %s" % token.replace("Bearer ","")
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]
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,
)
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(result, edition, ""),
source=MetaSourceInfo(
id=self.__id__,
description=Hardcover.DESCRIPTION,
link=Hardcover.META_URL,
),
series=(result.get("book_series") or [{}])[0].get("series",{}).get("name", ""),
)
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") or [{}])[0].get("position", "")
match.tags = self._parse_tags(result,[])
match.languages = self._parse_languages(edition,locale)
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 "")
}
isbn = edition.get("isbn_13",edition.get("isbn_10"))
if isbn:
match.identifiers["isbn"] = isbn
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(result: Dict, edition: Dict, url: str) -> str:
edition = edition.get("id", "")
slug = result.get("slug","")
if edition:
return f"https://hardcover.app/books/{slug}/editions/{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
@staticmethod
def _parse_languages(edition: Dict, locale: str) -> List[str]:
language_iso = (edition.get("language") or {}).get("code3","")
languages = (
[get_language_name(locale, language_iso)]
if language_iso
else []
)
return languages
+139
View File
@@ -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, copy_current_request_context
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(copy_current_request_context(c.search), query, static_cover, locale): 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,236 @@
# -*- 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}, where: {finished_at: {_is_null: true}}) {
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)
# Book doesn't exist, add it in Reading status
if not book:
book = self.add_book(ids, status=2)
# Book is either WTR or Read, and we aren't finished reading
if book.get("status_id") != 2 and progress_percent != 100:
book = self.change_book_status(book, 2)
# Book is already marked as read, and we are also done
if book.get("status_id") == 3 and progress_percent == 100:
return
pages = book.get("edition",{}).get("pages",0)
if pages:
pages_read = round(pages * (progress_percent / 100))
read = next(iter(book.get("user_book_reads")),None)
if not read:
# read = self.add_read(book, pages_read)
# No read exists for some reason, return since we can't update anything.
return
else:
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 == 100 else None
}
if progress_percent == 100:
self.change_book_status(book, 3)
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", {})
+492
View File
@@ -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"))
@@ -2758,13 +2758,20 @@ input.pill + label:hover {
margin: 0
}
#meta-info #book-list .media:nth-of-type(even) {
background: none;
}
#meta-info #book-list .media:nth-of-type(odd) {
background: hsla(0, 0%, 100%, .02)
}
#meta-info #book-list .media {
margin-top: 0;
padding: 20px 15px 5px
margin-bottom: 0;
padding: 20px 15px 5px;
box-shadow: none;
border-radius: 0;
}
#meta-info #book-list .media > .media-body > p > a {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
/* This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
* Copyright (C) 2018 idalin<dalin.lin@gmail.com>
*
* 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/>.
*/
/* global _, i18nMsg, tinymce, getPath */
$(function () {
var msg = i18nMsg;
var keyword = "";
var templates = {
bookResult: _.template($("#template-book-result").html()),
};
function getUniqueValues(attribute_name, book) {
var presentArray = $.map(
$("#" + attribute_name)
.val()
.split(","),
$.trim
);
if (presentArray.length === 1 && presentArray[0] === "") {
presentArray = [];
}
$.each(book[attribute_name], function (i, el) {
if ($.inArray(el, presentArray) === -1) presentArray.push(el);
});
return presentArray;
}
function populateForm(book, idx) {
var updateItems = Object.fromEntries(
Array.from(document.querySelectorAll(`[data-meta-index="${idx}"]`)).map(
(value) => [value.dataset.metaValue, value.checked]
)
);
if (updateItems.description) {
tinymce.get("comments").setContent(book.description);
}
if (updateItems.tags) {
var uniqueTags = getUniqueValues("tags", book);
$("#tags").val(uniqueTags.join(", "));
}
var uniqueLanguages = getUniqueValues("languages", book);
if (updateItems.authors) {
var ampSeparatedAuthors = (book.authors || []).join(" & ");
$("#authors").val(ampSeparatedAuthors);
}
if (updateItems.title) {
$("#title").val(book.title);
}
$("#languages").val(uniqueLanguages.join(", "));
$("#rating").data("rating").setValue(Math.round(book.rating));
if (updateItems.cover && book.cover && $("#cover_url").length) {
$(".cover img").attr("src", book.cover);
$("#cover_url").val(book.cover);
}
if (updateItems.pubDate) {
$("#pubdate").val(book.publishedDate);
}
if (updateItems.publisher) {
$("#publisher").val(book.publisher);
}
if (updateItems.series && typeof book.series !== "undefined") {
$("#series").val(book.series);
}
if (updateItems.seriesIndex && typeof book.series_index !== "undefined") {
$("#series_index").val(book.series_index);
}
if (typeof book.identifiers !== "undefined") {
selectedIdentifiers = Object.keys(book.identifiers)
.filter((key) => updateItems[key])
.reduce((result, key) => {
result[key] = book.identifiers[key];
return result;
}, {});
populateIdentifiers(selectedIdentifiers);
}
}
function populateIdentifiers(identifiers) {
for (const property in identifiers) {
console.log(`${property}: ${identifiers[property]}`);
if ($('input[name="identifier-type-' + property + '"]').length) {
$('input[name="identifier-val-' + property + '"]').val(
identifiers[property]
);
} else {
addIdentifier(property, identifiers[property]);
}
}
}
function addIdentifier(name, value) {
var line = "<tr>";
line +=
'<td><input type="text" class="form-control" name="identifier-type-' +
name +
'" required="required" placeholder="' +
_("Identifier Type") +
'" value="' +
name +
'"></td>';
line +=
'<td><input type="text" class="form-control" name="identifier-val-' +
name +
'" required="required" placeholder="' +
_("Identifier Value") +
'" value="' +
value +
'"></td>';
line +=
'<td><a class="btn btn-default" onclick="removeIdentifierLine(this)">' +
_("Remove") +
"</a></td>";
line += "</tr>";
$("#identifier-table").append(line);
}
function doSearch(keyword) {
if (keyword) {
$("#meta-info").text(msg.loading);
$.ajax({
url: getPath() + "/metadata/search",
type: "POST",
data: { query: keyword },
dataType: "json",
success: function success(data) {
if (data.length) {
$("#meta-info").html('<ul id="book-list" class="media-list"></ul>');
data.forEach(function (book, idx) {
var $book = $(templates.bookResult({ book: book, index: idx }));
$book.find("button").on("click", function () {
populateForm(book, idx);
});
$("#book-list").append($book);
});
} else {
$("#meta-info").html(
'<p class="text-danger">' +
msg.no_result +
"!</p>" +
$("#meta-info")[0].innerHTML
);
}
},
error: function error() {
$("#meta-info").html(
'<p class="text-danger">' +
msg.search_error +
"!</p>" +
$("#meta-info")[0].innerHTML
);
},
});
}
}
function populate_provider() {
$("#metadata_provider").empty();
$.ajax({
url: getPath() + "/metadata/provider",
type: "get",
dataType: "json",
success: function success(data) {
data.forEach(function (provider) {
var checked = "";
if (provider.active) {
checked = "checked";
}
var $provider_button =
'<input type="checkbox" id="show-' +
provider.name +
'" class="pill" data-initial="' +
provider.initial +
'" data-control="' +
provider.id +
'" ' +
checked +
'><label for="show-' +
provider.name +
'">' +
provider.name +
' <span class="glyphicon glyphicon-ok"></span></label>';
$("#metadata_provider").append($provider_button);
});
},
});
}
$(document).on("change", ".pill", function () {
var element = $(this);
var id = element.data("control");
var initial = element.data("initial");
var val = element.prop("checked");
var params = { id: id, value: val };
if (!initial) {
params["initial"] = initial;
params["query"] = keyword;
}
$.ajax({
method: "post",
contentType: "application/json; charset=utf-8",
dataType: "json",
url: getPath() + "/metadata/provider/" + id,
data: JSON.stringify(params),
success: function success(data) {
element.data("initial", "true");
data.forEach(function (book, idx) {
var $book = $(templates.bookResult({ book: book, index: idx }));
$book.find("button").on("click", function () {
populateForm(book, idx);
});
$("#book-list").append($book);
});
},
});
});
$("#meta-search").on("submit", function (e) {
e.preventDefault();
keyword = $("#keyword").val();
$(".pill").each(function () {
$(this).data("initial", $(this).prop("checked"));
});
doSearch(keyword);
});
$("#get_meta").click(function () {
populate_provider();
var bookTitle = $("#title").val();
$("#keyword").val(bookTitle);
keyword = bookTitle;
doSearch(bookTitle);
});
$("#metaModal").on("show.bs.modal", function (e) {
$(e.relatedTarget).one("focus", function (e) {
$(this).blur();
});
});
});
@@ -0,0 +1,21 @@
fetch('/user_profiles.json')
.then(response => response.json())
.then(usernameToImage => {
var usernameElement = document.querySelector('#top_user .hidden-sm');
if (usernameElement) {
var username = usernameElement.textContent.trim();
if (usernameToImage[username]) {
var style = document.createElement('style');
style.innerHTML = `
.profileDrop > span:before {
background-image: url(${usernameToImage[username]}) !important;
}
body.me > div.container-fluid > div.row-fluid > div.col-sm-10:before {
background-image: url(${usernameToImage[username]}) !important;
}
`;
document.head.appendChild(style);
}
}
});
@@ -51,6 +51,7 @@
<a class="btn btn-default" id="admin_user_table" href="{{url_for('admin.edit_user_table')}}">{{_('Edit Users')}}</a>
{% endif %}
<a class="btn btn-default" id="admin_new_user" href="{{url_for('admin.new_user')}}">{{_('Add New User')}}</a>
<a class="btn btn-default" id="admin_profile_pictures" href="{{ url_for('web.profile_pictures') }}">{{ _('Manage Profile Pictures') }}</a>
{% if (config.config_login_type == 1) %}
<div class="btn btn-default" id="import_ldap_users" data-toggle="modal" data-target="#StatusDialog">{{_('Import LDAP Users')}}</div>
{% endif %}
@@ -0,0 +1,382 @@
{% extends "layout.html" %}
{% block body %}
{% if book %}
<div class="col-sm-3 col-lg-3 col-xs-12">
<div class="cover">
<!-- Always use full-sized image for the book edit page -->
<img id="detailcover" title="{{book.title}}" src="{{url_for('web.get_cover', book_id=book.id, resolution='og', c=book|last_modified)}}" />
</div>
{% if current_user.role_delete_books() %}
<div class="text-center">
<button type="button" class="btn btn-danger" id="delete" data-toggle="modal" data-delete-id="{{ book.id }}" data-target="#deleteModal">{{_("Delete Book")}}</button>
</div>
{% if book.data|length > 1 %}
<div class="text-center more-stuff"><h4>{{_('Delete formats:')}}</h4>
{% for file in book.data %}
<div class="form-group">
<button type="button" class="btn btn-danger" id="delete_format" data-toggle="modal" data-delete-id="{{ book.id }}" data-delete-format="{{ file.format }}" data-target="#deleteModal">{{_('Delete')}} - {{file.format}}</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
{% if source_formats|length > 0 and conversion_formats|length > 0 %}
<div class="text-center more-stuff"><h4>{{_('Convert book format:')}}</h4>
<form class="padded-bottom" action="{{ url_for('edit-book.convert_bookformat', book_id=book.id) }}" method="post" id="book_convert_frm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<div class="text-left">
<label class="control-label" for="book_format_from">{{_('Convert from:')}}</label>
<select class="form-control" name="book_format_from" id="book_format_from">
<option disabled selected value>-- {{_('select an option')}} --</option>
{% for format in source_formats %}
<option>{{format|upper}}</option>
{% endfor %}
</select>
<label class="control-label" for="book_format_to">{{_('Convert to:')}}</label>
<select class="form-control" name="book_format_to" id="book_format_to">
<option disabled selected value>-- {{_('select an option')}} --</option>
{% for format in conversion_formats %}
<option>{{format|upper}}</option>
{% endfor %}
</select>
</div>
</div>
<button type="submit" class="btn btn-primary" id="btn-book-convert" name="btn-book-convert"><span class="glyphicon glyphicon-duplicate"></span> {{_('Convert book')}}</button>
</form>
</div>
{% endif %}
{% if current_user.role_upload() and g.allow_upload %}
<div class="text-center more-stuff"><!--h4 aria-label="Upload new book format"></h4-->
<form id="form-upload-format" action="{{ url_for('edit-book.upload') }}" data-title="{{_('Uploading...')}}" data-footer="{{_('Close')}}" data-failed="{{_('Error')}}" data-message="{{_('Upload done, processing, please wait...')}}" method="post" enctype="multipart/form-data">
<div class="text-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="book_id" value="{{ book.id }}">
<div role="group" aria-label="Upload new book format">
<label class="btn btn-primary btn-file" for="btn-upload-format">{{ _('Upload Format') }}</label>
<div class="upload-format-input-text" id="upload-format"></div>
<input id="btn-upload-format" name="btn-upload-format" type="file" accept="{% for format in accept %}.{% if format != ''%}{{format}}{% else %}*{% endif %}{{ ',' if not loop.last }}{% endfor %}" multiple>
</div>
</div>
</form>
</div>
{% endif %}
</div>
<form role="form" action="{{ url_for('edit-book.edit_book', book_id=book.id) }}" method="post" enctype="multipart/form-data" id="book_edit_frm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col-sm-9 col-xs-12">
<div class="form-group">
<label for="title">{{_('Book Title')}}</label>
<input type="text" class="form-control" name="title" id="title" value="{{book.title}}">
</div>
<div class="text-center">
<button type="button" class="btn btn-default" id="xchange" ><span class="glyphicon glyphicon-arrow-up"></span><span class="glyphicon glyphicon-arrow-down"></span></button>
</div>
<div id="author_div" class="form-group">
<label for="bookAuthor">{{_('Author')}}</label>
<input type="text" class="form-control typeahead" autocomplete="off" name="authors" id="authors" value="{{' & '.join(authors)}}">
</div>
<div class="form-group">
<label for="tags">{{_('Tags')}}</label>
<input type="text" class="form-control typeahead" autocomplete="off" name="tags" id="tags" value="{% for tag in book.tags %}{{tag.name.strip()}}{% if not loop.last %}, {% endif %}{% endfor %}">
</div>
<div class="form-group">
<label for="series">{{_('Series')}}</label>
<input type="text" class="form-control typeahead" autocomplete="off" name="series" id="series" value="{% if book.series %}{{book.series[0].name}}{% endif %}">
</div>
<div class="form-group">
<label for="series_index">{{_('Series ID')}}</label>
<input type="number" step="0.01" min="0" placeholder="1" class="form-control" name="series_index" id="series_index" value="{{book.series_index|formatfloat(2)}}">
</div>
<label for="pubdate">{{_('Published Date')}}</label>
<div class="form-group input-group">
<input type="text" class="datepicker form-control" name="pubdate" id="pubdate" value="{% if book.pubdate %}{{book.pubdate|formatdateinput}}{% endif %}">
<input type="text" class="form-control fake-input hidden" id="fake_pubdate" value="{% if book.pubdate %}{{book.pubdate|formatdate}}{% endif %}">
<span class="input-group-btn">
<button type="button" id="pubdate_delete" class="datepicker_delete btn btn-default"><span class="glyphicon glyphicon-remove-circle"></span></button>
</span>
</div>
<div class="form-group">
<label for="publisher">{{_('Publisher')}}</label>
<input type="text" class="form-control typeahead" autocomplete="off" name="publisher" id="publisher" value="{% if book.publishers|length > 0 %}{{book.publishers[0].name}}{% endif %}">
</div>
<div class="form-group">
<label for="languages">{{_('Language')}}</label>
<input type="text" class="form-control typeahead" autocomplete="off" name="languages" id="languages" value="{% for language in book.languages %}{{language.language_name.strip()}}{% if not loop.last %}, {% endif %}{% endfor %}">
</div>
<div class="form-group">
<label for="rating">{{_('Rating')}}</label>
<input type="number" name="rating" id="rating" class="rating input-lg" data-clearable="" value="{% if book.ratings %}{{(book.ratings[0].rating / 2)|int}}{% endif %}">
</div>
<div class="form-group">
<label for="comments">{{_('Description')}}</label>
<textarea class="form-control" name="comments" id="comments" rows="7">{% if book.comments %}{{book.comments[0].text}}{%endif%}</textarea>
</div>
<div class="form-group">
<label>{{_('Identifiers')}}</label>
<table class="table" id="identifier-table"><tbody>
{% for identifier in book.identifiers %}
<tr>
<td><input type="text" class="form-control" name="identifier-type-{{identifier.type}}" value="{{identifier.type}}" required="required" placeholder="{{_('Identifier Type')}}"></td>
<td><input type="text" class="form-control" name="identifier-val-{{identifier.type}}" value="{{identifier.val}}" required="required" placeholder="{{_('Identifier Value')}}"></td>
<td><a class="btn btn-default" onclick="removeIdentifierLine(this)">{{_('Remove')}}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<a id="add-identifier-line" class="btn btn-default">{{_('Add Identifier')}}</a>
</div>
{% if current_user.role_upload() and g.allow_upload %}
<div class="form-group">
<label for="cover_url">{{_('Fetch Cover from URL (JPEG - Image will be downloaded and stored in database)')}}</label>
<input type="text" class="form-control" name="cover_url" id="cover_url" value="">
</div>
<div class="form-group" aria-label="Upload cover from local drive">
<label class="btn btn-primary btn-file" for="btn-upload-cover">{{ _('Upload Cover from Local Disk') }}</label>
<div class="upload-cover-input-text" id="upload-cover"></div>
<input id="btn-upload-cover" name="btn-upload-cover" type="file" accept=".jpg, .jpeg, .png, .webp">
</div>
{% endif %}
{% if cc|length > 0 %}
{% for c in cc %}
<div class="form-group">
<label for="{{ 'custom_column_' ~ c.id }}">{{ c.name }}</label>
{% if c.datatype == 'bool' %}
<select name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" class="form-control">
<option value="None" {% if book['custom_column_' ~ c.id]|length == 0 %} selected {% endif %}></option>
<option value="True" {% if book['custom_column_' ~ c.id]|length > 0 %}{% if book['custom_column_' ~ c.id][0].value ==true %}selected{% endif %}{% endif %} >{{_('Yes')}}</option>
<option value="False" {% if book['custom_column_' ~ c.id]|length > 0 %}{% if book['custom_column_' ~ c.id][0].value ==false %}selected{% endif %}{% endif %}>{{_('No')}}</option>
</select>
{% endif %}
{% if c.datatype == 'int' or c.datatype == 'float' %}
<input type="number" step="{% if c.datatype == 'float' %}0.01{% else %}1{% endif %}" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" value="{% if book['custom_column_' ~ c.id]|length > 0 %}{{ book['custom_column_' ~ c.id][0].value }}{% endif %}">
{% endif %}
{% if c.datatype == 'text' %}
<input type="text" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}"
{% if book['custom_column_' ~ c.id]|length > 0 %}
value="{% for column in book['custom_column_' ~ c.id] %}{{ column.value.strip() }}{% if not loop.last %}, {% endif %}{% endfor %}"{% endif %}>
{% endif %}
{% if c.datatype == 'series' %}
<input type="text" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}"
{% if book['custom_column_' ~ c.id]|length > 0 %}
value="{% for column in book['custom_column_' ~ c.id] %} {{ '%s [%s]' % (book['custom_column_' ~ c.id][0].value, book['custom_column_' ~ c.id][0].extra|formatfloat(2)) }}{% if not loop.last %}, {% endif %}{% endfor %}"
{% endif %}>
{% endif %}
{% if c.datatype == 'datetime' %}
<div class="input-group">
<input type="text" class="datepicker form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}"
{% if book['custom_column_' ~ c.id]|length > 0 %}
value="{% if book['custom_column_' ~ c.id][0].value %}{{ book['custom_column_' ~ c.id][0].value|formatdateinput}}{% endif %}"
{% endif %}>
<input type="text" class="fake_custom_column_{{ c.id }} form-control fake-input hidden" id="fake_pubdate_{{ c.id }}"
{% if book['custom_column_' ~ c.id]|length > 0 %}
value="{% if book['custom_column_' ~ c.id][0].value %}{{book['custom_column_' ~ c.id][0].value|formatdate}}{% endif %}"
{% endif %}>
<span class="input-group-btn">
<button type="button" id="{{ 'custom_column_' ~ c.id }}_delete" class="datepicker_delete btn btn-default"><span class="glyphicon glyphicon-remove-circle"></span></button>
</span>
</div>
{% endif %}
{% if c.datatype == 'comments' %}
<textarea class="form-control tiny_editor" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" rows="7">{% if book['custom_column_' ~ c.id]|length > 0 %}{{book['custom_column_' ~ c.id][0].value}}{%endif%}</textarea>
{% endif %}
{% if c.datatype == 'enumeration' %}
<select class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}">
<option></option>
{% for opt in c.get_display_dict().enum_values %}
<option
{% if book['custom_column_' ~ c.id]|length > 0 %}
{% if book['custom_column_' ~ c.id][0].value == opt %}selected="selected"{% endif %}
{% endif %}
>{{ opt }}</option>
{% endfor %}
</select>
{% endif %}
{% if c.datatype == 'rating' %}
<input type="number" min="1" max="5" step="0.5" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}"
{% if book['custom_column_' ~ c.id]|length > 0 %}
value="{{ '%.1f' % (book['custom_column_' ~ c.id][0].value / 2) }}"
{% endif %}>
{% endif %}
</div>
{% endfor %}
{% endif %}
<div class="checkbox">
<label>
<input name="detail_view" type="checkbox" checked> {{_('View Book on Save')}}
</label>
</div>
<a href="#" id="get_meta" class="btn btn-default" data-toggle="modal" data-target="#metaModal">{{_('Fetch Metadata')}}</a>
<button type="submit" id="submit" class="btn btn-default">{{_('Save')}}</button>
<a href="{{ url_for('web.show_book', book_id=book.id) }}" id="edit_cancel" class="btn btn-default">{{_('Cancel')}}</a>
</div>
</form>
{% endif %}
{% endblock %}
{% block modal %}
{{ delete_book(current_user.role_delete_books()) }}
{{ delete_confirm_modal() }}
<div class="modal fade" id="metaModal" tabindex="-1" role="dialog" aria-labelledby="metaModalLabel">
<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">&times;</span></button>
<h4 class="modal-title text-center" id="metaModalLabel">{{_('Fetch Metadata')}}</h4>
<form class="padded-bottom" id="meta-search">
<div class="input-group">
<label class="sr-only" for="keyword">{{_('Keyword')}}</label>
<input type="text" class="form-control" id="keyword" name="keyword" placeholder="{{_("Search keyword")}}">
<span class="input-group-btn">
<button type="submit" class="btn btn-primary" id="do-search">{{_("Search")}}</button>
</span>
</div>
</form>
</div>
<div class="modal-body">
<div class="text-center padded-bottom" id="metadata_provider">
</div>
<div id="meta-info">
{{_("Loading...")}}
</div>
</div>
<div class="modal-footer">
<button id="meta_close" type="button" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script type="text/template" id="template-book-result">
<li class="media" data-related="<%= book.source.id %>">
<div class="media-image">
<div class="media-image-wrapper">
<img
onload="coverDimensions(this)"
src="<%= book.cover || "{{ url_for('static', filename='img/academicpaper.svg') }}" %>"
alt="Cover"
>
<div class="image-dimensions"></div>
<input type="checkbox" data-meta-index="<%= index %>" data-meta-value="cover" checked>
</div>
<button class="btn btn-default">Save</button>
<div><a class="meta_source" href="<%= book.source.link %>" target="_blank" rel="noopener"><%= book.source.description %></a></div>
<% if(book.format) { %>
<div>Format: <%= book.format %></div>
<% } %>
</div>
<dl class="media-body">
<dt><input type="checkbox" data-meta-index="<%= index %>" data-meta-value="title" checked>Title:</dt>
<dd><a class="meta_title" href="<%= book.url %>" target="_blank" rel="noopener"><%= book.title %></a></dd>
<dt><input type="checkbox" data-meta-index="<%= index %>" data-meta-value="authors" checked>Author:</dt>
<dd class="meta_author"><%= book.authors.join(" & ") %></dd>
<% if (book.publisher) { %>
<dt><input type="checkbox" data-meta-index="<%= index %>" data-meta-value="publisher" checked>Publisher:</dt>
<dd class="meta_publisher"><%= book.publisher %></dd>
<% } %>
<% if (book.publishedDate) { %>
<dt><input type="checkbox" data-meta-index="<%= index %>" data-meta-value="pubDate" checked>Published Date:</dt>
<dd class="meta_publishedDate"><%= book.publishedDate %></dd>
<% } %>
<% if (book.series) { %>
<dt><input type="checkbox" data-meta-index="<%= index %>" data-meta-value="series" checked>Series:</dt>
<dd class="meta_publishedDate"><%= book.series %></dd>
<% } %>
<% if (book.series_index) { %>
<dt><input type="checkbox" data-meta-index="<%= index %>" data-meta-value="seriesIndex" checked>Series Index:</dt>
<dd class="meta_publishedDate"><%= book.series_index %></dd>
<% } %>
<% if (book.description) { %>
<dt><input type="checkbox" data-meta-index="<%= index %>" data-meta-value="description" checked>Description:</dt>
<dd class="meta_description"><%= book.description %></dd>
<% } %>
<% if (book.tags.length !== 0) { %>
<dt><input type="checkbox" data-meta-index="<%= index %>" data-meta-value="tags" checked>Tags:</dt>
<dd class="meta_author"><%= book.tags.join(", ") %></dd>
<% } %>
<% if (Object.keys(book.identifiers).length !== 0 ) { %>
<% for (const key in book.identifiers) { %>
<% if (book.identifiers[key] !== "") { %>
<dt><input type="checkbox" data-meta-index="<%= index %>" data-meta-value="<%= key %>" checked>Identifier</dt>
<dd class="meta_identifier"><%= key %>:<%= book.identifiers[key] %>
<% if (key === "hardcover-id" && !document.getElementById("keyword").value.includes("hardcover-id")) { %>
<a href="#" data-hardcover-id="<%= book.identifiers[key] %>" ><span class="glyphicon glyphicon-search" style="padding-left: 10px; padding-right: 6px;"></span>Editions</a>
<% } %>
</dd>
<% } %>
<% } %>
<% } %>
</dl>
</li>
</script>
<script>
var i18nMsg = {
'loading': {{_('Loading...')|safe|tojson}},
'search_error': {{_('Search error!')|safe|tojson}},
'no_result': {{_('No Result(s) found! Please try another keyword.')|safe|tojson}},
'author': {{_('Author')|safe|tojson}},
'publisher': {{_('Publisher')|safe|tojson}},
'comments': {{_('Description')|safe|tojson}},
'source': {{_('Source')|safe|tojson}},
};
var language = '{{ current_user.locale }}';
$("#add-identifier-line").click(function() {
// create a random identifier type to have a valid name in form. This will not be used when dealing with the form
var rand_id = Math.floor(Math.random() * 1000000).toString();
var line = '<tr>';
line += '<td><input type="text" class="form-control" name="identifier-type-'+ rand_id +'" required="required" placeholder={{_('Identifier Type')|safe|tojson}}></td>';
line += '<td><input type="text" class="form-control" name="identifier-val-'+ rand_id +'" required="required" placeholder={{_('Identifier Value')|safe|tojson}}></td>';
line += '<td><a class="btn btn-default" onclick="removeIdentifierLine(this)">{{_('Remove')}}</a></td>';
line += '</tr>';
$("#identifier-table").append(line);
});
function removeIdentifierLine(el) {
$(el).parent().parent().remove();
}
function coverDimensions(el) {
var existing_cover = document.querySelector("#detailcover")
el.nextElementSibling.innerText = el.naturalWidth + 'x' + el.naturalHeight
if (existing_cover.naturalHeight*existing_cover.naturalWidth > el.naturalWidth * el.naturalHeight){
el.nextElementSibling.classList.add("smaller")
} else if (existing_cover.naturalHeight*existing_cover.naturalWidth < el.naturalWidth * el.naturalHeight){
el.nextElementSibling.classList.add("larger")
}
}
$(document).on('click','.meta_identifier a',
function (e) {
e.preventDefault()
document.getElementById("keyword").value = `hardcover-id:${e.target.dataset.hardcoverId}`
document.getElementById("do-search").click()
})
</script>
<script src="{{ url_for('static', filename='js/libs/typeahead.bundle.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-rating-input.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/get_meta.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/tinymce/tinymce.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-datepicker/bootstrap-datepicker.min.js') }}"></script>
{% if not current_user.locale == 'en' %}
<script src="{{ url_for('static', filename='js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.' + current_user.locale + '.min.js') }}" charset="UTF-8"></script>
{% endif %}
<script src="{{ url_for('static', filename='js/edit_books.js') }}"></script>
<script src="{{ url_for('static', filename='js/fullscreen.js') }}"></script>
{% endblock %}
{% block header %}
<meta name="referrer" content="never">
<link href="{{ url_for('static', filename='css/libs/typeahead.css') }}" rel="stylesheet" media="screen">
<link href="{{ url_for('static', filename='css/libs/bootstrap-datepicker3.min.css') }}" rel="stylesheet" media="screen">
{% endblock %}
@@ -0,0 +1,448 @@
{% 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_use_hardcover" name="config_use_hardcover" data-control="hardcover-settings" {% if config.config_use_hardcover %}checked{% endif %}>
<label for="config_use_hardcover">{{_('Use Hardcover')}}</label>
</div>
<div data-related="hardcover-settings">
<div class="form-group">
<label for="config_hardcover_token">{{_('Hardcover API Key')}}</label>
<input type="text" class="form-control" id="config_hardcover_token" name="config_hardcover_token" value="{% if config.config_hardcover_token != None %}{{ config.config_hardcover_token }}{% endif %}" autocomplete="off">
</div>
</div>
<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 %}
&amp;
{% 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 %}
+22
View File
@@ -0,0 +1,22 @@
{% macro book_cover(book, alt=None) -%}
{%- set image_title = book.title if book.title else book.name -%}
{%- set image_alt = alt if alt else image_title -%}
{% set srcset = book|get_cover_srcset %}
<img
srcset="{{ srcset }}"
src="{{ url_for('web.get_cover', book_id=book.id, resolution='og', c=book|last_modified) }}"
alt="{{ image_alt }}"
loading="lazy"
/>
{%- endmacro %}
{% macro series(series, alt=None) -%}
{%- set image_alt = alt if alt else image_title -%}
{% set srcset = series|get_series_srcset %}
<img
srcset="{{ srcset }}"
src="{{ url_for('web.get_series_cover', series_id=series.id, resolution='og', c='day'|cache_timestamp) }}"
alt="{{ title }}"
loading="lazy"
/>
{%- endmacro %}
@@ -254,7 +254,7 @@
{% if current_user.is_authenticated or g.allow_anonymous %}
<li class="nav-head hidden-xs public-shelves">{{_('Shelves')}}</li>
{% for shelf in g.shelves_access %}
<li><a href="{{url_for('shelf.show_shelf', shelf_id=shelf.id)}}"><span class="glyphicon glyphicon-list shelf"></span> {{shelf.name|shortentitle(40)}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %}</a></li>
<li><a href="{{url_for('shelf.show_shelf', shelf_id=shelf.id)}}"><span class="glyphicon glyphicon-list shelf"></span> {{shelf.name|shortentitle(40)}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %} <span style="font-size: 80%; color: #888;">({{shelf.books.all()|length}})</span></a></li>
{% endfor %}
{% if not current_user.is_anonymous %}
<li id="nav_createshelf" class="create-shelf"><a href="{{url_for('shelf.create_shelf')}}">{{_('Create a Shelf')}}</a></li>
@@ -327,5 +327,6 @@
<script src="{{ url_for('static', filename='js/caliBlur.js') }}"></script>
{% endif %}
{% block js %}{% endblock %}
<script src="{{ url_for('static', filename='user-profile-data/CWA-profile-updater.js') }}"></script>
</body>
</html>
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="{{ current_user.locale }}">
<head>
<title>{{instance}} | {{title}}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="mobile-web-app-capable" content="yes">
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="140x140" href="favicon.ico">
<link rel="shortcut icon" href="favicon.ico">
<!-- Bootstrap CSS -->
<link href="css/libs/bootstrap.min.css" rel="stylesheet" media="screen">
<!-- Theme CSS -->
{% if g.current_theme == 1 %}
<link href="{{ url_for('static', filename='css/caliBlur.css') }}" rel="stylesheet" media="screen">
<link href="{{ url_for('static', filename='css/caliBlur_override.css') }}" rel="stylesheet" media="screen">
<link href="{{ url_for('static', filename='css/caliBlur_cwa.css') }}" rel="stylesheet" media="screen">
{% endif %}
</head>
<style>
html, body {
height: auto;
min-height: 100%;
overflow-y: auto;
}
h1 {
font-size: 2.8rem;
font-weight: 900;
margin: 6px 0;
line-height: 1.5;
}
.container h1 {
text-align: center;
}
.container p {
text-align: center;
max-width: 1100px;
margin-left: auto;
margin-right: auto;
}
.back-button {
display: flex;
justify-content: center;
margin-top: 1rem;
}
.back-button a.btn {
background-color: #CC7B19;
color: #ffffff;
border: 2px solid #CC7B19;
padding: 10px 20px;
border-radius: 8px;
text-decoration: none;
font-weight: bold;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
transition: background-color 0.3s, color 0.3s;
display: inline-block;
width: auto;
max-width: fit-content;
}
.back-button a.btn:hover {
background-color: #E59029;
border: 2px solid #E59029;
color: #333;
cursor: pointer;
}
.form-wrapper {
max-width: 1000px;
margin: 0 auto;
padding: 0 15px;
box-sizing: border-box;
}
.pseudo-header {
background-color: #15191d;
padding: 0.1px 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
color: white;
text-align: center;
margin-bottom: 10px;
}
.image-box {
height: 300px;
}
.lead-line {
font-size: 1.2rem;
font-weight: 500;
}
</style>
<body>
<div class="pseudo-header">
<h1>{{ title }}</h1>
</div>
<div class="container mt-5">
<p class="lead-line">This is the admin page for managing profile pictures. This feature is currently in development.</p>
<p>
To upload a profile picture, please use the following form. Note: The image data must be in Base64 format.
You can convert an image to Base64 using various online tools. If you use a website such as
<a href="https://www.base64-image.de" target="_blank" rel="noopener noreferrer">www.base64-image.de</a>,
then make sure you use the version that starts with the line "data:image/png;base64," not the URL version.
To display the image as a round shape, it must already be in PNG format with a round frame applied.
It is recommended to use images no larger than 100x100px to avoid using excessive storage space.
</p>
<div class="form-wrapper">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label for="username">Username to add the image to:</label>
<input type="text" class="form-control" id="username" name="username" required aria-describedby="usernameHelp" />
<small id="usernameHelp" class="form-text text-muted">Enter the exact username for the profile picture assignment.</small>
</div>
<div class="form-group mt-3">
<label for="image_data">Base64 Image Data:</label>
<textarea class="form-control image-box" id="image_data" name="image_data" rows="30" required aria-describedby="imageDataHelp"></textarea>
<small id="imageDataHelp" class="form-text text-muted">Paste the full Base64 encoded PNG image data here.</small>
</div>
<button type="submit" class="btn btn-primary mt-4">Submit</button>
</form>
</div>
<div class="back-button mt-4">
<a class="btn btn-secondary" href="{{ url_for('admin.admin') }}">Back to Admin Page</a>
</div>
</div>
</body>
</html>
<!--
This template is for the profile picture upload page in Calibre-Web.
It includes a form for uploading Base64 encoded images and provides instructions for users.
The form submits the data to the server for processing.
-->
@@ -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 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">&times;</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 %}
+780
View File
@@ -0,0 +1,780 @@
# -*- 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=None)
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.hardcover_token = None
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
self.hardcover_token = data.hardcover_token
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)
migrate_user_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 ""
+79 -4
View File
@@ -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 ","") or None
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']
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,
@@ -1693,3 +1698,73 @@ def show_book(book_id):
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
category="error")
return redirect(url_for("web.index"))
# ################################### Profile Pictures ###################################################
@web.route("/user_profiles.json")
@user_login_required
def user_profiles_json():
try:
json_path = "/config/user_profiles.json"
with open(json_path, "r") as file:
data = json.load(file)
return jsonify(data)
except Exception as e:
log.error(f"Error reading user_profiles.json: {str(e)}")
return jsonify({}), 500
@web.route("/me/cwa/profilepictures", methods=["GET", "POST"])
@user_login_required
def profile_pictures():
log.debug("Accessed /me/cwa/profilepictures route.")
# Check if the user is an admin
if not current_user.role_admin():
flash(_("You must be an admin to access this page."), category="error")
log.warning(f"Unauthorized access attempt by user: {current_user.name}")
return redirect(url_for('web.profile'))
if request.method == "POST":
log.debug("POST request received on profile_pictures page.")
# Get the form data (username and image data)
username = request.form.get("username")
image_data = request.form.get("image_data")
log.debug(f"Form data received - Username: {username}, Image Data Length: {len(image_data) if image_data else 'None'}")
# Validate form fields
if not username or not image_data:
flash(_("Both username and image data are required."), category="error")
log.warning("Form submission missing username or image_data.")
return redirect(url_for('web.profile_pictures'))
try:
# Path to the JSON file
json_path = "/config/user_profiles.json"
log.debug(f"Opening JSON file at: {json_path}")
# Read the existing data from the JSON file and update it
with open(json_path, "r+") as file:
user_data = json.load(file)
user_data[username] = image_data # Add new or update existing entry
file.seek(0) # Move to the start of the file for writing
json.dump(user_data, file, indent=4) # Write back the updated data
file.truncate() # Ensure there is no leftover content
# Success feedback and logging
flash(_("Profile picture updated successfully."), category="success")
log.info(f"Profile picture updated for user: {username}")
except Exception as e:
# Error handling in case of an issue
flash(f"Error: {str(e)}", category="error")
log.error(f"Exception while updating profile picture JSON: {str(e)}")
return redirect(url_for('web.profile_pictures'))
# Handle the GET request and render the page
log.debug("Rendering GET view for profile_pictures page.")
return render_title_template("profile_pictures.html",
title=_("Profile Picture Management"),
page="profilepictures")
+11
View File
@@ -73,6 +73,17 @@ fi
echo "[cwa-init] CWA-init complete! Service exiting now..."
#------------------------------------------------------------------------------------------------------------------------
# Create blank json file for profile pictures if one doesn't exist
#------------------------------------------------------------------------------------------------------------------------
if [ ! -f /config/user_profiles.json ]; then
echo "[cwa-init] No existing user_profiles.json found! Creating blank one..."
echo -e "{\n}" > /config/user_profiles.json
else
echo "[cwa-init] Existing user_profiles.json found!"
fi
#------------------------------------------------------------------------------------------------------------------------
# Set required permissions
#------------------------------------------------------------------------------------------------------------------------
@@ -6,6 +6,9 @@ echo "========== STARTING METADATA CHANGE DETECTOR ==========="
WATCH_FOLDER="/app/calibre-web-automated/metadata_change_logs"
echo "[metadata-change-detector] Watching folder: $WATCH_FOLDER"
# Create the folder if it doesn't exist
mkdir -p "$WATCH_FOLDER"
# Monitor the folder for new files
s6-setuidgid abc inotifywait -m -e close_write -e moved_to --exclude '^.*\.(swp)$' "$WATCH_FOLDER" |
while read -r directory events filename; do
+1 -1
View File
@@ -163,7 +163,7 @@ class LibraryConverter:
continue
if self.target_format == "kepub":
convert_successful, target_filepath = self.convert_to_kepub(filename, file_extension)
convert_successful, target_filepath = self.convert_to_kepub(file, file_extension)
if not convert_successful:
print_and_log(f"[convert-library]: ({self.current_book}/{len(self.to_convert)}) Conversion of {os.path.basename(file)} was unsuccessful. Moving to next book...")
self.current_book += 1
+27 -3
View File
@@ -44,6 +44,16 @@ class NewBookProcessor:
self.auto_convert_on = self.cwa_settings['auto_convert']
self.target_format = self.cwa_settings['auto_convert_target_format']
self.ingest_ignored_formats = self.cwa_settings['auto_ingest_ignored_formats']
# Make sure it's a list, if it's a string convert it to a single-item list
if isinstance(self.ingest_ignored_formats, str):
self.ingest_ignored_formats = [self.ingest_ignored_formats]
# Ignore temporary files during download
self.ingest_ignored_formats.append(".crdownload") # Chromium based
self.ingest_ignored_formats.append(".download") # Safari
self.ingest_ignored_formats.append(".part") #Firefox and tools (ie curl)
self.convert_ignored_formats = self.cwa_settings['auto_convert_ignored_formats']
self.is_kindle_epub_fixer = self.cwa_settings['kindle_epub_fixer']
@@ -168,15 +178,16 @@ class NewBookProcessor:
def delete_current_file(self) -> None:
"""Deletes file just processed from ingest folder"""
os.remove(self.filepath) # Removes processed file
subprocess.run(["find", f"{self.ingest_folder}", "-mindepth", "1", "-type", "d", "-empty", "-delete"]) # Removes any now empty folders in the ingest folder
if not os.path.samefile(os.path.dirname(self.filepath),self.ingest_folder): # File not in ingest_folder, subdirectories to delete
subprocess.run(["find", f"{os.path.dirname(self.filepath)}", "-type", "d", "-empty", "-delete"]) # Removes any now empty folders including parent directory
def add_book_to_library(self, book_path:str) -> None:
if self.target_format == "epub" and self.is_kindle_epub_fixer:
self.run_kindle_epub_fixer(book_path, dest=self.tmp_conversion_dir)
fixed_epub_path = str(self.empty_tmp_con_dir) + str(os.path.basename(book_path))
fixed_epub_path = Path(self.tmp_conversion_dir) / os.path.basename(book_path)
if Path(fixed_epub_path).exists():
book_path = self.empty_tmp_con_dir + os.path.basename(book_path)
book_path = str(fixed_epub_path)
print("[ingest-processor]: Importing new book to CWA...")
import_path = Path(book_path)
@@ -226,6 +237,19 @@ class NewBookProcessor:
def main(filepath=sys.argv[1]):
"""Checks if filepath is a directory. If it is, main will be ran on every file in the given directory
Inotifywait won't detect files inside folders if the folder was moved rather than copied"""
##############################################################################################
# Truncates the filename if it is too long
MAX_LENGTH = 150
filename = os.path.basename(filepath)
name, ext = os.path.splitext(filename)
allowed_len = MAX_LENGTH - len(ext)
if len(name) > allowed_len:
new_name = name[:allowed_len] + ext
new_path = os.path.join(os.path.dirname(filepath), new_name)
os.rename(filepath, new_path)
filepath = new_path
###############################################################################################
if os.path.isdir(filepath) and Path(filepath).exists():
# print(os.listdir(filepath))
for filename in os.listdir(filepath):
+4 -6
View File
@@ -14,15 +14,13 @@ make_dirs () {
# Change ownership & permissions as required
change_script_permissions () {
chmod +x /app/calibre-web-automated/scripts/check-cwa-services.sh
chmod +x /etc/s6-overlay/s6-rc.d/cwa-ingest-service/run
chmod +x /etc/s6-overlay/s6-rc.d/cwa-init-remove-locks/run
chmod +x /etc/s6-overlay/s6-rc.d/metadata-change-detector/run
chmod +x /etc/s6-overlay/s6-rc.d/cwa-set-perms/run
chmod +x /etc/s6-overlay/s6-rc.d/cwa-auto-library/run
chmod +x /etc/s6-overlay/s6-rc.d/cwa-auto-zipper/run
chmod +x /etc/s6-overlay/s6-rc.d/cwa-set-binary-paths/run
chmod +x /etc/s6-overlay/s6-rc.d/cwa-ingest-service/run
chmod +x /etc/s6-overlay/s6-rc.d/cwa-init/run
chmod +x /etc/s6-overlay/s6-rc.d/metadata-change-detector/run
chmod +x /etc/s6-overlay/s6-rc.d/universal-calibre-setup/run
chmod +x /app/calibre-web-automated/scripts/check-cwa-services.sh
chmod 775 /app/calibre-web/cps/editbooks.py
chmod 775 /app/calibre-web/cps/admin.py
}