mirror of
https://github.com/janeczku/calibre-web
synced 2024-12-24 00:50:30 +00:00
Merge branch 'Develop' into kobo-sync-detect-fixed-layout
This commit is contained in:
commit
7ae9f89bbf
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
custom: ["https://PayPal.Me/calibreweb",]
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,6 +23,7 @@ vendor/
|
||||
# calibre-web
|
||||
*.db
|
||||
*.log
|
||||
cps/cache
|
||||
|
||||
.idea/
|
||||
*.bak
|
||||
|
29
README.md
29
README.md
@ -1,6 +1,6 @@
|
||||
# About
|
||||
|
||||
Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using an existing [Calibre](https://calibre-ebook.com) database.
|
||||
Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using a valid [Calibre](https://calibre-ebook.com) database.
|
||||
|
||||
[![GitHub License](https://img.shields.io/github/license/janeczku/calibre-web?style=flat-square)](https://github.com/janeczku/calibre-web/blob/master/LICENSE)
|
||||
[![GitHub commit activity](https://img.shields.io/github/commit-activity/w/janeczku/calibre-web?logo=github&style=flat-square&label=commits)]()
|
||||
@ -19,7 +19,7 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
|
||||
- full graphical setup
|
||||
- User management with fine-grained per-user permissions
|
||||
- Admin interface
|
||||
- User Interface in brazilian, czech, dutch, english, finnish, french, german, greek, hungarian, italian, japanese, khmer, polish, russian, simplified and traditional chinese, spanish, swedish, turkish, ukrainian
|
||||
- User Interface in brazilian, czech, dutch, english, finnish, french, german, greek, hungarian, italian, japanese, khmer, korean, polish, russian, simplified and traditional chinese, spanish, swedish, turkish, ukrainian
|
||||
- OPDS feed for eBook reader apps
|
||||
- Filter and search by titles, authors, tags, series and language
|
||||
- Create a custom book collection (shelves)
|
||||
@ -40,23 +40,20 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
|
||||
## Installation
|
||||
|
||||
#### Installation via pip (recommended)
|
||||
1. Install calibre web via pip with the command `pip install calibreweb` (Depending on your OS and or distro the command could also be `pip3`).
|
||||
2. Optional features can also be installed via pip, please refer to [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-Windows) for details
|
||||
3. Calibre-Web can be started afterwards by typing `cps` or `python3 -m cps`
|
||||
1. To avoid problems with already installed python dependencies, it's recommended to create a virtual environment for Calibre-Web
|
||||
2. Install Calibre-Web via pip with the command `pip install calibreweb` (Depending on your OS and or distro the command could also be `pip3`).
|
||||
3. Optional features can also be installed via pip, please refer to [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-Windows) for details
|
||||
4. Calibre-Web can be started afterwards by typing `cps`
|
||||
|
||||
#### Manual installation
|
||||
1. Install dependencies by running `pip3 install --target vendor -r requirements.txt` (python3.x). Alternativly set up a python virtual environment.
|
||||
2. Execute the command: `python3 cps.py` (or `nohup python3 cps.py` - recommended if you want to exit the terminal window)
|
||||
|
||||
Issues with Ubuntu:
|
||||
Please note that running the above install command can fail on some versions of Ubuntu, saying `"can't combine user with prefix"`. This is a [known bug](https://github.com/pypa/pip/issues/3826) and can be remedied by using the command `pip install --system --target vendor -r requirements.txt` instead.
|
||||
In the Wiki there are also examples for: a [manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation), [installation on Linux Mint](https://github.com/janeczku/calibre-web/wiki/How-To:Install-Calibre-Web-in-Linux-Mint-19-or-20), [installation on a Cloud Provider](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider).
|
||||
|
||||
## Quick start
|
||||
|
||||
Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
||||
Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button\
|
||||
Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/Configuration#using-google-drive-integration)
|
||||
Go to Login page
|
||||
Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog \
|
||||
Login with default admin login \
|
||||
Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button \
|
||||
Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/Configuration#using-google-drive-integration) \
|
||||
Afterwards you can configure your Calibre-Web instance ([Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) on admin page)
|
||||
|
||||
#### Default admin login:
|
||||
*Username:* admin\
|
||||
@ -71,7 +68,7 @@ Optionally, to enable on-the-fly conversion from one ebook format to another whe
|
||||
|
||||
[Download and install](https://calibre-ebook.com/download) the Calibre desktop program for your platform and enter the folder including program name (normally /opt/calibre/ebook-convert, or C:\Program Files\calibre\ebook-convert.exe) in the field "calibre's converter tool" on the setup page.
|
||||
|
||||
[Download](https://github.com/pgaskin/kepubify/releases/latest) Kepubify tool for your platform and place the binary starting with `kepubify` in Linux: `\opt\kepubify` Windows: `C:\Program Files\kepubify`.
|
||||
[Download](https://github.com/pgaskin/kepubify/releases/latest) Kepubify tool for your platform and place the binary starting with `kepubify` in Linux: `/opt/kepubify` Windows: `C:\Program Files\kepubify`.
|
||||
|
||||
## Docker Images
|
||||
|
||||
|
45
SECURITY.md
45
SECURITY.md
@ -10,23 +10,36 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
|
||||
|
||||
## History
|
||||
|
||||
| Fixed in | Description |CVE number |
|
||||
| ---------- |---------|---------|
|
||||
| 3rd July 2018 | Guest access acts as a backdoor||
|
||||
| V 0.6.7 |Hardcoded secret key for sessions |CVE-2020-12627 |
|
||||
| V 0.6.13|Calibre-Web Metadata cross site scripting |CVE-2021-25964|
|
||||
| V 0.6.13|Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo||
|
||||
| V 0.6.13|JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource)||
|
||||
| V 0.6.13|JavaScript could get executed in a custom column of type "comment" field ||
|
||||
| V 0.6.13|JavaScript could get executed after converting a book to another format with a title containing javascript code||
|
||||
| V 0.6.13|JavaScript could get executed after converting a book to another format with a username containing javascript code||
|
||||
| V 0.6.13|JavaScript could get executed in the description series, categories or publishers title||
|
||||
| V 0.6.13|JavaScript could get executed in the shelf title||
|
||||
| V 0.6.13|Login with the old session cookie after logout. Thanks to @ibarrionuevo||
|
||||
| V 0.6.14|CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
|
||||
| V 0.6.14|Cross-Site Scripting vulnerability on typeahead inputs. Thanks to @notdodo||
|
||||
| Fixed in | Description |CVE number |
|
||||
|---------------|--------------------------------------------------------------------------------------------------------------------|---------|
|
||||
| 3rd July 2018 | Guest access acts as a backdoor ||
|
||||
| V 0.6.7 | Hardcoded secret key for sessions |CVE-2020-12627 |
|
||||
| V 0.6.13 | Calibre-Web Metadata cross site scripting |CVE-2021-25964|
|
||||
| V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo ||
|
||||
| V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) ||
|
||||
| V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field ||
|
||||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code ||
|
||||
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code ||
|
||||
| V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title ||
|
||||
| V 0.6.13 | JavaScript could get executed in the shelf title ||
|
||||
| V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
|
||||
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
|
||||
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
|
||||
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
|
||||
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
|
||||
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
|
||||
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
|
||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
|
||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
|
||||
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) ||
|
||||
| V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939|
|
||||
| V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990|
|
||||
|
||||
|
||||
## Staement regarding Log4j (CVE-2021-44228 and related)
|
||||
## Statement regarding Log4j (CVE-2021-44228 and related)
|
||||
|
||||
Calibre-web is not affected by bugs related to Log4j. Calibre-Web is a python program, therefore not using Java, and not using the Java logging feature log4j.
|
||||
|
71
cps.py
71
cps.py
@ -2,7 +2,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2012-2019 OzzieIsaacs
|
||||
# Copyright (C) 2022 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@ -16,72 +16,19 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
try:
|
||||
from gevent import monkey
|
||||
monkey.patch_all()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
# Insert local directories into path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor'))
|
||||
|
||||
|
||||
from cps import create_app
|
||||
from cps import web_server
|
||||
from cps.opds import opds
|
||||
from cps.web import web
|
||||
from cps.jinjia import jinjia
|
||||
from cps.about import about
|
||||
from cps.shelf import shelf
|
||||
from cps.admin import admi
|
||||
from cps.gdrive import gdrive
|
||||
from cps.editbooks import editbook
|
||||
from cps.remotelogin import remotelogin
|
||||
from cps.search_metadata import meta
|
||||
from cps.error_handler import init_errorhandler
|
||||
|
||||
try:
|
||||
from cps.kobo import kobo, get_kobo_activated
|
||||
from cps.kobo_auth import kobo_auth
|
||||
kobo_available = get_kobo_activated()
|
||||
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
||||
kobo_available = False
|
||||
|
||||
try:
|
||||
from cps.oauth_bb import oauth
|
||||
oauth_available = True
|
||||
except ImportError:
|
||||
oauth_available = False
|
||||
|
||||
|
||||
def main():
|
||||
app = create_app()
|
||||
|
||||
init_errorhandler()
|
||||
|
||||
app.register_blueprint(web)
|
||||
app.register_blueprint(opds)
|
||||
app.register_blueprint(jinjia)
|
||||
app.register_blueprint(about)
|
||||
app.register_blueprint(shelf)
|
||||
app.register_blueprint(admi)
|
||||
app.register_blueprint(remotelogin)
|
||||
app.register_blueprint(meta)
|
||||
app.register_blueprint(gdrive)
|
||||
app.register_blueprint(editbook)
|
||||
if kobo_available:
|
||||
app.register_blueprint(kobo)
|
||||
app.register_blueprint(kobo_auth)
|
||||
if oauth_available:
|
||||
app.register_blueprint(oauth)
|
||||
success = web_server.start()
|
||||
sys.exit(0 if success else 1)
|
||||
# Add local path to sys.path so we can import cps
|
||||
path = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, path)
|
||||
|
||||
from cps.main import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
|
||||
|
@ -27,8 +27,9 @@ from flask import session
|
||||
|
||||
class MyLoginManager(LoginManager):
|
||||
def _session_protection_failed(self):
|
||||
sess = session._get_current_object()
|
||||
_session = session._get_current_object()
|
||||
ident = self._session_identifier_generator()
|
||||
if(sess and not (len(sess) == 1 and sess.get('csrf_token', None))) and ident != sess.get('_id', None):
|
||||
if(_session and not (len(_session) == 1
|
||||
and _session.get('csrf_token', None))) and ident != _session.get('_id', None):
|
||||
return super(). _session_protection_failed()
|
||||
return False
|
||||
|
137
cps/__init__.py
137
cps/__init__.py
@ -25,24 +25,21 @@ import sys
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
from babel import Locale as LC
|
||||
from babel import negotiate_locale
|
||||
from babel.core import UnknownLocaleError
|
||||
from flask import Flask, request, g
|
||||
from flask import Flask
|
||||
from .MyLoginManager import MyLoginManager
|
||||
from flask_babel import Babel
|
||||
from flask_principal import Principal
|
||||
|
||||
from . import config_sql, logger, cache_buster, cli, ub, db
|
||||
from . import logger
|
||||
from .cli import CliParameter
|
||||
from .constants import CONFIG_DIR
|
||||
from .reverseproxy import ReverseProxied
|
||||
from .server import WebServer
|
||||
from .dep_check import dependency_check
|
||||
|
||||
try:
|
||||
import lxml
|
||||
lxml_present = True
|
||||
except ImportError:
|
||||
lxml_present = False
|
||||
from .updater import Updater
|
||||
from .babel import babel
|
||||
from . import config_sql
|
||||
from . import cache_buster
|
||||
from . import ub, db
|
||||
|
||||
try:
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
@ -50,6 +47,7 @@ try:
|
||||
except ImportError:
|
||||
wtf_present = False
|
||||
|
||||
|
||||
mimetypes.init()
|
||||
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
||||
mimetypes.add_type('application/epub+zip', '.epub')
|
||||
@ -71,6 +69,8 @@ mimetypes.add_type('application/ogg', '.oga')
|
||||
mimetypes.add_type('text/css', '.css')
|
||||
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
||||
|
||||
log = logger.create()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
@ -79,61 +79,72 @@ app.config.update(
|
||||
WTF_CSRF_SSL_STRICT=False
|
||||
)
|
||||
|
||||
|
||||
lm = MyLoginManager()
|
||||
lm.login_view = 'web.login'
|
||||
lm.anonymous_user = ub.Anonymous
|
||||
lm.session_protection = 'strong'
|
||||
|
||||
config = config_sql._ConfigSQL()
|
||||
|
||||
cli_param = CliParameter()
|
||||
|
||||
if wtf_present:
|
||||
csrf = CSRFProtect()
|
||||
csrf.init_app(app)
|
||||
else:
|
||||
csrf = None
|
||||
|
||||
ub.init_db(cli.settingspath)
|
||||
# pylint: disable=no-member
|
||||
config = config_sql.load_configuration(ub.session)
|
||||
calibre_db = db.CalibreDB()
|
||||
|
||||
web_server = WebServer()
|
||||
|
||||
babel = Babel()
|
||||
_BABEL_TRANSLATIONS = set()
|
||||
updater_thread = Updater()
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
from . import services
|
||||
|
||||
db.CalibreDB.update_config(config)
|
||||
db.CalibreDB.setup_db(config.config_calibre_dir, cli.settingspath)
|
||||
|
||||
|
||||
calibre_db = db.CalibreDB()
|
||||
|
||||
def create_app():
|
||||
lm.login_view = 'web.login'
|
||||
lm.anonymous_user = ub.Anonymous
|
||||
lm.session_protection = 'strong'
|
||||
|
||||
if csrf:
|
||||
csrf.init_app(app)
|
||||
|
||||
cli_param.init()
|
||||
|
||||
ub.init_db(cli_param.settings_path, cli_param.user_credentials)
|
||||
|
||||
# pylint: disable=no-member
|
||||
config_sql.load_configuration(config, ub.session, cli_param)
|
||||
|
||||
db.CalibreDB.update_config(config)
|
||||
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
||||
calibre_db.init_db()
|
||||
|
||||
updater_thread.init_updater(config, web_server)
|
||||
# Perform dry run of updater and exit afterwards
|
||||
if cli_param.dry_run:
|
||||
updater_thread.dry_run()
|
||||
sys.exit(0)
|
||||
updater_thread.start()
|
||||
|
||||
if sys.version_info < (3, 0):
|
||||
log.info(
|
||||
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, please update your installation to Python3 ***')
|
||||
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
|
||||
'please update your installation to Python3 ***')
|
||||
print(
|
||||
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, please update your installation to Python3 ***')
|
||||
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
|
||||
'please update your installation to Python3 ***')
|
||||
web_server.stop(True)
|
||||
sys.exit(5)
|
||||
if not lxml_present:
|
||||
log.info('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***')
|
||||
print('*** "lxml" is needed for calibre-web to run. Please install it using pip: "pip install lxml" ***')
|
||||
web_server.stop(True)
|
||||
sys.exit(6)
|
||||
if not wtf_present:
|
||||
log.info('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***')
|
||||
print('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***')
|
||||
log.info('*** "flask-WTF" is needed for calibre-web to run. '
|
||||
'Please install it using pip: "pip install flask-WTF" ***')
|
||||
print('*** "flask-WTF" is needed for calibre-web to run. '
|
||||
'Please install it using pip: "pip install flask-WTF" ***')
|
||||
web_server.stop(True)
|
||||
sys.exit(7)
|
||||
for res in dependency_check() + dependency_check(True):
|
||||
log.info('*** "{}" version does not fit the requirements. Should: {}, Found: {}, please consider installing required version ***'
|
||||
.format(res['name'],
|
||||
res['target'],
|
||||
res['found']))
|
||||
log.info('*** "{}" version does not fit the requirements. '
|
||||
'Should: {}, Found: {}, please consider installing required version ***'
|
||||
.format(res['name'],
|
||||
res['target'],
|
||||
res['found']))
|
||||
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||
|
||||
if os.environ.get('FLASK_DEBUG'):
|
||||
@ -147,8 +158,8 @@ def create_app():
|
||||
web_server.init_app(app, config)
|
||||
|
||||
babel.init_app(app)
|
||||
_BABEL_TRANSLATIONS.update(str(item) for item in babel.list_translations())
|
||||
_BABEL_TRANSLATIONS.add('en')
|
||||
|
||||
from . import services
|
||||
|
||||
if services.ldap:
|
||||
services.ldap.init_app(app, config)
|
||||
@ -156,34 +167,12 @@ def create_app():
|
||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||
config.config_goodreads_api_secret,
|
||||
config.config_use_goodreads)
|
||||
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||
# Register scheduled tasks
|
||||
from .schedule import register_scheduled_tasks, register_startup_tasks
|
||||
register_scheduled_tasks(config.schedule_reconnect)
|
||||
register_startup_tasks()
|
||||
|
||||
return app
|
||||
|
||||
@babel.localeselector
|
||||
def get_locale():
|
||||
# if a user is logged in, use the locale from the user settings
|
||||
user = getattr(g, 'user', None)
|
||||
if user is not None and hasattr(user, "locale"):
|
||||
if user.name != 'Guest': # if the account is the guest account bypass the config lang settings
|
||||
return user.locale
|
||||
|
||||
preferred = list()
|
||||
if request.accept_languages:
|
||||
for x in request.accept_languages.values():
|
||||
try:
|
||||
preferred.append(str(LC.parse(x.replace('-', '_'))))
|
||||
except (UnknownLocaleError, ValueError) as e:
|
||||
log.debug('Could not parse locale "%s": %s', x, e)
|
||||
|
||||
return negotiate_locale(preferred or ['en'], _BABEL_TRANSLATIONS)
|
||||
|
||||
|
||||
@babel.timezoneselector
|
||||
def get_timezone():
|
||||
user = getattr(g, 'user', None)
|
||||
return user.timezone if user else None
|
||||
|
||||
|
||||
from .updater import Updater
|
||||
updater_thread = Updater()
|
||||
updater_thread.start()
|
||||
|
116
cps/about.py
116
cps/about.py
@ -25,46 +25,15 @@ import platform
|
||||
import sqlite3
|
||||
from collections import OrderedDict
|
||||
|
||||
import babel, pytz, requests, sqlalchemy
|
||||
import werkzeug, flask, flask_login, flask_principal, jinja2
|
||||
import werkzeug
|
||||
import flask
|
||||
import flask_login
|
||||
import jinja2
|
||||
from flask_babel import gettext as _
|
||||
try:
|
||||
from flask_wtf import __version__ as flaskwtf_version
|
||||
except ImportError:
|
||||
flaskwtf_version = _(u'not installed')
|
||||
|
||||
from . import db, calibre_db, converter, uploader, server, isoLanguages, constants, gdriveutils, dep_check
|
||||
from . import db, calibre_db, converter, uploader, constants, dep_check
|
||||
from .render_template import render_title_template
|
||||
|
||||
try:
|
||||
from flask_login import __version__ as flask_loginVersion
|
||||
except ImportError:
|
||||
from flask_login.__about__ import __version__ as flask_loginVersion
|
||||
try:
|
||||
# pylint: disable=unused-import
|
||||
import unidecode
|
||||
# _() necessary to make babel aware of string for translation
|
||||
unidecode_version = _(u'installed')
|
||||
except ImportError:
|
||||
unidecode_version = _(u'not installed')
|
||||
|
||||
try:
|
||||
from flask_dance import __version__ as flask_danceVersion
|
||||
except ImportError:
|
||||
flask_danceVersion = None
|
||||
|
||||
try:
|
||||
from greenlet import __version__ as greenlet_Version
|
||||
except ImportError:
|
||||
greenlet_Version = None
|
||||
|
||||
try:
|
||||
from scholarly import scholarly
|
||||
scholarly_version = _(u'installed')
|
||||
except ImportError:
|
||||
scholarly_version = _(u'not installed')
|
||||
|
||||
from . import services
|
||||
|
||||
about = flask.Blueprint('about', __name__)
|
||||
|
||||
@ -74,59 +43,38 @@ opt = dep_check.load_dependencys(True)
|
||||
for i in (req + opt):
|
||||
ret[i[1]] = i[0]
|
||||
|
||||
if not ret:
|
||||
_VERSIONS = OrderedDict(
|
||||
Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()),
|
||||
Python=sys.version,
|
||||
Calibre_Web=constants.STABLE_VERSION['version'] + ' - '
|
||||
+ constants.NIGHTLY_VERSION[0].replace('%','%%') + ' - '
|
||||
+ constants.NIGHTLY_VERSION[1].replace('%','%%'),
|
||||
WebServer=server.VERSION,
|
||||
Flask=flask.__version__,
|
||||
Flask_Login=flask_loginVersion,
|
||||
Flask_Principal=flask_principal.__version__,
|
||||
Flask_WTF=flaskwtf_version,
|
||||
Werkzeug=werkzeug.__version__,
|
||||
Babel=babel.__version__,
|
||||
Jinja2=jinja2.__version__,
|
||||
Requests=requests.__version__,
|
||||
SqlAlchemy=sqlalchemy.__version__,
|
||||
pySqlite=sqlite3.version,
|
||||
SQLite=sqlite3.sqlite_version,
|
||||
iso639=isoLanguages.__version__,
|
||||
pytz=pytz.__version__,
|
||||
Unidecode=unidecode_version,
|
||||
Scholarly=scholarly_version,
|
||||
Flask_SimpleLDAP=u'installed' if bool(services.ldap) else None,
|
||||
python_LDAP=services.ldapVersion if bool(services.ldapVersion) else None,
|
||||
Goodreads=u'installed' if bool(services.goodreads_support) else None,
|
||||
jsonschema=services.SyncToken.__version__ if bool(services.SyncToken) else None,
|
||||
flask_dance=flask_danceVersion,
|
||||
greenlet=greenlet_Version
|
||||
)
|
||||
_VERSIONS.update(gdriveutils.get_versions())
|
||||
_VERSIONS.update(uploader.get_versions(True))
|
||||
if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
|
||||
calibre_web_version = constants.STABLE_VERSION['version']
|
||||
else:
|
||||
_VERSIONS = OrderedDict(
|
||||
Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()),
|
||||
Python = sys.version,
|
||||
Calibre_Web = constants.STABLE_VERSION['version'] + ' - '
|
||||
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
||||
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'),
|
||||
Werkzeug = werkzeug.__version__,
|
||||
Jinja2=jinja2.__version__,
|
||||
pySqlite = sqlite3.version,
|
||||
SQLite = sqlite3.sqlite_version,
|
||||
)
|
||||
_VERSIONS.update(ret)
|
||||
_VERSIONS.update(uploader.get_versions(False))
|
||||
calibre_web_version = (constants.STABLE_VERSION['version'] + ' - '
|
||||
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
||||
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
calibre_web_version += " - Exe-Version"
|
||||
elif constants.HOME_CONFIG:
|
||||
calibre_web_version += " - pyPi"
|
||||
|
||||
_VERSIONS = OrderedDict(
|
||||
Platform='{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()),
|
||||
Python=sys.version,
|
||||
Calibre_Web=calibre_web_version,
|
||||
Werkzeug=werkzeug.__version__,
|
||||
Jinja2=jinja2.__version__,
|
||||
pySqlite=sqlite3.version,
|
||||
SQLite=sqlite3.sqlite_version,
|
||||
)
|
||||
_VERSIONS.update(ret)
|
||||
_VERSIONS.update(uploader.get_versions())
|
||||
|
||||
|
||||
def collect_stats():
|
||||
_VERSIONS['ebook converter'] = _(converter.get_calibre_version())
|
||||
_VERSIONS['unrar'] = _(converter.get_unrar_version())
|
||||
_VERSIONS['kepubify'] = _(converter.get_kepubify_version())
|
||||
_VERSIONS['ebook converter'] = converter.get_calibre_version()
|
||||
_VERSIONS['unrar'] = converter.get_unrar_version()
|
||||
_VERSIONS['kepubify'] = converter.get_kepubify_version()
|
||||
return _VERSIONS
|
||||
|
||||
|
||||
@about.route("/stats")
|
||||
@flask_login.login_required
|
||||
def stats():
|
||||
|
1108
cps/admin.py
1108
cps/admin.py
File diff suppressed because it is too large
Load Diff
39
cps/babel.py
Normal file
39
cps/babel.py
Normal file
@ -0,0 +1,39 @@
|
||||
from babel import negotiate_locale
|
||||
from flask_babel import Babel, Locale
|
||||
from babel.core import UnknownLocaleError
|
||||
from flask import request, g
|
||||
|
||||
from . import logger
|
||||
|
||||
log = logger.create()
|
||||
|
||||
babel = Babel()
|
||||
|
||||
|
||||
@babel.localeselector
|
||||
def get_locale():
|
||||
# if a user is logged in, use the locale from the user settings
|
||||
user = getattr(g, 'user', None)
|
||||
if user is not None and hasattr(user, "locale"):
|
||||
if user.name != 'Guest': # if the account is the guest account bypass the config lang settings
|
||||
return user.locale
|
||||
|
||||
preferred = list()
|
||||
if request.accept_languages:
|
||||
for x in request.accept_languages.values():
|
||||
try:
|
||||
preferred.append(str(Locale.parse(x.replace('-', '_'))))
|
||||
except (UnknownLocaleError, ValueError) as e:
|
||||
log.debug('Could not parse locale "%s": %s', x, e)
|
||||
|
||||
return negotiate_locale(preferred or ['en'], get_available_translations())
|
||||
|
||||
|
||||
def get_user_locale_language(user_language):
|
||||
return Locale.parse(user_language).get_language_name(get_locale())
|
||||
|
||||
def get_available_locale():
|
||||
return [Locale('en')] + babel.list_translations()
|
||||
|
||||
def get_available_translations():
|
||||
return set(str(item) for item in get_available_locale())
|
@ -47,13 +47,16 @@ def init_cache_busting(app):
|
||||
for filename in filenames:
|
||||
# compute version component
|
||||
rooted_filename = os.path.join(dirpath, filename)
|
||||
with open(rooted_filename, 'rb') as f:
|
||||
file_hash = hashlib.md5(f.read()).hexdigest()[:7] # nosec
|
||||
try:
|
||||
with open(rooted_filename, 'rb') as f:
|
||||
file_hash = hashlib.md5(f.read()).hexdigest()[:7] # nosec
|
||||
# save version to tables
|
||||
file_path = rooted_filename.replace(static_folder, "")
|
||||
file_path = file_path.replace("\\", "/") # Convert Windows path to web path
|
||||
hash_table[file_path] = file_hash
|
||||
except PermissionError:
|
||||
log.error("No permission to access {} file.".format(rooted_filename))
|
||||
|
||||
# save version to tables
|
||||
file_path = rooted_filename.replace(static_folder, "")
|
||||
file_path = file_path.replace("\\", "/") # Convert Windows path to web path
|
||||
hash_table[file_path] = file_hash
|
||||
log.debug('Finished computing cache-busting values')
|
||||
|
||||
def bust_filename(filename):
|
||||
|
150
cps/cli.py
150
cps/cli.py
@ -24,82 +24,106 @@ import socket
|
||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||
from .constants import STABLE_VERSION as _STABLE_VERSION
|
||||
from .constants import NIGHTLY_VERSION as _NIGHTLY_VERSION
|
||||
|
||||
from .constants import DEFAULT_SETTINGS_FILE, DEFAULT_GDRIVE_FILE
|
||||
|
||||
def version_info():
|
||||
if _NIGHTLY_VERSION[1].startswith('$Format'):
|
||||
return "Calibre-Web version: %s - unkown git-clone" % _STABLE_VERSION['version']
|
||||
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1])
|
||||
|
||||
class CliParameter(object):
|
||||
|
||||
parser = argparse.ArgumentParser(description='Calibre Web is a web app'
|
||||
' providing a interface for browsing, reading and downloading eBooks\n', prog='cps.py')
|
||||
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
|
||||
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
|
||||
parser.add_argument('-c', metavar='path',
|
||||
help='path and name to SSL certfile, e.g. /opt/test.cert, works only in combination with keyfile')
|
||||
parser.add_argument('-k', metavar='path',
|
||||
help='path and name to SSL keyfile, e.g. /opt/test.key, works only in combination with certfile')
|
||||
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-web',
|
||||
version=version_info())
|
||||
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
||||
parser.add_argument('-s', metavar='user:pass', help='Sets specific username to new password')
|
||||
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
|
||||
args = parser.parse_args()
|
||||
def init(self):
|
||||
self.arg_parser()
|
||||
|
||||
settingspath = args.p or os.path.join(_CONFIG_DIR, "app.db")
|
||||
gdpath = args.g or os.path.join(_CONFIG_DIR, "gdrive.db")
|
||||
def arg_parser(self):
|
||||
parser = argparse.ArgumentParser(description='Calibre Web is a web app'
|
||||
' providing a interface for browsing, reading and downloading eBooks\n',
|
||||
prog='cps.py')
|
||||
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
|
||||
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
|
||||
parser.add_argument('-c', metavar='path',
|
||||
help='path and name to SSL certfile, e.g. /opt/test.cert, works only in combination with keyfile')
|
||||
parser.add_argument('-k', metavar='path',
|
||||
help='path and name to SSL keyfile, e.g. /opt/test.key, works only in combination with certfile')
|
||||
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
|
||||
version=version_info())
|
||||
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
||||
parser.add_argument('-s', metavar='user:pass',
|
||||
help='Sets specific username to new password and exits Calibre-Web')
|
||||
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
|
||||
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
|
||||
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions in advance '
|
||||
'and exits Calibre-Web')
|
||||
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
|
||||
args = parser.parse_args()
|
||||
|
||||
# handle and check parameter for ssl encryption
|
||||
certfilepath = None
|
||||
keyfilepath = None
|
||||
if args.c:
|
||||
if os.path.isfile(args.c):
|
||||
certfilepath = args.c
|
||||
else:
|
||||
print("Certfile path is invalid. Exiting...")
|
||||
sys.exit(1)
|
||||
self.settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE)
|
||||
self.gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE)
|
||||
|
||||
if args.c == "":
|
||||
certfilepath = ""
|
||||
if os.path.isdir(self.settings_path):
|
||||
self.settings_path = os.path.join(self.settings_path, DEFAULT_SETTINGS_FILE)
|
||||
|
||||
if args.k:
|
||||
if os.path.isfile(args.k):
|
||||
keyfilepath = args.k
|
||||
else:
|
||||
print("Keyfile path is invalid. Exiting...")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(self.gd_path):
|
||||
self.gd_path = os.path.join(self.gd_path, DEFAULT_GDRIVE_FILE)
|
||||
|
||||
if (args.k and not args.c) or (not args.k and args.c):
|
||||
print("Certfile and Keyfile have to be used together. Exiting...")
|
||||
sys.exit(1)
|
||||
|
||||
if args.k == "":
|
||||
keyfilepath = ""
|
||||
|
||||
# handle and check ip address argument
|
||||
ip_address = args.i or None
|
||||
if ip_address:
|
||||
try:
|
||||
# try to parse the given ip address with socket
|
||||
if hasattr(socket, 'inet_pton'):
|
||||
if ':' in ip_address:
|
||||
socket.inet_pton(socket.AF_INET6, ip_address)
|
||||
# handle and check parameter for ssl encryption
|
||||
self.certfilepath = None
|
||||
self.keyfilepath = None
|
||||
if args.c:
|
||||
if os.path.isfile(args.c):
|
||||
self.certfilepath = args.c
|
||||
else:
|
||||
socket.inet_pton(socket.AF_INET, ip_address)
|
||||
else:
|
||||
# on windows python < 3.4, inet_pton is not available
|
||||
# inet_atom only handles IPv4 addresses
|
||||
socket.inet_aton(ip_address)
|
||||
except socket.error as err:
|
||||
print(ip_address, ':', err)
|
||||
sys.exit(1)
|
||||
print("Certfile path is invalid. Exiting...")
|
||||
sys.exit(1)
|
||||
|
||||
# handle and check user password argument
|
||||
user_credentials = args.s or None
|
||||
if user_credentials and ":" not in user_credentials:
|
||||
print("No valid 'username:password' format")
|
||||
sys.exit(3)
|
||||
if args.c == "":
|
||||
self.certfilepath = ""
|
||||
|
||||
if args.f:
|
||||
print("Warning: -f flag is depreciated and will be removed in next version")
|
||||
if args.k:
|
||||
if os.path.isfile(args.k):
|
||||
self.keyfilepath = args.k
|
||||
else:
|
||||
print("Keyfile path is invalid. Exiting...")
|
||||
sys.exit(1)
|
||||
|
||||
if (args.k and not args.c) or (not args.k and args.c):
|
||||
print("Certfile and Keyfile have to be used together. Exiting...")
|
||||
sys.exit(1)
|
||||
|
||||
if args.k == "":
|
||||
self.keyfilepath = ""
|
||||
|
||||
# dry run updater
|
||||
self.dry_run =args.d or None
|
||||
# enable reconnect endpoint for docker database reconnect
|
||||
self.reconnect_enable = args.r or os.environ.get("CALIBRE_RECONNECT", None)
|
||||
# load covers from localhost
|
||||
self.allow_localhost = args.l or os.environ.get("CALIBRE_LOCALHOST", None)
|
||||
# handle and check ip address argument
|
||||
self.ip_address = args.i or None
|
||||
if self.ip_address:
|
||||
try:
|
||||
# try to parse the given ip address with socket
|
||||
if hasattr(socket, 'inet_pton'):
|
||||
if ':' in self.ip_address:
|
||||
socket.inet_pton(socket.AF_INET6, self.ip_address)
|
||||
else:
|
||||
socket.inet_pton(socket.AF_INET, self.ip_address)
|
||||
else:
|
||||
# on windows python < 3.4, inet_pton is not available
|
||||
# inet_atom only handles IPv4 addresses
|
||||
socket.inet_aton(self.ip_address)
|
||||
except socket.error as err:
|
||||
print(self.ip_address, ':', err)
|
||||
sys.exit(1)
|
||||
|
||||
# handle and check user password argument
|
||||
self.user_credentials = args.s or None
|
||||
if self.user_credentials and ":" not in self.user_credentials:
|
||||
print("No valid 'username:password' format")
|
||||
sys.exit(3)
|
||||
|
||||
if args.f:
|
||||
print("Warning: -f flag is depreciated and will be removed in next version")
|
||||
|
92
cps/comic.py
92
cps/comic.py
@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018 OzzieIsaacs
|
||||
# Copyright (C) 2018-2022 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@ -18,19 +18,16 @@
|
||||
|
||||
import os
|
||||
|
||||
from . import logger, isoLanguages
|
||||
from . import logger, isoLanguages, cover
|
||||
from .constants import BookMeta
|
||||
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
try:
|
||||
from wand.image import Image
|
||||
use_IM = True
|
||||
except (ImportError, RuntimeError) as e:
|
||||
use_IM = False
|
||||
|
||||
log = logger.create()
|
||||
|
||||
try:
|
||||
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
||||
@ -51,37 +48,16 @@ except (ImportError, LookupError) as e:
|
||||
use_rarfile = False
|
||||
use_comic_meta = False
|
||||
|
||||
NO_JPEG_EXTENSIONS = ['.png', '.webp', '.bmp']
|
||||
COVER_EXTENSIONS = ['.png', '.webp', '.bmp', '.jpg', '.jpeg']
|
||||
|
||||
def _cover_processing(tmp_file_name, img, extension):
|
||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_name), 'cover.jpg')
|
||||
if use_IM:
|
||||
# convert to jpg because calibre only supports jpg
|
||||
if extension in NO_JPEG_EXTENSIONS:
|
||||
with Image(filename=tmp_file_name) as imgc:
|
||||
imgc.format = 'jpeg'
|
||||
imgc.transform_colorspace('rgb')
|
||||
imgc.save(tmp_cover_name)
|
||||
return tmp_cover_name
|
||||
|
||||
if not img:
|
||||
return None
|
||||
|
||||
with open(tmp_cover_name, 'wb') as f:
|
||||
f.write(img)
|
||||
return tmp_cover_name
|
||||
|
||||
|
||||
def _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecutable):
|
||||
cover_data = None
|
||||
def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable):
|
||||
cover_data = extension = None
|
||||
if original_file_extension.upper() == '.CBZ':
|
||||
cf = zipfile.ZipFile(tmp_file_name)
|
||||
for name in cf.namelist():
|
||||
ext = os.path.splitext(name)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension in COVER_EXTENSIONS:
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
cover_data = cf.read(name)
|
||||
break
|
||||
elif original_file_extension.upper() == '.CBT':
|
||||
@ -90,44 +66,44 @@ def _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecu
|
||||
ext = os.path.splitext(name)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension in COVER_EXTENSIONS:
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
cover_data = cf.extractfile(name).read()
|
||||
break
|
||||
elif original_file_extension.upper() == '.CBR' and use_rarfile:
|
||||
try:
|
||||
rarfile.UNRAR_TOOL = rarExecutable
|
||||
rarfile.UNRAR_TOOL = rar_executable
|
||||
cf = rarfile.RarFile(tmp_file_name)
|
||||
for name in cf.getnames():
|
||||
for name in cf.namelist():
|
||||
ext = os.path.splitext(name)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension in COVER_EXTENSIONS:
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
cover_data = cf.read(name)
|
||||
break
|
||||
except Exception as ex:
|
||||
log.debug('Rarfile failed with error: %s', ex)
|
||||
return cover_data
|
||||
log.debug('Rarfile failed with error: {}'.format(ex))
|
||||
return cover_data, extension
|
||||
|
||||
|
||||
def _extractCover(tmp_file_name, original_file_extension, rarExecutable):
|
||||
def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
|
||||
cover_data = extension = None
|
||||
if use_comic_meta:
|
||||
archive = ComicArchive(tmp_file_name, rar_exe_path=rarExecutable)
|
||||
archive = ComicArchive(tmp_file_name, rar_exe_path=rar_executable)
|
||||
for index, name in enumerate(archive.getPageNameList()):
|
||||
ext = os.path.splitext(name)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension in COVER_EXTENSIONS:
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
cover_data = archive.getPage(index)
|
||||
break
|
||||
else:
|
||||
cover_data = _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecutable)
|
||||
return _cover_processing(tmp_file_name, cover_data, extension)
|
||||
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable)
|
||||
return cover.cover_processing(tmp_file_name, cover_data, extension)
|
||||
|
||||
|
||||
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rarExecutable):
|
||||
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable):
|
||||
if use_comic_meta:
|
||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rarExecutable)
|
||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
|
||||
if archive.seemsToBeAComicArchive():
|
||||
if archive.hasMetadata(MetaDataStyle.CIX):
|
||||
style = MetaDataStyle.CIX
|
||||
@ -137,34 +113,38 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
|
||||
style = None
|
||||
|
||||
# if style is not None:
|
||||
loadedMetadata = archive.readMetadata(style)
|
||||
loaded_metadata = archive.readMetadata(style)
|
||||
|
||||
lang = loadedMetadata.language or ""
|
||||
loadedMetadata.language = isoLanguages.get_lang3(lang)
|
||||
lang = loaded_metadata.language or ""
|
||||
loaded_metadata.language = isoLanguages.get_lang3(lang)
|
||||
|
||||
return BookMeta(
|
||||
file_path=tmp_file_path,
|
||||
extension=original_file_extension,
|
||||
title=loadedMetadata.title or original_file_name,
|
||||
title=loaded_metadata.title or original_file_name,
|
||||
author=" & ".join([credit["person"]
|
||||
for credit in loadedMetadata.credits if credit["role"] == "Writer"]) or u'Unknown',
|
||||
cover=_extractCover(tmp_file_path, original_file_extension, rarExecutable),
|
||||
description=loadedMetadata.comments or "",
|
||||
for credit in loaded_metadata.credits if credit["role"] == "Writer"]) or 'Unknown',
|
||||
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||
description=loaded_metadata.comments or "",
|
||||
tags="",
|
||||
series=loadedMetadata.series or "",
|
||||
series_id=loadedMetadata.issue or "",
|
||||
languages=loadedMetadata.language,
|
||||
publisher="")
|
||||
series=loaded_metadata.series or "",
|
||||
series_id=loaded_metadata.issue or "",
|
||||
languages=loaded_metadata.language,
|
||||
publisher="",
|
||||
pubdate="",
|
||||
identifiers=[])
|
||||
|
||||
return BookMeta(
|
||||
file_path=tmp_file_path,
|
||||
extension=original_file_extension,
|
||||
title=original_file_name,
|
||||
author=u'Unknown',
|
||||
cover=_extractCover(tmp_file_path, original_file_extension, rarExecutable),
|
||||
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||
description="",
|
||||
tags="",
|
||||
series="",
|
||||
series_id="",
|
||||
languages="",
|
||||
publisher="")
|
||||
publisher="",
|
||||
pubdate="",
|
||||
identifiers=[])
|
||||
|
@ -29,7 +29,7 @@ try:
|
||||
except ImportError:
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from . import constants, cli, logger
|
||||
from . import constants, logger
|
||||
|
||||
|
||||
log = logger.create()
|
||||
@ -62,6 +62,7 @@ class _Settings(_Base):
|
||||
mail_gmail_token = Column(JSON, default={})
|
||||
|
||||
config_calibre_dir = Column(String)
|
||||
config_calibre_uuid = Column(String)
|
||||
config_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||
config_external_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||
config_certfile = Column(String)
|
||||
@ -133,13 +134,19 @@ class _Settings(_Base):
|
||||
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_unicode_filename = Column(Boolean, default=False)
|
||||
|
||||
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)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
@ -147,12 +154,16 @@ class _Settings(_Base):
|
||||
# Class holds all application specific settings in calibre-web
|
||||
class _ConfigSQL(object):
|
||||
# pylint: disable=no-member
|
||||
def __init__(self, session):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def init_config(self, session, cli):
|
||||
self._session = session
|
||||
self._settings = None
|
||||
self.db_configured = None
|
||||
self.config_calibre_dir = None
|
||||
self.load()
|
||||
self.cli = cli
|
||||
|
||||
change = False
|
||||
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
||||
@ -170,7 +181,6 @@ class _ConfigSQL(object):
|
||||
if change:
|
||||
self.save()
|
||||
|
||||
|
||||
def _read_from_storage(self):
|
||||
if self._settings is None:
|
||||
log.debug("_ConfigSQL._read_from_storage")
|
||||
@ -178,22 +188,21 @@ class _ConfigSQL(object):
|
||||
return self._settings
|
||||
|
||||
def get_config_certfile(self):
|
||||
if cli.certfilepath:
|
||||
return cli.certfilepath
|
||||
if cli.certfilepath == "":
|
||||
if self.cli.certfilepath:
|
||||
return self.cli.certfilepath
|
||||
if self.cli.certfilepath == "":
|
||||
return None
|
||||
return self.config_certfile
|
||||
|
||||
def get_config_keyfile(self):
|
||||
if cli.keyfilepath:
|
||||
return cli.keyfilepath
|
||||
if cli.certfilepath == "":
|
||||
if self.cli.keyfilepath:
|
||||
return self.cli.keyfilepath
|
||||
if self.cli.certfilepath == "":
|
||||
return None
|
||||
return self.config_keyfile
|
||||
|
||||
@staticmethod
|
||||
def get_config_ipaddress():
|
||||
return cli.ip_address or ""
|
||||
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)
|
||||
@ -254,6 +263,8 @@ class _ConfigSQL(object):
|
||||
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.
|
||||
@ -285,11 +296,10 @@ class _ConfigSQL(object):
|
||||
def toDict(self):
|
||||
storage = {}
|
||||
for k, v in self.__dict__.items():
|
||||
if k[0] != '_' and not k.endswith("password") and not k.endswith("secret"):
|
||||
if k[0] != '_' and not k.endswith("password") and not k.endswith("secret") and not k == "cli":
|
||||
storage[k] = v
|
||||
return storage
|
||||
|
||||
|
||||
def load(self):
|
||||
'''Load all configuration values from the underlying storage.'''
|
||||
s = self._read_from_storage() # type: _Settings
|
||||
@ -304,9 +314,8 @@ class _ConfigSQL(object):
|
||||
|
||||
have_metadata_db = bool(self.config_calibre_dir)
|
||||
if have_metadata_db:
|
||||
if not self.config_use_google_drive:
|
||||
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
||||
have_metadata_db = os.path.isfile(db_file)
|
||||
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
|
||||
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
||||
if os.environ.get('FLASK_DEBUG'):
|
||||
@ -351,6 +360,14 @@ class _ConfigSQL(object):
|
||||
# self.config_calibre_dir = None
|
||||
self.save()
|
||||
|
||||
def store_calibre_uuid(self, calibre_db, Library_table):
|
||||
try:
|
||||
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 _migrate_table(session, orm_class):
|
||||
changed = False
|
||||
@ -403,6 +420,7 @@ def autodetect_calibre_binary():
|
||||
return element
|
||||
return ""
|
||||
|
||||
|
||||
def autodetect_unrar_binary():
|
||||
if sys.platform == "win32":
|
||||
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
||||
@ -414,6 +432,7 @@ def autodetect_unrar_binary():
|
||||
return element
|
||||
return ""
|
||||
|
||||
|
||||
def autodetect_kepubify_binary():
|
||||
if sys.platform == "win32":
|
||||
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
||||
@ -425,6 +444,7 @@ def autodetect_kepubify_binary():
|
||||
return element
|
||||
return ""
|
||||
|
||||
|
||||
def _migrate_database(session):
|
||||
# make sure the table is created, if it does not exist
|
||||
_Base.metadata.create_all(session.bind)
|
||||
@ -432,26 +452,20 @@ def _migrate_database(session):
|
||||
_migrate_table(session, _Flask_Settings)
|
||||
|
||||
|
||||
def load_configuration(session):
|
||||
def load_configuration(conf, session, cli):
|
||||
_migrate_database(session)
|
||||
|
||||
if not session.query(_Settings).count():
|
||||
session.add(_Settings())
|
||||
session.commit()
|
||||
conf = _ConfigSQL(session)
|
||||
# Migrate from global restrictions to user based restrictions
|
||||
#if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
|
||||
# conf.config_denied_tags = conf.config_mature_content_tags
|
||||
# conf.save()
|
||||
# session.query(ub.User).filter(ub.User.mature_content != True). \
|
||||
# update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False)
|
||||
# session.commit()
|
||||
return conf
|
||||
# conf = _ConfigSQL()
|
||||
conf.init_config(session, cli)
|
||||
# return conf
|
||||
|
||||
def get_flask_session_key(session):
|
||||
flask_settings = session.query(_Flask_Settings).one_or_none()
|
||||
def get_flask_session_key(_session):
|
||||
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
||||
if flask_settings == None:
|
||||
flask_settings = _Flask_Settings(os.urandom(32))
|
||||
session.add(flask_settings)
|
||||
session.commit()
|
||||
_session.add(flask_settings)
|
||||
_session.commit()
|
||||
return flask_settings.flask_session_key
|
||||
|
@ -21,28 +21,37 @@ import os
|
||||
from collections import namedtuple
|
||||
from sqlalchemy import __version__ as sql_version
|
||||
|
||||
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2,0,0])
|
||||
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2, 0, 0])
|
||||
|
||||
# APP_MODE - production, development, or test
|
||||
APP_MODE = os.environ.get('APP_MODE', 'production')
|
||||
|
||||
# if installed via pip this variable is set to true (empty file with name .HOMEDIR present)
|
||||
HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR'))
|
||||
|
||||
#In executables updater is not available, so variable is set to False there
|
||||
# In executables updater is not available, so variable is set to False there
|
||||
UPDATER_AVAILABLE = True
|
||||
|
||||
# Base dir is parent of current file, necessary if called from different folder
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)),os.pardir))
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
|
||||
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
||||
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
|
||||
|
||||
# Cache dir - use CACHE_DIR environment variable, otherwise use the default directory: cps/cache
|
||||
DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache')
|
||||
CACHE_DIR = os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR)
|
||||
|
||||
if HOME_CONFIG:
|
||||
home_dir = os.path.join(os.path.expanduser("~"),".calibre-web")
|
||||
home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web")
|
||||
if not os.path.exists(home_dir):
|
||||
os.makedirs(home_dir)
|
||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
|
||||
else:
|
||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
|
||||
|
||||
DEFAULT_SETTINGS_FILE = "app.db"
|
||||
DEFAULT_GDRIVE_FILE = "gdrive.db"
|
||||
|
||||
ROLE_USER = 0 << 0
|
||||
ROLE_ADMIN = 1 << 0
|
||||
@ -133,11 +142,14 @@ except ValueError:
|
||||
del env_CALIBRE_PORT
|
||||
|
||||
|
||||
EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||
EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt','cbz','cbr']
|
||||
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
|
||||
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx',
|
||||
'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||
EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||
EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf',
|
||||
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr']
|
||||
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2',
|
||||
'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
|
||||
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu',
|
||||
'prc', 'doc', 'docx', 'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg',
|
||||
'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||
|
||||
|
||||
def has_flag(value, bit_flag):
|
||||
@ -149,16 +161,29 @@ def selected_roles(dictionary):
|
||||
|
||||
# :rtype: BookMeta
|
||||
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
|
||||
'series_id, languages, publisher')
|
||||
'series_id, languages, publisher, pubdate, identifiers')
|
||||
|
||||
STABLE_VERSION = {'version': '0.6.15 Beta'}
|
||||
STABLE_VERSION = {'version': '0.6.19 Beta'}
|
||||
|
||||
NIGHTLY_VERSION = {}
|
||||
NIGHTLY_VERSION = dict()
|
||||
NIGHTLY_VERSION[0] = '$Format:%H$'
|
||||
NIGHTLY_VERSION[1] = '$Format:%cI$'
|
||||
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
|
||||
# NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00'
|
||||
|
||||
# CACHE
|
||||
CACHE_TYPE_THUMBNAILS = 'thumbnails'
|
||||
|
||||
# Thumbnail Types
|
||||
THUMBNAIL_TYPE_COVER = 1
|
||||
THUMBNAIL_TYPE_SERIES = 2
|
||||
THUMBNAIL_TYPE_AUTHOR = 3
|
||||
|
||||
# Thumbnails Sizes
|
||||
COVER_THUMBNAIL_ORIGINAL = 0
|
||||
COVER_THUMBNAIL_SMALL = 1
|
||||
COVER_THUMBNAIL_MEDIUM = 2
|
||||
COVER_THUMBNAIL_LARGE = 3
|
||||
|
||||
# clean-up the module namespace
|
||||
del sys, os, namedtuple
|
||||
|
@ -18,7 +18,8 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from flask_babel import lazy_gettext as N_
|
||||
|
||||
from . import config, logger
|
||||
from .subproc_wrapper import process_wait
|
||||
@ -26,10 +27,9 @@ from .subproc_wrapper import process_wait
|
||||
|
||||
log = logger.create()
|
||||
|
||||
# _() necessary to make babel aware of string for translation
|
||||
_NOT_CONFIGURED = _('not configured')
|
||||
_NOT_INSTALLED = _('not installed')
|
||||
_EXECUTION_ERROR = _('Execution permissions missing')
|
||||
# strings getting translated when used
|
||||
_NOT_INSTALLED = N_('not installed')
|
||||
_EXECUTION_ERROR = N_('Execution permissions missing')
|
||||
|
||||
|
||||
def _get_command_version(path, pattern, argument=None):
|
||||
@ -48,14 +48,16 @@ def _get_command_version(path, pattern, argument=None):
|
||||
|
||||
|
||||
def get_calibre_version():
|
||||
return _get_command_version(config.config_converterpath, r'ebook-convert.*\(calibre', '--version') \
|
||||
or _NOT_CONFIGURED
|
||||
return _get_command_version(config.config_converterpath, r'ebook-convert.*\(calibre', '--version')
|
||||
|
||||
|
||||
def get_unrar_version():
|
||||
return _get_command_version(config.config_rarfile_location, r'UNRAR.*\d') or _NOT_CONFIGURED
|
||||
unrar_version = _get_command_version(config.config_rarfile_location, r'UNRAR.*\d')
|
||||
if unrar_version == "not installed":
|
||||
unrar_version = _get_command_version(config.config_rarfile_location, r'unrar.*\d','-V')
|
||||
return unrar_version
|
||||
|
||||
def get_kepubify_version():
|
||||
return _get_command_version(config.config_kepubifypath, r'kepubify\s','--version') or _NOT_CONFIGURED
|
||||
return _get_command_version(config.config_kepubifypath, r'kepubify\s','--version')
|
||||
|
||||
|
||||
|
48
cps/cover.py
Normal file
48
cps/cover.py
Normal file
@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2022 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
|
||||
try:
|
||||
from wand.image import Image
|
||||
use_IM = True
|
||||
except (ImportError, RuntimeError) as e:
|
||||
use_IM = False
|
||||
|
||||
|
||||
NO_JPEG_EXTENSIONS = ['.png', '.webp', '.bmp']
|
||||
COVER_EXTENSIONS = ['.png', '.webp', '.bmp', '.jpg', '.jpeg']
|
||||
|
||||
|
||||
def cover_processing(tmp_file_name, img, extension):
|
||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_name), 'cover.jpg')
|
||||
if extension in NO_JPEG_EXTENSIONS:
|
||||
if use_IM:
|
||||
with Image(blob=img) as imgc:
|
||||
imgc.format = 'jpeg'
|
||||
imgc.transform_colorspace('rgb')
|
||||
imgc.save(filename=tmp_cover_name)
|
||||
return tmp_cover_name
|
||||
else:
|
||||
return None
|
||||
if img:
|
||||
with open(tmp_cover_name, 'wb') as f:
|
||||
f.write(img)
|
||||
return tmp_cover_name
|
||||
else:
|
||||
return None
|
302
cps/db.py
302
cps/db.py
@ -17,13 +17,15 @@
|
||||
# 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
|
||||
import os
|
||||
import re
|
||||
import ast
|
||||
import json
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
import unidecode
|
||||
|
||||
from sqlite3 import OperationalError as sqliteOperationalError
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
|
||||
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
|
||||
@ -41,6 +43,7 @@ from sqlalchemy.sql.expression import and_, true, false, text, func, or_
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from flask_login import current_user
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import get_locale
|
||||
from flask import flash
|
||||
|
||||
from . import logger, ub, isoLanguages
|
||||
@ -48,11 +51,6 @@ from .pagination import Pagination
|
||||
|
||||
from weakref import WeakSet
|
||||
|
||||
try:
|
||||
import unidecode
|
||||
use_unidecode = True
|
||||
except ImportError:
|
||||
use_unidecode = False
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@ -92,6 +90,12 @@ books_publishers_link = Table('books_publishers_link', Base.metadata,
|
||||
)
|
||||
|
||||
|
||||
class Library_Id(Base):
|
||||
__tablename__ = 'library_id'
|
||||
id = Column(Integer, primary_key=True)
|
||||
uuid = Column(String, nullable=False)
|
||||
|
||||
|
||||
class Identifiers(Base):
|
||||
__tablename__ = 'identifiers'
|
||||
|
||||
@ -105,7 +109,7 @@ class Identifiers(Base):
|
||||
self.type = id_type
|
||||
self.book = book
|
||||
|
||||
def formatType(self):
|
||||
def format_type(self):
|
||||
format_type = self.type.lower()
|
||||
if format_type == 'amazon':
|
||||
return u"Amazon"
|
||||
@ -164,6 +168,8 @@ class Identifiers(Base):
|
||||
return u"https://portal.issn.org/resource/ISSN/{0}".format(self.val)
|
||||
elif format_type == "isfdb":
|
||||
return u"http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
|
||||
elif self.val.lower().startswith("javascript:"):
|
||||
return quote(self.val)
|
||||
else:
|
||||
return u"{0}".format(self.val)
|
||||
|
||||
@ -172,11 +178,11 @@ class Comments(Base):
|
||||
__tablename__ = 'comments'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
book = Column(Integer, ForeignKey('books.id'), nullable=False, unique=True)
|
||||
text = Column(String(collation='NOCASE'), nullable=False)
|
||||
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
||||
|
||||
def __init__(self, text, book):
|
||||
self.text = text
|
||||
def __init__(self, comment, book):
|
||||
self.text = comment
|
||||
self.book = book
|
||||
|
||||
def get(self):
|
||||
@ -358,7 +364,6 @@ class Books(Base):
|
||||
self.path = path
|
||||
self.has_cover = (has_cover != None)
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return u"<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
|
||||
self.timestamp, self.pubdate, self.series_index,
|
||||
@ -366,10 +371,10 @@ class Books(Base):
|
||||
|
||||
@property
|
||||
def atom_timestamp(self):
|
||||
return (self.timestamp.strftime('%Y-%m-%dT%H:%M:%S+00:00') or '')
|
||||
return self.timestamp.strftime('%Y-%m-%dT%H:%M:%S+00:00') or ''
|
||||
|
||||
|
||||
class Custom_Columns(Base):
|
||||
class CustomColumns(Base):
|
||||
__tablename__ = 'custom_columns'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
@ -427,7 +432,7 @@ class AlchemyEncoder(json.JSONEncoder):
|
||||
return json.JSONEncoder.default(self, o)
|
||||
|
||||
|
||||
class CalibreDB():
|
||||
class CalibreDB:
|
||||
_init = False
|
||||
engine = None
|
||||
config = None
|
||||
@ -436,22 +441,27 @@ class CalibreDB():
|
||||
# instances alive once they reach the end of their respective scopes
|
||||
instances = WeakSet()
|
||||
|
||||
def __init__(self, expire_on_commit=True):
|
||||
def __init__(self, expire_on_commit=True, init=False):
|
||||
""" Initialize a new CalibreDB session
|
||||
"""
|
||||
self.session = None
|
||||
if init:
|
||||
self.init_db(expire_on_commit)
|
||||
|
||||
|
||||
def init_db(self, expire_on_commit=True):
|
||||
if self._init:
|
||||
self.initSession(expire_on_commit)
|
||||
self.init_session(expire_on_commit)
|
||||
|
||||
self.instances.add(self)
|
||||
|
||||
def initSession(self, expire_on_commit=True):
|
||||
def init_session(self, expire_on_commit=True):
|
||||
self.session = self.session_factory()
|
||||
self.session.expire_on_commit = expire_on_commit
|
||||
self.update_title_sort(self.config)
|
||||
|
||||
@classmethod
|
||||
def setup_db_cc_classes(self, cc):
|
||||
def setup_db_cc_classes(cls, cc):
|
||||
cc_ids = []
|
||||
books_custom_column_links = {}
|
||||
for row in cc:
|
||||
@ -522,25 +532,31 @@ class CalibreDB():
|
||||
return cc_classes
|
||||
|
||||
@classmethod
|
||||
def check_valid_db(cls, config_calibre_dir, app_db_path):
|
||||
def check_valid_db(cls, config_calibre_dir, app_db_path, config_calibre_uuid):
|
||||
if not config_calibre_dir:
|
||||
return False
|
||||
return False, False
|
||||
dbpath = os.path.join(config_calibre_dir, "metadata.db")
|
||||
if not os.path.exists(dbpath):
|
||||
return False
|
||||
return False, False
|
||||
try:
|
||||
check_engine = create_engine('sqlite://',
|
||||
echo=False,
|
||||
isolation_level="SERIALIZABLE",
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool)
|
||||
echo=False,
|
||||
isolation_level="SERIALIZABLE",
|
||||
connect_args={'check_same_thread': False},
|
||||
poolclass=StaticPool)
|
||||
with check_engine.begin() as connection:
|
||||
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
|
||||
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
||||
local_session = scoped_session(sessionmaker())
|
||||
local_session.configure(bind=connection)
|
||||
database_uuid = local_session().query(Library_Id).one_or_none()
|
||||
# local_session.dispose()
|
||||
|
||||
check_engine.connect()
|
||||
db_change = config_calibre_uuid != database_uuid.uuid
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
return False, False
|
||||
return True, db_change
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, config):
|
||||
@ -582,13 +598,14 @@ class CalibreDB():
|
||||
cc = conn.execute(text("SELECT id, datatype FROM custom_columns"))
|
||||
cls.setup_db_cc_classes(cc)
|
||||
except OperationalError as e:
|
||||
log.debug_or_exception(e)
|
||||
log.error_or_exception(e)
|
||||
return False
|
||||
|
||||
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||
autoflush=True,
|
||||
bind=cls.engine))
|
||||
for inst in cls.instances:
|
||||
inst.initSession()
|
||||
inst.init_session()
|
||||
|
||||
cls._init = True
|
||||
return True
|
||||
@ -611,8 +628,8 @@ class CalibreDB():
|
||||
bd = (self.session.query(Books, read_column.value, ub.ArchivedBook.is_archived).select_from(Books)
|
||||
.join(read_column, read_column.book == book_id,
|
||||
isouter=True))
|
||||
except (KeyError, AttributeError):
|
||||
log.error("Custom Column No.%d is not existing in calibre database", read_column)
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
log.error("Custom Column No.{} is not existing in calibre database".format(read_column))
|
||||
# Skip linking read column and return None instead of read status
|
||||
bd = self.session.query(Books, None, ub.ArchivedBook.is_archived)
|
||||
return (bd.filter(Books.id == book_id)
|
||||
@ -629,12 +646,10 @@ class CalibreDB():
|
||||
# Language and content filters for displaying in the UI
|
||||
def common_filters(self, allow_show_archived=False, return_all_languages=False):
|
||||
if not allow_show_archived:
|
||||
archived_books = (
|
||||
ub.session.query(ub.ArchivedBook)
|
||||
.filter(ub.ArchivedBook.user_id == int(current_user.id))
|
||||
.filter(ub.ArchivedBook.is_archived == True)
|
||||
.all()
|
||||
)
|
||||
archived_books = (ub.session.query(ub.ArchivedBook)
|
||||
.filter(ub.ArchivedBook.user_id == int(current_user.id))
|
||||
.filter(ub.ArchivedBook.is_archived == True)
|
||||
.all())
|
||||
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
|
||||
archived_filter = Books.id.notin_(archived_book_ids)
|
||||
else:
|
||||
@ -653,16 +668,16 @@ class CalibreDB():
|
||||
pos_cc_list = current_user.allowed_column_value.split(',')
|
||||
pos_content_cc_filter = true() if pos_cc_list == [''] else \
|
||||
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
|
||||
any(cc_classes[self.config.config_restricted_column].value.in_(pos_cc_list))
|
||||
any(cc_classes[self.config.config_restricted_column].value.in_(pos_cc_list))
|
||||
neg_cc_list = current_user.denied_column_value.split(',')
|
||||
neg_content_cc_filter = false() if neg_cc_list == [''] else \
|
||||
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
|
||||
any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list))
|
||||
except (KeyError, AttributeError):
|
||||
any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list))
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
pos_content_cc_filter = false()
|
||||
neg_content_cc_filter = true()
|
||||
log.error(u"Custom Column No.%d is not existing in calibre database",
|
||||
self.config.config_restricted_column)
|
||||
log.error("Custom Column No.{} is not existing in calibre database".format(
|
||||
self.config.config_restricted_column))
|
||||
flash(_("Custom Column No.%(column)d is not existing in calibre database",
|
||||
column=self.config.config_restricted_column),
|
||||
category="error")
|
||||
@ -673,6 +688,25 @@ class CalibreDB():
|
||||
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
|
||||
pos_content_cc_filter, ~neg_content_cc_filter, archived_filter)
|
||||
|
||||
def generate_linked_query(self, config_read_column, database):
|
||||
if not config_read_column:
|
||||
query = (self.session.query(database, ub.ArchivedBook.is_archived, ub.ReadBook.read_status)
|
||||
.select_from(Books)
|
||||
.outerjoin(ub.ReadBook,
|
||||
and_(ub.ReadBook.user_id == int(current_user.id), ub.ReadBook.book_id == Books.id)))
|
||||
else:
|
||||
try:
|
||||
read_column = cc_classes[config_read_column]
|
||||
query = (self.session.query(database, ub.ArchivedBook.is_archived, read_column.value)
|
||||
.select_from(Books)
|
||||
.outerjoin(read_column, read_column.book == Books.id))
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
log.error("Custom Column No.{} is not existing in calibre database".format(config_read_column))
|
||||
# Skip linking read column and return None instead of read status
|
||||
query = self.session.query(database, None, ub.ArchivedBook.is_archived)
|
||||
return query.outerjoin(ub.ArchivedBook, and_(Books.id == ub.ArchivedBook.book_id,
|
||||
int(current_user.id) == ub.ArchivedBook.user_id))
|
||||
|
||||
@staticmethod
|
||||
def get_checkbox_sorted(inputlist, state, offset, limit, order, combo=False):
|
||||
outcome = list()
|
||||
@ -702,30 +736,14 @@ class CalibreDB():
|
||||
join_archive_read, config_read_column, *join):
|
||||
pagesize = pagesize or self.config.config_books_per_page
|
||||
if current_user.show_detail_random():
|
||||
randm = self.session.query(Books) \
|
||||
.filter(self.common_filters(allow_show_archived)) \
|
||||
.order_by(func.random()) \
|
||||
.limit(self.config.config_random_books).all()
|
||||
random_query = self.generate_linked_query(config_read_column, database)
|
||||
randm = (random_query.filter(self.common_filters(allow_show_archived))
|
||||
.order_by(func.random())
|
||||
.limit(self.config.config_random_books).all())
|
||||
else:
|
||||
randm = false()
|
||||
if join_archive_read:
|
||||
if not config_read_column:
|
||||
query = (self.session.query(database, ub.ReadBook.read_status, ub.ArchivedBook.is_archived)
|
||||
.select_from(Books)
|
||||
.outerjoin(ub.ReadBook,
|
||||
and_(ub.ReadBook.user_id == int(current_user.id), ub.ReadBook.book_id == Books.id)))
|
||||
else:
|
||||
try:
|
||||
read_column = cc_classes[config_read_column]
|
||||
query = (self.session.query(database, read_column.value, ub.ArchivedBook.is_archived)
|
||||
.select_from(Books)
|
||||
.outerjoin(read_column, read_column.book == Books.id))
|
||||
except (KeyError, AttributeError):
|
||||
log.error("Custom Column No.%d is not existing in calibre database", read_column)
|
||||
# Skip linking read column and return None instead of read status
|
||||
query =self.session.query(database, None, ub.ArchivedBook.is_archived)
|
||||
query = query.outerjoin(ub.ArchivedBook, and_(Books.id == ub.ArchivedBook.book_id,
|
||||
int(current_user.id) == ub.ArchivedBook.user_id))
|
||||
query = self.generate_linked_query(config_read_column, database)
|
||||
else:
|
||||
query = self.session.query(database)
|
||||
off = int(int(pagesize) * (page - 1))
|
||||
@ -754,13 +772,15 @@ class CalibreDB():
|
||||
len(query.all()))
|
||||
entries = query.order_by(*order).offset(off).limit(pagesize).all()
|
||||
except Exception as ex:
|
||||
log.debug_or_exception(ex)
|
||||
log.error_or_exception(ex)
|
||||
# display authors in right order
|
||||
entries = self.order_authors(entries, True, join_archive_read)
|
||||
return entries, randm, pagination
|
||||
|
||||
# Orders all Authors in the list according to authors sort
|
||||
def order_authors(self, entries, list_return=False, combined=False):
|
||||
# entries_copy = copy.deepcopy(entries)
|
||||
# entries_copy =[]
|
||||
for entry in entries:
|
||||
if combined:
|
||||
sort_authors = entry.Books.author_sort.split('&')
|
||||
@ -770,25 +790,30 @@ class CalibreDB():
|
||||
sort_authors = entry.author_sort.split('&')
|
||||
ids = [a.id for a in entry.authors]
|
||||
authors_ordered = list()
|
||||
error = False
|
||||
# error = False
|
||||
for auth in sort_authors:
|
||||
results = self.session.query(Authors).filter(Authors.sort == auth.lstrip().strip()).all()
|
||||
# ToDo: How to handle not found authorname
|
||||
# ToDo: How to handle not found author name
|
||||
if not len(results):
|
||||
error = True
|
||||
log.error("Author {} not found to display name in right order".format(auth.strip()))
|
||||
# error = True
|
||||
break
|
||||
for r in results:
|
||||
if r.id in ids:
|
||||
authors_ordered.append(r)
|
||||
if not error:
|
||||
ids.remove(r.id)
|
||||
for author_id in ids:
|
||||
result = self.session.query(Authors).filter(Authors.id == author_id).first()
|
||||
authors_ordered.append(result)
|
||||
|
||||
if list_return:
|
||||
if combined:
|
||||
entry.Books.authors = authors_ordered
|
||||
else:
|
||||
entry.authors = authors_ordered
|
||||
if list_return:
|
||||
return entries
|
||||
else:
|
||||
return authors_ordered
|
||||
entry.ordered_authors = authors_ordered
|
||||
else:
|
||||
return authors_ordered
|
||||
return entries
|
||||
|
||||
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
|
||||
query = query or ''
|
||||
@ -802,36 +827,21 @@ class CalibreDB():
|
||||
def check_exists_book(self, authr, title):
|
||||
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||
q = list()
|
||||
authorterms = re.split(r'\s*&\s*', authr)
|
||||
for authorterm in authorterms:
|
||||
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%")))
|
||||
author_terms = re.split(r'\s*&\s*', authr)
|
||||
for author_term in author_terms:
|
||||
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + author_term + "%")))
|
||||
|
||||
return self.session.query(Books) \
|
||||
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
|
||||
|
||||
def search_query(self, term, config_read_column, *join):
|
||||
def search_query(self, term, config, *join):
|
||||
term.strip().lower()
|
||||
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||
q = list()
|
||||
authorterms = re.split("[, ]+", term)
|
||||
for authorterm in authorterms:
|
||||
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%")))
|
||||
if not config_read_column:
|
||||
query = (self.session.query(Books, ub.ArchivedBook.is_archived, ub.ReadBook).select_from(Books)
|
||||
.outerjoin(ub.ReadBook, and_(Books.id == ub.ReadBook.book_id,
|
||||
int(current_user.id) == ub.ReadBook.user_id)))
|
||||
else:
|
||||
try:
|
||||
read_column = cc_classes[config_read_column]
|
||||
query = (self.session.query(Books, ub.ArchivedBook.is_archived, read_column.value).select_from(Books)
|
||||
.outerjoin(read_column, read_column.book == Books.id))
|
||||
except (KeyError, AttributeError):
|
||||
log.error("Custom Column No.%d is not existing in calibre database", config_read_column)
|
||||
# Skip linking read column
|
||||
query = self.session.query(Books, ub.ArchivedBook.is_archived, None)
|
||||
query = query.outerjoin(ub.ArchivedBook, and_(Books.id == ub.ArchivedBook.book_id,
|
||||
int(current_user.id) == ub.ArchivedBook.user_id))
|
||||
|
||||
author_terms = re.split("[, ]+", term)
|
||||
for author_term in author_terms:
|
||||
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + author_term + "%")))
|
||||
query = self.generate_linked_query(config.config_read_column, Books)
|
||||
if len(join) == 6:
|
||||
query = query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4]).outerjoin(join[5])
|
||||
if len(join) == 3:
|
||||
@ -840,20 +850,42 @@ class CalibreDB():
|
||||
query = query.outerjoin(join[0], join[1])
|
||||
elif len(join) == 1:
|
||||
query = query.outerjoin(join[0])
|
||||
return query.filter(self.common_filters(True)).filter(
|
||||
or_(Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")),
|
||||
Books.series.any(func.lower(Series.name).ilike("%" + term + "%")),
|
||||
Books.authors.any(and_(*q)),
|
||||
Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")),
|
||||
func.lower(Books.title).ilike("%" + term + "%")
|
||||
))
|
||||
|
||||
cc = self.get_cc_columns(config, filter_config_custom_read=True)
|
||||
filter_expression = [Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")),
|
||||
Books.series.any(func.lower(Series.name).ilike("%" + term + "%")),
|
||||
Books.authors.any(and_(*q)),
|
||||
Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")),
|
||||
func.lower(Books.title).ilike("%" + term + "%")]
|
||||
for c in cc:
|
||||
if c.datatype not in ["datetime", "rating", "bool", "int", "float"]:
|
||||
filter_expression.append(
|
||||
getattr(Books,
|
||||
'custom_column_' + str(c.id)).any(
|
||||
func.lower(cc_classes[c.id].value).ilike("%" + term + "%")))
|
||||
return query.filter(self.common_filters(True)).filter(or_(*filter_expression))
|
||||
|
||||
def get_cc_columns(self, config, filter_config_custom_read=False):
|
||||
tmp_cc = self.session.query(CustomColumns).filter(CustomColumns.datatype.notin_(cc_exceptions)).all()
|
||||
cc = []
|
||||
r = None
|
||||
if config.config_columns_to_ignore:
|
||||
r = re.compile(config.config_columns_to_ignore)
|
||||
|
||||
for col in tmp_cc:
|
||||
if filter_config_custom_read and config.config_read_column and config.config_read_column == col.id:
|
||||
continue
|
||||
if r and r.match(col.name):
|
||||
continue
|
||||
cc.append(col)
|
||||
|
||||
return cc
|
||||
|
||||
# read search results from calibre-database and return it (function is used for feed and simple search
|
||||
def get_search_results(self, term, offset=None, order=None, limit=None, allow_show_archived=False,
|
||||
config_read_column=False, *join):
|
||||
def get_search_results(self, term, config, offset=None, order=None, limit=None, *join):
|
||||
order = order[0] if order else [Books.sort]
|
||||
pagination = None
|
||||
result = self.search_query(term, config_read_column, *join).order_by(*order).all()
|
||||
result = self.search_query(term, config, *join).order_by(*order).all()
|
||||
result_count = len(result)
|
||||
if offset != None and limit != None:
|
||||
offset = int(offset)
|
||||
@ -870,28 +902,38 @@ class CalibreDB():
|
||||
|
||||
# Creates for all stored languages a translated speaking name in the array for the UI
|
||||
def speaking_language(self, languages=None, return_all_languages=False, with_count=False, reverse_order=False):
|
||||
from . import get_locale
|
||||
|
||||
if not languages:
|
||||
if with_count:
|
||||
if with_count:
|
||||
if not languages:
|
||||
languages = self.session.query(Languages, func.count('books_languages_link.book'))\
|
||||
.join(books_languages_link).join(Books)\
|
||||
.filter(self.common_filters(return_all_languages=return_all_languages)) \
|
||||
.group_by(text('books_languages_link.lang_code')).all()
|
||||
for lang in languages:
|
||||
lang[0].name = isoLanguages.get_language_name(get_locale(), lang[0].lang_code)
|
||||
return sorted(languages, key=lambda x: x[0].name, reverse=reverse_order)
|
||||
else:
|
||||
tags = list()
|
||||
for lang in languages:
|
||||
tag = Category(isoLanguages.get_language_name(get_locale(), lang[0].lang_code), lang[0].lang_code)
|
||||
tags.append([tag, lang[1]])
|
||||
# Append all books without language to list
|
||||
if not return_all_languages:
|
||||
no_lang_count = (self.session.query(Books)
|
||||
.outerjoin(books_languages_link).outerjoin(Languages)
|
||||
.filter(Languages.lang_code == None)
|
||||
.filter(self.common_filters())
|
||||
.count())
|
||||
if no_lang_count:
|
||||
tags.append([Category(_("None"), "none"), no_lang_count])
|
||||
return sorted(tags, key=lambda x: x[0].name.lower(), reverse=reverse_order)
|
||||
else:
|
||||
if not languages:
|
||||
languages = self.session.query(Languages) \
|
||||
.join(books_languages_link) \
|
||||
.join(Books) \
|
||||
.filter(self.common_filters(return_all_languages=return_all_languages)) \
|
||||
.group_by(text('books_languages_link.lang_code')).all()
|
||||
for lang in languages:
|
||||
lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
|
||||
for lang in languages:
|
||||
lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
|
||||
return sorted(languages, key=lambda x: x.name, reverse=reverse_order)
|
||||
|
||||
|
||||
def update_title_sort(self, config, conn=None):
|
||||
# user defined sort function for calibre databases (Series, etc.)
|
||||
def _title_sort(title):
|
||||
@ -904,7 +946,10 @@ class CalibreDB():
|
||||
return title.strip()
|
||||
|
||||
conn = conn or self.session.connection().connection.connection
|
||||
conn.create_function("title_sort", 1, _title_sort)
|
||||
try:
|
||||
conn.create_function("title_sort", 1, _title_sort)
|
||||
except sqliteOperationalError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def dispose(cls):
|
||||
@ -949,6 +994,25 @@ def lcase(s):
|
||||
try:
|
||||
return unidecode.unidecode(s.lower())
|
||||
except Exception as ex:
|
||||
log = logger.create()
|
||||
log.debug_or_exception(ex)
|
||||
_log = logger.create()
|
||||
_log.error_or_exception(ex)
|
||||
return s.lower()
|
||||
|
||||
|
||||
class Category:
|
||||
name = None
|
||||
id = None
|
||||
count = None
|
||||
rating = None
|
||||
|
||||
def __init__(self, name, cat_id, rating=None):
|
||||
self.name = name
|
||||
self.id = cat_id
|
||||
self.rating = rating
|
||||
self.count = 1
|
||||
|
||||
'''class Count:
|
||||
count = None
|
||||
|
||||
def __init__(self, count):
|
||||
self.count = count'''
|
||||
|
@ -1,14 +1,16 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
|
||||
from .constants import BASE_DIR
|
||||
try:
|
||||
from importlib_metadata import version
|
||||
from importlib.metadata import version
|
||||
importlib = True
|
||||
ImportNotFound = BaseException
|
||||
except ImportError:
|
||||
importlib = False
|
||||
|
||||
version = None
|
||||
|
||||
if not importlib:
|
||||
try:
|
||||
@ -20,6 +22,13 @@ if not importlib:
|
||||
|
||||
def load_dependencys(optional=False):
|
||||
deps = list()
|
||||
if getattr(sys, 'frozen', False):
|
||||
pip_installed = os.path.join(BASE_DIR, ".pip_installed")
|
||||
if os.path.exists(pip_installed):
|
||||
with open(pip_installed) as f:
|
||||
exe_deps = json.loads("".join(f.readlines()))
|
||||
else:
|
||||
return deps
|
||||
if importlib or pkgresources:
|
||||
if optional:
|
||||
req_path = os.path.join(BASE_DIR, "optional-requirements.txt")
|
||||
@ -31,11 +40,14 @@ def load_dependencys(optional=False):
|
||||
if not line.startswith('#') and not line == '\n' and not line.startswith('git'):
|
||||
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?', line.strip())
|
||||
try:
|
||||
if importlib:
|
||||
dep_version = version(res.group(1))
|
||||
if getattr(sys, 'frozen', False):
|
||||
dep_version = exe_deps[res.group(1).lower().replace('_','-')]
|
||||
else:
|
||||
dep_version = pkg_resources.get_distribution(res.group(1)).version
|
||||
except ImportNotFound:
|
||||
if importlib:
|
||||
dep_version = version(res.group(1))
|
||||
else:
|
||||
dep_version = pkg_resources.get_distribution(res.group(1)).version
|
||||
except (ImportNotFound, KeyError):
|
||||
if optional:
|
||||
continue
|
||||
dep_version = "not installed"
|
||||
|
1772
cps/editbooks.py
Normal file → Executable file
1772
cps/editbooks.py
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
122
cps/epub.py
122
cps/epub.py
@ -20,25 +20,27 @@ import os
|
||||
import zipfile
|
||||
from lxml import etree
|
||||
|
||||
from . import isoLanguages
|
||||
from . import isoLanguages, cover
|
||||
from . import config
|
||||
from .helper import split_authors
|
||||
from .constants import BookMeta
|
||||
|
||||
|
||||
|
||||
def extractCover(zipFile, coverFile, coverpath, tmp_file_name):
|
||||
if coverFile is None:
|
||||
def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
||||
if cover_file is None:
|
||||
return None
|
||||
else:
|
||||
zipCoverPath = os.path.join(coverpath, coverFile).replace('\\', '/')
|
||||
cf = zipFile.read(zipCoverPath)
|
||||
cf = extension = None
|
||||
zip_cover_path = os.path.join(cover_path, cover_file).replace('\\', '/')
|
||||
|
||||
prefix = os.path.splitext(tmp_file_name)[0]
|
||||
tmp_cover_name = prefix + '.' + os.path.basename(zipCoverPath)
|
||||
image = open(tmp_cover_name, 'wb')
|
||||
image.write(cf)
|
||||
image.close()
|
||||
return tmp_cover_name
|
||||
tmp_cover_name = prefix + '.' + os.path.basename(zip_cover_path)
|
||||
ext = os.path.splitext(tmp_cover_name)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension in cover.COVER_EXTENSIONS:
|
||||
cf = zip_file.read(zip_cover_path)
|
||||
return cover.cover_processing(tmp_file_name, cf, extension)
|
||||
|
||||
def get_epub_layout(book, book_data):
|
||||
ns = {
|
||||
@ -72,35 +74,43 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
'dc': 'http://purl.org/dc/elements/1.1/'
|
||||
}
|
||||
|
||||
epubZip = zipfile.ZipFile(tmp_file_path)
|
||||
epub_zip = zipfile.ZipFile(tmp_file_path)
|
||||
|
||||
txt = epubZip.read('META-INF/container.xml')
|
||||
txt = epub_zip.read('META-INF/container.xml')
|
||||
tree = etree.fromstring(txt)
|
||||
cfname = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
||||
cf = epubZip.read(cfname)
|
||||
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
||||
cf = epub_zip.read(cf_name)
|
||||
tree = etree.fromstring(cf)
|
||||
|
||||
coverpath = os.path.dirname(cfname)
|
||||
cover_path = os.path.dirname(cf_name)
|
||||
|
||||
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=ns)[0]
|
||||
|
||||
epub_metadata = {}
|
||||
|
||||
for s in ['title', 'description', 'creator', 'language', 'subject']:
|
||||
for s in ['title', 'description', 'creator', 'language', 'subject', 'publisher', 'date']:
|
||||
tmp = p.xpath('dc:%s/text()' % s, namespaces=ns)
|
||||
if len(tmp) > 0:
|
||||
if s == 'creator':
|
||||
epub_metadata[s] = ' & '.join(split_authors(tmp))
|
||||
elif s == 'subject':
|
||||
epub_metadata[s] = ', '.join(tmp)
|
||||
elif s == 'date':
|
||||
epub_metadata[s] = tmp[0][:10]
|
||||
else:
|
||||
epub_metadata[s] = tmp[0]
|
||||
else:
|
||||
epub_metadata[s] = u'Unknown'
|
||||
epub_metadata[s] = 'Unknown'
|
||||
|
||||
if epub_metadata['subject'] == u'Unknown':
|
||||
if epub_metadata['subject'] == 'Unknown':
|
||||
epub_metadata['subject'] = ''
|
||||
|
||||
if epub_metadata['publisher'] == u'Unknown':
|
||||
epub_metadata['publisher'] = ''
|
||||
|
||||
if epub_metadata['date'] == u'Unknown':
|
||||
epub_metadata['date'] = ''
|
||||
|
||||
if epub_metadata['description'] == u'Unknown':
|
||||
description = tree.xpath("//*[local-name() = 'description']/text()")
|
||||
if len(description) > 0:
|
||||
@ -111,9 +121,17 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
lang = epub_metadata['language'].split('-', 1)[0].lower()
|
||||
epub_metadata['language'] = isoLanguages.get_lang3(lang)
|
||||
|
||||
epub_metadata = parse_epbub_series(ns, tree, epub_metadata)
|
||||
epub_metadata = parse_epub_series(ns, tree, epub_metadata)
|
||||
|
||||
coverfile = parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path)
|
||||
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
|
||||
|
||||
identifiers = []
|
||||
for node in p.xpath('dc:identifier', namespaces=ns):
|
||||
identifier_name=node.attrib.values()[-1];
|
||||
identifier_value=node.text;
|
||||
if identifier_name in ('uuid','calibre'):
|
||||
continue;
|
||||
identifiers.append( [identifier_name, identifier_value] )
|
||||
|
||||
if not epub_metadata['title']:
|
||||
title = original_file_name
|
||||
@ -125,45 +143,57 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
extension=original_file_extension,
|
||||
title=title.encode('utf-8').decode('utf-8'),
|
||||
author=epub_metadata['creator'].encode('utf-8').decode('utf-8'),
|
||||
cover=coverfile,
|
||||
cover=cover_file,
|
||||
description=epub_metadata['description'],
|
||||
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
|
||||
series=epub_metadata['series'].encode('utf-8').decode('utf-8'),
|
||||
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
|
||||
languages=epub_metadata['language'],
|
||||
publisher="")
|
||||
publisher=epub_metadata['publisher'].encode('utf-8').decode('utf-8'),
|
||||
pubdate=epub_metadata['date'],
|
||||
identifiers=identifiers)
|
||||
|
||||
def parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path):
|
||||
coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
|
||||
coverfile = None
|
||||
if len(coversection) > 0:
|
||||
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
|
||||
else:
|
||||
|
||||
def parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path):
|
||||
cover_section = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
|
||||
cover_file = None
|
||||
# if len(cover_section) > 0:
|
||||
for cs in cover_section:
|
||||
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
||||
if cover_file:
|
||||
break
|
||||
if not cover_file:
|
||||
meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns)
|
||||
if len(meta_cover) > 0:
|
||||
coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='"+meta_cover[0]+"']/@href", namespaces=ns)
|
||||
cover_section = tree.xpath(
|
||||
"/pkg:package/pkg:manifest/pkg:item[@id='"+meta_cover[0]+"']/@href", namespaces=ns)
|
||||
if not cover_section:
|
||||
cover_section = tree.xpath(
|
||||
"/pkg:package/pkg:manifest/pkg:item[@properties='" + meta_cover[0] + "']/@href", namespaces=ns)
|
||||
else:
|
||||
coversection = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
|
||||
if len(coversection) > 0:
|
||||
filetype = coversection[0].rsplit('.', 1)[-1]
|
||||
cover_section = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
|
||||
for cs in cover_section:
|
||||
filetype = cs.rsplit('.', 1)[-1]
|
||||
if filetype == "xhtml" or filetype == "html": # if cover is (x)html format
|
||||
markup = epubZip.read(os.path.join(coverpath, coversection[0]))
|
||||
markupTree = etree.fromstring(markup)
|
||||
markup = epub_zip.read(os.path.join(cover_path, cs))
|
||||
markup_tree = etree.fromstring(markup)
|
||||
# no matter xhtml or html with no namespace
|
||||
imgsrc = markupTree.xpath("//*[local-name() = 'img']/@src")
|
||||
img_src = markup_tree.xpath("//*[local-name() = 'img']/@src")
|
||||
# Alternative image source
|
||||
if not len(imgsrc):
|
||||
imgsrc = markupTree.xpath("//attribute::*[contains(local-name(), 'href')]")
|
||||
if len(imgsrc):
|
||||
# imgsrc maybe startwith "../"" so fullpath join then relpath to cwd
|
||||
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(coverpath, coversection[0])),
|
||||
imgsrc[0]))
|
||||
coverfile = extractCover(epubZip, filename, "", tmp_file_path)
|
||||
if not len(img_src):
|
||||
img_src = markup_tree.xpath("//attribute::*[contains(local-name(), 'href')]")
|
||||
if len(img_src):
|
||||
# img_src maybe start with "../"" so fullpath join then relpath to cwd
|
||||
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(cover_path, cover_section[0])),
|
||||
img_src[0]))
|
||||
cover_file = _extract_cover(epub_zip, filename, "", tmp_file_path)
|
||||
else:
|
||||
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
|
||||
return coverfile
|
||||
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
||||
if cover_file: break
|
||||
return cover_file
|
||||
|
||||
def parse_epbub_series(ns, tree, epub_metadata):
|
||||
|
||||
def parse_epub_series(ns, tree, epub_metadata):
|
||||
series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns)
|
||||
if len(series) > 0:
|
||||
epub_metadata['series'] = series[0]
|
||||
|
@ -17,6 +17,7 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import traceback
|
||||
|
||||
from flask import render_template
|
||||
from werkzeug.exceptions import default_exceptions
|
||||
try:
|
||||
@ -42,8 +43,9 @@ def error_http(error):
|
||||
|
||||
def internal_error(error):
|
||||
return render_template('http_error.html',
|
||||
error_code="Internal Server Error",
|
||||
error_name=str(error),
|
||||
error_code="500 Internal Server Error",
|
||||
error_name='The server encountered an internal error and was unable to complete your '
|
||||
'request. There is an error in the application.',
|
||||
issue=True,
|
||||
unconfigured=False,
|
||||
error_stack=traceback.format_exc().split("\n"),
|
||||
|
@ -77,4 +77,6 @@ def get_fb2_info(tmp_file_path, original_file_extension):
|
||||
series="",
|
||||
series_id="",
|
||||
languages="",
|
||||
publisher="")
|
||||
publisher="",
|
||||
pubdate="",
|
||||
identifiers=[])
|
||||
|
95
cps/fs.py
Normal file
95
cps/fs.py
Normal file
@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 mmonkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from . import logger
|
||||
from .constants import CACHE_DIR
|
||||
from os import makedirs, remove
|
||||
from os.path import isdir, isfile, join
|
||||
from shutil import rmtree
|
||||
|
||||
|
||||
class FileSystem:
|
||||
_instance = None
|
||||
_cache_dir = CACHE_DIR
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(FileSystem, cls).__new__(cls)
|
||||
cls.log = logger.create()
|
||||
return cls._instance
|
||||
|
||||
def get_cache_dir(self, cache_type=None):
|
||||
if not isdir(self._cache_dir):
|
||||
try:
|
||||
makedirs(self._cache_dir)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to create path {self._cache_dir} (Permission denied).')
|
||||
raise
|
||||
|
||||
path = join(self._cache_dir, cache_type)
|
||||
if cache_type and not isdir(path):
|
||||
try:
|
||||
makedirs(path)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to create path {path} (Permission denied).')
|
||||
raise
|
||||
|
||||
return path if cache_type else self._cache_dir
|
||||
|
||||
def get_cache_file_dir(self, filename, cache_type=None):
|
||||
path = join(self.get_cache_dir(cache_type), filename[:2])
|
||||
if not isdir(path):
|
||||
try:
|
||||
makedirs(path)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to create path {path} (Permission denied).')
|
||||
raise
|
||||
|
||||
return path
|
||||
|
||||
def get_cache_file_path(self, filename, cache_type=None):
|
||||
return join(self.get_cache_file_dir(filename, cache_type), filename) if filename else None
|
||||
|
||||
def get_cache_file_exists(self, filename, cache_type=None):
|
||||
path = self.get_cache_file_path(filename, cache_type)
|
||||
return isfile(path)
|
||||
|
||||
def delete_cache_dir(self, cache_type=None):
|
||||
if not cache_type and isdir(self._cache_dir):
|
||||
try:
|
||||
rmtree(self._cache_dir)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to delete path {self._cache_dir} (Permission denied).')
|
||||
raise
|
||||
|
||||
path = join(self._cache_dir, cache_type)
|
||||
if cache_type and isdir(path):
|
||||
try:
|
||||
rmtree(path)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to delete path {path} (Permission denied).')
|
||||
raise
|
||||
|
||||
def delete_cache_file(self, filename, cache_type=None):
|
||||
path = self.get_cache_file_path(filename, cache_type)
|
||||
if isfile(path):
|
||||
try:
|
||||
remove(path)
|
||||
except OSError:
|
||||
self.log.info(f'Failed to delete path {path} (Permission denied).')
|
||||
raise
|
@ -109,7 +109,7 @@ def revoke_watch_gdrive():
|
||||
try:
|
||||
gdriveutils.stopChannel(gdriveutils.Gdrive.Instance().drive, last_watch_response['id'],
|
||||
last_watch_response['resourceId'])
|
||||
except HttpError:
|
||||
except (HttpError, AttributeError):
|
||||
pass
|
||||
config.config_google_drive_watch_changes_response = {}
|
||||
config.save()
|
||||
@ -152,7 +152,7 @@ try:
|
||||
move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath)
|
||||
calibre_db.reconnect_db(config, ub.app_DB_path)
|
||||
except Exception as ex:
|
||||
log.debug_or_exception(ex)
|
||||
log.error_or_exception(ex)
|
||||
return ''
|
||||
except AttributeError:
|
||||
pass
|
||||
|
@ -32,13 +32,9 @@ try:
|
||||
from sqlalchemy.orm import declarative_base
|
||||
except ImportError:
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.exc import OperationalError, InvalidRequestError
|
||||
from sqlalchemy.exc import OperationalError, InvalidRequestError, IntegrityError
|
||||
from sqlalchemy.sql.expression import text
|
||||
|
||||
try:
|
||||
from six import __version__ as six_version
|
||||
except ImportError:
|
||||
six_version = "not installed"
|
||||
try:
|
||||
from httplib2 import __version__ as httplib2_version
|
||||
except ImportError:
|
||||
@ -56,16 +52,18 @@ try:
|
||||
from pydrive2.auth import GoogleAuth
|
||||
from pydrive2.drive import GoogleDrive
|
||||
from pydrive2.auth import RefreshError
|
||||
from pydrive2.files import ApiRequestError
|
||||
except ImportError as err:
|
||||
try:
|
||||
from pydrive.auth import GoogleAuth
|
||||
from pydrive.drive import GoogleDrive
|
||||
from pydrive.auth import RefreshError
|
||||
from pydrive.files import ApiRequestError
|
||||
except ImportError as err:
|
||||
importError = err
|
||||
gdrive_support = False
|
||||
|
||||
from . import logger, cli, config
|
||||
from . import logger, cli_param, config
|
||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||
|
||||
|
||||
@ -79,7 +77,7 @@ if gdrive_support:
|
||||
if not logger.is_debug_enabled():
|
||||
logger.get('googleapiclient.discovery').setLevel(logger.logging.ERROR)
|
||||
else:
|
||||
log.debug("Cannot import pydrive,httplib2, using gdrive will not work: %s", importError)
|
||||
log.debug("Cannot import pydrive, httplib2, using gdrive will not work: {}".format(importError))
|
||||
|
||||
|
||||
class Singleton:
|
||||
@ -139,11 +137,12 @@ class Gdrive:
|
||||
def __init__(self):
|
||||
self.drive = getDrive(gauth=Gauth.Instance().auth)
|
||||
|
||||
|
||||
def is_gdrive_ready():
|
||||
return os.path.exists(SETTINGS_YAML) and os.path.exists(CREDENTIALS)
|
||||
|
||||
|
||||
engine = create_engine('sqlite:///{0}'.format(cli.gdpath), echo=False)
|
||||
engine = create_engine('sqlite:///{0}'.format(cli_param.gd_path), echo=False)
|
||||
Base = declarative_base()
|
||||
|
||||
# Open session for database connection
|
||||
@ -191,10 +190,11 @@ def migrate():
|
||||
session.execute('ALTER TABLE gdrive_ids2 RENAME to gdrive_ids')
|
||||
break
|
||||
|
||||
if not os.path.exists(cli.gdpath):
|
||||
if not os.path.exists(cli_param.gd_path):
|
||||
try:
|
||||
Base.metadata.create_all(engine)
|
||||
except Exception:
|
||||
except Exception as ex:
|
||||
log.error("Error connect to database: {} - {}".format(cli_param.gd_path, ex))
|
||||
raise
|
||||
migrate()
|
||||
|
||||
@ -210,9 +210,9 @@ def getDrive(drive=None, gauth=None):
|
||||
try:
|
||||
gauth.Refresh()
|
||||
except RefreshError as e:
|
||||
log.error("Google Drive error: %s", e)
|
||||
log.error("Google Drive error: {}".format(e))
|
||||
except Exception as ex:
|
||||
log.debug_or_exception(ex)
|
||||
log.error_or_exception(ex)
|
||||
else:
|
||||
# Initialize the saved creds
|
||||
gauth.Authorize()
|
||||
@ -222,7 +222,7 @@ def getDrive(drive=None, gauth=None):
|
||||
try:
|
||||
drive.auth.Refresh()
|
||||
except RefreshError as e:
|
||||
log.error("Google Drive error: %s", e)
|
||||
log.error("Google Drive error: {}".format(e))
|
||||
return drive
|
||||
|
||||
def listRootFolders():
|
||||
@ -231,7 +231,7 @@ def listRootFolders():
|
||||
folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
|
||||
fileList = drive.ListFile({'q': folder}).GetList()
|
||||
except (ServerNotFoundError, ssl.SSLError, RefreshError) as e:
|
||||
log.info("GDrive Error %s" % e)
|
||||
log.info("GDrive Error {}".format(e))
|
||||
fileList = []
|
||||
return fileList
|
||||
|
||||
@ -269,8 +269,7 @@ def getEbooksFolderId(drive=None):
|
||||
try:
|
||||
session.commit()
|
||||
except OperationalError as ex:
|
||||
log.error("gdrive.db DB is not Writeable")
|
||||
log.debug('Database error: %s', ex)
|
||||
log.error_or_exception('Database error: {}'.format(ex))
|
||||
session.rollback()
|
||||
return gDriveId.gdrive_id
|
||||
|
||||
@ -286,6 +285,7 @@ def getFile(pathId, fileName, drive):
|
||||
|
||||
def getFolderId(path, drive):
|
||||
# drive = getDrive(drive)
|
||||
currentFolderId = None
|
||||
try:
|
||||
currentFolderId = getEbooksFolderId(drive)
|
||||
sqlCheckPath = path if path[-1] == '/' else path + '/'
|
||||
@ -318,10 +318,14 @@ def getFolderId(path, drive):
|
||||
session.commit()
|
||||
else:
|
||||
currentFolderId = storedPathName.gdrive_id
|
||||
except OperationalError as ex:
|
||||
log.error("gdrive.db DB is not Writeable")
|
||||
log.debug('Database error: %s', ex)
|
||||
except (OperationalError, IntegrityError) as ex:
|
||||
log.error_or_exception('Database error: {}'.format(ex))
|
||||
session.rollback()
|
||||
except ApiRequestError as ex:
|
||||
log.error('{} {}'.format(ex.error['message'], path))
|
||||
session.rollback()
|
||||
except RefreshError as ex:
|
||||
log.error(ex)
|
||||
return currentFolderId
|
||||
|
||||
|
||||
@ -355,16 +359,27 @@ def moveGdriveFolderRemote(origin_file, target_folder):
|
||||
children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
||||
gFileTargetDir = getFileFromEbooksFolder(None, target_folder)
|
||||
if not gFileTargetDir:
|
||||
# Folder is not existing, create, and move folder
|
||||
gFileTargetDir = drive.CreateFile(
|
||||
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
||||
"mimeType": "application/vnd.google-apps.folder"})
|
||||
gFileTargetDir.Upload()
|
||||
# Move the file to the new folder
|
||||
drive.auth.service.files().update(fileId=origin_file['id'],
|
||||
addParents=gFileTargetDir['id'],
|
||||
removeParents=previous_parents,
|
||||
fields='id, parents').execute()
|
||||
# Move the file to the new folder
|
||||
drive.auth.service.files().update(fileId=origin_file['id'],
|
||||
addParents=gFileTargetDir['id'],
|
||||
removeParents=previous_parents,
|
||||
fields='id, parents').execute()
|
||||
|
||||
elif gFileTargetDir['title'] != target_folder:
|
||||
# Folder is not existing, create, and move folder
|
||||
drive.auth.service.files().patch(fileId=origin_file['id'],
|
||||
body={'title': target_folder},
|
||||
fields='title').execute()
|
||||
else:
|
||||
# Move the file to the new folder
|
||||
drive.auth.service.files().update(fileId=origin_file['id'],
|
||||
addParents=gFileTargetDir['id'],
|
||||
removeParents=previous_parents,
|
||||
fields='id, parents').execute()
|
||||
# if previous_parents has no children anymore, delete original fileparent
|
||||
if len(children['items']) == 1:
|
||||
deleteDatabaseEntry(previous_parents)
|
||||
@ -412,24 +427,24 @@ def uploadFileToEbooksFolder(destFile, f):
|
||||
splitDir = destFile.split('/')
|
||||
for i, x in enumerate(splitDir):
|
||||
if i == len(splitDir)-1:
|
||||
existingFiles = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||
existing_Files = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
||||
if len(existingFiles) > 0:
|
||||
driveFile = existingFiles[0]
|
||||
if len(existing_Files) > 0:
|
||||
driveFile = existing_Files[0]
|
||||
else:
|
||||
driveFile = drive.CreateFile({'title': x,
|
||||
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
||||
driveFile.SetContentFile(f)
|
||||
driveFile.Upload()
|
||||
else:
|
||||
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||
existing_Folder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
||||
if len(existingFolder) == 0:
|
||||
if len(existing_Folder) == 0:
|
||||
parent = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
||||
"mimeType": "application/vnd.google-apps.folder"})
|
||||
parent.Upload()
|
||||
else:
|
||||
parent = existingFolder[0]
|
||||
parent = existing_Folder[0]
|
||||
|
||||
|
||||
def watchChange(drive, channel_id, channel_type, channel_address,
|
||||
@ -528,8 +543,8 @@ def deleteDatabaseOnChange():
|
||||
session.commit()
|
||||
except (OperationalError, InvalidRequestError) as ex:
|
||||
session.rollback()
|
||||
log.debug('Database error: %s', ex)
|
||||
log.error(u"GDrive DB is not Writeable")
|
||||
log.error_or_exception('Database error: {}'.format(ex))
|
||||
session.rollback()
|
||||
|
||||
|
||||
def updateGdriveCalibreFromLocal():
|
||||
@ -547,8 +562,7 @@ def updateDatabaseOnEdit(ID,newPath):
|
||||
try:
|
||||
session.commit()
|
||||
except OperationalError as ex:
|
||||
log.error("gdrive.db DB is not Writeable")
|
||||
log.debug('Database error: %s', ex)
|
||||
log.error_or_exception('Database error: {}'.format(ex))
|
||||
session.rollback()
|
||||
|
||||
|
||||
@ -558,8 +572,7 @@ def deleteDatabaseEntry(ID):
|
||||
try:
|
||||
session.commit()
|
||||
except OperationalError as ex:
|
||||
log.error("gdrive.db DB is not Writeable")
|
||||
log.debug('Database error: %s', ex)
|
||||
log.error_or_exception('Database error: {}'.format(ex))
|
||||
session.rollback()
|
||||
|
||||
|
||||
@ -580,8 +593,7 @@ def get_cover_via_gdrive(cover_path):
|
||||
try:
|
||||
session.commit()
|
||||
except OperationalError as ex:
|
||||
log.error("gdrive.db DB is not Writeable")
|
||||
log.debug('Database error: %s', ex)
|
||||
log.error_or_exception('Database error: {}'.format(ex))
|
||||
session.rollback()
|
||||
return df.metadata.get('webContentLink')
|
||||
else:
|
||||
@ -603,7 +615,7 @@ def do_gdrive_download(df, headers, convert_encoding=False):
|
||||
|
||||
def stream(convert_encoding):
|
||||
for byte in s:
|
||||
headers = {"Range": 'bytes=%s-%s' % (byte[0], byte[1])}
|
||||
headers = {"Range": 'bytes={}-{}'.format(byte[0], byte[1])}
|
||||
resp, content = df.auth.Get_Http_Object().request(download_url, headers=headers)
|
||||
if resp.status == 206:
|
||||
if convert_encoding:
|
||||
@ -611,7 +623,7 @@ def do_gdrive_download(df, headers, convert_encoding=False):
|
||||
content = content.decode(result['encoding']).encode('utf-8')
|
||||
yield content
|
||||
else:
|
||||
log.warning('An error occurred: %s', resp)
|
||||
log.warning('An error occurred: {}'.format(resp))
|
||||
return
|
||||
return Response(stream_with_context(stream(convert_encoding)), headers=headers)
|
||||
|
||||
@ -668,8 +680,3 @@ def get_error_text(client_secrets=None):
|
||||
return 'Callback url (redirect url) is missing in client_secrets.json'
|
||||
if client_secrets:
|
||||
client_secrets.update(filedata['web'])
|
||||
|
||||
|
||||
def get_versions():
|
||||
return {'six': six_version,
|
||||
'httplib2': httplib2_version}
|
||||
|
29
cps/gevent_wsgi.py
Normal file
29
cps/gevent_wsgi.py
Normal file
@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2022 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from gevent.pywsgi import WSGIHandler
|
||||
|
||||
class MyWSGIHandler(WSGIHandler):
|
||||
def get_environ(self):
|
||||
env = super().get_environ()
|
||||
path, __ = self.path.split('?', 1) if '?' in self.path else (self.path, '')
|
||||
env['RAW_URI'] = path
|
||||
return env
|
||||
|
||||
|
771
cps/helper.py
771
cps/helper.py
File diff suppressed because it is too large
Load Diff
@ -49,7 +49,7 @@ except ImportError:
|
||||
|
||||
|
||||
def get_language_names(locale):
|
||||
return _LANGUAGE_NAMES.get(locale)
|
||||
return _LANGUAGE_NAMES.get(str(locale))
|
||||
|
||||
|
||||
def get_language_name(locale, lang_code):
|
||||
|
@ -102,6 +102,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (macrolanguage)",
|
||||
"dsb": "Sorbian; Lower",
|
||||
"dse": "holandský znakový jazyk",
|
||||
"dua": "dualština",
|
||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||
"dyu": "djula",
|
||||
@ -526,6 +527,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (Makrosprache)",
|
||||
"dsb": "Sorbisch; Nieder",
|
||||
"dse": "Niederländische Zeichensprache",
|
||||
"dua": "Duala",
|
||||
"dum": "Niederländisch; Mittel (ca. 1050-1350)",
|
||||
"dyu": "Dyula",
|
||||
@ -945,6 +947,7 @@ LANGUAGE_NAMES = {
|
||||
"dgr": "Dogrib",
|
||||
"dua": "Duala",
|
||||
"nld": "Ολλανδικά",
|
||||
"dse": "Ολλανδική νοηματική γλώσσα",
|
||||
"dyu": "Dyula",
|
||||
"dzo": "Dzongkha",
|
||||
"efi": "Efik",
|
||||
@ -1329,6 +1332,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (macrolengua)",
|
||||
"dsb": "Bajo sorabo",
|
||||
"dse": "Lengua de signos neerlandesa",
|
||||
"dua": "Duala",
|
||||
"dum": "Neerlandés medio (ca. 1050-1350)",
|
||||
"dyu": "Diula",
|
||||
@ -1753,6 +1757,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (macrolanguage)",
|
||||
"dsb": "alasorbi",
|
||||
"dse": "Dutch Sign Language",
|
||||
"dua": "duala",
|
||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||
"dyu": "dyula",
|
||||
@ -2177,6 +2182,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "dhivehi",
|
||||
"doi": "dogri (macrolangue)",
|
||||
"dsb": "bas-sorbien",
|
||||
"dse": "langue des signes néerlandaise",
|
||||
"dua": "duala",
|
||||
"dum": "néerlandais moyen (environ 1050-1350)",
|
||||
"dyu": "dioula",
|
||||
@ -2601,6 +2607,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (macrolanguage)",
|
||||
"dsb": "Sorbian; Lower",
|
||||
"dse": "Dutch Sign Language",
|
||||
"dua": "duala",
|
||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||
"dyu": "djula",
|
||||
@ -3025,6 +3032,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (macrolingua)",
|
||||
"dsb": "Lusaziano inferiore",
|
||||
"dse": "Olandense (linguaggio dei segni)",
|
||||
"dua": "Duala",
|
||||
"dum": "Olandese medio (ca. 1050-1350)",
|
||||
"dyu": "Diula",
|
||||
@ -3449,6 +3457,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (macrolanguage)",
|
||||
"dsb": "Sorbian; Lower",
|
||||
"dse": "Dutch Sign Language",
|
||||
"dua": "ドゥアラ語",
|
||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||
"dyu": "デュラ語",
|
||||
@ -3873,6 +3882,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (macrolanguage)",
|
||||
"dsb": "Sorbian; Lower",
|
||||
"dse": "Dutch Sign Language",
|
||||
"dua": "Duala",
|
||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||
"dyu": "Dyula",
|
||||
@ -4207,6 +4217,384 @@ LANGUAGE_NAMES = {
|
||||
"zxx": "No linguistic content",
|
||||
"zza": "Zaza"
|
||||
},
|
||||
"ko": {
|
||||
"abk": "압하스어",
|
||||
"ace": "아체어",
|
||||
"ach": "아촐리어",
|
||||
"ada": "Adangme",
|
||||
"ady": "Adyghe",
|
||||
"aar": "아파르어",
|
||||
"afh": "Afrihili",
|
||||
"afr": "아프리칸스어",
|
||||
"ain": "Ainu (Japan)",
|
||||
"aka": "Akan",
|
||||
"akk": "Akkadian",
|
||||
"sqi": "Albanian",
|
||||
"ale": "Aleut",
|
||||
"amh": "Amharic",
|
||||
"anp": "Angika",
|
||||
"ara": "아라비아어",
|
||||
"arg": "Aragonese",
|
||||
"arp": "Arapaho",
|
||||
"arw": "Arawak",
|
||||
"hye": "아르메니아어",
|
||||
"asm": "Assamese",
|
||||
"ast": "Asturian",
|
||||
"ava": "Avaric",
|
||||
"ave": "아베스타어",
|
||||
"awa": "Awadhi",
|
||||
"aym": "Aymara",
|
||||
"aze": "Azerbaijani",
|
||||
"ban": "발리 문자",
|
||||
"bal": "Baluchi",
|
||||
"bam": "Bambara",
|
||||
"bas": "Basa (Cameroon)",
|
||||
"bak": "Bashkir",
|
||||
"eus": "바스크어",
|
||||
"bej": "Beja",
|
||||
"bel": "벨로루시어",
|
||||
"bem": "Bemba (Zambia)",
|
||||
"ben": "벵골 문자",
|
||||
"bit": "Berinomo",
|
||||
"bho": "Bhojpuri",
|
||||
"bik": "Bikol",
|
||||
"byn": "Bilin",
|
||||
"bin": "Bini",
|
||||
"bis": "Bislama",
|
||||
"zbl": "Blissymbols",
|
||||
"bos": "Bosnian",
|
||||
"bra": "Braj",
|
||||
"bre": "Breton",
|
||||
"bug": "부기 문자",
|
||||
"bul": "불가리아어",
|
||||
"bua": "Buriat",
|
||||
"mya": "Burmese",
|
||||
"cad": "Caddo",
|
||||
"cat": "카탈로니아어",
|
||||
"ceb": "Cebuano",
|
||||
"chg": "Chagatai",
|
||||
"cha": "Chamorro",
|
||||
"che": "Chechen",
|
||||
"chr": "체로키 문자",
|
||||
"chy": "Cheyenne",
|
||||
"chb": "Chibcha",
|
||||
"zho": "중국어",
|
||||
"chn": "Chinook jargon",
|
||||
"chp": "Chipewyan",
|
||||
"cho": "Choctaw",
|
||||
"cht": "Cholón",
|
||||
"chk": "Chuukese",
|
||||
"chv": "Chuvash",
|
||||
"cop": "콥트어",
|
||||
"cor": "Cornish",
|
||||
"cos": "Corsican",
|
||||
"cre": "Cree",
|
||||
"mus": "Creek",
|
||||
"hrv": "크로아티아어",
|
||||
"ces": "체크어",
|
||||
"dak": "Dakota",
|
||||
"dan": "덴마크어",
|
||||
"dar": "Dargwa",
|
||||
"del": "Delaware",
|
||||
"div": "Dhivehi",
|
||||
"din": "Dinka",
|
||||
"doi": "Dogri (macrolanguage)",
|
||||
"dgr": "Dogrib",
|
||||
"dua": "Duala",
|
||||
"nld": "네덜란드어",
|
||||
"dse": "Dutch Sign Language",
|
||||
"dyu": "Dyula",
|
||||
"dzo": "Dzongkha",
|
||||
"efi": "Efik",
|
||||
"egy": "Egyptian (Ancient)",
|
||||
"eka": "Ekajuk",
|
||||
"elx": "Elamite",
|
||||
"eng": "영어",
|
||||
"enu": "Enu",
|
||||
"myv": "Erzya",
|
||||
"epo": "에스페란토어",
|
||||
"est": "에스토니아어",
|
||||
"ewe": "Ewe",
|
||||
"ewo": "Ewondo",
|
||||
"fan": "Fang (Equatorial Guinea)",
|
||||
"fat": "Fanti",
|
||||
"fao": "페로스어",
|
||||
"fij": "Fijian",
|
||||
"fil": "Filipino",
|
||||
"fin": "핀란드어",
|
||||
"fon": "Fon",
|
||||
"fra": "프랑스어",
|
||||
"fur": "Friulian",
|
||||
"ful": "Fulah",
|
||||
"gaa": "Ga",
|
||||
"glg": "Galician",
|
||||
"lug": "Ganda",
|
||||
"gay": "Gayo",
|
||||
"gba": "Gbaya (Central African Republic)",
|
||||
"hmj": "Ge",
|
||||
"gez": "Geez",
|
||||
"kat": "그루지야어",
|
||||
"deu": "독일어",
|
||||
"gil": "Gilbertese",
|
||||
"gon": "Gondi",
|
||||
"gor": "Gorontalo",
|
||||
"got": "고트어",
|
||||
"grb": "Grebo",
|
||||
"grn": "Guarani",
|
||||
"guj": "구자라트 문자",
|
||||
"gwi": "Gwichʼin",
|
||||
"hai": "Haida",
|
||||
"hau": "Hausa",
|
||||
"haw": "Hawaiian",
|
||||
"heb": "헤브루어",
|
||||
"her": "Herero",
|
||||
"hil": "Hiligaynon",
|
||||
"hin": "Hindi",
|
||||
"hmo": "Hiri Motu",
|
||||
"hit": "Hittite",
|
||||
"hmn": "Hmong",
|
||||
"hun": "헝가리어",
|
||||
"hup": "Hupa",
|
||||
"iba": "Iban",
|
||||
"isl": "아이슬란드어",
|
||||
"ido": "Ido",
|
||||
"ibo": "Igbo",
|
||||
"ilo": "Iloko",
|
||||
"ind": "인도네시아어",
|
||||
"inh": "Ingush",
|
||||
"ina": "Interlingua (International Auxiliary Language Association)",
|
||||
"ile": "Interlingue",
|
||||
"iku": "Inuktitut",
|
||||
"ipk": "Inupiaq",
|
||||
"gle": "아일랜드어",
|
||||
"ita": "이탈리아어",
|
||||
"jpn": "일본어",
|
||||
"jav": "Javanese",
|
||||
"jrb": "Judeo-Arabic",
|
||||
"jpr": "Judeo-Persian",
|
||||
"kbd": "Kabardian",
|
||||
"kab": "Kabyle",
|
||||
"kac": "Kachin",
|
||||
"kal": "Kalaallisut",
|
||||
"xal": "Kalmyk",
|
||||
"kam": "Kamba (Kenya)",
|
||||
"kan": " 칸나다 문자",
|
||||
"kau": "Kanuri",
|
||||
"kaa": "Kara-Kalpak",
|
||||
"krc": "Karachay-Balkar",
|
||||
"krl": "Karelian",
|
||||
"kas": "Kashmiri",
|
||||
"csb": "Kashubian",
|
||||
"kaw": "Kawi",
|
||||
"kaz": "Kazakh",
|
||||
"kha": "Khasi",
|
||||
"kho": "Khotanese",
|
||||
"kik": "Kikuyu",
|
||||
"kmb": "Kimbundu",
|
||||
"kin": "Kinyarwanda",
|
||||
"kir": "Kirghiz",
|
||||
"tlh": "Klingon",
|
||||
"kom": "Komi",
|
||||
"kon": "Kongo",
|
||||
"kok": "Konkani (macrolanguage)",
|
||||
"kor": "한국어",
|
||||
"kos": "Kosraean",
|
||||
"kpe": "Kpelle",
|
||||
"kua": "Kuanyama",
|
||||
"kum": "Kumyk",
|
||||
"kur": "Kurdish",
|
||||
"kru": "Kurukh",
|
||||
"kut": "Kutenai",
|
||||
"lad": "Ladino",
|
||||
"lah": "Lahnda",
|
||||
"lam": "Lamba",
|
||||
"lao": "라오 문자",
|
||||
"lat": "Latin",
|
||||
"lav": "라트비아어",
|
||||
"lez": "Lezghian",
|
||||
"lim": "Limburgan",
|
||||
"lin": "Lingala",
|
||||
"lit": "리투아니아어",
|
||||
"jbo": "Lojban",
|
||||
"loz": "Lozi",
|
||||
"lub": "Luba-Katanga",
|
||||
"lua": "Luba-Lulua",
|
||||
"lui": "Luiseno",
|
||||
"smj": "Lule Sami",
|
||||
"lun": "Lunda",
|
||||
"luo": "Luo (Kenya and Tanzania)",
|
||||
"lus": "Lushai",
|
||||
"ltz": "Luxembourgish",
|
||||
"mkd": "마케도니아어",
|
||||
"mad": "Madurese",
|
||||
"mag": "Magahi",
|
||||
"mai": "Maithili",
|
||||
"mak": "Makasar",
|
||||
"mlg": "Malagasy",
|
||||
"msa": "Malay (macrolanguage)",
|
||||
"mal": "말라얄람 문자",
|
||||
"mlt": "Maltese",
|
||||
"mnc": "Manchu",
|
||||
"mdr": "Mandar",
|
||||
"man": "Mandingo",
|
||||
"mni": "Manipuri",
|
||||
"glv": "Manx",
|
||||
"mri": "Maori",
|
||||
"arn": "Mapudungun",
|
||||
"mar": "Marathi",
|
||||
"chm": "Mari (Russia)",
|
||||
"mah": "Marshallese",
|
||||
"mwr": "Marwari",
|
||||
"mas": "Masai",
|
||||
"men": "Mende (Sierra Leone)",
|
||||
"mic": "Mi'kmaq",
|
||||
"min": "Minangkabau",
|
||||
"mwl": "Mirandese",
|
||||
"moh": "Mohawk",
|
||||
"mdf": "Moksha",
|
||||
"lol": "Mongo",
|
||||
"mon": "몽골 문자",
|
||||
"mos": "Mossi",
|
||||
"mul": "Multiple languages",
|
||||
"nqo": "응코 문자",
|
||||
"nau": "나우루어",
|
||||
"nav": "나바호어",
|
||||
"ndo": "Ndonga",
|
||||
"nap": "Neapolitan",
|
||||
"nia": "Nias",
|
||||
"niu": "Niuean",
|
||||
"zxx": "No linguistic content",
|
||||
"nog": "Nogai",
|
||||
"nor": "노르웨이어",
|
||||
"nob": "Norwegian Bokmål",
|
||||
"nno": "Norwegian Nynorsk",
|
||||
"nym": "Nyamwezi",
|
||||
"nya": "Nyanja",
|
||||
"nyn": "Nyankole",
|
||||
"nyo": "Nyoro",
|
||||
"nzi": "Nzima",
|
||||
"oci": "Occitan (post 1500)",
|
||||
"oji": "Ojibwa",
|
||||
"orm": "Oromo",
|
||||
"osa": "Osage",
|
||||
"oss": "Ossetian",
|
||||
"pal": "Pahlavi",
|
||||
"pau": "Palauan",
|
||||
"pli": "Pali",
|
||||
"pam": "Pampanga",
|
||||
"pag": "Pangasinan",
|
||||
"pan": "Panjabi",
|
||||
"pap": "Papiamento",
|
||||
"fas": "Persian",
|
||||
"phn": " 페니키아 문자",
|
||||
"pon": "Pohnpeian",
|
||||
"pol": "폴란드어",
|
||||
"por": "포르투갈어",
|
||||
"pus": "Pashto",
|
||||
"que": "Quechua",
|
||||
"raj": "Rajasthani",
|
||||
"rap": "Rapanui",
|
||||
"ron": "루마니아어",
|
||||
"roh": "Romansh",
|
||||
"rom": "Romany",
|
||||
"run": "Rundi",
|
||||
"rus": "러시아어",
|
||||
"smo": "Samoan",
|
||||
"sad": "Sandawe",
|
||||
"sag": "Sango",
|
||||
"san": "Sanskrit",
|
||||
"sat": "Santali",
|
||||
"srd": "Sardinian",
|
||||
"sas": "Sasak",
|
||||
"sco": "Scots",
|
||||
"sel": "Selkup",
|
||||
"srp": "세르비아어",
|
||||
"srr": "Serer",
|
||||
"shn": "Shan",
|
||||
"sna": "Shona",
|
||||
"scn": "Sicilian",
|
||||
"sid": "Sidamo",
|
||||
"bla": "Siksika",
|
||||
"snd": "Sindhi",
|
||||
"sin": "싱할라 문자",
|
||||
"den": "Slave (Athapascan)",
|
||||
"slk": "슬로바키아어",
|
||||
"slv": "슬로베니아어",
|
||||
"sog": "Sogdian",
|
||||
"som": "Somali",
|
||||
"snk": "Soninke",
|
||||
"spa": "스페인어",
|
||||
"srn": "Sranan Tongo",
|
||||
"suk": "Sukuma",
|
||||
"sux": "Sumerian",
|
||||
"sun": "Sundanese",
|
||||
"sus": "Susu",
|
||||
"swa": "Swahili (macrolanguage)",
|
||||
"ssw": "Swati",
|
||||
"swe": "스웨덴어",
|
||||
"syr": "시리아 문자",
|
||||
"tgl": "타갈로그 문자",
|
||||
"tah": "Tahitian",
|
||||
"tgk": "Tajik",
|
||||
"tmh": "Tamashek",
|
||||
"tam": "타밀 문자",
|
||||
"tat": "Tatar",
|
||||
"tel": "텔루구 문자",
|
||||
"ter": "Tereno",
|
||||
"tet": "Tetum",
|
||||
"tha": "태국어",
|
||||
"bod": "티베트 문자",
|
||||
"tig": "Tigre",
|
||||
"tir": "Tigrinya",
|
||||
"tem": "Timne",
|
||||
"tiv": "Tiv",
|
||||
"tli": "Tlingit",
|
||||
"tpi": "Tok Pisin",
|
||||
"tkl": "Tokelau",
|
||||
"tog": "Tonga (Nyasa)",
|
||||
"ton": "Tonga (Tonga Islands)",
|
||||
"tsi": "Tsimshian",
|
||||
"tso": "Tsonga",
|
||||
"tsn": "Tswana",
|
||||
"tum": "Tumbuka",
|
||||
"tur": "터키어",
|
||||
"tuk": "Turkmen",
|
||||
"tvl": "Tuvalu",
|
||||
"tyv": "Tuvinian",
|
||||
"twi": "Twi",
|
||||
"udm": "Udmurt",
|
||||
"uga": "우가리트 문자",
|
||||
"uig": "Uighur",
|
||||
"ukr": "Ukrainian",
|
||||
"umb": "Umbundu",
|
||||
"mis": "Uncoded languages",
|
||||
"und": "Undetermined",
|
||||
"urd": "Urdu",
|
||||
"uzb": "Uzbek",
|
||||
"vai": "Vai",
|
||||
"ven": "Venda",
|
||||
"vie": "베트남어",
|
||||
"vol": "Volapük",
|
||||
"vot": "Votic",
|
||||
"wln": "Walloon",
|
||||
"war": "Waray (Philippines)",
|
||||
"was": "Washo",
|
||||
"cym": "Welsh",
|
||||
"wal": "Wolaytta",
|
||||
"wol": "Wolof",
|
||||
"xho": "Xhosa",
|
||||
"sah": "Yakut",
|
||||
"yao": "Yao",
|
||||
"yap": "Yapese",
|
||||
"yid": "Yiddish",
|
||||
"yor": "Yoruba",
|
||||
"zap": "Zapotec",
|
||||
"zza": "Zaza",
|
||||
"zen": "Zenaga",
|
||||
"zha": "Zhuang",
|
||||
"zul": "Zulu",
|
||||
"zun": "Zuni"
|
||||
},
|
||||
"nl": {
|
||||
"aar": "Afar; Hamitisch",
|
||||
"abk": "Abchazisch",
|
||||
@ -4297,6 +4685,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Divehi",
|
||||
"doi": "Dogri",
|
||||
"dsb": "Sorbisch; lager",
|
||||
"dse": "Nederlandse gebarentaal",
|
||||
"dua": "Duala",
|
||||
"dum": "Nederlands; middel (ca. 1050-1350)",
|
||||
"dyu": "Dyula",
|
||||
@ -4721,6 +5110,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "malediwski; divehi",
|
||||
"doi": "dogri (makrojęzyk)",
|
||||
"dsb": "dolnołużycki",
|
||||
"dse": "holenderski język migowy",
|
||||
"dua": "duala",
|
||||
"dum": "holenderski średniowieczny (ok. 1050-1350)",
|
||||
"dyu": "diula",
|
||||
@ -5140,6 +5530,7 @@ LANGUAGE_NAMES = {
|
||||
"dgr": "Dogrib",
|
||||
"dua": "Duala",
|
||||
"nld": "Holandês",
|
||||
"dse": "Língua gestual holandesa",
|
||||
"dyu": "Dyula",
|
||||
"dzo": "Dzongkha",
|
||||
"efi": "Efik",
|
||||
@ -5522,6 +5913,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (macrolanguage)",
|
||||
"dsb": "Sorbian; Lower",
|
||||
"dse": "Dutch Sign Language",
|
||||
"dua": "Дуала",
|
||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||
"dyu": "Диула (Дьюла)",
|
||||
@ -5946,6 +6338,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Divehi",
|
||||
"doi": "Dogri (macrolanguage)",
|
||||
"dsb": "Sorbian; nedre",
|
||||
"dse": "Nederländskt teckenspråk",
|
||||
"dua": "Duala",
|
||||
"dum": "Hollänska; medeltida (ca. 1050-1350)",
|
||||
"dyu": "Dyula",
|
||||
@ -6365,6 +6758,7 @@ LANGUAGE_NAMES = {
|
||||
"dgr": "Dogrib (Kanada)",
|
||||
"dua": "Duala (Afrika)",
|
||||
"nld": "Flâmanca (Hollanda dili)",
|
||||
"dse": "Hollandalı İşaret Dili",
|
||||
"dyu": "Dyula (Burkina Faso; Mali)",
|
||||
"dzo": "Dzongkha (Butan)",
|
||||
"efi": "Efik (Afrika)",
|
||||
@ -6747,6 +7141,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "мальдивська",
|
||||
"doi": "догрі (макромова)",
|
||||
"dsb": "нижньолужицька",
|
||||
"dse": "голландська мова жестів",
|
||||
"dua": "дуала",
|
||||
"dum": "середньовічна голландська (бл. 1050-1350)",
|
||||
"dyu": "діула",
|
||||
@ -7171,6 +7566,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "迪维希语",
|
||||
"doi": "多格拉语",
|
||||
"dsb": "索布语(下)",
|
||||
"dse": "荷兰手语",
|
||||
"dua": "杜亚拉语",
|
||||
"dum": "荷兰语(中古,约 1050-1350)",
|
||||
"dyu": "迪尤拉语",
|
||||
@ -7590,6 +7986,7 @@ LANGUAGE_NAMES = {
|
||||
"dgr": "Dogrib",
|
||||
"dua": "Duala",
|
||||
"nld": "荷蘭文",
|
||||
"dse": "Dutch Sign Language",
|
||||
"dyu": "Dyula",
|
||||
"dzo": "Dzongkha",
|
||||
"efi": "Efik",
|
||||
@ -7973,6 +8370,7 @@ LANGUAGE_NAMES = {
|
||||
"div": "Dhivehi",
|
||||
"doi": "Dogri (macrolanguage)",
|
||||
"dsb": "Sorbian; Lower",
|
||||
"dse": "Dutch Sign Language",
|
||||
"dua": "Duala",
|
||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||
"dyu": "Dyula",
|
||||
|
@ -22,17 +22,17 @@
|
||||
|
||||
# custom jinja filters
|
||||
|
||||
from markupsafe import escape
|
||||
import datetime
|
||||
import mimetypes
|
||||
from uuid import uuid4
|
||||
|
||||
from babel.dates import format_date
|
||||
# from babel.dates import format_date
|
||||
from flask import Blueprint, request, url_for
|
||||
from flask_babel import get_locale
|
||||
from flask_babel import format_date
|
||||
from flask_login import current_user
|
||||
from markupsafe import escape
|
||||
from . import logger
|
||||
|
||||
from . import constants, logger
|
||||
|
||||
jinjia = Blueprint('jinjia', __name__)
|
||||
log = logger.create()
|
||||
@ -77,7 +77,7 @@ def mimetype_filter(val):
|
||||
@jinjia.app_template_filter('formatdate')
|
||||
def formatdate_filter(val):
|
||||
try:
|
||||
return format_date(val, format='medium', locale=get_locale())
|
||||
return format_date(val, format='medium')
|
||||
except AttributeError as e:
|
||||
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
|
||||
current_user.locale,
|
||||
@ -128,12 +128,55 @@ def formatseriesindex_filter(series_index):
|
||||
return series_index
|
||||
return 0
|
||||
|
||||
|
||||
@jinjia.app_template_filter('escapedlink')
|
||||
def escapedlink_filter(url, text):
|
||||
return "<a href='{}'>{}</a>".format(url, escape(text))
|
||||
|
||||
|
||||
@jinjia.app_template_filter('uuidfilter')
|
||||
def uuidfilter(var):
|
||||
return uuid4()
|
||||
|
||||
|
||||
@jinjia.app_template_filter('cache_timestamp')
|
||||
def cache_timestamp(rolling_period='month'):
|
||||
if rolling_period == 'day':
|
||||
return str(int(datetime.datetime.today().replace(hour=1, minute=1).timestamp()))
|
||||
elif rolling_period == 'year':
|
||||
return str(int(datetime.datetime.today().replace(day=1).timestamp()))
|
||||
else:
|
||||
return str(int(datetime.datetime.today().replace(month=1, day=1).timestamp()))
|
||||
|
||||
|
||||
@jinjia.app_template_filter('last_modified')
|
||||
def book_last_modified(book):
|
||||
return str(int(book.last_modified.timestamp()))
|
||||
|
||||
|
||||
@jinjia.app_template_filter('get_cover_srcset')
|
||||
def get_cover_srcset(book):
|
||||
srcset = list()
|
||||
resolutions = {
|
||||
constants.COVER_THUMBNAIL_SMALL: 'sm',
|
||||
constants.COVER_THUMBNAIL_MEDIUM: 'md',
|
||||
constants.COVER_THUMBNAIL_LARGE: 'lg'
|
||||
}
|
||||
for resolution, shortname in resolutions.items():
|
||||
url = url_for('web.get_cover', book_id=book.id, resolution=shortname, c=book_last_modified(book))
|
||||
srcset.append(f'{url} {resolution}x')
|
||||
return ', '.join(srcset)
|
||||
|
||||
|
||||
@jinjia.app_template_filter('get_series_srcset')
|
||||
def get_cover_srcset(series):
|
||||
srcset = list()
|
||||
resolutions = {
|
||||
constants.COVER_THUMBNAIL_SMALL: 'sm',
|
||||
constants.COVER_THUMBNAIL_MEDIUM: 'md',
|
||||
constants.COVER_THUMBNAIL_LARGE: 'lg'
|
||||
}
|
||||
for resolution, shortname in resolutions.items():
|
||||
url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp())
|
||||
srcset.append(f'{url} {resolution}x')
|
||||
return ', '.join(srcset)
|
||||
|
125
cps/kobo.py
125
cps/kobo.py
@ -23,11 +23,7 @@ import os
|
||||
import uuid
|
||||
from time import gmtime, strftime
|
||||
import json
|
||||
|
||||
try:
|
||||
from urllib import unquote
|
||||
except ImportError:
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import unquote
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
@ -50,7 +46,7 @@ import requests
|
||||
|
||||
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
||||
from .epub import get_epub_layout
|
||||
from .constants import sqlalchemy_version2
|
||||
from .constants import sqlalchemy_version2, COVER_THUMBNAIL_SMALL
|
||||
from .helper import get_download_link
|
||||
from .services import SyncToken as SyncToken
|
||||
from .web import download_required
|
||||
@ -153,8 +149,8 @@ def HandleSyncRequest():
|
||||
sync_token.books_last_created = datetime.datetime.min
|
||||
sync_token.reading_state_last_modified = datetime.datetime.min
|
||||
|
||||
new_books_last_modified = sync_token.books_last_modified # needed for sync selected shelfs only
|
||||
new_books_last_created = sync_token.books_last_created # needed to distinguish between new and changed entitlement
|
||||
new_books_last_modified = sync_token.books_last_modified # needed for sync selected shelfs only
|
||||
new_books_last_created = sync_token.books_last_created # needed to distinguish between new and changed entitlement
|
||||
new_reading_state_last_modified = sync_token.reading_state_last_modified
|
||||
|
||||
new_archived_last_modified = datetime.datetime.min
|
||||
@ -178,21 +174,20 @@ def HandleSyncRequest():
|
||||
ub.BookShelf.date_added,
|
||||
ub.ArchivedBook.is_archived)
|
||||
changed_entries = (changed_entries
|
||||
.join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id)
|
||||
.join(ub.KoboSyncedBooks, ub.KoboSyncedBooks.book_id == db.Books.id, isouter=True)
|
||||
.filter(or_(ub.KoboSyncedBooks.user_id != current_user.id,
|
||||
ub.KoboSyncedBooks.book_id == None))
|
||||
.filter(ub.BookShelf.date_added > sync_token.books_last_modified)
|
||||
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||
.filter(calibre_db.common_filters(allow_show_archived=True))
|
||||
.order_by(db.Books.id)
|
||||
.order_by(ub.ArchivedBook.last_modified)
|
||||
.join(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)
|
||||
.join(ub.Shelf)
|
||||
.filter(ub.Shelf.user_id == current_user.id)
|
||||
.filter(ub.Shelf.kobo_sync)
|
||||
.distinct()
|
||||
)
|
||||
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||
ub.ArchivedBook.user_id == current_user.id))
|
||||
.filter(db.Books.id.notin_(calibre_db.session.query(ub.KoboSyncedBooks.book_id)
|
||||
.filter(ub.KoboSyncedBooks.user_id == current_user.id)))
|
||||
.filter(ub.BookShelf.date_added > sync_token.books_last_modified)
|
||||
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||
.filter(calibre_db.common_filters(allow_show_archived=True))
|
||||
.order_by(db.Books.id)
|
||||
.order_by(ub.ArchivedBook.last_modified)
|
||||
.join(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)
|
||||
.join(ub.Shelf)
|
||||
.filter(ub.Shelf.user_id == current_user.id)
|
||||
.filter(ub.Shelf.kobo_sync)
|
||||
.distinct())
|
||||
else:
|
||||
if sqlalchemy_version2:
|
||||
changed_entries = select(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived)
|
||||
@ -201,16 +196,14 @@ def HandleSyncRequest():
|
||||
ub.ArchivedBook.last_modified,
|
||||
ub.ArchivedBook.is_archived)
|
||||
changed_entries = (changed_entries
|
||||
.join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id)
|
||||
.join(ub.KoboSyncedBooks, ub.KoboSyncedBooks.book_id == db.Books.id, isouter=True)
|
||||
.filter(or_(ub.KoboSyncedBooks.user_id != current_user.id,
|
||||
ub.KoboSyncedBooks.book_id == None))
|
||||
.filter(calibre_db.common_filters())
|
||||
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||
.order_by(db.Books.last_modified)
|
||||
.order_by(db.Books.id)
|
||||
)
|
||||
|
||||
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||
ub.ArchivedBook.user_id == current_user.id))
|
||||
.filter(db.Books.id.notin_(calibre_db.session.query(ub.KoboSyncedBooks.book_id)
|
||||
.filter(ub.KoboSyncedBooks.user_id == current_user.id)))
|
||||
.filter(calibre_db.common_filters(allow_show_archived=True))
|
||||
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||
.order_by(db.Books.last_modified)
|
||||
.order_by(db.Books.id))
|
||||
|
||||
reading_states_in_new_entitlements = []
|
||||
if sqlalchemy_version2:
|
||||
@ -220,7 +213,7 @@ def HandleSyncRequest():
|
||||
log.debug("Books to Sync: {}".format(len(books.all())))
|
||||
for book in books:
|
||||
formats = [data.format for data in book.Books.data]
|
||||
if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats:
|
||||
if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats:
|
||||
helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
||||
|
||||
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
||||
@ -262,10 +255,12 @@ def HandleSyncRequest():
|
||||
if sqlalchemy_version2:
|
||||
max_change = calibre_db.session.execute(changed_entries
|
||||
.filter(ub.ArchivedBook.is_archived)
|
||||
.filter(ub.ArchivedBook.user_id == current_user.id)
|
||||
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()))\
|
||||
.columns(db.Books).first()
|
||||
else:
|
||||
max_change = changed_entries.from_self().filter(ub.ArchivedBook.is_archived) \
|
||||
max_change = changed_entries.from_self().filter(ub.ArchivedBook.is_archived)\
|
||||
.filter(ub.ArchivedBook.user_id == current_user.id) \
|
||||
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
|
||||
|
||||
max_change = max_change.last_modified if max_change else new_archived_last_modified
|
||||
@ -300,7 +295,8 @@ def HandleSyncRequest():
|
||||
|
||||
changed_reading_states = changed_reading_states.filter(
|
||||
and_(ub.KoboReadingState.user_id == current_user.id,
|
||||
ub.KoboReadingState.book_id.notin_(reading_states_in_new_entitlements)))
|
||||
ub.KoboReadingState.book_id.notin_(reading_states_in_new_entitlements)))\
|
||||
.order_by(ub.KoboReadingState.last_modified)
|
||||
cont_sync |= bool(changed_reading_states.count() > SYNC_ITEM_LIMIT)
|
||||
for kobo_reading_state in changed_reading_states.limit(SYNC_ITEM_LIMIT).all():
|
||||
book = calibre_db.session.query(db.Books).filter(db.Books.id == kobo_reading_state.book_id).one_or_none()
|
||||
@ -326,7 +322,7 @@ def HandleSyncRequest():
|
||||
|
||||
def generate_sync_response(sync_token, sync_results, set_cont=False):
|
||||
extra_headers = {}
|
||||
if config.config_kobo_proxy:
|
||||
if config.config_kobo_proxy and not set_cont:
|
||||
# Merge in sync results from the official Kobo store.
|
||||
try:
|
||||
store_response = make_request_to_kobo_store(sync_token)
|
||||
@ -344,7 +340,7 @@ def generate_sync_response(sync_token, sync_results, set_cont=False):
|
||||
extra_headers["x-kobo-sync"] = "continue"
|
||||
sync_token.to_headers(extra_headers)
|
||||
|
||||
log.debug("Kobo Sync Content: {}".format(sync_results))
|
||||
# log.debug("Kobo Sync Content: {}".format(sync_results))
|
||||
# jsonify decodes the unicode string different to what kobo expects
|
||||
response = make_response(json.dumps(sync_results), extra_headers)
|
||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
@ -427,9 +423,9 @@ def get_author(book):
|
||||
author_list = []
|
||||
autor_roles = []
|
||||
for author in book.authors:
|
||||
autor_roles.append({"Name":author.name}) #.encode('unicode-escape').decode('latin-1')
|
||||
autor_roles.append({"Name": author.name})
|
||||
author_list.append(author.name)
|
||||
return {"ContributorRoles": autor_roles, "Contributors":author_list}
|
||||
return {"ContributorRoles": autor_roles, "Contributors": author_list}
|
||||
|
||||
|
||||
def get_publisher(book):
|
||||
@ -443,6 +439,7 @@ def get_series(book):
|
||||
return None
|
||||
return book.series[0].name
|
||||
|
||||
|
||||
def get_seriesindex(book):
|
||||
return book.series_index or 1
|
||||
|
||||
@ -489,7 +486,7 @@ def get_metadata(book):
|
||||
"Language": "en",
|
||||
"PhoneticPronunciations": {},
|
||||
"PublicationDate": convert_to_kobo_timestamp_string(book.pubdate),
|
||||
"Publisher": {"Imprint": "", "Name": get_publisher(book),},
|
||||
"Publisher": {"Imprint": "", "Name": get_publisher(book), },
|
||||
"RevisionId": book_uuid,
|
||||
"Title": book.title,
|
||||
"WorkId": book_uuid,
|
||||
@ -508,6 +505,7 @@ def get_metadata(book):
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
@csrf.exempt
|
||||
@kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
|
||||
@requires_kobo_auth
|
||||
@ -556,11 +554,9 @@ def HandleTagUpdate(tag_id):
|
||||
else:
|
||||
abort(404, description="Collection isn't known to CalibreWeb")
|
||||
|
||||
if not shelf_lib.check_shelf_edit_permissions(shelf):
|
||||
abort(401, description="User is unauthaurized to edit shelf.")
|
||||
|
||||
if request.method == "DELETE":
|
||||
shelf_lib.delete_shelf_helper(shelf)
|
||||
if not shelf_lib.delete_shelf_helper(shelf):
|
||||
abort(401, description="Error deleting Shelf")
|
||||
else:
|
||||
name = None
|
||||
try:
|
||||
@ -678,11 +674,8 @@ def HandleTagRemoveItem(tag_id):
|
||||
# Note: Public shelves that aren't owned by the user aren't supported.
|
||||
def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
||||
new_tags_last_modified = sync_token.tags_last_modified
|
||||
|
||||
for shelf in ub.session.query(ub.ShelfArchive).filter(
|
||||
func.datetime(ub.ShelfArchive.last_modified) > sync_token.tags_last_modified,
|
||||
ub.ShelfArchive.user_id == current_user.id
|
||||
):
|
||||
# transmit all archived shelfs independent of last sync (why should this matter?)
|
||||
for shelf in ub.session.query(ub.ShelfArchive).filter(ub.ShelfArchive.user_id == current_user.id):
|
||||
new_tags_last_modified = max(shelf.last_modified, new_tags_last_modified)
|
||||
sync_results.append({
|
||||
"DeletedTag": {
|
||||
@ -695,7 +688,6 @@ def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
||||
ub.session.delete(shelf)
|
||||
ub.session_commit()
|
||||
|
||||
|
||||
extra_filters = []
|
||||
if only_kobo_shelves:
|
||||
for shelf in ub.session.query(ub.Shelf).filter(
|
||||
@ -728,7 +720,6 @@ def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
||||
*extra_filters
|
||||
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())
|
||||
|
||||
|
||||
for shelf in shelflist:
|
||||
if not shelf_lib.check_shelf_view_permissions(shelf):
|
||||
continue
|
||||
@ -774,6 +765,7 @@ def create_kobo_tag(shelf):
|
||||
)
|
||||
return {"Tag": tag}
|
||||
|
||||
|
||||
@csrf.exempt
|
||||
@kobo.route("/v1/library/<book_uuid>/state", methods=["GET", "PUT"])
|
||||
@requires_kobo_auth
|
||||
@ -818,7 +810,7 @@ def HandleStateRequest(book_uuid):
|
||||
book_read = kobo_reading_state.book_read_link
|
||||
new_book_read_status = get_ub_read_status(request_status_info["Status"])
|
||||
if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \
|
||||
and new_book_read_status != book_read.read_status:
|
||||
and new_book_read_status != book_read.read_status:
|
||||
book_read.times_started_reading += 1
|
||||
book_read.last_time_started_reading = datetime.datetime.utcnow()
|
||||
book_read.read_status = new_book_read_status
|
||||
@ -858,7 +850,7 @@ def get_ub_read_status(kobo_read_status):
|
||||
|
||||
def get_or_create_reading_state(book_id):
|
||||
book_read = ub.session.query(ub.ReadBook).filter(ub.ReadBook.book_id == book_id,
|
||||
ub.ReadBook.user_id == current_user.id).one_or_none()
|
||||
ub.ReadBook.user_id == int(current_user.id)).one_or_none()
|
||||
if not book_read:
|
||||
book_read = ub.ReadBook(user_id=current_user.id, book_id=book_id)
|
||||
if not book_read.kobo_reading_state:
|
||||
@ -922,13 +914,12 @@ def get_current_bookmark_response(current_bookmark):
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
@kobo.route("/<book_uuid>/<width>/<height>/<isGreyscale>/image.jpg", defaults={'Quality': ""})
|
||||
@kobo.route("/<book_uuid>/<width>/<height>/<Quality>/<isGreyscale>/image.jpg")
|
||||
@requires_kobo_auth
|
||||
def HandleCoverImageRequest(book_uuid, width, height,Quality, isGreyscale):
|
||||
book_cover = helper.get_book_cover_with_uuid(
|
||||
book_uuid, use_generic_cover_on_failure=False
|
||||
)
|
||||
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
||||
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=COVER_THUMBNAIL_SMALL)
|
||||
if not book_cover:
|
||||
if config.config_kobo_proxy:
|
||||
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
|
||||
@ -1001,8 +992,8 @@ def handle_getests():
|
||||
if config.config_kobo_proxy:
|
||||
return redirect_or_proxy_request()
|
||||
else:
|
||||
testkey = request.headers.get("X-Kobo-userkey","")
|
||||
return make_response(jsonify({"Result": "Success", "TestKey":testkey, "Tests": {}}))
|
||||
testkey = request.headers.get("X-Kobo-userkey", "")
|
||||
return make_response(jsonify({"Result": "Success", "TestKey": testkey, "Tests": {}}))
|
||||
|
||||
|
||||
@csrf.exempt
|
||||
@ -1032,7 +1023,7 @@ def make_calibre_web_auth_response():
|
||||
content = request.get_json()
|
||||
AccessToken = base64.b64encode(os.urandom(24)).decode('utf-8')
|
||||
RefreshToken = base64.b64encode(os.urandom(24)).decode('utf-8')
|
||||
return make_response(
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"AccessToken": AccessToken,
|
||||
@ -1170,14 +1161,16 @@ def NATIVE_KOBO_RESOURCES():
|
||||
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
|
||||
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
|
||||
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
|
||||
"facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://store.kobobooks.com/",
|
||||
"facebook_sso_page":
|
||||
"https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://store.kobobooks.com/",
|
||||
"featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
|
||||
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
|
||||
"free_books_page": {
|
||||
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
|
||||
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
|
||||
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
|
||||
"NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
|
||||
"NL": "https://www.kobo.com/{region}/{language}/"
|
||||
"List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
|
||||
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis",
|
||||
},
|
||||
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
|
||||
@ -1202,7 +1195,8 @@ def NATIVE_KOBO_RESOURCES():
|
||||
"library_stack": "https://storeapi.kobo.com/v1/user/library/stacks/{LibraryItemId}",
|
||||
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
|
||||
"love_dashboard_page": "https://store.kobobooks.com/{culture}/kobosuperpoints",
|
||||
"love_points_redemption_page": "https://store.kobobooks.com/{culture}/KoboSuperPointsRedemption?productId={ProductId}",
|
||||
"love_points_redemption_page":
|
||||
"https://store.kobobooks.com/{culture}/KoboSuperPointsRedemption?productId={ProductId}",
|
||||
"magazine_landing_page": "https://store.kobobooks.com/emagazines",
|
||||
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
|
||||
"oauth_host": "https://oauth.kobo.com",
|
||||
@ -1218,7 +1212,8 @@ def NATIVE_KOBO_RESOURCES():
|
||||
"product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
|
||||
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
|
||||
"products": "https://storeapi.kobo.com/v1/products",
|
||||
"provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://store.kobobooks.com/",
|
||||
"provider_external_sign_in_page":
|
||||
"https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://store.kobobooks.com/",
|
||||
"purchase_buy": "https://www.kobo.com/checkout/createpurchase/",
|
||||
"purchase_buy_templated": "https://www.kobo.com/{culture}/checkout/createpurchase/{ProductId}",
|
||||
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
|
||||
|
132
cps/kobo_auth.py
132
cps/kobo_auth.py
@ -62,6 +62,7 @@ particular calls to non-Kobo specific endpoints such as the CalibreWeb book down
|
||||
from binascii import hexlify
|
||||
from datetime import datetime
|
||||
from os import urandom
|
||||
from functools import wraps
|
||||
|
||||
from flask import g, Blueprint, url_for, abort, request
|
||||
from flask_login import login_user, current_user, login_required
|
||||
@ -70,20 +71,61 @@ from flask_babel import gettext as _
|
||||
from . import logger, config, calibre_db, db, helper, ub, lm
|
||||
from .render_template import render_title_template
|
||||
|
||||
try:
|
||||
from functools import wraps
|
||||
except ImportError:
|
||||
pass # We're not using Python 3
|
||||
|
||||
|
||||
log = logger.create()
|
||||
|
||||
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
||||
|
||||
def register_url_value_preprocessor(kobo):
|
||||
@kobo.url_value_preprocessor
|
||||
# pylint: disable=unused-variable
|
||||
def pop_auth_token(__, values):
|
||||
g.auth_token = values.pop("auth_token")
|
||||
|
||||
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
||||
@login_required
|
||||
def generate_auth_token(user_id):
|
||||
warning = False
|
||||
host_list = request.host.rsplit(':')
|
||||
if len(host_list) == 1:
|
||||
host = ':'.join(host_list)
|
||||
else:
|
||||
host = ':'.join(host_list[0:-1])
|
||||
if host.startswith('127.') or host.lower() == 'localhost' or host.startswith('[::ffff:7f') or host == "[::1]":
|
||||
warning = _('Please access Calibre-Web from non localhost to get valid api_endpoint for kobo device')
|
||||
|
||||
# Generate auth token if none is existing for this user
|
||||
auth_token = ub.session.query(ub.RemoteAuthToken).filter(
|
||||
ub.RemoteAuthToken.user_id == user_id
|
||||
).filter(ub.RemoteAuthToken.token_type==1).first()
|
||||
|
||||
if not auth_token:
|
||||
auth_token = ub.RemoteAuthToken()
|
||||
auth_token.user_id = user_id
|
||||
auth_token.expiration = datetime.max
|
||||
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8")
|
||||
auth_token.token_type = 1
|
||||
|
||||
ub.session.add(auth_token)
|
||||
ub.session_commit()
|
||||
|
||||
books = calibre_db.session.query(db.Books).join(db.Data).all()
|
||||
|
||||
for book in books:
|
||||
formats = [data.format for data in book.data]
|
||||
if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats:
|
||||
helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
||||
|
||||
return render_title_template(
|
||||
"generate_kobo_auth_url.html",
|
||||
title=_(u"Kobo Setup"),
|
||||
auth_token=auth_token.auth_token,
|
||||
warning = warning
|
||||
)
|
||||
|
||||
|
||||
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_auth_token(user_id):
|
||||
# Invalidate any previously generated Kobo Auth token for this user
|
||||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
||||
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
||||
|
||||
return ub.session_commit()
|
||||
|
||||
|
||||
def disable_failed_auth_redirect_for_blueprint(bp):
|
||||
@ -97,6 +139,13 @@ def get_auth_token():
|
||||
return None
|
||||
|
||||
|
||||
def register_url_value_preprocessor(kobo):
|
||||
@kobo.url_value_preprocessor
|
||||
# pylint: disable=unused-variable
|
||||
def pop_auth_token(__, values):
|
||||
g.auth_token = values.pop("auth_token")
|
||||
|
||||
|
||||
def requires_kobo_auth(f):
|
||||
@wraps(f)
|
||||
def inner(*args, **kwargs):
|
||||
@ -114,64 +163,3 @@ def requires_kobo_auth(f):
|
||||
log.debug("Received Kobo request without a recognizable auth token.")
|
||||
return abort(401)
|
||||
return inner
|
||||
|
||||
|
||||
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
||||
|
||||
|
||||
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
||||
@login_required
|
||||
def generate_auth_token(user_id):
|
||||
host_list = request.host.rsplit(':')
|
||||
if len(host_list) == 1:
|
||||
host = ':'.join(host_list)
|
||||
else:
|
||||
host = ':'.join(host_list[0:-1])
|
||||
if host.startswith('127.') or host.lower() == 'localhost' or host.startswith('[::ffff:7f'):
|
||||
warning = _('PLease access calibre-web from non localhost to get valid api_endpoint for kobo device')
|
||||
return render_title_template(
|
||||
"generate_kobo_auth_url.html",
|
||||
title=_(u"Kobo Setup"),
|
||||
warning = warning
|
||||
)
|
||||
else:
|
||||
# Invalidate any prevously generated Kobo Auth token for this user.
|
||||
auth_token = ub.session.query(ub.RemoteAuthToken).filter(
|
||||
ub.RemoteAuthToken.user_id == user_id
|
||||
).filter(ub.RemoteAuthToken.token_type==1).first()
|
||||
|
||||
if not auth_token:
|
||||
auth_token = ub.RemoteAuthToken()
|
||||
auth_token.user_id = user_id
|
||||
auth_token.expiration = datetime.max
|
||||
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8")
|
||||
auth_token.token_type = 1
|
||||
|
||||
ub.session.add(auth_token)
|
||||
ub.session_commit()
|
||||
|
||||
books = calibre_db.session.query(db.Books).join(db.Data).all()
|
||||
|
||||
for book in books:
|
||||
formats = [data.format for data in book.data]
|
||||
if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats:
|
||||
helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
||||
|
||||
return render_title_template(
|
||||
"generate_kobo_auth_url.html",
|
||||
title=_(u"Kobo Setup"),
|
||||
kobo_auth_url=url_for(
|
||||
"kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True
|
||||
),
|
||||
warning = False
|
||||
)
|
||||
|
||||
|
||||
@kobo_auth.route("/deleteauthtoken/<int:user_id>")
|
||||
@login_required
|
||||
def delete_auth_token(user_id):
|
||||
# Invalidate any prevously generated Kobo Auth token for this user.
|
||||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
||||
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
||||
|
||||
return ub.session_commit()
|
||||
|
@ -20,7 +20,8 @@
|
||||
from flask_login import current_user
|
||||
from . import ub
|
||||
import datetime
|
||||
from sqlalchemy.sql.expression import or_, and_
|
||||
from sqlalchemy.sql.expression import or_, and_, true
|
||||
from sqlalchemy import exc
|
||||
|
||||
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
|
||||
# do nothing (safety precaution)
|
||||
@ -36,10 +37,18 @@ def add_synced_books(book_id):
|
||||
|
||||
|
||||
# Select all entries of current book in kobo_synced_books table, which are from current user and delete them
|
||||
def remove_synced_book(book_id):
|
||||
ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.book_id == book_id) \
|
||||
.filter(ub.KoboSyncedBooks.user_id == current_user.id).delete()
|
||||
ub.session_commit()
|
||||
def remove_synced_book(book_id, all=False, session=None):
|
||||
if not all:
|
||||
user = ub.KoboSyncedBooks.user_id == current_user.id
|
||||
else:
|
||||
user = true()
|
||||
if not session:
|
||||
ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.book_id == book_id).filter(user).delete()
|
||||
ub.session_commit()
|
||||
else:
|
||||
session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.book_id == book_id).filter(user).delete()
|
||||
ub.session_commit(_session=session)
|
||||
|
||||
|
||||
|
||||
def change_archived_books(book_id, state=None, message=None):
|
||||
@ -56,7 +65,7 @@ def change_archived_books(book_id, state=None, message=None):
|
||||
return archived_book.is_archived
|
||||
|
||||
|
||||
# select all books which are synced by the current user and do not belong to a synced shelf and them to archive
|
||||
# select all books which are synced by the current user and do not belong to a synced shelf and set them to archive
|
||||
# select all shelves from current user which are synced and do not belong to the "only sync" shelves
|
||||
def update_on_sync_shelfs(user_id):
|
||||
books_to_archive = (ub.session.query(ub.KoboSyncedBooks)
|
||||
@ -71,6 +80,7 @@ def update_on_sync_shelfs(user_id):
|
||||
.filter(ub.KoboSyncedBooks.user_id == user_id).delete()
|
||||
ub.session_commit()
|
||||
|
||||
# Search all shelf which are currently not synced
|
||||
shelves_to_archive = ub.session.query(ub.Shelf).filter(ub.Shelf.user_id == user_id).filter(
|
||||
ub.Shelf.kobo_sync == 0).all()
|
||||
for a in shelves_to_archive:
|
||||
|
@ -42,20 +42,15 @@ logging.addLevelName(logging.CRITICAL, "CRIT")
|
||||
|
||||
class _Logger(logging.Logger):
|
||||
|
||||
def debug_or_exception(self, message, *args, **kwargs):
|
||||
def error_or_exception(self, message, stacklevel=2, *args, **kwargs):
|
||||
if sys.version_info > (3, 7):
|
||||
if is_debug_enabled():
|
||||
self.exception(message, stacklevel=2, *args, **kwargs)
|
||||
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
||||
else:
|
||||
self.error(message, stacklevel=2, *args, **kwargs)
|
||||
elif sys.version_info > (3, 0):
|
||||
if is_debug_enabled():
|
||||
self.exception(message, stack_info=True, *args, **kwargs)
|
||||
else:
|
||||
self.error(message, *args, **kwargs)
|
||||
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
||||
else:
|
||||
if is_debug_enabled():
|
||||
self.exception(message, *args, **kwargs)
|
||||
self.exception(message, stack_info=True, *args, **kwargs)
|
||||
else:
|
||||
self.error(message, *args, **kwargs)
|
||||
|
||||
|
73
cps/main.py
Normal file
73
cps/main.py
Normal file
@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2012-2022 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
|
||||
from . import create_app
|
||||
from .jinjia import jinjia
|
||||
from .remotelogin import remotelogin
|
||||
|
||||
def main():
|
||||
app = create_app()
|
||||
|
||||
from .web import web
|
||||
from .opds import opds
|
||||
from .admin import admi
|
||||
from .gdrive import gdrive
|
||||
from .editbooks import editbook
|
||||
from .about import about
|
||||
from .search import search
|
||||
from .search_metadata import meta
|
||||
from .shelf import shelf
|
||||
from .tasks_status import tasks
|
||||
from .error_handler import init_errorhandler
|
||||
try:
|
||||
from .kobo import kobo, get_kobo_activated
|
||||
from .kobo_auth import kobo_auth
|
||||
kobo_available = get_kobo_activated()
|
||||
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
||||
kobo_available = False
|
||||
|
||||
try:
|
||||
from .oauth_bb import oauth
|
||||
oauth_available = True
|
||||
except ImportError:
|
||||
oauth_available = False
|
||||
|
||||
from . import web_server
|
||||
init_errorhandler()
|
||||
|
||||
app.register_blueprint(search)
|
||||
app.register_blueprint(tasks)
|
||||
app.register_blueprint(web)
|
||||
app.register_blueprint(opds)
|
||||
app.register_blueprint(jinjia)
|
||||
app.register_blueprint(about)
|
||||
app.register_blueprint(shelf)
|
||||
app.register_blueprint(admi)
|
||||
app.register_blueprint(remotelogin)
|
||||
app.register_blueprint(meta)
|
||||
app.register_blueprint(gdrive)
|
||||
app.register_blueprint(editbook)
|
||||
if kobo_available:
|
||||
app.register_blueprint(kobo)
|
||||
app.register_blueprint(kobo_auth)
|
||||
if oauth_available:
|
||||
app.register_blueprint(oauth)
|
||||
success = web_server.start()
|
||||
sys.exit(0 if success else 1)
|
141
cps/metadata_provider/amazon.py
Normal file
141
cps/metadata_provider/amazon.py
Normal file
@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2022 quarz12
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import concurrent.futures
|
||||
import requests
|
||||
from bs4 import BeautifulSoup as BS # requirement
|
||||
from typing import List, Optional
|
||||
|
||||
try:
|
||||
import cchardet #optional for better speed
|
||||
except ImportError:
|
||||
pass
|
||||
from cps import logger
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
import cps.logger as logger
|
||||
|
||||
#from time import time
|
||||
from operator import itemgetter
|
||||
log = logger.create()
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class Amazon(Metadata):
|
||||
__name__ = "Amazon"
|
||||
__id__ = "amazon"
|
||||
headers = {'upgrade-insecure-requests': '1',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36',
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
|
||||
'sec-gpc': '1',
|
||||
'sec-fetch-site': 'none',
|
||||
'sec-fetch-mode': 'navigate',
|
||||
'sec-fetch-user': '?1',
|
||||
'sec-fetch-dest': 'document',
|
||||
'accept-encoding': 'gzip, deflate, br',
|
||||
'accept-language': 'en-US,en;q=0.9'}
|
||||
session = requests.Session()
|
||||
session.headers=headers
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
#timer=time()
|
||||
def inner(link, index) -> [dict, int]:
|
||||
with self.session as session:
|
||||
try:
|
||||
r = session.get(f"https://www.amazon.com/{link}")
|
||||
r.raise_for_status()
|
||||
except Exception as ex:
|
||||
log.warning(ex)
|
||||
return
|
||||
long_soup = BS(r.text, "lxml") #~4sec :/
|
||||
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
|
||||
if soup2 is None:
|
||||
return
|
||||
try:
|
||||
match = MetaRecord(
|
||||
title = "",
|
||||
authors = "",
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description="Amazon Books",
|
||||
link="https://amazon.com/"
|
||||
),
|
||||
url = f"https://www.amazon.com{link}",
|
||||
#the more searches the slower, these are too hard to find in reasonable time or might not even exist
|
||||
publisher= "", # very unreliable
|
||||
publishedDate= "", # very unreliable
|
||||
id = None, # ?
|
||||
tags = [] # dont exist on amazon
|
||||
)
|
||||
|
||||
try:
|
||||
match.description = "\n".join(
|
||||
soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\
|
||||
.replace("\xa0"," ")[:-9].strip().strip("\n")
|
||||
except (AttributeError, TypeError):
|
||||
return None # if there is no description it is not a book and therefore should be ignored
|
||||
try:
|
||||
match.title = soup2.find("span", attrs={"id": "productTitle"}).text
|
||||
except (AttributeError, TypeError):
|
||||
match.title = ""
|
||||
try:
|
||||
match.authors = [next(
|
||||
filter(lambda i: i != " " and i != "\n" and not i.startswith("{"),
|
||||
x.findAll(text=True))).strip()
|
||||
for x in soup2.findAll("span", attrs={"class": "author"})]
|
||||
except (AttributeError, TypeError, StopIteration):
|
||||
match.authors = ""
|
||||
try:
|
||||
match.rating = int(
|
||||
soup2.find("span", class_="a-icon-alt").text.split(" ")[0].split(".")[
|
||||
0]) # first number in string
|
||||
except (AttributeError, ValueError):
|
||||
match.rating = 0
|
||||
try:
|
||||
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image frontImage"})["src"]
|
||||
except (AttributeError, TypeError):
|
||||
match.cover = ""
|
||||
return match, index
|
||||
except Exception as e:
|
||||
log.error_or_exception(e)
|
||||
return
|
||||
|
||||
val = list()
|
||||
if self.active:
|
||||
try:
|
||||
results = self.session.get(
|
||||
f"https://www.amazon.com/s?k={query.replace(' ', '+')}&i=digital-text&sprefix={query.replace(' ', '+')}"
|
||||
f"%2Cdigital-text&ref=nb_sb_noss",
|
||||
headers=self.headers)
|
||||
results.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
log.error_or_exception(e)
|
||||
return None
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
soup = BS(results.text, 'html.parser')
|
||||
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in
|
||||
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:5])}
|
||||
val = list(map(lambda x : x.result() ,concurrent.futures.as_completed(fut)))
|
||||
result = list(filter(lambda x: x, val))
|
||||
return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance
|
@ -17,49 +17,76 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# ComicVine api document: https://comicvine.gamespot.com/api/documentation
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from cps.services.Metadata import Metadata
|
||||
from cps import logger
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class ComicVine(Metadata):
|
||||
__name__ = "ComicVine"
|
||||
__id__ = "comicvine"
|
||||
DESCRIPTION = "ComicVine Books"
|
||||
META_URL = "https://comicvine.gamespot.com/"
|
||||
API_KEY = "57558043c53943d5d1e96a9ad425b0eb85532ee6"
|
||||
BASE_URL = (
|
||||
f"https://comicvine.gamespot.com/api/search?api_key={API_KEY}"
|
||||
f"&resources=issue&query="
|
||||
)
|
||||
QUERY_PARAMS = "&sort=name:desc&format=json"
|
||||
HEADERS = {"User-Agent": "Not Evil Browser"}
|
||||
|
||||
def search(self, query, __):
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
val = list()
|
||||
apikey = "57558043c53943d5d1e96a9ad425b0eb85532ee6"
|
||||
if self.active:
|
||||
headers = {
|
||||
'User-Agent': 'Not Evil Browser'
|
||||
}
|
||||
|
||||
result = requests.get("https://comicvine.gamespot.com/api/search?api_key="
|
||||
+ apikey + "&resources=issue&query=" + query + "&sort=name:desc&format=json", headers=headers)
|
||||
for r in result.json()['results']:
|
||||
seriesTitle = r['volume'].get('name', "")
|
||||
if r.get('store_date'):
|
||||
dateFomers = r.get('store_date')
|
||||
else:
|
||||
dateFomers = r.get('date_added')
|
||||
v = dict()
|
||||
v['id'] = r['id']
|
||||
v['title'] = seriesTitle + " #" + r.get('issue_number', "0") + " - " + ( r.get('name', "") or "")
|
||||
v['authors'] = r.get('authors', [])
|
||||
v['description'] = r.get('description', "")
|
||||
v['publisher'] = ""
|
||||
v['publishedDate'] = dateFomers
|
||||
v['tags'] = ["Comics", seriesTitle]
|
||||
v['rating'] = 0
|
||||
v['series'] = seriesTitle
|
||||
v['cover'] = r['image'].get('original_url')
|
||||
v['source'] = {
|
||||
"id": self.__id__,
|
||||
"description": "ComicVine Books",
|
||||
"link": "https://comicvine.gamespot.com/"
|
||||
}
|
||||
v['url'] = r.get('site_detail_url', "")
|
||||
val.append(v)
|
||||
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||
if title_tokens:
|
||||
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||
query = "%20".join(tokens)
|
||||
try:
|
||||
result = requests.get(
|
||||
f"{ComicVine.BASE_URL}{query}{ComicVine.QUERY_PARAMS}",
|
||||
headers=ComicVine.HEADERS,
|
||||
)
|
||||
result.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
for result in result.json()["results"]:
|
||||
match = self._parse_search_result(
|
||||
result=result, generic_cover=generic_cover, locale=locale
|
||||
)
|
||||
val.append(match)
|
||||
return val
|
||||
|
||||
|
||||
def _parse_search_result(
|
||||
self, result: Dict, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
series = result["volume"].get("name", "")
|
||||
series_index = result.get("issue_number", 0)
|
||||
issue_name = result.get("name", "")
|
||||
match = MetaRecord(
|
||||
id=result["id"],
|
||||
title=f"{series}#{series_index} - {issue_name}",
|
||||
authors=result.get("authors", []),
|
||||
url=result.get("site_detail_url", ""),
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description=ComicVine.DESCRIPTION,
|
||||
link=ComicVine.META_URL,
|
||||
),
|
||||
series=series,
|
||||
)
|
||||
match.cover = result["image"].get("original_url", generic_cover)
|
||||
match.description = result.get("description", "")
|
||||
match.publishedDate = result.get("store_date", result.get("date_added"))
|
||||
match.series_index = series_index
|
||||
match.tags = ["Comics", series]
|
||||
match.identifiers = {"comicvine": match.id}
|
||||
return match
|
||||
|
206
cps/metadata_provider/douban.py
Normal file
206
cps/metadata_provider/douban.py
Normal file
@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2022 xlivevil
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import re
|
||||
from concurrent import futures
|
||||
from typing import List, Optional
|
||||
|
||||
import requests
|
||||
from html2text import HTML2Text
|
||||
from lxml import etree
|
||||
|
||||
from cps import logger
|
||||
from cps.services.Metadata import Metadata, MetaRecord, MetaSourceInfo
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
def html2text(html: str) -> str:
|
||||
|
||||
h2t = HTML2Text()
|
||||
h2t.body_width = 0
|
||||
h2t.single_line_break = True
|
||||
h2t.emphasis_mark = "*"
|
||||
return h2t.handle(html)
|
||||
|
||||
|
||||
class Douban(Metadata):
|
||||
__name__ = "豆瓣"
|
||||
__id__ = "douban"
|
||||
DESCRIPTION = "豆瓣"
|
||||
META_URL = "https://book.douban.com/"
|
||||
SEARCH_URL = "https://www.douban.com/j/search"
|
||||
|
||||
ID_PATTERN = re.compile(r"sid: (?P<id>\d+),")
|
||||
AUTHORS_PATTERN = re.compile(r"作者|译者")
|
||||
PUBLISHER_PATTERN = re.compile(r"出版社")
|
||||
SUBTITLE_PATTERN = re.compile(r"副标题")
|
||||
PUBLISHED_DATE_PATTERN = re.compile(r"出版年")
|
||||
SERIES_PATTERN = re.compile(r"丛书")
|
||||
IDENTIFIERS_PATTERN = re.compile(r"ISBN|统一书号")
|
||||
|
||||
TITTLE_XPATH = "//span[@property='v:itemreviewed']"
|
||||
COVER_XPATH = "//a[@class='nbg']"
|
||||
INFO_XPATH = "//*[@id='info']//span[@class='pl']"
|
||||
TAGS_XPATH = "//a[contains(@class, 'tag')]"
|
||||
DESCRIPTION_XPATH = "//div[@id='link-report']//div[@class='intro']"
|
||||
RATING_XPATH = "//div[@class='rating_self clearfix']/strong"
|
||||
|
||||
session = requests.Session()
|
||||
session.headers = {
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 Edg/98.0.1108.56',
|
||||
}
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
if self.active:
|
||||
log.debug(f"starting search {query} on douban")
|
||||
if title_tokens := list(
|
||||
self.get_title_tokens(query, strip_joiners=False)
|
||||
):
|
||||
query = "+".join(title_tokens)
|
||||
|
||||
try:
|
||||
r = self.session.get(
|
||||
self.SEARCH_URL, params={"cat": 1001, "q": query}
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
|
||||
results = r.json()
|
||||
if results["total"] == 0:
|
||||
return []
|
||||
|
||||
book_id_list = [
|
||||
self.ID_PATTERN.search(item).group("id")
|
||||
for item in results["items"][:10] if self.ID_PATTERN.search(item)
|
||||
]
|
||||
|
||||
with futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
|
||||
fut = [
|
||||
executor.submit(self._parse_single_book, book_id, generic_cover)
|
||||
for book_id in book_id_list
|
||||
]
|
||||
|
||||
val = [
|
||||
future.result()
|
||||
for future in futures.as_completed(fut) if future.result()
|
||||
]
|
||||
|
||||
return val
|
||||
|
||||
def _parse_single_book(
|
||||
self, id: str, generic_cover: str = ""
|
||||
) -> Optional[MetaRecord]:
|
||||
url = f"https://book.douban.com/subject/{id}/"
|
||||
|
||||
try:
|
||||
r = self.session.get(url)
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
|
||||
match = MetaRecord(
|
||||
id=id,
|
||||
title="",
|
||||
authors=[],
|
||||
url=url,
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description=self.DESCRIPTION,
|
||||
link=self.META_URL,
|
||||
),
|
||||
)
|
||||
|
||||
html = etree.HTML(r.content.decode("utf8"))
|
||||
|
||||
match.title = html.xpath(self.TITTLE_XPATH)[0].text
|
||||
match.cover = html.xpath(self.COVER_XPATH)[0].attrib["href"] or generic_cover
|
||||
try:
|
||||
rating_num = float(html.xpath(self.RATING_XPATH)[0].text.strip())
|
||||
except Exception:
|
||||
rating_num = 0
|
||||
match.rating = int(-1 * rating_num // 2 * -1) if rating_num else 0
|
||||
|
||||
tag_elements = html.xpath(self.TAGS_XPATH)
|
||||
if len(tag_elements):
|
||||
match.tags = [tag_element.text for tag_element in tag_elements]
|
||||
|
||||
description_element = html.xpath(self.DESCRIPTION_XPATH)
|
||||
if len(description_element):
|
||||
match.description = html2text(etree.tostring(
|
||||
description_element[-1], encoding="utf8").decode("utf8"))
|
||||
|
||||
info = html.xpath(self.INFO_XPATH)
|
||||
|
||||
for element in info:
|
||||
text = element.text
|
||||
if self.AUTHORS_PATTERN.search(text):
|
||||
next = element.getnext()
|
||||
while next is not None and next.tag != "br":
|
||||
match.authors.append(next.text)
|
||||
next = next.getnext()
|
||||
elif self.PUBLISHER_PATTERN.search(text):
|
||||
match.publisher = element.tail.strip()
|
||||
elif self.SUBTITLE_PATTERN.search(text):
|
||||
match.title = f'{match.title}:' + element.tail.strip()
|
||||
elif self.PUBLISHED_DATE_PATTERN.search(text):
|
||||
match.publishedDate = self._clean_date(element.tail.strip())
|
||||
elif self.SUBTITLE_PATTERN.search(text):
|
||||
match.series = element.getnext().text
|
||||
elif i_type := self.IDENTIFIERS_PATTERN.search(text):
|
||||
match.identifiers[i_type.group()] = element.tail.strip()
|
||||
|
||||
return match
|
||||
|
||||
|
||||
def _clean_date(self, date: str) -> str:
|
||||
"""
|
||||
Clean up the date string to be in the format YYYY-MM-DD
|
||||
|
||||
Examples of possible patterns:
|
||||
'2014-7-16', '1988年4月', '1995-04', '2021-8', '2020-12-1', '1996年',
|
||||
'1972', '2004/11/01', '1959年3月北京第1版第1印'
|
||||
"""
|
||||
year = date[:4]
|
||||
moon = "01"
|
||||
day = "01"
|
||||
|
||||
if len(date) > 5:
|
||||
digit = []
|
||||
ls = []
|
||||
for i in range(5, len(date)):
|
||||
if date[i].isdigit():
|
||||
digit.append(date[i])
|
||||
elif digit:
|
||||
ls.append("".join(digit) if len(digit)==2 else f"0{digit[0]}")
|
||||
digit = []
|
||||
if digit:
|
||||
ls.append("".join(digit) if len(digit)==2 else f"0{digit[0]}")
|
||||
|
||||
moon = ls[0]
|
||||
if len(ls)>1:
|
||||
day = ls[1]
|
||||
|
||||
return f"{year}-{moon}-{day}"
|
@ -17,39 +17,101 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Google Books api document: https://developers.google.com/books/docs/v1/using
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from cps.services.Metadata import Metadata
|
||||
|
||||
from cps import logger
|
||||
from cps.isoLanguages import get_lang3, get_language_name
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class Google(Metadata):
|
||||
__name__ = "Google"
|
||||
__id__ = "google"
|
||||
DESCRIPTION = "Google Books"
|
||||
META_URL = "https://books.google.com/"
|
||||
BOOK_URL = "https://books.google.com/books?id="
|
||||
SEARCH_URL = "https://www.googleapis.com/books/v1/volumes?q="
|
||||
ISBN_TYPE = "ISBN_13"
|
||||
|
||||
def search(self, query, __):
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
val = list()
|
||||
if self.active:
|
||||
val = list()
|
||||
result = requests.get("https://www.googleapis.com/books/v1/volumes?q="+query.replace(" ","+"))
|
||||
for r in result.json()['items']:
|
||||
v = dict()
|
||||
v['id'] = r['id']
|
||||
v['title'] = r['volumeInfo']['title']
|
||||
v['authors'] = r['volumeInfo'].get('authors', [])
|
||||
v['description'] = r['volumeInfo'].get('description', "")
|
||||
v['publisher'] = r['volumeInfo'].get('publisher', "")
|
||||
v['publishedDate'] = r['volumeInfo'].get('publishedDate', "")
|
||||
v['tags'] = r['volumeInfo'].get('categories', [])
|
||||
v['rating'] = r['volumeInfo'].get('averageRating', 0)
|
||||
if r['volumeInfo'].get('imageLinks'):
|
||||
v['cover'] = r['volumeInfo']['imageLinks']['thumbnail'].replace("http://", "https://")
|
||||
else:
|
||||
v['cover'] = "/../../../static/generic_cover.jpg"
|
||||
v['source'] = {
|
||||
"id": self.__id__,
|
||||
"description": "Google Books",
|
||||
"link": "https://books.google.com/"}
|
||||
v['url'] = "https://books.google.com/books?id=" + r['id']
|
||||
val.append(v)
|
||||
return val
|
||||
|
||||
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||
if title_tokens:
|
||||
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||
query = "+".join(tokens)
|
||||
try:
|
||||
results = requests.get(Google.SEARCH_URL + query)
|
||||
results.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
for result in results.json().get("items", []):
|
||||
val.append(
|
||||
self._parse_search_result(
|
||||
result=result, generic_cover=generic_cover, locale=locale
|
||||
)
|
||||
)
|
||||
return val
|
||||
|
||||
def _parse_search_result(
|
||||
self, result: Dict, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
match = MetaRecord(
|
||||
id=result["id"],
|
||||
title=result["volumeInfo"]["title"],
|
||||
authors=result["volumeInfo"].get("authors", []),
|
||||
url=Google.BOOK_URL + result["id"],
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__,
|
||||
description=Google.DESCRIPTION,
|
||||
link=Google.META_URL,
|
||||
),
|
||||
)
|
||||
|
||||
match.cover = self._parse_cover(result=result, generic_cover=generic_cover)
|
||||
match.description = result["volumeInfo"].get("description", "")
|
||||
match.languages = self._parse_languages(result=result, locale=locale)
|
||||
match.publisher = result["volumeInfo"].get("publisher", "")
|
||||
match.publishedDate = result["volumeInfo"].get("publishedDate", "")
|
||||
match.rating = result["volumeInfo"].get("averageRating", 0)
|
||||
match.series, match.series_index = "", 1
|
||||
match.tags = result["volumeInfo"].get("categories", [])
|
||||
|
||||
match.identifiers = {"google": match.id}
|
||||
match = self._parse_isbn(result=result, match=match)
|
||||
return match
|
||||
|
||||
@staticmethod
|
||||
def _parse_isbn(result: Dict, match: MetaRecord) -> MetaRecord:
|
||||
identifiers = result["volumeInfo"].get("industryIdentifiers", [])
|
||||
for identifier in identifiers:
|
||||
if identifier.get("type") == Google.ISBN_TYPE:
|
||||
match.identifiers["isbn"] = identifier.get("identifier")
|
||||
break
|
||||
return match
|
||||
|
||||
@staticmethod
|
||||
def _parse_cover(result: Dict, generic_cover: str) -> str:
|
||||
if result["volumeInfo"].get("imageLinks"):
|
||||
cover_url = result["volumeInfo"]["imageLinks"]["thumbnail"]
|
||||
return cover_url.replace("http://", "https://")
|
||||
return generic_cover
|
||||
|
||||
@staticmethod
|
||||
def _parse_languages(result: Dict, locale: str) -> List[str]:
|
||||
language_iso2 = result["volumeInfo"].get("language", "")
|
||||
languages = (
|
||||
[get_language_name(locale, get_lang3(language_iso2))]
|
||||
if language_iso2
|
||||
else []
|
||||
)
|
||||
return languages
|
||||
|
350
cps/metadata_provider/lubimyczytac.py
Normal file
350
cps/metadata_provider/lubimyczytac.py
Normal file
@ -0,0 +1,350 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2021 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from dateutil import parser
|
||||
from html2text import HTML2Text
|
||||
from lxml.html import HtmlElement, fromstring, tostring
|
||||
from markdown2 import Markdown
|
||||
|
||||
from cps import logger
|
||||
from cps.isoLanguages import get_language_name
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
|
||||
log = logger.create()
|
||||
|
||||
SYMBOLS_TO_TRANSLATE = (
|
||||
"öÖüÜóÓőŐúÚéÉáÁűŰíÍąĄćĆęĘłŁńŃóÓśŚźŹżŻ",
|
||||
"oOuUoOoOuUeEaAuUiIaAcCeElLnNoOsSzZzZ",
|
||||
)
|
||||
SYMBOL_TRANSLATION_MAP = dict(
|
||||
[(ord(a), ord(b)) for (a, b) in zip(*SYMBOLS_TO_TRANSLATE)]
|
||||
)
|
||||
|
||||
|
||||
def get_int_or_float(value: str) -> Union[int, float]:
|
||||
number_as_float = float(value)
|
||||
number_as_int = int(number_as_float)
|
||||
return number_as_int if number_as_float == number_as_int else number_as_float
|
||||
|
||||
|
||||
def strip_accents(s: Optional[str]) -> Optional[str]:
|
||||
return s.translate(SYMBOL_TRANSLATION_MAP) if s is not None else s
|
||||
|
||||
|
||||
def sanitize_comments_html(html: str) -> str:
|
||||
text = html2text(html)
|
||||
md = Markdown()
|
||||
html = md.convert(text)
|
||||
return html
|
||||
|
||||
|
||||
def html2text(html: str) -> str:
|
||||
# replace <u> tags with <span> as <u> becomes emphasis in html2text
|
||||
if isinstance(html, bytes):
|
||||
html = html.decode("utf-8")
|
||||
html = re.sub(
|
||||
r"<\s*(?P<solidus>/?)\s*[uU]\b(?P<rest>[^>]*)>",
|
||||
r"<\g<solidus>span\g<rest>>",
|
||||
html,
|
||||
)
|
||||
h2t = HTML2Text()
|
||||
h2t.body_width = 0
|
||||
h2t.single_line_break = True
|
||||
h2t.emphasis_mark = "*"
|
||||
return h2t.handle(html)
|
||||
|
||||
|
||||
class LubimyCzytac(Metadata):
|
||||
__name__ = "LubimyCzytac.pl"
|
||||
__id__ = "lubimyczytac"
|
||||
|
||||
BASE_URL = "https://lubimyczytac.pl"
|
||||
|
||||
BOOK_SEARCH_RESULT_XPATH = (
|
||||
"*//div[@class='listSearch']//div[@class='authorAllBooks__single']"
|
||||
)
|
||||
SINGLE_BOOK_RESULT_XPATH = ".//div[contains(@class,'authorAllBooks__singleText')]"
|
||||
TITLE_PATH = "/div/a[contains(@class,'authorAllBooks__singleTextTitle')]"
|
||||
TITLE_TEXT_PATH = f"{TITLE_PATH}//text()"
|
||||
URL_PATH = f"{TITLE_PATH}/@href"
|
||||
AUTHORS_PATH = "/div/a[contains(@href,'autor')]//text()"
|
||||
|
||||
SIBLINGS = "/following-sibling::dd"
|
||||
|
||||
CONTAINER = "//section[@class='container book']"
|
||||
PUBLISHER = f"{CONTAINER}//dt[contains(text(),'Wydawnictwo:')]{SIBLINGS}/a/text()"
|
||||
LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()"
|
||||
DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']"
|
||||
SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]/text()"
|
||||
|
||||
DETAILS = "//div[@id='book-details']"
|
||||
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
||||
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
|
||||
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
|
||||
TAGS = "//nav[@aria-label='breadcrumb']//a[contains(@href,'/ksiazki/k/')]/text()"
|
||||
|
||||
RATING = "//meta[@property='books:rating:value']/@content"
|
||||
COVER = "//meta[@property='og:image']/@content"
|
||||
ISBN = "//meta[@property='books:isbn']/@content"
|
||||
META_TITLE = "//meta[@property='og:description']/@content"
|
||||
|
||||
SUMMARY = "//script[@type='application/ld+json']//text()"
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
if self.active:
|
||||
try:
|
||||
result = requests.get(self._prepare_query(title=query))
|
||||
result.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
root = fromstring(result.text)
|
||||
lc_parser = LubimyCzytacParser(root=root, metadata=self)
|
||||
matches = lc_parser.parse_search_results()
|
||||
if matches:
|
||||
with ThreadPool(processes=10) as pool:
|
||||
final_matches = pool.starmap(
|
||||
lc_parser.parse_single_book,
|
||||
[(match, generic_cover, locale) for match in matches],
|
||||
)
|
||||
return final_matches
|
||||
return matches
|
||||
|
||||
def _prepare_query(self, title: str) -> str:
|
||||
query = ""
|
||||
characters_to_remove = "\?()\/"
|
||||
pattern = "[" + characters_to_remove + "]"
|
||||
title = re.sub(pattern, "", title)
|
||||
title = title.replace("_", " ")
|
||||
if '"' in title or ",," in title:
|
||||
title = title.split('"')[0].split(",,")[0]
|
||||
|
||||
if "/" in title:
|
||||
title_tokens = [
|
||||
token for token in title.lower().split(" ") if len(token) > 1
|
||||
]
|
||||
else:
|
||||
title_tokens = list(self.get_title_tokens(title, strip_joiners=False))
|
||||
if title_tokens:
|
||||
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||
query = query + "%20".join(tokens)
|
||||
if not query:
|
||||
return ""
|
||||
return f"{LubimyCzytac.BASE_URL}/szukaj/ksiazki?phrase={query}"
|
||||
|
||||
|
||||
class LubimyCzytacParser:
|
||||
PAGES_TEMPLATE = "<p id='strony'>Książka ma {0} stron(y).</p>"
|
||||
PUBLISH_DATE_TEMPLATE = "<p id='pierwsze_wydanie'>Data pierwszego wydania: {0}</p>"
|
||||
PUBLISH_DATE_PL_TEMPLATE = (
|
||||
"<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>"
|
||||
)
|
||||
|
||||
def __init__(self, root: HtmlElement, metadata: Metadata) -> None:
|
||||
self.root = root
|
||||
self.metadata = metadata
|
||||
|
||||
def parse_search_results(self) -> List[MetaRecord]:
|
||||
matches = []
|
||||
results = self.root.xpath(LubimyCzytac.BOOK_SEARCH_RESULT_XPATH)
|
||||
for result in results:
|
||||
title = self._parse_xpath_node(
|
||||
root=result,
|
||||
xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}"
|
||||
f"{LubimyCzytac.TITLE_TEXT_PATH}",
|
||||
)
|
||||
|
||||
book_url = self._parse_xpath_node(
|
||||
root=result,
|
||||
xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}"
|
||||
f"{LubimyCzytac.URL_PATH}",
|
||||
)
|
||||
authors = self._parse_xpath_node(
|
||||
root=result,
|
||||
xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}"
|
||||
f"{LubimyCzytac.AUTHORS_PATH}",
|
||||
take_first=False,
|
||||
)
|
||||
if not all([title, book_url, authors]):
|
||||
continue
|
||||
matches.append(
|
||||
MetaRecord(
|
||||
id=book_url.replace(f"/ksiazka/", "").split("/")[0],
|
||||
title=title,
|
||||
authors=[strip_accents(author) for author in authors],
|
||||
url=LubimyCzytac.BASE_URL + book_url,
|
||||
source=MetaSourceInfo(
|
||||
id=self.metadata.__id__,
|
||||
description=self.metadata.__name__,
|
||||
link=LubimyCzytac.BASE_URL,
|
||||
),
|
||||
)
|
||||
)
|
||||
return matches
|
||||
|
||||
def parse_single_book(
|
||||
self, match: MetaRecord, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
try:
|
||||
response = requests.get(match.url)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
self.root = fromstring(response.text)
|
||||
match.cover = self._parse_cover(generic_cover=generic_cover)
|
||||
match.description = self._parse_description()
|
||||
match.languages = self._parse_languages(locale=locale)
|
||||
match.publisher = self._parse_publisher()
|
||||
match.publishedDate = self._parse_from_summary(attribute_name="datePublished")
|
||||
match.rating = self._parse_rating()
|
||||
match.series, match.series_index = self._parse_series()
|
||||
match.tags = self._parse_tags()
|
||||
match.identifiers = {
|
||||
"isbn": self._parse_isbn(),
|
||||
"lubimyczytac": match.id,
|
||||
}
|
||||
return match
|
||||
|
||||
def _parse_xpath_node(
|
||||
self,
|
||||
xpath: str,
|
||||
root: HtmlElement = None,
|
||||
take_first: bool = True,
|
||||
strip_element: bool = True,
|
||||
) -> Optional[Union[str, List[str]]]:
|
||||
root = root if root is not None else self.root
|
||||
node = root.xpath(xpath)
|
||||
if not node:
|
||||
return None
|
||||
return (
|
||||
(node[0].strip() if strip_element else node[0])
|
||||
if take_first
|
||||
else [x.strip() for x in node]
|
||||
)
|
||||
|
||||
def _parse_cover(self, generic_cover) -> Optional[str]:
|
||||
return (
|
||||
self._parse_xpath_node(xpath=LubimyCzytac.COVER, take_first=True)
|
||||
or generic_cover
|
||||
)
|
||||
|
||||
def _parse_publisher(self) -> Optional[str]:
|
||||
return self._parse_xpath_node(xpath=LubimyCzytac.PUBLISHER, take_first=True)
|
||||
|
||||
def _parse_languages(self, locale: str) -> List[str]:
|
||||
languages = list()
|
||||
lang = self._parse_xpath_node(xpath=LubimyCzytac.LANGUAGES, take_first=True)
|
||||
if lang:
|
||||
if "polski" in lang:
|
||||
languages.append("pol")
|
||||
if "angielski" in lang:
|
||||
languages.append("eng")
|
||||
return [get_language_name(locale, language) for language in languages]
|
||||
|
||||
def _parse_series(self) -> Tuple[Optional[str], Optional[Union[float, int]]]:
|
||||
series_index = 0
|
||||
series = self._parse_xpath_node(xpath=LubimyCzytac.SERIES, take_first=True)
|
||||
if series:
|
||||
if "tom " in series:
|
||||
series_name, series_info = series.split(" (tom ", 1)
|
||||
series_info = series_info.replace(" ", "").replace(")", "")
|
||||
# Check if book is not a bundle, i.e. chapter 1-3
|
||||
if "-" in series_info:
|
||||
series_info = series_info.split("-", 1)[0]
|
||||
if series_info.replace(".", "").isdigit() is True:
|
||||
series_index = get_int_or_float(series_info)
|
||||
return series_name, series_index
|
||||
return None, None
|
||||
|
||||
def _parse_tags(self) -> List[str]:
|
||||
tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False)
|
||||
return [
|
||||
strip_accents(w.replace(", itd.", " itd."))
|
||||
for w in tags
|
||||
if isinstance(w, str)
|
||||
]
|
||||
|
||||
def _parse_from_summary(self, attribute_name: str) -> Optional[str]:
|
||||
value = None
|
||||
summary_text = self._parse_xpath_node(xpath=LubimyCzytac.SUMMARY)
|
||||
if summary_text:
|
||||
data = json.loads(summary_text)
|
||||
value = data.get(attribute_name)
|
||||
return value.strip() if value is not None else value
|
||||
|
||||
def _parse_rating(self) -> Optional[str]:
|
||||
rating = self._parse_xpath_node(xpath=LubimyCzytac.RATING)
|
||||
return round(float(rating.replace(",", ".")) / 2) if rating else rating
|
||||
|
||||
def _parse_date(self, xpath="first_publish") -> Optional[datetime.datetime]:
|
||||
options = {
|
||||
"first_publish": LubimyCzytac.FIRST_PUBLISH_DATE,
|
||||
"first_publish_pl": LubimyCzytac.FIRST_PUBLISH_DATE_PL,
|
||||
}
|
||||
date = self._parse_xpath_node(xpath=options.get(xpath))
|
||||
return parser.parse(date) if date else None
|
||||
|
||||
def _parse_isbn(self) -> Optional[str]:
|
||||
return self._parse_xpath_node(xpath=LubimyCzytac.ISBN)
|
||||
|
||||
def _parse_description(self) -> str:
|
||||
description = ""
|
||||
description_node = self._parse_xpath_node(
|
||||
xpath=LubimyCzytac.DESCRIPTION, strip_element=False
|
||||
)
|
||||
if description_node is not None:
|
||||
for source in self.root.xpath('//p[@class="source"]'):
|
||||
source.getparent().remove(source)
|
||||
description = tostring(description_node, method="html")
|
||||
description = sanitize_comments_html(description)
|
||||
|
||||
else:
|
||||
description_node = self._parse_xpath_node(xpath=LubimyCzytac.META_TITLE)
|
||||
if description_node is not None:
|
||||
description = description_node
|
||||
description = sanitize_comments_html(description)
|
||||
description = self._add_extra_info_to_description(description=description)
|
||||
return description
|
||||
|
||||
def _add_extra_info_to_description(self, description: str) -> str:
|
||||
pages = self._parse_from_summary(attribute_name="numberOfPages")
|
||||
if pages:
|
||||
description += LubimyCzytacParser.PAGES_TEMPLATE.format(pages)
|
||||
|
||||
first_publish_date = self._parse_date()
|
||||
if first_publish_date:
|
||||
description += LubimyCzytacParser.PUBLISH_DATE_TEMPLATE.format(
|
||||
first_publish_date.strftime("%d.%m.%Y")
|
||||
)
|
||||
|
||||
first_publish_date_pl = self._parse_date(xpath="first_publish_pl")
|
||||
if first_publish_date_pl:
|
||||
description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format(
|
||||
first_publish_date_pl.strftime("%d.%m.%Y")
|
||||
)
|
||||
|
||||
return description
|
@ -15,47 +15,67 @@
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import itertools
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from scholarly import scholarly
|
||||
try:
|
||||
from fake_useragent.errors import FakeUserAgentError
|
||||
except (ImportError):
|
||||
FakeUserAgentError = BaseException
|
||||
try:
|
||||
from scholarly import scholarly
|
||||
except FakeUserAgentError:
|
||||
raise ImportError("No module named 'scholarly'")
|
||||
|
||||
from cps.services.Metadata import Metadata
|
||||
from cps import logger
|
||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class scholar(Metadata):
|
||||
__name__ = "Google Scholar"
|
||||
__id__ = "googlescholar"
|
||||
META_URL = "https://scholar.google.com/"
|
||||
|
||||
def search(self, query, generic_cover=""):
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
val = list()
|
||||
if self.active:
|
||||
scholar_gen = scholarly.search_pubs(' '.join(query.split('+')))
|
||||
i = 0
|
||||
for publication in scholar_gen:
|
||||
v = dict()
|
||||
v['id'] = "1234" # publication['bib'].get('title')
|
||||
v['title'] = publication['bib'].get('title')
|
||||
v['authors'] = publication['bib'].get('author', [])
|
||||
v['description'] = publication['bib'].get('abstract', "")
|
||||
v['publisher'] = publication['bib'].get('venue', "")
|
||||
if publication['bib'].get('pub_year'):
|
||||
v['publishedDate'] = publication['bib'].get('pub_year')+"-01-01"
|
||||
else:
|
||||
v['publishedDate'] = ""
|
||||
v['tags'] = ""
|
||||
v['ratings'] = 0
|
||||
v['series'] = ""
|
||||
v['cover'] = generic_cover
|
||||
v['url'] = publication.get('pub_url') or publication.get('eprint_url') or "",
|
||||
v['source'] = {
|
||||
"id": self.__id__,
|
||||
"description": "Google Scholar",
|
||||
"link": "https://scholar.google.com/"
|
||||
}
|
||||
val.append(v)
|
||||
i += 1
|
||||
if (i >= 10):
|
||||
break
|
||||
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||
if title_tokens:
|
||||
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||
query = " ".join(tokens)
|
||||
try:
|
||||
scholar_gen = itertools.islice(scholarly.search_pubs(query), 10)
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
for result in scholar_gen:
|
||||
match = self._parse_search_result(
|
||||
result=result, generic_cover="", locale=locale
|
||||
)
|
||||
val.append(match)
|
||||
return val
|
||||
|
||||
def _parse_search_result(
|
||||
self, result: Dict, generic_cover: str, locale: str
|
||||
) -> MetaRecord:
|
||||
match = MetaRecord(
|
||||
id=result.get("pub_url", result.get("eprint_url", "")),
|
||||
title=result["bib"].get("title"),
|
||||
authors=result["bib"].get("author", []),
|
||||
url=result.get("pub_url", result.get("eprint_url", "")),
|
||||
source=MetaSourceInfo(
|
||||
id=self.__id__, description=self.__name__, link=scholar.META_URL
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
match.cover = result.get("image", {}).get("original_url", generic_cover)
|
||||
match.description = unquote(result["bib"].get("abstract", ""))
|
||||
match.publisher = result["bib"].get("venue", "")
|
||||
match.publishedDate = result["bib"].get("pub_year") + "-01-01"
|
||||
match.identifiers = {"scholar": match.id}
|
||||
return match
|
||||
|
14
cps/oauth.py
14
cps/oauth.py
@ -19,18 +19,12 @@
|
||||
from flask import session
|
||||
|
||||
try:
|
||||
from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user
|
||||
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend
|
||||
from flask_dance.consumer.storage.sqla import first, _get_real_user
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
backend_resultcode = False # prevent storing values with this resultcode
|
||||
backend_resultcode = True # prevent storing values with this resultcode
|
||||
except ImportError:
|
||||
# fails on flask-dance >1.3, due to renaming
|
||||
try:
|
||||
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend
|
||||
from flask_dance.consumer.storage.sqla import first, _get_real_user
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
backend_resultcode = True # prevent storing values with this resultcode
|
||||
except ImportError:
|
||||
pass
|
||||
pass
|
||||
|
||||
|
||||
class OAuthBackend(SQLAlchemyBackend):
|
||||
|
@ -149,7 +149,7 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
|
||||
log.info("Link to {} Succeeded".format(provider_name))
|
||||
return redirect(url_for('web.profile'))
|
||||
except Exception as ex:
|
||||
log.debug_or_exception(ex)
|
||||
log.error_or_exception(ex)
|
||||
ub.session.rollback()
|
||||
else:
|
||||
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
|
||||
@ -197,7 +197,7 @@ def unlink_oauth(provider):
|
||||
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
|
||||
log.info("Unlink to {} Succeeded".format(oauth_check[provider]))
|
||||
except Exception as ex:
|
||||
log.debug_or_exception(ex)
|
||||
log.error_or_exception(ex)
|
||||
ub.session.rollback()
|
||||
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
|
||||
except NoResultFound:
|
||||
|
338
cps/opds.py
338
cps/opds.py
@ -26,10 +26,12 @@ from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, render_template, Response, g, make_response, abort
|
||||
from flask_login import current_user
|
||||
from flask_babel import get_locale
|
||||
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
from werkzeug.security import check_password_hash
|
||||
from tornado.httputil import HTTPServerRequest
|
||||
from . import constants, logger, config, db, calibre_db, ub, services, get_locale, isoLanguages
|
||||
|
||||
from . import constants, logger, config, db, calibre_db, ub, services, isoLanguages
|
||||
from .helper import get_download_link, get_book_cover
|
||||
from .pagination import Pagination
|
||||
from .web import render_read_books
|
||||
@ -54,20 +56,6 @@ def requires_basic_auth_if_no_ano(f):
|
||||
return decorated
|
||||
|
||||
|
||||
class FeedObject:
|
||||
def __init__(self, rating_id, rating_name):
|
||||
self.rating_id = rating_id
|
||||
self.rating_name = rating_name
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.rating_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.rating_name
|
||||
|
||||
|
||||
@opds.route("/opds/")
|
||||
@opds.route("/opds")
|
||||
@requires_basic_auth_if_no_ano
|
||||
@ -86,7 +74,7 @@ def feed_osd():
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_cc_search(query):
|
||||
# Handle strange query from Libera Reader with + instead of spaces
|
||||
plus_query = unquote_plus(request.base_url.split('/opds/search/')[1]).strip()
|
||||
plus_query = unquote_plus(request.environ['RAW_URI'].split('/opds/search/')[1]).strip()
|
||||
return feed_search(plus_query)
|
||||
|
||||
|
||||
@ -99,26 +87,7 @@ def feed_normal_search():
|
||||
@opds.route("/opds/books")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_booksindex():
|
||||
shift = 0
|
||||
off = int(request.args.get("offset") or 0)
|
||||
entries = calibre_db.session.query(func.upper(func.substr(db.Books.sort, 1, 1)).label('id'))\
|
||||
.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(db.Books.sort, 1, 1))).all()
|
||||
|
||||
elements = []
|
||||
if off == 0:
|
||||
elements.append({'id': "00", 'name':_("All")})
|
||||
shift = 1
|
||||
for entry in entries[
|
||||
off + shift - 1:
|
||||
int(off + int(config.config_books_per_page) - shift)]:
|
||||
elements.append({'id': entry.id, 'name': entry.id})
|
||||
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(entries) + 1)
|
||||
return render_xml_template('feed.xml',
|
||||
letterelements=elements,
|
||||
folder='opds.feed_letter_books',
|
||||
pagination=pagination)
|
||||
return render_element_index(db.Books.sort, None, 'opds.feed_letter_books')
|
||||
|
||||
|
||||
@opds.route("/opds/books/letter/<book_id>")
|
||||
@ -129,7 +98,8 @@ def feed_letter_books(book_id):
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books,
|
||||
letter,
|
||||
[db.Books.sort])
|
||||
[db.Books.sort],
|
||||
True, config.config_read_column)
|
||||
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
|
||||
@ -139,15 +109,16 @@ def feed_letter_books(book_id):
|
||||
def feed_new():
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books, True, [db.Books.timestamp.desc()])
|
||||
db.Books, True, [db.Books.timestamp.desc()],
|
||||
True, config.config_read_column)
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
|
||||
|
||||
@opds.route("/opds/discover")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_discover():
|
||||
entries = calibre_db.session.query(db.Books).filter(calibre_db.common_filters()).order_by(func.random())\
|
||||
.limit(config.config_books_per_page)
|
||||
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
|
||||
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
|
||||
@ -158,7 +129,8 @@ def feed_best_rated():
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
|
||||
[db.Books.timestamp.desc()])
|
||||
[db.Books.timestamp.desc()],
|
||||
True, config.config_read_column)
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
|
||||
|
||||
@ -171,43 +143,23 @@ def feed_hot():
|
||||
hot_books = all_books.offset(off).limit(config.config_books_per_page)
|
||||
entries = list()
|
||||
for book in hot_books:
|
||||
downloadBook = calibre_db.get_book(book.Downloads.book_id)
|
||||
if downloadBook:
|
||||
entries.append(
|
||||
calibre_db.get_filtered_book(book.Downloads.book_id)
|
||||
)
|
||||
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||
download_book = query.filter(calibre_db.common_filters()).filter(
|
||||
book.Downloads.book_id == db.Books.id).first()
|
||||
if download_book:
|
||||
entries.append(download_book)
|
||||
else:
|
||||
ub.delete_download(book.Downloads.book_id)
|
||||
numBooks = entries.__len__()
|
||||
num_books = entries.__len__()
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1),
|
||||
config.config_books_per_page, numBooks)
|
||||
config.config_books_per_page, num_books)
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
|
||||
|
||||
@opds.route("/opds/author")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_authorindex():
|
||||
shift = 0
|
||||
off = int(request.args.get("offset") or 0)
|
||||
entries = calibre_db.session.query(func.upper(func.substr(db.Authors.sort, 1, 1)).label('id'))\
|
||||
.join(db.books_authors_link).join(db.Books).filter(calibre_db.common_filters())\
|
||||
.group_by(func.upper(func.substr(db.Authors.sort, 1, 1))).all()
|
||||
|
||||
elements = []
|
||||
if off == 0:
|
||||
elements.append({'id': "00", 'name':_("All")})
|
||||
shift = 1
|
||||
for entry in entries[
|
||||
off + shift - 1:
|
||||
int(off + int(config.config_books_per_page) - shift)]:
|
||||
elements.append({'id': entry.id, 'name': entry.id})
|
||||
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(entries) + 1)
|
||||
return render_xml_template('feed.xml',
|
||||
letterelements=elements,
|
||||
folder='opds.feed_letter_author',
|
||||
pagination=pagination)
|
||||
return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
|
||||
|
||||
|
||||
@opds.route("/opds/author/letter/<book_id>")
|
||||
@ -228,12 +180,7 @@ def feed_letter_author(book_id):
|
||||
@opds.route("/opds/author/<int:book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_author(book_id):
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books,
|
||||
db.Books.authors.any(db.Authors.id == book_id),
|
||||
[db.Books.timestamp.desc()])
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
return render_xml_dataset(db.Authors, book_id)
|
||||
|
||||
|
||||
@opds.route("/opds/publisher")
|
||||
@ -254,37 +201,14 @@ def feed_publisherindex():
|
||||
@opds.route("/opds/publisher/<int:book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_publisher(book_id):
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books,
|
||||
db.Books.publishers.any(db.Publishers.id == book_id),
|
||||
[db.Books.timestamp.desc()])
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
return render_xml_dataset(db.Publishers, book_id)
|
||||
|
||||
|
||||
@opds.route("/opds/category")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_categoryindex():
|
||||
shift = 0
|
||||
off = int(request.args.get("offset") or 0)
|
||||
entries = calibre_db.session.query(func.upper(func.substr(db.Tags.name, 1, 1)).label('id'))\
|
||||
.join(db.books_tags_link).join(db.Books).filter(calibre_db.common_filters())\
|
||||
.group_by(func.upper(func.substr(db.Tags.name, 1, 1))).all()
|
||||
elements = []
|
||||
if off == 0:
|
||||
elements.append({'id': "00", 'name':_("All")})
|
||||
shift = 1
|
||||
for entry in entries[
|
||||
off + shift - 1:
|
||||
int(off + int(config.config_books_per_page) - shift)]:
|
||||
elements.append({'id': entry.id, 'name': entry.id})
|
||||
return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
|
||||
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(entries) + 1)
|
||||
return render_xml_template('feed.xml',
|
||||
letterelements=elements,
|
||||
folder='opds.feed_letter_category',
|
||||
pagination=pagination)
|
||||
|
||||
@opds.route("/opds/category/letter/<book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
@ -306,36 +230,14 @@ def feed_letter_category(book_id):
|
||||
@opds.route("/opds/category/<int:book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_category(book_id):
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books,
|
||||
db.Books.tags.any(db.Tags.id == book_id),
|
||||
[db.Books.timestamp.desc()])
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
return render_xml_dataset(db.Tags, book_id)
|
||||
|
||||
|
||||
@opds.route("/opds/series")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_seriesindex():
|
||||
shift = 0
|
||||
off = int(request.args.get("offset") or 0)
|
||||
entries = calibre_db.session.query(func.upper(func.substr(db.Series.sort, 1, 1)).label('id'))\
|
||||
.join(db.books_series_link).join(db.Books).filter(calibre_db.common_filters())\
|
||||
.group_by(func.upper(func.substr(db.Series.sort, 1, 1))).all()
|
||||
elements = []
|
||||
if off == 0:
|
||||
elements.append({'id': "00", 'name':_("All")})
|
||||
shift = 1
|
||||
for entry in entries[
|
||||
off + shift - 1:
|
||||
int(off + int(config.config_books_per_page) - shift)]:
|
||||
elements.append({'id': entry.id, 'name': entry.id})
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(entries) + 1)
|
||||
return render_xml_template('feed.xml',
|
||||
letterelements=elements,
|
||||
folder='opds.feed_letter_series',
|
||||
pagination=pagination)
|
||||
return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
|
||||
|
||||
|
||||
@opds.route("/opds/series/letter/<book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
@ -361,7 +263,8 @@ def feed_series(book_id):
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books,
|
||||
db.Books.series.any(db.Series.id == book_id),
|
||||
[db.Books.series_index])
|
||||
[db.Books.series_index],
|
||||
True, config.config_read_column)
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
|
||||
|
||||
@ -370,7 +273,7 @@ def feed_series(book_id):
|
||||
def feed_ratingindex():
|
||||
off = request.args.get("offset") or 0
|
||||
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
||||
(db.Ratings.rating / 2).label('name')) \
|
||||
(db.Ratings.rating / 2).label('name')) \
|
||||
.join(db.books_ratings_link)\
|
||||
.join(db.Books)\
|
||||
.filter(calibre_db.common_filters()) \
|
||||
@ -388,12 +291,7 @@ def feed_ratingindex():
|
||||
@opds.route("/opds/ratings/<book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_ratings(book_id):
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books,
|
||||
db.Books.ratings.any(db.Ratings.id == book_id),
|
||||
[db.Books.timestamp.desc()])
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
return render_xml_dataset(db.Ratings, book_id)
|
||||
|
||||
|
||||
@opds.route("/opds/formats")
|
||||
@ -420,7 +318,8 @@ def feed_format(book_id):
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books,
|
||||
db.Books.data.any(db.Data.format == book_id.upper()),
|
||||
[db.Books.timestamp.desc()])
|
||||
[db.Books.timestamp.desc()],
|
||||
True, config.config_read_column)
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
|
||||
|
||||
@ -432,17 +331,9 @@ def feed_languagesindex():
|
||||
if current_user.filter_language() == u"all":
|
||||
languages = calibre_db.speaking_language()
|
||||
else:
|
||||
#try:
|
||||
# cur_l = LC.parse(current_user.filter_language())
|
||||
#except UnknownLocaleError:
|
||||
# cur_l = None
|
||||
languages = calibre_db.session.query(db.Languages).filter(
|
||||
db.Languages.lang_code == current_user.filter_language()).all()
|
||||
languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
|
||||
#if cur_l:
|
||||
# languages[0].name = cur_l.get_language_name(get_locale())
|
||||
#else:
|
||||
# languages[0].name = _(isoLanguages.get(part3=languages[0].lang_code).name)
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(languages))
|
||||
return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination)
|
||||
@ -455,7 +346,8 @@ def feed_languages(book_id):
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books,
|
||||
db.Books.languages.any(db.Languages.id == book_id),
|
||||
[db.Books.timestamp.desc()])
|
||||
[db.Books.timestamp.desc()],
|
||||
True, config.config_read_column)
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
|
||||
|
||||
@ -485,13 +377,25 @@ def feed_shelf(book_id):
|
||||
result = list()
|
||||
# user is allowed to access shelf
|
||||
if shelf:
|
||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == book_id).order_by(
|
||||
ub.BookShelf.order.asc()).all()
|
||||
for book in books_in_shelf:
|
||||
cur_book = calibre_db.get_book(book.book_id)
|
||||
result.append(cur_book)
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(result))
|
||||
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
||||
config.config_books_per_page,
|
||||
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))
|
||||
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||
|
||||
|
||||
@ -499,7 +403,7 @@ def feed_shelf(book_id):
|
||||
@requires_basic_auth_if_no_ano
|
||||
def opds_download_link(book_id, book_format):
|
||||
# I gave up with this: With enabled ldap login, the user doesn't get logged in, therefore it's always guest
|
||||
# workaround, loading the user from the request and checking it's download rights here
|
||||
# workaround, loading the user from the request and checking its download rights here
|
||||
# in case of anonymous browsing user is None
|
||||
user = load_user_from_request(request) or current_user
|
||||
if not user.role_download():
|
||||
@ -525,47 +429,6 @@ def get_metadata_calibre_companion(uuid, library):
|
||||
return ""
|
||||
|
||||
|
||||
def feed_search(term):
|
||||
if term:
|
||||
entries, __, ___ = calibre_db.get_search_results(term, config_read_column=config.config_read_column)
|
||||
entries_count = len(entries) if len(entries) > 0 else 1
|
||||
pagination = Pagination(1, entries_count, entries_count)
|
||||
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination)
|
||||
else:
|
||||
return render_xml_template('feed.xml', searchterm="")
|
||||
|
||||
|
||||
def check_auth(username, password):
|
||||
try:
|
||||
username = username.encode('windows-1252')
|
||||
except UnicodeEncodeError:
|
||||
username = username.encode('utf-8')
|
||||
user = ub.session.query(ub.User).filter(func.lower(ub.User.name) ==
|
||||
username.decode('utf-8').lower()).first()
|
||||
if bool(user and check_password_hash(str(user.password), password)):
|
||||
return True
|
||||
else:
|
||||
ip_Address = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
log.warning('OPDS Login failed for user "%s" IP-address: %s', username.decode('utf-8'), ip_Address)
|
||||
return False
|
||||
|
||||
|
||||
def authenticate():
|
||||
return Response(
|
||||
'Could not verify your access level for that URL.\n'
|
||||
'You have to login with proper credentials', 401,
|
||||
{'WWW-Authenticate': 'Basic realm="Login Required"'})
|
||||
|
||||
|
||||
def render_xml_template(*args, **kwargs):
|
||||
# ToDo: return time in current timezone similar to %z
|
||||
currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
||||
xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, *args, **kwargs)
|
||||
response = make_response(xml)
|
||||
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
|
||||
return response
|
||||
|
||||
|
||||
@opds.route("/opds/thumb_240_240/<book_id>")
|
||||
@opds.route("/opds/cover_240_240/<book_id>")
|
||||
@opds.route("/opds/cover_90_90/<book_id>")
|
||||
@ -589,3 +452,92 @@ def feed_unread_books():
|
||||
off = request.args.get("offset") or 0
|
||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
|
||||
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||
|
||||
|
||||
class FeedObject:
|
||||
def __init__(self, rating_id, rating_name):
|
||||
self.rating_id = rating_id
|
||||
self.rating_name = rating_name
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.rating_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.rating_name
|
||||
|
||||
|
||||
def feed_search(term):
|
||||
if term:
|
||||
entries, __, ___ = calibre_db.get_search_results(term, config=config)
|
||||
entries_count = len(entries) if len(entries) > 0 else 1
|
||||
pagination = Pagination(1, entries_count, entries_count)
|
||||
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination)
|
||||
else:
|
||||
return render_xml_template('feed.xml', searchterm="")
|
||||
|
||||
|
||||
def check_auth(username, password):
|
||||
try:
|
||||
username = username.encode('windows-1252')
|
||||
except UnicodeEncodeError:
|
||||
username = username.encode('utf-8')
|
||||
user = ub.session.query(ub.User).filter(func.lower(ub.User.name) ==
|
||||
username.decode('utf-8').lower()).first()
|
||||
if bool(user and check_password_hash(str(user.password), password)):
|
||||
return True
|
||||
else:
|
||||
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
log.warning('OPDS Login failed for user "%s" IP-address: %s', username.decode('utf-8'), ip_address)
|
||||
return False
|
||||
|
||||
|
||||
def authenticate():
|
||||
return Response(
|
||||
'Could not verify your access level for that URL.\n'
|
||||
'You have to login with proper credentials', 401,
|
||||
{'WWW-Authenticate': 'Basic realm="Login Required"'})
|
||||
|
||||
|
||||
def render_xml_template(*args, **kwargs):
|
||||
# ToDo: return time in current timezone similar to %z
|
||||
currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
||||
xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, *args, **kwargs)
|
||||
response = make_response(xml)
|
||||
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
|
||||
return response
|
||||
|
||||
|
||||
def render_xml_dataset(data_table, book_id):
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
db.Books,
|
||||
getattr(db.Books, data_table.__tablename__).any(data_table.id == book_id),
|
||||
[db.Books.timestamp.desc()],
|
||||
True, config.config_read_column)
|
||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||
|
||||
|
||||
def render_element_index(database_column, linked_table, folder):
|
||||
shift = 0
|
||||
off = int(request.args.get("offset") or 0)
|
||||
entries = calibre_db.session.query(func.upper(func.substr(database_column, 1, 1)).label('id'), None, None)
|
||||
# query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||
if linked_table is not None:
|
||||
entries = entries.join(linked_table).join(db.Books)
|
||||
entries = entries.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(database_column, 1, 1))).all()
|
||||
elements = []
|
||||
if off == 0:
|
||||
elements.append({'id': "00", 'name': _("All")})
|
||||
shift = 1
|
||||
for entry in entries[
|
||||
off + shift - 1:
|
||||
int(off + int(config.config_books_per_page) - shift)]:
|
||||
elements.append({'id': entry.id, 'name': entry.id})
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(entries) + 1)
|
||||
return render_xml_template('feed.xml',
|
||||
letterelements=elements,
|
||||
folder=folder,
|
||||
pagination=pagination)
|
||||
|
@ -57,10 +57,10 @@ class Pagination(object):
|
||||
def has_next(self):
|
||||
return self.page < self.pages
|
||||
|
||||
# right_edge: last right_edges count of all pages are shown as number, means, if 10 pages are paginated -> 9,10 shwn
|
||||
# left_edge: first left_edges count of all pages are shown as number -> 1,2 shwn
|
||||
# left_current: left_current count below current page are shown as number, means if current page 5 -> 3,4 shwn
|
||||
# left_current: right_current count above current page are shown as number, means if current page 5 -> 6,7 shwn
|
||||
# right_edge: last right_edges count of all pages are shown as number, means, if 10 pages are paginated -> 9,10 shown
|
||||
# left_edge: first left_edges count of all pages are shown as number -> 1,2 shown
|
||||
# left_current: left_current count below current page are shown as number, means if current page 5 -> 3,4 shown
|
||||
# left_current: right_current count above current page are shown as number, means if current page 5 -> 6,7 shown
|
||||
def iter_pages(self, left_edge=2, left_current=2,
|
||||
right_current=4, right_edge=2):
|
||||
last = 0
|
||||
|
@ -27,10 +27,7 @@
|
||||
|
||||
# http://flask.pocoo.org/snippets/62/
|
||||
|
||||
try:
|
||||
from urllib.parse import urlparse, urljoin
|
||||
except ImportError:
|
||||
from urlparse import urlparse, urljoin
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
from flask import request, url_for, redirect
|
||||
|
||||
|
@ -22,6 +22,7 @@
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
|
||||
from flask_login import login_required, current_user, login_user
|
||||
@ -31,10 +32,6 @@ from sqlalchemy.sql.expression import true
|
||||
from . import config, logger, ub
|
||||
from .render_template import render_title_template
|
||||
|
||||
try:
|
||||
from functools import wraps
|
||||
except ImportError:
|
||||
pass # We're not using Python 3
|
||||
|
||||
remotelogin = Blueprint('remotelogin', __name__)
|
||||
log = logger.create()
|
||||
|
@ -16,13 +16,12 @@
|
||||
# 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 flask import render_template
|
||||
from flask import render_template, g, abort, request
|
||||
from flask_babel import gettext as _
|
||||
from flask import g
|
||||
from werkzeug.local import LocalProxy
|
||||
from flask_login import current_user
|
||||
|
||||
from . import config, constants, ub, logger, db, calibre_db
|
||||
from . import config, constants, logger
|
||||
from .ub import User
|
||||
|
||||
|
||||
@ -30,6 +29,8 @@ log = logger.create()
|
||||
|
||||
def get_sidebar_config(kwargs=None):
|
||||
kwargs = kwargs or []
|
||||
simple = bool([e for e in ['kindle', 'tolino', "kobo", "bookeen"]
|
||||
if (e in request.headers.get('User-Agent', "").lower())])
|
||||
if 'content' in kwargs:
|
||||
content = kwargs['content']
|
||||
content = isinstance(content, (User, LocalProxy)) and not content.role_anonymous()
|
||||
@ -93,14 +94,14 @@ def get_sidebar_config(kwargs=None):
|
||||
{"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
|
||||
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not g.user.is_anonymous), "page": "archived",
|
||||
"show_text": _('Show archived books'), "config_show": content})
|
||||
sidebar.append(
|
||||
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
||||
"visibility": constants.SIDEBAR_LIST, 'public': (not g.user.is_anonymous), "page": "list",
|
||||
"show_text": _('Show Books List'), "config_show": content})
|
||||
if not simple:
|
||||
sidebar.append(
|
||||
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
||||
"visibility": constants.SIDEBAR_LIST, 'public': (not g.user.is_anonymous), "page": "list",
|
||||
"show_text": _('Show Books List'), "config_show": content})
|
||||
return sidebar, simple
|
||||
|
||||
return sidebar
|
||||
|
||||
def get_readbooks_ids():
|
||||
'''def get_readbooks_ids():
|
||||
if not config.config_read_column:
|
||||
readBooks = ub.session.query(ub.ReadBook).filter(ub.ReadBook.user_id == int(current_user.id))\
|
||||
.filter(ub.ReadBook.read_status == ub.ReadBook.STATUS_FINISHED).all()
|
||||
@ -110,13 +111,17 @@ def get_readbooks_ids():
|
||||
readBooks = calibre_db.session.query(db.cc_classes[config.config_read_column])\
|
||||
.filter(db.cc_classes[config.config_read_column].value == True).all()
|
||||
return frozenset([x.book for x in readBooks])
|
||||
except (KeyError, AttributeError):
|
||||
log.error("Custom Column No.%d is not existing in calibre database", config.config_read_column)
|
||||
return []
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
log.error("Custom Column No.{} is not existing in calibre database".format(config.config_read_column))
|
||||
return []'''
|
||||
|
||||
# Returns the template for rendering and includes the instance name
|
||||
def render_title_template(*args, **kwargs):
|
||||
sidebar = get_sidebar_config(kwargs)
|
||||
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar,
|
||||
accept=constants.EXTENSIONS_UPLOAD, read_book_ids=get_readbooks_ids(),
|
||||
*args, **kwargs)
|
||||
sidebar, simple = get_sidebar_config(kwargs)
|
||||
try:
|
||||
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple,
|
||||
accept=constants.EXTENSIONS_UPLOAD,
|
||||
*args, **kwargs)
|
||||
except PermissionError:
|
||||
log.error("No permission to access {} file.".format(args[0]))
|
||||
abort(403)
|
||||
|
97
cps/schedule.py
Normal file
97
cps/schedule.py
Normal file
@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 mmonkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from . import config, constants
|
||||
from .services.background_scheduler import BackgroundScheduler, use_APScheduler
|
||||
from .tasks.database import TaskReconnectDatabase
|
||||
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
||||
from .services.worker import WorkerThread
|
||||
|
||||
|
||||
def get_scheduled_tasks(reconnect=True):
|
||||
tasks = list()
|
||||
# config.schedule_reconnect or
|
||||
# Reconnect Calibre database (metadata.db)
|
||||
if reconnect:
|
||||
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
||||
|
||||
# Generate all missing book cover thumbnails
|
||||
if config.schedule_generate_book_covers:
|
||||
tasks.append([lambda: TaskClearCoverThumbnailCache(0), 'delete superfluous book covers', True])
|
||||
tasks.append([lambda: TaskGenerateCoverThumbnails(), 'generate book covers', False])
|
||||
|
||||
# Generate all missing series thumbnails
|
||||
if config.schedule_generate_series_covers:
|
||||
tasks.append([lambda: TaskGenerateSeriesThumbnails(), 'generate book covers', False])
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def end_scheduled_tasks():
|
||||
worker = WorkerThread.get_instance()
|
||||
for __, __, __, task, __ in worker.tasks:
|
||||
if task.scheduled and task.is_cancellable:
|
||||
worker.end_task(task.id)
|
||||
|
||||
|
||||
def register_scheduled_tasks(reconnect=True):
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
if scheduler:
|
||||
# Remove all existing jobs
|
||||
scheduler.remove_all_jobs()
|
||||
|
||||
start = config.schedule_start_time
|
||||
duration = config.schedule_duration
|
||||
|
||||
# Register scheduled tasks
|
||||
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger='cron', hour=start)
|
||||
end_time = calclulate_end_time(start, duration)
|
||||
scheduler.schedule(func=end_scheduled_tasks, trigger='cron', name="end scheduled task", hour=end_time.hour,
|
||||
minute=end_time.minute)
|
||||
|
||||
# Kick-off tasks, if they should currently be running
|
||||
if should_task_be_running(start, duration):
|
||||
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(reconnect))
|
||||
|
||||
|
||||
def register_startup_tasks():
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
if scheduler:
|
||||
start = config.schedule_start_time
|
||||
duration = config.schedule_duration
|
||||
|
||||
# Run scheduled tasks immediately for development and testing
|
||||
# Ignore tasks that should currently be running, as these will be added when registering scheduled tasks
|
||||
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
|
||||
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
||||
|
||||
|
||||
def should_task_be_running(start, duration):
|
||||
now = datetime.datetime.now()
|
||||
start_time = datetime.datetime.now().replace(hour=start, minute=0, second=0, microsecond=0)
|
||||
end_time = start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
||||
return start_time < now < end_time
|
||||
|
||||
def calclulate_end_time(start, duration):
|
||||
start_time = datetime.datetime.now().replace(hour=start, minute=0)
|
||||
return start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
||||
|
418
cps/search.py
Normal file
418
cps/search.py
Normal file
@ -0,0 +1,418 @@
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2022 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, request, redirect, url_for, flash
|
||||
from flask import session as flask_session
|
||||
from flask_login import current_user
|
||||
from flask_babel import format_date
|
||||
from flask_babel import gettext as _
|
||||
from sqlalchemy.sql.expression import func, not_, and_, or_, text
|
||||
from sqlalchemy.sql.functions import coalesce
|
||||
|
||||
from . import logger, db, calibre_db, config, ub
|
||||
from .usermanagement import login_required_if_no_ano
|
||||
from .render_template import render_title_template
|
||||
from .pagination import Pagination
|
||||
|
||||
search = Blueprint('search', __name__)
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
@search.route("/search", methods=["GET"])
|
||||
@login_required_if_no_ano
|
||||
def simple_search():
|
||||
term = request.args.get("query")
|
||||
if term:
|
||||
return redirect(url_for('web.books_list', data="search", sort_param='stored', query=term.strip()))
|
||||
else:
|
||||
return render_title_template('search.html',
|
||||
searchterm="",
|
||||
result_count=0,
|
||||
title=_(u"Search"),
|
||||
page="search")
|
||||
|
||||
|
||||
@search.route("/advsearch", methods=['POST'])
|
||||
@login_required_if_no_ano
|
||||
def advanced_search():
|
||||
values = dict(request.form)
|
||||
params = ['include_tag', 'exclude_tag', 'include_serie', 'exclude_serie', 'include_shelf', 'exclude_shelf',
|
||||
'include_language', 'exclude_language', 'include_extension', 'exclude_extension']
|
||||
for param in params:
|
||||
values[param] = list(request.form.getlist(param))
|
||||
flask_session['query'] = json.dumps(values)
|
||||
return redirect(url_for('web.books_list', data="advsearch", sort_param='stored', query=""))
|
||||
|
||||
|
||||
@search.route("/advsearch", methods=['GET'])
|
||||
@login_required_if_no_ano
|
||||
def advanced_search_form():
|
||||
# Build custom columns names
|
||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
||||
return render_prepare_search_form(cc)
|
||||
|
||||
|
||||
def adv_search_custom_columns(cc, term, q):
|
||||
for c in cc:
|
||||
if c.datatype == "datetime":
|
||||
custom_start = term.get('custom_column_' + str(c.id) + '_start')
|
||||
custom_end = term.get('custom_column_' + str(c.id) + '_end')
|
||||
if custom_start:
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
func.datetime(db.cc_classes[c.id].value) >= func.datetime(custom_start)))
|
||||
if custom_end:
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end)))
|
||||
else:
|
||||
custom_query = term.get('custom_column_' + str(c.id))
|
||||
if custom_query != '' and custom_query is not None:
|
||||
if c.datatype == 'bool':
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value == (custom_query == "True")))
|
||||
elif c.datatype == 'int' or c.datatype == 'float':
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value == custom_query))
|
||||
elif c.datatype == 'rating':
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value == int(float(custom_query) * 2)))
|
||||
else:
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
func.lower(db.cc_classes[c.id].value).ilike("%" + custom_query + "%")))
|
||||
return q
|
||||
|
||||
|
||||
def adv_search_language(q, include_languages_inputs, exclude_languages_inputs):
|
||||
if current_user.filter_language() != "all":
|
||||
q = q.filter(db.Books.languages.any(db.Languages.lang_code == current_user.filter_language()))
|
||||
else:
|
||||
for language in include_languages_inputs:
|
||||
q = q.filter(db.Books.languages.any(db.Languages.id == language))
|
||||
for language in exclude_languages_inputs:
|
||||
q = q.filter(not_(db.Books.series.any(db.Languages.id == language)))
|
||||
return q
|
||||
|
||||
|
||||
def adv_search_ratings(q, rating_high, rating_low):
|
||||
if rating_high:
|
||||
rating_high = int(rating_high) * 2
|
||||
q = q.filter(db.Books.ratings.any(db.Ratings.rating <= rating_high))
|
||||
if rating_low:
|
||||
rating_low = int(rating_low) * 2
|
||||
q = q.filter(db.Books.ratings.any(db.Ratings.rating >= rating_low))
|
||||
return q
|
||||
|
||||
|
||||
def adv_search_read_status(q, read_status):
|
||||
if read_status:
|
||||
if config.config_read_column:
|
||||
try:
|
||||
if read_status == "True":
|
||||
q = q.join(db.cc_classes[config.config_read_column], isouter=True) \
|
||||
.filter(db.cc_classes[config.config_read_column].value == True)
|
||||
else:
|
||||
q = q.join(db.cc_classes[config.config_read_column], isouter=True) \
|
||||
.filter(coalesce(db.cc_classes[config.config_read_column].value, False) != True)
|
||||
except (KeyError, AttributeError):
|
||||
log.error(u"Custom Column No.%d is not existing in calibre database", config.config_read_column)
|
||||
flash(_("Custom Column No.%(column)d is not existing in calibre database",
|
||||
column=config.config_read_column),
|
||||
category="error")
|
||||
return q
|
||||
else:
|
||||
if read_status == "True":
|
||||
q = q.join(ub.ReadBook, db.Books.id == ub.ReadBook.book_id, isouter=True) \
|
||||
.filter(ub.ReadBook.user_id == int(current_user.id),
|
||||
ub.ReadBook.read_status == ub.ReadBook.STATUS_FINISHED)
|
||||
else:
|
||||
q = q.join(ub.ReadBook, db.Books.id == ub.ReadBook.book_id, isouter=True) \
|
||||
.filter(ub.ReadBook.user_id == int(current_user.id),
|
||||
coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED)
|
||||
return q
|
||||
|
||||
|
||||
def adv_search_extension(q, include_extension_inputs, exclude_extension_inputs):
|
||||
for extension in include_extension_inputs:
|
||||
q = q.filter(db.Books.data.any(db.Data.format == extension))
|
||||
for extension in exclude_extension_inputs:
|
||||
q = q.filter(not_(db.Books.data.any(db.Data.format == extension)))
|
||||
return q
|
||||
|
||||
|
||||
def adv_search_tag(q, include_tag_inputs, exclude_tag_inputs):
|
||||
for tag in include_tag_inputs:
|
||||
q = q.filter(db.Books.tags.any(db.Tags.id == tag))
|
||||
for tag in exclude_tag_inputs:
|
||||
q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag)))
|
||||
return q
|
||||
|
||||
|
||||
def adv_search_serie(q, include_series_inputs, exclude_series_inputs):
|
||||
for serie in include_series_inputs:
|
||||
q = q.filter(db.Books.series.any(db.Series.id == serie))
|
||||
for serie in exclude_series_inputs:
|
||||
q = q.filter(not_(db.Books.series.any(db.Series.id == serie)))
|
||||
return q
|
||||
|
||||
def adv_search_shelf(q, include_shelf_inputs, exclude_shelf_inputs):
|
||||
q = q.outerjoin(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)\
|
||||
.filter(or_(ub.BookShelf.shelf == None, ub.BookShelf.shelf.notin_(exclude_shelf_inputs)))
|
||||
if len(include_shelf_inputs) > 0:
|
||||
q = q.filter(ub.BookShelf.shelf.in_(include_shelf_inputs))
|
||||
return q
|
||||
|
||||
def extend_search_term(searchterm,
|
||||
author_name,
|
||||
book_title,
|
||||
publisher,
|
||||
pub_start,
|
||||
pub_end,
|
||||
tags,
|
||||
rating_high,
|
||||
rating_low,
|
||||
read_status,
|
||||
):
|
||||
searchterm.extend((author_name.replace('|', ','), book_title, publisher))
|
||||
if pub_start:
|
||||
try:
|
||||
searchterm.extend([_(u"Published after ") +
|
||||
format_date(datetime.strptime(pub_start, "%Y-%m-%d"),
|
||||
format='medium')])
|
||||
except ValueError:
|
||||
pub_start = u""
|
||||
if pub_end:
|
||||
try:
|
||||
searchterm.extend([_(u"Published before ") +
|
||||
format_date(datetime.strptime(pub_end, "%Y-%m-%d"),
|
||||
format='medium')])
|
||||
except ValueError:
|
||||
pub_end = u""
|
||||
elements = {'tag': db.Tags, 'serie':db.Series, 'shelf':ub.Shelf}
|
||||
for key, db_element in elements.items():
|
||||
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all()
|
||||
searchterm.extend(tag.name for tag in tag_names)
|
||||
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['exclude_' + key])).all()
|
||||
searchterm.extend(tag.name for tag in tag_names)
|
||||
language_names = calibre_db.session.query(db.Languages). \
|
||||
filter(db.Languages.id.in_(tags['include_language'])).all()
|
||||
if language_names:
|
||||
language_names = calibre_db.speaking_language(language_names)
|
||||
searchterm.extend(language.name for language in language_names)
|
||||
language_names = calibre_db.session.query(db.Languages). \
|
||||
filter(db.Languages.id.in_(tags['exclude_language'])).all()
|
||||
if language_names:
|
||||
language_names = calibre_db.speaking_language(language_names)
|
||||
searchterm.extend(language.name for language in language_names)
|
||||
if rating_high:
|
||||
searchterm.extend([_(u"Rating <= %(rating)s", rating=rating_high)])
|
||||
if rating_low:
|
||||
searchterm.extend([_(u"Rating >= %(rating)s", rating=rating_low)])
|
||||
if read_status:
|
||||
searchterm.extend([_(u"Read Status = %(status)s", status=read_status)])
|
||||
searchterm.extend(ext for ext in tags['include_extension'])
|
||||
searchterm.extend(ext for ext in tags['exclude_extension'])
|
||||
# handle custom columns
|
||||
searchterm = " + ".join(filter(None, searchterm))
|
||||
return searchterm, pub_start, pub_end
|
||||
|
||||
|
||||
def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||
sort = order[0] if order else [db.Books.sort]
|
||||
pagination = None
|
||||
|
||||
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
||||
calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
|
||||
if not config.config_read_column:
|
||||
query = (calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, ub.ReadBook).select_from(db.Books)
|
||||
.outerjoin(ub.ReadBook, and_(db.Books.id == ub.ReadBook.book_id,
|
||||
int(current_user.id) == ub.ReadBook.user_id)))
|
||||
else:
|
||||
try:
|
||||
read_column = cc[config.config_read_column]
|
||||
query = (calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, read_column.value)
|
||||
.select_from(db.Books)
|
||||
.outerjoin(read_column, read_column.book == db.Books.id))
|
||||
except (KeyError, AttributeError):
|
||||
log.error("Custom Column No.%d is not existing in calibre database", config.config_read_column)
|
||||
# Skip linking read column
|
||||
query = calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, None)
|
||||
query = query.outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||
int(current_user.id) == ub.ArchivedBook.user_id))
|
||||
|
||||
q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\
|
||||
.outerjoin(db.Series)\
|
||||
.filter(calibre_db.common_filters(True))
|
||||
|
||||
# parse multi selects to a complete dict
|
||||
tags = dict()
|
||||
elements = ['tag', 'serie', 'shelf', 'language', 'extension']
|
||||
for element in elements:
|
||||
tags['include_' + element] = term.get('include_' + element)
|
||||
tags['exclude_' + element] = term.get('exclude_' + element)
|
||||
|
||||
author_name = term.get("author_name")
|
||||
book_title = term.get("book_title")
|
||||
publisher = term.get("publisher")
|
||||
pub_start = term.get("publishstart")
|
||||
pub_end = term.get("publishend")
|
||||
rating_low = term.get("ratinghigh")
|
||||
rating_high = term.get("ratinglow")
|
||||
description = term.get("comment")
|
||||
read_status = term.get("read_status")
|
||||
if author_name:
|
||||
author_name = author_name.strip().lower().replace(',', '|')
|
||||
if book_title:
|
||||
book_title = book_title.strip().lower()
|
||||
if publisher:
|
||||
publisher = publisher.strip().lower()
|
||||
|
||||
search_term = []
|
||||
cc_present = False
|
||||
for c in cc:
|
||||
if c.datatype == "datetime":
|
||||
column_start = term.get('custom_column_' + str(c.id) + '_start')
|
||||
column_end = term.get('custom_column_' + str(c.id) + '_end')
|
||||
if column_start:
|
||||
search_term.extend([u"{} >= {}".format(c.name,
|
||||
format_date(datetime.strptime(column_start, "%Y-%m-%d").date(),
|
||||
format='medium')
|
||||
)])
|
||||
cc_present = True
|
||||
if column_end:
|
||||
search_term.extend([u"{} <= {}".format(c.name,
|
||||
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
||||
format='medium')
|
||||
)])
|
||||
cc_present = True
|
||||
elif term.get('custom_column_' + str(c.id)):
|
||||
search_term.extend([(u"{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||
cc_present = True
|
||||
|
||||
if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \
|
||||
or rating_high or description or cc_present or read_status:
|
||||
search_term, pub_start, pub_end = extend_search_term(search_term,
|
||||
author_name,
|
||||
book_title,
|
||||
publisher,
|
||||
pub_start,
|
||||
pub_end,
|
||||
tags,
|
||||
rating_high,
|
||||
rating_low,
|
||||
read_status)
|
||||
if author_name:
|
||||
q = q.filter(db.Books.authors.any(func.lower(db.Authors.name).ilike("%" + author_name + "%")))
|
||||
if book_title:
|
||||
q = q.filter(func.lower(db.Books.title).ilike("%" + book_title + "%"))
|
||||
if pub_start:
|
||||
q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start))
|
||||
if pub_end:
|
||||
q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end))
|
||||
q = adv_search_read_status(q, read_status)
|
||||
if publisher:
|
||||
q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + publisher + "%")))
|
||||
q = adv_search_tag(q, tags['include_tag'], tags['exclude_tag'])
|
||||
q = adv_search_serie(q, tags['include_serie'], tags['exclude_serie'])
|
||||
q = adv_search_shelf(q, tags['include_shelf'], tags['exclude_shelf'])
|
||||
q = adv_search_extension(q, tags['include_extension'], tags['exclude_extension'])
|
||||
q = adv_search_language(q, tags['include_language'], tags['exclude_language'])
|
||||
q = adv_search_ratings(q, rating_high, rating_low)
|
||||
|
||||
if description:
|
||||
q = q.filter(db.Books.comments.any(func.lower(db.Comments.text).ilike("%" + description + "%")))
|
||||
|
||||
# search custom columns
|
||||
try:
|
||||
q = adv_search_custom_columns(cc, term, q)
|
||||
except AttributeError as ex:
|
||||
log.debug_or_exception(ex)
|
||||
flash(_("Error on search for custom columns, please restart Calibre-Web"), category="error")
|
||||
|
||||
q = q.order_by(*sort).all()
|
||||
flask_session['query'] = json.dumps(term)
|
||||
ub.store_combo_ids(q)
|
||||
result_count = len(q)
|
||||
if offset is not None and limit is not None:
|
||||
offset = int(offset)
|
||||
limit_all = offset + int(limit)
|
||||
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
||||
else:
|
||||
offset = 0
|
||||
limit_all = result_count
|
||||
entries = calibre_db.order_authors(q[offset:limit_all], list_return=True, combined=True)
|
||||
return render_title_template('search.html',
|
||||
adv_searchterm=search_term,
|
||||
pagination=pagination,
|
||||
entries=entries,
|
||||
result_count=result_count,
|
||||
title=_(u"Advanced Search"), page="advsearch",
|
||||
order=order[1])
|
||||
|
||||
|
||||
def render_prepare_search_form(cc):
|
||||
# prepare data for search-form
|
||||
tags = calibre_db.session.query(db.Tags)\
|
||||
.join(db.books_tags_link)\
|
||||
.join(db.Books)\
|
||||
.filter(calibre_db.common_filters()) \
|
||||
.group_by(text('books_tags_link.tag'))\
|
||||
.order_by(db.Tags.name).all()
|
||||
series = calibre_db.session.query(db.Series)\
|
||||
.join(db.books_series_link)\
|
||||
.join(db.Books)\
|
||||
.filter(calibre_db.common_filters()) \
|
||||
.group_by(text('books_series_link.series'))\
|
||||
.order_by(db.Series.name)\
|
||||
.filter(calibre_db.common_filters()).all()
|
||||
shelves = ub.session.query(ub.Shelf)\
|
||||
.filter(or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == int(current_user.id)))\
|
||||
.order_by(ub.Shelf.name).all()
|
||||
extensions = calibre_db.session.query(db.Data)\
|
||||
.join(db.Books)\
|
||||
.filter(calibre_db.common_filters()) \
|
||||
.group_by(db.Data.format)\
|
||||
.order_by(db.Data.format).all()
|
||||
if current_user.filter_language() == u"all":
|
||||
languages = calibre_db.speaking_language()
|
||||
else:
|
||||
languages = None
|
||||
return render_title_template('search_form.html', tags=tags, languages=languages, extensions=extensions,
|
||||
series=series,shelves=shelves, title=_(u"Advanced Search"), cc=cc, page="advsearch")
|
||||
|
||||
|
||||
def render_search_results(term, offset=None, order=None, limit=None):
|
||||
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
|
||||
entries, result_count, pagination = calibre_db.get_search_results(term,
|
||||
config,
|
||||
offset,
|
||||
order,
|
||||
limit,
|
||||
*join)
|
||||
return render_title_template('search.html',
|
||||
searchterm=term,
|
||||
pagination=pagination,
|
||||
query=term,
|
||||
adv_searchterm=term,
|
||||
entries=entries,
|
||||
result_count=result_count,
|
||||
title=_(u"Search"),
|
||||
page="search",
|
||||
order=order[1])
|
||||
|
||||
|
@ -16,69 +16,91 @@
|
||||
# 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 json
|
||||
import importlib
|
||||
import sys
|
||||
import inspect
|
||||
import datetime
|
||||
import concurrent.futures
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, request, Response, url_for
|
||||
from flask import Blueprint, Response, request, url_for
|
||||
from flask_login import current_user
|
||||
from flask_login import login_required
|
||||
from flask_babel import get_locale
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from sqlalchemy.exc import OperationalError, InvalidRequestError
|
||||
|
||||
from . import constants, logger, ub
|
||||
from cps.services.Metadata import Metadata
|
||||
from . import constants, logger, ub, web_server
|
||||
|
||||
# current_milli_time = lambda: int(round(time() * 1000))
|
||||
|
||||
meta = Blueprint('metadata', __name__)
|
||||
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'):
|
||||
if os.path.isfile(os.path.join(meta_dir, f)) and not f.endswith("__init__.py"):
|
||||
a = os.path.basename(f)[:-3]
|
||||
try:
|
||||
importlib.import_module("cps.metadata_provider." + a)
|
||||
new_list.append(a)
|
||||
except ImportError:
|
||||
log.error("Import error for metadata source: {}".format(a))
|
||||
pass
|
||||
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):
|
||||
for name, obj in inspect.getmembers(
|
||||
sys.modules["cps.metadata_provider." + element]
|
||||
):
|
||||
if (
|
||||
inspect.isclass(obj)
|
||||
and name != "Metadata"
|
||||
and issubclass(obj, Metadata)
|
||||
):
|
||||
classes.append(obj())
|
||||
return classes
|
||||
|
||||
|
||||
cl = list_classes(new_list)
|
||||
|
||||
|
||||
@meta.route("/metadata/provider")
|
||||
@login_required
|
||||
def metadata_provider():
|
||||
active = current_user.view_settings.get('metadata', {})
|
||||
active = current_user.view_settings.get("metadata", {})
|
||||
provider = list()
|
||||
for c in cl:
|
||||
ac = active.get(c.__id__, True)
|
||||
provider.append({"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__})
|
||||
return Response(json.dumps(provider), mimetype='application/json')
|
||||
provider.append(
|
||||
{"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__}
|
||||
)
|
||||
return Response(json.dumps(provider), mimetype="application/json")
|
||||
|
||||
@meta.route("/metadata/provider", methods=['POST'])
|
||||
@meta.route("/metadata/provider/<prov_name>", methods=['POST'])
|
||||
|
||||
@meta.route("/metadata/provider", methods=["POST"])
|
||||
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
|
||||
@login_required
|
||||
def metadata_change_active_provider(prov_name):
|
||||
new_state = request.get_json()
|
||||
active = current_user.view_settings.get('metadata', {})
|
||||
active[new_state['id']] = new_state['value']
|
||||
current_user.view_settings['metadata'] = active
|
||||
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")
|
||||
@ -89,29 +111,33 @@ def metadata_change_active_provider(prov_name):
|
||||
log.error("Invalid request received: {}".format(request))
|
||||
return "Invalid request", 400
|
||||
if "initial" in new_state and prov_name:
|
||||
for c in cl:
|
||||
if c.__id__ == prov_name:
|
||||
data = c.search(new_state.get('query', ""))
|
||||
break
|
||||
return Response(json.dumps(data), mimetype='application/json')
|
||||
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 Response(
|
||||
json.dumps([asdict(x) for x in data]), mimetype="application/json"
|
||||
)
|
||||
return ""
|
||||
|
||||
@meta.route("/metadata/search", methods=['POST'])
|
||||
|
||||
@meta.route("/metadata/search", methods=["POST"])
|
||||
@login_required
|
||||
def metadata_search():
|
||||
query = request.form.to_dict().get('query')
|
||||
query = request.form.to_dict().get("query")
|
||||
data = list()
|
||||
active = current_user.view_settings.get('metadata', {})
|
||||
active = current_user.view_settings.get("metadata", {})
|
||||
locale = get_locale()
|
||||
if query:
|
||||
static_cover = url_for('static', filename='generic_cover.jpg')
|
||||
static_cover = url_for("static", filename="generic_cover.jpg")
|
||||
# start = current_milli_time()
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
meta = {executor.submit(c.search, query, static_cover): c for c in cl if active.get(c.__id__, True)}
|
||||
meta = {
|
||||
executor.submit(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(future.result())
|
||||
return Response(json.dumps(data), mimetype='application/json')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
data.extend([asdict(x) for x in future.result() if x])
|
||||
# log.info({'Time elapsed {}'.format(current_milli_time()-start)})
|
||||
return Response(json.dumps(data), mimetype="application/json")
|
||||
|
@ -25,6 +25,7 @@ import subprocess # nosec
|
||||
|
||||
try:
|
||||
from gevent.pywsgi import WSGIServer
|
||||
from .gevent_wsgi import MyWSGIHandler
|
||||
from gevent.pool import Pool
|
||||
from gevent import __version__ as _version
|
||||
from greenlet import GreenletExit
|
||||
@ -32,7 +33,7 @@ try:
|
||||
VERSION = 'Gevent ' + _version
|
||||
_GEVENT = True
|
||||
except ImportError:
|
||||
from tornado.wsgi import WSGIContainer
|
||||
from .tornado_wsgi import MyWSGIContainer
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado import version as _version
|
||||
@ -202,7 +203,8 @@ class WebServer(object):
|
||||
if output is None:
|
||||
output = _readable_listen_address(self.listen_address, self.listen_port)
|
||||
log.info('Starting Gevent server on %s', output)
|
||||
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, spawn=Pool(), **ssl_args)
|
||||
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
|
||||
spawn=Pool(), **ssl_args)
|
||||
if ssl_args:
|
||||
wrap_socket = self.wsgiserver.wrap_socket
|
||||
def my_wrap_socket(*args, **kwargs):
|
||||
@ -225,8 +227,8 @@ class WebServer(object):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
log.info('Starting Tornado server on %s', _readable_listen_address(self.listen_address, self.listen_port))
|
||||
|
||||
# Max Buffersize set to 200MB )
|
||||
http_server = HTTPServer(WSGIContainer(self.app),
|
||||
# Max Buffersize set to 200MB
|
||||
http_server = HTTPServer(MyWSGIContainer(self.app),
|
||||
max_buffer_size=209700000,
|
||||
ssl_options=self.ssl_args)
|
||||
http_server.listen(self.listen_port, self.listen_address)
|
||||
|
@ -15,13 +15,97 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
|
||||
class Metadata():
|
||||
@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)
|
||||
|
||||
|
||||
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
|
||||
|
@ -21,11 +21,8 @@ import sys
|
||||
from base64 import b64decode, b64encode
|
||||
from jsonschema import validate, exceptions, __version__
|
||||
from datetime import datetime
|
||||
try:
|
||||
# pylint: disable=unused-import
|
||||
from urllib import unquote
|
||||
except ImportError:
|
||||
from urllib.parse import unquote
|
||||
|
||||
from urllib.parse import unquote
|
||||
|
||||
from flask import json
|
||||
from .. import logger
|
||||
|
@ -18,11 +18,10 @@
|
||||
|
||||
from .. import logger
|
||||
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
try: from . import goodreads_support
|
||||
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
|
||||
|
84
cps/services/background_scheduler.py
Normal file
84
cps/services/background_scheduler.py
Normal file
@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 mmonkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import atexit
|
||||
|
||||
from .. import logger
|
||||
from .worker import WorkerThread
|
||||
|
||||
try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler as BScheduler
|
||||
use_APScheduler = True
|
||||
except (ImportError, RuntimeError) as e:
|
||||
use_APScheduler = False
|
||||
log = logger.create()
|
||||
log.info('APScheduler not found. Unable to schedule tasks.')
|
||||
|
||||
|
||||
class BackgroundScheduler:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if not use_APScheduler:
|
||||
return False
|
||||
|
||||
if cls._instance is None:
|
||||
cls._instance = super(BackgroundScheduler, cls).__new__(cls)
|
||||
cls.log = logger.create()
|
||||
cls.scheduler = BScheduler()
|
||||
cls.scheduler.start()
|
||||
|
||||
atexit.register(lambda: cls.scheduler.shutdown())
|
||||
|
||||
return cls._instance
|
||||
|
||||
def schedule(self, func, trigger, name=None, **trigger_args):
|
||||
if use_APScheduler:
|
||||
return self.scheduler.add_job(func=func, trigger=trigger, name=name, **trigger_args)
|
||||
|
||||
# Expects a lambda expression for the task
|
||||
def schedule_task(self, task, user=None, name=None, hidden=False, trigger='cron', **trigger_args):
|
||||
if use_APScheduler:
|
||||
def scheduled_task():
|
||||
worker_task = task()
|
||||
worker_task.scheduled = True
|
||||
WorkerThread.add(user, worker_task, hidden=hidden)
|
||||
return self.schedule(func=scheduled_task, trigger=trigger, name=name, **trigger_args)
|
||||
|
||||
# Expects a list of lambda expressions for the tasks
|
||||
def schedule_tasks(self, tasks, user=None, trigger='cron', **trigger_args):
|
||||
if use_APScheduler:
|
||||
for task in tasks:
|
||||
self.schedule_task(task[0], user=user, trigger=trigger, name=task[1], hidden=task[2], **trigger_args)
|
||||
|
||||
# Expects a lambda expression for the task
|
||||
def schedule_task_immediately(self, task, user=None, name=None, hidden=False):
|
||||
if use_APScheduler:
|
||||
def immediate_task():
|
||||
WorkerThread.add(user, task(), hidden)
|
||||
return self.schedule(func=immediate_task, trigger='date', name=name)
|
||||
|
||||
# Expects a list of lambda expressions for the tasks
|
||||
def schedule_tasks_immediately(self, tasks, user=None):
|
||||
if use_APScheduler:
|
||||
for task in tasks:
|
||||
self.schedule_task_immediately(task[0], user, name="immediately " + task[1], hidden=task[2])
|
||||
|
||||
# Remove all jobs
|
||||
def remove_all_jobs(self):
|
||||
self.scheduler.remove_all_jobs()
|
@ -25,7 +25,7 @@ from google.oauth2.credentials import Credentials
|
||||
from datetime import datetime
|
||||
import base64
|
||||
from flask_babel import gettext as _
|
||||
from ..constants import BASE_DIR
|
||||
from ..constants import CONFIG_DIR
|
||||
from .. import logger
|
||||
|
||||
|
||||
@ -53,11 +53,11 @@ def setup_gmail(token):
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
cred_file = os.path.join(BASE_DIR, 'gmail.json')
|
||||
cred_file = os.path.join(CONFIG_DIR, 'gmail.json')
|
||||
if not os.path.exists(cred_file):
|
||||
raise Exception(_("Found no valid gmail.json file with OAuth information"))
|
||||
flow = InstalledAppFlow.from_client_secrets_file(
|
||||
os.path.join(BASE_DIR, 'gmail.json'), SCOPES)
|
||||
os.path.join(CONFIG_DIR, 'gmail.json'), SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
user_info = get_user_info(creds)
|
||||
return {
|
||||
|
@ -37,11 +37,13 @@ STAT_WAITING = 0
|
||||
STAT_FAIL = 1
|
||||
STAT_STARTED = 2
|
||||
STAT_FINISH_SUCCESS = 3
|
||||
STAT_ENDED = 4
|
||||
STAT_CANCELLED = 5
|
||||
|
||||
# Only retain this many tasks in dequeued list
|
||||
TASK_CLEANUP_TRIGGER = 20
|
||||
|
||||
QueuedTask = namedtuple('QueuedTask', 'num, user, added, task')
|
||||
QueuedTask = namedtuple('QueuedTask', 'num, user, added, task, hidden')
|
||||
|
||||
|
||||
def _get_main_thread():
|
||||
@ -51,7 +53,6 @@ def _get_main_thread():
|
||||
raise Exception("main thread not found?!")
|
||||
|
||||
|
||||
|
||||
class ImprovedQueue(queue.Queue):
|
||||
def to_list(self):
|
||||
"""
|
||||
@ -61,12 +62,13 @@ class ImprovedQueue(queue.Queue):
|
||||
with self.mutex:
|
||||
return list(self.queue)
|
||||
|
||||
|
||||
# Class for all worker tasks in the background
|
||||
class WorkerThread(threading.Thread):
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def getInstance(cls):
|
||||
def get_instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = WorkerThread()
|
||||
return cls._instance
|
||||
@ -82,15 +84,17 @@ class WorkerThread(threading.Thread):
|
||||
self.start()
|
||||
|
||||
@classmethod
|
||||
def add(cls, user, task):
|
||||
ins = cls.getInstance()
|
||||
def add(cls, user, task, hidden=False):
|
||||
ins = cls.get_instance()
|
||||
ins.num += 1
|
||||
log.debug("Add Task for user: {} - {}".format(user, task))
|
||||
username = user if user is not None else 'System'
|
||||
log.debug("Add Task for user: {} - {}".format(username, task))
|
||||
ins.queue.put(QueuedTask(
|
||||
num=ins.num,
|
||||
user=user,
|
||||
user=username,
|
||||
added=datetime.now(),
|
||||
task=task,
|
||||
hidden=hidden
|
||||
))
|
||||
|
||||
@property
|
||||
@ -111,10 +115,10 @@ class WorkerThread(threading.Thread):
|
||||
if delta > TASK_CLEANUP_TRIGGER:
|
||||
ret = alive
|
||||
else:
|
||||
# otherwise, lop off the oldest dead tasks until we hit the target trigger
|
||||
ret = sorted(dead, key=lambda x: x.task.end_time)[-TASK_CLEANUP_TRIGGER:] + alive
|
||||
# otherwise, loop off the oldest dead tasks until we hit the target trigger
|
||||
ret = sorted(dead, key=lambda y: y.task.end_time)[-TASK_CLEANUP_TRIGGER:] + alive
|
||||
|
||||
self.dequeued = sorted(ret, key=lambda x: x.num)
|
||||
self.dequeued = sorted(ret, key=lambda y: y.num)
|
||||
|
||||
# Main thread loop starting the different tasks
|
||||
def run(self):
|
||||
@ -141,11 +145,21 @@ class WorkerThread(threading.Thread):
|
||||
|
||||
# sometimes tasks (like Upload) don't actually have work to do and are created as already finished
|
||||
if item.task.stat is STAT_WAITING:
|
||||
# CalibreTask.start() should wrap all exceptions in it's own error handling
|
||||
# CalibreTask.start() should wrap all exceptions in its own error handling
|
||||
item.task.start(self)
|
||||
|
||||
# remove self_cleanup tasks and hidden "System Tasks" from list
|
||||
if item.task.self_cleanup or item.hidden:
|
||||
self.dequeued.remove(item)
|
||||
|
||||
self.queue.task_done()
|
||||
|
||||
def end_task(self, task_id):
|
||||
ins = self.get_instance()
|
||||
for __, __, __, task, __ in ins.tasks:
|
||||
if str(task.id) == str(task_id) and task.is_cancellable:
|
||||
task.stat = STAT_CANCELLED if task.stat == STAT_WAITING else STAT_ENDED
|
||||
|
||||
|
||||
class CalibreTask:
|
||||
__metaclass__ = abc.ABCMeta
|
||||
@ -158,10 +172,12 @@ class CalibreTask:
|
||||
self.end_time = None
|
||||
self.message = message
|
||||
self.id = uuid.uuid4()
|
||||
self.self_cleanup = False
|
||||
self._scheduled = False
|
||||
|
||||
@abc.abstractmethod
|
||||
def run(self, worker_thread):
|
||||
"""Provides the caller some human-readable name for this class"""
|
||||
"""The main entry-point for this task"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
@ -169,6 +185,11 @@ class CalibreTask:
|
||||
"""Provides the caller some human-readable name for this class"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_cancellable(self):
|
||||
"""Does this task gracefully handle being cancelled (STAT_ENDED, STAT_CANCELLED)?"""
|
||||
raise NotImplementedError
|
||||
|
||||
def start(self, *args):
|
||||
self.start_time = datetime.now()
|
||||
self.stat = STAT_STARTED
|
||||
@ -178,7 +199,7 @@ class CalibreTask:
|
||||
self.run(*args)
|
||||
except Exception as ex:
|
||||
self._handleError(str(ex))
|
||||
log.debug_or_exception(ex)
|
||||
log.error_or_exception(ex)
|
||||
|
||||
self.end_time = datetime.now()
|
||||
|
||||
@ -219,15 +240,23 @@ class CalibreTask:
|
||||
We have a separate dictating this because there may be certain tasks that want to override this
|
||||
"""
|
||||
# By default, we're good to clean a task if it's "Done"
|
||||
return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL)
|
||||
return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL, STAT_ENDED, STAT_CANCELLED)
|
||||
|
||||
'''@progress.setter
|
||||
def progress(self, x):
|
||||
if x > 1:
|
||||
x = 1
|
||||
if x < 0:
|
||||
x = 0
|
||||
self._progress = x'''
|
||||
@property
|
||||
def self_cleanup(self):
|
||||
return self._self_cleanup
|
||||
|
||||
@self_cleanup.setter
|
||||
def self_cleanup(self, is_self_cleanup):
|
||||
self._self_cleanup = is_self_cleanup
|
||||
|
||||
@property
|
||||
def scheduled(self):
|
||||
return self._scheduled
|
||||
|
||||
@scheduled.setter
|
||||
def scheduled(self, is_scheduled):
|
||||
self._scheduled = is_scheduled
|
||||
|
||||
def _handleError(self, error_message):
|
||||
self.stat = STAT_FAIL
|
||||
|
223
cps/shelf.py
223
cps/shelf.py
@ -23,7 +23,7 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, flash, redirect, request, url_for
|
||||
from flask import Blueprint, flash, redirect, request, url_for, abort
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
@ -33,30 +33,12 @@ from . import calibre_db, config, db, logger, ub
|
||||
from .render_template import render_title_template
|
||||
from .usermanagement import login_required_if_no_ano
|
||||
|
||||
shelf = Blueprint('shelf', __name__)
|
||||
log = logger.create()
|
||||
|
||||
|
||||
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 %s not allowed to edit shelf %s", current_user, cur_shelf)
|
||||
return False
|
||||
if cur_shelf.is_public and not current_user.role_edit_shelfs():
|
||||
log.info("User %s not allowed to edit public shelves", current_user)
|
||||
return False
|
||||
return True
|
||||
shelf = Blueprint('shelf', __name__)
|
||||
|
||||
|
||||
def check_shelf_view_permissions(cur_shelf):
|
||||
if cur_shelf.is_public:
|
||||
return True
|
||||
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
|
||||
log.error("User is unauthorized to view non-public shelf: %s", cur_shelf)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>")
|
||||
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||
@login_required
|
||||
def add_to_shelf(shelf_id, book_id):
|
||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
@ -94,10 +76,10 @@ def add_to_shelf(shelf_id, book_id):
|
||||
try:
|
||||
ub.session.merge(shelf)
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError):
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error("Settings DB is not Writeable")
|
||||
flash(_(u"Settings DB is not Writeable"), category="error")
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||
if "HTTP_REFERER" in request.environ:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
@ -112,12 +94,12 @@ def add_to_shelf(shelf_id, book_id):
|
||||
return "", 204
|
||||
|
||||
|
||||
@shelf.route("/shelf/massadd/<int:shelf_id>")
|
||||
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
||||
@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: %s", shelf_id)
|
||||
log.error("Invalid shelf specified: {}".format(shelf_id))
|
||||
flash(_(u"Invalid shelf specified"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
@ -154,17 +136,17 @@ def search_to_shelf(shelf_id):
|
||||
ub.session.merge(shelf)
|
||||
ub.session.commit()
|
||||
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
||||
except (OperationalError, InvalidRequestError):
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error("Settings DB is not Writeable")
|
||||
flash(_("Settings DB is not Writeable"), category="error")
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||
else:
|
||||
log.error("Could not add books to shelf: {}".format(shelf.name))
|
||||
flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
|
||||
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>")
|
||||
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||
@login_required
|
||||
def remove_from_shelf(shelf_id, book_id):
|
||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
@ -197,10 +179,10 @@ def remove_from_shelf(shelf_id, book_id):
|
||||
ub.session.delete(book_shelf)
|
||||
shelf.last_modified = datetime.utcnow()
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError):
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error("Settings DB is not Writeable")
|
||||
flash(_("Settings DB is not Writeable"), category="error")
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||
if "HTTP_REFERER" in request.environ:
|
||||
return redirect(request.environ["HTTP_REFERER"])
|
||||
else:
|
||||
@ -228,7 +210,6 @@ def create_shelf():
|
||||
return create_edit_shelf(shelf, page_title=_(u"Create a Shelf"), page="shelfcreate")
|
||||
|
||||
|
||||
|
||||
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_shelf(shelf_id):
|
||||
@ -239,6 +220,89 @@ def edit_shelf(shelf_id):
|
||||
return create_edit_shelf(shelf, page_title=_(u"Edit a shelf"), page="shelfedit", shelf_id=shelf_id)
|
||||
|
||||
|
||||
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
||||
@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(_(u"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": "order", '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"])
|
||||
@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 diffrent from before -> shelf.last_modified = datetime.utcnow()
|
||||
try:
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_(u"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=_(u"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):
|
||||
if cur_shelf.is_public:
|
||||
return True
|
||||
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
|
||||
log.error("User is unauthorized to view non-public shelf: {}".format(cur_shelf.name))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# if shelf ID is set, we are editing a shelf
|
||||
def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
||||
sync_only_selected_shelves = current_user.kobo_only_shelves_sync
|
||||
@ -248,12 +312,17 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
||||
if not current_user.role_edit_shelfs() and to_save.get("is_public") == "on":
|
||||
flash(_(u"Sorry you are not allowed to create a public shelf"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
shelf.is_public = 1 if to_save.get("is_public") else 0
|
||||
is_public = 1 if to_save.get("is_public") == "on" else 0
|
||||
if config.config_kobo_sync:
|
||||
shelf.kobo_sync = True if to_save.get("kobo_sync") else False
|
||||
if shelf.kobo_sync:
|
||||
ub.session.query(ub.ShelfArchive).filter(ub.ShelfArchive.user_id == current_user.id).filter(
|
||||
ub.ShelfArchive.uuid == shelf.uuid).delete()
|
||||
ub.session_commit()
|
||||
shelf_title = to_save.get("title", "")
|
||||
if check_shelf_is_unique(shelf, shelf_title, shelf_id):
|
||||
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)
|
||||
@ -269,12 +338,12 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
||||
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
||||
except (OperationalError, InvalidRequestError) as ex:
|
||||
ub.session.rollback()
|
||||
log.debug_or_exception(ex)
|
||||
log.error("Settings DB is not Writeable")
|
||||
flash(_("Settings DB is not Writeable"), category="error")
|
||||
log.error_or_exception(ex)
|
||||
log.error_or_exception("Settings Database error: {}".format(ex))
|
||||
flash(_(u"Database error: %(error)s.", error=ex.orig), category="error")
|
||||
except Exception as ex:
|
||||
ub.session.rollback()
|
||||
log.debug_or_exception(ex)
|
||||
log.error_or_exception(ex)
|
||||
flash(_(u"There was an error"), category="error")
|
||||
return render_title_template('shelf_edit.html',
|
||||
shelf=shelf,
|
||||
@ -284,12 +353,12 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
||||
sync_only_selected_shelves=sync_only_selected_shelves)
|
||||
|
||||
|
||||
def check_shelf_is_unique(shelf, title, shelf_id=False):
|
||||
def check_shelf_is_unique(title, is_public, shelf_id=False):
|
||||
if shelf_id:
|
||||
ident = ub.Shelf.id != shelf_id
|
||||
else:
|
||||
ident = true()
|
||||
if shelf.is_public == 1:
|
||||
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) \
|
||||
@ -315,70 +384,13 @@ def check_shelf_is_unique(shelf, title, shelf_id=False):
|
||||
|
||||
def delete_shelf_helper(cur_shelf):
|
||||
if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
|
||||
return
|
||||
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))
|
||||
|
||||
|
||||
@shelf.route("/shelf/delete/<int:shelf_id>")
|
||||
@login_required
|
||||
def delete_shelf(shelf_id):
|
||||
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
try:
|
||||
delete_shelf_helper(cur_shelf)
|
||||
except InvalidRequestError:
|
||||
ub.session.rollback()
|
||||
log.error("Settings DB is not Writeable")
|
||||
flash(_("Settings DB is not Writeable"), 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": "order", '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"])
|
||||
@login_required
|
||||
def order_shelf(shelf_id):
|
||||
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 diffrent from before -> shelf.last_modified = datetime.utcnow()
|
||||
try:
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError):
|
||||
ub.session.rollback()
|
||||
log.error("Settings DB is not Writeable")
|
||||
flash(_("Settings DB is not Writeable"), category="error")
|
||||
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
result = list()
|
||||
if shelf and check_shelf_view_permissions(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=_(u"Change order of Shelf: '%(name)s'", name=shelf.name),
|
||||
shelf=shelf, page="shelforder")
|
||||
return True
|
||||
|
||||
|
||||
def change_shelf_order(shelf_id, order):
|
||||
@ -398,7 +410,6 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||
|
||||
# check user is allowed to access shelf
|
||||
if shelf and check_shelf_view_permissions(shelf):
|
||||
|
||||
if shelf_type == 1:
|
||||
# order = [ub.BookShelf.order.asc()]
|
||||
if sort_param == 'pubnew':
|
||||
@ -429,7 +440,7 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||
db.Books,
|
||||
ub.BookShelf.shelf == shelf_id,
|
||||
[ub.BookShelf.order.asc()],
|
||||
False, 0,
|
||||
True, config.config_read_column,
|
||||
ub.BookShelf, ub.BookShelf.book_id == db.Books.id)
|
||||
# delete chelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
|
||||
wrong_entries = calibre_db.session.query(ub.BookShelf) \
|
||||
@ -440,10 +451,10 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||
try:
|
||||
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == entry.book_id).delete()
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError):
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error("Settings DB is not Writeable")
|
||||
flash(_("Settings DB is not Writeable"), category="error")
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||
|
||||
return render_title_template(page,
|
||||
entries=result,
|
||||
|
@ -5150,7 +5150,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover {
|
||||
#DeleteDomain:hover:before, #RestartDialog:hover:before, #ShutdownDialog:hover:before, #StatusDialog:hover:before, #deleteButton, #deleteModal:hover:before, #cancelTaskModal:hover:before, body.mailset > div.container-fluid > div > div.col-sm-10 > div.discover td > a:hover {
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
@ -5237,7 +5237,11 @@ body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > d
|
||||
margin-bottom: 20px
|
||||
}
|
||||
|
||||
body.admin:not(.modal-open) .btn-default {
|
||||
body.admin > div.container-fluid div.scheduled_tasks_details {
|
||||
margin-bottom: 20px
|
||||
}
|
||||
|
||||
body.admin .btn-default {
|
||||
margin-bottom: 10px
|
||||
}
|
||||
|
||||
@ -5468,7 +5472,7 @@ body.admin.modal-open .navbar {
|
||||
z-index: 0 !important
|
||||
}
|
||||
|
||||
#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal {
|
||||
#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal, #cancelTaskModal {
|
||||
top: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 70px;
|
||||
@ -5478,7 +5482,7 @@ body.admin.modal-open .navbar {
|
||||
background: rgba(0, 0, 0, .5)
|
||||
}
|
||||
|
||||
#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before {
|
||||
#RestartDialog:before, #ShutdownDialog:before, #StatusDialog:before, #deleteModal:before, #cancelTaskModal:before {
|
||||
content: "\E208";
|
||||
padding-right: 10px;
|
||||
display: block;
|
||||
@ -5500,18 +5504,18 @@ body.admin.modal-open .navbar {
|
||||
z-index: 99
|
||||
}
|
||||
|
||||
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before {
|
||||
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before {
|
||||
-webkit-transform: translate(0, 0);
|
||||
-ms-transform: translate(0, 0);
|
||||
transform: translate(0, 0)
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog {
|
||||
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog {
|
||||
width: 450px;
|
||||
margin: auto
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content {
|
||||
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content {
|
||||
max-height: calc(100% - 90px);
|
||||
-webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
|
||||
@ -5522,7 +5526,7 @@ body.admin.modal-open .navbar {
|
||||
width: 450px
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header {
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-header, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header, #StatusDialog > .modal-dialog > .modal-content > .modal-header, #deleteModal > .modal-dialog > .modal-content > .modal-header, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header {
|
||||
padding: 15px 20px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
line-height: 1.71428571;
|
||||
@ -5535,7 +5539,7 @@ body.admin.modal-open .navbar {
|
||||
text-align: left
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before {
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-header:before, #ShutdownDialog > .modal-dialog > .modal-content > .modal-header:before, #StatusDialog > .modal-dialog > .modal-content > .modal-header:before, #deleteModal > .modal-dialog > .modal-content > .modal-header:before, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before {
|
||||
padding-right: 10px;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
@ -5564,6 +5568,11 @@ body.admin.modal-open .navbar {
|
||||
font-family: plex-icons-new, serif
|
||||
}
|
||||
|
||||
#cancelTaskModal > .modal-dialog > .modal-content > .modal-header:before {
|
||||
content: "\EA6D";
|
||||
font-family: plex-icons-new, serif
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-header:after {
|
||||
content: "Restart Calibre-Web";
|
||||
display: inline-block;
|
||||
@ -5588,7 +5597,13 @@ body.admin.modal-open .navbar {
|
||||
font-size: 20px
|
||||
}
|
||||
|
||||
#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile {
|
||||
#cancelTaskModal > .modal-dialog > .modal-content > .modal-header:after {
|
||||
content: "Delete Book";
|
||||
display: inline-block;
|
||||
font-size: 20px
|
||||
}
|
||||
|
||||
#StatusDialog > .modal-dialog > .modal-content > .modal-header > span, #deleteModal > .modal-dialog > .modal-content > .modal-header > span, #cancelTaskModal > .modal-dialog > .modal-content > .modal-header > span, #loader > center > img, .rating-mobile {
|
||||
display: none
|
||||
}
|
||||
|
||||
@ -5602,7 +5617,7 @@ body.admin.modal-open .navbar {
|
||||
text-align: left
|
||||
}
|
||||
|
||||
#ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body {
|
||||
#ShutdownDialog > .modal-dialog > .modal-content > .modal-body, #StatusDialog > .modal-dialog > .modal-content > .modal-body, #deleteModal > .modal-dialog > .modal-content > .modal-body, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body {
|
||||
padding: 20px 20px 40px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6em;
|
||||
@ -5612,7 +5627,7 @@ body.admin.modal-open .navbar {
|
||||
text-align: left
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p {
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > p, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > p, #StatusDialog > .modal-dialog > .modal-content > .modal-body > p, #deleteModal > .modal-dialog > .modal-content > .modal-body > p, #cancelTaskModal > .modal-dialog > .modal-content > .modal-body > p {
|
||||
padding: 20px 20px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.6em;
|
||||
@ -5621,7 +5636,7 @@ body.admin.modal-open .navbar {
|
||||
background: #282828
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default {
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart), #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown), #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default {
|
||||
float: right;
|
||||
z-index: 9;
|
||||
position: relative;
|
||||
@ -5669,6 +5684,18 @@ body.admin.modal-open .navbar {
|
||||
border-radius: 3px
|
||||
}
|
||||
|
||||
#cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-danger {
|
||||
float: right;
|
||||
z-index: 9;
|
||||
position: relative;
|
||||
margin: 0 0 0 10px;
|
||||
min-width: 80px;
|
||||
padding: 10px 18px;
|
||||
font-size: 16px;
|
||||
line-height: 1.33;
|
||||
border-radius: 3px
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart) {
|
||||
margin: 25px 0 0 10px
|
||||
}
|
||||
@ -5681,7 +5708,11 @@ body.admin.modal-open .navbar {
|
||||
margin: 0 0 0 10px
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover {
|
||||
#cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default {
|
||||
margin: 0 0 0 10px
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart):hover, #ShutdownDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#shutdown):hover, #deleteModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover, #cancelTaskModal > .modal-dialog > .modal-content > .modal-footer > .btn-default:hover {
|
||||
background-color: hsla(0, 0%, 100%, .3)
|
||||
}
|
||||
|
||||
@ -7303,11 +7334,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
||||
background-color: transparent !important
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog {
|
||||
#RestartDialog > .modal-dialog, #ShutdownDialog > .modal-dialog, #StatusDialog > .modal-dialog, #deleteModal > .modal-dialog, #cancelTaskModal > .modal-dialog {
|
||||
max-width: calc(100vw - 40px)
|
||||
}
|
||||
|
||||
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content {
|
||||
#RestartDialog > .modal-dialog > .modal-content, #ShutdownDialog > .modal-dialog > .modal-content, #StatusDialog > .modal-dialog > .modal-content, #deleteModal > .modal-dialog > .modal-content, #cancelTaskModal > .modal-dialog > .modal-content {
|
||||
max-width: calc(100vw - 40px);
|
||||
left: 0
|
||||
}
|
||||
@ -7457,7 +7488,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
||||
padding: 30px 15px
|
||||
}
|
||||
|
||||
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before {
|
||||
#RestartDialog.in:before, #ShutdownDialog.in:before, #StatusDialog.in:before, #deleteModal.in:before, #cancelTaskModal.in:before {
|
||||
left: auto;
|
||||
right: 34px
|
||||
}
|
||||
|
@ -270,7 +270,7 @@ if ($("body.book").length > 0) {
|
||||
|
||||
if (position + $("#add-to-shelves").width() > $(window).width()) {
|
||||
positionOff = position + $("#add-to-shelves").width() - $(window).width();
|
||||
adsPosition = position - positionOff - 5
|
||||
adsPosition = position - positionOff - 5;
|
||||
$("#add-to-shelves").attr("style", "left: " + adsPosition + "px !important; right: auto; top: " + topPos + "px");
|
||||
} else {
|
||||
$("#add-to-shelves").attr("style", "left: " + position + "px !important; right: auto; top: " + topPos + "px");
|
||||
@ -429,7 +429,7 @@ if($("body.advsearch").length > 0) {
|
||||
|
||||
if (position + $("#add-to-shelves").width() > $(window).width()) {
|
||||
positionOff = position + $("#add-to-shelves").width() - $(window).width();
|
||||
adsPosition = position - positionOff - 5
|
||||
adsPosition = position - positionOff - 5;
|
||||
$("#add-to-shelves").attr("style", "left: " + adsPosition + "px !important; right: auto; top: " + topPos + "px");
|
||||
} else {
|
||||
$("#add-to-shelves").attr("style", "left: " + position + "px !important; right: auto; top: " + topPos + "px");
|
||||
@ -479,12 +479,12 @@ if ($.trim($("#add-to-shelves").html()).length === 0) {
|
||||
$("#add-to-shelf").addClass("empty-ul");
|
||||
}
|
||||
|
||||
shelfLength = $("#add-to-shelves li").length
|
||||
emptyLength = 0
|
||||
shelfLength = $("#add-to-shelves li").length;
|
||||
emptyLength = 0;
|
||||
|
||||
$("#add-to-shelves").on("click", "li a", function () {
|
||||
console.log("#remove-from-shelves change registered");
|
||||
emptyLength++
|
||||
emptyLength++;
|
||||
|
||||
setTimeout(function () {
|
||||
if (emptyLength >= shelfLength) {
|
||||
|
@ -28,14 +28,24 @@ $("#have_read_cb").on("change", function() {
|
||||
data: $(this).closest("form").serialize(),
|
||||
error: function(response) {
|
||||
var data = [{type:"danger", message:response.responseText}]
|
||||
$("#flash_success").remove();
|
||||
// $("#flash_success").parent().remove();
|
||||
$("#flash_danger").remove();
|
||||
$(".row-fluid.text-center").remove();
|
||||
if (!jQuery.isEmptyObject(data)) {
|
||||
data.forEach(function (item) {
|
||||
$(".navbar").after('<div class="row-fluid text-center" >' +
|
||||
'<div id="flash_' + item.type + '" class="alert alert-' + item.type + '">' + item.message + '</div>' +
|
||||
'</div>');
|
||||
});
|
||||
$("#have_read_cb").prop("checked", !$("#have_read_cb").prop("checked"));
|
||||
if($("#bookDetailsModal").is(":visible")) {
|
||||
data.forEach(function (item) {
|
||||
$(".modal-header").after('<div id="flash_' + item.type +
|
||||
'" class="text-center alert alert-' + item.type + '">' + item.message + '</div>');
|
||||
});
|
||||
} else
|
||||
{
|
||||
data.forEach(function (item) {
|
||||
$(".navbar").after('<div class="row-fluid text-center" >' +
|
||||
'<div id="flash_' + item.type + '" class="alert alert-' + item.type + '">' + item.message + '</div>' +
|
||||
'</div>');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -59,17 +69,20 @@ $("#archived_cb").on("change", function() {
|
||||
)
|
||||
};
|
||||
|
||||
$("#shelf-actions").on("click", "[data-shelf-action]", function (e) {
|
||||
$("#add-to-shelves, #remove-from-shelves").on("click", "[data-shelf-action]", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
$.get(this.href)
|
||||
$.ajax({
|
||||
url: $(this).data('href'),
|
||||
method:"post",
|
||||
data: {csrf_token:$("input[name='csrf_token']").val()},
|
||||
})
|
||||
.done(function() {
|
||||
var $this = $(this);
|
||||
switch ($this.data("shelf-action")) {
|
||||
case "add":
|
||||
$("#remove-from-shelves").append(
|
||||
templates.remove({
|
||||
add: this.href,
|
||||
add: $this.data('href'),
|
||||
remove: $this.data("remove-href"),
|
||||
content: $("<div>").text(this.textContent).html()
|
||||
})
|
||||
@ -79,7 +92,7 @@ $("#archived_cb").on("change", function() {
|
||||
$("#add-to-shelves").append(
|
||||
templates.add({
|
||||
add: $this.data("add-href"),
|
||||
remove: this.href,
|
||||
remove: $this.data('href'),
|
||||
content: $("<div>").text(this.textContent).html(),
|
||||
})
|
||||
);
|
||||
|
@ -33,7 +33,7 @@ $(".datepicker").datepicker({
|
||||
if (results) {
|
||||
pubDate = new Date(results[1], parseInt(results[2], 10) - 1, results[3]) || new Date(this.value);
|
||||
$(this).next('input')
|
||||
.val(pubDate.toLocaleDateString(language))
|
||||
.val(pubDate.toLocaleDateString(language.replaceAll("_","-")))
|
||||
.removeClass("hidden");
|
||||
}
|
||||
}).trigger("change");
|
||||
|
@ -26,21 +26,28 @@ $(function () {
|
||||
)
|
||||
};
|
||||
|
||||
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) {
|
||||
tinymce.get("description").setContent(book.description);
|
||||
var uniqueTags = $.map($("#tags").val().split(","), $.trim);
|
||||
if ( uniqueTags.length == 1 && uniqueTags[0] == "") {
|
||||
uniqueTags = [];
|
||||
}
|
||||
$.each(book.tags, function(i, el) {
|
||||
if ($.inArray(el, uniqueTags) === -1) uniqueTags.push(el);
|
||||
});
|
||||
var uniqueTags = getUniqueValues('tags', book)
|
||||
var uniqueLanguages = getUniqueValues('languages', book)
|
||||
var ampSeparatedAuthors = (book.authors || []).join(" & ");
|
||||
$("#bookAuthor").val(ampSeparatedAuthors);
|
||||
$("#book_title").val(book.title);
|
||||
$("#tags").val(uniqueTags.join(", "));
|
||||
$("#languages").val(uniqueLanguages.join(", "));
|
||||
$("#rating").data("rating").setValue(Math.round(book.rating));
|
||||
if(book.cover !== null){
|
||||
if(book.cover && $("#cover_url").length){
|
||||
$(".cover img").attr("src", book.cover);
|
||||
$("#cover_url").val(book.cover);
|
||||
}
|
||||
@ -48,7 +55,32 @@ $(function () {
|
||||
$("#publisher").val(book.publisher);
|
||||
if (typeof book.series !== "undefined") {
|
||||
$("#series").val(book.series);
|
||||
$("#series_index").val(book.series_index);
|
||||
}
|
||||
if (typeof book.identifiers !== "undefined") {
|
||||
populateIdentifiers(book.identifiers)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -60,14 +92,19 @@ $(function () {
|
||||
data: {"query": keyword},
|
||||
dataType: "json",
|
||||
success: function success(data) {
|
||||
$("#meta-info").html("<ul id=\"book-list\" class=\"media-list\"></ul>");
|
||||
data.forEach(function(book) {
|
||||
var $book = $(templates.bookResult(book));
|
||||
$book.find("img").on("click", function () {
|
||||
populateForm(book);
|
||||
if (data.length) {
|
||||
$("#meta-info").html("<ul id=\"book-list\" class=\"media-list\"></ul>");
|
||||
data.forEach(function(book) {
|
||||
var $book = $(templates.bookResult(book));
|
||||
$book.find("img").on("click", function () {
|
||||
populateForm(book);
|
||||
});
|
||||
$("#book-list").append($book);
|
||||
});
|
||||
$("#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);
|
||||
@ -128,9 +165,7 @@ $(function () {
|
||||
e.preventDefault();
|
||||
keyword = $("#keyword").val();
|
||||
$('.pill').each(function(){
|
||||
// console.log($(this).data('control'));
|
||||
$(this).data("initial", $(this).prop('checked'));
|
||||
// console.log($(this).data('initial'));
|
||||
});
|
||||
doSearch(keyword);
|
||||
});
|
||||
|
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.ko.min.js
vendored
Normal file
1
cps/static/js/libs/bootstrap-datepicker/locales/bootstrap-datepicker.ko.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
!function(a){a.fn.datepicker.dates.ko={days:["일요일","월요일","화요일","수요일","목요일","금요일","토요일"],daysShort:["일","월","화","수","목","금","토"],daysMin:["일","월","화","수","목","금","토"],months:["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],monthsShort:["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],today:"오늘",clear:"삭제",format:"yyyy-mm-dd",titleFormat:"yyyy년mm월",weekStart:0}}(jQuery);
|
261
cps/static/js/libs/tinymce/langs/ko.js
Normal file
261
cps/static/js/libs/tinymce/langs/ko.js
Normal file
@ -0,0 +1,261 @@
|
||||
tinymce.addI18n('ko_KR',{
|
||||
"Redo": "\ub2e4\uc2dc\uc2e4\ud589",
|
||||
"Undo": "\uc2e4\ud589\ucde8\uc18c",
|
||||
"Cut": "\uc798\ub77c\ub0b4\uae30",
|
||||
"Copy": "\ubcf5\uc0ac\ud558\uae30",
|
||||
"Paste": "\ubd99\uc5ec\ub123\uae30",
|
||||
"Select all": "\uc804\uccb4\uc120\ud0dd",
|
||||
"New document": "\uc0c8 \ubb38\uc11c",
|
||||
"Ok": "\ud655\uc778",
|
||||
"Cancel": "\ucde8\uc18c",
|
||||
"Visual aids": "\uc2dc\uac01\uad50\uc7ac",
|
||||
"Bold": "\uad75\uac8c",
|
||||
"Italic": "\uae30\uc6b8\uc784\uaf34",
|
||||
"Underline": "\ubc11\uc904",
|
||||
"Strikethrough": "\ucde8\uc18c\uc120",
|
||||
"Superscript": "\uc717\ucca8\uc790",
|
||||
"Subscript": "\uc544\ub798\ucca8\uc790",
|
||||
"Clear formatting": "\ud3ec\ub9f7\ucd08\uae30\ud654",
|
||||
"Align left": "\uc67c\ucabd\uc815\ub82c",
|
||||
"Align center": "\uac00\uc6b4\ub370\uc815\ub82c",
|
||||
"Align right": "\uc624\ub978\ucabd\uc815\ub82c",
|
||||
"Justify": "\uc591\ucabd\uc815\ub82c",
|
||||
"Bullet list": "\uc810\ub9ac\uc2a4\ud2b8",
|
||||
"Numbered list": "\uc22b\uc790\ub9ac\uc2a4\ud2b8",
|
||||
"Decrease indent": "\ub0b4\uc5b4\uc4f0\uae30",
|
||||
"Increase indent": "\ub4e4\uc5ec\uc4f0\uae30",
|
||||
"Close": "\ub2eb\uae30",
|
||||
"Formats": "\ud3ec\ub9f7",
|
||||
"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "\ube0c\ub77c\uc6b0\uc838\uac00 \ud074\ub9bd\ubcf4\ub4dc \uc811\uadfc\uc744 \ud5c8\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. Ctrl+X\/C\/V \ud0a4\ub97c \uc774\uc6a9\ud574 \uc8fc\uc138\uc694.",
|
||||
"Headers": "\uc2a4\ud0c0\uc77c",
|
||||
"Header 1": "\uc81c\ubaa9 1",
|
||||
"Header 2": "\uc81c\ubaa9 2",
|
||||
"Header 3": "\uc81c\ubaa9 3",
|
||||
"Header 4": "\uc81c\ubaa9 4",
|
||||
"Header 5": "\uc81c\ubaa9 5",
|
||||
"Header 6": "\uc81c\ubaa9 6",
|
||||
"Headings": "\uc81c\ubaa9",
|
||||
"Heading 1": "\uc81c\ubaa9 1",
|
||||
"Heading 2": "\uc81c\ubaa9 2",
|
||||
"Heading 3": "\uc81c\ubaa9 3",
|
||||
"Heading 4": "\uc81c\ubaa9 4",
|
||||
"Heading 5": "\uc81c\ubaa9 5",
|
||||
"Heading 6": "\uc81c\ubaa9 6",
|
||||
"Preformatted": "Preformatted",
|
||||
"Div": "\uad6c\ubd84",
|
||||
"Pre": "Pre",
|
||||
"Code": "\ucf54\ub4dc",
|
||||
"Paragraph": "\ub2e8\ub77d",
|
||||
"Blockquote": "\uad6c\ud68d",
|
||||
"Inline": "\ub77c\uc778 \uc124\uc815",
|
||||
"Blocks": "\ube14\ub85d \uc124\uc815",
|
||||
"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "\uc2a4\ud0c0\uc77c\ubcf5\uc0ac \ub044\uae30. \uc774 \uc635\uc158\uc744 \ub044\uae30 \uc804\uc5d0\ub294 \ubcf5\uc0ac \uc2dc, \uc2a4\ud0c0\uc77c\uc774 \ubcf5\uc0ac\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.",
|
||||
"Font Family": "\uae00\uaf34",
|
||||
"Font Sizes": "\ud3f0\ud2b8 \uc0ac\uc774\uc988",
|
||||
"Class": "\ud074\ub798\uc2a4",
|
||||
"Browse for an image": "\uc774\ubbf8\uc9c0 \ucc3e\uae30",
|
||||
"OR": "\ud639\uc740",
|
||||
"Drop an image here": "\uc774\ubbf8\uc9c0 \ub4dc\ub86d",
|
||||
"Upload": "\uc5c5\ub85c\ub4dc",
|
||||
"Block": "\ube14\ub85d",
|
||||
"Align": "\uc815\ub82c",
|
||||
"Default": "\uae30\ubcf8",
|
||||
"Circle": "\uc6d0",
|
||||
"Disc": "\uc6d0\ubc18",
|
||||
"Square": "\uc0ac\uac01",
|
||||
"Lower Alpha": "\uc54c\ud30c\ubcb3 \uc18c\ubb38\uc790",
|
||||
"Lower Greek": "\uadf8\ub9ac\uc2a4\uc5b4 \uc18c\ubb38\uc790",
|
||||
"Lower Roman": "\ub85c\ub9c8\uc790 \uc18c\ubb38\uc790",
|
||||
"Upper Alpha": "\uc54c\ud30c\ubcb3 \uc18c\ubb38\uc790",
|
||||
"Upper Roman": "\ub85c\ub9c8\uc790 \ub300\ubb38\uc790",
|
||||
"Anchor": "\uc575\ucee4",
|
||||
"Name": "\uc774\ub984",
|
||||
"Id": "\uc544\uc774\ub514",
|
||||
"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "\uc544\uc774\ub514\ub294 \ubb38\uc790, \uc22b\uc790, \ub300\uc2dc, \uc810, \ucf5c\ub860 \ub610\ub294 \ubc11\uc904\ub85c \uc2dc\uc791\ud574\uc57c\ud569\ub2c8\ub2e4.",
|
||||
"You have unsaved changes are you sure you want to navigate away?": "\uc800\uc7a5\ud558\uc9c0 \uc54a\uc740 \uc815\ubcf4\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uc774 \ud398\uc774\uc9c0\ub97c \ubc97\uc5b4\ub098\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
|
||||
"Restore last draft": "\ub9c8\uc9c0\ub9c9 \ucd08\uc548 \ubcf5\uc6d0",
|
||||
"Special character": "\ud2b9\uc218\ubb38\uc790",
|
||||
"Source code": "\uc18c\uc2a4\ucf54\ub4dc",
|
||||
"Insert\/Edit code sample": "\ucf54\ub4dc\uc0d8\ud50c \uc0bd\uc785\/\ud3b8\uc9d1",
|
||||
"Language": "\uc5b8\uc5b4",
|
||||
"Code sample": "\ucf54\ub4dc\uc0d8\ud50c",
|
||||
"Color": "\uc0c9\uc0c1",
|
||||
"R": "R",
|
||||
"G": "G",
|
||||
"B": "B",
|
||||
"Left to right": "\uc67c\ucabd\uc5d0\uc11c \uc624\ub978\ucabd",
|
||||
"Right to left": "\uc624\ub978\ucabd\uc5d0\uc11c \uc67c\ucabd",
|
||||
"Emoticons": "\uc774\ubaa8\ud2f0\ucf58",
|
||||
"Document properties": "\ubb38\uc11c \uc18d\uc131",
|
||||
"Title": "\uc81c\ubaa9",
|
||||
"Keywords": "\ud0a4\uc6cc\ub4dc",
|
||||
"Description": "\uc124\uba85",
|
||||
"Robots": "\ub85c\ubd07",
|
||||
"Author": "\uc800\uc790",
|
||||
"Encoding": "\uc778\ucf54\ub529",
|
||||
"Fullscreen": "\uc804\uccb4\ud654\uba74",
|
||||
"Action": "\ub3d9\uc791",
|
||||
"Shortcut": "\ub2e8\ucd95\ud0a4",
|
||||
"Help": "\ub3c4\uc6c0\ub9d0",
|
||||
"Address": "\uc8fc\uc18c",
|
||||
"Focus to menubar": "\uba54\ub274\uc5d0 \ud3ec\ucee4\uc2a4",
|
||||
"Focus to toolbar": "\ud234\ubc14\uc5d0 \ud3ec\ucee4\uc2a4",
|
||||
"Focus to element path": "element path\uc5d0 \ud3ec\ucee4\uc2a4",
|
||||
"Focus to contextual toolbar": "\ucf04\ud14d\uc2a4\ud2b8 \ud234\ubc14\uc5d0 \ud3ec\ucee4\uc2a4",
|
||||
"Insert link (if link plugin activated)": "\ub9c1\ud06c \uc0bd\uc785 (link \ud50c\ub7ec\uadf8\uc778\uc774 \ud65c\uc131\ud654\ub41c \uc0c1\ud0dc\uc5d0\uc11c)",
|
||||
"Save (if save plugin activated)": "\uc800\uc7a5 (save \ud50c\ub7ec\uadf8\uc778\uc774 \ud65c\uc131\ud654\ub41c \uc0c1\ud0dc\uc5d0\uc11c)",
|
||||
"Find (if searchreplace plugin activated)": "\ucc3e\uae30(searchreplace \ud50c\ub7ec\uadf8\uc778\uc774 \ud65c\uc131\ud654\ub41c \uc0c1\ud0dc\uc5d0\uc11c)",
|
||||
"Plugins installed ({0}):": "\uc124\uce58\ub41c \ud50c\ub7ec\uadf8\uc778 ({0}):",
|
||||
"Premium plugins:": "\uace0\uae09 \ud50c\ub7ec\uadf8\uc778",
|
||||
"Learn more...": "\uc880 \ub354 \uc0b4\ud3b4\ubcf4\uae30",
|
||||
"You are using {0}": "{0}\ub97c \uc0ac\uc6a9\uc911",
|
||||
"Plugins": "\ud50c\ub7ec\uadf8\uc778",
|
||||
"Handy Shortcuts": "\ub2e8\ucd95\ud0a4",
|
||||
"Horizontal line": "\uac00\ub85c",
|
||||
"Insert\/edit image": "\uc774\ubbf8\uc9c0 \uc0bd\uc785\/\uc218\uc815",
|
||||
"Image description": "\uc774\ubbf8\uc9c0 \uc124\uba85",
|
||||
"Source": "\uc18c\uc2a4",
|
||||
"Dimensions": "\ud06c\uae30",
|
||||
"Constrain proportions": "\uc791\uc5c5 \uc81c\ud55c",
|
||||
"General": "\uc77c\ubc18",
|
||||
"Advanced": "\uace0\uae09",
|
||||
"Style": "\uc2a4\ud0c0\uc77c",
|
||||
"Vertical space": "\uc218\uc9c1 \uacf5\ubc31",
|
||||
"Horizontal space": "\uc218\ud3c9 \uacf5\ubc31",
|
||||
"Border": "\ud14c\ub450\ub9ac",
|
||||
"Insert image": "\uc774\ubbf8\uc9c0 \uc0bd\uc785",
|
||||
"Image": "\uc774\ubbf8\uc9c0",
|
||||
"Image list": "\uc774\ubbf8\uc9c0 \ubaa9\ub85d",
|
||||
"Rotate counterclockwise": "\uc2dc\uacc4\ubc18\ub300\ubc29\ud5a5\uc73c\ub85c \ud68c\uc804",
|
||||
"Rotate clockwise": "\uc2dc\uacc4\ubc29\ud5a5\uc73c\ub85c \ud68c\uc804",
|
||||
"Flip vertically": "\uc218\uc9c1 \ub4a4\uc9d1\uae30",
|
||||
"Flip horizontally": "\uc218\ud3c9 \ub4a4\uc9d1\uae30",
|
||||
"Edit image": "\uc774\ubbf8\uc9c0 \ud3b8\uc9d1",
|
||||
"Image options": "\uc774\ubbf8\uc9c0 \uc635\uc158",
|
||||
"Zoom in": "\ud655\ub300",
|
||||
"Zoom out": "\ucd95\uc18c",
|
||||
"Crop": "\uc790\ub974\uae30",
|
||||
"Resize": "\ud06c\uae30 \uc870\uc808",
|
||||
"Orientation": "\ubc29\ud5a5",
|
||||
"Brightness": "\ubc1d\uae30",
|
||||
"Sharpen": "\uc120\uba85\ud558\uac8c",
|
||||
"Contrast": "\ub300\ube44",
|
||||
"Color levels": "\uc0c9\uc0c1\ub808\ubca8",
|
||||
"Gamma": "\uac10\ub9c8",
|
||||
"Invert": "\ubc18\uc804",
|
||||
"Apply": "\uc801\uc6a9",
|
||||
"Back": "\ub4a4\ub85c",
|
||||
"Insert date\/time": "\ub0a0\uc9dc\/\uc2dc\uac04\uc0bd\uc785",
|
||||
"Date\/time": "\ub0a0\uc9dc\/\uc2dc\uac04",
|
||||
"Insert link": "\ub9c1\ud06c \uc0bd\uc785 ",
|
||||
"Insert\/edit link": "\ub9c1\ud06c \uc0bd\uc785\/\uc218\uc815",
|
||||
"Text to display": "\ubcf8\ubb38",
|
||||
"Url": "\uc8fc\uc18c",
|
||||
"Target": "\ub300\uc0c1",
|
||||
"None": "\uc5c6\uc74c",
|
||||
"New window": "\uc0c8\ucc3d",
|
||||
"Remove link": "\ub9c1\ud06c\uc0ad\uc81c",
|
||||
"Anchors": "\ucc45\uac08\ud53c",
|
||||
"Link": "\ub9c1\ud06c",
|
||||
"Paste or type a link": "\ub9c1\ud06c\ub97c \ubd99\uc5ec\ub123\uac70\ub098 \uc785\ub825\ud558\uc138\uc694",
|
||||
"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "\ud604\uc7ac E-mail\uc8fc\uc18c\ub97c \uc785\ub825\ud558\uc168\uc2b5\ub2c8\ub2e4. E-mail \uc8fc\uc18c\uc5d0 \ub9c1\ud06c\ub97c \uac78\uae4c\uc694?",
|
||||
"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "\ud604\uc7ac \uc6f9\uc0ac\uc774\ud2b8 \uc8fc\uc18c\ub97c \uc785\ub825\ud558\uc168\uc2b5\ub2c8\ub2e4. \ud574\ub2f9 \uc8fc\uc18c\uc5d0 \ub9c1\ud06c\ub97c \uac78\uae4c\uc694?",
|
||||
"Link list": "\ub9c1\ud06c \ub9ac\uc2a4\ud2b8",
|
||||
"Insert video": "\ube44\ub514\uc624 \uc0bd\uc785",
|
||||
"Insert\/edit video": "\ube44\ub514\uc624 \uc0bd\uc785\/\uc218\uc815",
|
||||
"Insert\/edit media": "\ubbf8\ub514\uc5b4 \uc0bd\uc785\/\uc218\uc815",
|
||||
"Alternative source": "\ub300\uccb4 \uc18c\uc2a4",
|
||||
"Poster": "\ud3ec\uc2a4\ud130",
|
||||
"Paste your embed code below:": "\uc544\ub798\uc5d0 \ucf54\ub4dc\ub97c \ubd99\uc5ec\ub123\uc73c\uc138\uc694:",
|
||||
"Embed": "\uc0bd\uc785",
|
||||
"Media": "\ubbf8\ub514\uc5b4",
|
||||
"Nonbreaking space": "\ub744\uc5b4\uc4f0\uae30",
|
||||
"Page break": "\ud398\uc774\uc9c0 \uad6c\ubd84\uc790",
|
||||
"Paste as text": "\ud14d\uc2a4\ud2b8\ub85c \ubd99\uc5ec\ub123\uae30",
|
||||
"Preview": "\ubbf8\ub9ac\ubcf4\uae30",
|
||||
"Print": "\ucd9c\ub825",
|
||||
"Save": "\uc800\uc7a5",
|
||||
"Find": "\ucc3e\uae30",
|
||||
"Replace with": "\uad50\uccb4",
|
||||
"Replace": "\uad50\uccb4",
|
||||
"Replace all": "\uc804\uccb4 \uad50\uccb4",
|
||||
"Prev": "\uc774\uc804",
|
||||
"Next": "\ub2e4\uc74c",
|
||||
"Find and replace": "\ucc3e\uc544\uc11c \uad50\uccb4",
|
||||
"Could not find the specified string.": "\ubb38\uc790\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.",
|
||||
"Match case": "\ub300\uc18c\ubb38\uc790 \uc77c\uce58",
|
||||
"Whole words": "\uc804\uccb4 \ub2e8\uc5b4",
|
||||
"Spellcheck": "\ubb38\ubc95\uccb4\ud06c",
|
||||
"Ignore": "\ubb34\uc2dc",
|
||||
"Ignore all": "\uc804\uccb4\ubb34\uc2dc",
|
||||
"Finish": "\uc644\ub8cc",
|
||||
"Add to Dictionary": "\uc0ac\uc804\uc5d0 \ucd94\uac00",
|
||||
"Insert table": "\ud14c\uc774\ube14 \uc0bd\uc785",
|
||||
"Table properties": "\ud14c\uc774\ube14 \uc18d\uc131",
|
||||
"Delete table": "\ud14c\uc774\ube14 \uc0ad\uc81c",
|
||||
"Cell": "\uc140",
|
||||
"Row": "\uc5f4",
|
||||
"Column": "\ud589",
|
||||
"Cell properties": "\uc140 \uc18d",
|
||||
"Merge cells": "\uc140 \ud569\uce58\uae30",
|
||||
"Split cell": "\uc140 \ub098\ub204\uae30",
|
||||
"Insert row before": "\uc774\uc804\uc5d0 \ud589 \uc0bd\uc785",
|
||||
"Insert row after": "\ub2e4\uc74c\uc5d0 \ud589 \uc0bd\uc785",
|
||||
"Delete row": "\ud589 \uc9c0\uc6b0\uae30",
|
||||
"Row properties": "\ud589 \uc18d\uc131",
|
||||
"Cut row": "\ud589 \uc798\ub77c\ub0b4\uae30",
|
||||
"Copy row": "\ud589 \ubcf5\uc0ac",
|
||||
"Paste row before": "\uc774\uc804\uc5d0 \ud589 \ubd99\uc5ec\ub123\uae30",
|
||||
"Paste row after": "\ub2e4\uc74c\uc5d0 \ud589 \ubd99\uc5ec\ub123\uae30",
|
||||
"Insert column before": "\uc774\uc804\uc5d0 \ud589 \uc0bd\uc785",
|
||||
"Insert column after": "\ub2e4\uc74c\uc5d0 \uc5f4 \uc0bd\uc785",
|
||||
"Delete column": "\uc5f4 \uc9c0\uc6b0\uae30",
|
||||
"Cols": "\uc5f4",
|
||||
"Rows": "\ud589",
|
||||
"Width": "\ub113\uc774",
|
||||
"Height": "\ub192\uc774",
|
||||
"Cell spacing": "\uc140 \uac04\uaca9",
|
||||
"Cell padding": "\uc140 \uc548\ucabd \uc5ec\ubc31",
|
||||
"Caption": "\ucea1\uc158",
|
||||
"Left": "\uc67c\ucabd",
|
||||
"Center": "\uac00\uc6b4\ub370",
|
||||
"Right": "\uc624\ub978\ucabd",
|
||||
"Cell type": "\uc140 \ud0c0\uc785",
|
||||
"Scope": "\ubc94\uc704",
|
||||
"Alignment": "\uc815\ub82c",
|
||||
"H Align": "\uac00\ub85c \uc815\ub82c",
|
||||
"V Align": "\uc138\ub85c \uc815\ub82c",
|
||||
"Top": "\uc0c1\ub2e8",
|
||||
"Middle": "\uc911\uac04",
|
||||
"Bottom": "\ud558\ub2e8",
|
||||
"Header cell": "\ud5e4\ub354 \uc140",
|
||||
"Row group": "\ud589 \uadf8\ub8f9",
|
||||
"Column group": "\uc5f4 \uadf8\ub8f9",
|
||||
"Row type": "\ud589 \ud0c0\uc785",
|
||||
"Header": "\ud5e4\ub354",
|
||||
"Body": "\ubc14\ub514",
|
||||
"Footer": "\ud478\ud130",
|
||||
"Border color": "\ud14c\ub450\ub9ac \uc0c9",
|
||||
"Insert template": "\ud15c\ud50c\ub9bf \uc0bd\uc785",
|
||||
"Templates": "\ud15c\ud50c\ub9bf",
|
||||
"Template": "\ud15c\ud50c\ub9bf",
|
||||
"Text color": "\ubb38\uc790 \uc0c9\uae54",
|
||||
"Background color": "\ubc30\uacbd\uc0c9",
|
||||
"Custom...": "\uc9c1\uc811 \uc0c9\uae54 \uc9c0\uc815\ud558\uae30",
|
||||
"Custom color": "\uc9c1\uc811 \uc9c0\uc815\ud55c \uc0c9\uae54",
|
||||
"No color": "\uc0c9\uc0c1 \uc5c6\uc74c",
|
||||
"Table of Contents": "\ubaa9\ucc28",
|
||||
"Show blocks": "\ube14\ub7ed \ubcf4\uc5ec\uc8fc\uae30",
|
||||
"Show invisible characters": "\uc548\ubcf4\uc774\ub294 \ubb38\uc790 \ubcf4\uc774\uae30",
|
||||
"Words: {0}": "\ub2e8\uc5b4: {0}",
|
||||
"{0} words": "{0} \ub2e8\uc5b4",
|
||||
"File": "\ud30c\uc77c",
|
||||
"Edit": "\uc218\uc815",
|
||||
"Insert": "\uc0bd\uc785",
|
||||
"View": "\ubcf4\uae30",
|
||||
"Format": "\ud3ec\ub9f7",
|
||||
"Table": "\ud14c\uc774\ube14",
|
||||
"Tools": "\ub3c4\uad6c",
|
||||
"Powered by {0}": "Powered by {0}",
|
||||
"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "\uc11c\uc2dd \uc788\ub294 \ud14d\uc2a4\ud2b8 \ud3b8\uc9d1\uae30 \uc785\ub2c8\ub2e4. ALT-F9\ub97c \ub204\ub974\uba74 \uba54\ub274, ALT-F10\ub97c \ub204\ub974\uba74 \ud234\ubc14, ALT-0\uc744 \ub204\ub974\uba74 \ub3c4\uc6c0\ub9d0\uc744 \ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4."
|
||||
});
|
85
cps/static/js/main.js
Normal file → Executable file
85
cps/static/js/main.js
Normal file → Executable file
@ -20,6 +20,20 @@ function getPath() {
|
||||
return jsFileLocation.substr(0, jsFileLocation.search("/static/js/libs/jquery.min.js")); // the js folder path
|
||||
}
|
||||
|
||||
function postButton(event, action){
|
||||
event.preventDefault();
|
||||
var newForm = jQuery('<form>', {
|
||||
"action": action,
|
||||
'target': "_top",
|
||||
'method': "post"
|
||||
}).append(jQuery('<input>', {
|
||||
'name': 'csrf_token',
|
||||
'value': $("input[name=\'csrf_token\']").val(),
|
||||
'type': 'hidden'
|
||||
})).appendTo('body');
|
||||
newForm.submit();
|
||||
}
|
||||
|
||||
function elementSorter(a, b) {
|
||||
a = +a.slice(0, -2);
|
||||
b = +b.slice(0, -2);
|
||||
@ -71,6 +85,22 @@ $(document).on("change", "select[data-controlall]", function() {
|
||||
}
|
||||
});
|
||||
|
||||
/*$(document).on("click", "#sendbtn", function (event) {
|
||||
postButton(event, $(this).data('action'));
|
||||
});
|
||||
|
||||
$(document).on("click", ".sendbutton", function (event) {
|
||||
// $(".sendbutton").on("click", "body", function(event) {
|
||||
postButton(event, $(this).data('action'));
|
||||
});*/
|
||||
|
||||
$(document).on("click", ".postAction", function (event) {
|
||||
// $(".sendbutton").on("click", "body", function(event) {
|
||||
postButton(event, $(this).data('action'));
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Syntax has to be bind not on, otherwise problems with firefox
|
||||
$(".container-fluid").bind("dragenter dragover", function () {
|
||||
if($("#btn-upload").length && !$('body').hasClass('shelforder')) {
|
||||
@ -168,18 +198,18 @@ function confirmDialog(id, dialogid, dataValue, yesFn, noFn) {
|
||||
$confirm.modal('show');
|
||||
}
|
||||
|
||||
$("#delete_confirm").click(function() {
|
||||
$("#delete_confirm").click(function(event) {
|
||||
//get data-id attribute of the clicked element
|
||||
var deleteId = $(this).data("delete-id");
|
||||
var bookFormat = $(this).data("delete-format");
|
||||
var ajaxResponse = $(this).data("ajax");
|
||||
if (bookFormat) {
|
||||
window.location.href = getPath() + "/delete/" + deleteId + "/" + bookFormat;
|
||||
postButton(event, getPath() + "/delete/" + deleteId + "/" + bookFormat);
|
||||
} else {
|
||||
if (ajaxResponse) {
|
||||
path = getPath() + "/ajax/delete/" + deleteId;
|
||||
$.ajax({
|
||||
method:"get",
|
||||
method:"post",
|
||||
url: path,
|
||||
timeout: 900,
|
||||
success:function(data) {
|
||||
@ -198,8 +228,7 @@ $("#delete_confirm").click(function() {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
window.location.href = getPath() + "/delete/" + deleteId;
|
||||
|
||||
postButton(event, getPath() + "/delete/" + deleteId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -352,8 +381,8 @@ $(function() {
|
||||
//extraScrollPx: 300
|
||||
});
|
||||
$loadMore.on( "append.infiniteScroll", function( event, response, path, data ) {
|
||||
$(".pagination").addClass("hidden").html(() => $(response).find(".pagination").html());
|
||||
if ($("body").hasClass("blur")) {
|
||||
$(".pagination").addClass("hidden").html(() => $(response).find(".pagination").html());
|
||||
$(" a:not(.dropdown-toggle) ")
|
||||
.removeAttr("data-toggle");
|
||||
}
|
||||
@ -376,9 +405,11 @@ $(function() {
|
||||
|
||||
$("#restart").click(function() {
|
||||
$.ajax({
|
||||
method:"post",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
url: window.location.pathname + "/../../shutdown",
|
||||
data: {"parameter":0},
|
||||
url: getPath() + "/shutdown",
|
||||
data: JSON.stringify({"parameter":0}),
|
||||
success: function success() {
|
||||
$("#spinner").show();
|
||||
setTimeout(restartTimer, 3000);
|
||||
@ -387,9 +418,11 @@ $(function() {
|
||||
});
|
||||
$("#shutdown").click(function() {
|
||||
$.ajax({
|
||||
method:"post",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
url: window.location.pathname + "/../../shutdown",
|
||||
data: {"parameter":1},
|
||||
url: getPath() + "/shutdown",
|
||||
data: JSON.stringify({"parameter":1}),
|
||||
success: function success(data) {
|
||||
return alert(data.text);
|
||||
}
|
||||
@ -441,15 +474,28 @@ $(function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
$("#admin_refresh_cover_cache").click(function() {
|
||||
confirmDialog("admin_refresh_cover_cache", "GeneralChangeModal", 0, function () {
|
||||
$.ajax({
|
||||
method:"post",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
url: getPath() + "/ajax/updateThumbnails",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$("#restart_database").click(function() {
|
||||
$("#DialogHeader").addClass("hidden");
|
||||
$("#DialogFinished").addClass("hidden");
|
||||
$("#DialogContent").html("");
|
||||
$("#spinner2").show();
|
||||
$.ajax({
|
||||
method:"post",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
url: getPath() + "/shutdown",
|
||||
data: {"parameter":2},
|
||||
data: JSON.stringify({"parameter":2}),
|
||||
success: function success(data) {
|
||||
$("#spinner2").hide();
|
||||
$("#DialogContent").html(data.text);
|
||||
@ -480,6 +526,7 @@ $(function() {
|
||||
|
||||
$("#bookDetailsModal")
|
||||
.on("show.bs.modal", function(e) {
|
||||
$("#flash_danger").remove();
|
||||
var $modalBody = $(this).find(".modal-body");
|
||||
|
||||
// Prevent static assets from loading multiple times
|
||||
@ -500,6 +547,7 @@ $(function() {
|
||||
|
||||
$("#modal_kobo_token")
|
||||
.on("show.bs.modal", function(e) {
|
||||
$(e.relatedTarget).one('focus', function(e){$(this).blur();});
|
||||
var $modalBody = $(this).find(".modal-body");
|
||||
|
||||
// Prevent static assets from loading multiple times
|
||||
@ -527,7 +575,7 @@ $(function() {
|
||||
$(this).data('value'),
|
||||
function (value) {
|
||||
$.ajax({
|
||||
method: "get",
|
||||
method: "post",
|
||||
url: getPath() + "/kobo_auth/deleteauthtoken/" + value,
|
||||
});
|
||||
$("#config_delete_kobo_token").hide();
|
||||
@ -574,7 +622,7 @@ $(function() {
|
||||
function(value){
|
||||
path = getPath() + "/ajax/fullsync"
|
||||
$.ajax({
|
||||
method:"get",
|
||||
method:"post",
|
||||
url: path,
|
||||
timeout: 900,
|
||||
success:function(data) {
|
||||
@ -638,7 +686,7 @@ $(function() {
|
||||
else {
|
||||
$("#InvalidDialog").modal('show');
|
||||
}
|
||||
} else {
|
||||
} else {
|
||||
changeDbSettings();
|
||||
}
|
||||
}
|
||||
@ -679,13 +727,14 @@ $(function() {
|
||||
});
|
||||
});
|
||||
|
||||
$("#delete_shelf").click(function() {
|
||||
$("#delete_shelf").click(function(event) {
|
||||
confirmDialog(
|
||||
$(this).attr('id'),
|
||||
"GeneralDeleteModal",
|
||||
$(this).data('value'),
|
||||
function(value){
|
||||
window.location.href = window.location.pathname + "/../../shelf/delete/" + value
|
||||
postButton(event, $("#delete_shelf").data("action"));
|
||||
// $("#delete_shelf").closest("form").submit()
|
||||
}
|
||||
);
|
||||
|
||||
@ -734,7 +783,8 @@ $(function() {
|
||||
$("#DialogContent").html("");
|
||||
$("#spinner2").show();
|
||||
$.ajax({
|
||||
method:"get",
|
||||
method:"post",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
url: getPath() + "/import_ldap_users",
|
||||
success: function success(data) {
|
||||
@ -768,4 +818,3 @@ $(function() {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -15,7 +15,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* exported TableActions, RestrictionActions, EbookActions, responseHandler */
|
||||
/* exported TableActions, RestrictionActions, EbookActions, TaskActions, responseHandler */
|
||||
/* global getPath, confirmDialog */
|
||||
|
||||
var selections = [];
|
||||
@ -42,20 +42,38 @@ $(function() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
$("#cancel_task_confirm").click(function() {
|
||||
//get data-id attribute of the clicked element
|
||||
var taskId = $(this).data("task-id");
|
||||
$.ajax({
|
||||
method: "post",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
url: window.location.pathname + "/../ajax/canceltask",
|
||||
data: JSON.stringify({"task_id": taskId}),
|
||||
});
|
||||
});
|
||||
//triggered when modal is about to be shown
|
||||
$("#cancelTaskModal").on("show.bs.modal", function(e) {
|
||||
//get data-id attribute of the clicked element and store in button
|
||||
var taskId = $(e.relatedTarget).data("task-id");
|
||||
$(e.currentTarget).find("#cancel_task_confirm").data("task-id", taskId);
|
||||
});
|
||||
|
||||
$("#books-table").on("check.bs.table check-all.bs.table uncheck.bs.table uncheck-all.bs.table",
|
||||
function (e, rowsAfter, rowsBefore) {
|
||||
var rows = rowsAfter;
|
||||
|
||||
if (e.type === "uncheck-all") {
|
||||
rows = rowsBefore;
|
||||
selections = [];
|
||||
} else {
|
||||
var ids = $.map(!$.isArray(rows) ? [rows] : rows, function (row) {
|
||||
return row.id;
|
||||
});
|
||||
|
||||
var func = $.inArray(e.type, ["check", "check-all"]) > -1 ? "union" : "difference";
|
||||
selections = window._[func](selections, ids);
|
||||
}
|
||||
|
||||
var ids = $.map(!$.isArray(rows) ? [rows] : rows, function (row) {
|
||||
return row.id;
|
||||
});
|
||||
|
||||
var func = $.inArray(e.type, ["check", "check-all"]) > -1 ? "union" : "difference";
|
||||
selections = window._[func](selections, ids);
|
||||
if (selections.length >= 2) {
|
||||
$("#merge_books").removeClass("disabled");
|
||||
$("#merge_books").attr("aria-disabled", false);
|
||||
@ -107,8 +125,9 @@ $(function() {
|
||||
url: window.location.pathname + "/../ajax/simulatemerge",
|
||||
data: JSON.stringify({"Merge_books":selections}),
|
||||
success: function success(booTitles) {
|
||||
$('#merge_from').empty();
|
||||
$.each(booTitles.from, function(i, item) {
|
||||
$("<span>- " + item + "</span>").appendTo("#merge_from");
|
||||
$("<span>- " + item + "</span><p></p>").appendTo("#merge_from");
|
||||
});
|
||||
$("#merge_to").text("- " + booTitles.to);
|
||||
|
||||
@ -531,7 +550,7 @@ $(function() {
|
||||
|
||||
$("#user-table").on("click-cell.bs.table", function (field, value, row, $element) {
|
||||
if (value === "denied_column_value") {
|
||||
ConfirmDialog("btndeluser", "GeneralDeleteModal", $element.id, user_handle);
|
||||
confirmDialog("btndeluser", "GeneralDeleteModal", $element.id, user_handle);
|
||||
}
|
||||
});
|
||||
|
||||
@ -540,14 +559,14 @@ $(function() {
|
||||
var rows = rowsAfter;
|
||||
|
||||
if (e.type === "uncheck-all") {
|
||||
rows = rowsBefore;
|
||||
selections = [];
|
||||
} else {
|
||||
var ids = $.map(!$.isArray(rows) ? [rows] : rows, function (row) {
|
||||
return row.id;
|
||||
});
|
||||
var func = $.inArray(e.type, ["check", "check-all"]) > -1 ? "union" : "difference";
|
||||
selections = window._[func](selections, ids);
|
||||
}
|
||||
|
||||
var ids = $.map(!$.isArray(rows) ? [rows] : rows, function (row) {
|
||||
return row.id;
|
||||
});
|
||||
var func = $.inArray(e.type, ["check", "check-all"]) > -1 ? "union" : "difference";
|
||||
selections = window._[func](selections, ids);
|
||||
handle_header_buttons();
|
||||
});
|
||||
});
|
||||
@ -581,6 +600,7 @@ function handle_header_buttons () {
|
||||
$(".header_select").removeAttr("disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/* Function for deleting domain restrictions */
|
||||
function TableActions (value, row) {
|
||||
return [
|
||||
@ -618,6 +638,19 @@ function UserActions (value, row) {
|
||||
].join("");
|
||||
}
|
||||
|
||||
/* Function for cancelling tasks */
|
||||
function TaskActions (value, row) {
|
||||
var cancellableStats = [0, 1, 2];
|
||||
if (row.task_id && row.is_cancellable && cancellableStats.includes(row.stat)) {
|
||||
return [
|
||||
"<div class=\"danger task-cancel\" data-toggle=\"modal\" data-target=\"#cancelTaskModal\" data-task-id=\"" + row.task_id + "\" title=\"Cancel\">",
|
||||
"<i class=\"glyphicon glyphicon-ban-circle\"></i>",
|
||||
"</div>"
|
||||
].join("");
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/* Function for keeping checked rows */
|
||||
function responseHandler(res) {
|
||||
$.each(res.rows, function (i, row) {
|
||||
@ -811,11 +844,13 @@ function checkboxChange(checkbox, userId, field, field_index) {
|
||||
|
||||
function BookCheckboxChange(checkbox, userId, field) {
|
||||
var value = checkbox.checked ? "True" : "False";
|
||||
var element = checkbox;
|
||||
$.ajax({
|
||||
method: "post",
|
||||
url: getPath() + "/ajax/editbooks/" + field,
|
||||
data: {"pk": userId, "value": value},
|
||||
error: function(data) {
|
||||
element.checked = !element.checked;
|
||||
handleListServerResponse([{type:"danger", message:data.responseText}])
|
||||
},
|
||||
success: handleListServerResponse
|
||||
|
@ -16,33 +16,35 @@
|
||||
# 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
|
||||
import os
|
||||
import re
|
||||
|
||||
from glob import glob
|
||||
from shutil import copyfile
|
||||
from markupsafe import escape
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from flask_babel import lazy_gettext as N_
|
||||
|
||||
from cps.services.worker import CalibreTask
|
||||
from cps import db
|
||||
from cps import logger, config
|
||||
from cps.subproc_wrapper import process_open
|
||||
from flask_babel import gettext as _
|
||||
from flask import url_for
|
||||
from cps.kobo_sync_status import remove_synced_book
|
||||
from cps.ub import init_db_thread
|
||||
|
||||
from cps.tasks.mail import TaskEmail
|
||||
from cps import gdriveutils
|
||||
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class TaskConvert(CalibreTask):
|
||||
def __init__(self, file_path, bookid, taskMessage, settings, kindle_mail, user=None):
|
||||
super(TaskConvert, self).__init__(taskMessage)
|
||||
def __init__(self, file_path, book_id, task_message, settings, kindle_mail, user=None):
|
||||
super(TaskConvert, self).__init__(task_message)
|
||||
self.file_path = file_path
|
||||
self.bookid = bookid
|
||||
self.book_id = book_id
|
||||
self.title = ""
|
||||
self.settings = settings
|
||||
self.kindle_mail = kindle_mail
|
||||
@ -53,10 +55,10 @@ class TaskConvert(CalibreTask):
|
||||
def run(self, worker_thread):
|
||||
self.worker_thread = worker_thread
|
||||
if config.config_use_google_drive:
|
||||
worker_db = db.CalibreDB(expire_on_commit=False)
|
||||
cur_book = worker_db.get_book(self.bookid)
|
||||
worker_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
cur_book = worker_db.get_book(self.book_id)
|
||||
self.title = cur_book.title
|
||||
data = worker_db.get_book_format(self.bookid, self.settings['old_book_format'])
|
||||
data = worker_db.get_book_format(self.book_id, self.settings['old_book_format'])
|
||||
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
|
||||
data.name + "." + self.settings['old_book_format'].lower())
|
||||
if df:
|
||||
@ -87,7 +89,7 @@ class TaskConvert(CalibreTask):
|
||||
# if we're sending to kindle after converting, create a one-off task and run it immediately
|
||||
# todo: figure out how to incorporate this into the progress
|
||||
try:
|
||||
EmailText = _(u"%(book)s send to Kindle", book=escape(self.title))
|
||||
EmailText = N_(u"%(book)s send to Kindle", book=escape(self.title))
|
||||
worker_thread.add(self.user, TaskEmail(self.settings['subject'],
|
||||
self.results["path"],
|
||||
filename,
|
||||
@ -102,9 +104,9 @@ class TaskConvert(CalibreTask):
|
||||
|
||||
def _convert_ebook_format(self):
|
||||
error_message = None
|
||||
local_db = db.CalibreDB(expire_on_commit=False)
|
||||
local_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
file_path = self.file_path
|
||||
book_id = self.bookid
|
||||
book_id = self.book_id
|
||||
format_old_ext = u'.' + self.settings['old_book_format'].lower()
|
||||
format_new_ext = u'.' + self.settings['new_book_format'].lower()
|
||||
|
||||
@ -112,15 +114,30 @@ class TaskConvert(CalibreTask):
|
||||
# if it does - mark the conversion task as complete and return a success
|
||||
# this will allow send to kindle workflow to continue to work
|
||||
if os.path.isfile(file_path + format_new_ext) or\
|
||||
local_db.get_book_format(self.bookid, self.settings['new_book_format']):
|
||||
local_db.get_book_format(self.book_id, self.settings['new_book_format']):
|
||||
log.info("Book id %d already converted to %s", book_id, format_new_ext)
|
||||
cur_book = local_db.get_book(book_id)
|
||||
self.title = cur_book.title
|
||||
self.results['path'] = file_path
|
||||
self.results['path'] = cur_book.path
|
||||
self.results['title'] = self.title
|
||||
self._handleSuccess()
|
||||
local_db.session.close()
|
||||
return os.path.basename(file_path + format_new_ext)
|
||||
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id)\
|
||||
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
||||
if not new_format:
|
||||
new_format = db.Data(name=os.path.basename(file_path),
|
||||
book_format=self.settings['new_book_format'].upper(),
|
||||
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
||||
try:
|
||||
local_db.session.merge(new_format)
|
||||
local_db.session.commit()
|
||||
except SQLAlchemyError as e:
|
||||
local_db.session.rollback()
|
||||
log.error("Database error: %s", e)
|
||||
local_db.session.close()
|
||||
self._handleError(N_("Database error: %(error)s.", error=e))
|
||||
return
|
||||
self._handleSuccess()
|
||||
local_db.session.close()
|
||||
return os.path.basename(file_path + format_new_ext)
|
||||
else:
|
||||
log.info("Book id %d - target format of %s does not exist. Moving forward with convert.",
|
||||
book_id,
|
||||
@ -133,26 +150,32 @@ class TaskConvert(CalibreTask):
|
||||
else:
|
||||
# check if calibre converter-executable is existing
|
||||
if not os.path.exists(config.config_converterpath):
|
||||
# ToDo Text is not translated
|
||||
self._handleError(_(u"Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
||||
self._handleError(N_(u"Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
||||
return
|
||||
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext)
|
||||
|
||||
if check == 0:
|
||||
cur_book = local_db.get_book(book_id)
|
||||
if os.path.isfile(file_path + format_new_ext):
|
||||
new_format = db.Data(name=cur_book.data[0].name,
|
||||
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id) \
|
||||
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
||||
if not new_format:
|
||||
new_format = db.Data(name=cur_book.data[0].name,
|
||||
book_format=self.settings['new_book_format'].upper(),
|
||||
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
||||
try:
|
||||
local_db.session.merge(new_format)
|
||||
local_db.session.commit()
|
||||
except SQLAlchemyError as e:
|
||||
local_db.session.rollback()
|
||||
log.error("Database error: %s", e)
|
||||
local_db.session.close()
|
||||
self._handleError(error_message)
|
||||
return
|
||||
try:
|
||||
local_db.session.merge(new_format)
|
||||
local_db.session.commit()
|
||||
if self.settings['new_book_format'].upper() in ['KEPUB', 'EPUB', 'EPUB3']:
|
||||
ub_session = init_db_thread()
|
||||
remove_synced_book(book_id, True, ub_session)
|
||||
ub_session.close()
|
||||
except SQLAlchemyError as e:
|
||||
local_db.session.rollback()
|
||||
log.error("Database error: %s", e)
|
||||
local_db.session.close()
|
||||
self._handleError(error_message)
|
||||
return
|
||||
self.results['path'] = cur_book.path
|
||||
self.title = cur_book.title
|
||||
self.results['title'] = self.title
|
||||
@ -160,11 +183,11 @@ class TaskConvert(CalibreTask):
|
||||
self._handleSuccess()
|
||||
return os.path.basename(file_path + format_new_ext)
|
||||
else:
|
||||
error_message = _('%(format)s format not found on disk', format=format_new_ext.upper())
|
||||
error_message = N_('%(format)s format not found on disk', format=format_new_ext.upper())
|
||||
local_db.session.close()
|
||||
log.info("ebook converter failed with error while converting book")
|
||||
if not error_message:
|
||||
error_message = _('Ebook converter failed with unknown error')
|
||||
error_message = N_('Ebook converter failed with unknown error')
|
||||
self._handleError(error_message)
|
||||
return
|
||||
|
||||
@ -174,7 +197,7 @@ class TaskConvert(CalibreTask):
|
||||
try:
|
||||
p = process_open(command, quotes)
|
||||
except OSError as e:
|
||||
return 1, _(u"Kepubify-converter failed: %(error)s", error=e)
|
||||
return 1, N_(u"Kepubify-converter failed: %(error)s", error=e)
|
||||
self.progress = 0.01
|
||||
while True:
|
||||
nextline = p.stdout.readlines()
|
||||
@ -195,7 +218,7 @@ class TaskConvert(CalibreTask):
|
||||
copyfile(converted_file[0], (file_path + format_new_ext))
|
||||
os.unlink(converted_file[0])
|
||||
else:
|
||||
return 1, _(u"Converted file not found or more than one file in folder %(folder)s",
|
||||
return 1, N_(u"Converted file not found or more than one file in folder %(folder)s",
|
||||
folder=os.path.dirname(file_path))
|
||||
return check, None
|
||||
|
||||
@ -219,7 +242,7 @@ class TaskConvert(CalibreTask):
|
||||
|
||||
p = process_open(command, quotes, newlines=False)
|
||||
except OSError as e:
|
||||
return 1, _(u"Ebook-converter failed: %(error)s", error=e)
|
||||
return 1, N_(u"Ebook-converter failed: %(error)s", error=e)
|
||||
|
||||
while p.poll() is None:
|
||||
nextline = p.stdout.readline()
|
||||
@ -242,12 +265,16 @@ class TaskConvert(CalibreTask):
|
||||
ele = ele.decode('utf-8', errors="ignore").strip('\n')
|
||||
log.debug(ele)
|
||||
if not ele.startswith('Traceback') and not ele.startswith(' File'):
|
||||
error_message = _("Calibre failed with error: %(error)s", error=ele)
|
||||
error_message = N_("Calibre failed with error: %(error)s", error=ele)
|
||||
return check, error_message
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "Convert"
|
||||
return N_("Convert")
|
||||
|
||||
def __str__(self):
|
||||
return "Convert {} {}".format(self.bookid, self.kindle_mail)
|
||||
return "Convert {} {}".format(self.book_id, self.kindle_mail)
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return False
|
||||
|
51
cps/tasks/database.py
Normal file
51
cps/tasks/database.py
Normal file
@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 mmonkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from urllib.request import urlopen
|
||||
|
||||
from flask_babel import lazy_gettext as N_
|
||||
|
||||
from cps import config, logger
|
||||
from cps.services.worker import CalibreTask
|
||||
|
||||
|
||||
class TaskReconnectDatabase(CalibreTask):
|
||||
def __init__(self, task_message=N_('Reconnecting Calibre database')):
|
||||
super(TaskReconnectDatabase, self).__init__(task_message)
|
||||
self.log = logger.create()
|
||||
self.listen_address = config.get_config_ipaddress()
|
||||
self.listen_port = config.config_port
|
||||
|
||||
|
||||
def run(self, worker_thread):
|
||||
address = self.listen_address if self.listen_address else 'localhost'
|
||||
port = self.listen_port if self.listen_port else 8083
|
||||
|
||||
try:
|
||||
urlopen('http://' + address + ':' + str(port) + '/reconnect')
|
||||
self._handleSuccess()
|
||||
except Exception as ex:
|
||||
self._handleError('Unable to reconnect Calibre database: ' + str(ex))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "Reconnect Database"
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return False
|
110
cps/tasks/mail.py
Normal file → Executable file
110
cps/tasks/mail.py
Normal file → Executable file
@ -16,35 +16,25 @@
|
||||
# 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
|
||||
import os
|
||||
import smtplib
|
||||
import threading
|
||||
import socket
|
||||
import mimetypes
|
||||
|
||||
try:
|
||||
from StringIO import StringIO
|
||||
from email.MIMEBase import MIMEBase
|
||||
from email.MIMEMultipart import MIMEMultipart
|
||||
from email.MIMEText import MIMEText
|
||||
except ImportError:
|
||||
from io import StringIO
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
|
||||
|
||||
from email import encoders
|
||||
from email.utils import formatdate, make_msgid
|
||||
from io import StringIO
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, parseaddr
|
||||
from email.generator import Generator
|
||||
from flask_babel import lazy_gettext as N_
|
||||
from email.utils import formatdate
|
||||
|
||||
from cps.services.worker import CalibreTask
|
||||
from cps.services import gmail
|
||||
from cps import logger, config
|
||||
|
||||
from cps import gdriveutils
|
||||
import uuid
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@ -119,31 +109,48 @@ class EmailSSL(EmailBase, smtplib.SMTP_SSL):
|
||||
|
||||
|
||||
class TaskEmail(CalibreTask):
|
||||
def __init__(self, subject, filepath, attachment, settings, recipient, taskMessage, text, internal=False):
|
||||
super(TaskEmail, self).__init__(taskMessage)
|
||||
def __init__(self, subject, filepath, attachment, settings, recipient, task_message, text, internal=False):
|
||||
super(TaskEmail, self).__init__(task_message)
|
||||
self.subject = subject
|
||||
self.attachment = attachment
|
||||
self.settings = settings
|
||||
self.filepath = filepath
|
||||
self.recipent = recipient
|
||||
self.recipient = recipient
|
||||
self.text = text
|
||||
self.asyncSMTP = None
|
||||
self.results = dict()
|
||||
|
||||
# from calibre code:
|
||||
# https://github.com/kovidgoyal/calibre/blob/731ccd92a99868de3e2738f65949f19768d9104c/src/calibre/utils/smtp.py#L60
|
||||
def get_msgid_domain(self):
|
||||
try:
|
||||
# Parse out the address from the From line, and then the domain from that
|
||||
from_email = parseaddr(self.settings["mail_from"])[1]
|
||||
msgid_domain = from_email.partition('@')[2].strip()
|
||||
# This can sometimes sneak through parseaddr if the input is malformed
|
||||
msgid_domain = msgid_domain.rstrip('>').strip()
|
||||
except Exception:
|
||||
msgid_domain = ''
|
||||
return msgid_domain or 'calibre-web.com'
|
||||
|
||||
def prepare_message(self):
|
||||
message = MIMEMultipart()
|
||||
message['to'] = self.recipent
|
||||
message['from'] = self.settings["mail_from"]
|
||||
message['subject'] = self.subject
|
||||
message['Message-Id'] = make_msgid('calibre-web')
|
||||
message = EmailMessage()
|
||||
# message = MIMEMultipart()
|
||||
message['From'] = self.settings["mail_from"]
|
||||
message['To'] = self.recipient
|
||||
message['Subject'] = self.subject
|
||||
message['Date'] = formatdate(localtime=True)
|
||||
text = self.text
|
||||
msg = MIMEText(text.encode('UTF-8'), 'plain', 'UTF-8')
|
||||
message.attach(msg)
|
||||
message['Message-Id'] = "{}@{}".format(uuid.uuid4(), self.get_msgid_domain()) # f"<{uuid.uuid4()}@{get_msgid_domain(from_)}>" # make_msgid('calibre-web')
|
||||
message.set_content(self.text.encode('UTF-8'), "text", "plain")
|
||||
if self.attachment:
|
||||
result = self._get_attachment(self.filepath, self.attachment)
|
||||
if result:
|
||||
message.attach(result)
|
||||
data = self._get_attachment(self.filepath, self.attachment)
|
||||
if data:
|
||||
# Set mimetype
|
||||
content_type, encoding = mimetypes.guess_type(self.attachment)
|
||||
if content_type is None or encoding is not None:
|
||||
content_type = 'application/octet-stream'
|
||||
main_type, sub_type = content_type.split('/', 1)
|
||||
message.add_attachment(data, maintype=main_type, subtype=sub_type, filename=self.attachment)
|
||||
else:
|
||||
self._handleError(u"Attachment not found")
|
||||
return
|
||||
@ -158,10 +165,10 @@ class TaskEmail(CalibreTask):
|
||||
else:
|
||||
self.send_gmail_email(msg)
|
||||
except MemoryError as e:
|
||||
log.debug_or_exception(e)
|
||||
log.error_or_exception(e, stacklevel=3)
|
||||
self._handleError(u'MemoryError sending e-mail: {}'.format(str(e)))
|
||||
except (smtplib.SMTPException, smtplib.SMTPAuthenticationError) as e:
|
||||
log.debug_or_exception(e)
|
||||
log.error_or_exception(e, stacklevel=3)
|
||||
if hasattr(e, "smtp_error"):
|
||||
text = e.smtp_error.decode('utf-8').replace("\n", '. ')
|
||||
elif hasattr(e, "message"):
|
||||
@ -171,11 +178,11 @@ class TaskEmail(CalibreTask):
|
||||
else:
|
||||
text = ''
|
||||
self._handleError(u'Smtplib Error sending e-mail: {}'.format(text))
|
||||
except socket.error as e:
|
||||
log.debug_or_exception(e)
|
||||
except (socket.error) as e:
|
||||
log.error_or_exception(e, stacklevel=3)
|
||||
self._handleError(u'Socket Error sending e-mail: {}'.format(e.strerror))
|
||||
except Exception as ex:
|
||||
log.debug_or_exception(ex)
|
||||
log.error_or_exception(ex, stacklevel=3)
|
||||
self._handleError(u'Error sending e-mail: {}'.format(ex))
|
||||
|
||||
def send_standard_email(self, msg):
|
||||
@ -203,7 +210,7 @@ class TaskEmail(CalibreTask):
|
||||
gen = Generator(fp, mangle_from_=False)
|
||||
gen.flatten(msg)
|
||||
|
||||
self.asyncSMTP.sendmail(self.settings["mail_from"], self.recipent, fp.getvalue())
|
||||
self.asyncSMTP.sendmail(self.settings["mail_from"], self.recipient, fp.getvalue())
|
||||
self.asyncSMTP.quit()
|
||||
self._handleSuccess()
|
||||
log.debug("E-mail send successfully")
|
||||
@ -226,15 +233,15 @@ class TaskEmail(CalibreTask):
|
||||
self._progress = x
|
||||
|
||||
@classmethod
|
||||
def _get_attachment(cls, bookpath, filename):
|
||||
def _get_attachment(cls, book_path, filename):
|
||||
"""Get file as MIMEBase message"""
|
||||
calibre_path = config.config_calibre_dir
|
||||
if config.config_use_google_drive:
|
||||
df = gdriveutils.getFileFromEbooksFolder(bookpath, filename)
|
||||
df = gdriveutils.getFileFromEbooksFolder(book_path, filename)
|
||||
if df:
|
||||
datafile = os.path.join(calibre_path, bookpath, filename)
|
||||
if not os.path.exists(os.path.join(calibre_path, bookpath)):
|
||||
os.makedirs(os.path.join(calibre_path, bookpath))
|
||||
datafile = os.path.join(calibre_path, book_path, filename)
|
||||
if not os.path.exists(os.path.join(calibre_path, book_path)):
|
||||
os.makedirs(os.path.join(calibre_path, book_path))
|
||||
df.GetContentFile(datafile)
|
||||
else:
|
||||
return None
|
||||
@ -244,27 +251,22 @@ class TaskEmail(CalibreTask):
|
||||
os.remove(datafile)
|
||||
else:
|
||||
try:
|
||||
file_ = open(os.path.join(calibre_path, bookpath, filename), 'rb')
|
||||
file_ = open(os.path.join(calibre_path, book_path, filename), 'rb')
|
||||
data = file_.read()
|
||||
file_.close()
|
||||
except IOError as e:
|
||||
log.debug_or_exception(e)
|
||||
log.error_or_exception(e, stacklevel=3)
|
||||
log.error(u'The requested file could not be read. Maybe wrong permissions?')
|
||||
return None
|
||||
# Set mimetype
|
||||
content_type, encoding = mimetypes.guess_type(filename)
|
||||
if content_type is None or encoding is not None:
|
||||
content_type = 'application/octet-stream'
|
||||
main_type, sub_type = content_type.split('/', 1)
|
||||
attachment = MIMEBase(main_type, sub_type)
|
||||
attachment.set_payload(data)
|
||||
encoders.encode_base64(attachment)
|
||||
attachment.add_header('Content-Disposition', 'attachment', filename=filename)
|
||||
return attachment
|
||||
return data
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "E-mail"
|
||||
return N_("E-mail")
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return "E-mail {}, {}".format(self.name, self.subject)
|
||||
|
514
cps/tasks/thumbnail.py
Normal file
514
cps/tasks/thumbnail.py
Normal file
@ -0,0 +1,514 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2020 monkey
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
from urllib.request import urlopen
|
||||
|
||||
from .. import constants
|
||||
from cps import config, db, fs, gdriveutils, logger, ub
|
||||
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func, text, or_
|
||||
from flask_babel import lazy_gettext as N_
|
||||
|
||||
try:
|
||||
from wand.image import Image
|
||||
use_IM = True
|
||||
except (ImportError, RuntimeError) as e:
|
||||
use_IM = False
|
||||
|
||||
|
||||
def get_resize_height(resolution):
|
||||
return int(225 * resolution)
|
||||
|
||||
|
||||
def get_resize_width(resolution, original_width, original_height):
|
||||
height = get_resize_height(resolution)
|
||||
percent = (height / float(original_height))
|
||||
width = int((float(original_width) * float(percent)))
|
||||
return width if width % 2 == 0 else width + 1
|
||||
|
||||
|
||||
def get_best_fit(width, height, image_width, image_height):
|
||||
resize_width = int(width / 2.0)
|
||||
resize_height = int(height / 2.0)
|
||||
aspect_ratio = image_width / image_height
|
||||
|
||||
# If this image's aspect ratio is different from the first image, then resize this image
|
||||
# to fill the width and height of the first image
|
||||
if aspect_ratio < width / height:
|
||||
resize_width = int(width / 2.0)
|
||||
resize_height = image_height * int(width / 2.0) / image_width
|
||||
|
||||
elif aspect_ratio > width / height:
|
||||
resize_width = image_width * int(height / 2.0) / image_height
|
||||
resize_height = int(height / 2.0)
|
||||
|
||||
return {'width': resize_width, 'height': resize_height}
|
||||
|
||||
|
||||
class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
def __init__(self, book_id=-1, task_message=''):
|
||||
super(TaskGenerateCoverThumbnails, self).__init__(task_message)
|
||||
self.log = logger.create()
|
||||
self.book_id = book_id
|
||||
self.app_db_session = ub.get_new_session_instance()
|
||||
# self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
self.cache = fs.FileSystem()
|
||||
self.resolutions = [
|
||||
constants.COVER_THUMBNAIL_SMALL,
|
||||
constants.COVER_THUMBNAIL_MEDIUM
|
||||
]
|
||||
|
||||
def run(self, worker_thread):
|
||||
if use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
||||
self.message = 'Scanning Books'
|
||||
books_with_covers = self.get_books_with_covers(self.book_id)
|
||||
count = len(books_with_covers)
|
||||
|
||||
total_generated = 0
|
||||
for i, book in enumerate(books_with_covers):
|
||||
|
||||
# Generate new thumbnails for missing covers
|
||||
generated = self.create_book_cover_thumbnails(book)
|
||||
|
||||
# Increment the progress
|
||||
self.progress = (1.0 / count) * i
|
||||
|
||||
if generated > 0:
|
||||
total_generated += generated
|
||||
self.message = N_(u'Generated %(count)s cover thumbnails', count=total_generated)
|
||||
|
||||
# Check if job has been cancelled or ended
|
||||
if self.stat == STAT_CANCELLED:
|
||||
self.log.info(f'GenerateCoverThumbnails task has been cancelled.')
|
||||
return
|
||||
|
||||
if self.stat == STAT_ENDED:
|
||||
self.log.info(f'GenerateCoverThumbnails task has been ended.')
|
||||
return
|
||||
|
||||
if total_generated == 0:
|
||||
self.self_cleanup = True
|
||||
|
||||
self._handleSuccess()
|
||||
self.app_db_session.remove()
|
||||
|
||||
def get_books_with_covers(self, book_id=-1):
|
||||
filter_exp = (db.Books.id == book_id) if book_id != -1 else True
|
||||
calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
books_cover = calibre_db.session.query(db.Books).filter(db.Books.has_cover == 1).filter(filter_exp).all()
|
||||
calibre_db.session.close()
|
||||
return books_cover
|
||||
|
||||
def get_book_cover_thumbnails(self, book_id):
|
||||
return self.app_db_session \
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
|
||||
.filter(ub.Thumbnail.entity_id == book_id) \
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||
.all()
|
||||
|
||||
def create_book_cover_thumbnails(self, book):
|
||||
generated = 0
|
||||
book_cover_thumbnails = self.get_book_cover_thumbnails(book.id)
|
||||
|
||||
# Generate new thumbnails for missing covers
|
||||
resolutions = list(map(lambda t: t.resolution, book_cover_thumbnails))
|
||||
missing_resolutions = list(set(self.resolutions).difference(resolutions))
|
||||
for resolution in missing_resolutions:
|
||||
generated += 1
|
||||
self.create_book_cover_single_thumbnail(book, resolution)
|
||||
|
||||
# Replace outdated or missing thumbnails
|
||||
for thumbnail in book_cover_thumbnails:
|
||||
if book.last_modified > thumbnail.generated_at:
|
||||
generated += 1
|
||||
self.update_book_cover_thumbnail(book, thumbnail)
|
||||
|
||||
elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
|
||||
generated += 1
|
||||
self.update_book_cover_thumbnail(book, thumbnail)
|
||||
return generated
|
||||
|
||||
def create_book_cover_single_thumbnail(self, book, resolution):
|
||||
thumbnail = ub.Thumbnail()
|
||||
thumbnail.type = constants.THUMBNAIL_TYPE_COVER
|
||||
thumbnail.entity_id = book.id
|
||||
thumbnail.format = 'jpeg'
|
||||
thumbnail.resolution = resolution
|
||||
|
||||
self.app_db_session.add(thumbnail)
|
||||
try:
|
||||
self.app_db_session.commit()
|
||||
self.generate_book_thumbnail(book, thumbnail)
|
||||
except Exception as ex:
|
||||
self.log.debug('Error creating book thumbnail: ' + str(ex))
|
||||
self._handleError('Error creating book thumbnail: ' + str(ex))
|
||||
self.app_db_session.rollback()
|
||||
|
||||
def update_book_cover_thumbnail(self, book, thumbnail):
|
||||
thumbnail.generated_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
self.app_db_session.commit()
|
||||
self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||
self.generate_book_thumbnail(book, thumbnail)
|
||||
except Exception as ex:
|
||||
self.log.debug('Error updating book thumbnail: ' + str(ex))
|
||||
self._handleError('Error updating book thumbnail: ' + str(ex))
|
||||
self.app_db_session.rollback()
|
||||
|
||||
def generate_book_thumbnail(self, book, thumbnail):
|
||||
if book and thumbnail:
|
||||
if config.config_use_google_drive:
|
||||
if not gdriveutils.is_gdrive_ready():
|
||||
raise Exception('Google Drive is configured but not ready')
|
||||
|
||||
web_content_link = gdriveutils.get_cover_via_gdrive(book.path)
|
||||
if not web_content_link:
|
||||
raise Exception('Google Drive cover url not found')
|
||||
|
||||
stream = None
|
||||
try:
|
||||
stream = urlopen(web_content_link)
|
||||
with Image(file=stream) as img:
|
||||
height = get_resize_height(thumbnail.resolution)
|
||||
if img.height > height:
|
||||
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||
img.resize(width=width, height=height, filter='lanczos')
|
||||
img.format = thumbnail.format
|
||||
filename = self.cache.get_cache_file_path(thumbnail.filename,
|
||||
constants.CACHE_TYPE_THUMBNAILS)
|
||||
img.save(filename=filename)
|
||||
except Exception as ex:
|
||||
# Bubble exception to calling function
|
||||
self.log.debug('Error generating thumbnail file: ' + str(ex))
|
||||
raise ex
|
||||
finally:
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
else:
|
||||
book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg')
|
||||
if not os.path.isfile(book_cover_filepath):
|
||||
raise Exception('Book cover file not found')
|
||||
|
||||
with Image(filename=book_cover_filepath) as img:
|
||||
height = get_resize_height(thumbnail.resolution)
|
||||
if img.height > height:
|
||||
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||
img.resize(width=width, height=height, filter='lanczos')
|
||||
img.format = thumbnail.format
|
||||
filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||
img.save(filename=filename)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return N_('Cover Thumbnails')
|
||||
|
||||
def __str__(self):
|
||||
if self.book_id > 0:
|
||||
return "Add Cover Thumbnails for Book {}".format(self.book_id)
|
||||
else:
|
||||
return "Generate Cover Thumbnails"
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return True
|
||||
|
||||
|
||||
class TaskGenerateSeriesThumbnails(CalibreTask):
|
||||
def __init__(self, task_message=''):
|
||||
super(TaskGenerateSeriesThumbnails, self).__init__(task_message)
|
||||
self.log = logger.create()
|
||||
self.app_db_session = ub.get_new_session_instance()
|
||||
self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
self.cache = fs.FileSystem()
|
||||
self.resolutions = [
|
||||
constants.COVER_THUMBNAIL_SMALL,
|
||||
constants.COVER_THUMBNAIL_MEDIUM,
|
||||
]
|
||||
|
||||
def run(self, worker_thread):
|
||||
if self.calibre_db.session and use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
||||
self.message = 'Scanning Series'
|
||||
all_series = self.get_series_with_four_plus_books()
|
||||
count = len(all_series)
|
||||
|
||||
total_generated = 0
|
||||
for i, series in enumerate(all_series):
|
||||
generated = 0
|
||||
series_thumbnails = self.get_series_thumbnails(series.id)
|
||||
series_books = self.get_series_books(series.id)
|
||||
|
||||
# Generate new thumbnails for missing covers
|
||||
resolutions = list(map(lambda t: t.resolution, series_thumbnails))
|
||||
missing_resolutions = list(set(self.resolutions).difference(resolutions))
|
||||
for resolution in missing_resolutions:
|
||||
generated += 1
|
||||
self.create_series_thumbnail(series, series_books, resolution)
|
||||
|
||||
# Replace outdated or missing thumbnails
|
||||
for thumbnail in series_thumbnails:
|
||||
if any(book.last_modified > thumbnail.generated_at for book in series_books):
|
||||
generated += 1
|
||||
self.update_series_thumbnail(series_books, thumbnail)
|
||||
|
||||
elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS):
|
||||
generated += 1
|
||||
self.update_series_thumbnail(series_books, thumbnail)
|
||||
|
||||
# Increment the progress
|
||||
self.progress = (1.0 / count) * i
|
||||
|
||||
if generated > 0:
|
||||
total_generated += generated
|
||||
self.message = N_('Generated {0} series thumbnails').format(total_generated)
|
||||
|
||||
# Check if job has been cancelled or ended
|
||||
if self.stat == STAT_CANCELLED:
|
||||
self.log.info(f'GenerateSeriesThumbnails task has been cancelled.')
|
||||
return
|
||||
|
||||
if self.stat == STAT_ENDED:
|
||||
self.log.info(f'GenerateSeriesThumbnails task has been ended.')
|
||||
return
|
||||
|
||||
if total_generated == 0:
|
||||
self.self_cleanup = True
|
||||
|
||||
self._handleSuccess()
|
||||
self.app_db_session.remove()
|
||||
|
||||
def get_series_with_four_plus_books(self):
|
||||
return self.calibre_db.session \
|
||||
.query(db.Series) \
|
||||
.join(db.books_series_link) \
|
||||
.join(db.Books) \
|
||||
.filter(db.Books.has_cover == 1) \
|
||||
.group_by(text('books_series_link.series')) \
|
||||
.having(func.count('book_series_link') > 3) \
|
||||
.all()
|
||||
|
||||
def get_series_books(self, series_id):
|
||||
return self.calibre_db.session \
|
||||
.query(db.Books) \
|
||||
.join(db.books_series_link) \
|
||||
.join(db.Series) \
|
||||
.filter(db.Books.has_cover == 1) \
|
||||
.filter(db.Series.id == series_id) \
|
||||
.all()
|
||||
|
||||
def get_series_thumbnails(self, series_id):
|
||||
return self.app_db_session \
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES) \
|
||||
.filter(ub.Thumbnail.entity_id == series_id) \
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||
.all()
|
||||
|
||||
def create_series_thumbnail(self, series, series_books, resolution):
|
||||
thumbnail = ub.Thumbnail()
|
||||
thumbnail.type = constants.THUMBNAIL_TYPE_SERIES
|
||||
thumbnail.entity_id = series.id
|
||||
thumbnail.format = 'jpeg'
|
||||
thumbnail.resolution = resolution
|
||||
|
||||
self.app_db_session.add(thumbnail)
|
||||
try:
|
||||
self.app_db_session.commit()
|
||||
self.generate_series_thumbnail(series_books, thumbnail)
|
||||
except Exception as ex:
|
||||
self.log.debug('Error creating book thumbnail: ' + str(ex))
|
||||
self._handleError('Error creating book thumbnail: ' + str(ex))
|
||||
self.app_db_session.rollback()
|
||||
|
||||
def update_series_thumbnail(self, series_books, thumbnail):
|
||||
thumbnail.generated_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
self.app_db_session.commit()
|
||||
self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||
self.generate_series_thumbnail(series_books, thumbnail)
|
||||
except Exception as ex:
|
||||
self.log.debug('Error updating book thumbnail: ' + str(ex))
|
||||
self._handleError('Error updating book thumbnail: ' + str(ex))
|
||||
self.app_db_session.rollback()
|
||||
|
||||
def generate_series_thumbnail(self, series_books, thumbnail):
|
||||
# Get the last four books in the series based on series_index
|
||||
books = sorted(series_books, key=lambda b: float(b.series_index), reverse=True)[:4]
|
||||
|
||||
top = 0
|
||||
left = 0
|
||||
width = 0
|
||||
height = 0
|
||||
with Image() as canvas:
|
||||
for book in books:
|
||||
if config.config_use_google_drive:
|
||||
if not gdriveutils.is_gdrive_ready():
|
||||
raise Exception('Google Drive is configured but not ready')
|
||||
|
||||
web_content_link = gdriveutils.get_cover_via_gdrive(book.path)
|
||||
if not web_content_link:
|
||||
raise Exception('Google Drive cover url not found')
|
||||
|
||||
stream = None
|
||||
try:
|
||||
stream = urlopen(web_content_link)
|
||||
with Image(file=stream) as img:
|
||||
# Use the first image in this set to determine the width and height to scale the
|
||||
# other images in this set
|
||||
if width == 0 or height == 0:
|
||||
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||
height = get_resize_height(thumbnail.resolution)
|
||||
canvas.blank(width, height)
|
||||
|
||||
dimensions = get_best_fit(width, height, img.width, img.height)
|
||||
|
||||
# resize and crop the image
|
||||
img.resize(width=int(dimensions['width']), height=int(dimensions['height']),
|
||||
filter='lanczos')
|
||||
img.crop(width=int(width / 2.0), height=int(height / 2.0), gravity='center')
|
||||
|
||||
# add the image to the canvas
|
||||
canvas.composite(img, left, top)
|
||||
|
||||
except Exception as ex:
|
||||
self.log.debug('Error generating thumbnail file: ' + str(ex))
|
||||
raise ex
|
||||
finally:
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
|
||||
book_cover_filepath = os.path.join(config.config_calibre_dir, book.path, 'cover.jpg')
|
||||
if not os.path.isfile(book_cover_filepath):
|
||||
raise Exception('Book cover file not found')
|
||||
|
||||
with Image(filename=book_cover_filepath) as img:
|
||||
# Use the first image in this set to determine the width and height to scale the
|
||||
# other images in this set
|
||||
if width == 0 or height == 0:
|
||||
width = get_resize_width(thumbnail.resolution, img.width, img.height)
|
||||
height = get_resize_height(thumbnail.resolution)
|
||||
canvas.blank(width, height)
|
||||
|
||||
dimensions = get_best_fit(width, height, img.width, img.height)
|
||||
|
||||
# resize and crop the image
|
||||
img.resize(width=int(dimensions['width']), height=int(dimensions['height']), filter='lanczos')
|
||||
img.crop(width=int(width / 2.0), height=int(height / 2.0), gravity='center')
|
||||
|
||||
# add the image to the canvas
|
||||
canvas.composite(img, left, top)
|
||||
|
||||
# set the coordinates for the next iteration
|
||||
if left == 0 and top == 0:
|
||||
left = int(width / 2.0)
|
||||
elif left == int(width / 2.0) and top == 0:
|
||||
left = 0
|
||||
top = int(height / 2.0)
|
||||
else:
|
||||
left = int(width / 2.0)
|
||||
|
||||
canvas.format = thumbnail.format
|
||||
filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||
canvas.save(filename=filename)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return N_('Cover Thumbnails')
|
||||
|
||||
def __str__(self):
|
||||
return "GenerateSeriesThumbnails"
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return True
|
||||
|
||||
|
||||
class TaskClearCoverThumbnailCache(CalibreTask):
|
||||
def __init__(self, book_id, task_message=N_('Clearing cover thumbnail cache')):
|
||||
super(TaskClearCoverThumbnailCache, self).__init__(task_message)
|
||||
self.log = logger.create()
|
||||
self.book_id = book_id
|
||||
self.app_db_session = ub.get_new_session_instance()
|
||||
self.cache = fs.FileSystem()
|
||||
|
||||
def run(self, worker_thread):
|
||||
if self.app_db_session:
|
||||
if self.book_id == 0: # delete superfluous thumbnails
|
||||
calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
thumbnails = (calibre_db.session.query(ub.Thumbnail)
|
||||
.join(db.Books, ub.Thumbnail.entity_id == db.Books.id, isouter=True)
|
||||
.filter(db.Books.id == None)
|
||||
.all())
|
||||
calibre_db.session.close()
|
||||
elif self.book_id > 0: # make sure single book is selected
|
||||
thumbnails = self.get_thumbnails_for_book(self.book_id)
|
||||
if self.book_id < 0:
|
||||
self.delete_all_thumbnails()
|
||||
else:
|
||||
for thumbnail in thumbnails:
|
||||
self.delete_thumbnail(thumbnail)
|
||||
self._handleSuccess()
|
||||
self.app_db_session.remove()
|
||||
|
||||
def get_thumbnails_for_book(self, book_id):
|
||||
return self.app_db_session \
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
|
||||
.filter(ub.Thumbnail.entity_id == book_id) \
|
||||
.all()
|
||||
|
||||
def delete_thumbnail(self, thumbnail):
|
||||
try:
|
||||
self.cache.delete_cache_file(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS)
|
||||
self.app_db_session \
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
|
||||
.filter(ub.Thumbnail.entity_id == thumbnail.entity_id) \
|
||||
.delete()
|
||||
self.app_db_session.commit()
|
||||
except Exception as ex:
|
||||
self.log.debug('Error deleting book thumbnail: ' + str(ex))
|
||||
self._handleError('Error deleting book thumbnail: ' + str(ex))
|
||||
|
||||
def delete_all_thumbnails(self):
|
||||
try:
|
||||
self.app_db_session.query(ub.Thumbnail).filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER).delete()
|
||||
self.app_db_session.commit()
|
||||
self.cache.delete_cache_dir(constants.CACHE_TYPE_THUMBNAILS)
|
||||
except Exception as ex:
|
||||
self.log.debug('Error deleting thumbnail directory: ' + str(ex))
|
||||
self._handleError('Error deleting thumbnail directory: ' + str(ex))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return N_('Cover Thumbnails')
|
||||
|
||||
# needed for logging
|
||||
def __str__(self):
|
||||
if self.book_id > 0:
|
||||
return "Replace/Delete Cover Thumbnails for book " + str(self.book_id)
|
||||
else:
|
||||
return "Delete Thumbnail cache directory"
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return False
|
@ -17,21 +17,29 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask_babel import lazy_gettext as N_
|
||||
|
||||
from cps.services.worker import CalibreTask, STAT_FINISH_SUCCESS
|
||||
|
||||
class TaskUpload(CalibreTask):
|
||||
def __init__(self, taskMessage):
|
||||
super(TaskUpload, self).__init__(taskMessage)
|
||||
def __init__(self, task_message, book_title):
|
||||
super(TaskUpload, self).__init__(task_message)
|
||||
self.start_time = self.end_time = datetime.now()
|
||||
self.stat = STAT_FINISH_SUCCESS
|
||||
self.progress = 1
|
||||
self.book_title = book_title
|
||||
|
||||
def run(self, worker_thread):
|
||||
"""Upload task doesn't have anything to do, it's simply a way to add information to the task list"""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "Upload"
|
||||
return N_("Upload")
|
||||
|
||||
def __str__(self):
|
||||
return "Upload {}".format(self.message)
|
||||
return "Upload {}".format(self.book_title)
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return False
|
||||
|
106
cps/tasks_status.py
Normal file
106
cps/tasks_status.py
Normal file
@ -0,0 +1,106 @@
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2022 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from markupsafe import escape
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import format_datetime
|
||||
from babel.units import format_unit
|
||||
|
||||
from . import logger
|
||||
from .render_template import render_title_template
|
||||
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \
|
||||
STAT_CANCELLED
|
||||
|
||||
tasks = Blueprint('tasks', __name__)
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
@tasks.route("/ajax/emailstat")
|
||||
@login_required
|
||||
def get_email_status_json():
|
||||
tasks = WorkerThread.get_instance().tasks
|
||||
return jsonify(render_task_status(tasks))
|
||||
|
||||
|
||||
@tasks.route("/tasks")
|
||||
@login_required
|
||||
def get_tasks_status():
|
||||
# if current user admin, show all email, otherwise only own emails
|
||||
tasks = WorkerThread.get_instance().tasks
|
||||
answer = render_task_status(tasks)
|
||||
return render_title_template('tasks.html', entries=answer, title=_(u"Tasks"), page="tasks")
|
||||
|
||||
|
||||
# helper function to apply localize status information in tasklist entries
|
||||
def render_task_status(tasklist):
|
||||
rendered_tasklist = list()
|
||||
for __, user, __, task, __ in tasklist:
|
||||
if user == current_user.name or current_user.role_admin():
|
||||
ret = {}
|
||||
if task.start_time:
|
||||
ret['starttime'] = format_datetime(task.start_time, format='short')
|
||||
ret['runtime'] = format_runtime(task.runtime)
|
||||
|
||||
# localize the task status
|
||||
if isinstance(task.stat, int):
|
||||
if task.stat == STAT_WAITING:
|
||||
ret['status'] = _(u'Waiting')
|
||||
elif task.stat == STAT_FAIL:
|
||||
ret['status'] = _(u'Failed')
|
||||
elif task.stat == STAT_STARTED:
|
||||
ret['status'] = _(u'Started')
|
||||
elif task.stat == STAT_FINISH_SUCCESS:
|
||||
ret['status'] = _(u'Finished')
|
||||
elif task.stat == STAT_ENDED:
|
||||
ret['status'] = _(u'Ended')
|
||||
elif task.stat == STAT_CANCELLED:
|
||||
ret['status'] = _(u'Cancelled')
|
||||
else:
|
||||
ret['status'] = _(u'Unknown Status')
|
||||
|
||||
ret['taskMessage'] = "{}: {}".format(task.name, task.message) if task.message else task.name
|
||||
ret['progress'] = "{} %".format(int(task.progress * 100))
|
||||
ret['user'] = escape(user) # prevent xss
|
||||
|
||||
# Hidden fields
|
||||
ret['task_id'] = task.id
|
||||
ret['stat'] = task.stat
|
||||
ret['is_cancellable'] = task.is_cancellable
|
||||
|
||||
rendered_tasklist.append(ret)
|
||||
|
||||
return rendered_tasklist
|
||||
|
||||
|
||||
# helper function for displaying the runtime of tasks
|
||||
def format_runtime(runtime):
|
||||
ret_val = ""
|
||||
if runtime.days:
|
||||
ret_val = format_unit(runtime.days, 'duration-day', length="long") + ', '
|
||||
minutes, seconds = divmod(runtime.seconds, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
# ToDo: locale.number_symbols._data['timeSeparator'] -> localize time separator ?
|
||||
if hours:
|
||||
ret_val += '{:d}:{:02d}:{:02d}s'.format(hours, minutes, seconds)
|
||||
elif minutes:
|
||||
ret_val += '{:2d}:{:02d}s'.format(minutes, seconds)
|
||||
else:
|
||||
ret_val += '{:2d}s'.format(seconds)
|
||||
return ret_val
|
@ -47,7 +47,9 @@
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
<a class="btn btn-default" id="admin_user_table" href="{{url_for('admin.edit_user_table')}}">{{_('Edit Users')}}</a>
|
||||
{% if not simple %}
|
||||
<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>
|
||||
{% 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>
|
||||
@ -159,21 +161,56 @@
|
||||
<a class="btn btn-default" id="view_config" href="{{url_for('admin.view_configuration')}}">{{_('Edit UI Configuration')}}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if feature_support['scheduler'] %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2>{{_('Scheduled Tasks')}}</h2>
|
||||
<div class="col-xs-12 col-sm-12 scheduled_tasks_details">
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('Time at which tasks start to run')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{schedule_time}}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('Maximum tasks duration')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{schedule_duration}}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('Generate book cover thumbnails')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_book_covers) }}</div>
|
||||
</div>
|
||||
<!--div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('Generate series cover thumbnails')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_series_covers) }}</div>
|
||||
</div-->
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('Reconnect to Calibre Library')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_reconnect) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row form-group">
|
||||
<h2>{{_('Administration')}}</h2>
|
||||
<a class="btn btn-default" id="debug" href="{{url_for('admin.download_debug')}}">{{_('Download Debug Package')}}</a>
|
||||
<a class="btn btn-default" id="logfile" href="{{url_for('admin.view_logfile')}}">{{_('View Logs')}}</a>
|
||||
</div>
|
||||
<a class="btn btn-default scheduledtasks" id="admin_edit_scheduled_tasks" href="{{url_for('admin.edit_scheduledtasks')}}">{{_('Edit Scheduled Tasks Settings')}}</a>
|
||||
{% if config.schedule_generate_book_covers %}
|
||||
<a class="btn btn-default" id="admin_refresh_cover_cache">{{_('Refresh Thumbnail Cover Cache')}}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row form-group">
|
||||
<div class="btn btn-default" id="restart_database" data-toggle="modal" data-target="#StatusDialog">{{_('Reconnect Calibre Database')}}</div>
|
||||
<div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div>
|
||||
<div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row form-group">
|
||||
<h2>{{_('Administration')}}</h2>
|
||||
<a class="btn btn-default" id="debug" href="{{url_for('admin.download_debug')}}">{{_('Download Debug Package')}}</a>
|
||||
<a class="btn btn-default" id="logfile" href="{{url_for('admin.view_logfile')}}">{{_('View Logs')}}</a>
|
||||
</div>
|
||||
<div class="row form-group">
|
||||
<div class="btn btn-default" id="restart_database" data-toggle="modal" data-target="#StatusDialog">{{_('Reconnect Calibre Database')}}</div>
|
||||
</div>
|
||||
<div class="row form-group">
|
||||
<div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div>
|
||||
<div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2>{{_('Update')}}</h2>
|
||||
<h2>{{_('Version Information')}}</h2>
|
||||
<table class="table table-striped" id="update_table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -188,9 +225,8 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if feature_support['updater'] %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if feature_support['updater'] %}
|
||||
<div class="hidden" id="update_error"> <span>{{update_error}}</span></div>
|
||||
<div class="btn btn-primary" id="check_for_update">{{_('Check for Update')}}</div>
|
||||
<div class="btn btn-primary hidden" id="perform_update" data-toggle="modal" data-target="#StatusDialog">{{_('Perform Update')}}</div>
|
||||
@ -251,3 +287,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block modal %}
|
||||
{{ change_confirm_modal() }}
|
||||
{% endblock %}
|
||||
|
@ -5,11 +5,11 @@
|
||||
{% if author is not none %}
|
||||
<section class="author-bio">
|
||||
{%if author.image_url is not none %}
|
||||
<img title="{{author.name|safe}}" src="{{author.image_url}}" alt="{{author.name|safe}}" class="author-photo pull-left">
|
||||
<img title="{{author.name}}" src="{{author.image_url}}" alt="{{author.name}}" class="author-photo pull-left">
|
||||
{% endif %}
|
||||
|
||||
{%if author.about is not none %}
|
||||
<p>{{author.about|safe}}</p>
|
||||
<p>{{author.about}}</p>
|
||||
{% endif %}
|
||||
|
||||
- {{_("via")}} <a href="{{author.link}}" class="author-link" target="_blank" rel="noopener">Goodreads</a>
|
||||
@ -31,28 +31,27 @@
|
||||
<a id="pub_old" data-toggle="tooltip" title="{{_('Sort according to publishing date, oldest first')}}" class="btn btn-primary{% if order == "pubold" %} active{% endif%}" href="{{url_for('web.books_list', data='author', book_id=id, sort_param='pubold')}}"><span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order-alt"></span></a>
|
||||
</div>
|
||||
<div class="row display-flex">
|
||||
{% if entries[0] %}
|
||||
{% for entry in entries %}
|
||||
<div id="books" class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||
<div class="cover">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}">
|
||||
<span class="img" title="{{entry.title|safe}}">
|
||||
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" />
|
||||
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<span class="img" title="{{entry.Books.title}}">
|
||||
{{ image.book_cover(entry.Books, alt=author.name|safe) }}
|
||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}">
|
||||
<p title="{{ entry.title }}" class="title">{{entry.title|shortentitle}}</p>
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<p title="{{ entry.Books.title }}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||
</a>
|
||||
<p class="author">
|
||||
{% for author in entry.authors %}
|
||||
{% for author in entry.Books.authors %}
|
||||
{% if loop.index > g.config_authors_max and g.config_authors_max != 0 %}
|
||||
{% if not loop.first %}
|
||||
<span class="author-hidden-divider">&</span>
|
||||
{% endif %}
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% if loop.last %}
|
||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||
{% endif %}
|
||||
@ -60,26 +59,26 @@
|
||||
{% if not loop.first %}
|
||||
<span>&</span>
|
||||
{% endif %}
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for format in entry.data %}
|
||||
{% for format in entry.Books.data %}
|
||||
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
|
||||
<span class="glyphicon glyphicon-music"></span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% if entry.series.__len__() > 0 %}
|
||||
{% if entry.Books.series.__len__() > 0 %}
|
||||
<p class="series">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='new', book_id=entry.series[0].id )}}">
|
||||
{{entry.series[0].name}}
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
||||
{{entry.Books.series[0].name}}
|
||||
</a>
|
||||
({{entry.series_index|formatseriesindex}})
|
||||
({{entry.Books.series_index|formatseriesindex}})
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if entry.ratings.__len__() > 0 %}
|
||||
{% if entry.Books.ratings.__len__() > 0 %}
|
||||
<div class="rating">
|
||||
{% for number in range((entry.ratings[0].rating/2)|int(2)) %}
|
||||
{% for number in range((entry.Books.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) %}
|
||||
@ -92,13 +91,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if other_books and author is not none %}
|
||||
<div class="discover">
|
||||
<h3>{{_("More by")}} {{ author.name.replace('|',',')|safe }}</h3>
|
||||
<h3>{{_("More by")}} {{ author.name.replace('|',',') }}</h3>
|
||||
<div class="row">
|
||||
{% for entry in other_books %}
|
||||
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||
@ -123,7 +121,7 @@
|
||||
</p>
|
||||
{% if entry.series.__len__() > 0 %}
|
||||
<p class="series">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='new', book_id=entry.series[0].id )}}">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.series[0].id )}}">
|
||||
{{entry.series[0].name}}
|
||||
</a>
|
||||
({{entry.series_index|formatseriesindex}})
|
||||
|
@ -3,7 +3,8 @@
|
||||
{% if book %}
|
||||
<div class="col-sm-3 col-lg-3 col-xs-12">
|
||||
<div class="cover">
|
||||
<img id="detailcover" title="{{book.title}}" src="{{ url_for('web.get_cover', book_id=book.id, edit=1|uuidfilter) }}" alt="{{ book.title }}"/>
|
||||
<!-- Always use full-sized image for the book edit page -->
|
||||
<img id="detailcover" title="{{book.title}}" src="{{url_for('web.get_cover', book_id=book.id, resolution='og', c=book|last_modified)}}" />
|
||||
</div>
|
||||
{% if g.user.role_delete_books() %}
|
||||
<div class="text-center">
|
||||
@ -22,7 +23,7 @@
|
||||
|
||||
{% 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('editbook.convert_bookformat', book_id=book.id) }}" method="post" id="book_convert_frm">
|
||||
<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">
|
||||
@ -48,7 +49,7 @@
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<form role="form" action="{{ url_for('editbook.edit_book', book_id=book.id) }}" method="post" enctype="multipart/form-data" id="book_edit_frm">
|
||||
<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">
|
||||
@ -226,7 +227,7 @@
|
||||
<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="metaModalLabel">{{_('Fetch Metadata')}}</h4>
|
||||
<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>
|
||||
@ -247,7 +248,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
|
||||
<button id="meta_close" type="button" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -265,17 +266,17 @@
|
||||
>
|
||||
<div class="media-body">
|
||||
<h4 class="media-heading">
|
||||
<a href="<%= url %>" target="_blank" rel="noopener"><%= title %></a>
|
||||
<a class="meta_title" href="<%= url %>" target="_blank" rel="noopener"><%= title %></a>
|
||||
</h4>
|
||||
<p>{{_('Author')}}:<%= authors.join(" & ") %></p>
|
||||
<p class="meta_author">{{_('Author')}}:<%= authors.join(" & ") %></p>
|
||||
<% if (publisher) { %>
|
||||
<p>{{_('Publisher')}}:<%= publisher %></p>
|
||||
<p class="meta_publisher">{{_('Publisher')}}:<%= publisher %></p>
|
||||
<% } %>
|
||||
<% if (description) { %>
|
||||
<p>{{_('Description')}}: <%= description %></p>
|
||||
<p class="meta_description">{{_('Description')}}: <%= description %></p>
|
||||
<% } %>
|
||||
<p>{{_('Source')}}:
|
||||
<a href="<%= source.url %>" target="_blank" rel="noopener"><%= source.description %></a>
|
||||
<a class="meta_source" href="<%= source.link %>" target="_blank" rel="noopener"><%= source.description %></a>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
@ -1,3 +1,3 @@
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<span class="title">{{entry.title|shortentitle}}</span>
|
||||
</a>
|
||||
</a>
|
||||
|
@ -6,7 +6,7 @@
|
||||
data-escape="true"
|
||||
{% if g.user.role_edit() %}
|
||||
data-editable-type="text"
|
||||
data-editable-url="{{ url_for('editbook.edit_list_book', param=parameter)}}"
|
||||
data-editable-url="{{ url_for('edit-book.edit_list_book', param=parameter)}}"
|
||||
data-editable-title="{{ edit_text }}"
|
||||
data-edit="true"
|
||||
{% if validate %}data-edit-validate="{{ _('This Field is Required') }}" {% endif %}
|
||||
@ -66,30 +66,30 @@
|
||||
{{ text_table_row('authors', _('Enter Authors'),_('Authors'), true, true) }}
|
||||
{{ text_table_row('tags', _('Enter Categories'),_('Categories'), false, true) }}
|
||||
{{ text_table_row('series', _('Enter Series'),_('Series'), false, true) }}
|
||||
<th data-field="series_index" id="series_index" data-visible="{{visiblility.get('series_index')}}" data-edit-validate="{{ _('This Field is Required') }}" data-sortable="true" {% if g.user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.01" data-editable-min="0" data-editable-url="{{ url_for('editbook.edit_list_book', param='series_index')}}" data-edit="true" data-editable-title="{{_('Enter Title')}}"{% endif %}>{{_('Series Index')}}</th>
|
||||
<th data-field="series_index" id="series_index" data-visible="{{visiblility.get('series_index')}}" data-edit-validate="{{ _('This Field is Required') }}" data-sortable="true" {% if g.user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.01" data-editable-min="0" data-editable-url="{{ url_for('edit-book.edit_list_book', param='series_index')}}" data-edit="true" data-editable-title="{{_('Enter Title')}}"{% endif %}>{{_('Series Index')}}</th>
|
||||
{{ text_table_row('languages', _('Enter Languages'),_('Languages'), false, true) }}
|
||||
<!--th data-field="pubdate" data-type="date" data-visible="{{visiblility.get('pubdate')}}" data-viewformat="dd.mm.yyyy" id="pubdate" data-sortable="true">{{_('Publishing Date')}}</th-->
|
||||
{{ text_table_row('publishers', _('Enter Publishers'),_('Publishers'), false, true) }}
|
||||
<th data-field="comments" id="comments" data-escape="true" data-editable-mode="popup" data-visible="{{visiblility.get('comments')}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="wysihtml5" data-editable-url="{{ url_for('editbook.edit_list_book', param='comments')}}" data-edit="true" data-editable-title="{{_('Enter comments')}}"{% endif %}>{{_('Comments')}}</th>
|
||||
<th data-field="comments" id="comments" data-escape="true" data-editable-mode="popup" data-visible="{{visiblility.get('comments')}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="wysihtml5" data-editable-url="{{ url_for('edit-book.edit_list_book', param='comments')}}" data-edit="true" data-editable-title="{{_('Enter comments')}}"{% endif %}>{{_('Comments')}}</th>
|
||||
{% if g.user.check_visibility(32768) %}
|
||||
{{ book_checkbox_row('is_archived', _('Archiv Status'), false)}}
|
||||
{% endif %}
|
||||
{{ book_checkbox_row('read_status', _('Read Status'), false)}}
|
||||
{% for c in cc %}
|
||||
{% if c.datatype == "int" %}
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="1" data-editable-url="{{ url_for('editbook.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="1" data-editable-url="{{ url_for('edit-book.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
{% elif c.datatype == "rating" %}
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-formatter="ratingFormatter" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.5" data-editable-step="1" data-editable-min="1" data-editable-max="5" data-editable-url="{{ url_for('editbook.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-formatter="ratingFormatter" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.5" data-editable-step="1" data-editable-min="1" data-editable-max="5" data-editable-url="{{ url_for('edit-book.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
{% elif c.datatype == "float" %}
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.01" data-editable-url="{{ url_for('editbook.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="number" data-editable-placeholder="1" data-editable-step="0.01" data-editable-url="{{ url_for('edit-book.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
{% elif c.datatype == "enumeration" %}
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="select" data-editable-source={{ url_for('editbook.table_get_custom_enum', c_id=c.id) }} data-editable-url="{{ url_for('editbook.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="select" data-editable-source={{ url_for('edit-book.table_get_custom_enum', c_id=c.id) }} data-editable-url="{{ url_for('edit-book.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
{% elif c.datatype in ["datetime"] %}
|
||||
<!-- missing -->
|
||||
{% elif c.datatype == "text" %}
|
||||
{{ text_table_row('custom_column_' + c.id|string, _('Enter ') + c.name, c.name, false, false) }}
|
||||
{% elif c.datatype == "comments" %}
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-escape="true" data-editable-mode="popup" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="wysihtml5" data-editable-url="{{ url_for('editbook.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
<th data-field="custom_column_{{ c.id|string }}" id="custom_column_{{ c.id|string }}" data-escape="true" data-editable-mode="popup" data-visible="{{visiblility.get('custom_column_'+ c.id|string)}}" data-sortable="false" {% if g.user.role_edit() %} data-editable-type="wysihtml5" data-editable-url="{{ url_for('edit-book.edit_list_book', param='custom_column_'+ c.id|string)}}" data-edit="true" data-editable-title="{{_('Enter ') + c.name}}"{% endif %}>{{c.name}}</th>
|
||||
{% elif c.datatype == "bool" %}
|
||||
{{ book_checkbox_row('custom_column_' + c.id|string, c.name, false)}}
|
||||
{% else %}
|
||||
@ -123,8 +123,8 @@
|
||||
<div class="text-left" id="merge_to"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="button" class="btn btn-danger" value="{{_('Merge')}}" name="merge_confirm" id="merge_confirm" data-dismiss="modal">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
|
||||
<input id="merge_confirm" type="button" class="btn btn-danger" value="{{_('Merge')}}" name="merge_confirm" id="merge_confirm" data-dismiss="modal">
|
||||
<button id="merge_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -162,8 +162,10 @@
|
||||
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if conf.show_detail_random() %}checked{% endif %}>
|
||||
<label for="Show_detail_random">{{_('Show Random Books in Detail View')}}</label>
|
||||
</div>
|
||||
{% if not simple %}
|
||||
<a href="#" id="get_tags" data-id="0" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add Allowed/Denied Tags')}}</a>
|
||||
<a href="#" id="get_column_values" data-id="0" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add Allowed/Denied custom column values')}}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,7 +4,8 @@
|
||||
<div class="row">
|
||||
<div class="col-sm-3 col-lg-3 col-xs-5">
|
||||
<div class="cover">
|
||||
<img id="detailcover" title="{{entry.title}}" src="{{ url_for('web.get_cover', book_id=entry.id, edit=1|uuidfilter) }}" alt="{{ entry.title }}" />
|
||||
<!-- Always use full-sized image for the detail page -->
|
||||
<img id="detailcover" title="{{entry.title}}" src="{{url_for('web.get_cover', book_id=entry.id, resolution='og', c=entry|last_modified)}}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-9 col-lg-9 book-meta">
|
||||
@ -38,7 +39,7 @@
|
||||
{% endif %}
|
||||
{% if g.user.kindle_mail and entry.kindle_list %}
|
||||
{% if entry.kindle_list.__len__() == 1 %}
|
||||
<a href="{{url_for('web.send_to_kindle', book_id=entry.id, book_format=entry.kindle_list[0]['format'], convert=entry.kindle_list[0]['convert'])}}" id="sendbtn" data-text="{{_('Send to Kindle')}}" class="btn btn-primary" role="button"><span class="glyphicon glyphicon-send"></span> {{entry.kindle_list[0]['text']}}</a>
|
||||
<div id="sendbtn" data-action="{{url_for('web.send_to_kindle', book_id=entry.id, book_format=entry.kindle_list[0]['format'], convert=entry.kindle_list[0]['convert'])}}" data-text="{{_('Send to Kindle')}}" class="btn btn-primary postAction" role="button"><span class="glyphicon glyphicon-send"></span> {{entry.kindle_list[0]['text']}}</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">
|
||||
@ -47,7 +48,7 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="send-to-kindle">
|
||||
{% for format in entry.kindle_list %}
|
||||
<li><a href="{{url_for('web.send_to_kindle', book_id=entry.id, book_format=format['format'], convert=format['convert'])}}">{{format['text']}}</a></li>
|
||||
<li><a class="postAction" data-action="{{url_for('web.send_to_kindle', book_id=entry.id, book_format=format['format'], convert=format['convert'])}}">{{format['text']}}</a></li>
|
||||
{%endfor%}
|
||||
</ul>
|
||||
</div>
|
||||
@ -70,9 +71,9 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if entry.audioentries|length > 0 and g.user.role_viewer() %}
|
||||
{% if entry.audio_entries|length > 0 and g.user.role_viewer() %}
|
||||
<div class="btn-group" role="group">
|
||||
{% if entry.audioentries|length > 1 %}
|
||||
{% if entry.audio_entries|length > 1 %}
|
||||
<button id="listen-in-browser" type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="glyphicon glyphicon-music"></span> {{_('Listen in Browser')}}
|
||||
<span class="caret"></span>
|
||||
@ -85,13 +86,13 @@
|
||||
<ul class="dropdown-menu" aria-labelledby="listen-in-browser">
|
||||
|
||||
{% for format in entry.data %}
|
||||
{% if format.format|lower in entry.audioentries %}
|
||||
{% if format.format|lower in entry.audio_entries %}
|
||||
<li><a target="_blank" href="{{ url_for('web.read_book', book_id=entry.id, book_format=format.format|lower) }}">{{format.format|lower }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<a target="_blank" href="{{url_for('web.read_book', book_id=entry.id, book_format=entry.audioentries[0])}}" id="listenbtn" class="btn btn-primary" role="button"><span class="glyphicon glyphicon-music"></span> {{_('Listen in Browser')}} - {{entry.audioentries[0]}}</a>
|
||||
<a target="_blank" href="{{url_for('web.read_book', book_id=entry.id, book_format=entry.audio_entries[0])}}" id="listenbtn" class="btn btn-primary" role="button"><span class="glyphicon glyphicon-music"></span> {{_('Listen in Browser')}} - {{entry.audio_entries[0]}}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -99,7 +100,7 @@
|
||||
</div>
|
||||
<h2 id="title">{{entry.title}}</h2>
|
||||
<p class="author">
|
||||
{% for author in entry.authors %}
|
||||
{% 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 %}
|
||||
&
|
||||
@ -138,7 +139,7 @@
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-link"></span>
|
||||
{% for identifier in entry.identifiers %}
|
||||
<a href="{{identifier}}" target="_blank" class="btn btn-xs btn-success" role="button">{{identifier.formatType()}}</a>
|
||||
<a href="{{identifier}}" target="_blank" class="btn btn-xs btn-success" role="button">{{identifier.format_type()}}</a>
|
||||
{%endfor%}
|
||||
</p>
|
||||
</div>
|
||||
@ -260,7 +261,7 @@
|
||||
{% for shelf in g.shelves_access %}
|
||||
{% if not shelf.id in books_shelfs and ( not shelf.is_public or g.user.role_edit_shelfs() ) %}
|
||||
<li>
|
||||
<a href="{{ url_for('shelf.add_to_shelf', book_id=entry.id, shelf_id=shelf.id) }}"
|
||||
<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"
|
||||
>
|
||||
@ -275,7 +276,7 @@
|
||||
{% if books_shelfs %}
|
||||
{% for shelf in g.shelves_access %}
|
||||
{% if shelf.id in books_shelfs %}
|
||||
<a href="{{ url_for('shelf.remove_from_shelf', book_id=entry.id, shelf_id=shelf.id) }}"
|
||||
<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"
|
||||
>
|
||||
@ -295,7 +296,7 @@
|
||||
{% if g.user.role_edit() %}
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group" aria-label="Edit/Delete book">
|
||||
<a href="{{ url_for('editbook.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>
|
||||
<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>
|
||||
{% endif %}
|
||||
@ -309,13 +310,13 @@
|
||||
{% block js %}
|
||||
<script type="text/template" id="template-shelf-add">
|
||||
<li>
|
||||
<a href="<%= add %>" data-remove-href="<%= remove %>" data-shelf-action="add">
|
||||
<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 href="<%= remove %>" data-add-href="<%= add %>" class="btn btn-sm btn-default" data-shelf-action="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>
|
||||
|
@ -1,65 +0,0 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="discover load-more">
|
||||
<h2>{{title}}</h2>
|
||||
<div class="row display-flex">
|
||||
{% for entry in entries %}
|
||||
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||
<div class="cover">
|
||||
{% if entry.has_cover is defined %}
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<span class="img" title="{{entry.title}}">
|
||||
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" />
|
||||
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<p title="{{ entry.title }}" class="title">{{entry.title|shortentitle}}</p>
|
||||
</a>
|
||||
<p class="author">
|
||||
{% for author in entry.authors %}
|
||||
{% if loop.index > g.config_authors_max and g.config_authors_max != 0 %}
|
||||
{% if not loop.first %}
|
||||
<span class="author-hidden-divider">&</span>
|
||||
{% endif %}
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% if loop.last %}
|
||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if not loop.first %}
|
||||
<span>&</span>
|
||||
{% endif %}
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% if entry.series.__len__() > 0 %}
|
||||
<p class="series">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='new', book_id=entry.series[0].id )}}">
|
||||
{{entry.series[0].name}}
|
||||
</a>
|
||||
({{entry.series_index|formatseriesindex}})
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if entry.ratings.__len__() > 0 %}
|
||||
<div class="rating">
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -69,7 +69,7 @@
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.admin') }}" id="email_back" class="btn btn-default">{{_('Back')}}</a>
|
||||
</form>
|
||||
{% if g.allow_registration %}
|
||||
{% if g.allow_registration and not simple%}
|
||||
<div class="col-md-10 col-lg-6">
|
||||
<h2>{{_('Allowed Domains (Whitelist)')}}</h2>
|
||||
<form id="domain_add_allow" action="{{ url_for('admin.add_domain',allow=1)}}" method="POST">
|
||||
|
@ -40,35 +40,35 @@
|
||||
{% if entries and entries[0] %}
|
||||
{% for entry in entries %}
|
||||
<entry>
|
||||
<title>{{entry.title}}</title>
|
||||
<id>urn:uuid:{{entry.uuid}}</id>
|
||||
<updated>{{entry.atom_timestamp}}</updated>
|
||||
{% if entry.authors.__len__() > 0 %}
|
||||
<title>{{entry.Books.title}}</title>
|
||||
<id>urn:uuid:{{entry.Books.uuid}}</id>
|
||||
<updated>{{entry.Books.atom_timestamp}}</updated>
|
||||
{% if entry.Books.authors.__len__() > 0 %}
|
||||
<author>
|
||||
<name>{{entry.authors[0].name}}</name>
|
||||
<name>{{entry.Books.authors[0].name}}</name>
|
||||
</author>
|
||||
{% endif %}
|
||||
{% if entry.publishers.__len__() > 0 %}
|
||||
{% if entry.Books.publishers.__len__() > 0 %}
|
||||
<publisher>
|
||||
<name>{{entry.publishers[0].name}}</name>
|
||||
<name>{{entry.Books.publishers[0].name}}</name>
|
||||
</publisher>
|
||||
{% endif %}
|
||||
{% for lang in entry.languages %}
|
||||
{% for lang in entry.Books.languages %}
|
||||
<dcterms:language>{{lang.lang_code}}</dcterms:language>
|
||||
{% endfor %}
|
||||
{% for tag in entry.tags %}
|
||||
{% for tag in entry.Books.tags %}
|
||||
<category scheme="http://www.bisg.org/standards/bisac_subject/index.html"
|
||||
term="{{tag.name}}"
|
||||
label="{{tag.name}}"/>
|
||||
{% endfor %}
|
||||
{% if entry.comments[0] %}<summary>{{entry.comments[0].text|striptags}}</summary>{% endif %}
|
||||
{% if entry.has_cover %}
|
||||
<link type="image/jpeg" href="{{url_for('opds.feed_get_cover', book_id=entry.id)}}" rel="http://opds-spec.org/image"/>
|
||||
<link type="image/jpeg" href="{{url_for('opds.feed_get_cover', book_id=entry.id)}}" rel="http://opds-spec.org/image/thumbnail"/>
|
||||
{% if entry.Books.comments[0] %}<summary>{{entry.Books.comments[0].text|striptags}}</summary>{% endif %}
|
||||
{% if entry.Books.has_cover %}
|
||||
<link type="image/jpeg" href="{{url_for('opds.feed_get_cover', book_id=entry.Books.id)}}" rel="http://opds-spec.org/image"/>
|
||||
<link type="image/jpeg" href="{{url_for('opds.feed_get_cover', book_id=entry.Books.id)}}" rel="http://opds-spec.org/image/thumbnail"/>
|
||||
{% endif %}
|
||||
{% for format in entry.data %}
|
||||
<link rel="http://opds-spec.org/acquisition" href="{{ url_for('opds.opds_download_link', book_id=entry.id, book_format=format.format|lower)}}"
|
||||
length="{{format.uncompressed_size}}" mtime="{{entry.atom_timestamp}}" type="{{format.format|lower|mimetype}}"/>
|
||||
{% for format in entry.Books.data %}
|
||||
<link rel="http://opds-spec.org/acquisition" href="{{ url_for('opds.opds_download_link', book_id=entry.Books.id, book_format=format.format|lower)}}"
|
||||
length="{{format.uncompressed_size}}" mtime="{{entry.Books.atom_timestamp}}" type="{{format.format|lower|mimetype}}"/>
|
||||
{% endfor %}
|
||||
</entry>
|
||||
{% endfor %}
|
||||
|
@ -1,3 +1,4 @@
|
||||
{% import 'image.html' as image %}
|
||||
<div class="container-fluid">
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
|
@ -1,12 +1,15 @@
|
||||
{% extends "fragment.html" %}
|
||||
{% block body %}
|
||||
<div class="well">
|
||||
<p>
|
||||
{{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}</a>
|
||||
<p>
|
||||
{% if not warning %}
|
||||
{{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}
|
||||
</p><p>
|
||||
api_endpoint={{url_for("kobo.TopLevelEndpoint", auth_token=auth_token, _external=True)}}
|
||||
{% else %}
|
||||
{{warning}}
|
||||
</p><p>{{_('Kobo Token:')}} {{ auth_token }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
{% if not warning %}api_endpoint={{kobo_auth_url}}{% else %}{{warning}}{% endif %}</a>
|
||||
</p>
|
||||
<p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,3 +1,4 @@
|
||||
{% import 'image.html' as image %}
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<h1 class="{{page}}">{{_(title)}}</h1>
|
||||
@ -27,7 +28,7 @@
|
||||
<div class="cover">
|
||||
<a href="{{url_for('web.books_list', data=data, sort_param='stored', book_id=entry[0].series[0].id )}}">
|
||||
<span class="img" title="{{entry[0].series[0].name}}">
|
||||
<img src="{{ url_for('web.get_cover', book_id=entry[3]) }}" alt="{{ entry[0].series[0].name }}"/>
|
||||
{{ image.series(entry[0].series[0], alt=entry[0].series[0].name|shortentitle) }}
|
||||
<span class="badge">{{entry.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
|
20
cps/templates/image.html
Normal file
20
cps/templates/image.html
Normal file
@ -0,0 +1,20 @@
|
||||
{% macro book_cover(book, alt=None) -%}
|
||||
{%- set image_title = book.title if book.title else book.name -%}
|
||||
{%- set image_alt = alt if alt else image_title -%}
|
||||
{% set srcset = book|get_cover_srcset %}
|
||||
<img
|
||||
srcset="{{ srcset }}"
|
||||
src="{{ url_for('web.get_cover', book_id=book.id, resolution='og', c=book|last_modified) }}"
|
||||
alt="{{ image_alt }}"
|
||||
/>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro series(series, alt=None) -%}
|
||||
{%- set image_alt = alt if alt else image_title -%}
|
||||
{% set srcset = series|get_series_srcset %}
|
||||
<img
|
||||
srcset="{{ srcset }}"
|
||||
src="{{ url_for('web.get_series_cover', series_id=series.id, resolution='og', c='day'|cache_timestamp) }}"
|
||||
alt="{{ book_title }}"
|
||||
/>
|
||||
{%- endmacro %}
|
@ -1,30 +1,31 @@
|
||||
{% import 'image.html' as image %}
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
{% if g.user.show_detail_random() %}
|
||||
{% if g.user.show_detail_random() and page != "discover" %}
|
||||
<div class="discover random-books">
|
||||
<h2 class="random-books">{{_('Discover (Random Books)')}}</h2>
|
||||
<div class="row display-flex">
|
||||
{% for entry in random %}
|
||||
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books_rand">
|
||||
<div class="cover">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<span class="img" title="{{ entry.title }}">
|
||||
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" />
|
||||
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<span class="img" title="{{ entry.Books.title }}">
|
||||
{{ image.book_cover(entry.Books) }}
|
||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<p title="{{entry.title}}" class="title">{{entry.title|shortentitle}}</p>
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<p title="{{entry.Books.title}}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||
</a>
|
||||
<p class="author">
|
||||
{% for author in entry.authors %}
|
||||
{% for author in entry.Books.authors %}
|
||||
{% if loop.index > g.config_authors_max and g.config_authors_max != 0 %}
|
||||
{% if not loop.first %}
|
||||
<span class="author-hidden-divider">&</span>
|
||||
{% endif %}
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% if loop.last %}
|
||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||
{% endif %}
|
||||
@ -32,21 +33,21 @@
|
||||
{% if not loop.first %}
|
||||
<span>&</span>
|
||||
{% endif %}
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% if entry.series.__len__() > 0 %}
|
||||
{% if entry.Books.series.__len__() > 0 %}
|
||||
<p class="series">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='new', book_id=entry.series[0].id )}}">
|
||||
{{entry.series[0].name}}
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
||||
{{entry.Books.series[0].name}}
|
||||
</a>
|
||||
({{entry.series_index|formatseriesindex}})
|
||||
({{entry.Books.series_index|formatseriesindex}})
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if entry.ratings.__len__() > 0 %}
|
||||
{% if entry.Books.ratings.__len__() > 0 %}
|
||||
<div class="rating">
|
||||
{% for number in range((entry.ratings[0].rating/2)|int(2)) %}
|
||||
{% for number in range((entry.Books.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) %}
|
||||
@ -64,6 +65,7 @@
|
||||
{% endif %}
|
||||
<div class="discover load-more">
|
||||
<h2 class="{{title}}">{{title}}</h2>
|
||||
{% if page != 'discover' %}
|
||||
<div class="filterheader hidden-xs">
|
||||
{% if page == 'hot' %}
|
||||
<a data-toggle="tooltip" title="{{_('Sort ascending according to download count')}}" id="hot_asc" class="btn btn-primary{% if order == "hotasc" %} active{% endif%}" href="{{url_for('web.books_list', data=page, book_id=id, sort_param='hotasc')}}"><span class="glyphicon glyphicon-sort-by-order"></span></a>
|
||||
@ -83,30 +85,30 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
<div class="row display-flex">
|
||||
{% if entries[0] %}
|
||||
{% for entry in entries %}
|
||||
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books">
|
||||
<div class="cover">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<span class="img" title="{{ entry.title }}">
|
||||
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}"/>
|
||||
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<span class="img" title="{{ entry.Books.title }}">
|
||||
{{ image.book_cover(entry.Books) }}
|
||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<p title="{{ entry.title }}" class="title">{{entry.title|shortentitle}}</p>
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<p title="{{ entry.Books.title }}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||
</a>
|
||||
<p class="author">
|
||||
{% for author in entry.authors %}
|
||||
{% for author in entry.Books.authors %}
|
||||
{% if loop.index > g.config_authors_max and g.config_authors_max != 0 %}
|
||||
{% if not loop.first %}
|
||||
<span class="author-hidden-divider">&</span>
|
||||
{% endif %}
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', book_id=author.id, sort_param='new') }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', book_id=author.id, sort_param='stored') }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% if loop.last %}
|
||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||
{% endif %}
|
||||
@ -114,26 +116,30 @@
|
||||
{% if not loop.first %}
|
||||
<span>&</span>
|
||||
{% endif %}
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', book_id=author.id, sort_param='new') }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', book_id=author.id, sort_param='stored') }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for format in entry.data %}
|
||||
{% for format in entry.Books.data %}
|
||||
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
|
||||
<span class="glyphicon glyphicon-music"></span>
|
||||
{% endif %}
|
||||
{%endfor%}
|
||||
</p>
|
||||
{% if entry.series.__len__() > 0 %}
|
||||
{% if entry.Books.series.__len__() > 0 %}
|
||||
<p class="series">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='new', book_id=entry.series[0].id )}}">
|
||||
{{entry.series[0].name}}
|
||||
{% if page != "series" %}
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
||||
{{entry.Books.series[0].name}}
|
||||
</a>
|
||||
({{entry.series_index|formatseriesindex}})
|
||||
{% else %}
|
||||
<span>{{entry.Books.series[0].name}}</span>
|
||||
{% endif %}
|
||||
({{entry.Books.series_index|formatseriesindex}})
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if entry.ratings.__len__() > 0 %}
|
||||
{% if entry.Books.ratings.__len__() > 0 %}
|
||||
<div class="rating">
|
||||
{% for number in range((entry.ratings[0].rating/2)|int(2)) %}
|
||||
{% for number in range((entry.Books.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) %}
|
||||
|
@ -1,35 +0,0 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<h1>{{title}}</h1>
|
||||
<div class="filterheader hidden-xs">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div id="asc" data-order="{{ order }}" data-id="{{ data }}" class="btn btn-primary {% if order == 1 %} active{% endif%}"><span class="glyphicon glyphicon-sort-by-alphabet"></span></div>
|
||||
<div id="desc" data-id="{{ data }}" class="btn btn-primary{% if order == 0 %} active{% endif%}"><span class="glyphicon glyphicon-sort-by-alphabet-alt"></span></div>
|
||||
{% if charlist|length %}
|
||||
<div id="all" class="active btn btn-primary {% if charlist|length > 9 %}hidden-sm{% endif %}">{{_('All')}}</div>
|
||||
{% endif %}
|
||||
<div class="btn-group character {% if charlist|length > 9 %}hidden-sm{% endif %}" role="group">
|
||||
{% for char in charlist%}
|
||||
<div class="btn btn-primary char">{{char}}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div div id="list" class="col-xs-12 col-sm-6">
|
||||
{% for lang in languages %}
|
||||
{% if loop.index0 == (loop.length/2)|int and loop.length > 20 %}
|
||||
</div>
|
||||
<div id="second" class="col-xs-12 col-sm-6">
|
||||
{% endif %}
|
||||
<div class="row" data-id="{{lang[0].name}}">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1" align="left"><span class="badge">{{lang[1]}}</span></div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-11"><a id="list_{{loop.index0}}" href="{{url_for('web.books_list', book_id=lang[0].lang_code, data=data, sort_param='new')}}">{{lang[0].name}}</a></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<script src="{{ url_for('static', filename='js/filter_list.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
@ -1,4 +1,5 @@
|
||||
{% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal, change_confirm_modal %}
|
||||
{% import 'image.html' as image %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ g.user.locale }}">
|
||||
<head>
|
||||
@ -40,7 +41,7 @@
|
||||
<a class="navbar-brand" href="{{url_for('web.index')}}">{{instance}}</a>
|
||||
</div>
|
||||
{% if g.user.is_authenticated or g.allow_anonymous %}
|
||||
<form class="navbar-form navbar-left" role="search" action="{{url_for('web.search')}}" method="GET">
|
||||
<form class="navbar-form navbar-left" role="search" action="{{url_for('search.simple_search')}}" method="GET">
|
||||
<div class="form-group input-group input-group-sm">
|
||||
<label for="query" class="sr-only">{{_('Search')}}</label>
|
||||
<input type="text" class="form-control" id="query" name="query" placeholder="{{_('Search Library')}}" value="{{searchterm}}">
|
||||
@ -53,14 +54,14 @@
|
||||
<div class="navbar-collapse collapse">
|
||||
{% if g.user.is_authenticated or g.allow_anonymous %}
|
||||
<ul class="nav navbar-nav ">
|
||||
<li><a href="{{url_for('web.advanced_search')}}" id="advanced_search"><span class="glyphicon glyphicon-search"></span><span class="hidden-sm"> {{_('Advanced Search')}}</span></a></li>
|
||||
<li><a href="{{url_for('search.advanced_search')}}" id="advanced_search"><span class="glyphicon glyphicon-search"></span><span class="hidden-sm"> {{_('Advanced Search')}}</span></a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<ul class="nav navbar-nav navbar-right" id="main-nav">
|
||||
{% if g.user.is_authenticated or g.allow_anonymous %}
|
||||
{% if g.user.role_upload() and g.allow_upload %}
|
||||
<li>
|
||||
<form id="form-upload" class="navbar-form" action="{{ url_for('editbook.upload') }}" data-title="{{_('Uploading...')}}" data-footer="{{_('Close')}}" data-failed="{{_('Error')}}" data-message="{{_('Upload done, processing, please wait...')}}" method="post" enctype="multipart/form-data">
|
||||
<form id="form-upload" class="navbar-form" 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">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<span class="btn btn-default btn-file">{{_('Upload')}}<input id="btn-upload" name="btn-upload"
|
||||
@ -69,8 +70,8 @@
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if not g.user.is_anonymous %}
|
||||
<li><a id="top_tasks" href="{{url_for('web.get_tasks_status')}}"><span class="glyphicon glyphicon-tasks"></span> <span class="hidden-sm">{{_('Tasks')}}</span></a></li>
|
||||
{% if not g.user.is_anonymous and not simple%}
|
||||
<li><a id="top_tasks" href="{{url_for('tasks.get_tasks_status')}}"><span class="glyphicon glyphicon-tasks"></span> <span class="hidden-sm">{{_('Tasks')}}</span></a></li>
|
||||
{% endif %}
|
||||
{% if g.user.role_admin() %}
|
||||
<li><a id="top_admin" data-text="{{_('Settings')}}" href="{{url_for('admin.admin')}}"><span class="glyphicon glyphicon-dashboard"></span> <span class="hidden-sm">{{_('Admin')}}</span></a></li>
|
||||
@ -183,7 +184,7 @@
|
||||
</div>
|
||||
<div class="modal-body">...</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
|
||||
<button type="button" id="details_close" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -14,7 +14,7 @@
|
||||
{% endif %}
|
||||
<div class="btn-group character {% if charlist|length > 9 %}hidden-sm{% endif %}" role="group">
|
||||
{% for char in charlist%}
|
||||
<div class="btn btn-primary char">{{char.char}}</div>
|
||||
<div class="btn btn-primary char">{{char[0]}}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@ -29,8 +29,8 @@
|
||||
</div>
|
||||
<div id="second" class="col-xs-12 col-sm-6">
|
||||
{% endif %}
|
||||
<div class="row" {% if entry[0].sort %}data-name="{{entry[0].name}}"{% endif %} data-id="{% if entry[0].sort %}{{entry[0].sort}}{% else %}{% if entry.name %}{{entry.name}}{% else %}{{entry[0].name}}{% endif %}{% endif %}">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1" align="left"><span class="badge">{{entry.count}}</span></div>
|
||||
<div class="row" {% if entry[0].sort %}data-name="{{entry[0].name}}"{% endif %} data-id="{% if entry[0].sort %}{{entry[0].sort}}{% else %}{% if entry[0].format %}{{entry[0].format}}{% else %}{% if entry[0].rating %}{{entry[0].rating}}{% else %}{{entry[0].name}}{% endif %}{% endif %}{% endif %}">
|
||||
<div class="col-xs-2 col-sm-2 col-md-1" align="left"><span class="badge">{{entry[1]}}</span></div>
|
||||
<div class="col-xs-10 col-sm-10 col-md-11"><a id="list_{{loop.index0}}" href="{% if entry.format %}{{url_for('web.books_list', data=data, sort_param='stored', book_id=entry.format )}}{% else %}{{url_for('web.books_list', data=data, sort_param='stored', book_id=entry[0].id )}}{% endif %}">
|
||||
{% if entry.name %}
|
||||
<div class="rating">
|
||||
|
@ -105,7 +105,7 @@
|
||||
|
||||
<div class="sm2-playlist-wrapper">
|
||||
<ul class="sm2-playlist-bd">
|
||||
<li><a href="{{ url_for('web.serve_book', book_id=mp3file,book_format=audioformat)}}"><b>{% for author in entry.authors %}{{author.name.replace('|',',')}}
|
||||
<li><a href="{{ url_for('web.serve_book', book_id=mp3file,book_format=audioformat)}}"><b>{% for author in entry.ordered_authors %}{{author.name.replace('|',',')}}
|
||||
{% if not loop.last %} & {% endif %} {% endfor %}</b> - {{entry.title}}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -134,7 +134,7 @@ window.calibre = {
|
||||
filePath: "{{ url_for('static', filename='js/libs/') }}",
|
||||
cssPath: "{{ url_for('static', filename='css/') }}",
|
||||
bookUrl: "{{ url_for('static', filename=mp3file) }}/",
|
||||
bookmarkUrl: "{{ url_for('web.bookmark', book_id=mp3file, book_format=audioformat.upper()) }}",
|
||||
bookmarkUrl: "{{ url_for('web.set_bookmark', book_id=mp3file, book_format=audioformat.upper()) }}",
|
||||
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
|
||||
useBookmarks: "{{ g.user.is_authenticated | tojson }}"
|
||||
};
|
||||
|
@ -86,7 +86,7 @@
|
||||
window.calibre = {
|
||||
filePath: "{{ url_for('static', filename='js/libs/') }}",
|
||||
cssPath: "{{ url_for('static', filename='css/') }}",
|
||||
bookmarkUrl: "{{ url_for('web.bookmark', book_id=bookid, book_format='EPUB') }}",
|
||||
bookmarkUrl: "{{ url_for('web.set_bookmark', book_id=bookid, book_format='EPUB') }}",
|
||||
bookUrl: "{{ url_for('web.serve_book', book_id=bookid, book_format='epub', anyname='file.epub') }}",
|
||||
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
|
||||
useBookmarks: "{{ g.user.is_authenticated | tojson }}"
|
||||
|
44
cps/templates/schedule_edit.html
Normal file
44
cps/templates/schedule_edit.html
Normal file
@ -0,0 +1,44 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block header %}
|
||||
<link href="{{ url_for('static', filename='css/libs/bootstrap-table.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/libs/bootstrap-editable.css') }}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
<h1>{{title}}</h1>
|
||||
<form role="form" class="col-md-10 col-lg-6" method="POST" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label for="schedule_start_time">{{_('Time at which tasks start to run')}}</label>
|
||||
<select name="schedule_start_time" id="schedule_start_time" class="form-control">
|
||||
{% for n in starttime %}
|
||||
<option value="{{n[0]}}" {% if config.schedule_start_time == n[0] %}selected{% endif %}>{{n[1]}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="schedule_duration">{{_('Maximum tasks duration')}}</label>
|
||||
<select name="schedule_duration" id="schedule_duration" class="form-control">
|
||||
{% for n in duration %}
|
||||
<option value="{{n[0]}}" {% if config.schedule_duration == n[0] %}selected{% endif %}>{{n[1]}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="schedule_generate_book_covers" name="schedule_generate_book_covers" {% if config.schedule_generate_book_covers %}checked{% endif %}>
|
||||
<label for="schedule_generate_book_covers">{{_('Generate Book Cover Thumbnails')}}</label>
|
||||
</div>
|
||||
<!--div class="form-group">
|
||||
<input type="checkbox" id="schedule_generate_series_covers" name="schedule_generate_series_covers" {% if config.schedule_generate_series_covers %}checked{% endif %}>
|
||||
<label for="schedule_generate_series_covers">{{_('Generate Series Cover Thumbnails')}}</label>
|
||||
</div-->
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="schedule_reconnect" name="schedule_reconnect" {% if config.schedule_reconnect %}checked{% endif %}>
|
||||
<label for="schedule_reconnect">{{_('Reconnect to Calibre Library')}}</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" name="submit" value="submit" class="btn btn-default">{{_('Save')}}</button>
|
||||
<a href="{{ url_for('admin.admin') }}" id="email_back" class="btn btn-default">{{_('Cancel')}}</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,3 +1,4 @@
|
||||
{% import 'image.html' as image %}
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
@ -9,6 +10,7 @@
|
||||
{% if g.user.is_authenticated %}
|
||||
{% if g.user.shelf.all() or g.shelves_access %}
|
||||
<div id="shelf-actions" class="btn-toolbar" role="toolbar">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<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')}}
|
||||
@ -17,7 +19,7 @@
|
||||
<ul id="add-to-shelves" class="dropdown-menu" aria-labelledby="add-to-shelf">
|
||||
{% for shelf in g.shelves_access %}
|
||||
{% if not shelf.is_public or g.user.role_edit_shelfs() %}
|
||||
<li><a href="{{ url_for('shelf.search_to_shelf', shelf_id=shelf.id) }}"> {{shelf.name}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %}</a></li>
|
||||
<li><a class="postAction" role="button" data-action="{{ url_for('shelf.search_to_shelf', shelf_id=shelf.id) }}"> {{shelf.name}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %}</a></li>
|
||||
{% endif %}
|
||||
{%endfor%}
|
||||
</ul>
|
||||
@ -42,16 +44,16 @@
|
||||
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||
<div class="cover">
|
||||
{% if entry.Books.has_cover is defined %}
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<span class="img" title="{{entry.Books.title}}" >
|
||||
<img src="{{ url_for('web.get_cover', book_id=entry.Books.id) }}" alt="{{ entry.Books.title }}" />
|
||||
{% if entry.Books.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
{{ image.book_cover(entry.Books) }}
|
||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<p title="{{entry.Books.title}}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||
</a>
|
||||
<p class="author">
|
||||
@ -60,7 +62,7 @@
|
||||
{% if not loop.first %}
|
||||
<span class="author-hidden-divider">&</span>
|
||||
{% endif %}
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% if loop.last %}
|
||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||
{% endif %}
|
||||
@ -68,7 +70,7 @@
|
||||
{% if not loop.first %}
|
||||
<span>&</span>
|
||||
{% endif %}
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for format in entry.Books.data %}
|
||||
@ -79,7 +81,7 @@
|
||||
</p>
|
||||
{% if entry.Books.series.__len__() > 0 %}
|
||||
<p class="series">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='new', book_id=entry.Books.series[0].id )}}">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
||||
{{entry.Books.series[0].name}}
|
||||
</a>
|
||||
({{entry.Books.series_index|formatseriesindex}})
|
||||
|
@ -2,7 +2,7 @@
|
||||
{% block body %}
|
||||
<h1 class="{{page}}">{{title}}</h1>
|
||||
<div class="col-md-10 col-lg-6">
|
||||
<form role="form" id="search" action="{{ url_for('web.advanced_search_form') }}" method="POST">
|
||||
<form role="form" id="search" action="{{ url_for('search.advanced_search_form') }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label for="book_title">{{_('Book Title')}}</label>
|
||||
|
@ -1,15 +1,18 @@
|
||||
{% import 'image.html' as image %}
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
<h2>{{title}}</h2>
|
||||
<!--form method="post"--->
|
||||
{% if g.user.role_download() %}
|
||||
<a id="shelf_down" href="{{ url_for('shelf.show_simpleshelf', shelf_id=shelf.id) }}" class="btn btn-primary">{{ _('Download') }} </a>
|
||||
{% endif %}
|
||||
{% if g.user.is_authenticated %}
|
||||
{% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="btn btn-danger" id="delete_shelf" data-value="{{ shelf.id }}">{{ _('Delete this Shelf') }}</div>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="btn btn-danger" data-action="{{url_for('shelf.delete_shelf', shelf_id=shelf.id)}}" id="delete_shelf" data-value="{{ shelf.id }}">{{ _('Delete this Shelf') }}</div>
|
||||
<a id="edit_shelf" href="{{ url_for('shelf.edit_shelf', shelf_id=shelf.id) }}" class="btn btn-primary">{{ _('Edit Shelf Properties') }} </a>
|
||||
</form>
|
||||
{% if entries.__len__() %}
|
||||
<a id="order_shelf" href="{{ url_for('shelf.order_shelf', shelf_id=shelf.id) }}" class="btn btn-primary">{{ _('Arrange books manually') }} </a>
|
||||
<button id="toggle_order_shelf" type="button" data-alt-text="{{ _('Disable Change order') }}" class="btn btn-primary">{{ _('Enable Change order') }}</button>
|
||||
@ -30,24 +33,24 @@
|
||||
{% for entry in entries %}
|
||||
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||
<div class="cover">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<span class="img" title="{{entry.title}}" >
|
||||
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" />
|
||||
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<span class="img" title="{{entry.Books.title}}" >
|
||||
{{ image.book_cover(entry.Books) }}
|
||||
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}" data-toggle="modal" data-target="#bookDetailsModal" data-remote="false">
|
||||
<p title="{{entry.title}}" class="title">{{entry.title|shortentitle}}</p>
|
||||
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||
<p title="{{entry.Books.title}}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||
</a>
|
||||
<p class="author">
|
||||
{% for author in entry.authors %}
|
||||
{% for author in entry.Books.authors %}
|
||||
{% if loop.index > g.config_authors_max and g.config_authors_max != 0 %}
|
||||
{% if not loop.first %}
|
||||
<span class="author-hidden-divider">&</span>
|
||||
{% endif %}
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name author-hidden" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% if loop.last %}
|
||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||
{% endif %}
|
||||
@ -55,21 +58,21 @@
|
||||
{% if not loop.first %}
|
||||
<span>&</span>
|
||||
{% endif %}
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
<a class="author-name" href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')|shortentitle(30)}}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% if entry.series.__len__() > 0 %}
|
||||
{% if entry.Books.series.__len__() > 0 %}
|
||||
<p class="series">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='new', book_id=entry.series[0].id )}}">
|
||||
{{entry.series[0].name}}
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
||||
{{entry.Books.series[0].name}}
|
||||
</a>
|
||||
({{entry.series_index|formatseriesindex}})
|
||||
({{entry.Books.series_index|formatseriesindex}})
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if entry.ratings.__len__() > 0 %}
|
||||
{% if entry.Books.ratings.__len__() > 0 %}
|
||||
<div class="rating">
|
||||
{% for number in range((entry.ratings[0].rating/2)|int(2)) %}
|
||||
{% for number in range((entry.Books.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) %}
|
||||
@ -84,22 +87,6 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<!--div id="DeleteShelfDialog" class="modal fade" role="dialog">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-center">
|
||||
<span>{{_('Are you sure you want to delete this shelf?')}}</span>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<span>{{_('Shelf will be deleted for all users')}}</span>
|
||||
<p></p>
|
||||
<a id="confirm" href="{{ url_for('shelf.delete_shelf', shelf_id=shelf.id) }}" class="btn btn-danger">{{_('OK')}}</a>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div-->
|
||||
|
||||
{% endblock %}
|
||||
{% block modal %}
|
||||
{{ delete_confirm_modal() }}
|
||||
|
@ -35,31 +35,31 @@
|
||||
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||
|
||||
<div class="meta">
|
||||
<p title="{{entry.title}}" class="title">{{entry.title|shortentitle}}</p>
|
||||
<p title="{{entry.Books.title}}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||
<p class="author">
|
||||
{% for author in entry.authors %}
|
||||
<a href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')}}</a>
|
||||
{% for author in entry.Books.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.series.__len__() > 0 %}
|
||||
{% if entry.Books.series.__len__() > 0 %}
|
||||
<p class="series">
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='new', book_id=entry.series[0].id )}}">
|
||||
{{entry.series[0].name}}
|
||||
<a href="{{url_for('web.books_list', data='series', sort_param='stored', book_id=entry.Books.series[0].id )}}">
|
||||
{{entry.Books.series[0].name}}
|
||||
</a>
|
||||
({{entry.series_index}})
|
||||
({{entry.Books.series_index}})
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-group" role="group" aria-label="Download, send to Kindle, reading">
|
||||
{% if g.user.role_download() %}
|
||||
{% if entry.data|length %}
|
||||
{% if entry.Books.data|length %}
|
||||
<div class="btn-group" role="group">
|
||||
{% 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) }}" id="btnGroupDrop{{entry.id}}{{format.format|lower}}" class="btn btn-primary" role="button">
|
||||
{% for format in entry.Books.data %}
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.Books.id, book_format=format.format|lower, anyname=entry.Books.id|string+'.'+format.format|lower) }}" id="btnGroupDrop{{entry.Books.id}}{{format.format|lower}}" class="btn btn-primary" role="button">
|
||||
<span class="glyphicon glyphicon-download"></span>{{format.format}} ({{ format.uncompressed_size|filesizeformat }})
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user