mirror of
https://github.com/janeczku/calibre-web
synced 2024-12-25 01:20:32 +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
|
# calibre-web
|
||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
cps/cache
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
*.bak
|
*.bak
|
||||||
|
29
README.md
29
README.md
@ -1,6 +1,6 @@
|
|||||||
# About
|
# 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 License](https://img.shields.io/github/license/janeczku/calibre-web?style=flat-square)](https://github.com/janeczku/calibre-web/blob/master/LICENSE)
|
||||||
[![GitHub commit activity](https://img.shields.io/github/commit-activity/w/janeczku/calibre-web?logo=github&style=flat-square&label=commits)]()
|
[![GitHub 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
|
- full graphical setup
|
||||||
- User management with fine-grained per-user permissions
|
- User management with fine-grained per-user permissions
|
||||||
- Admin interface
|
- Admin interface
|
||||||
- User Interface in 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
|
- OPDS feed for eBook reader apps
|
||||||
- Filter and search by titles, authors, tags, series and language
|
- Filter and search by titles, authors, tags, series and language
|
||||||
- Create a custom book collection (shelves)
|
- Create a custom book collection (shelves)
|
||||||
@ -40,23 +40,20 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
#### Installation via pip (recommended)
|
#### 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`).
|
1. To avoid problems with already installed python dependencies, it's recommended to create a virtual environment for Calibre-Web
|
||||||
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
|
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. Calibre-Web can be started afterwards by typing `cps` or `python3 -m cps`
|
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
|
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).
|
||||||
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.
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
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\
|
Login with default admin login \
|
||||||
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)
|
Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button \
|
||||||
Go to Login page
|
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:
|
#### Default admin login:
|
||||||
*Username:* admin\
|
*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 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
|
## 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
|
## History
|
||||||
|
|
||||||
| Fixed in | Description |CVE number |
|
| Fixed in | Description |CVE number |
|
||||||
| ---------- |---------|---------|
|
|---------------|--------------------------------------------------------------------------------------------------------------------|---------|
|
||||||
| 3rd July 2018 | Guest access acts as a backdoor||
|
| 3rd July 2018 | Guest access acts as a backdoor ||
|
||||||
| V 0.6.7 |Hardcoded secret key for sessions |CVE-2020-12627 |
|
| 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 | 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 | 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 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 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 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 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 description series, categories or publishers title ||
|
||||||
| V 0.6.13|JavaScript could get executed in the shelf 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.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 | 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||
|
| 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.
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# 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
|
# 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
|
# 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
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# 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 os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
# Insert local directories into path
|
# Add local path to sys.path so we can import cps
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
path = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor'))
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
from cps.main import main
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,8 +27,9 @@ from flask import session
|
|||||||
|
|
||||||
class MyLoginManager(LoginManager):
|
class MyLoginManager(LoginManager):
|
||||||
def _session_protection_failed(self):
|
def _session_protection_failed(self):
|
||||||
sess = session._get_current_object()
|
_session = session._get_current_object()
|
||||||
ident = self._session_identifier_generator()
|
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 super(). _session_protection_failed()
|
||||||
return False
|
return False
|
||||||
|
137
cps/__init__.py
137
cps/__init__.py
@ -25,24 +25,21 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
from babel import Locale as LC
|
from flask import Flask
|
||||||
from babel import negotiate_locale
|
|
||||||
from babel.core import UnknownLocaleError
|
|
||||||
from flask import Flask, request, g
|
|
||||||
from .MyLoginManager import MyLoginManager
|
from .MyLoginManager import MyLoginManager
|
||||||
from flask_babel import Babel
|
|
||||||
from flask_principal import Principal
|
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 .reverseproxy import ReverseProxied
|
||||||
from .server import WebServer
|
from .server import WebServer
|
||||||
from .dep_check import dependency_check
|
from .dep_check import dependency_check
|
||||||
|
from .updater import Updater
|
||||||
try:
|
from .babel import babel
|
||||||
import lxml
|
from . import config_sql
|
||||||
lxml_present = True
|
from . import cache_buster
|
||||||
except ImportError:
|
from . import ub, db
|
||||||
lxml_present = False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
@ -50,6 +47,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
wtf_present = False
|
wtf_present = False
|
||||||
|
|
||||||
|
|
||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
||||||
mimetypes.add_type('application/epub+zip', '.epub')
|
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/css', '.css')
|
||||||
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
@ -79,61 +79,72 @@ app.config.update(
|
|||||||
WTF_CSRF_SSL_STRICT=False
|
WTF_CSRF_SSL_STRICT=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
lm = MyLoginManager()
|
lm = MyLoginManager()
|
||||||
lm.login_view = 'web.login'
|
|
||||||
lm.anonymous_user = ub.Anonymous
|
config = config_sql._ConfigSQL()
|
||||||
lm.session_protection = 'strong'
|
|
||||||
|
cli_param = CliParameter()
|
||||||
|
|
||||||
if wtf_present:
|
if wtf_present:
|
||||||
csrf = CSRFProtect()
|
csrf = CSRFProtect()
|
||||||
csrf.init_app(app)
|
|
||||||
else:
|
else:
|
||||||
csrf = None
|
csrf = None
|
||||||
|
|
||||||
ub.init_db(cli.settingspath)
|
calibre_db = db.CalibreDB()
|
||||||
# pylint: disable=no-member
|
|
||||||
config = config_sql.load_configuration(ub.session)
|
|
||||||
|
|
||||||
web_server = WebServer()
|
web_server = WebServer()
|
||||||
|
|
||||||
babel = Babel()
|
updater_thread = Updater()
|
||||||
_BABEL_TRANSLATIONS = set()
|
|
||||||
|
|
||||||
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():
|
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):
|
if sys.version_info < (3, 0):
|
||||||
log.info(
|
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(
|
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)
|
web_server.stop(True)
|
||||||
sys.exit(5)
|
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:
|
if not wtf_present:
|
||||||
log.info('*** "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. '
|
||||||
print('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***')
|
'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)
|
web_server.stop(True)
|
||||||
sys.exit(7)
|
sys.exit(7)
|
||||||
for res in dependency_check() + dependency_check(True):
|
for res in dependency_check() + dependency_check(True):
|
||||||
log.info('*** "{}" version does not fit the requirements. Should: {}, Found: {}, please consider installing required version ***'
|
log.info('*** "{}" version does not fit the requirements. '
|
||||||
.format(res['name'],
|
'Should: {}, Found: {}, please consider installing required version ***'
|
||||||
res['target'],
|
.format(res['name'],
|
||||||
res['found']))
|
res['target'],
|
||||||
|
res['found']))
|
||||||
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||||
|
|
||||||
if os.environ.get('FLASK_DEBUG'):
|
if os.environ.get('FLASK_DEBUG'):
|
||||||
@ -147,8 +158,8 @@ def create_app():
|
|||||||
web_server.init_app(app, config)
|
web_server.init_app(app, config)
|
||||||
|
|
||||||
babel.init_app(app)
|
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:
|
if services.ldap:
|
||||||
services.ldap.init_app(app, config)
|
services.ldap.init_app(app, config)
|
||||||
@ -156,34 +167,12 @@ def create_app():
|
|||||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||||
config.config_goodreads_api_secret,
|
config.config_goodreads_api_secret,
|
||||||
config.config_use_goodreads)
|
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
|
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
|
import sqlite3
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
import babel, pytz, requests, sqlalchemy
|
import werkzeug
|
||||||
import werkzeug, flask, flask_login, flask_principal, jinja2
|
import flask
|
||||||
|
import flask_login
|
||||||
|
import jinja2
|
||||||
from flask_babel import gettext as _
|
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
|
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__)
|
about = flask.Blueprint('about', __name__)
|
||||||
|
|
||||||
@ -74,59 +43,38 @@ opt = dep_check.load_dependencys(True)
|
|||||||
for i in (req + opt):
|
for i in (req + opt):
|
||||||
ret[i[1]] = i[0]
|
ret[i[1]] = i[0]
|
||||||
|
|
||||||
if not ret:
|
if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
|
||||||
_VERSIONS = OrderedDict(
|
calibre_web_version = constants.STABLE_VERSION['version']
|
||||||
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))
|
|
||||||
else:
|
else:
|
||||||
_VERSIONS = OrderedDict(
|
calibre_web_version = (constants.STABLE_VERSION['version'] + ' - '
|
||||||
Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()),
|
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
||||||
Python = sys.version,
|
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
||||||
Calibre_Web = constants.STABLE_VERSION['version'] + ' - '
|
|
||||||
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
if getattr(sys, 'frozen', False):
|
||||||
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'),
|
calibre_web_version += " - Exe-Version"
|
||||||
Werkzeug = werkzeug.__version__,
|
elif constants.HOME_CONFIG:
|
||||||
Jinja2=jinja2.__version__,
|
calibre_web_version += " - pyPi"
|
||||||
pySqlite = sqlite3.version,
|
|
||||||
SQLite = sqlite3.sqlite_version,
|
_VERSIONS = OrderedDict(
|
||||||
)
|
Platform='{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()),
|
||||||
_VERSIONS.update(ret)
|
Python=sys.version,
|
||||||
_VERSIONS.update(uploader.get_versions(False))
|
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():
|
def collect_stats():
|
||||||
_VERSIONS['ebook converter'] = _(converter.get_calibre_version())
|
_VERSIONS['ebook converter'] = converter.get_calibre_version()
|
||||||
_VERSIONS['unrar'] = _(converter.get_unrar_version())
|
_VERSIONS['unrar'] = converter.get_unrar_version()
|
||||||
_VERSIONS['kepubify'] = _(converter.get_kepubify_version())
|
_VERSIONS['kepubify'] = converter.get_kepubify_version()
|
||||||
return _VERSIONS
|
return _VERSIONS
|
||||||
|
|
||||||
|
|
||||||
@about.route("/stats")
|
@about.route("/stats")
|
||||||
@flask_login.login_required
|
@flask_login.login_required
|
||||||
def stats():
|
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:
|
for filename in filenames:
|
||||||
# compute version component
|
# compute version component
|
||||||
rooted_filename = os.path.join(dirpath, filename)
|
rooted_filename = os.path.join(dirpath, filename)
|
||||||
with open(rooted_filename, 'rb') as f:
|
try:
|
||||||
file_hash = hashlib.md5(f.read()).hexdigest()[:7] # nosec
|
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')
|
log.debug('Finished computing cache-busting values')
|
||||||
|
|
||||||
def bust_filename(filename):
|
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 CONFIG_DIR as _CONFIG_DIR
|
||||||
from .constants import STABLE_VERSION as _STABLE_VERSION
|
from .constants import STABLE_VERSION as _STABLE_VERSION
|
||||||
from .constants import NIGHTLY_VERSION as _NIGHTLY_VERSION
|
from .constants import NIGHTLY_VERSION as _NIGHTLY_VERSION
|
||||||
|
from .constants import DEFAULT_SETTINGS_FILE, DEFAULT_GDRIVE_FILE
|
||||||
|
|
||||||
def version_info():
|
def version_info():
|
||||||
if _NIGHTLY_VERSION[1].startswith('$Format'):
|
if _NIGHTLY_VERSION[1].startswith('$Format'):
|
||||||
return "Calibre-Web version: %s - unkown git-clone" % _STABLE_VERSION['version']
|
return "Calibre-Web version: %s - unkown git-clone" % _STABLE_VERSION['version']
|
||||||
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1])
|
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'
|
def init(self):
|
||||||
' providing a interface for browsing, reading and downloading eBooks\n', prog='cps.py')
|
self.arg_parser()
|
||||||
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()
|
|
||||||
|
|
||||||
settingspath = args.p or os.path.join(_CONFIG_DIR, "app.db")
|
def arg_parser(self):
|
||||||
gdpath = args.g or os.path.join(_CONFIG_DIR, "gdrive.db")
|
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
|
self.settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE)
|
||||||
certfilepath = None
|
self.gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE)
|
||||||
keyfilepath = None
|
|
||||||
if args.c:
|
|
||||||
if os.path.isfile(args.c):
|
|
||||||
certfilepath = args.c
|
|
||||||
else:
|
|
||||||
print("Certfile path is invalid. Exiting...")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.c == "":
|
if os.path.isdir(self.settings_path):
|
||||||
certfilepath = ""
|
self.settings_path = os.path.join(self.settings_path, DEFAULT_SETTINGS_FILE)
|
||||||
|
|
||||||
if args.k:
|
if os.path.isdir(self.gd_path):
|
||||||
if os.path.isfile(args.k):
|
self.gd_path = os.path.join(self.gd_path, DEFAULT_GDRIVE_FILE)
|
||||||
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 == "":
|
# handle and check parameter for ssl encryption
|
||||||
keyfilepath = ""
|
self.certfilepath = None
|
||||||
|
self.keyfilepath = None
|
||||||
# handle and check ip address argument
|
if args.c:
|
||||||
ip_address = args.i or None
|
if os.path.isfile(args.c):
|
||||||
if ip_address:
|
self.certfilepath = args.c
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
socket.inet_pton(socket.AF_INET, ip_address)
|
print("Certfile path is invalid. Exiting...")
|
||||||
else:
|
sys.exit(1)
|
||||||
# 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)
|
|
||||||
|
|
||||||
# handle and check user password argument
|
if args.c == "":
|
||||||
user_credentials = args.s or None
|
self.certfilepath = ""
|
||||||
if user_credentials and ":" not in user_credentials:
|
|
||||||
print("No valid 'username:password' format")
|
|
||||||
sys.exit(3)
|
|
||||||
|
|
||||||
if args.f:
|
if args.k:
|
||||||
print("Warning: -f flag is depreciated and will be removed in next version")
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# 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
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
@ -18,19 +18,16 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from . import logger, isoLanguages
|
from . import logger, isoLanguages, cover
|
||||||
from .constants import BookMeta
|
from .constants import BookMeta
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from wand.image import Image
|
from wand.image import Image
|
||||||
use_IM = True
|
use_IM = True
|
||||||
except (ImportError, RuntimeError) as e:
|
except (ImportError, RuntimeError) as e:
|
||||||
use_IM = False
|
use_IM = False
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
||||||
@ -51,37 +48,16 @@ except (ImportError, LookupError) as e:
|
|||||||
use_rarfile = False
|
use_rarfile = False
|
||||||
use_comic_meta = 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):
|
def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable):
|
||||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_name), 'cover.jpg')
|
cover_data = extension = None
|
||||||
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
|
|
||||||
if original_file_extension.upper() == '.CBZ':
|
if original_file_extension.upper() == '.CBZ':
|
||||||
cf = zipfile.ZipFile(tmp_file_name)
|
cf = zipfile.ZipFile(tmp_file_name)
|
||||||
for name in cf.namelist():
|
for name in cf.namelist():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension in COVER_EXTENSIONS:
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
cover_data = cf.read(name)
|
cover_data = cf.read(name)
|
||||||
break
|
break
|
||||||
elif original_file_extension.upper() == '.CBT':
|
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)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension in COVER_EXTENSIONS:
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
cover_data = cf.extractfile(name).read()
|
cover_data = cf.extractfile(name).read()
|
||||||
break
|
break
|
||||||
elif original_file_extension.upper() == '.CBR' and use_rarfile:
|
elif original_file_extension.upper() == '.CBR' and use_rarfile:
|
||||||
try:
|
try:
|
||||||
rarfile.UNRAR_TOOL = rarExecutable
|
rarfile.UNRAR_TOOL = rar_executable
|
||||||
cf = rarfile.RarFile(tmp_file_name)
|
cf = rarfile.RarFile(tmp_file_name)
|
||||||
for name in cf.getnames():
|
for name in cf.namelist():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension in COVER_EXTENSIONS:
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
cover_data = cf.read(name)
|
cover_data = cf.read(name)
|
||||||
break
|
break
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.debug('Rarfile failed with error: %s', ex)
|
log.debug('Rarfile failed with error: {}'.format(ex))
|
||||||
return cover_data
|
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
|
cover_data = extension = None
|
||||||
if use_comic_meta:
|
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()):
|
for index, name in enumerate(archive.getPageNameList()):
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension in COVER_EXTENSIONS:
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
cover_data = archive.getPage(index)
|
cover_data = archive.getPage(index)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
cover_data = _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecutable)
|
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable)
|
||||||
return _cover_processing(tmp_file_name, cover_data, extension)
|
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:
|
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.seemsToBeAComicArchive():
|
||||||
if archive.hasMetadata(MetaDataStyle.CIX):
|
if archive.hasMetadata(MetaDataStyle.CIX):
|
||||||
style = 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
|
style = None
|
||||||
|
|
||||||
# if style is not None:
|
# if style is not None:
|
||||||
loadedMetadata = archive.readMetadata(style)
|
loaded_metadata = archive.readMetadata(style)
|
||||||
|
|
||||||
lang = loadedMetadata.language or ""
|
lang = loaded_metadata.language or ""
|
||||||
loadedMetadata.language = isoLanguages.get_lang3(lang)
|
loaded_metadata.language = isoLanguages.get_lang3(lang)
|
||||||
|
|
||||||
return BookMeta(
|
return BookMeta(
|
||||||
file_path=tmp_file_path,
|
file_path=tmp_file_path,
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=loadedMetadata.title or original_file_name,
|
title=loaded_metadata.title or original_file_name,
|
||||||
author=" & ".join([credit["person"]
|
author=" & ".join([credit["person"]
|
||||||
for credit in loadedMetadata.credits if credit["role"] == "Writer"]) or u'Unknown',
|
for credit in loaded_metadata.credits if credit["role"] == "Writer"]) or 'Unknown',
|
||||||
cover=_extractCover(tmp_file_path, original_file_extension, rarExecutable),
|
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||||
description=loadedMetadata.comments or "",
|
description=loaded_metadata.comments or "",
|
||||||
tags="",
|
tags="",
|
||||||
series=loadedMetadata.series or "",
|
series=loaded_metadata.series or "",
|
||||||
series_id=loadedMetadata.issue or "",
|
series_id=loaded_metadata.issue or "",
|
||||||
languages=loadedMetadata.language,
|
languages=loaded_metadata.language,
|
||||||
publisher="")
|
publisher="",
|
||||||
|
pubdate="",
|
||||||
|
identifiers=[])
|
||||||
|
|
||||||
return BookMeta(
|
return BookMeta(
|
||||||
file_path=tmp_file_path,
|
file_path=tmp_file_path,
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=original_file_name,
|
title=original_file_name,
|
||||||
author=u'Unknown',
|
author=u'Unknown',
|
||||||
cover=_extractCover(tmp_file_path, original_file_extension, rarExecutable),
|
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||||
description="",
|
description="",
|
||||||
tags="",
|
tags="",
|
||||||
series="",
|
series="",
|
||||||
series_id="",
|
series_id="",
|
||||||
languages="",
|
languages="",
|
||||||
publisher="")
|
publisher="",
|
||||||
|
pubdate="",
|
||||||
|
identifiers=[])
|
||||||
|
@ -29,7 +29,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
from . import constants, cli, logger
|
from . import constants, logger
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@ -62,6 +62,7 @@ class _Settings(_Base):
|
|||||||
mail_gmail_token = Column(JSON, default={})
|
mail_gmail_token = Column(JSON, default={})
|
||||||
|
|
||||||
config_calibre_dir = Column(String)
|
config_calibre_dir = Column(String)
|
||||||
|
config_calibre_uuid = Column(String)
|
||||||
config_port = Column(Integer, default=constants.DEFAULT_PORT)
|
config_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||||
config_external_port = Column(Integer, default=constants.DEFAULT_PORT)
|
config_external_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||||
config_certfile = Column(String)
|
config_certfile = Column(String)
|
||||||
@ -133,13 +134,19 @@ class _Settings(_Base):
|
|||||||
config_calibre = Column(String)
|
config_calibre = Column(String)
|
||||||
config_rarfile_location = Column(String, default=None)
|
config_rarfile_location = Column(String, default=None)
|
||||||
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
||||||
config_unicode_filename =Column(Boolean, default=False)
|
config_unicode_filename = Column(Boolean, default=False)
|
||||||
|
|
||||||
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
||||||
|
|
||||||
config_reverse_proxy_login_header_name = Column(String)
|
config_reverse_proxy_login_header_name = Column(String)
|
||||||
config_allow_reverse_proxy_header_login = Column(Boolean, default=False)
|
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):
|
def __repr__(self):
|
||||||
return self.__class__.__name__
|
return self.__class__.__name__
|
||||||
|
|
||||||
@ -147,12 +154,16 @@ class _Settings(_Base):
|
|||||||
# Class holds all application specific settings in calibre-web
|
# Class holds all application specific settings in calibre-web
|
||||||
class _ConfigSQL(object):
|
class _ConfigSQL(object):
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
def __init__(self, session):
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def init_config(self, session, cli):
|
||||||
self._session = session
|
self._session = session
|
||||||
self._settings = None
|
self._settings = None
|
||||||
self.db_configured = None
|
self.db_configured = None
|
||||||
self.config_calibre_dir = None
|
self.config_calibre_dir = None
|
||||||
self.load()
|
self.load()
|
||||||
|
self.cli = cli
|
||||||
|
|
||||||
change = False
|
change = False
|
||||||
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
||||||
@ -170,7 +181,6 @@ class _ConfigSQL(object):
|
|||||||
if change:
|
if change:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
def _read_from_storage(self):
|
def _read_from_storage(self):
|
||||||
if self._settings is None:
|
if self._settings is None:
|
||||||
log.debug("_ConfigSQL._read_from_storage")
|
log.debug("_ConfigSQL._read_from_storage")
|
||||||
@ -178,22 +188,21 @@ class _ConfigSQL(object):
|
|||||||
return self._settings
|
return self._settings
|
||||||
|
|
||||||
def get_config_certfile(self):
|
def get_config_certfile(self):
|
||||||
if cli.certfilepath:
|
if self.cli.certfilepath:
|
||||||
return cli.certfilepath
|
return self.cli.certfilepath
|
||||||
if cli.certfilepath == "":
|
if self.cli.certfilepath == "":
|
||||||
return None
|
return None
|
||||||
return self.config_certfile
|
return self.config_certfile
|
||||||
|
|
||||||
def get_config_keyfile(self):
|
def get_config_keyfile(self):
|
||||||
if cli.keyfilepath:
|
if self.cli.keyfilepath:
|
||||||
return cli.keyfilepath
|
return self.cli.keyfilepath
|
||||||
if cli.certfilepath == "":
|
if self.cli.certfilepath == "":
|
||||||
return None
|
return None
|
||||||
return self.config_keyfile
|
return self.config_keyfile
|
||||||
|
|
||||||
@staticmethod
|
def get_config_ipaddress(self):
|
||||||
def get_config_ipaddress():
|
return self.cli.ip_address or ""
|
||||||
return cli.ip_address or ""
|
|
||||||
|
|
||||||
def _has_role(self, role_flag):
|
def _has_role(self, role_flag):
|
||||||
return constants.has_flag(self.config_default_role, role_flag)
|
return constants.has_flag(self.config_default_role, role_flag)
|
||||||
@ -254,6 +263,8 @@ class _ConfigSQL(object):
|
|||||||
return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0)
|
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))
|
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):
|
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
|
||||||
"""Possibly updates a field of this object.
|
"""Possibly updates a field of this object.
|
||||||
@ -285,11 +296,10 @@ class _ConfigSQL(object):
|
|||||||
def toDict(self):
|
def toDict(self):
|
||||||
storage = {}
|
storage = {}
|
||||||
for k, v in self.__dict__.items():
|
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
|
storage[k] = v
|
||||||
return storage
|
return storage
|
||||||
|
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
'''Load all configuration values from the underlying storage.'''
|
'''Load all configuration values from the underlying storage.'''
|
||||||
s = self._read_from_storage() # type: _Settings
|
s = self._read_from_storage() # type: _Settings
|
||||||
@ -304,9 +314,8 @@ class _ConfigSQL(object):
|
|||||||
|
|
||||||
have_metadata_db = bool(self.config_calibre_dir)
|
have_metadata_db = bool(self.config_calibre_dir)
|
||||||
if have_metadata_db:
|
if have_metadata_db:
|
||||||
if not self.config_use_google_drive:
|
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
||||||
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
have_metadata_db = os.path.isfile(db_file)
|
||||||
have_metadata_db = os.path.isfile(db_file)
|
|
||||||
self.db_configured = have_metadata_db
|
self.db_configured = have_metadata_db
|
||||||
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
||||||
if os.environ.get('FLASK_DEBUG'):
|
if os.environ.get('FLASK_DEBUG'):
|
||||||
@ -351,6 +360,14 @@ class _ConfigSQL(object):
|
|||||||
# self.config_calibre_dir = None
|
# self.config_calibre_dir = None
|
||||||
self.save()
|
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):
|
def _migrate_table(session, orm_class):
|
||||||
changed = False
|
changed = False
|
||||||
@ -403,6 +420,7 @@ def autodetect_calibre_binary():
|
|||||||
return element
|
return element
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def autodetect_unrar_binary():
|
def autodetect_unrar_binary():
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
||||||
@ -414,6 +432,7 @@ def autodetect_unrar_binary():
|
|||||||
return element
|
return element
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def autodetect_kepubify_binary():
|
def autodetect_kepubify_binary():
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
||||||
@ -425,6 +444,7 @@ def autodetect_kepubify_binary():
|
|||||||
return element
|
return element
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _migrate_database(session):
|
def _migrate_database(session):
|
||||||
# make sure the table is created, if it does not exist
|
# make sure the table is created, if it does not exist
|
||||||
_Base.metadata.create_all(session.bind)
|
_Base.metadata.create_all(session.bind)
|
||||||
@ -432,26 +452,20 @@ def _migrate_database(session):
|
|||||||
_migrate_table(session, _Flask_Settings)
|
_migrate_table(session, _Flask_Settings)
|
||||||
|
|
||||||
|
|
||||||
def load_configuration(session):
|
def load_configuration(conf, session, cli):
|
||||||
_migrate_database(session)
|
_migrate_database(session)
|
||||||
|
|
||||||
if not session.query(_Settings).count():
|
if not session.query(_Settings).count():
|
||||||
session.add(_Settings())
|
session.add(_Settings())
|
||||||
session.commit()
|
session.commit()
|
||||||
conf = _ConfigSQL(session)
|
# conf = _ConfigSQL()
|
||||||
# Migrate from global restrictions to user based restrictions
|
conf.init_config(session, cli)
|
||||||
#if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
|
# return conf
|
||||||
# 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
|
|
||||||
|
|
||||||
def get_flask_session_key(session):
|
def get_flask_session_key(_session):
|
||||||
flask_settings = session.query(_Flask_Settings).one_or_none()
|
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
||||||
if flask_settings == None:
|
if flask_settings == None:
|
||||||
flask_settings = _Flask_Settings(os.urandom(32))
|
flask_settings = _Flask_Settings(os.urandom(32))
|
||||||
session.add(flask_settings)
|
_session.add(flask_settings)
|
||||||
session.commit()
|
_session.commit()
|
||||||
return flask_settings.flask_session_key
|
return flask_settings.flask_session_key
|
||||||
|
@ -21,28 +21,37 @@ import os
|
|||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from sqlalchemy import __version__ as sql_version
|
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)
|
# if installed via pip this variable is set to true (empty file with name .HOMEDIR present)
|
||||||
HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR'))
|
HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR'))
|
||||||
|
|
||||||
#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
|
UPDATER_AVAILABLE = True
|
||||||
|
|
||||||
# Base dir is parent of current file, necessary if called from different folder
|
# 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')
|
STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
|
||||||
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
||||||
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
|
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:
|
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):
|
if not os.path.exists(home_dir):
|
||||||
os.makedirs(home_dir)
|
os.makedirs(home_dir)
|
||||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
|
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
|
||||||
else:
|
else:
|
||||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
|
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS_FILE = "app.db"
|
||||||
|
DEFAULT_GDRIVE_FILE = "gdrive.db"
|
||||||
|
|
||||||
ROLE_USER = 0 << 0
|
ROLE_USER = 0 << 0
|
||||||
ROLE_ADMIN = 1 << 0
|
ROLE_ADMIN = 1 << 0
|
||||||
@ -133,11 +142,14 @@ except ValueError:
|
|||||||
del env_CALIBRE_PORT
|
del env_CALIBRE_PORT
|
||||||
|
|
||||||
|
|
||||||
EXTENSIONS_AUDIO = {'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_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf',
|
||||||
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
|
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr']
|
||||||
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx',
|
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2',
|
||||||
'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
'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):
|
def has_flag(value, bit_flag):
|
||||||
@ -149,16 +161,29 @@ def selected_roles(dictionary):
|
|||||||
|
|
||||||
# :rtype: BookMeta
|
# :rtype: BookMeta
|
||||||
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
|
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
|
||||||
'series_id, languages, publisher')
|
'series_id, languages, publisher, 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[0] = '$Format:%H$'
|
||||||
NIGHTLY_VERSION[1] = '$Format:%cI$'
|
NIGHTLY_VERSION[1] = '$Format:%cI$'
|
||||||
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
|
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
|
||||||
# NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00'
|
# 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
|
# clean-up the module namespace
|
||||||
del sys, os, namedtuple
|
del sys, os, namedtuple
|
||||||
|
@ -18,7 +18,8 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from flask_babel import gettext as _
|
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from . import config, logger
|
from . import config, logger
|
||||||
from .subproc_wrapper import process_wait
|
from .subproc_wrapper import process_wait
|
||||||
@ -26,10 +27,9 @@ from .subproc_wrapper import process_wait
|
|||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
# _() necessary to make babel aware of string for translation
|
# strings getting translated when used
|
||||||
_NOT_CONFIGURED = _('not configured')
|
_NOT_INSTALLED = N_('not installed')
|
||||||
_NOT_INSTALLED = _('not installed')
|
_EXECUTION_ERROR = N_('Execution permissions missing')
|
||||||
_EXECUTION_ERROR = _('Execution permissions missing')
|
|
||||||
|
|
||||||
|
|
||||||
def _get_command_version(path, pattern, argument=None):
|
def _get_command_version(path, pattern, argument=None):
|
||||||
@ -48,14 +48,16 @@ def _get_command_version(path, pattern, argument=None):
|
|||||||
|
|
||||||
|
|
||||||
def get_calibre_version():
|
def get_calibre_version():
|
||||||
return _get_command_version(config.config_converterpath, r'ebook-convert.*\(calibre', '--version') \
|
return _get_command_version(config.config_converterpath, r'ebook-convert.*\(calibre', '--version')
|
||||||
or _NOT_CONFIGURED
|
|
||||||
|
|
||||||
|
|
||||||
def get_unrar_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():
|
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
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
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 create_engine
|
||||||
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
|
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
|
||||||
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
|
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 sqlalchemy.ext.associationproxy import association_proxy
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
from flask_babel import get_locale
|
||||||
from flask import flash
|
from flask import flash
|
||||||
|
|
||||||
from . import logger, ub, isoLanguages
|
from . import logger, ub, isoLanguages
|
||||||
@ -48,11 +51,6 @@ from .pagination import Pagination
|
|||||||
|
|
||||||
from weakref import WeakSet
|
from weakref import WeakSet
|
||||||
|
|
||||||
try:
|
|
||||||
import unidecode
|
|
||||||
use_unidecode = True
|
|
||||||
except ImportError:
|
|
||||||
use_unidecode = False
|
|
||||||
|
|
||||||
log = logger.create()
|
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):
|
class Identifiers(Base):
|
||||||
__tablename__ = 'identifiers'
|
__tablename__ = 'identifiers'
|
||||||
|
|
||||||
@ -105,7 +109,7 @@ class Identifiers(Base):
|
|||||||
self.type = id_type
|
self.type = id_type
|
||||||
self.book = book
|
self.book = book
|
||||||
|
|
||||||
def formatType(self):
|
def format_type(self):
|
||||||
format_type = self.type.lower()
|
format_type = self.type.lower()
|
||||||
if format_type == 'amazon':
|
if format_type == 'amazon':
|
||||||
return u"Amazon"
|
return u"Amazon"
|
||||||
@ -164,6 +168,8 @@ class Identifiers(Base):
|
|||||||
return u"https://portal.issn.org/resource/ISSN/{0}".format(self.val)
|
return u"https://portal.issn.org/resource/ISSN/{0}".format(self.val)
|
||||||
elif format_type == "isfdb":
|
elif format_type == "isfdb":
|
||||||
return u"http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
|
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:
|
else:
|
||||||
return u"{0}".format(self.val)
|
return u"{0}".format(self.val)
|
||||||
|
|
||||||
@ -172,11 +178,11 @@ class Comments(Base):
|
|||||||
__tablename__ = 'comments'
|
__tablename__ = 'comments'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
book = Column(Integer, ForeignKey('books.id'), nullable=False, unique=True)
|
||||||
text = Column(String(collation='NOCASE'), nullable=False)
|
text = Column(String(collation='NOCASE'), nullable=False)
|
||||||
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
|
||||||
|
|
||||||
def __init__(self, text, book):
|
def __init__(self, comment, book):
|
||||||
self.text = text
|
self.text = comment
|
||||||
self.book = book
|
self.book = book
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
@ -358,7 +364,6 @@ class Books(Base):
|
|||||||
self.path = path
|
self.path = path
|
||||||
self.has_cover = (has_cover != None)
|
self.has_cover = (has_cover != None)
|
||||||
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
|
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,
|
self.timestamp, self.pubdate, self.series_index,
|
||||||
@ -366,10 +371,10 @@ class Books(Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def atom_timestamp(self):
|
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'
|
__tablename__ = 'custom_columns'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
@ -427,7 +432,7 @@ class AlchemyEncoder(json.JSONEncoder):
|
|||||||
return json.JSONEncoder.default(self, o)
|
return json.JSONEncoder.default(self, o)
|
||||||
|
|
||||||
|
|
||||||
class CalibreDB():
|
class CalibreDB:
|
||||||
_init = False
|
_init = False
|
||||||
engine = None
|
engine = None
|
||||||
config = None
|
config = None
|
||||||
@ -436,22 +441,27 @@ class CalibreDB():
|
|||||||
# instances alive once they reach the end of their respective scopes
|
# instances alive once they reach the end of their respective scopes
|
||||||
instances = WeakSet()
|
instances = WeakSet()
|
||||||
|
|
||||||
def __init__(self, expire_on_commit=True):
|
def __init__(self, expire_on_commit=True, init=False):
|
||||||
""" Initialize a new CalibreDB session
|
""" Initialize a new CalibreDB session
|
||||||
"""
|
"""
|
||||||
self.session = None
|
self.session = None
|
||||||
|
if init:
|
||||||
|
self.init_db(expire_on_commit)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(self, expire_on_commit=True):
|
||||||
if self._init:
|
if self._init:
|
||||||
self.initSession(expire_on_commit)
|
self.init_session(expire_on_commit)
|
||||||
|
|
||||||
self.instances.add(self)
|
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 = self.session_factory()
|
||||||
self.session.expire_on_commit = expire_on_commit
|
self.session.expire_on_commit = expire_on_commit
|
||||||
self.update_title_sort(self.config)
|
self.update_title_sort(self.config)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setup_db_cc_classes(self, cc):
|
def setup_db_cc_classes(cls, cc):
|
||||||
cc_ids = []
|
cc_ids = []
|
||||||
books_custom_column_links = {}
|
books_custom_column_links = {}
|
||||||
for row in cc:
|
for row in cc:
|
||||||
@ -522,25 +532,31 @@ class CalibreDB():
|
|||||||
return cc_classes
|
return cc_classes
|
||||||
|
|
||||||
@classmethod
|
@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:
|
if not config_calibre_dir:
|
||||||
return False
|
return False, False
|
||||||
dbpath = os.path.join(config_calibre_dir, "metadata.db")
|
dbpath = os.path.join(config_calibre_dir, "metadata.db")
|
||||||
if not os.path.exists(dbpath):
|
if not os.path.exists(dbpath):
|
||||||
return False
|
return False, False
|
||||||
try:
|
try:
|
||||||
check_engine = create_engine('sqlite://',
|
check_engine = create_engine('sqlite://',
|
||||||
echo=False,
|
echo=False,
|
||||||
isolation_level="SERIALIZABLE",
|
isolation_level="SERIALIZABLE",
|
||||||
connect_args={'check_same_thread': False},
|
connect_args={'check_same_thread': False},
|
||||||
poolclass=StaticPool)
|
poolclass=StaticPool)
|
||||||
with check_engine.begin() as connection:
|
with check_engine.begin() as connection:
|
||||||
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
|
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
|
||||||
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
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()
|
check_engine.connect()
|
||||||
|
db_change = config_calibre_uuid != database_uuid.uuid
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False, False
|
||||||
return True
|
return True, db_change
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_config(cls, config):
|
def update_config(cls, config):
|
||||||
@ -582,13 +598,14 @@ class CalibreDB():
|
|||||||
cc = conn.execute(text("SELECT id, datatype FROM custom_columns"))
|
cc = conn.execute(text("SELECT id, datatype FROM custom_columns"))
|
||||||
cls.setup_db_cc_classes(cc)
|
cls.setup_db_cc_classes(cc)
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
log.debug_or_exception(e)
|
log.error_or_exception(e)
|
||||||
|
return False
|
||||||
|
|
||||||
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||||
autoflush=True,
|
autoflush=True,
|
||||||
bind=cls.engine))
|
bind=cls.engine))
|
||||||
for inst in cls.instances:
|
for inst in cls.instances:
|
||||||
inst.initSession()
|
inst.init_session()
|
||||||
|
|
||||||
cls._init = True
|
cls._init = True
|
||||||
return True
|
return True
|
||||||
@ -611,8 +628,8 @@ class CalibreDB():
|
|||||||
bd = (self.session.query(Books, read_column.value, ub.ArchivedBook.is_archived).select_from(Books)
|
bd = (self.session.query(Books, read_column.value, ub.ArchivedBook.is_archived).select_from(Books)
|
||||||
.join(read_column, read_column.book == book_id,
|
.join(read_column, read_column.book == book_id,
|
||||||
isouter=True))
|
isouter=True))
|
||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError, IndexError):
|
||||||
log.error("Custom Column No.%d is not existing in calibre database", read_column)
|
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
|
# Skip linking read column and return None instead of read status
|
||||||
bd = self.session.query(Books, None, ub.ArchivedBook.is_archived)
|
bd = self.session.query(Books, None, ub.ArchivedBook.is_archived)
|
||||||
return (bd.filter(Books.id == book_id)
|
return (bd.filter(Books.id == book_id)
|
||||||
@ -629,12 +646,10 @@ class CalibreDB():
|
|||||||
# Language and content filters for displaying in the UI
|
# Language and content filters for displaying in the UI
|
||||||
def common_filters(self, allow_show_archived=False, return_all_languages=False):
|
def common_filters(self, allow_show_archived=False, return_all_languages=False):
|
||||||
if not allow_show_archived:
|
if not allow_show_archived:
|
||||||
archived_books = (
|
archived_books = (ub.session.query(ub.ArchivedBook)
|
||||||
ub.session.query(ub.ArchivedBook)
|
.filter(ub.ArchivedBook.user_id == int(current_user.id))
|
||||||
.filter(ub.ArchivedBook.user_id == int(current_user.id))
|
.filter(ub.ArchivedBook.is_archived == True)
|
||||||
.filter(ub.ArchivedBook.is_archived == True)
|
.all())
|
||||||
.all()
|
|
||||||
)
|
|
||||||
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
|
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
|
||||||
archived_filter = Books.id.notin_(archived_book_ids)
|
archived_filter = Books.id.notin_(archived_book_ids)
|
||||||
else:
|
else:
|
||||||
@ -653,16 +668,16 @@ class CalibreDB():
|
|||||||
pos_cc_list = current_user.allowed_column_value.split(',')
|
pos_cc_list = current_user.allowed_column_value.split(',')
|
||||||
pos_content_cc_filter = true() if pos_cc_list == [''] else \
|
pos_content_cc_filter = true() if pos_cc_list == [''] else \
|
||||||
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
|
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
|
||||||
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_cc_list = current_user.denied_column_value.split(',')
|
||||||
neg_content_cc_filter = false() if neg_cc_list == [''] else \
|
neg_content_cc_filter = false() if neg_cc_list == [''] else \
|
||||||
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
|
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
|
||||||
any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list))
|
any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list))
|
||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError, IndexError):
|
||||||
pos_content_cc_filter = false()
|
pos_content_cc_filter = false()
|
||||||
neg_content_cc_filter = true()
|
neg_content_cc_filter = true()
|
||||||
log.error(u"Custom Column No.%d is not existing in calibre database",
|
log.error("Custom Column No.{} is not existing in calibre database".format(
|
||||||
self.config.config_restricted_column)
|
self.config.config_restricted_column))
|
||||||
flash(_("Custom Column No.%(column)d is not existing in calibre database",
|
flash(_("Custom Column No.%(column)d is not existing in calibre database",
|
||||||
column=self.config.config_restricted_column),
|
column=self.config.config_restricted_column),
|
||||||
category="error")
|
category="error")
|
||||||
@ -673,6 +688,25 @@ class CalibreDB():
|
|||||||
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
|
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
|
||||||
pos_content_cc_filter, ~neg_content_cc_filter, archived_filter)
|
pos_content_cc_filter, ~neg_content_cc_filter, archived_filter)
|
||||||
|
|
||||||
|
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
|
@staticmethod
|
||||||
def get_checkbox_sorted(inputlist, state, offset, limit, order, combo=False):
|
def get_checkbox_sorted(inputlist, state, offset, limit, order, combo=False):
|
||||||
outcome = list()
|
outcome = list()
|
||||||
@ -702,30 +736,14 @@ class CalibreDB():
|
|||||||
join_archive_read, config_read_column, *join):
|
join_archive_read, config_read_column, *join):
|
||||||
pagesize = pagesize or self.config.config_books_per_page
|
pagesize = pagesize or self.config.config_books_per_page
|
||||||
if current_user.show_detail_random():
|
if current_user.show_detail_random():
|
||||||
randm = self.session.query(Books) \
|
random_query = self.generate_linked_query(config_read_column, database)
|
||||||
.filter(self.common_filters(allow_show_archived)) \
|
randm = (random_query.filter(self.common_filters(allow_show_archived))
|
||||||
.order_by(func.random()) \
|
.order_by(func.random())
|
||||||
.limit(self.config.config_random_books).all()
|
.limit(self.config.config_random_books).all())
|
||||||
else:
|
else:
|
||||||
randm = false()
|
randm = false()
|
||||||
if join_archive_read:
|
if join_archive_read:
|
||||||
if not config_read_column:
|
query = self.generate_linked_query(config_read_column, database)
|
||||||
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))
|
|
||||||
else:
|
else:
|
||||||
query = self.session.query(database)
|
query = self.session.query(database)
|
||||||
off = int(int(pagesize) * (page - 1))
|
off = int(int(pagesize) * (page - 1))
|
||||||
@ -754,13 +772,15 @@ class CalibreDB():
|
|||||||
len(query.all()))
|
len(query.all()))
|
||||||
entries = query.order_by(*order).offset(off).limit(pagesize).all()
|
entries = query.order_by(*order).offset(off).limit(pagesize).all()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.debug_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
# display authors in right order
|
# display authors in right order
|
||||||
entries = self.order_authors(entries, True, join_archive_read)
|
entries = self.order_authors(entries, True, join_archive_read)
|
||||||
return entries, randm, pagination
|
return entries, randm, pagination
|
||||||
|
|
||||||
# Orders all Authors in the list according to authors sort
|
# Orders all Authors in the list according to authors sort
|
||||||
def order_authors(self, entries, list_return=False, combined=False):
|
def order_authors(self, entries, list_return=False, combined=False):
|
||||||
|
# entries_copy = copy.deepcopy(entries)
|
||||||
|
# entries_copy =[]
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if combined:
|
if combined:
|
||||||
sort_authors = entry.Books.author_sort.split('&')
|
sort_authors = entry.Books.author_sort.split('&')
|
||||||
@ -770,25 +790,30 @@ class CalibreDB():
|
|||||||
sort_authors = entry.author_sort.split('&')
|
sort_authors = entry.author_sort.split('&')
|
||||||
ids = [a.id for a in entry.authors]
|
ids = [a.id for a in entry.authors]
|
||||||
authors_ordered = list()
|
authors_ordered = list()
|
||||||
error = False
|
# error = False
|
||||||
for auth in sort_authors:
|
for auth in sort_authors:
|
||||||
results = self.session.query(Authors).filter(Authors.sort == auth.lstrip().strip()).all()
|
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):
|
if not len(results):
|
||||||
error = True
|
log.error("Author {} not found to display name in right order".format(auth.strip()))
|
||||||
|
# error = True
|
||||||
break
|
break
|
||||||
for r in results:
|
for r in results:
|
||||||
if r.id in ids:
|
if r.id in ids:
|
||||||
authors_ordered.append(r)
|
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:
|
if combined:
|
||||||
entry.Books.authors = authors_ordered
|
entry.Books.authors = authors_ordered
|
||||||
else:
|
else:
|
||||||
entry.authors = authors_ordered
|
entry.ordered_authors = authors_ordered
|
||||||
if list_return:
|
else:
|
||||||
return entries
|
return authors_ordered
|
||||||
else:
|
return entries
|
||||||
return authors_ordered
|
|
||||||
|
|
||||||
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
|
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
|
||||||
query = query or ''
|
query = query or ''
|
||||||
@ -802,36 +827,21 @@ class CalibreDB():
|
|||||||
def check_exists_book(self, authr, title):
|
def check_exists_book(self, authr, title):
|
||||||
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||||
q = list()
|
q = list()
|
||||||
authorterms = re.split(r'\s*&\s*', authr)
|
author_terms = re.split(r'\s*&\s*', authr)
|
||||||
for authorterm in authorterms:
|
for author_term in author_terms:
|
||||||
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%")))
|
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + author_term + "%")))
|
||||||
|
|
||||||
return self.session.query(Books) \
|
return self.session.query(Books) \
|
||||||
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
|
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
|
||||||
|
|
||||||
def search_query(self, term, config_read_column, *join):
|
def search_query(self, term, config, *join):
|
||||||
term.strip().lower()
|
term.strip().lower()
|
||||||
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||||
q = list()
|
q = list()
|
||||||
authorterms = re.split("[, ]+", term)
|
author_terms = re.split("[, ]+", term)
|
||||||
for authorterm in authorterms:
|
for author_term in author_terms:
|
||||||
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%")))
|
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + author_term + "%")))
|
||||||
if not config_read_column:
|
query = self.generate_linked_query(config.config_read_column, Books)
|
||||||
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))
|
|
||||||
|
|
||||||
if len(join) == 6:
|
if len(join) == 6:
|
||||||
query = query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4]).outerjoin(join[5])
|
query = query.outerjoin(join[0], join[1]).outerjoin(join[2]).outerjoin(join[3], join[4]).outerjoin(join[5])
|
||||||
if len(join) == 3:
|
if len(join) == 3:
|
||||||
@ -840,20 +850,42 @@ class CalibreDB():
|
|||||||
query = query.outerjoin(join[0], join[1])
|
query = query.outerjoin(join[0], join[1])
|
||||||
elif len(join) == 1:
|
elif len(join) == 1:
|
||||||
query = query.outerjoin(join[0])
|
query = query.outerjoin(join[0])
|
||||||
return query.filter(self.common_filters(True)).filter(
|
|
||||||
or_(Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")),
|
cc = self.get_cc_columns(config, filter_config_custom_read=True)
|
||||||
Books.series.any(func.lower(Series.name).ilike("%" + term + "%")),
|
filter_expression = [Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")),
|
||||||
Books.authors.any(and_(*q)),
|
Books.series.any(func.lower(Series.name).ilike("%" + term + "%")),
|
||||||
Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")),
|
Books.authors.any(and_(*q)),
|
||||||
func.lower(Books.title).ilike("%" + term + "%")
|
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
|
# 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,
|
def get_search_results(self, term, config, offset=None, order=None, limit=None, *join):
|
||||||
config_read_column=False, *join):
|
|
||||||
order = order[0] if order else [Books.sort]
|
order = order[0] if order else [Books.sort]
|
||||||
pagination = None
|
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)
|
result_count = len(result)
|
||||||
if offset != None and limit != None:
|
if offset != None and limit != None:
|
||||||
offset = int(offset)
|
offset = int(offset)
|
||||||
@ -870,28 +902,38 @@ class CalibreDB():
|
|||||||
|
|
||||||
# Creates for all stored languages a translated speaking name in the array for the UI
|
# 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):
|
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'))\
|
languages = self.session.query(Languages, func.count('books_languages_link.book'))\
|
||||||
.join(books_languages_link).join(Books)\
|
.join(books_languages_link).join(Books)\
|
||||||
.filter(self.common_filters(return_all_languages=return_all_languages)) \
|
.filter(self.common_filters(return_all_languages=return_all_languages)) \
|
||||||
.group_by(text('books_languages_link.lang_code')).all()
|
.group_by(text('books_languages_link.lang_code')).all()
|
||||||
for lang in languages:
|
tags = list()
|
||||||
lang[0].name = isoLanguages.get_language_name(get_locale(), lang[0].lang_code)
|
for lang in languages:
|
||||||
return sorted(languages, key=lambda x: x[0].name, reverse=reverse_order)
|
tag = Category(isoLanguages.get_language_name(get_locale(), lang[0].lang_code), lang[0].lang_code)
|
||||||
else:
|
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) \
|
languages = self.session.query(Languages) \
|
||||||
.join(books_languages_link) \
|
.join(books_languages_link) \
|
||||||
.join(Books) \
|
.join(Books) \
|
||||||
.filter(self.common_filters(return_all_languages=return_all_languages)) \
|
.filter(self.common_filters(return_all_languages=return_all_languages)) \
|
||||||
.group_by(text('books_languages_link.lang_code')).all()
|
.group_by(text('books_languages_link.lang_code')).all()
|
||||||
for lang in languages:
|
for lang in languages:
|
||||||
lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
|
lang.name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
|
||||||
return sorted(languages, key=lambda x: x.name, reverse=reverse_order)
|
return sorted(languages, key=lambda x: x.name, reverse=reverse_order)
|
||||||
|
|
||||||
|
|
||||||
def update_title_sort(self, config, conn=None):
|
def update_title_sort(self, config, conn=None):
|
||||||
# user defined sort function for calibre databases (Series, etc.)
|
# user defined sort function for calibre databases (Series, etc.)
|
||||||
def _title_sort(title):
|
def _title_sort(title):
|
||||||
@ -904,7 +946,10 @@ class CalibreDB():
|
|||||||
return title.strip()
|
return title.strip()
|
||||||
|
|
||||||
conn = conn or self.session.connection().connection.connection
|
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
|
@classmethod
|
||||||
def dispose(cls):
|
def dispose(cls):
|
||||||
@ -949,6 +994,25 @@ def lcase(s):
|
|||||||
try:
|
try:
|
||||||
return unidecode.unidecode(s.lower())
|
return unidecode.unidecode(s.lower())
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log = logger.create()
|
_log = logger.create()
|
||||||
log.debug_or_exception(ex)
|
_log.error_or_exception(ex)
|
||||||
return s.lower()
|
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 os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
|
||||||
from .constants import BASE_DIR
|
from .constants import BASE_DIR
|
||||||
try:
|
try:
|
||||||
from importlib_metadata import version
|
from importlib.metadata import version
|
||||||
importlib = True
|
importlib = True
|
||||||
ImportNotFound = BaseException
|
ImportNotFound = BaseException
|
||||||
except ImportError:
|
except ImportError:
|
||||||
importlib = False
|
importlib = False
|
||||||
|
version = None
|
||||||
|
|
||||||
if not importlib:
|
if not importlib:
|
||||||
try:
|
try:
|
||||||
@ -20,6 +22,13 @@ if not importlib:
|
|||||||
|
|
||||||
def load_dependencys(optional=False):
|
def load_dependencys(optional=False):
|
||||||
deps = list()
|
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 importlib or pkgresources:
|
||||||
if optional:
|
if optional:
|
||||||
req_path = os.path.join(BASE_DIR, "optional-requirements.txt")
|
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'):
|
if not line.startswith('#') and not line == '\n' and not line.startswith('git'):
|
||||||
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?', line.strip())
|
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?', line.strip())
|
||||||
try:
|
try:
|
||||||
if importlib:
|
if getattr(sys, 'frozen', False):
|
||||||
dep_version = version(res.group(1))
|
dep_version = exe_deps[res.group(1).lower().replace('_','-')]
|
||||||
else:
|
else:
|
||||||
dep_version = pkg_resources.get_distribution(res.group(1)).version
|
if importlib:
|
||||||
except ImportNotFound:
|
dep_version = version(res.group(1))
|
||||||
|
else:
|
||||||
|
dep_version = pkg_resources.get_distribution(res.group(1)).version
|
||||||
|
except (ImportNotFound, KeyError):
|
||||||
if optional:
|
if optional:
|
||||||
continue
|
continue
|
||||||
dep_version = "not installed"
|
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
|
import zipfile
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from . import isoLanguages
|
from . import isoLanguages, cover
|
||||||
from . import config
|
from . import config
|
||||||
from .helper import split_authors
|
from .helper import split_authors
|
||||||
from .constants import BookMeta
|
from .constants import BookMeta
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
||||||
def extractCover(zipFile, coverFile, coverpath, tmp_file_name):
|
if cover_file is None:
|
||||||
if coverFile is None:
|
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
zipCoverPath = os.path.join(coverpath, coverFile).replace('\\', '/')
|
cf = extension = None
|
||||||
cf = zipFile.read(zipCoverPath)
|
zip_cover_path = os.path.join(cover_path, cover_file).replace('\\', '/')
|
||||||
|
|
||||||
prefix = os.path.splitext(tmp_file_name)[0]
|
prefix = os.path.splitext(tmp_file_name)[0]
|
||||||
tmp_cover_name = prefix + '.' + os.path.basename(zipCoverPath)
|
tmp_cover_name = prefix + '.' + os.path.basename(zip_cover_path)
|
||||||
image = open(tmp_cover_name, 'wb')
|
ext = os.path.splitext(tmp_cover_name)
|
||||||
image.write(cf)
|
if len(ext) > 1:
|
||||||
image.close()
|
extension = ext[1].lower()
|
||||||
return tmp_cover_name
|
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):
|
def get_epub_layout(book, book_data):
|
||||||
ns = {
|
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/'
|
'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)
|
tree = etree.fromstring(txt)
|
||||||
cfname = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
||||||
cf = epubZip.read(cfname)
|
cf = epub_zip.read(cf_name)
|
||||||
tree = etree.fromstring(cf)
|
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]
|
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=ns)[0]
|
||||||
|
|
||||||
epub_metadata = {}
|
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)
|
tmp = p.xpath('dc:%s/text()' % s, namespaces=ns)
|
||||||
if len(tmp) > 0:
|
if len(tmp) > 0:
|
||||||
if s == 'creator':
|
if s == 'creator':
|
||||||
epub_metadata[s] = ' & '.join(split_authors(tmp))
|
epub_metadata[s] = ' & '.join(split_authors(tmp))
|
||||||
elif s == 'subject':
|
elif s == 'subject':
|
||||||
epub_metadata[s] = ', '.join(tmp)
|
epub_metadata[s] = ', '.join(tmp)
|
||||||
|
elif s == 'date':
|
||||||
|
epub_metadata[s] = tmp[0][:10]
|
||||||
else:
|
else:
|
||||||
epub_metadata[s] = tmp[0]
|
epub_metadata[s] = tmp[0]
|
||||||
else:
|
else:
|
||||||
epub_metadata[s] = u'Unknown'
|
epub_metadata[s] = 'Unknown'
|
||||||
|
|
||||||
if epub_metadata['subject'] == u'Unknown':
|
if epub_metadata['subject'] == 'Unknown':
|
||||||
epub_metadata['subject'] = ''
|
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':
|
if epub_metadata['description'] == u'Unknown':
|
||||||
description = tree.xpath("//*[local-name() = 'description']/text()")
|
description = tree.xpath("//*[local-name() = 'description']/text()")
|
||||||
if len(description) > 0:
|
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()
|
lang = epub_metadata['language'].split('-', 1)[0].lower()
|
||||||
epub_metadata['language'] = isoLanguages.get_lang3(lang)
|
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']:
|
if not epub_metadata['title']:
|
||||||
title = original_file_name
|
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,
|
extension=original_file_extension,
|
||||||
title=title.encode('utf-8').decode('utf-8'),
|
title=title.encode('utf-8').decode('utf-8'),
|
||||||
author=epub_metadata['creator'].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'],
|
description=epub_metadata['description'],
|
||||||
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
|
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
|
||||||
series=epub_metadata['series'].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'),
|
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
|
||||||
languages=epub_metadata['language'],
|
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)
|
def parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path):
|
||||||
coverfile = None
|
cover_section = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
|
||||||
if len(coversection) > 0:
|
cover_file = None
|
||||||
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
|
# if len(cover_section) > 0:
|
||||||
else:
|
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)
|
meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns)
|
||||||
if len(meta_cover) > 0:
|
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:
|
else:
|
||||||
coversection = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
|
cover_section = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
|
||||||
if len(coversection) > 0:
|
for cs in cover_section:
|
||||||
filetype = coversection[0].rsplit('.', 1)[-1]
|
filetype = cs.rsplit('.', 1)[-1]
|
||||||
if filetype == "xhtml" or filetype == "html": # if cover is (x)html format
|
if filetype == "xhtml" or filetype == "html": # if cover is (x)html format
|
||||||
markup = epubZip.read(os.path.join(coverpath, coversection[0]))
|
markup = epub_zip.read(os.path.join(cover_path, cs))
|
||||||
markupTree = etree.fromstring(markup)
|
markup_tree = etree.fromstring(markup)
|
||||||
# no matter xhtml or html with no namespace
|
# 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
|
# Alternative image source
|
||||||
if not len(imgsrc):
|
if not len(img_src):
|
||||||
imgsrc = markupTree.xpath("//attribute::*[contains(local-name(), 'href')]")
|
img_src = markup_tree.xpath("//attribute::*[contains(local-name(), 'href')]")
|
||||||
if len(imgsrc):
|
if len(img_src):
|
||||||
# imgsrc maybe startwith "../"" so fullpath join then relpath to cwd
|
# 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(coverpath, coversection[0])),
|
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(cover_path, cover_section[0])),
|
||||||
imgsrc[0]))
|
img_src[0]))
|
||||||
coverfile = extractCover(epubZip, filename, "", tmp_file_path)
|
cover_file = _extract_cover(epub_zip, filename, "", tmp_file_path)
|
||||||
else:
|
else:
|
||||||
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
|
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
||||||
return coverfile
|
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)
|
series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns)
|
||||||
if len(series) > 0:
|
if len(series) > 0:
|
||||||
epub_metadata['series'] = series[0]
|
epub_metadata['series'] = series[0]
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
from werkzeug.exceptions import default_exceptions
|
from werkzeug.exceptions import default_exceptions
|
||||||
try:
|
try:
|
||||||
@ -42,8 +43,9 @@ def error_http(error):
|
|||||||
|
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
return render_template('http_error.html',
|
return render_template('http_error.html',
|
||||||
error_code="Internal Server Error",
|
error_code="500 Internal Server Error",
|
||||||
error_name=str(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,
|
issue=True,
|
||||||
unconfigured=False,
|
unconfigured=False,
|
||||||
error_stack=traceback.format_exc().split("\n"),
|
error_stack=traceback.format_exc().split("\n"),
|
||||||
|
@ -77,4 +77,6 @@ def get_fb2_info(tmp_file_path, original_file_extension):
|
|||||||
series="",
|
series="",
|
||||||
series_id="",
|
series_id="",
|
||||||
languages="",
|
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:
|
try:
|
||||||
gdriveutils.stopChannel(gdriveutils.Gdrive.Instance().drive, last_watch_response['id'],
|
gdriveutils.stopChannel(gdriveutils.Gdrive.Instance().drive, last_watch_response['id'],
|
||||||
last_watch_response['resourceId'])
|
last_watch_response['resourceId'])
|
||||||
except HttpError:
|
except (HttpError, AttributeError):
|
||||||
pass
|
pass
|
||||||
config.config_google_drive_watch_changes_response = {}
|
config.config_google_drive_watch_changes_response = {}
|
||||||
config.save()
|
config.save()
|
||||||
@ -152,7 +152,7 @@ try:
|
|||||||
move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath)
|
move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath)
|
||||||
calibre_db.reconnect_db(config, ub.app_DB_path)
|
calibre_db.reconnect_db(config, ub.app_DB_path)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.debug_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
return ''
|
return ''
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
@ -32,13 +32,9 @@ try:
|
|||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.exc import OperationalError, InvalidRequestError
|
from sqlalchemy.exc import OperationalError, InvalidRequestError, IntegrityError
|
||||||
from sqlalchemy.sql.expression import text
|
from sqlalchemy.sql.expression import text
|
||||||
|
|
||||||
try:
|
|
||||||
from six import __version__ as six_version
|
|
||||||
except ImportError:
|
|
||||||
six_version = "not installed"
|
|
||||||
try:
|
try:
|
||||||
from httplib2 import __version__ as httplib2_version
|
from httplib2 import __version__ as httplib2_version
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -56,16 +52,18 @@ try:
|
|||||||
from pydrive2.auth import GoogleAuth
|
from pydrive2.auth import GoogleAuth
|
||||||
from pydrive2.drive import GoogleDrive
|
from pydrive2.drive import GoogleDrive
|
||||||
from pydrive2.auth import RefreshError
|
from pydrive2.auth import RefreshError
|
||||||
|
from pydrive2.files import ApiRequestError
|
||||||
except ImportError as err:
|
except ImportError as err:
|
||||||
try:
|
try:
|
||||||
from pydrive.auth import GoogleAuth
|
from pydrive.auth import GoogleAuth
|
||||||
from pydrive.drive import GoogleDrive
|
from pydrive.drive import GoogleDrive
|
||||||
from pydrive.auth import RefreshError
|
from pydrive.auth import RefreshError
|
||||||
|
from pydrive.files import ApiRequestError
|
||||||
except ImportError as err:
|
except ImportError as err:
|
||||||
importError = err
|
importError = err
|
||||||
gdrive_support = False
|
gdrive_support = False
|
||||||
|
|
||||||
from . import logger, cli, config
|
from . import logger, cli_param, config
|
||||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
|
|
||||||
|
|
||||||
@ -79,7 +77,7 @@ if gdrive_support:
|
|||||||
if not logger.is_debug_enabled():
|
if not logger.is_debug_enabled():
|
||||||
logger.get('googleapiclient.discovery').setLevel(logger.logging.ERROR)
|
logger.get('googleapiclient.discovery').setLevel(logger.logging.ERROR)
|
||||||
else:
|
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:
|
class Singleton:
|
||||||
@ -139,11 +137,12 @@ class Gdrive:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.drive = getDrive(gauth=Gauth.Instance().auth)
|
self.drive = getDrive(gauth=Gauth.Instance().auth)
|
||||||
|
|
||||||
|
|
||||||
def is_gdrive_ready():
|
def is_gdrive_ready():
|
||||||
return os.path.exists(SETTINGS_YAML) and os.path.exists(CREDENTIALS)
|
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()
|
Base = declarative_base()
|
||||||
|
|
||||||
# Open session for database connection
|
# Open session for database connection
|
||||||
@ -191,10 +190,11 @@ def migrate():
|
|||||||
session.execute('ALTER TABLE gdrive_ids2 RENAME to gdrive_ids')
|
session.execute('ALTER TABLE gdrive_ids2 RENAME to gdrive_ids')
|
||||||
break
|
break
|
||||||
|
|
||||||
if not os.path.exists(cli.gdpath):
|
if not os.path.exists(cli_param.gd_path):
|
||||||
try:
|
try:
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
except Exception:
|
except Exception as ex:
|
||||||
|
log.error("Error connect to database: {} - {}".format(cli_param.gd_path, ex))
|
||||||
raise
|
raise
|
||||||
migrate()
|
migrate()
|
||||||
|
|
||||||
@ -210,9 +210,9 @@ def getDrive(drive=None, gauth=None):
|
|||||||
try:
|
try:
|
||||||
gauth.Refresh()
|
gauth.Refresh()
|
||||||
except RefreshError as e:
|
except RefreshError as e:
|
||||||
log.error("Google Drive error: %s", e)
|
log.error("Google Drive error: {}".format(e))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.debug_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
else:
|
else:
|
||||||
# Initialize the saved creds
|
# Initialize the saved creds
|
||||||
gauth.Authorize()
|
gauth.Authorize()
|
||||||
@ -222,7 +222,7 @@ def getDrive(drive=None, gauth=None):
|
|||||||
try:
|
try:
|
||||||
drive.auth.Refresh()
|
drive.auth.Refresh()
|
||||||
except RefreshError as e:
|
except RefreshError as e:
|
||||||
log.error("Google Drive error: %s", e)
|
log.error("Google Drive error: {}".format(e))
|
||||||
return drive
|
return drive
|
||||||
|
|
||||||
def listRootFolders():
|
def listRootFolders():
|
||||||
@ -231,7 +231,7 @@ def listRootFolders():
|
|||||||
folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
|
folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
|
||||||
fileList = drive.ListFile({'q': folder}).GetList()
|
fileList = drive.ListFile({'q': folder}).GetList()
|
||||||
except (ServerNotFoundError, ssl.SSLError, RefreshError) as e:
|
except (ServerNotFoundError, ssl.SSLError, RefreshError) as e:
|
||||||
log.info("GDrive Error %s" % e)
|
log.info("GDrive Error {}".format(e))
|
||||||
fileList = []
|
fileList = []
|
||||||
return fileList
|
return fileList
|
||||||
|
|
||||||
@ -269,8 +269,7 @@ def getEbooksFolderId(drive=None):
|
|||||||
try:
|
try:
|
||||||
session.commit()
|
session.commit()
|
||||||
except OperationalError as ex:
|
except OperationalError as ex:
|
||||||
log.error("gdrive.db DB is not Writeable")
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
log.debug('Database error: %s', ex)
|
|
||||||
session.rollback()
|
session.rollback()
|
||||||
return gDriveId.gdrive_id
|
return gDriveId.gdrive_id
|
||||||
|
|
||||||
@ -286,6 +285,7 @@ def getFile(pathId, fileName, drive):
|
|||||||
|
|
||||||
def getFolderId(path, drive):
|
def getFolderId(path, drive):
|
||||||
# drive = getDrive(drive)
|
# drive = getDrive(drive)
|
||||||
|
currentFolderId = None
|
||||||
try:
|
try:
|
||||||
currentFolderId = getEbooksFolderId(drive)
|
currentFolderId = getEbooksFolderId(drive)
|
||||||
sqlCheckPath = path if path[-1] == '/' else path + '/'
|
sqlCheckPath = path if path[-1] == '/' else path + '/'
|
||||||
@ -318,10 +318,14 @@ def getFolderId(path, drive):
|
|||||||
session.commit()
|
session.commit()
|
||||||
else:
|
else:
|
||||||
currentFolderId = storedPathName.gdrive_id
|
currentFolderId = storedPathName.gdrive_id
|
||||||
except OperationalError as ex:
|
except (OperationalError, IntegrityError) as ex:
|
||||||
log.error("gdrive.db DB is not Writeable")
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
log.debug('Database error: %s', ex)
|
|
||||||
session.rollback()
|
session.rollback()
|
||||||
|
except ApiRequestError as ex:
|
||||||
|
log.error('{} {}'.format(ex.error['message'], path))
|
||||||
|
session.rollback()
|
||||||
|
except RefreshError as ex:
|
||||||
|
log.error(ex)
|
||||||
return currentFolderId
|
return currentFolderId
|
||||||
|
|
||||||
|
|
||||||
@ -355,16 +359,27 @@ def moveGdriveFolderRemote(origin_file, target_folder):
|
|||||||
children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
||||||
gFileTargetDir = getFileFromEbooksFolder(None, target_folder)
|
gFileTargetDir = getFileFromEbooksFolder(None, target_folder)
|
||||||
if not gFileTargetDir:
|
if not gFileTargetDir:
|
||||||
# Folder is not existing, create, and move folder
|
|
||||||
gFileTargetDir = drive.CreateFile(
|
gFileTargetDir = drive.CreateFile(
|
||||||
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
||||||
"mimeType": "application/vnd.google-apps.folder"})
|
"mimeType": "application/vnd.google-apps.folder"})
|
||||||
gFileTargetDir.Upload()
|
gFileTargetDir.Upload()
|
||||||
# Move the file to the new folder
|
# Move the file to the new folder
|
||||||
drive.auth.service.files().update(fileId=origin_file['id'],
|
drive.auth.service.files().update(fileId=origin_file['id'],
|
||||||
addParents=gFileTargetDir['id'],
|
addParents=gFileTargetDir['id'],
|
||||||
removeParents=previous_parents,
|
removeParents=previous_parents,
|
||||||
fields='id, parents').execute()
|
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 previous_parents has no children anymore, delete original fileparent
|
||||||
if len(children['items']) == 1:
|
if len(children['items']) == 1:
|
||||||
deleteDatabaseEntry(previous_parents)
|
deleteDatabaseEntry(previous_parents)
|
||||||
@ -412,24 +427,24 @@ def uploadFileToEbooksFolder(destFile, f):
|
|||||||
splitDir = destFile.split('/')
|
splitDir = destFile.split('/')
|
||||||
for i, x in enumerate(splitDir):
|
for i, x in enumerate(splitDir):
|
||||||
if i == len(splitDir)-1:
|
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()
|
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
||||||
if len(existingFiles) > 0:
|
if len(existing_Files) > 0:
|
||||||
driveFile = existingFiles[0]
|
driveFile = existing_Files[0]
|
||||||
else:
|
else:
|
||||||
driveFile = drive.CreateFile({'title': x,
|
driveFile = drive.CreateFile({'title': x,
|
||||||
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
||||||
driveFile.SetContentFile(f)
|
driveFile.SetContentFile(f)
|
||||||
driveFile.Upload()
|
driveFile.Upload()
|
||||||
else:
|
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()
|
(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']}],
|
parent = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
||||||
"mimeType": "application/vnd.google-apps.folder"})
|
"mimeType": "application/vnd.google-apps.folder"})
|
||||||
parent.Upload()
|
parent.Upload()
|
||||||
else:
|
else:
|
||||||
parent = existingFolder[0]
|
parent = existing_Folder[0]
|
||||||
|
|
||||||
|
|
||||||
def watchChange(drive, channel_id, channel_type, channel_address,
|
def watchChange(drive, channel_id, channel_type, channel_address,
|
||||||
@ -528,8 +543,8 @@ def deleteDatabaseOnChange():
|
|||||||
session.commit()
|
session.commit()
|
||||||
except (OperationalError, InvalidRequestError) as ex:
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
log.debug('Database error: %s', ex)
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
log.error(u"GDrive DB is not Writeable")
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
def updateGdriveCalibreFromLocal():
|
def updateGdriveCalibreFromLocal():
|
||||||
@ -547,8 +562,7 @@ def updateDatabaseOnEdit(ID,newPath):
|
|||||||
try:
|
try:
|
||||||
session.commit()
|
session.commit()
|
||||||
except OperationalError as ex:
|
except OperationalError as ex:
|
||||||
log.error("gdrive.db DB is not Writeable")
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
log.debug('Database error: %s', ex)
|
|
||||||
session.rollback()
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
@ -558,8 +572,7 @@ def deleteDatabaseEntry(ID):
|
|||||||
try:
|
try:
|
||||||
session.commit()
|
session.commit()
|
||||||
except OperationalError as ex:
|
except OperationalError as ex:
|
||||||
log.error("gdrive.db DB is not Writeable")
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
log.debug('Database error: %s', ex)
|
|
||||||
session.rollback()
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
@ -580,8 +593,7 @@ def get_cover_via_gdrive(cover_path):
|
|||||||
try:
|
try:
|
||||||
session.commit()
|
session.commit()
|
||||||
except OperationalError as ex:
|
except OperationalError as ex:
|
||||||
log.error("gdrive.db DB is not Writeable")
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
log.debug('Database error: %s', ex)
|
|
||||||
session.rollback()
|
session.rollback()
|
||||||
return df.metadata.get('webContentLink')
|
return df.metadata.get('webContentLink')
|
||||||
else:
|
else:
|
||||||
@ -603,7 +615,7 @@ def do_gdrive_download(df, headers, convert_encoding=False):
|
|||||||
|
|
||||||
def stream(convert_encoding):
|
def stream(convert_encoding):
|
||||||
for byte in s:
|
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)
|
resp, content = df.auth.Get_Http_Object().request(download_url, headers=headers)
|
||||||
if resp.status == 206:
|
if resp.status == 206:
|
||||||
if convert_encoding:
|
if convert_encoding:
|
||||||
@ -611,7 +623,7 @@ def do_gdrive_download(df, headers, convert_encoding=False):
|
|||||||
content = content.decode(result['encoding']).encode('utf-8')
|
content = content.decode(result['encoding']).encode('utf-8')
|
||||||
yield content
|
yield content
|
||||||
else:
|
else:
|
||||||
log.warning('An error occurred: %s', resp)
|
log.warning('An error occurred: {}'.format(resp))
|
||||||
return
|
return
|
||||||
return Response(stream_with_context(stream(convert_encoding)), headers=headers)
|
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'
|
return 'Callback url (redirect url) is missing in client_secrets.json'
|
||||||
if client_secrets:
|
if client_secrets:
|
||||||
client_secrets.update(filedata['web'])
|
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):
|
def get_language_names(locale):
|
||||||
return _LANGUAGE_NAMES.get(locale)
|
return _LANGUAGE_NAMES.get(str(locale))
|
||||||
|
|
||||||
|
|
||||||
def get_language_name(locale, lang_code):
|
def get_language_name(locale, lang_code):
|
||||||
|
@ -102,6 +102,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (macrolanguage)",
|
"doi": "Dogri (macrolanguage)",
|
||||||
"dsb": "Sorbian; Lower",
|
"dsb": "Sorbian; Lower",
|
||||||
|
"dse": "holandský znakový jazyk",
|
||||||
"dua": "dualština",
|
"dua": "dualština",
|
||||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||||
"dyu": "djula",
|
"dyu": "djula",
|
||||||
@ -526,6 +527,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (Makrosprache)",
|
"doi": "Dogri (Makrosprache)",
|
||||||
"dsb": "Sorbisch; Nieder",
|
"dsb": "Sorbisch; Nieder",
|
||||||
|
"dse": "Niederländische Zeichensprache",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"dum": "Niederländisch; Mittel (ca. 1050-1350)",
|
"dum": "Niederländisch; Mittel (ca. 1050-1350)",
|
||||||
"dyu": "Dyula",
|
"dyu": "Dyula",
|
||||||
@ -945,6 +947,7 @@ LANGUAGE_NAMES = {
|
|||||||
"dgr": "Dogrib",
|
"dgr": "Dogrib",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"nld": "Ολλανδικά",
|
"nld": "Ολλανδικά",
|
||||||
|
"dse": "Ολλανδική νοηματική γλώσσα",
|
||||||
"dyu": "Dyula",
|
"dyu": "Dyula",
|
||||||
"dzo": "Dzongkha",
|
"dzo": "Dzongkha",
|
||||||
"efi": "Efik",
|
"efi": "Efik",
|
||||||
@ -1329,6 +1332,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (macrolengua)",
|
"doi": "Dogri (macrolengua)",
|
||||||
"dsb": "Bajo sorabo",
|
"dsb": "Bajo sorabo",
|
||||||
|
"dse": "Lengua de signos neerlandesa",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"dum": "Neerlandés medio (ca. 1050-1350)",
|
"dum": "Neerlandés medio (ca. 1050-1350)",
|
||||||
"dyu": "Diula",
|
"dyu": "Diula",
|
||||||
@ -1753,6 +1757,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (macrolanguage)",
|
"doi": "Dogri (macrolanguage)",
|
||||||
"dsb": "alasorbi",
|
"dsb": "alasorbi",
|
||||||
|
"dse": "Dutch Sign Language",
|
||||||
"dua": "duala",
|
"dua": "duala",
|
||||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||||
"dyu": "dyula",
|
"dyu": "dyula",
|
||||||
@ -2177,6 +2182,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "dhivehi",
|
"div": "dhivehi",
|
||||||
"doi": "dogri (macrolangue)",
|
"doi": "dogri (macrolangue)",
|
||||||
"dsb": "bas-sorbien",
|
"dsb": "bas-sorbien",
|
||||||
|
"dse": "langue des signes néerlandaise",
|
||||||
"dua": "duala",
|
"dua": "duala",
|
||||||
"dum": "néerlandais moyen (environ 1050-1350)",
|
"dum": "néerlandais moyen (environ 1050-1350)",
|
||||||
"dyu": "dioula",
|
"dyu": "dioula",
|
||||||
@ -2601,6 +2607,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (macrolanguage)",
|
"doi": "Dogri (macrolanguage)",
|
||||||
"dsb": "Sorbian; Lower",
|
"dsb": "Sorbian; Lower",
|
||||||
|
"dse": "Dutch Sign Language",
|
||||||
"dua": "duala",
|
"dua": "duala",
|
||||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||||
"dyu": "djula",
|
"dyu": "djula",
|
||||||
@ -3025,6 +3032,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (macrolingua)",
|
"doi": "Dogri (macrolingua)",
|
||||||
"dsb": "Lusaziano inferiore",
|
"dsb": "Lusaziano inferiore",
|
||||||
|
"dse": "Olandense (linguaggio dei segni)",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"dum": "Olandese medio (ca. 1050-1350)",
|
"dum": "Olandese medio (ca. 1050-1350)",
|
||||||
"dyu": "Diula",
|
"dyu": "Diula",
|
||||||
@ -3449,6 +3457,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (macrolanguage)",
|
"doi": "Dogri (macrolanguage)",
|
||||||
"dsb": "Sorbian; Lower",
|
"dsb": "Sorbian; Lower",
|
||||||
|
"dse": "Dutch Sign Language",
|
||||||
"dua": "ドゥアラ語",
|
"dua": "ドゥアラ語",
|
||||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||||
"dyu": "デュラ語",
|
"dyu": "デュラ語",
|
||||||
@ -3873,6 +3882,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (macrolanguage)",
|
"doi": "Dogri (macrolanguage)",
|
||||||
"dsb": "Sorbian; Lower",
|
"dsb": "Sorbian; Lower",
|
||||||
|
"dse": "Dutch Sign Language",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||||
"dyu": "Dyula",
|
"dyu": "Dyula",
|
||||||
@ -4207,6 +4217,384 @@ LANGUAGE_NAMES = {
|
|||||||
"zxx": "No linguistic content",
|
"zxx": "No linguistic content",
|
||||||
"zza": "Zaza"
|
"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": {
|
"nl": {
|
||||||
"aar": "Afar; Hamitisch",
|
"aar": "Afar; Hamitisch",
|
||||||
"abk": "Abchazisch",
|
"abk": "Abchazisch",
|
||||||
@ -4297,6 +4685,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Divehi",
|
"div": "Divehi",
|
||||||
"doi": "Dogri",
|
"doi": "Dogri",
|
||||||
"dsb": "Sorbisch; lager",
|
"dsb": "Sorbisch; lager",
|
||||||
|
"dse": "Nederlandse gebarentaal",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"dum": "Nederlands; middel (ca. 1050-1350)",
|
"dum": "Nederlands; middel (ca. 1050-1350)",
|
||||||
"dyu": "Dyula",
|
"dyu": "Dyula",
|
||||||
@ -4721,6 +5110,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "malediwski; divehi",
|
"div": "malediwski; divehi",
|
||||||
"doi": "dogri (makrojęzyk)",
|
"doi": "dogri (makrojęzyk)",
|
||||||
"dsb": "dolnołużycki",
|
"dsb": "dolnołużycki",
|
||||||
|
"dse": "holenderski język migowy",
|
||||||
"dua": "duala",
|
"dua": "duala",
|
||||||
"dum": "holenderski średniowieczny (ok. 1050-1350)",
|
"dum": "holenderski średniowieczny (ok. 1050-1350)",
|
||||||
"dyu": "diula",
|
"dyu": "diula",
|
||||||
@ -5140,6 +5530,7 @@ LANGUAGE_NAMES = {
|
|||||||
"dgr": "Dogrib",
|
"dgr": "Dogrib",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"nld": "Holandês",
|
"nld": "Holandês",
|
||||||
|
"dse": "Língua gestual holandesa",
|
||||||
"dyu": "Dyula",
|
"dyu": "Dyula",
|
||||||
"dzo": "Dzongkha",
|
"dzo": "Dzongkha",
|
||||||
"efi": "Efik",
|
"efi": "Efik",
|
||||||
@ -5522,6 +5913,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (macrolanguage)",
|
"doi": "Dogri (macrolanguage)",
|
||||||
"dsb": "Sorbian; Lower",
|
"dsb": "Sorbian; Lower",
|
||||||
|
"dse": "Dutch Sign Language",
|
||||||
"dua": "Дуала",
|
"dua": "Дуала",
|
||||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||||
"dyu": "Диула (Дьюла)",
|
"dyu": "Диула (Дьюла)",
|
||||||
@ -5946,6 +6338,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Divehi",
|
"div": "Divehi",
|
||||||
"doi": "Dogri (macrolanguage)",
|
"doi": "Dogri (macrolanguage)",
|
||||||
"dsb": "Sorbian; nedre",
|
"dsb": "Sorbian; nedre",
|
||||||
|
"dse": "Nederländskt teckenspråk",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"dum": "Hollänska; medeltida (ca. 1050-1350)",
|
"dum": "Hollänska; medeltida (ca. 1050-1350)",
|
||||||
"dyu": "Dyula",
|
"dyu": "Dyula",
|
||||||
@ -6365,6 +6758,7 @@ LANGUAGE_NAMES = {
|
|||||||
"dgr": "Dogrib (Kanada)",
|
"dgr": "Dogrib (Kanada)",
|
||||||
"dua": "Duala (Afrika)",
|
"dua": "Duala (Afrika)",
|
||||||
"nld": "Flâmanca (Hollanda dili)",
|
"nld": "Flâmanca (Hollanda dili)",
|
||||||
|
"dse": "Hollandalı İşaret Dili",
|
||||||
"dyu": "Dyula (Burkina Faso; Mali)",
|
"dyu": "Dyula (Burkina Faso; Mali)",
|
||||||
"dzo": "Dzongkha (Butan)",
|
"dzo": "Dzongkha (Butan)",
|
||||||
"efi": "Efik (Afrika)",
|
"efi": "Efik (Afrika)",
|
||||||
@ -6747,6 +7141,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "мальдивська",
|
"div": "мальдивська",
|
||||||
"doi": "догрі (макромова)",
|
"doi": "догрі (макромова)",
|
||||||
"dsb": "нижньолужицька",
|
"dsb": "нижньолужицька",
|
||||||
|
"dse": "голландська мова жестів",
|
||||||
"dua": "дуала",
|
"dua": "дуала",
|
||||||
"dum": "середньовічна голландська (бл. 1050-1350)",
|
"dum": "середньовічна голландська (бл. 1050-1350)",
|
||||||
"dyu": "діула",
|
"dyu": "діула",
|
||||||
@ -7171,6 +7566,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "迪维希语",
|
"div": "迪维希语",
|
||||||
"doi": "多格拉语",
|
"doi": "多格拉语",
|
||||||
"dsb": "索布语(下)",
|
"dsb": "索布语(下)",
|
||||||
|
"dse": "荷兰手语",
|
||||||
"dua": "杜亚拉语",
|
"dua": "杜亚拉语",
|
||||||
"dum": "荷兰语(中古,约 1050-1350)",
|
"dum": "荷兰语(中古,约 1050-1350)",
|
||||||
"dyu": "迪尤拉语",
|
"dyu": "迪尤拉语",
|
||||||
@ -7590,6 +7986,7 @@ LANGUAGE_NAMES = {
|
|||||||
"dgr": "Dogrib",
|
"dgr": "Dogrib",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"nld": "荷蘭文",
|
"nld": "荷蘭文",
|
||||||
|
"dse": "Dutch Sign Language",
|
||||||
"dyu": "Dyula",
|
"dyu": "Dyula",
|
||||||
"dzo": "Dzongkha",
|
"dzo": "Dzongkha",
|
||||||
"efi": "Efik",
|
"efi": "Efik",
|
||||||
@ -7973,6 +8370,7 @@ LANGUAGE_NAMES = {
|
|||||||
"div": "Dhivehi",
|
"div": "Dhivehi",
|
||||||
"doi": "Dogri (macrolanguage)",
|
"doi": "Dogri (macrolanguage)",
|
||||||
"dsb": "Sorbian; Lower",
|
"dsb": "Sorbian; Lower",
|
||||||
|
"dse": "Dutch Sign Language",
|
||||||
"dua": "Duala",
|
"dua": "Duala",
|
||||||
"dum": "Dutch; Middle (ca. 1050-1350)",
|
"dum": "Dutch; Middle (ca. 1050-1350)",
|
||||||
"dyu": "Dyula",
|
"dyu": "Dyula",
|
||||||
|
@ -22,17 +22,17 @@
|
|||||||
|
|
||||||
# custom jinja filters
|
# custom jinja filters
|
||||||
|
|
||||||
|
from markupsafe import escape
|
||||||
import datetime
|
import datetime
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from uuid import uuid4
|
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 import Blueprint, request, url_for
|
||||||
from flask_babel import get_locale
|
from flask_babel import format_date
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from markupsafe import escape
|
|
||||||
from . import logger
|
|
||||||
|
|
||||||
|
from . import constants, logger
|
||||||
|
|
||||||
jinjia = Blueprint('jinjia', __name__)
|
jinjia = Blueprint('jinjia', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@ -77,7 +77,7 @@ def mimetype_filter(val):
|
|||||||
@jinjia.app_template_filter('formatdate')
|
@jinjia.app_template_filter('formatdate')
|
||||||
def formatdate_filter(val):
|
def formatdate_filter(val):
|
||||||
try:
|
try:
|
||||||
return format_date(val, format='medium', locale=get_locale())
|
return format_date(val, format='medium')
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
|
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
|
||||||
current_user.locale,
|
current_user.locale,
|
||||||
@ -128,12 +128,55 @@ def formatseriesindex_filter(series_index):
|
|||||||
return series_index
|
return series_index
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@jinjia.app_template_filter('escapedlink')
|
@jinjia.app_template_filter('escapedlink')
|
||||||
def escapedlink_filter(url, text):
|
def escapedlink_filter(url, text):
|
||||||
return "<a href='{}'>{}</a>".format(url, escape(text))
|
return "<a href='{}'>{}</a>".format(url, escape(text))
|
||||||
|
|
||||||
|
|
||||||
@jinjia.app_template_filter('uuidfilter')
|
@jinjia.app_template_filter('uuidfilter')
|
||||||
def uuidfilter(var):
|
def uuidfilter(var):
|
||||||
return uuid4()
|
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
|
import uuid
|
||||||
from time import gmtime, strftime
|
from time import gmtime, strftime
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import unquote
|
||||||
try:
|
|
||||||
from urllib import unquote
|
|
||||||
except ImportError:
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
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 . 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 .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 .helper import get_download_link
|
||||||
from .services import SyncToken as SyncToken
|
from .services import SyncToken as SyncToken
|
||||||
from .web import download_required
|
from .web import download_required
|
||||||
@ -153,8 +149,8 @@ def HandleSyncRequest():
|
|||||||
sync_token.books_last_created = datetime.datetime.min
|
sync_token.books_last_created = datetime.datetime.min
|
||||||
sync_token.reading_state_last_modified = 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_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_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_reading_state_last_modified = sync_token.reading_state_last_modified
|
||||||
|
|
||||||
new_archived_last_modified = datetime.datetime.min
|
new_archived_last_modified = datetime.datetime.min
|
||||||
@ -178,21 +174,20 @@ def HandleSyncRequest():
|
|||||||
ub.BookShelf.date_added,
|
ub.BookShelf.date_added,
|
||||||
ub.ArchivedBook.is_archived)
|
ub.ArchivedBook.is_archived)
|
||||||
changed_entries = (changed_entries
|
changed_entries = (changed_entries
|
||||||
.join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id)
|
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||||
.join(ub.KoboSyncedBooks, ub.KoboSyncedBooks.book_id == db.Books.id, isouter=True)
|
ub.ArchivedBook.user_id == current_user.id))
|
||||||
.filter(or_(ub.KoboSyncedBooks.user_id != current_user.id,
|
.filter(db.Books.id.notin_(calibre_db.session.query(ub.KoboSyncedBooks.book_id)
|
||||||
ub.KoboSyncedBooks.book_id == None))
|
.filter(ub.KoboSyncedBooks.user_id == current_user.id)))
|
||||||
.filter(ub.BookShelf.date_added > sync_token.books_last_modified)
|
.filter(ub.BookShelf.date_added > sync_token.books_last_modified)
|
||||||
.filter(db.Data.format.in_(KOBO_FORMATS))
|
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||||
.filter(calibre_db.common_filters(allow_show_archived=True))
|
.filter(calibre_db.common_filters(allow_show_archived=True))
|
||||||
.order_by(db.Books.id)
|
.order_by(db.Books.id)
|
||||||
.order_by(ub.ArchivedBook.last_modified)
|
.order_by(ub.ArchivedBook.last_modified)
|
||||||
.join(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)
|
.join(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)
|
||||||
.join(ub.Shelf)
|
.join(ub.Shelf)
|
||||||
.filter(ub.Shelf.user_id == current_user.id)
|
.filter(ub.Shelf.user_id == current_user.id)
|
||||||
.filter(ub.Shelf.kobo_sync)
|
.filter(ub.Shelf.kobo_sync)
|
||||||
.distinct()
|
.distinct())
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
if sqlalchemy_version2:
|
if sqlalchemy_version2:
|
||||||
changed_entries = select(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived)
|
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.last_modified,
|
||||||
ub.ArchivedBook.is_archived)
|
ub.ArchivedBook.is_archived)
|
||||||
changed_entries = (changed_entries
|
changed_entries = (changed_entries
|
||||||
.join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id)
|
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||||
.join(ub.KoboSyncedBooks, ub.KoboSyncedBooks.book_id == db.Books.id, isouter=True)
|
ub.ArchivedBook.user_id == current_user.id))
|
||||||
.filter(or_(ub.KoboSyncedBooks.user_id != current_user.id,
|
.filter(db.Books.id.notin_(calibre_db.session.query(ub.KoboSyncedBooks.book_id)
|
||||||
ub.KoboSyncedBooks.book_id == None))
|
.filter(ub.KoboSyncedBooks.user_id == current_user.id)))
|
||||||
.filter(calibre_db.common_filters())
|
.filter(calibre_db.common_filters(allow_show_archived=True))
|
||||||
.filter(db.Data.format.in_(KOBO_FORMATS))
|
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||||
.order_by(db.Books.last_modified)
|
.order_by(db.Books.last_modified)
|
||||||
.order_by(db.Books.id)
|
.order_by(db.Books.id))
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
reading_states_in_new_entitlements = []
|
reading_states_in_new_entitlements = []
|
||||||
if sqlalchemy_version2:
|
if sqlalchemy_version2:
|
||||||
@ -220,7 +213,7 @@ def HandleSyncRequest():
|
|||||||
log.debug("Books to Sync: {}".format(len(books.all())))
|
log.debug("Books to Sync: {}".format(len(books.all())))
|
||||||
for book in books:
|
for book in books:
|
||||||
formats = [data.format for data in book.Books.data]
|
formats = [data.format for data in book.Books.data]
|
||||||
if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats:
|
if '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)
|
helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
||||||
|
|
||||||
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
||||||
@ -262,10 +255,12 @@ def HandleSyncRequest():
|
|||||||
if sqlalchemy_version2:
|
if sqlalchemy_version2:
|
||||||
max_change = calibre_db.session.execute(changed_entries
|
max_change = calibre_db.session.execute(changed_entries
|
||||||
.filter(ub.ArchivedBook.is_archived)
|
.filter(ub.ArchivedBook.is_archived)
|
||||||
|
.filter(ub.ArchivedBook.user_id == current_user.id)
|
||||||
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()))\
|
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()))\
|
||||||
.columns(db.Books).first()
|
.columns(db.Books).first()
|
||||||
else:
|
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()
|
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
|
||||||
|
|
||||||
max_change = max_change.last_modified if max_change else new_archived_last_modified
|
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(
|
changed_reading_states = changed_reading_states.filter(
|
||||||
and_(ub.KoboReadingState.user_id == current_user.id,
|
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)
|
cont_sync |= bool(changed_reading_states.count() > SYNC_ITEM_LIMIT)
|
||||||
for kobo_reading_state in changed_reading_states.limit(SYNC_ITEM_LIMIT).all():
|
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()
|
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):
|
def generate_sync_response(sync_token, sync_results, set_cont=False):
|
||||||
extra_headers = {}
|
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.
|
# Merge in sync results from the official Kobo store.
|
||||||
try:
|
try:
|
||||||
store_response = make_request_to_kobo_store(sync_token)
|
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"
|
extra_headers["x-kobo-sync"] = "continue"
|
||||||
sync_token.to_headers(extra_headers)
|
sync_token.to_headers(extra_headers)
|
||||||
|
|
||||||
log.debug("Kobo Sync Content: {}".format(sync_results))
|
# log.debug("Kobo Sync Content: {}".format(sync_results))
|
||||||
# jsonify decodes the unicode string different to what kobo expects
|
# jsonify decodes the unicode string different to what kobo expects
|
||||||
response = make_response(json.dumps(sync_results), extra_headers)
|
response = make_response(json.dumps(sync_results), extra_headers)
|
||||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
@ -427,9 +423,9 @@ def get_author(book):
|
|||||||
author_list = []
|
author_list = []
|
||||||
autor_roles = []
|
autor_roles = []
|
||||||
for author in book.authors:
|
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)
|
author_list.append(author.name)
|
||||||
return {"ContributorRoles": autor_roles, "Contributors":author_list}
|
return {"ContributorRoles": autor_roles, "Contributors": author_list}
|
||||||
|
|
||||||
|
|
||||||
def get_publisher(book):
|
def get_publisher(book):
|
||||||
@ -443,6 +439,7 @@ def get_series(book):
|
|||||||
return None
|
return None
|
||||||
return book.series[0].name
|
return book.series[0].name
|
||||||
|
|
||||||
|
|
||||||
def get_seriesindex(book):
|
def get_seriesindex(book):
|
||||||
return book.series_index or 1
|
return book.series_index or 1
|
||||||
|
|
||||||
@ -489,7 +486,7 @@ def get_metadata(book):
|
|||||||
"Language": "en",
|
"Language": "en",
|
||||||
"PhoneticPronunciations": {},
|
"PhoneticPronunciations": {},
|
||||||
"PublicationDate": convert_to_kobo_timestamp_string(book.pubdate),
|
"PublicationDate": convert_to_kobo_timestamp_string(book.pubdate),
|
||||||
"Publisher": {"Imprint": "", "Name": get_publisher(book),},
|
"Publisher": {"Imprint": "", "Name": get_publisher(book), },
|
||||||
"RevisionId": book_uuid,
|
"RevisionId": book_uuid,
|
||||||
"Title": book.title,
|
"Title": book.title,
|
||||||
"WorkId": book_uuid,
|
"WorkId": book_uuid,
|
||||||
@ -508,6 +505,7 @@ def get_metadata(book):
|
|||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
|
@kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
@ -556,11 +554,9 @@ def HandleTagUpdate(tag_id):
|
|||||||
else:
|
else:
|
||||||
abort(404, description="Collection isn't known to CalibreWeb")
|
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":
|
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:
|
else:
|
||||||
name = None
|
name = None
|
||||||
try:
|
try:
|
||||||
@ -678,11 +674,8 @@ def HandleTagRemoveItem(tag_id):
|
|||||||
# Note: Public shelves that aren't owned by the user aren't supported.
|
# Note: Public shelves that aren't owned by the user aren't supported.
|
||||||
def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
||||||
new_tags_last_modified = sync_token.tags_last_modified
|
new_tags_last_modified = sync_token.tags_last_modified
|
||||||
|
# transmit all archived shelfs independent of last sync (why should this matter?)
|
||||||
for shelf in ub.session.query(ub.ShelfArchive).filter(
|
for shelf in ub.session.query(ub.ShelfArchive).filter(ub.ShelfArchive.user_id == current_user.id):
|
||||||
func.datetime(ub.ShelfArchive.last_modified) > sync_token.tags_last_modified,
|
|
||||||
ub.ShelfArchive.user_id == current_user.id
|
|
||||||
):
|
|
||||||
new_tags_last_modified = max(shelf.last_modified, new_tags_last_modified)
|
new_tags_last_modified = max(shelf.last_modified, new_tags_last_modified)
|
||||||
sync_results.append({
|
sync_results.append({
|
||||||
"DeletedTag": {
|
"DeletedTag": {
|
||||||
@ -695,7 +688,6 @@ def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
|||||||
ub.session.delete(shelf)
|
ub.session.delete(shelf)
|
||||||
ub.session_commit()
|
ub.session_commit()
|
||||||
|
|
||||||
|
|
||||||
extra_filters = []
|
extra_filters = []
|
||||||
if only_kobo_shelves:
|
if only_kobo_shelves:
|
||||||
for shelf in ub.session.query(ub.Shelf).filter(
|
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
|
*extra_filters
|
||||||
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())
|
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())
|
||||||
|
|
||||||
|
|
||||||
for shelf in shelflist:
|
for shelf in shelflist:
|
||||||
if not shelf_lib.check_shelf_view_permissions(shelf):
|
if not shelf_lib.check_shelf_view_permissions(shelf):
|
||||||
continue
|
continue
|
||||||
@ -774,6 +765,7 @@ def create_kobo_tag(shelf):
|
|||||||
)
|
)
|
||||||
return {"Tag": tag}
|
return {"Tag": tag}
|
||||||
|
|
||||||
|
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/library/<book_uuid>/state", methods=["GET", "PUT"])
|
@kobo.route("/v1/library/<book_uuid>/state", methods=["GET", "PUT"])
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
@ -818,7 +810,7 @@ def HandleStateRequest(book_uuid):
|
|||||||
book_read = kobo_reading_state.book_read_link
|
book_read = kobo_reading_state.book_read_link
|
||||||
new_book_read_status = get_ub_read_status(request_status_info["Status"])
|
new_book_read_status = get_ub_read_status(request_status_info["Status"])
|
||||||
if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \
|
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.times_started_reading += 1
|
||||||
book_read.last_time_started_reading = datetime.datetime.utcnow()
|
book_read.last_time_started_reading = datetime.datetime.utcnow()
|
||||||
book_read.read_status = new_book_read_status
|
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):
|
def get_or_create_reading_state(book_id):
|
||||||
book_read = ub.session.query(ub.ReadBook).filter(ub.ReadBook.book_id == 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:
|
if not book_read:
|
||||||
book_read = ub.ReadBook(user_id=current_user.id, book_id=book_id)
|
book_read = ub.ReadBook(user_id=current_user.id, book_id=book_id)
|
||||||
if not book_read.kobo_reading_state:
|
if not book_read.kobo_reading_state:
|
||||||
@ -922,13 +914,12 @@ def get_current_bookmark_response(current_bookmark):
|
|||||||
}
|
}
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("/<book_uuid>/<width>/<height>/<isGreyscale>/image.jpg", defaults={'Quality': ""})
|
@kobo.route("/<book_uuid>/<width>/<height>/<isGreyscale>/image.jpg", defaults={'Quality': ""})
|
||||||
@kobo.route("/<book_uuid>/<width>/<height>/<Quality>/<isGreyscale>/image.jpg")
|
@kobo.route("/<book_uuid>/<width>/<height>/<Quality>/<isGreyscale>/image.jpg")
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
def HandleCoverImageRequest(book_uuid, width, height,Quality, isGreyscale):
|
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
||||||
book_cover = helper.get_book_cover_with_uuid(
|
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=COVER_THUMBNAIL_SMALL)
|
||||||
book_uuid, use_generic_cover_on_failure=False
|
|
||||||
)
|
|
||||||
if not book_cover:
|
if not book_cover:
|
||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
|
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
|
||||||
@ -1001,8 +992,8 @@ def handle_getests():
|
|||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
else:
|
else:
|
||||||
testkey = request.headers.get("X-Kobo-userkey","")
|
testkey = request.headers.get("X-Kobo-userkey", "")
|
||||||
return make_response(jsonify({"Result": "Success", "TestKey":testkey, "Tests": {}}))
|
return make_response(jsonify({"Result": "Success", "TestKey": testkey, "Tests": {}}))
|
||||||
|
|
||||||
|
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@ -1032,7 +1023,7 @@ def make_calibre_web_auth_response():
|
|||||||
content = request.get_json()
|
content = request.get_json()
|
||||||
AccessToken = base64.b64encode(os.urandom(24)).decode('utf-8')
|
AccessToken = base64.b64encode(os.urandom(24)).decode('utf-8')
|
||||||
RefreshToken = base64.b64encode(os.urandom(24)).decode('utf-8')
|
RefreshToken = base64.b64encode(os.urandom(24)).decode('utf-8')
|
||||||
return make_response(
|
return make_response(
|
||||||
jsonify(
|
jsonify(
|
||||||
{
|
{
|
||||||
"AccessToken": AccessToken,
|
"AccessToken": AccessToken,
|
||||||
@ -1170,14 +1161,16 @@ def NATIVE_KOBO_RESOURCES():
|
|||||||
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
|
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
|
||||||
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
|
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
|
||||||
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
|
"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_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
|
||||||
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
|
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
|
||||||
"free_books_page": {
|
"free_books_page": {
|
||||||
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
|
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
|
||||||
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
|
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
|
||||||
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
|
"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",
|
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis",
|
||||||
},
|
},
|
||||||
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
|
"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_stack": "https://storeapi.kobo.com/v1/user/library/stacks/{LibraryItemId}",
|
||||||
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
|
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
|
||||||
"love_dashboard_page": "https://store.kobobooks.com/{culture}/kobosuperpoints",
|
"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",
|
"magazine_landing_page": "https://store.kobobooks.com/emagazines",
|
||||||
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
|
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
|
||||||
"oauth_host": "https://oauth.kobo.com",
|
"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_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
|
||||||
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
|
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
|
||||||
"products": "https://storeapi.kobo.com/v1/products",
|
"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": "https://www.kobo.com/checkout/createpurchase/",
|
||||||
"purchase_buy_templated": "https://www.kobo.com/{culture}/checkout/createpurchase/{ProductId}",
|
"purchase_buy_templated": "https://www.kobo.com/{culture}/checkout/createpurchase/{ProductId}",
|
||||||
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
|
"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 binascii import hexlify
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from os import urandom
|
from os import urandom
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from flask import g, Blueprint, url_for, abort, request
|
from flask import g, Blueprint, url_for, abort, request
|
||||||
from flask_login import login_user, current_user, login_required
|
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 . import logger, config, calibre_db, db, helper, ub, lm
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
|
|
||||||
try:
|
|
||||||
from functools import wraps
|
|
||||||
except ImportError:
|
|
||||||
pass # We're not using Python 3
|
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
||||||
|
|
||||||
def register_url_value_preprocessor(kobo):
|
|
||||||
@kobo.url_value_preprocessor
|
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
||||||
# pylint: disable=unused-variable
|
@login_required
|
||||||
def pop_auth_token(__, values):
|
def generate_auth_token(user_id):
|
||||||
g.auth_token = values.pop("auth_token")
|
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):
|
def disable_failed_auth_redirect_for_blueprint(bp):
|
||||||
@ -97,6 +139,13 @@ def get_auth_token():
|
|||||||
return None
|
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):
|
def requires_kobo_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
@ -114,64 +163,3 @@ def requires_kobo_auth(f):
|
|||||||
log.debug("Received Kobo request without a recognizable auth token.")
|
log.debug("Received Kobo request without a recognizable auth token.")
|
||||||
return abort(401)
|
return abort(401)
|
||||||
return inner
|
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 flask_login import current_user
|
||||||
from . import ub
|
from . import ub
|
||||||
import datetime
|
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,
|
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
|
||||||
# do nothing (safety precaution)
|
# 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
|
# 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):
|
def remove_synced_book(book_id, all=False, session=None):
|
||||||
ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.book_id == book_id) \
|
if not all:
|
||||||
.filter(ub.KoboSyncedBooks.user_id == current_user.id).delete()
|
user = ub.KoboSyncedBooks.user_id == current_user.id
|
||||||
ub.session_commit()
|
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):
|
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
|
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
|
# 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):
|
def update_on_sync_shelfs(user_id):
|
||||||
books_to_archive = (ub.session.query(ub.KoboSyncedBooks)
|
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()
|
.filter(ub.KoboSyncedBooks.user_id == user_id).delete()
|
||||||
ub.session_commit()
|
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(
|
shelves_to_archive = ub.session.query(ub.Shelf).filter(ub.Shelf.user_id == user_id).filter(
|
||||||
ub.Shelf.kobo_sync == 0).all()
|
ub.Shelf.kobo_sync == 0).all()
|
||||||
for a in shelves_to_archive:
|
for a in shelves_to_archive:
|
||||||
|
@ -42,20 +42,15 @@ logging.addLevelName(logging.CRITICAL, "CRIT")
|
|||||||
|
|
||||||
class _Logger(logging.Logger):
|
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 sys.version_info > (3, 7):
|
||||||
if is_debug_enabled():
|
if is_debug_enabled():
|
||||||
self.exception(message, stacklevel=2, *args, **kwargs)
|
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
self.error(message, stacklevel=2, *args, **kwargs)
|
self.error(message, stacklevel=stacklevel, *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)
|
|
||||||
else:
|
else:
|
||||||
if is_debug_enabled():
|
if is_debug_enabled():
|
||||||
self.exception(message, *args, **kwargs)
|
self.exception(message, stack_info=True, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
self.error(message, *args, **kwargs)
|
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/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
# ComicVine api document: https://comicvine.gamespot.com/api/documentation
|
# ComicVine api document: https://comicvine.gamespot.com/api/documentation
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
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):
|
class ComicVine(Metadata):
|
||||||
__name__ = "ComicVine"
|
__name__ = "ComicVine"
|
||||||
__id__ = "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()
|
val = list()
|
||||||
apikey = "57558043c53943d5d1e96a9ad425b0eb85532ee6"
|
|
||||||
if self.active:
|
if self.active:
|
||||||
headers = {
|
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||||
'User-Agent': 'Not Evil Browser'
|
if title_tokens:
|
||||||
}
|
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||||
|
query = "%20".join(tokens)
|
||||||
result = requests.get("https://comicvine.gamespot.com/api/search?api_key="
|
try:
|
||||||
+ apikey + "&resources=issue&query=" + query + "&sort=name:desc&format=json", headers=headers)
|
result = requests.get(
|
||||||
for r in result.json()['results']:
|
f"{ComicVine.BASE_URL}{query}{ComicVine.QUERY_PARAMS}",
|
||||||
seriesTitle = r['volume'].get('name', "")
|
headers=ComicVine.HEADERS,
|
||||||
if r.get('store_date'):
|
)
|
||||||
dateFomers = r.get('store_date')
|
result.raise_for_status()
|
||||||
else:
|
except Exception as e:
|
||||||
dateFomers = r.get('date_added')
|
log.warning(e)
|
||||||
v = dict()
|
return None
|
||||||
v['id'] = r['id']
|
for result in result.json()["results"]:
|
||||||
v['title'] = seriesTitle + " #" + r.get('issue_number', "0") + " - " + ( r.get('name', "") or "")
|
match = self._parse_search_result(
|
||||||
v['authors'] = r.get('authors', [])
|
result=result, generic_cover=generic_cover, locale=locale
|
||||||
v['description'] = r.get('description', "")
|
)
|
||||||
v['publisher'] = ""
|
val.append(match)
|
||||||
v['publishedDate'] = dateFomers
|
|
||||||
v['tags'] = ["Comics", seriesTitle]
|
|
||||||
v['rating'] = 0
|
|
||||||
v['series'] = seriesTitle
|
|
||||||
v['cover'] = r['image'].get('original_url')
|
|
||||||
v['source'] = {
|
|
||||||
"id": self.__id__,
|
|
||||||
"description": "ComicVine Books",
|
|
||||||
"link": "https://comicvine.gamespot.com/"
|
|
||||||
}
|
|
||||||
v['url'] = r.get('site_detail_url', "")
|
|
||||||
val.append(v)
|
|
||||||
return val
|
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/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
# Google Books api document: https://developers.google.com/books/docs/v1/using
|
# 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
|
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):
|
class Google(Metadata):
|
||||||
__name__ = "Google"
|
__name__ = "Google"
|
||||||
__id__ = "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:
|
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
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# 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):
|
class scholar(Metadata):
|
||||||
__name__ = "Google Scholar"
|
__name__ = "Google Scholar"
|
||||||
__id__ = "googlescholar"
|
__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()
|
val = list()
|
||||||
if self.active:
|
if self.active:
|
||||||
scholar_gen = scholarly.search_pubs(' '.join(query.split('+')))
|
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||||
i = 0
|
if title_tokens:
|
||||||
for publication in scholar_gen:
|
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||||
v = dict()
|
query = " ".join(tokens)
|
||||||
v['id'] = "1234" # publication['bib'].get('title')
|
try:
|
||||||
v['title'] = publication['bib'].get('title')
|
scholar_gen = itertools.islice(scholarly.search_pubs(query), 10)
|
||||||
v['authors'] = publication['bib'].get('author', [])
|
except Exception as e:
|
||||||
v['description'] = publication['bib'].get('abstract', "")
|
log.warning(e)
|
||||||
v['publisher'] = publication['bib'].get('venue', "")
|
return None
|
||||||
if publication['bib'].get('pub_year'):
|
for result in scholar_gen:
|
||||||
v['publishedDate'] = publication['bib'].get('pub_year')+"-01-01"
|
match = self._parse_search_result(
|
||||||
else:
|
result=result, generic_cover="", locale=locale
|
||||||
v['publishedDate'] = ""
|
)
|
||||||
v['tags'] = ""
|
val.append(match)
|
||||||
v['ratings'] = 0
|
|
||||||
v['series'] = ""
|
|
||||||
v['cover'] = generic_cover
|
|
||||||
v['url'] = publication.get('pub_url') or publication.get('eprint_url') or "",
|
|
||||||
v['source'] = {
|
|
||||||
"id": self.__id__,
|
|
||||||
"description": "Google Scholar",
|
|
||||||
"link": "https://scholar.google.com/"
|
|
||||||
}
|
|
||||||
val.append(v)
|
|
||||||
i += 1
|
|
||||||
if (i >= 10):
|
|
||||||
break
|
|
||||||
return val
|
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
|
from flask import session
|
||||||
|
|
||||||
try:
|
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
|
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:
|
except ImportError:
|
||||||
# fails on flask-dance >1.3, due to renaming
|
pass
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthBackend(SQLAlchemyBackend):
|
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))
|
log.info("Link to {} Succeeded".format(provider_name))
|
||||||
return redirect(url_for('web.profile'))
|
return redirect(url_for('web.profile'))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.debug_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
else:
|
else:
|
||||||
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
|
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
|
||||||
@ -197,7 +197,7 @@ def unlink_oauth(provider):
|
|||||||
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
|
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
|
||||||
log.info("Unlink to {} Succeeded".format(oauth_check[provider]))
|
log.info("Unlink to {} Succeeded".format(oauth_check[provider]))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.debug_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
|
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
|
||||||
except NoResultFound:
|
except NoResultFound:
|
||||||
|
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 import Blueprint, request, render_template, Response, g, make_response, abort
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
from flask_babel import get_locale
|
||||||
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||||
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
from werkzeug.security import check_password_hash
|
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 .helper import get_download_link, get_book_cover
|
||||||
from .pagination import Pagination
|
from .pagination import Pagination
|
||||||
from .web import render_read_books
|
from .web import render_read_books
|
||||||
@ -54,20 +56,6 @@ def requires_basic_auth_if_no_ano(f):
|
|||||||
return decorated
|
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/")
|
||||||
@opds.route("/opds")
|
@opds.route("/opds")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
@ -86,7 +74,7 @@ def feed_osd():
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_cc_search(query):
|
def feed_cc_search(query):
|
||||||
# Handle strange query from Libera Reader with + instead of spaces
|
# 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)
|
return feed_search(plus_query)
|
||||||
|
|
||||||
|
|
||||||
@ -99,26 +87,7 @@ def feed_normal_search():
|
|||||||
@opds.route("/opds/books")
|
@opds.route("/opds/books")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_booksindex():
|
def feed_booksindex():
|
||||||
shift = 0
|
return render_element_index(db.Books.sort, None, 'opds.feed_letter_books')
|
||||||
off = int(request.args.get("offset") or 0)
|
|
||||||
entries = calibre_db.session.query(func.upper(func.substr(db.Books.sort, 1, 1)).label('id'))\
|
|
||||||
.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(db.Books.sort, 1, 1))).all()
|
|
||||||
|
|
||||||
elements = []
|
|
||||||
if off == 0:
|
|
||||||
elements.append({'id': "00", 'name':_("All")})
|
|
||||||
shift = 1
|
|
||||||
for entry in entries[
|
|
||||||
off + shift - 1:
|
|
||||||
int(off + int(config.config_books_per_page) - shift)]:
|
|
||||||
elements.append({'id': entry.id, 'name': entry.id})
|
|
||||||
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(entries) + 1)
|
|
||||||
return render_xml_template('feed.xml',
|
|
||||||
letterelements=elements,
|
|
||||||
folder='opds.feed_letter_books',
|
|
||||||
pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/books/letter/<book_id>")
|
@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,
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
letter,
|
letter,
|
||||||
[db.Books.sort])
|
[db.Books.sort],
|
||||||
|
True, config.config_read_column)
|
||||||
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
@ -139,15 +109,16 @@ def feed_letter_books(book_id):
|
|||||||
def feed_new():
|
def feed_new():
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 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)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/discover")
|
@opds.route("/opds/discover")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_discover():
|
def feed_discover():
|
||||||
entries = calibre_db.session.query(db.Books).filter(calibre_db.common_filters()).order_by(func.random())\
|
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||||
.limit(config.config_books_per_page)
|
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))
|
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
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
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 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, 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)
|
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)
|
hot_books = all_books.offset(off).limit(config.config_books_per_page)
|
||||||
entries = list()
|
entries = list()
|
||||||
for book in hot_books:
|
for book in hot_books:
|
||||||
downloadBook = calibre_db.get_book(book.Downloads.book_id)
|
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||||
if downloadBook:
|
download_book = query.filter(calibre_db.common_filters()).filter(
|
||||||
entries.append(
|
book.Downloads.book_id == db.Books.id).first()
|
||||||
calibre_db.get_filtered_book(book.Downloads.book_id)
|
if download_book:
|
||||||
)
|
entries.append(download_book)
|
||||||
else:
|
else:
|
||||||
ub.delete_download(book.Downloads.book_id)
|
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),
|
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)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/author")
|
@opds.route("/opds/author")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_authorindex():
|
def feed_authorindex():
|
||||||
shift = 0
|
return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/author/letter/<book_id>")
|
@opds.route("/opds/author/letter/<book_id>")
|
||||||
@ -228,12 +180,7 @@ def feed_letter_author(book_id):
|
|||||||
@opds.route("/opds/author/<int:book_id>")
|
@opds.route("/opds/author/<int:book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_author(book_id):
|
def feed_author(book_id):
|
||||||
off = request.args.get("offset") or 0
|
return render_xml_dataset(db.Authors, book_id)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/publisher")
|
@opds.route("/opds/publisher")
|
||||||
@ -254,37 +201,14 @@ def feed_publisherindex():
|
|||||||
@opds.route("/opds/publisher/<int:book_id>")
|
@opds.route("/opds/publisher/<int:book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_publisher(book_id):
|
def feed_publisher(book_id):
|
||||||
off = request.args.get("offset") or 0
|
return render_xml_dataset(db.Publishers, book_id)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/category")
|
@opds.route("/opds/category")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_categoryindex():
|
def feed_categoryindex():
|
||||||
shift = 0
|
return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
|
||||||
off = int(request.args.get("offset") or 0)
|
|
||||||
entries = calibre_db.session.query(func.upper(func.substr(db.Tags.name, 1, 1)).label('id'))\
|
|
||||||
.join(db.books_tags_link).join(db.Books).filter(calibre_db.common_filters())\
|
|
||||||
.group_by(func.upper(func.substr(db.Tags.name, 1, 1))).all()
|
|
||||||
elements = []
|
|
||||||
if off == 0:
|
|
||||||
elements.append({'id': "00", 'name':_("All")})
|
|
||||||
shift = 1
|
|
||||||
for entry in entries[
|
|
||||||
off + shift - 1:
|
|
||||||
int(off + int(config.config_books_per_page) - shift)]:
|
|
||||||
elements.append({'id': entry.id, 'name': entry.id})
|
|
||||||
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(entries) + 1)
|
|
||||||
return render_xml_template('feed.xml',
|
|
||||||
letterelements=elements,
|
|
||||||
folder='opds.feed_letter_category',
|
|
||||||
pagination=pagination)
|
|
||||||
|
|
||||||
@opds.route("/opds/category/letter/<book_id>")
|
@opds.route("/opds/category/letter/<book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
@ -306,36 +230,14 @@ def feed_letter_category(book_id):
|
|||||||
@opds.route("/opds/category/<int:book_id>")
|
@opds.route("/opds/category/<int:book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_category(book_id):
|
def feed_category(book_id):
|
||||||
off = request.args.get("offset") or 0
|
return render_xml_dataset(db.Tags, book_id)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/series")
|
@opds.route("/opds/series")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_seriesindex():
|
def feed_seriesindex():
|
||||||
shift = 0
|
return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
|
||||||
off = int(request.args.get("offset") or 0)
|
|
||||||
entries = calibre_db.session.query(func.upper(func.substr(db.Series.sort, 1, 1)).label('id'))\
|
|
||||||
.join(db.books_series_link).join(db.Books).filter(calibre_db.common_filters())\
|
|
||||||
.group_by(func.upper(func.substr(db.Series.sort, 1, 1))).all()
|
|
||||||
elements = []
|
|
||||||
if off == 0:
|
|
||||||
elements.append({'id': "00", 'name':_("All")})
|
|
||||||
shift = 1
|
|
||||||
for entry in entries[
|
|
||||||
off + shift - 1:
|
|
||||||
int(off + int(config.config_books_per_page) - shift)]:
|
|
||||||
elements.append({'id': entry.id, 'name': entry.id})
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(entries) + 1)
|
|
||||||
return render_xml_template('feed.xml',
|
|
||||||
letterelements=elements,
|
|
||||||
folder='opds.feed_letter_series',
|
|
||||||
pagination=pagination)
|
|
||||||
|
|
||||||
@opds.route("/opds/series/letter/<book_id>")
|
@opds.route("/opds/series/letter/<book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@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,
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.series.any(db.Series.id == book_id),
|
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)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@ -370,7 +273,7 @@ def feed_series(book_id):
|
|||||||
def feed_ratingindex():
|
def feed_ratingindex():
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
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_ratings_link)\
|
||||||
.join(db.Books)\
|
.join(db.Books)\
|
||||||
.filter(calibre_db.common_filters()) \
|
.filter(calibre_db.common_filters()) \
|
||||||
@ -388,12 +291,7 @@ def feed_ratingindex():
|
|||||||
@opds.route("/opds/ratings/<book_id>")
|
@opds.route("/opds/ratings/<book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_ratings(book_id):
|
def feed_ratings(book_id):
|
||||||
off = request.args.get("offset") or 0
|
return render_xml_dataset(db.Ratings, book_id)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/formats")
|
@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,
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.data.any(db.Data.format == book_id.upper()),
|
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)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@ -432,17 +331,9 @@ def feed_languagesindex():
|
|||||||
if current_user.filter_language() == u"all":
|
if current_user.filter_language() == u"all":
|
||||||
languages = calibre_db.speaking_language()
|
languages = calibre_db.speaking_language()
|
||||||
else:
|
else:
|
||||||
#try:
|
|
||||||
# cur_l = LC.parse(current_user.filter_language())
|
|
||||||
#except UnknownLocaleError:
|
|
||||||
# cur_l = None
|
|
||||||
languages = calibre_db.session.query(db.Languages).filter(
|
languages = calibre_db.session.query(db.Languages).filter(
|
||||||
db.Languages.lang_code == current_user.filter_language()).all()
|
db.Languages.lang_code == current_user.filter_language()).all()
|
||||||
languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
|
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,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(languages))
|
len(languages))
|
||||||
return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination)
|
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,
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.languages.any(db.Languages.id == book_id),
|
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)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@ -485,13 +377,25 @@ def feed_shelf(book_id):
|
|||||||
result = list()
|
result = list()
|
||||||
# user is allowed to access shelf
|
# user is allowed to access shelf
|
||||||
if shelf:
|
if shelf:
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == book_id).order_by(
|
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
||||||
ub.BookShelf.order.asc()).all()
|
config.config_books_per_page,
|
||||||
for book in books_in_shelf:
|
db.Books,
|
||||||
cur_book = calibre_db.get_book(book.book_id)
|
ub.BookShelf.shelf == shelf.id,
|
||||||
result.append(cur_book)
|
[ub.BookShelf.order.asc()],
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
True, config.config_read_column,
|
||||||
len(result))
|
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)
|
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
|
@requires_basic_auth_if_no_ano
|
||||||
def opds_download_link(book_id, book_format):
|
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
|
# 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
|
# in case of anonymous browsing user is None
|
||||||
user = load_user_from_request(request) or current_user
|
user = load_user_from_request(request) or current_user
|
||||||
if not user.role_download():
|
if not user.role_download():
|
||||||
@ -525,47 +429,6 @@ def get_metadata_calibre_companion(uuid, library):
|
|||||||
return ""
|
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/thumb_240_240/<book_id>")
|
||||||
@opds.route("/opds/cover_240_240/<book_id>")
|
@opds.route("/opds/cover_240_240/<book_id>")
|
||||||
@opds.route("/opds/cover_90_90/<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
|
off = request.args.get("offset") or 0
|
||||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
|
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)
|
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):
|
def has_next(self):
|
||||||
return self.page < self.pages
|
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
|
# 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 shwn
|
# 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 shwn
|
# 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 shwn
|
# 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,
|
def iter_pages(self, left_edge=2, left_current=2,
|
||||||
right_current=4, right_edge=2):
|
right_current=4, right_edge=2):
|
||||||
last = 0
|
last = 0
|
||||||
|
@ -27,10 +27,7 @@
|
|||||||
|
|
||||||
# http://flask.pocoo.org/snippets/62/
|
# http://flask.pocoo.org/snippets/62/
|
||||||
|
|
||||||
try:
|
from urllib.parse import urlparse, urljoin
|
||||||
from urllib.parse import urlparse, urljoin
|
|
||||||
except ImportError:
|
|
||||||
from urlparse import urlparse, urljoin
|
|
||||||
|
|
||||||
from flask import request, url_for, redirect
|
from flask import request, url_for, redirect
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
|
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
|
||||||
from flask_login import login_required, current_user, login_user
|
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 . import config, logger, ub
|
||||||
from .render_template import render_title_template
|
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__)
|
remotelogin = Blueprint('remotelogin', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
@ -16,13 +16,12 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template, g, abort, request
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask import g
|
|
||||||
from werkzeug.local import LocalProxy
|
from werkzeug.local import LocalProxy
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
from . import config, constants, ub, logger, db, calibre_db
|
from . import config, constants, logger
|
||||||
from .ub import User
|
from .ub import User
|
||||||
|
|
||||||
|
|
||||||
@ -30,6 +29,8 @@ log = logger.create()
|
|||||||
|
|
||||||
def get_sidebar_config(kwargs=None):
|
def get_sidebar_config(kwargs=None):
|
||||||
kwargs = kwargs or []
|
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:
|
if 'content' in kwargs:
|
||||||
content = kwargs['content']
|
content = kwargs['content']
|
||||||
content = isinstance(content, (User, LocalProxy)) and not content.role_anonymous()
|
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",
|
{"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
|
||||||
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not g.user.is_anonymous), "page": "archived",
|
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not g.user.is_anonymous), "page": "archived",
|
||||||
"show_text": _('Show archived books'), "config_show": content})
|
"show_text": _('Show archived books'), "config_show": content})
|
||||||
sidebar.append(
|
if not simple:
|
||||||
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
sidebar.append(
|
||||||
"visibility": constants.SIDEBAR_LIST, 'public': (not g.user.is_anonymous), "page": "list",
|
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
||||||
"show_text": _('Show Books List'), "config_show": content})
|
"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:
|
if not config.config_read_column:
|
||||||
readBooks = ub.session.query(ub.ReadBook).filter(ub.ReadBook.user_id == int(current_user.id))\
|
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()
|
.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])\
|
readBooks = calibre_db.session.query(db.cc_classes[config.config_read_column])\
|
||||||
.filter(db.cc_classes[config.config_read_column].value == True).all()
|
.filter(db.cc_classes[config.config_read_column].value == True).all()
|
||||||
return frozenset([x.book for x in readBooks])
|
return frozenset([x.book for x in readBooks])
|
||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError, IndexError):
|
||||||
log.error("Custom Column No.%d is not existing in calibre database", config.config_read_column)
|
log.error("Custom Column No.{} is not existing in calibre database".format(config.config_read_column))
|
||||||
return []
|
return []'''
|
||||||
|
|
||||||
# Returns the template for rendering and includes the instance name
|
# Returns the template for rendering and includes the instance name
|
||||||
def render_title_template(*args, **kwargs):
|
def render_title_template(*args, **kwargs):
|
||||||
sidebar = get_sidebar_config(kwargs)
|
sidebar, simple = get_sidebar_config(kwargs)
|
||||||
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar,
|
try:
|
||||||
accept=constants.EXTENSIONS_UPLOAD, read_book_ids=get_readbooks_ids(),
|
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple,
|
||||||
*args, **kwargs)
|
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
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# 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 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 current_user
|
||||||
from flask_login import login_required
|
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.orm.attributes import flag_modified
|
||||||
from sqlalchemy.exc import OperationalError, InvalidRequestError
|
|
||||||
|
|
||||||
from . import constants, logger, ub
|
|
||||||
from cps.services.Metadata import Metadata
|
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()
|
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()
|
new_list = list()
|
||||||
meta_dir = os.path.join(constants.BASE_DIR, "cps", "metadata_provider")
|
meta_dir = os.path.join(constants.BASE_DIR, "cps", "metadata_provider")
|
||||||
modules = os.listdir(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:
|
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]
|
a = os.path.basename(f)[:-3]
|
||||||
try:
|
try:
|
||||||
importlib.import_module("cps.metadata_provider." + a)
|
importlib.import_module("cps.metadata_provider." + a)
|
||||||
new_list.append(a)
|
new_list.append(a)
|
||||||
except ImportError:
|
except (IndentationError, SyntaxError) as e:
|
||||||
log.error("Import error for metadata source: {}".format(a))
|
log.error("Syntax error for metadata source: {} - {}".format(a, e))
|
||||||
pass
|
except ImportError as e:
|
||||||
|
log.debug("Import error for metadata source: {} - {}".format(a, e))
|
||||||
|
|
||||||
|
|
||||||
def list_classes(provider_list):
|
def list_classes(provider_list):
|
||||||
classes = list()
|
classes = list()
|
||||||
for element in provider_list:
|
for element in provider_list:
|
||||||
for name, obj in inspect.getmembers(sys.modules["cps.metadata_provider." + element]):
|
for name, obj in inspect.getmembers(
|
||||||
if inspect.isclass(obj) and name != "Metadata" and issubclass(obj, Metadata):
|
sys.modules["cps.metadata_provider." + element]
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
inspect.isclass(obj)
|
||||||
|
and name != "Metadata"
|
||||||
|
and issubclass(obj, Metadata)
|
||||||
|
):
|
||||||
classes.append(obj())
|
classes.append(obj())
|
||||||
return classes
|
return classes
|
||||||
|
|
||||||
|
|
||||||
cl = list_classes(new_list)
|
cl = list_classes(new_list)
|
||||||
|
|
||||||
|
|
||||||
@meta.route("/metadata/provider")
|
@meta.route("/metadata/provider")
|
||||||
@login_required
|
@login_required
|
||||||
def metadata_provider():
|
def metadata_provider():
|
||||||
active = current_user.view_settings.get('metadata', {})
|
active = current_user.view_settings.get("metadata", {})
|
||||||
provider = list()
|
provider = list()
|
||||||
for c in cl:
|
for c in cl:
|
||||||
ac = active.get(c.__id__, True)
|
ac = active.get(c.__id__, True)
|
||||||
provider.append({"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__})
|
provider.append(
|
||||||
return Response(json.dumps(provider), mimetype='application/json')
|
{"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
|
@login_required
|
||||||
def metadata_change_active_provider(prov_name):
|
def metadata_change_active_provider(prov_name):
|
||||||
new_state = request.get_json()
|
new_state = request.get_json()
|
||||||
active = current_user.view_settings.get('metadata', {})
|
active = current_user.view_settings.get("metadata", {})
|
||||||
active[new_state['id']] = new_state['value']
|
active[new_state["id"]] = new_state["value"]
|
||||||
current_user.view_settings['metadata'] = active
|
current_user.view_settings["metadata"] = active
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
flag_modified(current_user, "view_settings")
|
flag_modified(current_user, "view_settings")
|
||||||
@ -89,29 +111,33 @@ def metadata_change_active_provider(prov_name):
|
|||||||
log.error("Invalid request received: {}".format(request))
|
log.error("Invalid request received: {}".format(request))
|
||||||
return "Invalid request", 400
|
return "Invalid request", 400
|
||||||
if "initial" in new_state and prov_name:
|
if "initial" in new_state and prov_name:
|
||||||
for c in cl:
|
data = []
|
||||||
if c.__id__ == prov_name:
|
provider = next((c for c in cl if c.__id__ == prov_name), None)
|
||||||
data = c.search(new_state.get('query', ""))
|
if provider is not None:
|
||||||
break
|
data = provider.search(new_state.get("query", ""))
|
||||||
return Response(json.dumps(data), mimetype='application/json')
|
return Response(
|
||||||
|
json.dumps([asdict(x) for x in data]), mimetype="application/json"
|
||||||
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@meta.route("/metadata/search", methods=['POST'])
|
|
||||||
|
@meta.route("/metadata/search", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def metadata_search():
|
def metadata_search():
|
||||||
query = request.form.to_dict().get('query')
|
query = request.form.to_dict().get("query")
|
||||||
data = list()
|
data = list()
|
||||||
active = current_user.view_settings.get('metadata', {})
|
active = current_user.view_settings.get("metadata", {})
|
||||||
|
locale = get_locale()
|
||||||
if query:
|
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:
|
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):
|
for future in concurrent.futures.as_completed(meta):
|
||||||
data.extend(future.result())
|
data.extend([asdict(x) for x in future.result() if x])
|
||||||
return Response(json.dumps(data), mimetype='application/json')
|
# 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:
|
try:
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
from .gevent_wsgi import MyWSGIHandler
|
||||||
from gevent.pool import Pool
|
from gevent.pool import Pool
|
||||||
from gevent import __version__ as _version
|
from gevent import __version__ as _version
|
||||||
from greenlet import GreenletExit
|
from greenlet import GreenletExit
|
||||||
@ -32,7 +33,7 @@ try:
|
|||||||
VERSION = 'Gevent ' + _version
|
VERSION = 'Gevent ' + _version
|
||||||
_GEVENT = True
|
_GEVENT = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from tornado.wsgi import WSGIContainer
|
from .tornado_wsgi import MyWSGIContainer
|
||||||
from tornado.httpserver import HTTPServer
|
from tornado.httpserver import HTTPServer
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
from tornado import version as _version
|
from tornado import version as _version
|
||||||
@ -202,7 +203,8 @@ class WebServer(object):
|
|||||||
if output is None:
|
if output is None:
|
||||||
output = _readable_listen_address(self.listen_address, self.listen_port)
|
output = _readable_listen_address(self.listen_address, self.listen_port)
|
||||||
log.info('Starting Gevent server on %s', output)
|
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:
|
if ssl_args:
|
||||||
wrap_socket = self.wsgiserver.wrap_socket
|
wrap_socket = self.wsgiserver.wrap_socket
|
||||||
def my_wrap_socket(*args, **kwargs):
|
def my_wrap_socket(*args, **kwargs):
|
||||||
@ -225,8 +227,8 @@ class WebServer(object):
|
|||||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||||
log.info('Starting Tornado server on %s', _readable_listen_address(self.listen_address, self.listen_port))
|
log.info('Starting Tornado server on %s', _readable_listen_address(self.listen_address, self.listen_port))
|
||||||
|
|
||||||
# Max Buffersize set to 200MB )
|
# Max Buffersize set to 200MB
|
||||||
http_server = HTTPServer(WSGIContainer(self.app),
|
http_server = HTTPServer(MyWSGIContainer(self.app),
|
||||||
max_buffer_size=209700000,
|
max_buffer_size=209700000,
|
||||||
ssl_options=self.ssl_args)
|
ssl_options=self.ssl_args)
|
||||||
http_server.listen(self.listen_port, self.listen_address)
|
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
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# 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"
|
__name__ = "Generic"
|
||||||
|
__id__ = "generic"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.active = True
|
self.active = True
|
||||||
|
|
||||||
def set_status(self, state):
|
def set_status(self, state):
|
||||||
self.active = 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 base64 import b64decode, b64encode
|
||||||
from jsonschema import validate, exceptions, __version__
|
from jsonschema import validate, exceptions, __version__
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
try:
|
|
||||||
# pylint: disable=unused-import
|
from urllib.parse import unquote
|
||||||
from urllib import unquote
|
|
||||||
except ImportError:
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from flask import json
|
from flask import json
|
||||||
from .. import logger
|
from .. import logger
|
||||||
|
@ -18,11 +18,10 @@
|
|||||||
|
|
||||||
from .. import logger
|
from .. import logger
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
try:
|
||||||
try: from . import goodreads_support
|
from . import goodreads_support
|
||||||
except ImportError as err:
|
except ImportError as err:
|
||||||
log.debug("Cannot import goodreads, showing authors-metadata will not work: %s", err)
|
log.debug("Cannot import goodreads, showing authors-metadata will not work: %s", err)
|
||||||
goodreads_support = None
|
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
|
from datetime import datetime
|
||||||
import base64
|
import base64
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from ..constants import BASE_DIR
|
from ..constants import CONFIG_DIR
|
||||||
from .. import logger
|
from .. import logger
|
||||||
|
|
||||||
|
|
||||||
@ -53,11 +53,11 @@ def setup_gmail(token):
|
|||||||
if creds and creds.expired and creds.refresh_token:
|
if creds and creds.expired and creds.refresh_token:
|
||||||
creds.refresh(Request())
|
creds.refresh(Request())
|
||||||
else:
|
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):
|
if not os.path.exists(cred_file):
|
||||||
raise Exception(_("Found no valid gmail.json file with OAuth information"))
|
raise Exception(_("Found no valid gmail.json file with OAuth information"))
|
||||||
flow = InstalledAppFlow.from_client_secrets_file(
|
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)
|
creds = flow.run_local_server(port=0)
|
||||||
user_info = get_user_info(creds)
|
user_info = get_user_info(creds)
|
||||||
return {
|
return {
|
||||||
|
@ -37,11 +37,13 @@ STAT_WAITING = 0
|
|||||||
STAT_FAIL = 1
|
STAT_FAIL = 1
|
||||||
STAT_STARTED = 2
|
STAT_STARTED = 2
|
||||||
STAT_FINISH_SUCCESS = 3
|
STAT_FINISH_SUCCESS = 3
|
||||||
|
STAT_ENDED = 4
|
||||||
|
STAT_CANCELLED = 5
|
||||||
|
|
||||||
# Only retain this many tasks in dequeued list
|
# Only retain this many tasks in dequeued list
|
||||||
TASK_CLEANUP_TRIGGER = 20
|
TASK_CLEANUP_TRIGGER = 20
|
||||||
|
|
||||||
QueuedTask = namedtuple('QueuedTask', 'num, user, added, task')
|
QueuedTask = namedtuple('QueuedTask', 'num, user, added, task, hidden')
|
||||||
|
|
||||||
|
|
||||||
def _get_main_thread():
|
def _get_main_thread():
|
||||||
@ -51,7 +53,6 @@ def _get_main_thread():
|
|||||||
raise Exception("main thread not found?!")
|
raise Exception("main thread not found?!")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ImprovedQueue(queue.Queue):
|
class ImprovedQueue(queue.Queue):
|
||||||
def to_list(self):
|
def to_list(self):
|
||||||
"""
|
"""
|
||||||
@ -61,12 +62,13 @@ class ImprovedQueue(queue.Queue):
|
|||||||
with self.mutex:
|
with self.mutex:
|
||||||
return list(self.queue)
|
return list(self.queue)
|
||||||
|
|
||||||
|
|
||||||
# Class for all worker tasks in the background
|
# Class for all worker tasks in the background
|
||||||
class WorkerThread(threading.Thread):
|
class WorkerThread(threading.Thread):
|
||||||
_instance = None
|
_instance = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def getInstance(cls):
|
def get_instance(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = WorkerThread()
|
cls._instance = WorkerThread()
|
||||||
return cls._instance
|
return cls._instance
|
||||||
@ -82,15 +84,17 @@ class WorkerThread(threading.Thread):
|
|||||||
self.start()
|
self.start()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def add(cls, user, task):
|
def add(cls, user, task, hidden=False):
|
||||||
ins = cls.getInstance()
|
ins = cls.get_instance()
|
||||||
ins.num += 1
|
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(
|
ins.queue.put(QueuedTask(
|
||||||
num=ins.num,
|
num=ins.num,
|
||||||
user=user,
|
user=username,
|
||||||
added=datetime.now(),
|
added=datetime.now(),
|
||||||
task=task,
|
task=task,
|
||||||
|
hidden=hidden
|
||||||
))
|
))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -111,10 +115,10 @@ class WorkerThread(threading.Thread):
|
|||||||
if delta > TASK_CLEANUP_TRIGGER:
|
if delta > TASK_CLEANUP_TRIGGER:
|
||||||
ret = alive
|
ret = alive
|
||||||
else:
|
else:
|
||||||
# otherwise, lop off the oldest dead tasks until we hit the target trigger
|
# otherwise, loop 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
|
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
|
# Main thread loop starting the different tasks
|
||||||
def run(self):
|
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
|
# sometimes tasks (like Upload) don't actually have work to do and are created as already finished
|
||||||
if item.task.stat is STAT_WAITING:
|
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)
|
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()
|
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:
|
class CalibreTask:
|
||||||
__metaclass__ = abc.ABCMeta
|
__metaclass__ = abc.ABCMeta
|
||||||
@ -158,10 +172,12 @@ class CalibreTask:
|
|||||||
self.end_time = None
|
self.end_time = None
|
||||||
self.message = message
|
self.message = message
|
||||||
self.id = uuid.uuid4()
|
self.id = uuid.uuid4()
|
||||||
|
self.self_cleanup = False
|
||||||
|
self._scheduled = False
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
"""Provides the caller some human-readable name for this class"""
|
"""The main entry-point for this task"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
@ -169,6 +185,11 @@ class CalibreTask:
|
|||||||
"""Provides the caller some human-readable name for this class"""
|
"""Provides the caller some human-readable name for this class"""
|
||||||
raise NotImplementedError
|
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):
|
def start(self, *args):
|
||||||
self.start_time = datetime.now()
|
self.start_time = datetime.now()
|
||||||
self.stat = STAT_STARTED
|
self.stat = STAT_STARTED
|
||||||
@ -178,7 +199,7 @@ class CalibreTask:
|
|||||||
self.run(*args)
|
self.run(*args)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
self._handleError(str(ex))
|
self._handleError(str(ex))
|
||||||
log.debug_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
|
|
||||||
self.end_time = datetime.now()
|
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
|
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"
|
# 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
|
@property
|
||||||
def progress(self, x):
|
def self_cleanup(self):
|
||||||
if x > 1:
|
return self._self_cleanup
|
||||||
x = 1
|
|
||||||
if x < 0:
|
@self_cleanup.setter
|
||||||
x = 0
|
def self_cleanup(self, is_self_cleanup):
|
||||||
self._progress = x'''
|
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):
|
def _handleError(self, error_message):
|
||||||
self.stat = STAT_FAIL
|
self.stat = STAT_FAIL
|
||||||
|
223
cps/shelf.py
223
cps/shelf.py
@ -23,7 +23,7 @@
|
|||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
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_babel import gettext as _
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
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 .render_template import render_title_template
|
||||||
from .usermanagement import login_required_if_no_ano
|
from .usermanagement import login_required_if_no_ano
|
||||||
|
|
||||||
shelf = Blueprint('shelf', __name__)
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
shelf = Blueprint('shelf', __name__)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def check_shelf_view_permissions(cur_shelf):
|
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||||
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>")
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_to_shelf(shelf_id, book_id):
|
def add_to_shelf(shelf_id, book_id):
|
||||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
@ -94,10 +76,10 @@ def add_to_shelf(shelf_id, book_id):
|
|||||||
try:
|
try:
|
||||||
ub.session.merge(shelf)
|
ub.session.merge(shelf)
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
except (OperationalError, InvalidRequestError):
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error("Settings DB is not Writeable")
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Settings DB is not Writeable"), category="error")
|
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||||
if "HTTP_REFERER" in request.environ:
|
if "HTTP_REFERER" in request.environ:
|
||||||
return redirect(request.environ["HTTP_REFERER"])
|
return redirect(request.environ["HTTP_REFERER"])
|
||||||
else:
|
else:
|
||||||
@ -112,12 +94,12 @@ def add_to_shelf(shelf_id, book_id):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/massadd/<int:shelf_id>")
|
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def search_to_shelf(shelf_id):
|
def search_to_shelf(shelf_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
if shelf is None:
|
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")
|
flash(_(u"Invalid shelf specified"), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
@ -154,17 +136,17 @@ def search_to_shelf(shelf_id):
|
|||||||
ub.session.merge(shelf)
|
ub.session.merge(shelf)
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
||||||
except (OperationalError, InvalidRequestError):
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error("Settings DB is not Writeable")
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_("Settings DB is not Writeable"), category="error")
|
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||||
else:
|
else:
|
||||||
log.error("Could not add books to shelf: {}".format(shelf.name))
|
log.error("Could not add books to shelf: {}".format(shelf.name))
|
||||||
flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
|
flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>")
|
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def remove_from_shelf(shelf_id, book_id):
|
def remove_from_shelf(shelf_id, book_id):
|
||||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
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)
|
ub.session.delete(book_shelf)
|
||||||
shelf.last_modified = datetime.utcnow()
|
shelf.last_modified = datetime.utcnow()
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
except (OperationalError, InvalidRequestError):
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error("Settings DB is not Writeable")
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_("Settings DB is not Writeable"), category="error")
|
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||||
if "HTTP_REFERER" in request.environ:
|
if "HTTP_REFERER" in request.environ:
|
||||||
return redirect(request.environ["HTTP_REFERER"])
|
return redirect(request.environ["HTTP_REFERER"])
|
||||||
else:
|
else:
|
||||||
@ -228,7 +210,6 @@ def create_shelf():
|
|||||||
return create_edit_shelf(shelf, page_title=_(u"Create a Shelf"), page="shelfcreate")
|
return create_edit_shelf(shelf, page_title=_(u"Create a Shelf"), page="shelfcreate")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_shelf(shelf_id):
|
def edit_shelf(shelf_id):
|
||||||
@ -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)
|
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
|
# if shelf ID is set, we are editing a shelf
|
||||||
def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
||||||
sync_only_selected_shelves = current_user.kobo_only_shelves_sync
|
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":
|
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")
|
flash(_(u"Sorry you are not allowed to create a public shelf"), category="error")
|
||||||
return redirect(url_for('web.index'))
|
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:
|
if config.config_kobo_sync:
|
||||||
shelf.kobo_sync = True if to_save.get("kobo_sync") else False
|
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", "")
|
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.name = shelf_title
|
||||||
|
shelf.is_public = is_public
|
||||||
if not shelf_id:
|
if not shelf_id:
|
||||||
shelf.user_id = int(current_user.id)
|
shelf.user_id = int(current_user.id)
|
||||||
ub.session.add(shelf)
|
ub.session.add(shelf)
|
||||||
@ -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))
|
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
||||||
except (OperationalError, InvalidRequestError) as ex:
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.debug_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
log.error("Settings DB is not Writeable")
|
log.error_or_exception("Settings Database error: {}".format(ex))
|
||||||
flash(_("Settings DB is not Writeable"), category="error")
|
flash(_(u"Database error: %(error)s.", error=ex.orig), category="error")
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.debug_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
flash(_(u"There was an error"), category="error")
|
flash(_(u"There was an error"), category="error")
|
||||||
return render_title_template('shelf_edit.html',
|
return render_title_template('shelf_edit.html',
|
||||||
shelf=shelf,
|
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)
|
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:
|
if shelf_id:
|
||||||
ident = ub.Shelf.id != shelf_id
|
ident = ub.Shelf.id != shelf_id
|
||||||
else:
|
else:
|
||||||
ident = true()
|
ident = true()
|
||||||
if shelf.is_public == 1:
|
if is_public == 1:
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||||
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 1)) \
|
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 1)) \
|
||||||
.filter(ident) \
|
.filter(ident) \
|
||||||
@ -315,70 +384,13 @@ def check_shelf_is_unique(shelf, title, shelf_id=False):
|
|||||||
|
|
||||||
def delete_shelf_helper(cur_shelf):
|
def delete_shelf_helper(cur_shelf):
|
||||||
if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
|
if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
|
||||||
return
|
return False
|
||||||
shelf_id = cur_shelf.id
|
shelf_id = cur_shelf.id
|
||||||
ub.session.delete(cur_shelf)
|
ub.session.delete(cur_shelf)
|
||||||
ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
|
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.add(ub.ShelfArchive(uuid=cur_shelf.uuid, user_id=cur_shelf.user_id))
|
||||||
ub.session_commit("successfully deleted Shelf {}".format(cur_shelf.name))
|
ub.session_commit("successfully deleted Shelf {}".format(cur_shelf.name))
|
||||||
|
return True
|
||||||
|
|
||||||
@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")
|
|
||||||
|
|
||||||
|
|
||||||
def change_shelf_order(shelf_id, order):
|
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
|
# check user is allowed to access shelf
|
||||||
if shelf and check_shelf_view_permissions(shelf):
|
if shelf and check_shelf_view_permissions(shelf):
|
||||||
|
|
||||||
if shelf_type == 1:
|
if shelf_type == 1:
|
||||||
# order = [ub.BookShelf.order.asc()]
|
# order = [ub.BookShelf.order.asc()]
|
||||||
if sort_param == 'pubnew':
|
if sort_param == 'pubnew':
|
||||||
@ -429,7 +440,7 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
|||||||
db.Books,
|
db.Books,
|
||||||
ub.BookShelf.shelf == shelf_id,
|
ub.BookShelf.shelf == shelf_id,
|
||||||
[ub.BookShelf.order.asc()],
|
[ub.BookShelf.order.asc()],
|
||||||
False, 0,
|
True, config.config_read_column,
|
||||||
ub.BookShelf, ub.BookShelf.book_id == db.Books.id)
|
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
|
# 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) \
|
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:
|
try:
|
||||||
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == entry.book_id).delete()
|
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == entry.book_id).delete()
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
except (OperationalError, InvalidRequestError):
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error("Settings DB is not Writeable")
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_("Settings DB is not Writeable"), category="error")
|
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
||||||
|
|
||||||
return render_title_template(page,
|
return render_title_template(page,
|
||||||
entries=result,
|
entries=result,
|
||||||
|
@ -5150,7 +5150,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
|
|||||||
pointer-events: none
|
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
|
cursor: pointer
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5237,7 +5237,11 @@ body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > d
|
|||||||
margin-bottom: 20px
|
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
|
margin-bottom: 10px
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5468,7 +5472,7 @@ body.admin.modal-open .navbar {
|
|||||||
z-index: 0 !important
|
z-index: 0 !important
|
||||||
}
|
}
|
||||||
|
|
||||||
#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal {
|
#RestartDialog, #ShutdownDialog, #StatusDialog, #deleteModal, #cancelTaskModal {
|
||||||
top: 0;
|
top: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-top: 70px;
|
padding-top: 70px;
|
||||||
@ -5478,7 +5482,7 @@ body.admin.modal-open .navbar {
|
|||||||
background: rgba(0, 0, 0, .5)
|
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";
|
content: "\E208";
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
display: block;
|
display: block;
|
||||||
@ -5500,18 +5504,18 @@ body.admin.modal-open .navbar {
|
|||||||
z-index: 99
|
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);
|
-webkit-transform: translate(0, 0);
|
||||||
-ms-transform: translate(0, 0);
|
-ms-transform: translate(0, 0);
|
||||||
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;
|
width: 450px;
|
||||||
margin: auto
|
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);
|
max-height: calc(100% - 90px);
|
||||||
-webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
|
-webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);
|
||||||
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
|
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;
|
padding: 15px 20px;
|
||||||
border-radius: 3px 3px 0 0;
|
border-radius: 3px 3px 0 0;
|
||||||
line-height: 1.71428571;
|
line-height: 1.71428571;
|
||||||
@ -5535,7 +5539,7 @@ body.admin.modal-open .navbar {
|
|||||||
text-align: left
|
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;
|
padding-right: 10px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #999;
|
color: #999;
|
||||||
@ -5564,6 +5568,11 @@ body.admin.modal-open .navbar {
|
|||||||
font-family: plex-icons-new, serif
|
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 {
|
#RestartDialog > .modal-dialog > .modal-content > .modal-header:after {
|
||||||
content: "Restart Calibre-Web";
|
content: "Restart Calibre-Web";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@ -5588,7 +5597,13 @@ body.admin.modal-open .navbar {
|
|||||||
font-size: 20px
|
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
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5602,7 +5617,7 @@ body.admin.modal-open .navbar {
|
|||||||
text-align: left
|
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;
|
padding: 20px 20px 40px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6em;
|
line-height: 1.6em;
|
||||||
@ -5612,7 +5627,7 @@ body.admin.modal-open .navbar {
|
|||||||
text-align: left
|
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;
|
padding: 20px 20px 0 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6em;
|
line-height: 1.6em;
|
||||||
@ -5621,7 +5636,7 @@ body.admin.modal-open .navbar {
|
|||||||
background: #282828
|
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;
|
float: right;
|
||||||
z-index: 9;
|
z-index: 9;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -5669,6 +5684,18 @@ body.admin.modal-open .navbar {
|
|||||||
border-radius: 3px
|
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) {
|
#RestartDialog > .modal-dialog > .modal-content > .modal-body > .btn-default:not(#restart) {
|
||||||
margin: 25px 0 0 10px
|
margin: 25px 0 0 10px
|
||||||
}
|
}
|
||||||
@ -5681,7 +5708,11 @@ body.admin.modal-open .navbar {
|
|||||||
margin: 0 0 0 10px
|
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)
|
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
|
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)
|
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);
|
max-width: calc(100vw - 40px);
|
||||||
left: 0
|
left: 0
|
||||||
}
|
}
|
||||||
@ -7457,7 +7488,7 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
|||||||
padding: 30px 15px
|
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;
|
left: auto;
|
||||||
right: 34px
|
right: 34px
|
||||||
}
|
}
|
||||||
|
@ -270,7 +270,7 @@ if ($("body.book").length > 0) {
|
|||||||
|
|
||||||
if (position + $("#add-to-shelves").width() > $(window).width()) {
|
if (position + $("#add-to-shelves").width() > $(window).width()) {
|
||||||
positionOff = 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");
|
$("#add-to-shelves").attr("style", "left: " + adsPosition + "px !important; right: auto; top: " + topPos + "px");
|
||||||
} else {
|
} else {
|
||||||
$("#add-to-shelves").attr("style", "left: " + position + "px !important; right: auto; top: " + topPos + "px");
|
$("#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()) {
|
if (position + $("#add-to-shelves").width() > $(window).width()) {
|
||||||
positionOff = 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");
|
$("#add-to-shelves").attr("style", "left: " + adsPosition + "px !important; right: auto; top: " + topPos + "px");
|
||||||
} else {
|
} else {
|
||||||
$("#add-to-shelves").attr("style", "left: " + position + "px !important; right: auto; top: " + topPos + "px");
|
$("#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");
|
$("#add-to-shelf").addClass("empty-ul");
|
||||||
}
|
}
|
||||||
|
|
||||||
shelfLength = $("#add-to-shelves li").length
|
shelfLength = $("#add-to-shelves li").length;
|
||||||
emptyLength = 0
|
emptyLength = 0;
|
||||||
|
|
||||||
$("#add-to-shelves").on("click", "li a", function () {
|
$("#add-to-shelves").on("click", "li a", function () {
|
||||||
console.log("#remove-from-shelves change registered");
|
console.log("#remove-from-shelves change registered");
|
||||||
emptyLength++
|
emptyLength++;
|
||||||
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
if (emptyLength >= shelfLength) {
|
if (emptyLength >= shelfLength) {
|
||||||
|
@ -28,14 +28,24 @@ $("#have_read_cb").on("change", function() {
|
|||||||
data: $(this).closest("form").serialize(),
|
data: $(this).closest("form").serialize(),
|
||||||
error: function(response) {
|
error: function(response) {
|
||||||
var data = [{type:"danger", message:response.responseText}]
|
var data = [{type:"danger", message:response.responseText}]
|
||||||
$("#flash_success").remove();
|
// $("#flash_success").parent().remove();
|
||||||
$("#flash_danger").remove();
|
$("#flash_danger").remove();
|
||||||
|
$(".row-fluid.text-center").remove();
|
||||||
if (!jQuery.isEmptyObject(data)) {
|
if (!jQuery.isEmptyObject(data)) {
|
||||||
data.forEach(function (item) {
|
$("#have_read_cb").prop("checked", !$("#have_read_cb").prop("checked"));
|
||||||
$(".navbar").after('<div class="row-fluid text-center" >' +
|
if($("#bookDetailsModal").is(":visible")) {
|
||||||
'<div id="flash_' + item.type + '" class="alert alert-' + item.type + '">' + item.message + '</div>' +
|
data.forEach(function (item) {
|
||||||
'</div>');
|
$(".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();
|
e.preventDefault();
|
||||||
|
$.ajax({
|
||||||
$.get(this.href)
|
url: $(this).data('href'),
|
||||||
|
method:"post",
|
||||||
|
data: {csrf_token:$("input[name='csrf_token']").val()},
|
||||||
|
})
|
||||||
.done(function() {
|
.done(function() {
|
||||||
var $this = $(this);
|
var $this = $(this);
|
||||||
switch ($this.data("shelf-action")) {
|
switch ($this.data("shelf-action")) {
|
||||||
case "add":
|
case "add":
|
||||||
$("#remove-from-shelves").append(
|
$("#remove-from-shelves").append(
|
||||||
templates.remove({
|
templates.remove({
|
||||||
add: this.href,
|
add: $this.data('href'),
|
||||||
remove: $this.data("remove-href"),
|
remove: $this.data("remove-href"),
|
||||||
content: $("<div>").text(this.textContent).html()
|
content: $("<div>").text(this.textContent).html()
|
||||||
})
|
})
|
||||||
@ -79,7 +92,7 @@ $("#archived_cb").on("change", function() {
|
|||||||
$("#add-to-shelves").append(
|
$("#add-to-shelves").append(
|
||||||
templates.add({
|
templates.add({
|
||||||
add: $this.data("add-href"),
|
add: $this.data("add-href"),
|
||||||
remove: this.href,
|
remove: $this.data('href'),
|
||||||
content: $("<div>").text(this.textContent).html(),
|
content: $("<div>").text(this.textContent).html(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -33,7 +33,7 @@ $(".datepicker").datepicker({
|
|||||||
if (results) {
|
if (results) {
|
||||||
pubDate = new Date(results[1], parseInt(results[2], 10) - 1, results[3]) || new Date(this.value);
|
pubDate = new Date(results[1], parseInt(results[2], 10) - 1, results[3]) || new Date(this.value);
|
||||||
$(this).next('input')
|
$(this).next('input')
|
||||||
.val(pubDate.toLocaleDateString(language))
|
.val(pubDate.toLocaleDateString(language.replaceAll("_","-")))
|
||||||
.removeClass("hidden");
|
.removeClass("hidden");
|
||||||
}
|
}
|
||||||
}).trigger("change");
|
}).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) {
|
function populateForm (book) {
|
||||||
tinymce.get("description").setContent(book.description);
|
tinymce.get("description").setContent(book.description);
|
||||||
var uniqueTags = $.map($("#tags").val().split(","), $.trim);
|
var uniqueTags = getUniqueValues('tags', book)
|
||||||
if ( uniqueTags.length == 1 && uniqueTags[0] == "") {
|
var uniqueLanguages = getUniqueValues('languages', book)
|
||||||
uniqueTags = [];
|
|
||||||
}
|
|
||||||
$.each(book.tags, function(i, el) {
|
|
||||||
if ($.inArray(el, uniqueTags) === -1) uniqueTags.push(el);
|
|
||||||
});
|
|
||||||
var ampSeparatedAuthors = (book.authors || []).join(" & ");
|
var ampSeparatedAuthors = (book.authors || []).join(" & ");
|
||||||
$("#bookAuthor").val(ampSeparatedAuthors);
|
$("#bookAuthor").val(ampSeparatedAuthors);
|
||||||
$("#book_title").val(book.title);
|
$("#book_title").val(book.title);
|
||||||
$("#tags").val(uniqueTags.join(", "));
|
$("#tags").val(uniqueTags.join(", "));
|
||||||
|
$("#languages").val(uniqueLanguages.join(", "));
|
||||||
$("#rating").data("rating").setValue(Math.round(book.rating));
|
$("#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 img").attr("src", book.cover);
|
||||||
$("#cover_url").val(book.cover);
|
$("#cover_url").val(book.cover);
|
||||||
}
|
}
|
||||||
@ -48,7 +55,32 @@ $(function () {
|
|||||||
$("#publisher").val(book.publisher);
|
$("#publisher").val(book.publisher);
|
||||||
if (typeof book.series !== "undefined") {
|
if (typeof book.series !== "undefined") {
|
||||||
$("#series").val(book.series);
|
$("#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) {
|
function doSearch (keyword) {
|
||||||
@ -60,14 +92,19 @@ $(function () {
|
|||||||
data: {"query": keyword},
|
data: {"query": keyword},
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
success: function success(data) {
|
success: function success(data) {
|
||||||
$("#meta-info").html("<ul id=\"book-list\" class=\"media-list\"></ul>");
|
if (data.length) {
|
||||||
data.forEach(function(book) {
|
$("#meta-info").html("<ul id=\"book-list\" class=\"media-list\"></ul>");
|
||||||
var $book = $(templates.bookResult(book));
|
data.forEach(function(book) {
|
||||||
$book.find("img").on("click", function () {
|
var $book = $(templates.bookResult(book));
|
||||||
populateForm(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() {
|
error: function error() {
|
||||||
$("#meta-info").html("<p class=\"text-danger\">" + msg.search_error + "!</p>" + $("#meta-info")[0].innerHTML);
|
$("#meta-info").html("<p class=\"text-danger\">" + msg.search_error + "!</p>" + $("#meta-info")[0].innerHTML);
|
||||||
@ -128,9 +165,7 @@ $(function () {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
keyword = $("#keyword").val();
|
keyword = $("#keyword").val();
|
||||||
$('.pill').each(function(){
|
$('.pill').each(function(){
|
||||||
// console.log($(this).data('control'));
|
|
||||||
$(this).data("initial", $(this).prop('checked'));
|
$(this).data("initial", $(this).prop('checked'));
|
||||||
// console.log($(this).data('initial'));
|
|
||||||
});
|
});
|
||||||
doSearch(keyword);
|
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."
|
||||||
|
});
|
83
cps/static/js/main.js
Normal file → Executable file
83
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
|
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) {
|
function elementSorter(a, b) {
|
||||||
a = +a.slice(0, -2);
|
a = +a.slice(0, -2);
|
||||||
b = +b.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
|
// Syntax has to be bind not on, otherwise problems with firefox
|
||||||
$(".container-fluid").bind("dragenter dragover", function () {
|
$(".container-fluid").bind("dragenter dragover", function () {
|
||||||
if($("#btn-upload").length && !$('body').hasClass('shelforder')) {
|
if($("#btn-upload").length && !$('body').hasClass('shelforder')) {
|
||||||
@ -168,18 +198,18 @@ function confirmDialog(id, dialogid, dataValue, yesFn, noFn) {
|
|||||||
$confirm.modal('show');
|
$confirm.modal('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#delete_confirm").click(function() {
|
$("#delete_confirm").click(function(event) {
|
||||||
//get data-id attribute of the clicked element
|
//get data-id attribute of the clicked element
|
||||||
var deleteId = $(this).data("delete-id");
|
var deleteId = $(this).data("delete-id");
|
||||||
var bookFormat = $(this).data("delete-format");
|
var bookFormat = $(this).data("delete-format");
|
||||||
var ajaxResponse = $(this).data("ajax");
|
var ajaxResponse = $(this).data("ajax");
|
||||||
if (bookFormat) {
|
if (bookFormat) {
|
||||||
window.location.href = getPath() + "/delete/" + deleteId + "/" + bookFormat;
|
postButton(event, getPath() + "/delete/" + deleteId + "/" + bookFormat);
|
||||||
} else {
|
} else {
|
||||||
if (ajaxResponse) {
|
if (ajaxResponse) {
|
||||||
path = getPath() + "/ajax/delete/" + deleteId;
|
path = getPath() + "/ajax/delete/" + deleteId;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method:"get",
|
method:"post",
|
||||||
url: path,
|
url: path,
|
||||||
timeout: 900,
|
timeout: 900,
|
||||||
success:function(data) {
|
success:function(data) {
|
||||||
@ -198,8 +228,7 @@ $("#delete_confirm").click(function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
window.location.href = getPath() + "/delete/" + deleteId;
|
postButton(event, getPath() + "/delete/" + deleteId);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,8 +381,8 @@ $(function() {
|
|||||||
//extraScrollPx: 300
|
//extraScrollPx: 300
|
||||||
});
|
});
|
||||||
$loadMore.on( "append.infiniteScroll", function( event, response, path, data ) {
|
$loadMore.on( "append.infiniteScroll", function( event, response, path, data ) {
|
||||||
|
$(".pagination").addClass("hidden").html(() => $(response).find(".pagination").html());
|
||||||
if ($("body").hasClass("blur")) {
|
if ($("body").hasClass("blur")) {
|
||||||
$(".pagination").addClass("hidden").html(() => $(response).find(".pagination").html());
|
|
||||||
$(" a:not(.dropdown-toggle) ")
|
$(" a:not(.dropdown-toggle) ")
|
||||||
.removeAttr("data-toggle");
|
.removeAttr("data-toggle");
|
||||||
}
|
}
|
||||||
@ -376,9 +405,11 @@ $(function() {
|
|||||||
|
|
||||||
$("#restart").click(function() {
|
$("#restart").click(function() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
|
method:"post",
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: window.location.pathname + "/../../shutdown",
|
url: getPath() + "/shutdown",
|
||||||
data: {"parameter":0},
|
data: JSON.stringify({"parameter":0}),
|
||||||
success: function success() {
|
success: function success() {
|
||||||
$("#spinner").show();
|
$("#spinner").show();
|
||||||
setTimeout(restartTimer, 3000);
|
setTimeout(restartTimer, 3000);
|
||||||
@ -387,9 +418,11 @@ $(function() {
|
|||||||
});
|
});
|
||||||
$("#shutdown").click(function() {
|
$("#shutdown").click(function() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
|
method:"post",
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: window.location.pathname + "/../../shutdown",
|
url: getPath() + "/shutdown",
|
||||||
data: {"parameter":1},
|
data: JSON.stringify({"parameter":1}),
|
||||||
success: function success(data) {
|
success: function success(data) {
|
||||||
return alert(data.text);
|
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() {
|
$("#restart_database").click(function() {
|
||||||
$("#DialogHeader").addClass("hidden");
|
$("#DialogHeader").addClass("hidden");
|
||||||
$("#DialogFinished").addClass("hidden");
|
$("#DialogFinished").addClass("hidden");
|
||||||
$("#DialogContent").html("");
|
$("#DialogContent").html("");
|
||||||
$("#spinner2").show();
|
$("#spinner2").show();
|
||||||
$.ajax({
|
$.ajax({
|
||||||
|
method:"post",
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: getPath() + "/shutdown",
|
url: getPath() + "/shutdown",
|
||||||
data: {"parameter":2},
|
data: JSON.stringify({"parameter":2}),
|
||||||
success: function success(data) {
|
success: function success(data) {
|
||||||
$("#spinner2").hide();
|
$("#spinner2").hide();
|
||||||
$("#DialogContent").html(data.text);
|
$("#DialogContent").html(data.text);
|
||||||
@ -480,6 +526,7 @@ $(function() {
|
|||||||
|
|
||||||
$("#bookDetailsModal")
|
$("#bookDetailsModal")
|
||||||
.on("show.bs.modal", function(e) {
|
.on("show.bs.modal", function(e) {
|
||||||
|
$("#flash_danger").remove();
|
||||||
var $modalBody = $(this).find(".modal-body");
|
var $modalBody = $(this).find(".modal-body");
|
||||||
|
|
||||||
// Prevent static assets from loading multiple times
|
// Prevent static assets from loading multiple times
|
||||||
@ -500,6 +547,7 @@ $(function() {
|
|||||||
|
|
||||||
$("#modal_kobo_token")
|
$("#modal_kobo_token")
|
||||||
.on("show.bs.modal", function(e) {
|
.on("show.bs.modal", function(e) {
|
||||||
|
$(e.relatedTarget).one('focus', function(e){$(this).blur();});
|
||||||
var $modalBody = $(this).find(".modal-body");
|
var $modalBody = $(this).find(".modal-body");
|
||||||
|
|
||||||
// Prevent static assets from loading multiple times
|
// Prevent static assets from loading multiple times
|
||||||
@ -527,7 +575,7 @@ $(function() {
|
|||||||
$(this).data('value'),
|
$(this).data('value'),
|
||||||
function (value) {
|
function (value) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "get",
|
method: "post",
|
||||||
url: getPath() + "/kobo_auth/deleteauthtoken/" + value,
|
url: getPath() + "/kobo_auth/deleteauthtoken/" + value,
|
||||||
});
|
});
|
||||||
$("#config_delete_kobo_token").hide();
|
$("#config_delete_kobo_token").hide();
|
||||||
@ -574,7 +622,7 @@ $(function() {
|
|||||||
function(value){
|
function(value){
|
||||||
path = getPath() + "/ajax/fullsync"
|
path = getPath() + "/ajax/fullsync"
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method:"get",
|
method:"post",
|
||||||
url: path,
|
url: path,
|
||||||
timeout: 900,
|
timeout: 900,
|
||||||
success:function(data) {
|
success:function(data) {
|
||||||
@ -679,13 +727,14 @@ $(function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#delete_shelf").click(function() {
|
$("#delete_shelf").click(function(event) {
|
||||||
confirmDialog(
|
confirmDialog(
|
||||||
$(this).attr('id'),
|
$(this).attr('id'),
|
||||||
"GeneralDeleteModal",
|
"GeneralDeleteModal",
|
||||||
$(this).data('value'),
|
$(this).data('value'),
|
||||||
function(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("");
|
$("#DialogContent").html("");
|
||||||
$("#spinner2").show();
|
$("#spinner2").show();
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method:"get",
|
method:"post",
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: getPath() + "/import_ldap_users",
|
url: getPath() + "/import_ldap_users",
|
||||||
success: function success(data) {
|
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/>.
|
* 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 */
|
/* global getPath, confirmDialog */
|
||||||
|
|
||||||
var selections = [];
|
var selections = [];
|
||||||
@ -42,20 +42,38 @@ $(function() {
|
|||||||
}, 1000);
|
}, 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",
|
$("#books-table").on("check.bs.table check-all.bs.table uncheck.bs.table uncheck-all.bs.table",
|
||||||
function (e, rowsAfter, rowsBefore) {
|
function (e, rowsAfter, rowsBefore) {
|
||||||
var rows = rowsAfter;
|
var rows = rowsAfter;
|
||||||
|
|
||||||
if (e.type === "uncheck-all") {
|
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) {
|
if (selections.length >= 2) {
|
||||||
$("#merge_books").removeClass("disabled");
|
$("#merge_books").removeClass("disabled");
|
||||||
$("#merge_books").attr("aria-disabled", false);
|
$("#merge_books").attr("aria-disabled", false);
|
||||||
@ -107,8 +125,9 @@ $(function() {
|
|||||||
url: window.location.pathname + "/../ajax/simulatemerge",
|
url: window.location.pathname + "/../ajax/simulatemerge",
|
||||||
data: JSON.stringify({"Merge_books":selections}),
|
data: JSON.stringify({"Merge_books":selections}),
|
||||||
success: function success(booTitles) {
|
success: function success(booTitles) {
|
||||||
|
$('#merge_from').empty();
|
||||||
$.each(booTitles.from, function(i, item) {
|
$.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);
|
$("#merge_to").text("- " + booTitles.to);
|
||||||
|
|
||||||
@ -531,7 +550,7 @@ $(function() {
|
|||||||
|
|
||||||
$("#user-table").on("click-cell.bs.table", function (field, value, row, $element) {
|
$("#user-table").on("click-cell.bs.table", function (field, value, row, $element) {
|
||||||
if (value === "denied_column_value") {
|
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;
|
var rows = rowsAfter;
|
||||||
|
|
||||||
if (e.type === "uncheck-all") {
|
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();
|
handle_header_buttons();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -581,6 +600,7 @@ function handle_header_buttons () {
|
|||||||
$(".header_select").removeAttr("disabled");
|
$(".header_select").removeAttr("disabled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Function for deleting domain restrictions */
|
/* Function for deleting domain restrictions */
|
||||||
function TableActions (value, row) {
|
function TableActions (value, row) {
|
||||||
return [
|
return [
|
||||||
@ -618,6 +638,19 @@ function UserActions (value, row) {
|
|||||||
].join("");
|
].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 for keeping checked rows */
|
||||||
function responseHandler(res) {
|
function responseHandler(res) {
|
||||||
$.each(res.rows, function (i, row) {
|
$.each(res.rows, function (i, row) {
|
||||||
@ -811,11 +844,13 @@ function checkboxChange(checkbox, userId, field, field_index) {
|
|||||||
|
|
||||||
function BookCheckboxChange(checkbox, userId, field) {
|
function BookCheckboxChange(checkbox, userId, field) {
|
||||||
var value = checkbox.checked ? "True" : "False";
|
var value = checkbox.checked ? "True" : "False";
|
||||||
|
var element = checkbox;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: "post",
|
method: "post",
|
||||||
url: getPath() + "/ajax/editbooks/" + field,
|
url: getPath() + "/ajax/editbooks/" + field,
|
||||||
data: {"pk": userId, "value": value},
|
data: {"pk": userId, "value": value},
|
||||||
error: function(data) {
|
error: function(data) {
|
||||||
|
element.checked = !element.checked;
|
||||||
handleListServerResponse([{type:"danger", message:data.responseText}])
|
handleListServerResponse([{type:"danger", message:data.responseText}])
|
||||||
},
|
},
|
||||||
success: handleListServerResponse
|
success: handleListServerResponse
|
||||||
|
@ -16,33 +16,35 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from glob import glob
|
from glob import glob
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from cps.services.worker import CalibreTask
|
from cps.services.worker import CalibreTask
|
||||||
from cps import db
|
from cps import db
|
||||||
from cps import logger, config
|
from cps import logger, config
|
||||||
from cps.subproc_wrapper import process_open
|
from cps.subproc_wrapper import process_open
|
||||||
from flask_babel import gettext as _
|
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.tasks.mail import TaskEmail
|
||||||
from cps import gdriveutils
|
from cps import gdriveutils
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
class TaskConvert(CalibreTask):
|
class TaskConvert(CalibreTask):
|
||||||
def __init__(self, file_path, bookid, taskMessage, settings, kindle_mail, user=None):
|
def __init__(self, file_path, book_id, task_message, settings, kindle_mail, user=None):
|
||||||
super(TaskConvert, self).__init__(taskMessage)
|
super(TaskConvert, self).__init__(task_message)
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.bookid = bookid
|
self.book_id = book_id
|
||||||
self.title = ""
|
self.title = ""
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.kindle_mail = kindle_mail
|
self.kindle_mail = kindle_mail
|
||||||
@ -53,10 +55,10 @@ class TaskConvert(CalibreTask):
|
|||||||
def run(self, worker_thread):
|
def run(self, worker_thread):
|
||||||
self.worker_thread = worker_thread
|
self.worker_thread = worker_thread
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
worker_db = db.CalibreDB(expire_on_commit=False)
|
worker_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||||
cur_book = worker_db.get_book(self.bookid)
|
cur_book = worker_db.get_book(self.book_id)
|
||||||
self.title = cur_book.title
|
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,
|
df = gdriveutils.getFileFromEbooksFolder(cur_book.path,
|
||||||
data.name + "." + self.settings['old_book_format'].lower())
|
data.name + "." + self.settings['old_book_format'].lower())
|
||||||
if df:
|
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
|
# 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
|
# todo: figure out how to incorporate this into the progress
|
||||||
try:
|
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'],
|
worker_thread.add(self.user, TaskEmail(self.settings['subject'],
|
||||||
self.results["path"],
|
self.results["path"],
|
||||||
filename,
|
filename,
|
||||||
@ -102,9 +104,9 @@ class TaskConvert(CalibreTask):
|
|||||||
|
|
||||||
def _convert_ebook_format(self):
|
def _convert_ebook_format(self):
|
||||||
error_message = None
|
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
|
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_old_ext = u'.' + self.settings['old_book_format'].lower()
|
||||||
format_new_ext = u'.' + self.settings['new_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
|
# if it does - mark the conversion task as complete and return a success
|
||||||
# this will allow send to kindle workflow to continue to work
|
# this will allow send to kindle workflow to continue to work
|
||||||
if os.path.isfile(file_path + format_new_ext) or\
|
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)
|
log.info("Book id %d already converted to %s", book_id, format_new_ext)
|
||||||
cur_book = local_db.get_book(book_id)
|
cur_book = local_db.get_book(book_id)
|
||||||
self.title = cur_book.title
|
self.title = cur_book.title
|
||||||
self.results['path'] = file_path
|
self.results['path'] = cur_book.path
|
||||||
self.results['title'] = self.title
|
self.results['title'] = self.title
|
||||||
self._handleSuccess()
|
new_format = local_db.session.query(db.Data).filter(db.Data.book == book_id)\
|
||||||
local_db.session.close()
|
.filter(db.Data.format == self.settings['new_book_format'].upper()).one_or_none()
|
||||||
return os.path.basename(file_path + format_new_ext)
|
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:
|
else:
|
||||||
log.info("Book id %d - target format of %s does not exist. Moving forward with convert.",
|
log.info("Book id %d - target format of %s does not exist. Moving forward with convert.",
|
||||||
book_id,
|
book_id,
|
||||||
@ -133,26 +150,32 @@ class TaskConvert(CalibreTask):
|
|||||||
else:
|
else:
|
||||||
# check if calibre converter-executable is existing
|
# check if calibre converter-executable is existing
|
||||||
if not os.path.exists(config.config_converterpath):
|
if not os.path.exists(config.config_converterpath):
|
||||||
# ToDo Text is not translated
|
self._handleError(N_(u"Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
||||||
self._handleError(_(u"Calibre ebook-convert %(tool)s not found", tool=config.config_converterpath))
|
|
||||||
return
|
return
|
||||||
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext)
|
check, error_message = self._convert_calibre(file_path, format_old_ext, format_new_ext)
|
||||||
|
|
||||||
if check == 0:
|
if check == 0:
|
||||||
cur_book = local_db.get_book(book_id)
|
cur_book = local_db.get_book(book_id)
|
||||||
if os.path.isfile(file_path + format_new_ext):
|
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_format=self.settings['new_book_format'].upper(),
|
||||||
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
book=book_id, uncompressed_size=os.path.getsize(file_path + format_new_ext))
|
||||||
try:
|
try:
|
||||||
local_db.session.merge(new_format)
|
local_db.session.merge(new_format)
|
||||||
local_db.session.commit()
|
local_db.session.commit()
|
||||||
except SQLAlchemyError as e:
|
if self.settings['new_book_format'].upper() in ['KEPUB', 'EPUB', 'EPUB3']:
|
||||||
local_db.session.rollback()
|
ub_session = init_db_thread()
|
||||||
log.error("Database error: %s", e)
|
remove_synced_book(book_id, True, ub_session)
|
||||||
local_db.session.close()
|
ub_session.close()
|
||||||
self._handleError(error_message)
|
except SQLAlchemyError as e:
|
||||||
return
|
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.results['path'] = cur_book.path
|
||||||
self.title = cur_book.title
|
self.title = cur_book.title
|
||||||
self.results['title'] = self.title
|
self.results['title'] = self.title
|
||||||
@ -160,11 +183,11 @@ class TaskConvert(CalibreTask):
|
|||||||
self._handleSuccess()
|
self._handleSuccess()
|
||||||
return os.path.basename(file_path + format_new_ext)
|
return os.path.basename(file_path + format_new_ext)
|
||||||
else:
|
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()
|
local_db.session.close()
|
||||||
log.info("ebook converter failed with error while converting book")
|
log.info("ebook converter failed with error while converting book")
|
||||||
if not error_message:
|
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)
|
self._handleError(error_message)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -174,7 +197,7 @@ class TaskConvert(CalibreTask):
|
|||||||
try:
|
try:
|
||||||
p = process_open(command, quotes)
|
p = process_open(command, quotes)
|
||||||
except OSError as e:
|
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
|
self.progress = 0.01
|
||||||
while True:
|
while True:
|
||||||
nextline = p.stdout.readlines()
|
nextline = p.stdout.readlines()
|
||||||
@ -195,7 +218,7 @@ class TaskConvert(CalibreTask):
|
|||||||
copyfile(converted_file[0], (file_path + format_new_ext))
|
copyfile(converted_file[0], (file_path + format_new_ext))
|
||||||
os.unlink(converted_file[0])
|
os.unlink(converted_file[0])
|
||||||
else:
|
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))
|
folder=os.path.dirname(file_path))
|
||||||
return check, None
|
return check, None
|
||||||
|
|
||||||
@ -219,7 +242,7 @@ class TaskConvert(CalibreTask):
|
|||||||
|
|
||||||
p = process_open(command, quotes, newlines=False)
|
p = process_open(command, quotes, newlines=False)
|
||||||
except OSError as e:
|
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:
|
while p.poll() is None:
|
||||||
nextline = p.stdout.readline()
|
nextline = p.stdout.readline()
|
||||||
@ -242,12 +265,16 @@ class TaskConvert(CalibreTask):
|
|||||||
ele = ele.decode('utf-8', errors="ignore").strip('\n')
|
ele = ele.decode('utf-8', errors="ignore").strip('\n')
|
||||||
log.debug(ele)
|
log.debug(ele)
|
||||||
if not ele.startswith('Traceback') and not ele.startswith(' File'):
|
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
|
return check, error_message
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return "Convert"
|
return N_("Convert")
|
||||||
|
|
||||||
def __str__(self):
|
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
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import smtplib
|
import smtplib
|
||||||
import threading
|
import threading
|
||||||
import socket
|
import socket
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
try:
|
from io import StringIO
|
||||||
from StringIO import StringIO
|
from email.message import EmailMessage
|
||||||
from email.MIMEBase import MIMEBase
|
from email.utils import formatdate, parseaddr
|
||||||
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 email.generator import Generator
|
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.worker import CalibreTask
|
||||||
from cps.services import gmail
|
from cps.services import gmail
|
||||||
from cps import logger, config
|
from cps import logger, config
|
||||||
|
|
||||||
from cps import gdriveutils
|
from cps import gdriveutils
|
||||||
|
import uuid
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
@ -119,31 +109,48 @@ class EmailSSL(EmailBase, smtplib.SMTP_SSL):
|
|||||||
|
|
||||||
|
|
||||||
class TaskEmail(CalibreTask):
|
class TaskEmail(CalibreTask):
|
||||||
def __init__(self, subject, filepath, attachment, settings, recipient, taskMessage, text, internal=False):
|
def __init__(self, subject, filepath, attachment, settings, recipient, task_message, text, internal=False):
|
||||||
super(TaskEmail, self).__init__(taskMessage)
|
super(TaskEmail, self).__init__(task_message)
|
||||||
self.subject = subject
|
self.subject = subject
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.filepath = filepath
|
self.filepath = filepath
|
||||||
self.recipent = recipient
|
self.recipient = recipient
|
||||||
self.text = text
|
self.text = text
|
||||||
self.asyncSMTP = None
|
self.asyncSMTP = None
|
||||||
self.results = dict()
|
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):
|
def prepare_message(self):
|
||||||
message = MIMEMultipart()
|
message = EmailMessage()
|
||||||
message['to'] = self.recipent
|
# message = MIMEMultipart()
|
||||||
message['from'] = self.settings["mail_from"]
|
message['From'] = self.settings["mail_from"]
|
||||||
message['subject'] = self.subject
|
message['To'] = self.recipient
|
||||||
message['Message-Id'] = make_msgid('calibre-web')
|
message['Subject'] = self.subject
|
||||||
message['Date'] = formatdate(localtime=True)
|
message['Date'] = formatdate(localtime=True)
|
||||||
text = self.text
|
message['Message-Id'] = "{}@{}".format(uuid.uuid4(), self.get_msgid_domain()) # f"<{uuid.uuid4()}@{get_msgid_domain(from_)}>" # make_msgid('calibre-web')
|
||||||
msg = MIMEText(text.encode('UTF-8'), 'plain', 'UTF-8')
|
message.set_content(self.text.encode('UTF-8'), "text", "plain")
|
||||||
message.attach(msg)
|
|
||||||
if self.attachment:
|
if self.attachment:
|
||||||
result = self._get_attachment(self.filepath, self.attachment)
|
data = self._get_attachment(self.filepath, self.attachment)
|
||||||
if result:
|
if data:
|
||||||
message.attach(result)
|
# 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:
|
else:
|
||||||
self._handleError(u"Attachment not found")
|
self._handleError(u"Attachment not found")
|
||||||
return
|
return
|
||||||
@ -158,10 +165,10 @@ class TaskEmail(CalibreTask):
|
|||||||
else:
|
else:
|
||||||
self.send_gmail_email(msg)
|
self.send_gmail_email(msg)
|
||||||
except MemoryError as e:
|
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)))
|
self._handleError(u'MemoryError sending e-mail: {}'.format(str(e)))
|
||||||
except (smtplib.SMTPException, smtplib.SMTPAuthenticationError) as 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"):
|
if hasattr(e, "smtp_error"):
|
||||||
text = e.smtp_error.decode('utf-8').replace("\n", '. ')
|
text = e.smtp_error.decode('utf-8').replace("\n", '. ')
|
||||||
elif hasattr(e, "message"):
|
elif hasattr(e, "message"):
|
||||||
@ -171,11 +178,11 @@ class TaskEmail(CalibreTask):
|
|||||||
else:
|
else:
|
||||||
text = ''
|
text = ''
|
||||||
self._handleError(u'Smtplib Error sending e-mail: {}'.format(text))
|
self._handleError(u'Smtplib Error sending e-mail: {}'.format(text))
|
||||||
except socket.error as e:
|
except (socket.error) as e:
|
||||||
log.debug_or_exception(e)
|
log.error_or_exception(e, stacklevel=3)
|
||||||
self._handleError(u'Socket Error sending e-mail: {}'.format(e.strerror))
|
self._handleError(u'Socket Error sending e-mail: {}'.format(e.strerror))
|
||||||
except Exception as ex:
|
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))
|
self._handleError(u'Error sending e-mail: {}'.format(ex))
|
||||||
|
|
||||||
def send_standard_email(self, msg):
|
def send_standard_email(self, msg):
|
||||||
@ -203,7 +210,7 @@ class TaskEmail(CalibreTask):
|
|||||||
gen = Generator(fp, mangle_from_=False)
|
gen = Generator(fp, mangle_from_=False)
|
||||||
gen.flatten(msg)
|
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.asyncSMTP.quit()
|
||||||
self._handleSuccess()
|
self._handleSuccess()
|
||||||
log.debug("E-mail send successfully")
|
log.debug("E-mail send successfully")
|
||||||
@ -226,15 +233,15 @@ class TaskEmail(CalibreTask):
|
|||||||
self._progress = x
|
self._progress = x
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_attachment(cls, bookpath, filename):
|
def _get_attachment(cls, book_path, filename):
|
||||||
"""Get file as MIMEBase message"""
|
"""Get file as MIMEBase message"""
|
||||||
calibre_path = config.config_calibre_dir
|
calibre_path = config.config_calibre_dir
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
df = gdriveutils.getFileFromEbooksFolder(bookpath, filename)
|
df = gdriveutils.getFileFromEbooksFolder(book_path, filename)
|
||||||
if df:
|
if df:
|
||||||
datafile = os.path.join(calibre_path, bookpath, filename)
|
datafile = os.path.join(calibre_path, book_path, filename)
|
||||||
if not os.path.exists(os.path.join(calibre_path, bookpath)):
|
if not os.path.exists(os.path.join(calibre_path, book_path)):
|
||||||
os.makedirs(os.path.join(calibre_path, bookpath))
|
os.makedirs(os.path.join(calibre_path, book_path))
|
||||||
df.GetContentFile(datafile)
|
df.GetContentFile(datafile)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
@ -244,27 +251,22 @@ class TaskEmail(CalibreTask):
|
|||||||
os.remove(datafile)
|
os.remove(datafile)
|
||||||
else:
|
else:
|
||||||
try:
|
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()
|
data = file_.read()
|
||||||
file_.close()
|
file_.close()
|
||||||
except IOError as e:
|
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?')
|
log.error(u'The requested file could not be read. Maybe wrong permissions?')
|
||||||
return None
|
return None
|
||||||
# Set mimetype
|
return data
|
||||||
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
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return "E-mail"
|
return N_("E-mail")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cancellable(self):
|
||||||
|
return False
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "E-mail {}, {}".format(self.name, self.subject)
|
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/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from cps.services.worker import CalibreTask, STAT_FINISH_SUCCESS
|
from cps.services.worker import CalibreTask, STAT_FINISH_SUCCESS
|
||||||
|
|
||||||
class TaskUpload(CalibreTask):
|
class TaskUpload(CalibreTask):
|
||||||
def __init__(self, taskMessage):
|
def __init__(self, task_message, book_title):
|
||||||
super(TaskUpload, self).__init__(taskMessage)
|
super(TaskUpload, self).__init__(task_message)
|
||||||
self.start_time = self.end_time = datetime.now()
|
self.start_time = self.end_time = datetime.now()
|
||||||
self.stat = STAT_FINISH_SUCCESS
|
self.stat = STAT_FINISH_SUCCESS
|
||||||
self.progress = 1
|
self.progress = 1
|
||||||
|
self.book_title = book_title
|
||||||
|
|
||||||
def run(self, worker_thread):
|
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"""
|
"""Upload task doesn't have anything to do, it's simply a way to add information to the task list"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return "Upload"
|
return N_("Upload")
|
||||||
|
|
||||||
def __str__(self):
|
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 %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% 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>
|
<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) %}
|
{% 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>
|
<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>
|
<a class="btn btn-default" id="view_config" href="{{url_for('admin.view_configuration')}}">{{_('Edit UI Configuration')}}</a>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
</div>
|
||||||
<h2>{{_('Administration')}}</h2>
|
<a class="btn btn-default scheduledtasks" id="admin_edit_scheduled_tasks" href="{{url_for('admin.edit_scheduledtasks')}}">{{_('Edit Scheduled Tasks Settings')}}</a>
|
||||||
<a class="btn btn-default" id="debug" href="{{url_for('admin.download_debug')}}">{{_('Download Debug Package')}}</a>
|
{% if config.schedule_generate_book_covers %}
|
||||||
<a class="btn btn-default" id="logfile" href="{{url_for('admin.view_logfile')}}">{{_('View Logs')}}</a>
|
<a class="btn btn-default" id="admin_refresh_cover_cache">{{_('Refresh Thumbnail Cover Cache')}}</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="row form-group">
|
</div>
|
||||||
<div class="btn btn-default" id="restart_database" data-toggle="modal" data-target="#StatusDialog">{{_('Reconnect Calibre Database')}}</div>
|
{% endif %}
|
||||||
<div class="btn btn-default" id="admin_restart" data-toggle="modal" data-target="#RestartDialog">{{_('Restart')}}</div>
|
<div class="row form-group">
|
||||||
<div class="btn btn-default" id="admin_stop" data-toggle="modal" data-target="#ShutdownDialog">{{_('Shutdown')}}</div>
|
<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>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2>{{_('Update')}}</h2>
|
<h2>{{_('Version Information')}}</h2>
|
||||||
<table class="table table-striped" id="update_table">
|
<table class="table table-striped" id="update_table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -188,9 +225,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% if feature_support['updater'] %}
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<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="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" 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>
|
<div class="btn btn-primary hidden" id="perform_update" data-toggle="modal" data-target="#StatusDialog">{{_('Perform Update')}}</div>
|
||||||
@ -251,3 +287,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block modal %}
|
||||||
|
{{ change_confirm_modal() }}
|
||||||
|
{% endblock %}
|
||||||
|
@ -5,11 +5,11 @@
|
|||||||
{% if author is not none %}
|
{% if author is not none %}
|
||||||
<section class="author-bio">
|
<section class="author-bio">
|
||||||
{%if author.image_url is not none %}
|
{%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 %}
|
{% endif %}
|
||||||
|
|
||||||
{%if author.about is not none %}
|
{%if author.about is not none %}
|
||||||
<p>{{author.about|safe}}</p>
|
<p>{{author.about}}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
- {{_("via")}} <a href="{{author.link}}" class="author-link" target="_blank" rel="noopener">Goodreads</a>
|
- {{_("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>
|
<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>
|
||||||
<div class="row display-flex">
|
<div class="row display-flex">
|
||||||
{% if entries[0] %}
|
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<div id="books" class="col-sm-3 col-lg-2 col-xs-6 book">
|
<div id="books" class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}">
|
<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.title|safe}}">
|
<span class="img" title="{{entry.Books.title}}">
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" />
|
{{ image.book_cover(entry.Books, alt=author.name|safe) }}
|
||||||
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a href="{{ url_for('web.show_book', book_id=entry.id) }}">
|
<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.title }}" class="title">{{entry.title|shortentitle}}</p>
|
<p title="{{ entry.Books.title }}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||||
</a>
|
</a>
|
||||||
<p class="author">
|
<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 loop.index > g.config_authors_max and g.config_authors_max != 0 %}
|
||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span class="author-hidden-divider">&</span>
|
<span class="author-hidden-divider">&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if loop.last %}
|
||||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -60,26 +59,26 @@
|
|||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span>&</span>
|
<span>&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for format in entry.data %}
|
{% for format in entry.Books.data %}
|
||||||
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
|
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
|
||||||
<span class="glyphicon glyphicon-music"></span>
|
<span class="glyphicon glyphicon-music"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
{% if entry.series.__len__() > 0 %}
|
{% if entry.Books.series.__len__() > 0 %}
|
||||||
<p class="series">
|
<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.Books.series[0].id )}}">
|
||||||
{{entry.series[0].name}}
|
{{entry.Books.series[0].name}}
|
||||||
</a>
|
</a>
|
||||||
({{entry.series_index|formatseriesindex}})
|
({{entry.Books.series_index|formatseriesindex}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.ratings.__len__() > 0 %}
|
{% if entry.Books.ratings.__len__() > 0 %}
|
||||||
<div class="rating">
|
<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>
|
<span class="glyphicon glyphicon-star good"></span>
|
||||||
{% if loop.last and loop.index < 5 %}
|
{% if loop.last and loop.index < 5 %}
|
||||||
{% for numer in range(5 - loop.index) %}
|
{% for numer in range(5 - loop.index) %}
|
||||||
@ -92,13 +91,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if other_books and author is not none %}
|
{% if other_books and author is not none %}
|
||||||
<div class="discover">
|
<div class="discover">
|
||||||
<h3>{{_("More by")}} {{ author.name.replace('|',',')|safe }}</h3>
|
<h3>{{_("More by")}} {{ author.name.replace('|',',') }}</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for entry in other_books %}
|
{% for entry in other_books %}
|
||||||
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||||
@ -123,7 +121,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{% if entry.series.__len__() > 0 %}
|
{% if entry.series.__len__() > 0 %}
|
||||||
<p class="series">
|
<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}}
|
{{entry.series[0].name}}
|
||||||
</a>
|
</a>
|
||||||
({{entry.series_index|formatseriesindex}})
|
({{entry.series_index|formatseriesindex}})
|
||||||
|
@ -3,7 +3,8 @@
|
|||||||
{% if book %}
|
{% if book %}
|
||||||
<div class="col-sm-3 col-lg-3 col-xs-12">
|
<div class="col-sm-3 col-lg-3 col-xs-12">
|
||||||
<div class="cover">
|
<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>
|
</div>
|
||||||
{% if g.user.role_delete_books() %}
|
{% if g.user.role_delete_books() %}
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@ -22,7 +23,7 @@
|
|||||||
|
|
||||||
{% if source_formats|length > 0 and conversion_formats|length > 0 %}
|
{% if source_formats|length > 0 and conversion_formats|length > 0 %}
|
||||||
<div class="text-center more-stuff"><h4>{{_('Convert book format:')}}</h4>
|
<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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="text-left">
|
<div class="text-left">
|
||||||
@ -48,7 +49,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="col-sm-9 col-xs-12">
|
<div class="col-sm-9 col-xs-12">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -226,7 +227,7 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
<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">
|
<form class="padded-bottom" id="meta-search">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label class="sr-only" for="keyword">{{_('Keyword')}}</label>
|
<label class="sr-only" for="keyword">{{_('Keyword')}}</label>
|
||||||
@ -247,7 +248,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -265,17 +266,17 @@
|
|||||||
>
|
>
|
||||||
<div class="media-body">
|
<div class="media-body">
|
||||||
<h4 class="media-heading">
|
<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>
|
</h4>
|
||||||
<p>{{_('Author')}}:<%= authors.join(" & ") %></p>
|
<p class="meta_author">{{_('Author')}}:<%= authors.join(" & ") %></p>
|
||||||
<% if (publisher) { %>
|
<% if (publisher) { %>
|
||||||
<p>{{_('Publisher')}}:<%= publisher %></p>
|
<p class="meta_publisher">{{_('Publisher')}}:<%= publisher %></p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% if (description) { %>
|
<% if (description) { %>
|
||||||
<p>{{_('Description')}}: <%= description %></p>
|
<p class="meta_description">{{_('Description')}}: <%= description %></p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<p>{{_('Source')}}:
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
<span class="title">{{entry.title|shortentitle}}</span>
|
||||||
</a>
|
</a>
|
@ -6,7 +6,7 @@
|
|||||||
data-escape="true"
|
data-escape="true"
|
||||||
{% if g.user.role_edit() %}
|
{% if g.user.role_edit() %}
|
||||||
data-editable-type="text"
|
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-editable-title="{{ edit_text }}"
|
||||||
data-edit="true"
|
data-edit="true"
|
||||||
{% if validate %}data-edit-validate="{{ _('This Field is Required') }}" {% endif %}
|
{% 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('authors', _('Enter Authors'),_('Authors'), true, true) }}
|
||||||
{{ text_table_row('tags', _('Enter Categories'),_('Categories'), false, true) }}
|
{{ text_table_row('tags', _('Enter Categories'),_('Categories'), false, true) }}
|
||||||
{{ text_table_row('series', _('Enter Series'),_('Series'), 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) }}
|
{{ 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-->
|
<!--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) }}
|
{{ 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) %}
|
{% if g.user.check_visibility(32768) %}
|
||||||
{{ book_checkbox_row('is_archived', _('Archiv Status'), false)}}
|
{{ book_checkbox_row('is_archived', _('Archiv Status'), false)}}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ book_checkbox_row('read_status', _('Read Status'), false)}}
|
{{ book_checkbox_row('read_status', _('Read Status'), false)}}
|
||||||
{% for c in cc %}
|
{% for c in cc %}
|
||||||
{% if c.datatype == "int" %}
|
{% 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" %}
|
{% 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" %}
|
{% 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" %}
|
{% 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"] %}
|
{% elif c.datatype in ["datetime"] %}
|
||||||
<!-- missing -->
|
<!-- missing -->
|
||||||
{% elif c.datatype == "text" %}
|
{% elif c.datatype == "text" %}
|
||||||
{{ text_table_row('custom_column_' + c.id|string, _('Enter ') + c.name, c.name, false, false) }}
|
{{ text_table_row('custom_column_' + c.id|string, _('Enter ') + c.name, c.name, false, false) }}
|
||||||
{% elif c.datatype == "comments" %}
|
{% 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" %}
|
{% elif c.datatype == "bool" %}
|
||||||
{{ book_checkbox_row('custom_column_' + c.id|string, c.name, false)}}
|
{{ book_checkbox_row('custom_column_' + c.id|string, c.name, false)}}
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -123,8 +123,8 @@
|
|||||||
<div class="text-left" id="merge_to"></div>
|
<div class="text-left" id="merge_to"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<input type="button" class="btn btn-danger" value="{{_('Merge')}}" name="merge_confirm" id="merge_confirm" data-dismiss="modal">
|
<input id="merge_confirm" 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>
|
<button id="merge_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}>
|
<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>
|
<label for="Show_detail_random">{{_('Show Random Books in Detail View')}}</label>
|
||||||
</div>
|
</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_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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,7 +4,8 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3 col-lg-3 col-xs-5">
|
<div class="col-sm-3 col-lg-3 col-xs-5">
|
||||||
<div class="cover">
|
<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>
|
</div>
|
||||||
<div class="col-sm-9 col-lg-9 book-meta">
|
<div class="col-sm-9 col-lg-9 book-meta">
|
||||||
@ -38,7 +39,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if g.user.kindle_mail and entry.kindle_list %}
|
{% if g.user.kindle_mail and entry.kindle_list %}
|
||||||
{% if entry.kindle_list.__len__() == 1 %}
|
{% 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 %}
|
{% else %}
|
||||||
<div class="btn-group" role="group">
|
<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">
|
<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>
|
</button>
|
||||||
<ul class="dropdown-menu" aria-labelledby="send-to-kindle">
|
<ul class="dropdown-menu" aria-labelledby="send-to-kindle">
|
||||||
{% for format in entry.kindle_list %}
|
{% 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%}
|
{%endfor%}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -70,9 +71,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<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">
|
<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="glyphicon glyphicon-music"></span> {{_('Listen in Browser')}}
|
||||||
<span class="caret"></span>
|
<span class="caret"></span>
|
||||||
@ -85,13 +86,13 @@
|
|||||||
<ul class="dropdown-menu" aria-labelledby="listen-in-browser">
|
<ul class="dropdown-menu" aria-labelledby="listen-in-browser">
|
||||||
|
|
||||||
{% for format in entry.data %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -99,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h2 id="title">{{entry.title}}</h2>
|
<h2 id="title">{{entry.title}}</h2>
|
||||||
<p class="author">
|
<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>
|
<a href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id ) }}">{{author.name.replace('|',',')}}</a>
|
||||||
{% if not loop.last %}
|
{% if not loop.last %}
|
||||||
&
|
&
|
||||||
@ -138,7 +139,7 @@
|
|||||||
<p>
|
<p>
|
||||||
<span class="glyphicon glyphicon-link"></span>
|
<span class="glyphicon glyphicon-link"></span>
|
||||||
{% for identifier in entry.identifiers %}
|
{% 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%}
|
{%endfor%}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -260,7 +261,7 @@
|
|||||||
{% for shelf in g.shelves_access %}
|
{% for shelf in g.shelves_access %}
|
||||||
{% if not shelf.id in books_shelfs and ( not shelf.is_public or g.user.role_edit_shelfs() ) %}
|
{% if not shelf.id in books_shelfs and ( not shelf.is_public or g.user.role_edit_shelfs() ) %}
|
||||||
<li>
|
<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-remove-href="{{ url_for('shelf.remove_from_shelf', book_id=entry.id, shelf_id=shelf.id) }}"
|
||||||
data-shelf-action="add"
|
data-shelf-action="add"
|
||||||
>
|
>
|
||||||
@ -275,7 +276,7 @@
|
|||||||
{% if books_shelfs %}
|
{% if books_shelfs %}
|
||||||
{% for shelf in g.shelves_access %}
|
{% for shelf in g.shelves_access %}
|
||||||
{% if shelf.id in books_shelfs %}
|
{% 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) }}"
|
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"
|
class="btn btn-sm btn-default" role="button" data-shelf-action="remove"
|
||||||
>
|
>
|
||||||
@ -295,7 +296,7 @@
|
|||||||
{% if g.user.role_edit() %}
|
{% if g.user.role_edit() %}
|
||||||
<div class="btn-toolbar" role="toolbar">
|
<div class="btn-toolbar" role="toolbar">
|
||||||
<div class="btn-group" role="group" aria-label="Edit/Delete book">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -309,13 +310,13 @@
|
|||||||
{% block js %}
|
{% block js %}
|
||||||
<script type="text/template" id="template-shelf-add">
|
<script type="text/template" id="template-shelf-add">
|
||||||
<li>
|
<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 %>
|
<%= content %>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</script>
|
</script>
|
||||||
<script type="text/template" id="template-shelf-remove">
|
<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 %>
|
<span class="glyphicon glyphicon-remove"></span> <%= content %>
|
||||||
</a>
|
</a>
|
||||||
</script>
|
</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 %}
|
{% endif %}
|
||||||
<a href="{{ url_for('admin.admin') }}" id="email_back" class="btn btn-default">{{_('Back')}}</a>
|
<a href="{{ url_for('admin.admin') }}" id="email_back" class="btn btn-default">{{_('Back')}}</a>
|
||||||
</form>
|
</form>
|
||||||
{% if g.allow_registration %}
|
{% if g.allow_registration and not simple%}
|
||||||
<div class="col-md-10 col-lg-6">
|
<div class="col-md-10 col-lg-6">
|
||||||
<h2>{{_('Allowed Domains (Whitelist)')}}</h2>
|
<h2>{{_('Allowed Domains (Whitelist)')}}</h2>
|
||||||
<form id="domain_add_allow" action="{{ url_for('admin.add_domain',allow=1)}}" method="POST">
|
<form id="domain_add_allow" action="{{ url_for('admin.add_domain',allow=1)}}" method="POST">
|
||||||
|
@ -40,35 +40,35 @@
|
|||||||
{% if entries and entries[0] %}
|
{% if entries and entries[0] %}
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<entry>
|
<entry>
|
||||||
<title>{{entry.title}}</title>
|
<title>{{entry.Books.title}}</title>
|
||||||
<id>urn:uuid:{{entry.uuid}}</id>
|
<id>urn:uuid:{{entry.Books.uuid}}</id>
|
||||||
<updated>{{entry.atom_timestamp}}</updated>
|
<updated>{{entry.Books.atom_timestamp}}</updated>
|
||||||
{% if entry.authors.__len__() > 0 %}
|
{% if entry.Books.authors.__len__() > 0 %}
|
||||||
<author>
|
<author>
|
||||||
<name>{{entry.authors[0].name}}</name>
|
<name>{{entry.Books.authors[0].name}}</name>
|
||||||
</author>
|
</author>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.publishers.__len__() > 0 %}
|
{% if entry.Books.publishers.__len__() > 0 %}
|
||||||
<publisher>
|
<publisher>
|
||||||
<name>{{entry.publishers[0].name}}</name>
|
<name>{{entry.Books.publishers[0].name}}</name>
|
||||||
</publisher>
|
</publisher>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for lang in entry.languages %}
|
{% for lang in entry.Books.languages %}
|
||||||
<dcterms:language>{{lang.lang_code}}</dcterms:language>
|
<dcterms:language>{{lang.lang_code}}</dcterms:language>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for tag in entry.tags %}
|
{% for tag in entry.Books.tags %}
|
||||||
<category scheme="http://www.bisg.org/standards/bisac_subject/index.html"
|
<category scheme="http://www.bisg.org/standards/bisac_subject/index.html"
|
||||||
term="{{tag.name}}"
|
term="{{tag.name}}"
|
||||||
label="{{tag.name}}"/>
|
label="{{tag.name}}"/>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if entry.comments[0] %}<summary>{{entry.comments[0].text|striptags}}</summary>{% endif %}
|
{% if entry.Books.comments[0] %}<summary>{{entry.Books.comments[0].text|striptags}}</summary>{% endif %}
|
||||||
{% if entry.has_cover %}
|
{% if entry.Books.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.Books.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"/>
|
<link type="image/jpeg" href="{{url_for('opds.feed_get_cover', book_id=entry.Books.id)}}" rel="http://opds-spec.org/image/thumbnail"/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for format in entry.data %}
|
{% for format in entry.Books.data %}
|
||||||
<link rel="http://opds-spec.org/acquisition" href="{{ url_for('opds.opds_download_link', book_id=entry.id, book_format=format.format|lower)}}"
|
<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.atom_timestamp}}" type="{{format.format|lower|mimetype}}"/>
|
length="{{format.uncompressed_size}}" mtime="{{entry.Books.atom_timestamp}}" type="{{format.format|lower|mimetype}}"/>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</entry>
|
</entry>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
{% import 'image.html' as image %}
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
{% extends "fragment.html" %}
|
{% extends "fragment.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="well">
|
<div class="well">
|
||||||
<p>
|
<p>
|
||||||
{{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}</a>
|
{% 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>
|
||||||
<p>
|
|
||||||
{% if not warning %}api_endpoint={{kobo_auth_url}}{% else %}{{warning}}{% endif %}</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
{% import 'image.html' as image %}
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1 class="{{page}}">{{_(title)}}</h1>
|
<h1 class="{{page}}">{{_(title)}}</h1>
|
||||||
@ -27,7 +28,7 @@
|
|||||||
<div class="cover">
|
<div class="cover">
|
||||||
<a href="{{url_for('web.books_list', data=data, sort_param='stored', book_id=entry[0].series[0].id )}}">
|
<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}}">
|
<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 class="badge">{{entry.count}}</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</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" %}
|
{% extends "layout.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% if g.user.show_detail_random() %}
|
{% if g.user.show_detail_random() and page != "discover" %}
|
||||||
<div class="discover random-books">
|
<div class="discover random-books">
|
||||||
<h2 class="random-books">{{_('Discover (Random Books)')}}</h2>
|
<h2 class="random-books">{{_('Discover (Random Books)')}}</h2>
|
||||||
<div class="row display-flex">
|
<div class="row display-flex">
|
||||||
{% for entry in random %}
|
{% for entry in random %}
|
||||||
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books_rand">
|
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books_rand">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<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.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<span class="img" title="{{ entry.title }}">
|
<span class="img" title="{{ entry.Books.title }}">
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" />
|
{{ image.book_cover(entry.Books) }}
|
||||||
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<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.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<p title="{{entry.title}}" class="title">{{entry.title|shortentitle}}</p>
|
<p title="{{entry.Books.title}}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||||
</a>
|
</a>
|
||||||
<p class="author">
|
<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 loop.index > g.config_authors_max and g.config_authors_max != 0 %}
|
||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span class="author-hidden-divider">&</span>
|
<span class="author-hidden-divider">&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if loop.last %}
|
||||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -32,21 +33,21 @@
|
|||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span>&</span>
|
<span>&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
{% if entry.series.__len__() > 0 %}
|
{% if entry.Books.series.__len__() > 0 %}
|
||||||
<p class="series">
|
<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.Books.series[0].id )}}">
|
||||||
{{entry.series[0].name}}
|
{{entry.Books.series[0].name}}
|
||||||
</a>
|
</a>
|
||||||
({{entry.series_index|formatseriesindex}})
|
({{entry.Books.series_index|formatseriesindex}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.ratings.__len__() > 0 %}
|
{% if entry.Books.ratings.__len__() > 0 %}
|
||||||
<div class="rating">
|
<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>
|
<span class="glyphicon glyphicon-star good"></span>
|
||||||
{% if loop.last and loop.index < 5 %}
|
{% if loop.last and loop.index < 5 %}
|
||||||
{% for numer in range(5 - loop.index) %}
|
{% for numer in range(5 - loop.index) %}
|
||||||
@ -64,6 +65,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="discover load-more">
|
<div class="discover load-more">
|
||||||
<h2 class="{{title}}">{{title}}</h2>
|
<h2 class="{{title}}">{{title}}</h2>
|
||||||
|
{% if page != 'discover' %}
|
||||||
<div class="filterheader hidden-xs">
|
<div class="filterheader hidden-xs">
|
||||||
{% if page == 'hot' %}
|
{% 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>
|
<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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="row display-flex">
|
<div class="row display-flex">
|
||||||
{% if entries[0] %}
|
{% if entries[0] %}
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books">
|
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<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.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<span class="img" title="{{ entry.title }}">
|
<span class="img" title="{{ entry.Books.title }}">
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}"/>
|
{{ image.book_cover(entry.Books) }}
|
||||||
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<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.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<p title="{{ entry.title }}" class="title">{{entry.title|shortentitle}}</p>
|
<p title="{{ entry.Books.title }}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||||
</a>
|
</a>
|
||||||
<p class="author">
|
<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 loop.index > g.config_authors_max and g.config_authors_max != 0 %}
|
||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span class="author-hidden-divider">&</span>
|
<span class="author-hidden-divider">&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if loop.last %}
|
||||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -114,26 +116,30 @@
|
|||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span>&</span>
|
<span>&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for format in entry.data %}
|
{% for format in entry.Books.data %}
|
||||||
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
|
{% if format.format|lower in g.constants.EXTENSIONS_AUDIO %}
|
||||||
<span class="glyphicon glyphicon-music"></span>
|
<span class="glyphicon glyphicon-music"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%endfor%}
|
{%endfor%}
|
||||||
</p>
|
</p>
|
||||||
{% if entry.series.__len__() > 0 %}
|
{% if entry.Books.series.__len__() > 0 %}
|
||||||
<p class="series">
|
<p class="series">
|
||||||
<a href="{{url_for('web.books_list', data='series', sort_param='new', book_id=entry.series[0].id )}}">
|
{% if page != "series" %}
|
||||||
{{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>
|
</a>
|
||||||
({{entry.series_index|formatseriesindex}})
|
{% else %}
|
||||||
|
<span>{{entry.Books.series[0].name}}</span>
|
||||||
|
{% endif %}
|
||||||
|
({{entry.Books.series_index|formatseriesindex}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.ratings.__len__() > 0 %}
|
{% if entry.Books.ratings.__len__() > 0 %}
|
||||||
<div class="rating">
|
<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>
|
<span class="glyphicon glyphicon-star good"></span>
|
||||||
{% if loop.last and loop.index < 5 %}
|
{% if loop.last and loop.index < 5 %}
|
||||||
{% for numer in range(5 - loop.index) %}
|
{% 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 %}
|
{% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal, change_confirm_modal %}
|
||||||
|
{% import 'image.html' as image %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ g.user.locale }}">
|
<html lang="{{ g.user.locale }}">
|
||||||
<head>
|
<head>
|
||||||
@ -40,7 +41,7 @@
|
|||||||
<a class="navbar-brand" href="{{url_for('web.index')}}">{{instance}}</a>
|
<a class="navbar-brand" href="{{url_for('web.index')}}">{{instance}}</a>
|
||||||
</div>
|
</div>
|
||||||
{% if g.user.is_authenticated or g.allow_anonymous %}
|
{% 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">
|
<div class="form-group input-group input-group-sm">
|
||||||
<label for="query" class="sr-only">{{_('Search')}}</label>
|
<label for="query" class="sr-only">{{_('Search')}}</label>
|
||||||
<input type="text" class="form-control" id="query" name="query" placeholder="{{_('Search Library')}}" value="{{searchterm}}">
|
<input type="text" class="form-control" id="query" name="query" placeholder="{{_('Search Library')}}" value="{{searchterm}}">
|
||||||
@ -53,14 +54,14 @@
|
|||||||
<div class="navbar-collapse collapse">
|
<div class="navbar-collapse collapse">
|
||||||
{% if g.user.is_authenticated or g.allow_anonymous %}
|
{% if g.user.is_authenticated or g.allow_anonymous %}
|
||||||
<ul class="nav navbar-nav ">
|
<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>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul class="nav navbar-nav navbar-right" id="main-nav">
|
<ul class="nav navbar-nav navbar-right" id="main-nav">
|
||||||
{% if g.user.is_authenticated or g.allow_anonymous %}
|
{% if g.user.is_authenticated or g.allow_anonymous %}
|
||||||
{% if g.user.role_upload() and g.allow_upload %}
|
{% if g.user.role_upload() and g.allow_upload %}
|
||||||
<li>
|
<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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<span class="btn btn-default btn-file">{{_('Upload')}}<input id="btn-upload" name="btn-upload"
|
<span class="btn btn-default btn-file">{{_('Upload')}}<input id="btn-upload" name="btn-upload"
|
||||||
@ -69,8 +70,8 @@
|
|||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not g.user.is_anonymous %}
|
{% if not g.user.is_anonymous and not simple%}
|
||||||
<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>
|
<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 %}
|
{% endif %}
|
||||||
{% if g.user.role_admin() %}
|
{% 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>
|
<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>
|
||||||
<div class="modal-body">...</div>
|
<div class="modal-body">...</div>
|
||||||
<div class="modal-footer">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="btn-group character {% if charlist|length > 9 %}hidden-sm{% endif %}" role="group">
|
<div class="btn-group character {% if charlist|length > 9 %}hidden-sm{% endif %}" role="group">
|
||||||
{% for char in charlist%}
|
{% for char in charlist%}
|
||||||
<div class="btn btn-primary char">{{char.char}}</div>
|
<div class="btn btn-primary char">{{char[0]}}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -29,8 +29,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="second" class="col-xs-12 col-sm-6">
|
<div id="second" class="col-xs-12 col-sm-6">
|
||||||
{% endif %}
|
{% 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="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.count}}</span></div>
|
<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 %}">
|
<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 %}
|
{% if entry.name %}
|
||||||
<div class="rating">
|
<div class="rating">
|
||||||
|
@ -105,7 +105,7 @@
|
|||||||
|
|
||||||
<div class="sm2-playlist-wrapper">
|
<div class="sm2-playlist-wrapper">
|
||||||
<ul class="sm2-playlist-bd">
|
<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>
|
{% if not loop.last %} & {% endif %} {% endfor %}</b> - {{entry.title}}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -134,7 +134,7 @@ window.calibre = {
|
|||||||
filePath: "{{ url_for('static', filename='js/libs/') }}",
|
filePath: "{{ url_for('static', filename='js/libs/') }}",
|
||||||
cssPath: "{{ url_for('static', filename='css/') }}",
|
cssPath: "{{ url_for('static', filename='css/') }}",
|
||||||
bookUrl: "{{ url_for('static', filename=mp3file) }}/",
|
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 }}",
|
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
|
||||||
useBookmarks: "{{ g.user.is_authenticated | tojson }}"
|
useBookmarks: "{{ g.user.is_authenticated | tojson }}"
|
||||||
};
|
};
|
||||||
|
@ -86,7 +86,7 @@
|
|||||||
window.calibre = {
|
window.calibre = {
|
||||||
filePath: "{{ url_for('static', filename='js/libs/') }}",
|
filePath: "{{ url_for('static', filename='js/libs/') }}",
|
||||||
cssPath: "{{ url_for('static', filename='css/') }}",
|
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') }}",
|
bookUrl: "{{ url_for('web.serve_book', book_id=bookid, book_format='epub', anyname='file.epub') }}",
|
||||||
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
|
bookmark: "{{ bookmark.bookmark_key if bookmark != None }}",
|
||||||
useBookmarks: "{{ g.user.is_authenticated | tojson }}"
|
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" %}
|
{% extends "layout.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="discover">
|
<div class="discover">
|
||||||
@ -9,6 +10,7 @@
|
|||||||
{% if g.user.is_authenticated %}
|
{% if g.user.is_authenticated %}
|
||||||
{% if g.user.shelf.all() or g.shelves_access %}
|
{% if g.user.shelf.all() or g.shelves_access %}
|
||||||
<div id="shelf-actions" class="btn-toolbar" role="toolbar">
|
<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">
|
<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">
|
<button id="add-to-shelf" type="button" class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
<span class="glyphicon glyphicon-list"></span> {{_('Add to shelf')}}
|
<span class="glyphicon glyphicon-list"></span> {{_('Add to shelf')}}
|
||||||
@ -17,7 +19,7 @@
|
|||||||
<ul id="add-to-shelves" class="dropdown-menu" aria-labelledby="add-to-shelf">
|
<ul id="add-to-shelves" class="dropdown-menu" aria-labelledby="add-to-shelf">
|
||||||
{% for shelf in g.shelves_access %}
|
{% for shelf in g.shelves_access %}
|
||||||
{% if not shelf.is_public or g.user.role_edit_shelfs() %}
|
{% 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 %}
|
{% endif %}
|
||||||
{%endfor%}
|
{%endfor%}
|
||||||
</ul>
|
</ul>
|
||||||
@ -42,16 +44,16 @@
|
|||||||
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
{% if entry.Books.has_cover is defined %}
|
{% 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}}" >
|
<span class="img" title="{{entry.Books.title}}" >
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.Books.id) }}" alt="{{ entry.Books.title }}" />
|
{{ image.book_cover(entry.Books) }}
|
||||||
{% if entry.Books.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<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>
|
<p title="{{entry.Books.title}}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||||
</a>
|
</a>
|
||||||
<p class="author">
|
<p class="author">
|
||||||
@ -60,7 +62,7 @@
|
|||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span class="author-hidden-divider">&</span>
|
<span class="author-hidden-divider">&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if loop.last %}
|
||||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -68,7 +70,7 @@
|
|||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span>&</span>
|
<span>&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for format in entry.Books.data %}
|
{% for format in entry.Books.data %}
|
||||||
@ -79,7 +81,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{% if entry.Books.series.__len__() > 0 %}
|
{% if entry.Books.series.__len__() > 0 %}
|
||||||
<p class="series">
|
<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}}
|
{{entry.Books.series[0].name}}
|
||||||
</a>
|
</a>
|
||||||
({{entry.Books.series_index|formatseriesindex}})
|
({{entry.Books.series_index|formatseriesindex}})
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<h1 class="{{page}}">{{title}}</h1>
|
<h1 class="{{page}}">{{title}}</h1>
|
||||||
<div class="col-md-10 col-lg-6">
|
<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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="book_title">{{_('Book Title')}}</label>
|
<label for="book_title">{{_('Book Title')}}</label>
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
|
{% import 'image.html' as image %}
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="discover">
|
<div class="discover">
|
||||||
<h2>{{title}}</h2>
|
<h2>{{title}}</h2>
|
||||||
|
<!--form method="post"--->
|
||||||
{% if g.user.role_download() %}
|
{% 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>
|
<a id="shelf_down" href="{{ url_for('shelf.show_simpleshelf', shelf_id=shelf.id) }}" class="btn btn-primary">{{ _('Download') }} </a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if g.user.is_authenticated %}
|
{% if g.user.is_authenticated %}
|
||||||
{% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public %}
|
{% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public %}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<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>
|
<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>
|
<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__() %}
|
{% 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>
|
<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>
|
<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 %}
|
{% for entry in entries %}
|
||||||
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<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.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<span class="img" title="{{entry.title}}" >
|
<span class="img" title="{{entry.Books.title}}" >
|
||||||
<img src="{{ url_for('web.get_cover', book_id=entry.id) }}" alt="{{ entry.title }}" />
|
{{ image.book_cover(entry.Books) }}
|
||||||
{% if entry.id in read_book_ids %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
{% if entry[2] == True %}<span class="badge read glyphicon glyphicon-ok"></span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<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.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
|
||||||
<p title="{{entry.title}}" class="title">{{entry.title|shortentitle}}</p>
|
<p title="{{entry.Books.title}}" class="title">{{entry.Books.title|shortentitle}}</p>
|
||||||
</a>
|
</a>
|
||||||
<p class="author">
|
<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 loop.index > g.config_authors_max and g.config_authors_max != 0 %}
|
||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span class="author-hidden-divider">&</span>
|
<span class="author-hidden-divider">&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if loop.last %}
|
||||||
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
<a href="#" class="author-expand" data-authors-max="{{g.config_authors_max}}" data-collapse-caption="({{_('reduce')}})">(...)</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -55,21 +58,21 @@
|
|||||||
{% if not loop.first %}
|
{% if not loop.first %}
|
||||||
<span>&</span>
|
<span>&</span>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
{% if entry.series.__len__() > 0 %}
|
{% if entry.Books.series.__len__() > 0 %}
|
||||||
<p class="series">
|
<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.Books.series[0].id )}}">
|
||||||
{{entry.series[0].name}}
|
{{entry.Books.series[0].name}}
|
||||||
</a>
|
</a>
|
||||||
({{entry.series_index|formatseriesindex}})
|
({{entry.Books.series_index|formatseriesindex}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.ratings.__len__() > 0 %}
|
{% if entry.Books.ratings.__len__() > 0 %}
|
||||||
<div class="rating">
|
<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>
|
<span class="glyphicon glyphicon-star good"></span>
|
||||||
{% if loop.last and loop.index < 5 %}
|
{% if loop.last and loop.index < 5 %}
|
||||||
{% for numer in range(5 - loop.index) %}
|
{% for numer in range(5 - loop.index) %}
|
||||||
@ -84,22 +87,6 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endblock %}
|
||||||
{% block modal %}
|
{% block modal %}
|
||||||
{{ delete_confirm_modal() }}
|
{{ delete_confirm_modal() }}
|
||||||
|
@ -35,31 +35,31 @@
|
|||||||
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
<div class="col-sm-3 col-lg-2 col-xs-6 book">
|
||||||
|
|
||||||
<div class="meta">
|
<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">
|
<p class="author">
|
||||||
{% for author in entry.authors %}
|
{% for author in entry.Books.authors %}
|
||||||
<a href="{{url_for('web.books_list', data='author', sort_param='new', book_id=author.id) }}">{{author.name.replace('|',',')}}</a>
|
<a href="{{url_for('web.books_list', data='author', sort_param='stored', book_id=author.id) }}">{{author.name.replace('|',',')}}</a>
|
||||||
{% if not loop.last %}
|
{% if not loop.last %}
|
||||||
&
|
&
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
{% if entry.series.__len__() > 0 %}
|
{% if entry.Books.series.__len__() > 0 %}
|
||||||
<p class="series">
|
<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.Books.series[0].id )}}">
|
||||||
{{entry.series[0].name}}
|
{{entry.Books.series[0].name}}
|
||||||
</a>
|
</a>
|
||||||
({{entry.series_index}})
|
({{entry.Books.series_index}})
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-group" role="group" aria-label="Download, send to Kindle, reading">
|
<div class="btn-group" role="group" aria-label="Download, send to Kindle, reading">
|
||||||
{% if g.user.role_download() %}
|
{% if g.user.role_download() %}
|
||||||
{% if entry.data|length %}
|
{% if entry.Books.data|length %}
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
{% for format in entry.data %}
|
{% for format in entry.Books.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">
|
<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 }})
|
<span class="glyphicon glyphicon-download"></span>{{format.format}} ({{ format.uncompressed_size|filesizeformat }})
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% 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