Merge branch 'main' into fix_for_syno_wsl2
This commit is contained in:
+8
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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", {})
|
||||
@@ -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">×</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 %}
|
||||
&
|
||||
{% 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
@@ -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">×</span></button>
|
||||
<h4 class="modal-title" id="kobo_tokenModalLabel">{{_('Generate Kobo Auth URL')}}</h4>
|
||||
</div>
|
||||
<div class="modal-body">...</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="kobo_close" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% block modal %}
|
||||
{{ restrict_modal() }}
|
||||
{{ delete_confirm_modal() }}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-editable.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-editable.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/pwstrength/i18next.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/pwstrength/i18nextHttpBackend.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/pwstrength/pwstrength-bootstrap.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/password.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/table.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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 ""
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user